Neues README im Root mit Zweck, Aufbau, Start, Prüfbefehlen, Betrieb und Projektstand (Issues #44, #43). Backend- und Frontend-README ersetzen die Framework-Vorlagen. CLAUDE.md: Ordnerstruktur und echte Arbeitsweise (main, gebündelt pushen, Rollout nur mit Freigabe). ARCHITECTURE §7/§8: Live-ConfigMap, ensure-secrets.sh, korrektes Admin-Seed-Verhalten. .env.example um BACKUP_MAX_IMPORT_BYTES und SEED_ADMIN_* ergänzt. Statuszeilen in Plänen Rückgrat, T3–T6 und Specs Fahrplan, T7. Refs #42 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
159 KiB
T6 Compliance-Autopilot „prüfungsfest“ Implementation Plan
Status (2026-10-16): Tasks 1–19 umgesetzt und gemergt (Wellen 1–6, 8a31c42 bis 8188e4c, Browser-Fixes e712c5d). Nicht ausgerollt. Issues: #16, Inhaber-Punkte nach dem Rollout #34, Rollout #28.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Bei einer Kontrolle durch Veterinäramt oder Lebensmittelüberwachung legt Imkerei Wabenhain alles Verlangte sofort vor: ein Bestandsbuch ohne „FEHLT“ (Katalog und Packungen füllen die Behandlung vor und buchen ab), Etiketten nur mit Pflichtangaben, ein Behördenpaket (Änderungshinweis, Anzeige als PDF, TSK-Zählung, Sperrbezirk) und eine Seite „Prüfbereitschaft“ mit jedem offenen Punkt in Rot und einem Knopf zum Beheben.
Architecture: Neue Module veterinary-medicines (Katalog, Packungen, Bewegungsbuch), apiary-restrictions (Sperrbezirk) und compliance (Einstellungen, Anzeigen, Handler, Prüfbereitschaft mit einer reinen Regel-Datei je Prüfpunkt). Folgen laufen über Ereignisse des Rückgrats (ColonyCreated, ColonyMoved, ApiaryChanged, TskReferenceDayReached) und Handler, die nur über tx schreiben. Die Prüfbereitschaft wird live aus den Fachdaten berechnet; gespeichert werden nur Quittungen. Die Etikett-Prüfung und das Bestandsbuch nutzen je eine gemeinsame reine Funktion für Druck, Designer und Prüfbereitschaft. Rechtswerte sind Einstellungen mit Vermerk „Annahme – vom Inhaber zu prüfen“, nie Code.
Tech Stack: NestJS 11, Prisma 7.9 (@prisma/adapter-pg), PostgreSQL (Betrieb 18.6; die Test-DB auf Port 5433 ist 17, also keine Funktion nutzen, die erst ab 18 existiert), pdfkit 0.19, Jest 30 (Unit + e2e gegen Test-DB), Next.js 16.3.1 App Router, React 19.2, TanStack Query 5, Vitest 4 + RTL. Keine neue Abhängigkeit.
Spec: docs/superpowers/specs/2026-10-13-t6-compliance-design.md (bindend, mit Inhaber-Entscheidungen vom 2026-10-13). Bausteine aus docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md (Rückgrat), docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md (T3) und docs/superpowers/plans/2026-10-13-t4-logik-ketten.md (T4, läuft vor T6).
Global Constraints
Voraussetzungen (nicht neu bauen, genau so nutzen)
- Rückgrat Tasks 1–26 außer 17 und 25 sind im Arbeitsbaum. Genutzt werden:
DomainEventsService.record/recordAll(tx, …),@OnDomainEvent(types, '<consumer>.<name>')(Handler-ID^[a-z0-9-]+\.[a-z0-9-]+$),dailyDedupeKey,utcDay,inCatchUpWindow,DAILY_EVALUATION_RULES(src/jobs/daily-evaluation.rules.ts),AuditLogService.record(entry, tx),lockRowForUpdate/lockRowsForShare(src/prisma/row-lock.ts),runBulk(src/common/bulk/run-bulk.ts, Callback(tx, entry, colony, bulkId)),lockColony/colonyTransitionEvents(src/colonies/colony-events.ts),LIVING_COLONY_WHERE/isLivingColonyStatus,loggableError,PermissionResolverService.getPermissions(userId),drainDomainEvents(test/utils/domain-events.ts),@Idempotent(),IsOptionalNotNull,optionalDate,fifoOrder(src/stock/stock-ledger.ts),PDF_MARGIN/drawTable/fillWidth/formatDay/orDash(src/export/pdf-table.ts). - T3 ist gemergt:
TreatmentsService.createBulk(überrunBulk) mit der gemeinsamen AufbereitungtreatmentRow(dto, apiaryId, actingUserId)(freie Modulfunktion intreatments.service.ts, kein Klassenmitglied; sie spreizt...dto) und der privaten MethoderecordIn(tx, data, actingUserId, bulkId?)(T6 erweitert genau diese beiden),GET /treatments/last-by-product, FrontendPrefillNote(src/components/treatments/prefill-note.tsx),NumberStepper,RecordSelect,useCaptureMutation,applyPrefill.createsperrt das Volk mitlockRowForUpdate(tx, 'Colony', …);updatekenntapiaryIdheute nicht und spreizt...dto. - T4 ist gemergt: Meldungen je Nutzer (
NotificationsService.record(input, tx?),recordForUsers(userIds, input, tx?),recordForPermission(permission | permissions[], FanOutNotificationInput, tx?);NotificationCategoryist ein Prisma-EnumTASK_DUE | DIGEST | SUGGESTION | WITHDRAWAL | SEASON | SYSTEM;assertAppLinkundnotificationDedupeKeyinsrc/notifications/notification-link.ts; die Empfängerabfrage ist die private Methoderecipients(db, keys, userIds); jede neue Zeile plant überNotificationDeliveryPlanner.planIndie Telegram-Zustellung in derselben Transaktion),LOCKABLE_TABLESals Einträge{ table, key },src/treatments/withdrawal-window.ts(withdrawalWindowOf(t)→WithdrawalWindow { treatmentId, colonyId, product, fromDay, untilDay }odernull,isInWindow(window, day):fromDay <= day < untilDay,withdrawalUntilDay),src/withdrawal/withdrawal-text.ts(lastBlockedDay,withdrawalRangeText) undWithdrawalCheckService.conflictsForHarvests(src/withdrawal/),TimeEventsStep(src/jobs/steps/time-events.step.ts) überDAILY_EVALUATION_RULES, Berliner Tage insrc/common/dates.ts(berlinToday,berlinDayOf,dayToDate,addDays,daysBetween,addMonthsClamped; Annahme 41),DbClient(src/prisma/db-client.ts),ColonySplit,Product.shelfLifeMonths(Migration20261015100200_t4_product_shelf_life, T6 baut es nicht),roundKg(exportiert aussrc/honey-batches/honey-plausibility.ts), FrontendStepUpDialog,PrefillStatus,describeSaveError/saveErrorText(src/lib/honey-batches/errors.ts),refreshOn401: false,ConfirmDialogmittriggerDescribedBy/triggerDisabled,ToggleChipmitdisabled,NEVER_CACHED_API_PATHS(public/sw-routes.js),DOMAIN_EVENT_LABELS(src/lib/system/labels.ts). - Tagesauswertung: Eine Regel ist
DailyRule<T>mitoccurrences(db: PrismaService, today: Date, ctx: { seasons, day })(keinevaluate);ctx.dayist der Berliner Tag des Laufs,today = dayToDate(ctx.day);CATCH_UP_DAYS = 7ist exportiert; neue Typen kommen in die UnionDailyEventType. - Alle Backend-Pfade relativ zu
ApiCore-Backend/, alle Frontend-Pfade relativ zuApiCore-Frontend/, sofern nicht anders angegeben.
Migrationen (Zeitstempel fest, alle nach der höchsten T4-Migration 20261015110000_t4_telegram; alles additiv)
| Zeitstempel | Name | Inhalt |
|---|---|---|
| 20261016100000 | t6_veterinary_medicines |
Enums MedicineUnit, VeterinaryMedicineMovementType; VeterinaryMedicine, VeterinaryMedicineLot, VeterinaryMedicineMovement mit CHECKs quantity > 0 (Packung), quantity <> 0 (Bewegung), (type = 'TREATMENT') = ("treatmentId" IS NOT NULL); Treatment.medicineId, medicineLotId, doseQuantity mit FKs (ON DELETE RESTRICT) und @@index([medicineLotId]); DocumentEntityType + MEDICINE_LOT; Rechte veterinary-medicines:* samt Vergabe |
| 20261016100100 | t6_label_particulars |
SiteSettings.foodBusinessName, foodBusinessAddress; Product.legalName; LabelTemplate.minXHeightMm Float @default(1.2) + CHECK 0,5–5; AuditAction + PRINT |
| 20261016100200 | t6_authority_compliance |
ComplianceSettings (Vorgabe netQuantityDigitRules = [{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]); AuthorityNotice mit Enums; ApiaryRestriction + CHECK "endsOn" IS NULL OR "endsOn" >= "startsOn"; ComplianceAcknowledgement; DocumentEntityType + AUTHORITY_NOTICE, APIARY_RESTRICTION; Rechte compliance:* samt Vergabe |
- Geprüft am 2026-10-15 (HEAD
9760ce1): Die neueste Migration ist20261015110000_t4_telegram, alle drei T6-Stempel liegen dahinter. T4 hat wederAuditAction.PRINTnoch eine der dreiDocumentEntityType-Werte noch Rechteveterinary-medicines:*/compliance:*angelegt (Product.shelfLifeMonthsgibt es seit T4, T6 ergänzt dort nurlegalName). Doppelt oder widersprüchlich ist nichts. Hängt ein weiterer Strang vor T6 eine Migration an, rücken die Stempel hinter dessen höchsten. - Erzeugt mit
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --scriptgegen die Test-DB, von Hand nachgearbeitet (Kopfkommentar wie20261012100000_calendar_sync_claims). Niedb push, niemigrate dev.ALTER TYPE … ADD VALUEläuft in der Migrationstransaktion; kein neuer Wert wird in derselben Migration benutzt. - Test-DB migrieren (Git Bash, in
ApiCore-Backend/):export DATABASE_URL="$(grep '^DATABASE_URL=' .env.test | cut -d= -f2- | tr -d '"')" && npx prisma migrate deploy && npx prisma generate. Läuft Task 1 in einer Welle mit anderen Tasks, wendet er seine Migrationen zuerst an.
Rechte (Spec 4.8; Migration vergibt, der Katalog-Sync berechtigt nur System-Rollen)
| Recht | groupDe |
ADMIN | BEEKEEPER | STAFF | READONLY | API_CLIENT |
|---|---|---|---|---|---|---|
veterinary-medicines:read |
Tierarzneimittel | ✓ | ✓ | ✓ | ✓ | – |
veterinary-medicines:create / :update |
Tierarzneimittel | ✓ | ✓ | ✓ | – | – |
compliance:read |
Prüfbereitschaft & Behörden | ✓ | ✓ | ✓ | ✓ | – |
compliance:manage |
Prüfbereitschaft & Behörden | ✓ | ✓ | – | – | – |
- Jeder neue Endpunkt trägt
@RequirePermission(Tabelle in Spec 7);test/authorization-coverage.e2e-spec.tserfasst alle Controller. Eine Behandlung aus einer Packung bucht mittreatments:createab, es gibt kein eigenes Recht. Sperren lesen mitapiaries:read, schreiben mitcompliance:manage. - Belege, Rechnungsnummern, Anschriften, Koordinaten und Aktenzeichen nur mit
compliance:readbzw.veterinary-medicines:read; nie in öffentlichen Endpunkten, Shop-Feed, Webhooks oder Ereignis-Payloads.
Fachwerte aus der Spec
- Rest einer Packung =
lot.quantity + Σ movement.quantity, gerundet auf Tausendstel (roundToThousandths,src/common/quantity.ts;roundKginhoney-plausibility.tsundbottling.service.tsnutzen ihn). Immer berechnet, nie gespeichert. Negativer Rest ist erlaubt und wird in der Prüfbereitschaft gemeldet. Bewegungen werden nie geändert oder gelöscht, Fehler werden gegengebucht. - Behandlung (Spec 5.2): Packung plus
supplier/purchaseProof/batchNumber/prescribingVet→ 400 „Diese Angabe kommt aus der Packung“;medicineIdundproductweichen ab (ohne Groß-/Kleinschreibung) → 400 „Mittel und Katalogeintrag passen nicht zusammen“; Katalogeintrag inaktiv → Anlegen 400; Packung eines anderen Mittels → 400;doseQuantity≤ 0 → 400;medicineId: nullbei gesetzter Packung → 400. Der Server fülltactiveIngredient,withdrawalPeriodDays,method,doseQuantitynie still nach; nurproductwird zum Handelsnamen. - Etikett (Spec 5.4): Pflicht
LEGAL_NAME,NET_QUANTITY,BEST_BEFORE,LOT,ORIGIN,FOOD_BUSINESSsperren den Druck (409LABEL_PARTICULARS_MISSING,{ gaps }); WarnungenLOT_PREFIX,X_HEIGHT,NET_QUANTITY_HEIGHT,TEXT_CLIPPEDsperren nie. Probedruck?proof=trueimmer möglich, Aufdruck „MUSTER – nicht verkaufsfähig“ in 50 % Grau. Jeder Druck schreibt AuditPRINTamBottlingBatch:{ templateId, copies, proof, gapCodes, warningCodes }. Höhe in mm = Schriftgröße in pt × AFM-Metrik / 1000 × 25,4 / 72 (Helvetica x-Höhe 523, Versalhöhe 718); 6 pt → 1,11 mm Warnung, 7 pt → 1,29 mm in Ordnung; 500-g-Glas mit Regel 4 mm → Füllmenge ab 15,8 pt.minXHeightMmVorgabe 1,2, Bereich 0,5–5. - Sperrbezirk (Spec 5.6): aktiv bei
startsOn≤ Tag ≤endsOn(oderendsOnleer), Tag = UTC-Tag der DB-Uhr (Spec 5.6 sagt ausdrücklich UTC, begründet mit der Tagesauswertung, die seit T4 aber Berliner Tage nutzt, Annahme 41; bleibt UTC bis zur Entscheidung des Inhabers, siehe Aktualisierung, Frage 1; der Tag steht nur indbUtcDay, ein Wechsel ist eine Zeile). Alle anderen Tage der T6 sind Berliner Tage (berlinToday(),berlinDayOf,addDaysaussrc/common/dates.ts): Vorgabe vonreferenceDate/sentOn, Stichtag, „überfällig“,todayder Prüfbereitschaft. Sperre beimove(Quell- oder Zielstand),createim Stand, Stand-Korrektur überupdate(alter oder neuer Stand),mergeüber verschiedene Stände. Keine Sperre beisplit. Ernte nur Warnung.restrictionOverrideReason10–500 Zeichen, nur mitcompliance:manage(sonst 403), landet im Audit und inColonyApiaryHistory.notes(„Ausnahme Sperrbezirk: …“), wird nie am Volk gespeichert. Fehler: 409{ code: 'RESTRICTED_ZONE', message, restrictionIds }.apiaryIds1–50. - Behördenpaket (Spec 5.5): Bezugsstand = jüngste
SENT-Anzeige der ArtREGISTRATION/HERD_CHANGE(nachsentOn);REGISTRATIONnur ohne Bezugsstand,HERD_CHANGEnur mit („Erst die Erstanzeige erfassen“, 409);TSK_REPORTbrauchtyear; mitsentOnim Anlegen entstehtSENTgleich. Neu zählen und Löschen nur im Entwurf;openKey=REGISTRATION|HERD_CHANGE|TSK_REPORT:<Jahr>nur beiDRAFT; zweiter Entwurf → 409{ code: 'DRAFT_EXISTS', existingId }. Mutex: ZeileComplianceSettingsperlockRowForUpdate. Einstellungen:noticeOnColonyCountChange/noticeOnApiaryChangeVorgabetrue,tskReferenceDayVorgabe01-01(FormatMM-TT,02-29und1-1→ 400),tskReportDueDayleer,tskExcludedColonyTypesleer. Lebende Völker überLIVING_COLONY_WHEREan aktiven Ständen. Nachholfenster der Tagesauswertung 7 Tage. - Prüfbereitschaft (Spec 5.7): 18 Prüfpunkte in fünf Bereichen (
STAMMDATEN,BEHOERDEN,TIERARZNEIMITTEL,ETIKETTEN,ERNTE), SchwerePFLICHT|PRUEFEN; Quittieren nur beiacknowledgeable, Notiz 10–2000 Zeichen, Fingerabdruck muss zum aktuell berechneten passen (sonst 409), Quittung zurücknehmbar; Behandlungen der letzten 6 Kalenderjahre, Ernten der letzten 2 Jahre (HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2);subjectshöchstens 50, Rest inmore. Jeder offene Punkt ist rot, Schwere steht als Wort („Pflicht“/„Prüfen“) daneben. - Meldungen: höchstens eine ungelesene je Thema und Nutzer (
relatedType = 'ComplianceItem',relatedIdherd-changebzw.tsk-report:<Jahr>), KategorieSYSTEM(T4 hat keine Behörden-Kategorie; Telegram bekommt sie nur, wenn der NutzerSYSTEMunter seinen Telegram-Kategorien gewählt hat, Vorgabe sindTASK_DUEundDIGEST; Texte unter 600 Zeichen, ohne Adresse, also im Telegram-Format; der Link steht inlink, nicht im Text). Texte exakt: „Bestand geändert: jetzt {n} Völker an {m} Ständen. Angezeigt: {a} an {b}. Anzeige ans Veterinäramt prüfen.“ (PrioritätHINWEIS), „TSK-Stichtag {TT.MM.JJJJ}: {n} Völker gezählt. Bitte prüfen und melden.“ (WARNUNG). - Eingaben:
MaxLengthfür jeden Freitext (Namen 200, Anschriften 300, Begründungen 500, Notizen 2000); MengenIsPositiveund endlich; IDs im cuid-Zeichensatz (/^[A-Za-z0-9_-]+$/). - Keine Arzneimittel- oder Rechtsdaten im Code oder Seed: Der Katalog startet leer; Wächter
seed-no-medicines.spec.ts.
Projektregeln
- Sicherheit hat Priorität 1. Konsistenz ist oberstes Gesetz: ein Muster, gemeinsame Helfer, keine Kopien. Pflicht-
txals erster Parameter, optionalertxals letzter. - Datensatz, Audit und Ereignis in einer Transaktion. Handler sind reine DB-Schreiber (nur
tx, keine Netzaufrufe,handler-contract.spec.tsdecktcompliance.handlers.tsab). Zeitvergleiche in SQL gegennow(). - Read-modify-write nur unter Zeilensperre:
beforewird innerhalb der Transaktion nachlockRowForUpdate(tx, table, id)gelesen. Alle neuen Tabellen kommen inLOCKABLE_TABLES– in einem Task (Task 1). Sperr-Reihenfolge = Reihenfolge der Map. Offene Entscheidung des Inhabers, hier umgesetzt:Apiarysperrt vorColony. Task 1 ziehtApiaryan die erste Stelle der Map (heute steht es hinterNotificationPreference); die übrigen T6-Tabellen stehen hinter den bisherigen (Apiary→Colony→ … →Treatment→ … →NotificationDelivery→LabelTemplate→SiteSettings→VeterinaryMedicine→VeterinaryMedicineLot→ComplianceSettings→AuthorityNotice→ApiaryRestriction→ComplianceAcknowledgement;Productsteht schon in der Map). Die Völker-Pfade, die Stände und Völker zusammen sperren, nehmen zuerst die Stände (lockRowsForShare(tx, 'Apiary', ids), aufsteigend), dann das Volk (Task 8).ApiaryRestrictionsServicesperrt die betroffenen Stände mitlockRowsForUpdate, damit eine Sperre und ein laufendes Umsetzen sich hintereinander einreihen. Bestehende Services, die T6 anfasst undbeforeaußerhalb lesen (SiteSettingsService.update,ProductsService.update,LabelTemplatesService.update,ApiariesService.*), stellt der jeweilige Task auf die Sperre um (LabelTemplatesService.updateliest schon in der Transaktion; prüfen, obbeforenach der Sperre kommt). - Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20). Neue oder geänderte Update-DTOs stehen in einem Null-Vertrags-Spec (Task 1 liefert dafür
describeNullContracts; jede T6-Task legt ihre Verträge in eine eigene Spec-Datei, damit parallele Tasks nicht dieselbe Datei ändern). - Worker und Jobs nutzen den resolver-only
JobPermissionsModule, nichtPermissionsModule: Kein Provider eines Moduls, dasWorkerModuleimportiert, darfPermissionResolverServiceinjizieren (die Prüfung vonrestrictionOverrideReasonliegt darum inColoniesService). Jedes Modul mit Handlern wird vonAppModuleundWorkerModuleimportiert. - UI deutsch, kurze Sätze, ein Hauptknopf je Ansicht (
DetailHeader), seltene Felder eingeklappt (FormSection), Felder 44 px; Zustand nie nur über Farbe (Symbol + Wort); roter Text mit ≥ 4,5:1 (Tokendestructive); WCAG 2.2 AA;aria-live="polite"für Live-Listen; ein gesperrter Druckknopf bleibt fokussierbar (aria-disabled+aria-describedby); nach einem 409 springt der Fokus auf die Lückenliste. Code-Bezeichner, Log- und Fehlertexte (außer Nutzertexten) englisch. - Fehler und Logs nur über
loggableError(), nie Payload oder Freitext. PDFs mitCache-Control: no-store, Dateinamen ohne Nutzereingaben (anzeige-<id>.pdf,pruefbericht.pdf). - TDD: zuerst der fehlschlagende Test; Testnamen englisch. Agents committen nicht. Jeder Task endet mit „Verifikation“, „kein Commit“ und einer Dateiliste an den Orchestrator.
- Backend-Checks:
npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci(nienpm run lint, das Skript hat--fix); einzelne e2e-Dateinpx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand; alle e2enpm run test:e2e -- --runInBand(die Konfiguration hatmaxWorkers: 4gegen eine Test-DB,testTimeout20 s je Test: Tests mit PDF, Sammelerfassung oderdrainDomainEventsschlank halten). Frontend: vor Next.js-Codenode_modules/next/dist/docs/lesen (AGENTS.md); Checksnpm run lint && npm run typecheck && npx vitest run <pfade>(npm run test=vitest run). - Die Test-DB ist nicht leer (Reste früherer Läufe, Völker, Stände, Nutzer mit Rechten). E2E-Tests legen ihre Daten selbst an, erwarten nur das, was sie selbst erzeugt haben (Zahlen als Differenz vor/nach, Prüfpunkte über
key+subjectId, Meldungen für die eigenen Testnutzer), und räumen T6-eigene Tabellen (ComplianceSettings,AuthorityNotice,ApiaryRestriction,ComplianceAcknowledgement,VeterinaryMedicine*) inbeforeEachperdeleteManyauf. Sätze wie „ein leeres System“ oder „READY“ gelten für den eigenen Ausschnitt (die eigenen Stände/Völker/Vorlagen), nie für die ganze Datenbank; wo der Gesamtstatus gebraucht wird, prüft der Test die Regeln einzeln überREADINESS_RULESmit einem selbst gebautenReadinessContext. ARCHITECTURE.mdundTASKS.mdträgt der Orchestrator ein; jeder Task liefert seinen Doku-Text im Bericht („Docs“). Neue Annahmen bekommen die nächste freie Nummer zum Zeitpunkt des Eintrags.
Review Focus
- Rechte-Leck von Belegen, Anschriften und Sperrdaten. Der Dokument-Endpunkt prüft nur
documents:*; ohne Gegenmaßnahme sähe jeder mitdocuments:readErwerbsbelege, Anzeigen-Kopien und Verfügungen. Dazu Prüfpunkte mit Arzneimittelnamen, Ereignis-Payloads, Webhooks, Herkunftsseite.- Task 4 e2e
a user with documents:read but without veterinary-medicines:read gets 403 listing and downloading a MEDICINE_LOT document - Task 13 unit
hides subject labels of the medicine area without veterinary-medicines:read and keeps the count - Task 7 unit
none of the four new events is forwarded to webhooks and payloads carry only ids - Task 5 e2e
the public origin page exposes neither foodBusinessAddress nor legalName; Task 8 e2eGET /public routes never contain restriction data
- Task 4 e2e
- Bestand und Bestandsbuch falsch. Doppelte oder fehlende Buchung bei Bearbeiten, Packungswechsel, Sammelerfassung, Wiederholung; gleichzeitige Inventuren; Lücken-Helfer und PDF widersprechen sich.
- Task 3 e2e
editing dose and lot books only the delta and the movements sum to the new dose - Task 3 e2e
a repeated bulk request with the same Idempotency-Key books once - Task 2 e2e
two parallel inventories on one lot: the second computes with the new rest - Task 3 unit
prints FEHLT exactly where treatmentLogGaps reports a gap
- Task 3 e2e
- Etikett-Prüfung lässt Lücken durch oder sperrt Vollständiges. Abgeschnittener Text, Zeilenumbrüche und Trennzeichen in der Anschrift, fest getippte „500 g“ auf 250 g, Dezimalkomma, fehlendes Produkt.
- Task 5 unit
treats clipped text as missing,matches the address across line breaks and separators,flags a static "500 g" on a 250 g bottling - Task 6 e2e
409 with the gap list, then 200 and an audit PRINT after the master data is complete
- Task 5 unit
- Sperrbezirk umgehbar oder zu streng. Jeder Pfad, der ein Volk zwischen Ständen bringt;
restrictionOverrideReasondarf nicht in die Datenbank-Daten gelangen; Ableger und Ernte bleiben frei.- Task 8 e2e
update with a changed apiary, move, merge across apiaries and create each answer 409 RESTRICTED_ZONE - Task 8 unit
the override reason is never part of the data written to the colony - Task 8 e2e
split inside the apiary and a harvest at a restricted apiary succeed
- Task 8 e2e
- Doppelte Entwürfe und Meldungen bei Wiederholung, parallelen Handlern, Nachholen und späterem Wechsel der Einstellung.
- Task 9 e2e
two parallel creates of the same draft: one 201, one 409 with existingId - Task 11 e2e
a second colony within one unread notice adds no second notification - Task 11 e2e
daily evaluation twice on the reference day: one event, one draft - Task 12 unit
a revoked or stale acknowledgement reopens the item
- Task 9 e2e
Abhängigkeiten und Parallelisierung
| Welle | Tasks (parallel, verschiedene Dateien) | wartet auf |
|---|---|---|
| 1 | 1 | Rückgrat, T3, T4 |
| 2 | 2, 4, 5, 7 | 1 |
| 3 | 3, 6, 8, 9 | 3 → 2; 6 → 5; 8 → 1, 7; 9 → 1 |
| 4 | 10, 11, 12 | 10 → 9; 11 → 7, 9; 12 → 8, 9 |
| 5 | 13, 14, 15, 17 | 13 → 3, 6, 12; 14 → 12; 15 → 2, 4; 17 → 5, 6 |
| 6 | 16, 18, 19 | 16 → 3, 15; 18 → 10, 11, 14; 19 → 8, 14 |
| — | Abschluss und Rollout | 1–19 |
Geteilte Dateien, deshalb je Welle nur ein Task:
prisma/schema.prisma,src/backup/backup.service.ts(+ Spec),src/prisma/row-lock.ts(+ Spec),src/auth/permissions/permission-catalog.ts: nur Task 1.src/app.module.ts: 2 (W2), 9 (W3), 12 (W4);src/worker/worker.module.ts: nur 11.src/domain-events/domain-event-types.ts,src/colonies/colonies.service.ts,src/apiaries/apiaries.service.ts: 7 (W2), dann 8 (W3, nurcolonies.service.tsund DTOs). Von T4 geteilt und hier nur gelesen:src/inspections/inspections.service.ts(ruftcolonies.updateIn; Task 8 ändert es nicht, nur den Vertrag vonupdateIn),src/autopilot/autopilot.service.ts(syncApiaryIn, Reihenfolge Stand → Volk ist schon die neue; nur Task 1 passt den Spec an),src/honey-batches/honey-plausibility.ts(roundKg): nur 2.src/notifications/notifications.service.ts,src/jobs/daily-evaluation.rules.ts(+src/jobs/steps/time-events.step.ts, falls die Union der Ereignistypen dort gespiegelt wird): nur 11;src/worker/worker.module.ts: nur 11.src/compliance/compliance.module.ts: 9 (W3), 11 (W4);authority-notices.controller.ts: 9 (W3), 10 (W4);src/compliance/readiness/readiness-context.tsundrules/index.ts: 12 (W4), 13 (W5).src/label-templates/label-templates.service.ts,label-element.types.ts: 5 (W2), 6 (W3).src/export/treatment-log.ts,src/treatments/*: nur 3.src/documents/*: nur 4.src/notifications/notifications.service.ts,src/jobs/daily-evaluation.rules.ts: nur 11.- Frontend:
src/app/(app)/nav-config.ts,src/lib/system/labels.ts(+labels.spec.ts),public/sw-routes.js+public/sw.js(SW_ROUTES_VERSION) +src/lib/offline/sw-routes.spec.ts,src/lib/compliance/errors.ts: nur 14 (18 und 19 lesen nur);src/lib/documents/types.ts(DocumentEntityType+= die drei neuen Arten): nur 15 (W5; 18 und 19 in W6 lesen nur);src/lib/compliance/types.ts14 (W5), danach nur lesen;src/app/(app)/colonies/[id]/*: 16 (W6, nurtreatment-edit-dialog.tsxundpage.tsx) und 19 (W6, nurcolony-actions.tsx,colony-edit-card.tsx) – disjunkte Dateien;src/app/(app)/storage/page.tsx: nur 15. ARCHITECTURE.md,TASKS.md,docs/pruefung-2026-09.md: nur der Orchestrator.
Tasks
Lieferung T6a – Backend
Task 1: Schema, Rechte, Sperren, Backup, Null-Vertrags-Helfer
Files
- Modify:
prisma/schema.prisma,src/auth/permissions/permission-catalog.ts,src/prisma/row-lock.ts(+row-lock.spec.ts),src/backup/backup.service.ts(+backup.service.spec.ts),src/common/validation/update-dto-null-handling.spec.ts(nur Umbau auf den Helfer) - Create:
prisma/migrations/20261016100000_t6_veterinary_medicines/migration.sql,…100100_t6_label_particulars/migration.sql,…100200_t6_authority_compliance/migration.sql,src/common/validation/null-contract.ts - Test: neu
test/t6-schema.e2e-spec.ts,test/compliance-permissions.e2e-spec.ts;test/backup.e2e-spec.ts(ergänzen) - Docs: ARCHITECTURE §8 „Backup“: neue Modelle sind Teil des versiegelten Bundles; §10 Ereigniskatalog folgt in Task 7.
Interfaces
- Consumes: T4 Task 1 (
LOCKABLE_TABLESals{ table, key }). - Produces:
// Migration 1: enum MedicineUnit { ML G PIECE }, enum VeterinaryMedicineMovementType { TREATMENT CORRECTION DISPOSAL },
// model VeterinaryMedicine / VeterinaryMedicineLot / VeterinaryMedicineMovement exakt wie Spec 4.1,
// Treatment += medicineId String? (-> VeterinaryMedicine), medicineLotId String? (-> VeterinaryMedicineLot), doseQuantity Float?,
// medicineMovements VeterinaryMedicineMovement[]; @@index([medicineLotId]); Gegenrelationen an User
// Migration 2: SiteSettings += foodBusinessName String?, foodBusinessAddress String?; Product += legalName String?;
// LabelTemplate += minXHeightMm Float @default(1.2); AuditAction += PRINT
// Migration 3: enum AuthorityNoticeKind, AuthorityNoticeStatus; model ComplianceSettings, AuthorityNotice, ApiaryRestriction,
// ComplianceAcknowledgement exakt wie Spec 4.4/4.5; Apiary += restrictions ApiaryRestriction[]
// src/common/validation/null-contract.ts
export interface NullContract { dto: new () => object; notNull: string[]; clearable: string[] }
export function describeNullContracts(title: string, contracts: Record<string, NullContract>): void; // registriert die drei Prüfungen je Vertrag (leerer Patch, null auf notNull -> 400 genau dieses Felds, null auf clearable -> ok)
// permission-catalog.ts: veterinary-medicines {read 'Ansehen', create 'Anlegen', update 'Bearbeiten'} Gruppe 'Tierarzneimittel';
// compliance {read 'Ansehen', manage 'Verwalten'} Gruppe 'Prüfbereitschaft & Behörden'; sortOrder 3700 und 3800 (der höchste vorhandene Wert ist 3600, `erp-inventory`)
// row-lock.ts (Schlüssel jeweils "id"; ComplianceSettings und SiteSettings haben die Zeile "singleton"):
// 1. Apiary rückt an die ERSTE Stelle der Map (vor Colony) und verschwindet aus der alten Position hinter NotificationPreference
// 2. LOCKABLE_TABLES += LabelTemplate, SiteSettings, VeterinaryMedicine, VeterinaryMedicineLot, ComplianceSettings, AuthorityNotice,
// ApiaryRestriction, ComplianceAcknowledgement, in dieser Reihenfolge hinter NotificationDelivery (Product und Apiary stehen schon in der Map, nicht doppelt eintragen)
// neue Reihenfolge: Apiary, Colony, Suggestion, Task, Queen, AutomationRule, BottlingBatch, Feeding, Harvest, Inspection, Product, ProductionBatch,
// RawMaterialLot, StockItem, Treatment, VarroaMeasurement, NotificationPreference, PhenologyReport, Extraction, HoneyBatch, NotificationDelivery, <die acht T6-Tabellen>
-
Lock-Reihenfolge Stand → Volk. Heute sperrt genau ein Pfad Stand und Volk in einer Transaktion:
AutopilotService.syncApiaryIn(src/autopilot/autopilot.service.ts), und zwar schon Stand zuerst, dann alle Völker in einer Anweisung. Er braucht keine Codeänderung; nur der Kommentar der Map undrow-lock.spec.tsziehen nach. Kein bestehender Pfad sperrt Volk vor Stand. Die Aufrufer, die T6 für die neue Reihenfolge anpasst (vollständige Liste, alle in Task 8, Task 7 nur für Apiaries):ColoniesService.create(Stand vor dem Anlegen,assertApiaryActiveläuft dabei übertxstatt vor der Transaktion),ColoniesService.update(liest den Stand ungesperrt, sperrt alter und neuer Stand, dannlockColony, prüft unter der Sperre, dass der Stand unverändert ist, sonst 409),ColoniesService.move(ebenso),ColoniesService.merge(Stände aller beteiligten Völker, dannlockRowsForUpdateder Völker),ApiariesService.update/deactivate/reactivate(nur Stand, Task 7),ApiaryRestrictionsService.create/update(nur Stände,FOR UPDATE). Nicht geändert, aber zu schützen:InspectionsService.create→ColoniesService.updateInläuft schon unter der Volkssperre, der Übernahme-Patch enthält nieapiaryId(colony-takeover.ts),updateInsperrt darum keinen Stand; ein Test hält das fest. -
Schritt 1: Failing Tests
// test/t6-schema.e2e-spec.ts
it('the CHECK rejects a lot with quantity 0', ...);
it('the CHECK rejects a movement with quantity 0', ...);
it('the CHECK rejects a TREATMENT movement without treatmentId and a CORRECTION movement with one', ...);
it('rejects an ApiaryRestriction that ends before it starts', ...);
it('rejects minXHeightMm below 0.5 and above 5', ...);
it('ComplianceSettings default netQuantityDigitRules equals the four rules of the spec', ...);
it('openKey is unique, several rows with openKey NULL are allowed', ...);
it('a medicine with lots cannot be deleted (RESTRICT)', ...);
// test/compliance-permissions.e2e-spec.ts
it('BEEKEEPER holds compliance:manage and veterinary-medicines:update', ...);
it('STAFF holds compliance:read and veterinary-medicines:create but not compliance:manage', ...);
it('READONLY holds the two read permissions only', ...);
it('API_CLIENT holds none of the five new permissions', ...);
// row-lock.spec.ts
it('keeps the map in lock order: Apiary before Colony, the T6 tables last', ...); // ersetzt den bestehenden Test 'keeps the map in lock order: Colony, Suggestion, ...' (Liste LOCK_ORDER vollständig)
it('every table of the map is lockable by id and each appears once', ...); // erfasst das Doppel-Eintragen von Product/Apiary
it.each([...T6-Tabellen])('locks %s by "id"', ...); // ergänzt die bestehende it.each-Liste
// autopilot.service.spec.ts: der bestehende Test, der ['Apiary','api-1'] vor den Völkern erwartet, bleibt grün (Wächter für die Reihenfolge)
// backup.service.spec.ts
it('no T6 model is excluded from the backup or listed as legacy-unsealed', ...);
it('restores medicine, lot, treatment and movement in foreign-key order', ...);
it('an unsealed legacy file skips every T6 table and reports it', ...);
// update-dto-null-handling.spec.ts
it('keeps every existing contract green after the move to describeNullContracts', ...); // gleiche Fälle wie bisher
- Schritt 2:
npx jest src/prisma src/backup src/common/validationund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
- Schema und drei Migrationen nach Spec 4.1–4.7; SQL aus
migrate differzeugen und von Hand ergänzen: CHECKs, Vorgabe-JSON, Rechte-INSERTs nach dem Muster von20260928091348_winter_checks(Vergabe-Matrix aus „Rechte“ oben,ON CONFLICT DO NOTHING). backup.service.ts: AbschnitteveterinaryMedicine,veterinaryMedicineLot,veterinaryMedicineMovement,complianceSettings,authorityNotice,apiaryRestriction,complianceAcknowledgement; Import-ReihenfolgeveterinaryMedicine→veterinaryMedicineLot→treatment→veterinaryMedicineMovement,apiaryRestrictionnachapiary,userId-Spalten überresolveUserId/resolveOptionalUserId. Keine T6-Tabelle inLEGACY_UNSEALED_TABLES.null-contract.tsaus dem Rumpf des bestehenden Specs herausziehen (Verhalten unverändert); der Spec ruft den Helfer auf.
- Schema und drei Migrationen nach Spec 4.1–4.7; SQL aus
- Schritt 4: → PASS; Drift-Check
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code→ 0. - Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/t6-schema.e2e-spec.ts test/compliance-permissions.e2e-spec.ts test/backup.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 2: Katalog, Packungen und Bewegungsbuch
Files
- Create:
src/common/quantity.ts(+ Spec),src/veterinary-medicines/medicine-ledger.ts(+ Spec),veterinary-medicines.module.ts,veterinary-medicines.service.ts(+ Spec),veterinary-medicine-lots.service.ts(+ Spec),veterinary-medicines.controller.ts,veterinary-medicine-lots.controller.ts,dto/create-veterinary-medicine.dto.ts,update-veterinary-medicine.dto.ts,create-lot.dto.ts,update-lot.dto.ts,create-movement.dto.ts,medicine-list-query.dto.ts,lot-list-query.dto.ts,veterinary-medicines-null-contract.spec.ts,src/seed-no-medicines.spec.ts(Wächter:prisma/seed.tslegt keine Tierarzneimittel an) - Modify:
src/bottling/bottling.service.ts(die privateroundKgentfällt, Aufrufer nehmenroundToThousandths),src/honey-batches/honey-plausibility.ts(T4 hat dort ein exportiertesroundKgmit gleichem Rumpf angelegt, Aufrufer u. a.extractions.service.ts:export const roundKg = roundToThousandths;, damit es nicht drei Kopien gibt; die private Kopie insrc/backup/legacy-honey-ledger.tsbleibt, sie ist ein eingefrorener Altpfad),src/app.module.ts - Test: neu
test/veterinary-medicines.e2e-spec.ts;test/bottling.e2e-spec.ts(läuft unverändert grün) - Docs: neue Annahme „Katalog und Packungen“ (eigenes Bewegungsbuch statt
StockItem/BatchMovement, Rest berechnet, negativer Rest erlaubt, Katalog startet leer – Annahme 10).
Interfaces
- Consumes: Task 1.
- Produces:
export function roundToThousandths(value: number): number; // Math.round(v * 1000) / 1000
// medicine-ledger.ts
export interface LotLedgerEntry { quantity: number }
export function summarizeLot(initial: number, entries: readonly LotLedgerEntry[]): { remaining: number; used: number }; // remaining gerundet, used = -Σ(negative TREATMENT/DISPOSAL)... einfach: initial - remaining
export interface BookedPerLot { lotId: string; quantity: number } // vorzeichenbehaftet, bisherige Summe der TREATMENT-Buchungen je Packung
export interface DesiredBooking { lotId: string; dose: number } // dose > 0
export function treatmentBookingDelta(current: readonly BookedPerLot[], desired: DesiredBooking | null): BookedPerLot[]; // Gegenbuchung je alter Packung, Soll -dose auf der neuen; leer wenn nichts zu tun; Rundung auf Tausendstel
export interface MedicineView { id; name; ...alle Felder; totalRemaining: number; nextExpiry: string | null; lotCount: number }
export interface LotView { id; medicineId; medicineName; unit: MedicineUnit; batchNumber; purchasedOn; quantity; remaining: number; expiresOn; supplier; purchaseProof; prescribingVet; notes; movementCount: number }
export interface LotDetailView extends LotView { movements: { id; type; quantity; treatmentId; reason; userId; createdAt }[] }
export interface MedicineSuggestion { name: string; treatmentCount: number; latest: { activeIngredient: string | null; withdrawalPeriodDays: number | null; method: string | null; amount: string | null } }
VeterinaryMedicinesService.findAll(active?: boolean): Promise<MedicineView[]>;
VeterinaryMedicinesService.findOne(id: string): Promise<MedicineView & { lots: LotView[] }>;
VeterinaryMedicinesService.suggestions(): Promise<MedicineSuggestion[]>;
VeterinaryMedicinesService.create(dto: CreateVeterinaryMedicineDto, userId: string): Promise<MedicineView>;
VeterinaryMedicinesService.update(id: string, dto: UpdateVeterinaryMedicineDto, userId: string): Promise<MedicineView>; // unit: 409 sobald eine Packung existiert
VeterinaryMedicinesService.setActive(id: string, active: boolean, userId: string): Promise<MedicineView>;
VeterinaryMedicineLotsService.findAll(query: { medicineId?: string; selectable?: boolean }): Promise<LotView[]>; // selectable: Rest > 0, fifoOrder
VeterinaryMedicineLotsService.findOne(id: string): Promise<LotDetailView>;
VeterinaryMedicineLotsService.create(dto: CreateLotDto, userId: string): Promise<LotView>;
VeterinaryMedicineLotsService.update(id: string, dto: UpdateLotDto, userId: string): Promise<LotView>; // medicineId: 409 sobald eine Bewegung existiert
VeterinaryMedicineLotsService.addMovement(id: string, dto: CreateMovementDto, userId: string): Promise<LotDetailView>; // CORRECTION { actualRemaining } | DISPOSAL { quantity, reason }
// Endpunkte exakt nach Spec 7 (Medicines, Lots); POST-Anlegen, :id/movements und deactivate/reactivate tragen @Idempotent() bzw. nur die Anlege-Endpunkte (Annahme 11)
- Schritt 1: Failing Tests
// quantity.spec.ts
it('rounds to thousandths', () => expect(roundToThousandths(0.0004)).toBe(0));
it('keeps 1.2345 as 1.235 (half up)', ...);
// medicine-ledger.spec.ts
it('summarizeLot rounds to thousandths', () => expect(summarizeLot(10, [{ quantity: -3.3333 }, { quantity: -3.3333 }]).remaining).toBe(3.333));
it('treatmentBookingDelta books the full dose on create', ...); // current [], desired {A, 5} -> [{A, -5}]
it('moves the booking when the lot changes', ...); // current [{A,-5}], desired {B,5} -> [{A,+5},{B,-5}]
it('reverses the booking when dose or lot is cleared', ...); // desired null -> [{A,+5}]
it('books nothing when nothing changed', ...); // -> []
it('books only the difference when the dose changes on the same lot', ...); // -5 -> 7: [{A,-2}]
// veterinary-medicines.service.spec.ts
it('rejects changing the unit once a lot exists with 409', ...);
it('allows editing every other field of a medicine with lots', ...);
it('suggestions skip names already in the catalogue regardless of case and give the latest values', ...);
// veterinary-medicine-lots.service.spec.ts
it('CORRECTION books the delta between the rest and the actual rest and nothing for delta 0', ...);
it('DISPOSAL books the negative quantity and needs a reason', ...);
it('rejects changing medicineId once a movement exists with 409', ...);
it('selectable lots are ordered oldest expiry first and exclude an empty lot', ...);
it('a negative rest is allowed and reported in the view', ...);
// test/veterinary-medicines.e2e-spec.ts
it('lot -> treatment -> rest: a treatment books the dose and the rest shrinks', ...); // wird in Task 3 grün, hier mit direkter Bewegung CORRECTION
it('two parallel inventories on one lot: the second computes with the new rest', ...);
it('STAFF may create a lot, READONLY gets 403, API_CLIENT gets 403 on every route', ...);
it('a POST with a repeated Idempotency-Key creates one lot', ...);
it('rejects a quantity of 0 or NaN with 400', ...);
it('a movement can neither be changed nor deleted (no such routes)', ...);
- Schritt 2:
npx jest src/common/quantity.spec.ts src/veterinary-medicinesundtest/veterinary-medicines.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren
CORRECTION: in der TransaktionlockRowForUpdate(tx, 'VeterinaryMedicineLot', id), Summe der Bewegungen dort lesen, Delta =actualRemaining − remaining, Bewegung nur bei Delta ≠ 0, AuditUPDATEan der Packung (beforeData/afterDatamit Rest).DISPOSAL: gleiche Sperre,quantitypositiv im DTO, gebucht als negativ.updateder Packung und des Mittels: Sperre →beforelesen → Prüfung (unit:tx.veterinaryMedicineLot.count,medicineId:tx.veterinaryMedicineMovement.count) → schreiben → Audit. Mittelsperre vor Packungssperre.- Rest in Listen: eine Gruppen-Abfrage (
groupBy lotId, _sum quantity), kein N+1;totalRemainingsummiert nur Packungen aktiver Mittel nicht, sondern alle Packungen des Mittels. - Vorschläge: Roh-SQL gruppiert
lower(trim(product))überTreatmentohne Katalogeintrag mit gleichem Namen (ohne Groß-/Kleinschreibung), höchstens 50, jüngste Behandlung liefert die Werte. Es wird nichts angelegt. - DTOs:
MaxLengthnach Global Constraints,IsPositivefürquantity/dosePerColony,withdrawalPeriodDaysIsInt @Min(0), Temperaturen endlich undminTemperatureC ≤ maxTemperatureC(400), Update-DTO überPartialType(…, { skipNullProperties: false }); Null-Verträge inveterinary-medicines-null-contract.spec.tsüberdescribeNullContracts. - Modul in
AppModuleeintragen (exportiert beide Services für Task 3).
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/veterinary-medicines.e2e-spec.ts test/bottling.e2e-spec.ts test/extractions.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 3: Behandlung bucht von der Packung, Bestandsbuch aus der Packung, Lücken-Helfer
Files
- Modify:
src/treatments/treatments.service.ts(+ Spec),src/treatments/treatments.module.ts(importiertVeterinaryMedicinesModule),src/treatments/dto/create-treatment.dto.ts,dto/update-treatment.dto.ts,src/export/treatment-log.ts(+treatment-log.spec.ts) - Create:
src/treatments/treatments-null-contract.spec.ts - Test:
test/treatments.e2e-spec.ts,test/export.e2e-spec.ts(Spec nennttest/treatment-log.e2e-spec.ts; die Datei gibt es nicht, der Bestandsbuch-e2e lebt inexport.e2e-spec.ts) - Docs: ARCHITECTURE Annahme 15 ergänzen: Lieferant, Beleg, Ch.-B. und Tierarzt kommen bei Behandlung aus einer Packung live aus der Packung.
Interfaces
- Consumes: Task 1, Task 2 (
treatmentBookingDelta,roundToThousandths, Services), T3 (treatmentRow,recordIn,createBulk). - Produces:
// CreateTreatmentDto += medicineId?: string; medicineLotId?: string; doseQuantity?: number (@IsPositive, @IsFinite);
// product: @ValidateIf((o) => !o.medicineId && !o.medicineLotId) @IsString() @MaxLength(200) (Spec 5.2: fehlt product, wird es der Handelsname)
// UpdateTreatmentDto += apiaryId?: string (notNull; Stand am Behandlungstag korrigieren); medicineId/medicineLotId/doseQuantity clearable
// treatments.service.ts (privat, von create, createBulk und update gemeinsam genutzt)
interface ResolvedMedicine { medicineId: string | null; medicineLotId: string | null; product: string }
resolveMedicineIn(tx: Prisma.TransactionClient, input: { product?: string | null; medicineId?: string | null; medicineLotId?: string | null; supplier?: unknown; purchaseProof?: unknown; batchNumber?: unknown; prescribingVet?: unknown }, mode: 'create' | 'update'): Promise<ResolvedMedicine>;
bookTreatmentIn(tx: Prisma.TransactionClient, treatment: Treatment, userId: string): Promise<void>; // Gegenbuchung/Neubuchung über treatmentBookingDelta, Summe je Packung aus vorhandenen Bewegungen dieser Behandlung
// export/treatment-log.ts
export interface TreatmentLogSource { …bisher; medicineId: string | null; prescriptionOnly: boolean; doseQuantity: number | null; medicineUnit: MedicineUnit | null;
lot: { id: string; batchNumber: string | null; supplier: string | null; purchaseProof: string | null; prescribingVet: string | null } | null }
export interface TreatmentLogRow { …bisher; lotId: string | null; prescriptionOnly: boolean }
export type TreatmentLogGap = 'SUPPLIER' | 'PROOF' | 'AMOUNT' | 'WITHDRAWAL' | 'VET';
export function resolveSourceValues(source: TreatmentLogSource): Pick<TreatmentLogSource, 'batchNumber' | 'supplier' | 'purchaseProof' | 'prescribingVet'> & { amount: string | null }; // Spalten aus genau einer Quelle (Spec 5.3)
export function treatmentLogGaps(row: Pick<TreatmentLogRow, 'supplier' | 'purchaseProof' | 'amount' | 'withdrawalPeriodDays' | 'prescribingVet' | 'prescriptionOnly'>): TreatmentLogGap[];
- Schritt 1: Failing Tests
// treatments.service.spec.ts
it('create books the dose from the lot in the same transaction', ...);
it('rejects a lot of another medicine', ...);
it('rejects supplier text together with a lot', ...);
it('takes the product name from the catalogue', ...);
it('rejects medicine and product that differ ignoring case', ...);
it('rejects an inactive catalogue entry on create but allows editing an existing treatment of it', ...);
it('sets medicineId from the lot when only the lot is given', ...);
it('never fills withdrawal or ingredient from the catalogue', ...);
it('a dose without a lot is stored and books nothing', ...);
it('bulk books one movement per colony', ...);
it('bulk rolls back all bookings when one colony fails', ...);
it('update books only the delta when the dose changes and moves the booking when the lot changes', ...);
it('update with dose null or lot null reverses the booking', ...);
it('update rejects medicineId null while a lot is set', ...);
it('update reads before after locking Treatment and then the lots in ascending id order', ...);
it('re-filing to another colony leaves the stock untouched', ...);
it('update accepts apiaryId and rejects an unknown apiary with 404', ...);
// treatment-log.spec.ts
it('takes supplier, proof, batch and vet from the lot', ...);
it('prints the dose with unit when the amount text is empty', () => expect(resolveSourceValues(withDose(30, 'ML')).amount).toBe('30 ml'));
it('requires the vet only for prescription-only medicines', ...);
it('groups two colonies of the same lot and day into one row', ...);
it('prints FEHLT exactly where treatmentLogGaps reports a gap', ...);
it('footnote says supplier, proof and batch come from the recorded lot', ...);
// test/treatments.e2e-spec.ts
it('editing dose and lot books only the delta and the movements sum to the new dose', ...);
it('a repeated bulk request with the same Idempotency-Key books once', ...);
it('a treatment from an empty lot is stored and the rest turns negative', ...);
it('a treatment after the lot expired is stored', ...);
it('a user with treatments:create but without veterinary-medicines:read can book from a lot', ...);
// test/export.e2e-spec.ts
it('a complete catalogue entry and lot give a Bestandsbuch PDF without FEHLT and treatmentLogGaps empty for every row', ...);
- Schritt 2:
npx jest src/treatments src/exportund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
treatmentRow(freie Modulfunktion, spreizt...dto) bleibt die einzige Datenaufbereitung fürcreateundcreateBulk;updatebaut seine Daten weiter selbst (...dto, plus neuapiaryId), ruft aber wie die beiden anderenresolveMedicineIn. Die neuen SpaltenmedicineId/medicineLotId/doseQuantityfließen über das Spreizen;supplier/purchaseProof/batchNumber/prescribingVetbleiben bei gewählter Packung leer (der Server lehnt sie sonst ab, der Aufbereiter legt sie nie still an). Bulk: Prüfung einmal (vorrunBulk), Buchung je Volk inrecordIn(tx, data, actingUserId, bulkId?)(nach demtx.treatment.create, vor Audit);createsperrt das Volk weiter mitlockRowForUpdate(tx, 'Colony', …)und die Packung danach (Reihenfolge der Map: Volk vor Packung).update: nachlockRowForUpdate(tx, 'Treatment', id)und gelesenembeforedie betroffenen Packungen (alte und neue) in aufsteigender ID sperren (lockRowForUpdate(tx, 'VeterinaryMedicineLot', …)), Summe der bisherigenTREATMENT-Bewegungen je Packung lesen,treatmentBookingDeltaanwenden. Der zusammengeführte Zustand (before+ DTO) wird geprüft, nicht das DTO allein;nullleert das Feld.- Buchung:
tx.veterinaryMedicineMovement.create({ type: 'TREATMENT', quantity, treatmentId, lotId, userId }); Audit der Behandlung bleibt unverändert,afterDataenthältmedicineLotId/doseQuantity. treatment-log.ts:buildTreatmentLoglädtmedicineundmedicineLotperinclude, ruftresolveSourceValues, Gruppierungsschlüssel über die aufgelösten Werte;renderSheetrufttreatmentLogGaps(row)und drucktMISSINGgenau bei gemeldeter Lücke (Tierarzt: Spalte „a) Lieferant …“). Fußnote ergänzen. Layout unverändert.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/treatments.e2e-spec.ts test/export.e2e-spec.ts test/veterinary-medicines.e2e-spec.ts test/follow-up-controls.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 4: Dokumente zu Packung, Anzeige und Verfügung – Rechte je Art
Files
- Modify:
src/documents/documents.service.ts(+ Spec),src/documents/documents.controller.ts,src/documents/documents.module.ts(falls nötig) - Test:
test/documents.e2e-spec.ts - Docs: ARCHITECTURE Abschnitt Dokumente: für die drei T6-Arten gilt zusätzlich das Fachrecht.
Interfaces
- Consumes: Task 1 (Enum-Werte),
PermissionResolverService.getPermissions. - Produces:
export const DOCUMENT_ENTITY_PERMISSIONS: Partial<Record<DocumentEntityType, { read: PermissionKey; write: PermissionKey }>> = {
MEDICINE_LOT: { read: 'veterinary-medicines:read', write: 'veterinary-medicines:update' },
AUTHORITY_NOTICE: { read: 'compliance:read', write: 'compliance:manage' },
APIARY_RESTRICTION: { read: 'compliance:read', write: 'compliance:manage' },
};
DocumentsService.assertEntityAccess(userId: string, entityType: DocumentEntityType, mode: 'read' | 'write'): Promise<void>; // 403 ohne Recht; Arten ohne Eintrag: nichts zusätzlich
// create/update/delete haben `actingUserId` schon (für Audit und Upload); neu bekommen ihn `findAll` und `readFile` (Controller reicht `actor.id` durch); update prüft alte UND neue Art; assertEntityExists kennt die drei neuen Arten (404 wie bisher)
- Schritt 1: Failing Tests
// documents.service.spec.ts
it('assertEntityExists finds a medicine lot, an authority notice and an apiary restriction and 404s a missing one', ...);
it('assertEntityAccess is a no-op for the old entity types', ...);
it('update checks the permission of the old and of the new entity type', ...);
// test/documents.e2e-spec.ts
it('a user with documents:read but without veterinary-medicines:read gets 403 listing and downloading a MEDICINE_LOT document', ...);
it('a user with documents:upload but without compliance:manage gets 403 uploading to an AUTHORITY_NOTICE', ...);
it('re-linking a document to an APIARY_RESTRICTION needs compliance:manage', ...);
it('deleting a MEDICINE_LOT document needs veterinary-medicines:update', ...);
it('STAFF uploads a purchase receipt to a lot and downloads it again', ...);
it('uploads keep the MIME allowlist and signature check for the new types', ...);
- Schritt 2:
npx jest src/documentsundtest/documents.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren
readFile,update,deleteladen zuerst das Dokument, prüfen dannassertEntityAccess(userId, document.entityType, …), erst danach Datei lesen.findAllundcreateprüfen vor jeder Datenbankabfrage. Der Controller reichtactor.iddurch; die bestehenden@RequirePermission('documents:*')bleiben.- Speicherordner
entityType.toLowerCase()bleibt (neue Ordnermedicine_lot,authority_notice,apiary_restriction).
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/documents.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 5: Etikett-Pflichtangaben – Platzhalter, Stammdaten, Prüfregeln, Schriftmetriken
Files
- Create:
src/label-templates/label-particulars.ts(+label-particulars.spec.ts),src/label-templates/label-text.ts(+ Spec; aufgelöster Inhalt, Normalisierung),src/label-templates/font-metrics.ts(+ Spec),src/label-templates/label-compliance-null-contract.spec.ts,test/fixtures/placeholder-keys.json,src/label-templates/placeholder-keys.spec.ts - Modify:
src/label-templates/label-element.types.ts(PLACEHOLDER_KEYS),src/label-templates/label-templates.service.ts(buildPlaceholderValuesmit Einstellungen als zweitem Parameter;updateliestbeforeunter Sperre),src/label-templates/dto/create-label-template.dto.ts,update-label-template.dto.ts(minXHeightMm),src/site-settings/site-settings.service.ts(+ Spec;foodBusinessName/Address,updateunter Sperre),src/site-settings/dto/update-site-settings.dto.ts,src/products/products.service.ts,src/products/dto/create-product.dto.ts(+ Update;legalName) - Test:
test/products.e2e-spec.ts,test/public.e2e-spec.ts - Docs: neue Annahme „Etikett-Pflichtangaben“ (Prüfung am aufgelösten Inhalt, Sichtfeld-Regel nicht geprüft, Nährwert befreit – Annahme, Schriftmetriken aus den eingebauten PDF-Schriften).
Interfaces
- Consumes: Task 1.
- Produces:
export const PLACEHOLDER_KEYS = ['batchNumber','honeyType','lotNumber','bottlingDate','bestBeforeDate','jarSize','productName',
'legalName','originCountry','foodBusinessName','foodBusinessAddress','bestBeforeLine','lotLine'] as const; // bestBeforeLine "Mindestens haltbar bis TT.MM.JJJJ", lotLine "L <Losnummer>", jarSize bleibt "500 g"
export function buildPlaceholderValues(bottlingBatch: {…bisher; product: { name: string; legalName: string | null } | null },
settings: { originCountry: string; foodBusinessName: string | null; foodBusinessAddress: string | null }): Record<PlaceholderKey, string>;
// font-metrics.ts
export interface FontMetrics { xHeight: number; capHeight: number } // AFM-Einheiten (Helvetica 523/718)
export function fontMetricsOf(family: LabelFontFamily, bold: boolean, italic: boolean): FontMetrics; // liest die eingebauten pdfkit-AFM-Werte hinter EINER Kapsel (pdfkit-Feld _font ist privat)
export function heightMm(fontSizePt: number, metricPer1000: number): number; // pt * metric / 1000 * 25.4 / 72
export interface TextMeasurer { heightOfStringPt(text: string, element: LabelTextElement): number }
export function pdfTextMeasurer(): TextMeasurer; // wegwerf-PDFDocument, kein Ausgabestrom
// label-text.ts
export function normalizeForCheck(text: string): string; // toLowerCase, NFKC, alles außer Buchstaben/Ziffern -> ' ', zusammengefaltet, getrimmt
export function resolvedTexts(elements: readonly LabelTextElement[], values: Record<PlaceholderKey, string>): { element: LabelTextElement; text: string }[]; // sortiert: y, dann x
// label-particulars.ts
export type LabelFindingCode = 'LEGAL_NAME'|'NET_QUANTITY'|'BEST_BEFORE'|'LOT'|'ORIGIN'|'FOOD_BUSINESS'|'LOT_PREFIX'|'X_HEIGHT'|'NET_QUANTITY_HEIGHT'|'TEXT_CLIPPED';
export interface LabelFinding { code: LabelFindingCode; title: string; detail: string; elementIds: string[] }
export interface LabelCheckResult { printable: boolean; gaps: LabelFinding[]; warnings: LabelFinding[]; sample: boolean;
requirements: { minXHeightMm: number; netQuantityMinCapHeightMm: number | null } } // Frontend braucht keine eigenen Rechtswerte
export interface NetQuantityDigitRule { maxGrams: number | null; minMm: number }
export interface LabelCheckInput {
elements: readonly LabelTextElement[]; minXHeightMm: number; digitRules: readonly NetQuantityDigitRule[];
values: Record<PlaceholderKey, string>;
expected: { jarSizeGrams: number; bestBefore: string /* YYYY-MM-DD */; lotNumber: string; originCountry: string; foodBusinessName: string | null; foodBusinessAddress: string | null };
sample: boolean; measurer?: TextMeasurer;
}
export function checkLabel(input: LabelCheckInput): LabelCheckResult; // rein, ohne Datenbank
export const SAMPLE_BOTTLING: { jarSize: 500; lotNumber: '2026-0001'; … }; // nur Beispielwerte für die Vorschau, keine Rechtswerte
- Schritt 1: Failing Tests
// label-particulars.spec.ts
it('passes a label with all particulars', ...);
it('reports each missing particular by code', ...); // it.each über die sechs Pflichtcodes
it('matches the address across line breaks and separators', ...); // "Bühlweg 10 · 86450 Altenmünster" gegen "Bühlweg 10, 86450 Altenmünster"
it('flags a static "500 g" on a 250 g bottling', ...);
it('accepts 250,5 g for a 250.5 g jar and "1 kg" for a 1000 g jar', ...);
it('does not accept 1250 g for 250 g', ...);
it('best before needs the wording and the date, leading zeros do not matter', ...);
it('treats clipped text as missing and warns TEXT_CLIPPED', ...);
it('FOOD_BUSINESS is a gap when either master data field is empty', ...);
it('a label without product legalName is complete only with a fixed text containing "honig"', ...);
it('Helvetica 6 pt is x-height 1.11 mm (warning), 7 pt is 1.29 mm', ...);
it('x-height exactly at the limit is fine', ...);
it('needs 4 mm digits for 500 g with the default rules (15.8 pt)', ...);
it('uses the rule of the matching weight class for 50 g, 200 g and 1000 g and the open class above', ...);
it('warns on a lot without a leading L', ...);
it('a warning alone leaves printable true', ...);
it('sample result carries sample true', ...);
it('requirements carry minXHeightMm and the digit height of the jar size', ...);
// font-metrics.spec.ts
it('Helvetica metrics are x-height 523 and cap height 718', ...); // Wächter gegen pdfkit-Updates
it('Times and Courier return their own AFM values', ...);
// label-text.spec.ts
it('normalizes umlauts, NFKC compatibility characters and punctuation', ...);
it('sorts elements top first, then left', ...);
// placeholder-keys.spec.ts
it('the fixture file equals PLACEHOLDER_KEYS', ...); // test/fixtures/placeholder-keys.json; das Frontend vergleicht seine Liste damit
// site-settings.service.spec.ts
it('update reads before after locking the SiteSettings row', ...);
it('trims foodBusinessName and foodBusinessAddress and stores empty as null', ...);
// label-compliance-null-contract.spec.ts
// UpdateSiteSettingsDto: foodBusinessName/Address clearable; UpdateProductDto.legalName clearable; UpdateLabelTemplateDto.minXHeightMm notNull
// test/products.e2e-spec.ts
it('stores legalName and clears it with null', ...);
// test/public.e2e-spec.ts
it('the public origin page exposes neither foodBusinessAddress nor legalName', ...);
- Schritt 2:
npx jest src/label-templates src/site-settings src/productsund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
- Prüfregeln wie Spec 5.4 und Global Constraints.
NET_QUANTITY: normalisiertes"<n> g"als ganze Wörter im normalisierten Text (${text}enthält${needle}); bei vollen Kilogramm zusätzlich"<n/1000> kg".BEST_BEFORE: Text „mindestens haltbar bis“ und Datum als Zahlen0?T 0?M JJJJ.LOT: Losnummer als Wortfolge;LOT_PREFIX:(^| )l ?<losnummer>. Abgeschnitten, wennmeasurer.heightOfStringPt(text, element) > element.height × 72/25.4 + 0.5. - Beitrag eines Elements: Es trägt zur Prüfung bei, wenn es nicht abgeschnitten ist.
X_HEIGHTprüft jedes Element, das eine Pflichtangabe (alle sechs Pflichtcodes) trägt; Vergleich mit Toleranz1e-9; Titel „Text zu klein (x-Höhe 1,1 mm)“ mit deutschem Dezimalkomma, eine Nachkommastelle.NET_QUANTITY_HEIGHT: Versalhöhe des Elements mit der Füllmenge gegen die erste Regel mitjarSize ≤ maxGrams(letzte RegelmaxGrams: null). fontMetricsOfsetztdoc.font(pdfFontName(...))und liestxHeight/capHeightdes Schriftobjekts; die Kapselung ist die einzige Stelle mit dem privaten Feld, der Spec-Test fällt bei einem pdfkit-Update.renderLabelsPdfbleibt in diesem Task unverändert bis auf den neuen Parameter vonbuildPlaceholderValues(Aufrufer: Einstellungen übersiteSettings.get()); Druck-Sperre folgt in Task 6.LabelTemplatesService.update:beforeinnerhalb der Transaktion nachlockRowForUpdate(tx, 'LabelTemplate', id)lesen.SiteSettingsService.updateundProductsService.updateebenso. Einedto.elements-Prüfung überPLACEHOLDER_KEYS(DTO-Enum) übernimmt die neuen Schlüssel automatisch.test/fixtures/placeholder-keys.jsonenthält exaktPLACEHOLDER_KEYS.
- Prüfregeln wie Spec 5.4 und Global Constraints.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/products.e2e-spec.ts test/public.e2e-spec.ts test/bottling.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 6: Etikettendruck – Sperre, Probedruck, Audit, Prüf-Endpunkte
Files
- Modify:
src/label-templates/label-templates.service.ts(+ Spec),src/label-templates/label-templates.controller.ts,src/label-templates/label-templates.module.ts(importiertSiteSettingsModule,AuditModulefalls nötig) - Create:
src/label-templates/dto/check-label-template.dto.ts,src/label-templates/label-proof-watermark.ts(+ Spec),src/label-templates/label-check.loader.ts(+ Spec) - Test: neu
test/label-compliance.e2e-spec.ts - Docs: ARCHITECTURE §Etiketten: Druck-Sperre bei Pflichtlücke, Probedruck, Audit
PRINT.
Interfaces
- Consumes: Task 1, Task 5 (
checkLabel,SAMPLE_BOTTLING,buildPlaceholderValues). - Ändert eine bestehende Signatur:
renderLabelsPdf(templateId, bottlingBatchId, startPosition = 1)wird zurenderLabelsPdf(templateId, bottlingBatchId, options, actingUserId). Aufrufer:label-templates.controller.tsundlabel-templates.service.spec.ts(beide in diesem Task anpassen);buildPlaceholderValuesist eine freie exportierte Funktion mit einem Parameter (Task 5 gibt ihr den zweiten). - Produces:
export class CheckLabelTemplateDto { // POST /label-templates/check; nur Text-Elemente
elements!: LabelTextElementDto[]; // @ArrayMaxSize(100), @ValidateNested, type 'text' erzwungen (andere Art -> 400)
widthMm!: number; heightMm!: number; minXHeightMm!: number; bottlingBatchId?: string;
}
LabelTemplatesService.checkDraft(dto: CheckLabelTemplateDto): Promise<LabelCheckResult>; // ohne bottlingBatchId: SAMPLE_BOTTLING, sample true
LabelTemplatesService.checkForBottling(templateId: string, bottlingBatchId: string): Promise<LabelCheckResult>;
LabelTemplatesService.renderLabelsPdf(templateId: string, bottlingBatchId: string,
options: { startPosition?: number; proof?: boolean }, actingUserId: string): Promise<Buffer>; // wirft ConflictException({ code: 'LABEL_PARTICULARS_MISSING', message, gaps }) bei Pflichtlücke ohne proof
export function drawProofWatermark(doc: PDFKit.PDFDocument, originX: number, originY: number, widthPt: number, heightPt: number): void; // "MUSTER – nicht verkaufsfähig", diagonal, 50 % Grau
// GET /label-templates/:id/check/:bottlingBatchId @RequirePermission('label-templates:read')
// POST /label-templates/check @RequirePermission('label-templates:read')
// GET /label-templates/:id/print/:bottlingBatchId?startPosition=&proof= @RequirePermission('label-templates:print'); proof über ParseBoolPipe (optional)
- Schritt 1: Failing Tests
// label-templates.service.spec.ts
it('print refuses with 409 and the gap list', ...);
it('proof print renders despite a gap, adds the watermark and writes a PRINT audit entry', ...);
it('the audit entry carries templateId, copies, proof, gapCodes and warningCodes', ...);
it('a print with warnings only is rendered and audited with the warning codes', ...);
it('the audit is written before the PDF is returned and a failing audit fails the print', ...);
it('checkDraft without a bottling uses the sample values and sets sample true', ...);
it('checkForBottling uses the placeholder values of that bottling and the master data', ...);
// label-proof-watermark.spec.ts
it('draws the text MUSTER – nicht verkaufsfähig rotated, grey, half opaque, once per label', ...); // Doc-Fake zeichnet Aufrufe auf
// label-check.loader.spec.ts
it('maps a bottling batch to the expected particulars (jar grams, best-before day, lot, origin, master data)', ...);
// test/label-compliance.e2e-spec.ts
it('409 with the gap list, then 200 and an audit PRINT after the master data is complete', ...);
it('GET print with proof=true answers 200 although a particular is missing', ...);
it('POST check rejects an image element with 400', ...);
it('POST check with 101 elements answers 400', ...);
it('print needs label-templates:print, check needs label-templates:read', ...);
it('the response of a print is no-store', ...);
- Schritt 2:
npx jest src/label-templatesundtest/label-compliance.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren
renderLabelsPdflädt Vorlage, Abfüllung (mithoneyBatch,product) und Einstellungen, ruft dieselbe FunktioncheckLabelwie der Check-Endpunkt (überlabel-check.loader.ts), wirft beigaps.length > 0 && !proof, rendert, ruftdrawProofWatermarkje Etikett beiproof, schreibt danachauditLog.record({ entityType: 'BottlingBatch', entityId, action: 'PRINT', userId, afterData: {…} })und gibt erst dann das PDF zurück (fail closed). Der Controller reichtactor.iddurch und setztCache-Control: no-store.checkDraftlädtdigitRulesausComplianceSettings(Vorgabe, wenn die Zeile fehlt).- Route
POST /label-templates/checksteht vor@Post()-Varianten mit Parametern; keine Kollision mit:id.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/label-compliance.e2e-spec.ts test/bottling.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 7: Ereigniskatalog und Erzeuger für Völker und Stände
Files
- Modify:
src/domain-events/domain-event-types.ts(+domain-event-registry.spec.tsfalls Katalog-Zählung),src/colonies/colonies.service.ts(+ Spec),src/apiaries/apiaries.service.ts(+ Spec),src/apiaries/apiaries.module.ts,src/common/after-commit-usage.spec.ts - Create:
src/colonies/colony-created-events.spec.ts(optional, wenn die Service-Spec zu groß wird) - Test:
test/colonies.e2e-spec.ts,test/apiaries.e2e-spec.ts - Docs: ARCHITECTURE §10 „Ereignisse“:
ColonyCreated,ColonyMoved,ApiaryChanged,TskReferenceDayReached(nicht an Webhooks).
Interfaces
- Consumes: Task 1 (
Apiarylockbar), T4 Task 10 (ColonySplit). - Produces:
// DomainEventPayloads +=
ColonyCreated: { apiaryId: string };
ColonyMoved: { fromApiaryId: string; toApiaryId: string };
ApiaryChanged: { change: 'CREATED' | 'DEACTIVATED' | 'REACTIVATED' | 'LOCATION_CHANGED' };
TskReferenceDayReached: { year: number; referenceDay: string }; // referenceDay = YYYY-MM-DD des Stichtags
// AggregateType += 'Compliance' ('Apiary' gibt es seit T4b schon); CATALOG += die vier Typen (der Record über die Union erzwingt es);
// Handler-Registrierung: die Erzeuger (ColoniesService, ApiariesService) laufen nur in der API, die Handler (Task 11) liegen in `ComplianceModule`,
// das AppModule UND WorkerModule importieren; `@OnDomainEvent` steht auf einem Singleton-Provider (Handler-ID `compliance.herd-change`, `compliance.tsk-reference-day`)
// Frontend: DOMAIN_EVENT_LABELS (src/lib/system/labels.ts) bekommt die vier Typen in Task 14: ColonyCreated 'Volk angelegt', ColonyMoved 'Volk umgesetzt',
// ApiaryChanged 'Stand geändert', TskReferenceDayReached 'TSK-Stichtag erreicht'
// Erzeuger: ColoniesService.create -> ColonyCreated (in der Transaktion); split -> ColonyCreated für den Ableger ZUSÄTZLICH zu ColonySplit (T4);
// move und update mit Standwechsel -> ColonyMoved; ApiariesService.create/update/deactivate/reactivate: Datensatz + Audit(tx) + ApiaryChanged in EINER Transaktion
- Schritt 1: Failing Tests
// colonies.service.spec.ts
it('create records ColonyCreated with the apiary inside the transaction', ...);
it('split records ColonyCreated for the child next to ColonySplit', ...);
it('move records ColonyMoved with from and to', ...);
it('update with a changed apiary records ColonyMoved and without one records none', ...);
// apiaries.service.spec.ts
it('create writes record, audit and ApiaryChanged CREATED in one transaction', ...);
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', ...);
it('update reads before after locking the Apiary row', ...);
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', ...);
it('reactivate records REACTIVATED', ...);
it('a failing audit write rolls back the apiary change', ...);
// domain-event-registry.spec.ts / webhook.handlers.spec.ts
it('none of the four new events is forwarded to webhooks and payloads carry only ids', ...);
// after-commit-usage.spec.ts
it('apiaries.service.ts has no after-commit side effects', ...);
// e2e
it('a new colony leaves one ColonyCreated event', ...); // test/colonies.e2e-spec.ts
it('updating only the name of an apiary leaves no ApiaryChanged', ...); // test/apiaries.e2e-spec.ts
- Schritt 2:
npx jest src/domain-events src/colonies src/apiaries src/common/after-commit-usage.spec.ts src/webhooksund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
- Der Webhook
colony.createdbleibt im Altpfad nach dem Commit.assertApiaryActivebleibt vor der Transaktion (Fehlerbild unverändert). Ereignisse werden überthis.events.record(tx, …)/recordAllim selben$transaction-Block geschrieben. ApiariesServicehat heute keine Transaktion (prisma.apiary.update+auditLog.recordgetrennt,deactivatezählt außerhalb): alle vier Schreibpfade werden$transaction. Er übernimmtDomainEventsServiceundlockRowForUpdate(tx, 'Apiary', id);ApiariesModuleimportiertDomainEventsModule.deactivatezählt lebende Völker nach der Sperre in der Transaktion; weil Völker-Pfade den Stand mitFOR SHAREhalten (Task 8), wartet das Deaktivieren auf ein laufendes Umsetzen in diesen Stand.- Die Vergleichsfelder für
LOCATION_CHANGEDkommen ausbefore(unter Sperre) gegen den gespeicherten Stand.
- Der Webhook
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/colonies.e2e-spec.ts test/apiaries.e2e-spec.ts test/domain-event-dispatcher.e2e-spec.ts test/webhooks.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 8: Sperrbezirk – Modell, Wächter-Helfer, vier Völker-Pfade, Ausnahme
Files
- Create:
src/compliance/restricted-zone.ts(+ Spec),src/apiary-restrictions/apiary-restrictions.module.ts,apiary-restrictions.service.ts(+ Spec),apiary-restrictions.controller.ts,dto/create-apiary-restriction.dto.ts,dto/update-apiary-restriction.dto.ts,dto/restriction-list-query.dto.ts,src/apiary-restrictions/apiary-restrictions-null-contract.spec.ts - Modify:
src/colonies/colonies.service.ts(+ Spec),src/colonies/colonies.module.ts(importiertApiaryRestrictionsModule),src/colonies/dto/create-colony.dto.ts,update-colony.dto.ts,move-colony.dto.ts,merge-colony.dto.ts - Test: neu
test/restricted-zone.e2e-spec.ts;test/colonies.e2e-spec.ts - Docs: neue Annahme „Behördenpaket und Sperrbezirk“ (harte Sperre mit begründeter Ausnahme, Ernte nur Warnung, Ableger frei; BienSeuchV § 10 als Hinweis).
Interfaces
- Consumes: Task 1, Task 7 (
ColonyMoved;colonies.service.tsist frei),PermissionResolverService. - Produces:
// src/compliance/restricted-zone.ts
export interface RestrictionOverride { reason: string | null | undefined; canOverride: boolean }
export interface ActiveRestrictionInfo { id: string; apiaryId: string; apiaryName: string; disease: string; startsOn: string }
export function isRestrictionActive(r: { startsOn: Date; endsOn: Date | null }, day: string): boolean; // startsOn <= day <= endsOn (oder offen)
export function assertNoActiveRestriction(tx: Prisma.TransactionClient, apiaryIds: readonly string[], day: string,
override?: RestrictionOverride): Promise<{ overridden: boolean; restrictionIds: string[] }>;
// wirft 409 { code: 'RESTRICTED_ZONE', message, restrictionIds, restrictions: ActiveRestrictionInfo[] }; mit reason und canOverride false -> 403; reason ohne aktive Sperre -> ignoriert
export function dbUtcDay(db: DbClient): Promise<string>; // utcDay(SELECT now())
// DTOs: restrictionOverrideReason?: string (@IsOptional @IsString @MinLength(10) @MaxLength(500)) an Create/Update/Move/Merge; null gilt wie weggelassen
// ApiaryRestrictionsService
create(dto: CreateApiaryRestrictionDto, userId: string): Promise<ApiaryRestriction[]>; // apiaryIds 1-50, eine Zeile je Stand, eine Transaktion, Audit je Zeile; @Idempotent; zuerst `lockRowsForUpdate(tx, 'Apiary', apiaryIds)` (unbekannte Stände: 404 nach der Sperre, alles zurück); `update` sperrt den Stand der Sperre ebenso, dann `ApiaryRestriction`
update(id: string, dto: UpdateApiaryRestrictionDto, userId: string): Promise<ApiaryRestriction>; // endsOn >= startsOn auf dem zusammengeführten Stand (400), unter Sperre
findActive(): Promise<ActiveRestrictionInfo[]>; findForApiary(apiaryId: string): Promise<ApiaryRestriction[]>;
// GET /apiary-restrictions?active=true, GET /apiaries/:id/restrictions (apiaries:read); POST, PATCH (compliance:manage)
- Schritt 1: Failing Tests
// restricted-zone.spec.ts
it('active on the start and end day, inactive the day after', ...);
it('an open end date means until further notice', ...);
it('refuses moving into and out of a restricted apiary', ...);
it('override needs compliance:manage and a reason', ...); // 403 ohne Recht
it('a reason without an active restriction is ignored', ...);
it('the 409 body names apiary, disease and start', ...);
// colonies.service.spec.ts
it('split inside the apiary is allowed', ...);
it('merge across apiaries is refused when one is restricted and allowed within one apiary', ...);
it('update with a changed apiary checks old and new apiary, a name change checks nothing', ...);
it('create in a restricted apiary is refused', ...);
it('the override reason is never part of the data written to the colony', ...);
it('an override writes the reason into the audit entry and into ColonyApiaryHistory.notes', ...);
it('the restriction check runs inside the transaction after the apiary rows (share, ascending) and then the colony row are locked', ...); // Reihenfolge der Aufrufe auf einem Fake-tx
it('update, move and merge answer 409 when the colony changed its apiary between the unlocked read and the lock', ...);
it('updateIn called from an inspection takeover locks no apiary', ...);
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)', ...); // e2e, zwei Verbindungen
// apiary-restrictions.service.spec.ts
it('creates one row per apiary in one transaction and rolls all back when one apiary is unknown', ...);
it('rejects more than 50 apiaries and an endsOn before startsOn', ...);
it('ending a restriction is an update of endsOn, nothing is deleted', ...);
// apiary-restrictions-null-contract.spec.ts: endsOn/authority/reference/notes clearable, disease/startsOn notNull; UpdateColonyDto.restrictionOverrideReason clearable
// test/restricted-zone.e2e-spec.ts
it('update with a changed apiary, move, merge across apiaries and create each answer 409 RESTRICTED_ZONE', ...);
it('an override with compliance:manage answers 200, writes audit and the history note', ...);
it('an override without compliance:manage answers 403 and an override reason of 5 characters answers 400', ...);
it('split inside the apiary and a harvest at a restricted apiary succeed', ...);
it('GET /public routes never contain restriction data', ...);
it('a restriction ending yesterday no longer blocks', ...);
it('READONLY may read restrictions but not create them (403)', ...);
- Schritt 2:
npx jest src/compliance src/apiary-restrictions src/coloniesund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
- In
ColoniesService.create/update/move/merge:const { restrictionOverrideReason, ...data } = dto;– nurdatageht an Prisma (sonst „Unknown argument“;UpdateColonyDtoist perPickTypegebaut und bekommt das Feld ausdrücklich). Berechtigung einmal pro Anfrage:canOverride = (await resolver.getPermissions(userId)).has('compliance:manage'). Prüfung innerhalb der Transaktion, nach den Sperren Stand → Volk (beicreatenach der Standsperre, vortx.colony.create), Tag ausdbUtcDay(tx). Sperrmuster (Lock-Reihenfolge, Task 1): (1) Stand-IDs ungesperrt lesen (move/update:colony.apiaryIdplus Ziel;merge: Stände von Überlebendem und allen vereinigten Völkern), (2)lockRowsForShare(tx, 'Apiary', ids)aufsteigend, (3)lockColonybzw.lockRowsForUpdate(tx, 'Colony', …), (4) unter der Sperre prüfen, dassapiaryIdnoch der gelesene ist, sonst 409 „Das Volk wurde inzwischen umgesetzt. Bitte neu laden.“, (5)assertNoActiveRestriction.assertApiaryActiveläuft dabei übertx(heute überthis.prismavor der Transaktion).updateIn(tx, …)sperrt keinen Stand selbst:update()macht Schritte 1–2 und ruftupdateIndanach; der Aufruf ausInspectionsService(unter Volkssperre, Patch ohneapiaryId) bleibt unverändert. move: Quell- und Zielstand;update: alter und neuer Stand nur bei Wechsel;merge: alle beteiligten Stände, nur wenn mehr als ein verschiedener Stand beteiligt ist;split: keine Prüfung.- Override:
notesderColonyApiaryHistory=Ausnahme Sperrbezirk: <Begründung>(beiupdate/movevor ein vorhandenesnotesgestellt), AuditafterDatabekommtrestrictionOverride: { reason, restrictionIds }. ApiaryRestrictionsModuleinjiziert selbst keinen Resolver und wird nicht inWorkerModulebenötigt.- Controller:
GET /apiary-restrictionsundGET /apiaries/:id/restrictionsinApiaryRestrictionsControllermit leerem@Controller()-Präfix und vollen Pfaden; beide@RequirePermission('apiaries:read').
- In
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/restricted-zone.e2e-spec.ts test/colonies.e2e-spec.ts test/apiaries.e2e-spec.ts test/public.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 9: Behördenpaket I – Einstellungen, Bestandsbild, Anzeigen (API)
Files
- Create:
src/compliance/compliance.module.ts,compliance-settings.service.ts(+ Spec),compliance-settings.controller.ts,tsk-day.ts(+ Spec),herd-snapshot.ts(+herd-snapshot.spec.ts),herd-status.ts(+ Spec),authority-notices.service.ts(+ Spec),authority-notices.controller.ts,dto/update-compliance-settings.dto.ts,dto/create-authority-notice.dto.ts,dto/update-authority-notice.dto.ts,dto/mark-sent.dto.ts,dto/notice-list-query.dto.ts,src/compliance/compliance-null-contract.spec.ts - Modify:
src/app.module.ts - Test: neu
test/authority-notices.e2e-spec.ts - Docs: Annahme „Behördenpaket und Sperrbezirk“ (Bezugsstand, Abweichung, Lebenszyklus, Mutex,
totalColoniesist die Zahl der Anzeige und bleibt korrigierbar).
Interfaces
- Consumes: Task 1,
LIVING_COLONY_WHERE,berlinToday/dayToDate(src/common/dates.ts). Tests: Die Test-DB ist nicht leer (Projektregeln): „REGISTRATION nur ohne Bezugsstand“ und „Bezugsstand“ laufen nachauthorityNotice.deleteMany(); Zählungen (totalLiving) vergleicht der Test gegen die Zahl, dieloadHerdApiaryRowsim selben Test liefert. - Produces:
// tsk-day.ts
export function isValidMonthDay(value: string): boolean; // ^\d{2}-\d{2}$, Monat 1-12, Tag im Monat eines Nicht-Schaltjahrs (02-29 abgelehnt)
export function tskReferenceDayOf(year: number, monthDay: string): string; // YYYY-MM-DD
// herd-snapshot.ts
export interface HerdSnapshot { version: 1; takenAt: string; apiaries: { apiaryId: string; name: string; address: string | null; location: string | null;
latitude: number | null; longitude: number | null; livingColonies: number; byType: Partial<Record<ColonyType, number>> }[]; totalLiving: number }
export function buildHerdSnapshot(rows: readonly HerdApiaryRow[], takenAt: Date, excludedTypes?: readonly ColonyType[]): HerdSnapshot; // rein
export function loadHerdApiaryRows(db: DbClient): Promise<HerdApiaryRow[]>; // aktive Stände + lebende Völker (LIVING_COLONY_WHERE)
export interface HerdDeviation { triggered: boolean; colonyCountChanged: boolean; apiariesAdded: string[]; apiariesRemoved: string[]; apiariesRelocated: string[] }
export function herdDeviation(baseline: { snapshot: HerdSnapshot; totalColonies: number }, current: HerdSnapshot,
settings: Pick<ComplianceSettings, 'noticeOnColonyCountChange' | 'noticeOnApiaryChange'>): HerdDeviation; // Zahl: baseline.totalColonies vs current.totalLiving
// herd-status.ts
export interface HerdStatus { settings: ComplianceSettings; baseline: AuthorityNotice | null; current: HerdSnapshot; deviation: HerdDeviation | null } // deviation null ohne Bezugsstand
export function herdStatusIn(db: DbClient, now: Date): Promise<HerdStatus>;
// ComplianceSettingsService
get(): Promise<ComplianceSettings>; // Find-or-create wie SiteSettings
update(dto: UpdateComplianceSettingsDto, userId: string): Promise<ComplianceSettings>; // unter Sperre; Stichtag/Frist über isValidMonthDay, tskExcludedColonyTypes eindeutig,
// netQuantityDigitRules: 1-10 Regeln, maxGrams aufsteigend, letzte null, minMm 0,5-20
// AuthorityNoticesService
create(dto: CreateAuthorityNoticeDto, userId: string): Promise<AuthorityNotice>;
createDraftIn(tx: Prisma.TransactionClient, input: { kind: AuthorityNoticeKind; year?: number; referenceDate: Date }, userId: string | null): Promise<AuthorityNotice>; // gemeinsamer Kern, Task 11 nutzt ihn
recount(id: string, userId: string): Promise<AuthorityNotice>; markSent(id: string, dto: MarkSentDto, userId: string): Promise<AuthorityNotice>;
reopen(id: string, userId: string): Promise<AuthorityNotice>; cancel(id: string, userId: string): Promise<AuthorityNotice>;
update(id: string, dto: UpdateAuthorityNoticeDto, userId: string): Promise<AuthorityNotice>;
findAll(q: { kind?: AuthorityNoticeKind; year?: number }): Promise<AuthorityNotice[]>; findOne(id: string): Promise<AuthorityNotice>;
// Endpunkte nach Spec 7; POST /authority-notices @Idempotent; POST recount | mark-sent | reopen | cancel
- Schritt 1: Failing Tests
// tsk-day.spec.ts
it.each(['02-30', '02-29', '1-1', '13-01', '00-10', 'ab-cd'])('rejects %s', ...);
it('accepts 01-01 and 12-31', ...);
// herd-snapshot.spec.ts
it('no deviation without changes', ...);
it('count change triggers only when enabled', ...);
it('moving between apiaries keeps the total and triggers nothing', ...);
it('new, removed or relocated apiary triggers when enabled', ...);
it('compares the count with the corrected totalColonies of the baseline', ...);
it('excluded colony types do not count', ...);
it('counts only living colonies at active apiaries', ...);
// herd-status.spec.ts
it('no baseline means deviation null, not a herd change', ...);
it('the baseline is the newest SENT registration or herd change by sentOn and ignores TSK reports', ...);
// authority-notices.service.spec.ts
it('one open draft per kind and year', ...);
it('create with sentOn is SENT at once and has no openKey', ...);
it('recount only in DRAFT', ...);
it('herd change needs a baseline and registration needs none', ...);
it('TSK_REPORT needs a year', ...);
it('mark-sent on CANCELLED and recount on SENT answer 409', ...);
it('reopen answers 409 while another draft of the kind exists', ...);
it('every transition locks the notice row and writes an audit entry', ...);
// compliance-settings.service.spec.ts
it('creates defaults on first read', ...);
it('rejects 02-29 as reference day with 400', ...);
it('rejects digit rules that are not ascending or whose last rule has a limit', ...);
// compliance-null-contract.spec.ts: veterinaryOfficeName/Address, registrationNumber, tskReportDueDay, tskMemberNumber clearable; Booleans und tskReferenceDay notNull
// test/authority-notices.e2e-spec.ts
it('two parallel creates of the same draft: one 201, one 409 with existingId', ...);
it('a repeated POST with the same Idempotency-Key returns the stored answer', ...);
it('BEEKEEPER manages notices, STAFF may read but gets 403 on POST', ...);
it('a SENT registration with sentOn in the past is the baseline of the next herd change', ...);
- Schritt 2:
npx jest src/complianceundtest/authority-notices.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren
create: Transaktion, Einstellungszeile per Upsert sicherstellen,lockRowForUpdate(tx, 'ComplianceSettings', 'singleton')als Mutex, Bezugsstand lesen, Regeln nach Global Constraints prüfen, Bestandsbild zählen (loadHerdApiaryRows(tx)→buildHerdSnapshot),openKeynur beiDRAFT; Konflikt (vorhandener Entwurf oderP2002aufopenKey) → 409DRAFT_EXISTSmitexistingId.referenceDateVorgabe:berlinToday()(Annahme 41; kein UTC-Tag).- Zustandswechsel:
lockRowForUpdate(tx, 'AuthorityNotice', id),beforedanach lesen, Übergang prüfen (409 mit deutscher Meldung), schreiben, AuditUPDATEmitbeforeData/afterData.reopensetztopenKeyneu und prüft den Mutex;cancelundmark-sentsetzenopenKey = null. markSentverlangtsentOn(Vorgabe:berlinToday());PATCHänderttotalColonies,referenceDate,sentOn,sentVia,reference,notes(nichtkind/status); aufCANCELLED409.ComplianceModuleexportiert die Services; kein Provider mitPermissionResolverService.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/authority-notices.e2e-spec.ts test/authorization-coverage.e2e-spec.ts test/backup.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 10: Behördenpaket II – PDFs der Anzeigen und des Zählnachweises
Files
- Create:
src/compliance/pdf/authority-notice-model.ts(+ Spec),src/compliance/pdf/authority-notice-pdf.ts(+ Spec) - Modify:
src/compliance/authority-notices.controller.ts(GET :id/pdf),src/compliance/authority-notices.service.ts(pdf) - Test:
test/authority-notices.e2e-spec.ts(ergänzen)
Interfaces
- Consumes: Task 9 (
AuthorityNotice,HerdSnapshot, Services),pdf-table.ts. - Produces:
export interface NoticePdfModel {
kind: AuthorityNoticeKind; title: string; holder: string | null; registrationNumber: string | null; tskMemberNumber: string | null;
office: { name: string | null; address: string | null }; place: string | null; dateLine: string; referenceLine: string;
columns: string[]; rows: { name: string; location: string; cells: string[]; previous: number | null }[]; totals: { current: number; previous: number | null };
changes: string[]; countedNote: string | null; footer: string[];
}
export function buildNoticePdfModel(input: { notice: AuthorityNotice; baseline: AuthorityNotice | null; site: Pick<SiteSettings, 'holderName' | 'holderAddress'>;
settings: Pick<ComplianceSettings, 'veterinaryOfficeName' | 'veterinaryOfficeAddress' | 'registrationNumber' | 'tskMemberNumber'>; now: Date }): NoticePdfModel;
export function renderAuthorityNoticePdf(model: NoticePdfModel): Promise<Buffer>;
AuthorityNoticesService.pdf(id: string): Promise<{ buffer: Buffer; fileName: string }>; // fileName "anzeige-<id>.pdf"
- Schritt 1: Failing Tests
it('prints FEHLT for an empty veterinary office and an empty holder', ...);
it('the first notice has the heading "Anzeige der Bienenhaltung (§ 1a BienSeuchV)" and no "vorher" column', ...);
it('the change notice shows the previous count per apiary and the totals', ...);
it('lists new and abandoned apiaries under "Änderungen seit der Anzeige vom {sentOn}"', ...);
it('prints coordinates with five decimals only when there is no address', ...);
it('the TSK model has one column per counted colony type and a sum', ...);
it('the TSK model says "Gezählt am {countedAt}, nicht am Stichtag. Bitte prüfen." when counted after the reference day', ...);
it('the TSK footer says "Ersetzt nicht die Meldung an die Tierseuchenkasse."', ...);
it('renders a PDF buffer that starts with %PDF for each kind', ...);
// e2e
it('GET /authority-notices/:id/pdf answers application/pdf with Cache-Control no-store and the file name anzeige-<id>.pdf', ...);
it('the pdf route needs compliance:read', ...);
- Schritt 2:
npx jest src/compliance/pdfundtest/authority-notices.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren
- Modell (rein, testbar) und Renderer (pdfkit, A4 hoch,
PDF_MARGIN,drawTable) getrennt; Layout nach Spec 8.1/8.2; fehlende PflichtangabenFEHLT. Fußzeile: „Stand der Daten {updatedAt} · erstellt mit ApiCore“. - Controller setzt
Content-Type: application/pdf,Cache-Control: no-store,Content-Dispositionnur mit der ID.
- Modell (rein, testbar) und Renderer (pdfkit, A4 hoch,
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/authority-notices.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 11: Behördenpaket III – Handler, Meldung ohne Dublette, Regel der Tagesauswertung
Files
- Create:
src/compliance/compliance.handlers.ts(+compliance.handlers.spec.ts) - Modify:
src/notifications/notifications.service.ts(+ Spec; nurrecordForPermissionOnce, die vorhandene privaterecipientswird mitgenutzt),src/jobs/daily-evaluation.rules.ts(+ Spec),src/compliance/compliance.module.ts(Provider),src/worker/worker.module.ts(importiertComplianceModule) - Test:
test/authority-notices.e2e-spec.ts(ergänzen),test/daily-evaluation.e2e-spec.ts - Docs: ARCHITECTURE §10 Handler-Tabelle:
compliance.herd-change,compliance.tsk-reference-day; Meldungen ohne Dublette.
Interfaces
- Consumes: Task 7 (Ereignisse), Task 9 (
herdStatusIn,createDraftIn,tskReferenceDayOf), T4 (recordForUsers, KategorieSYSTEM,DAILY_EVALUATION_RULES). - Produces:
// Die Empfängerabfrage gibt es schon als private Methode `recipients(db, keys, userIds)` (T4); kein `recipientsFor`, keine zweite Kopie:
// recordForPermissionOnce liegt in derselben Klasse und nutzt `recipients` und `fanOut`/`recordForUsers`.
NotificationsService.recordForPermissionOnce(permission: PermissionKey | readonly PermissionKey[],
input: Omit<FanOutNotificationInput, 'dedupeKeyPrefix'> & { relatedType: string; relatedId: string }, tx: Prisma.TransactionClient): Promise<Notification[]>;
// pg_advisory_xact_lock(hashtextextended('notify-once:' || relatedType || ':' || relatedId, 0)), dann `recipients` (Rechte, aktiv), davon die ohne ungelesene Zeile
// derselben relatedType/relatedId (read = false), dann recordForUsers(neueEmpfänger, { ...input, requiredPermissions: <die Empfängerrechte> }) ohne dedupeKeyPrefix
// (die Telegram-Zustellung plant `write` → `planIn` selbst; Wächter no-broadcast-notifications.spec.ts bleibt grün, weil nur notifications.service.ts Zeilen schreibt)
// compliance.handlers.ts
@OnDomainEvent(['ColonyCreated','ColonyMoved','ColonyEnded','ColonyStatusChanged','ApiaryChanged'], 'compliance.herd-change')
herdChange(event: AnyDomainEventEnvelope<…>, tx: Prisma.TransactionClient): Promise<void>; // ColonyStatusChanged nur beim Wechsel lebend <-> beendet
@OnDomainEvent('TskReferenceDayReached', 'compliance.tsk-reference-day')
tskReferenceDay(event: AnyDomainEventEnvelope<'TskReferenceDayReached'>, tx: Prisma.TransactionClient): Promise<void>;
// daily-evaluation.rules.ts: DailyEventType += 'TskReferenceDayReached'; neue Regel in DAILY_EVALUATION_RULES
export const tskReferenceDayRule: DailyRule<'TskReferenceDayReached'>; // occurrences(db: PrismaService, today, { day }): Jahr von ctx.day (Berliner Tag) und Vorjahr, Tag = tskReferenceDayOf(Jahr, settings.tskReferenceDay), Vorgabe 01-01 ohne Einstellungszeile (db.complianceSettings.findUnique({ where: { id: 'singleton' } })); catchUpEvents filtert auf CATCH_UP_DAYS = 7
- Schritt 1: Failing Tests
// compliance.handlers.spec.ts
it('herd-change notifies each permitted user once while unread', ...);
it('ignores a status change within living states and one within ended states', ...);
it('does nothing without a baseline or without deviation', ...);
it('the message uses the exact wording with counts and never an address', ...);
it('tsk handler creates one draft and skips a year already sent', ...);
it('tsk handler with an existing draft changes nothing', ...);
it('tsk handler notifies with WARNUNG and the exact wording', ...);
it('a second handler run for the same event adds no second draft or notification', ...);
// notifications.service.spec.ts
it('recordForPermissionOnce skips users with an unread notification of the same topic', ...);
it('recordForPermissionOnce writes again after the user read the earlier one', ...);
it('recordForPermissionOnce takes the advisory lock before reading the unread rows', ...);
it('recordForPermissionOnce plans the Telegram delivery for a user with SYSTEM chosen and none for a user without', ...);
it('recordForPermission is unchanged', ...);
// daily-evaluation.rules.spec.ts
it('emits TskReferenceDayReached on the configured day', ...);
it('catches up within 7 days exactly once', ...);
it('covers the previous year\'s day in early January', ...);
it('uses 01-01 when no settings row exists', ...);
// handler-contract.spec.ts läuft unverändert und deckt die neue Datei ab
// e2e
it('a new colony changes the herd: after draining one unread notification per permitted user', ...); // Messkriterium 3; "permitted" = die im Test angelegten Nutzer mit compliance:manage, andere Nutzer der Test-DB zählen nicht; Baseline über eine SENT-Anzeige mit totalColonies = aktueller Stand, damit die Abweichung vom selbst gesetzten Stand ausgeht
it('a second colony within one unread notice adds no second notification', ...);
it('creating and sending a change notice removes the deviation; the next colony after reading notifies again', ...);
it('daily evaluation twice on the reference day: one event, one draft', ...);
it('a user without compliance:manage gets no herd notification', ...);
- Schritt 2:
npx jest src/compliance src/notifications src/jobs src/domain-eventsund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
- Meldungen:
category: 'SYSTEM'(T4 hat keinen Wert für Behörden; die Telegram-Kategorienliste bleibt unverändert, Zustellung per Telegram nur für Nutzer, dieSYSTEMgewählt haben, Frage 2),relatedType: 'ComplianceItem',relatedId: 'herd-change'bzw.tsk-report:<Jahr>,link: '/compliance'bzw./compliance/notices/<id>, Empfängercompliance:manage. T4 vergibtdedupeKeydauerhaft eindeutig; hier bewusst keiner, die Dublette steuert nur „ungelesen“. - Handler lesen nur über
tx;herdStatusIn(tx, now)mitnowausSELECT now()übertx.herdChangelegt nie eine Anzeige an. tskReferenceDay:lockRowForUpdate(tx, 'ComplianceSettings', 'singleton'); gibt es für das JahrSENToder einen offenen Entwurf, kehrt der Handler zurück; sonstcreateDraftIn(tx, { kind: 'TSK_REPORT', year, referenceDate }, null)und Meldung.- Regel: je Jahr eine
DailyOccurrencemitaggregateType: 'Compliance',aggregateId: 'tsk:<Jahr>',payload: { year, referenceDay: <Tag> }; Schlüssel übercatchUpEvents→dailyDedupeKey. WorkerModuleimportiertComplianceModule;AppModulehat es seit Task 9 (Worker = API: der Dispatcher findet die Handler per DI-Entdeckung in beiden Wurzeln;test/worker-runtime.e2e-spec.tsunddomain-event-registry.spec.tssollencompliance.herd-changeundcompliance.tsk-reference-dayin beiden Kontexten sehen). WederComplianceModulenoch ein davon importiertes Modul darfPermissionsModuleimportieren; der Worker hat nurJobPermissionsModule.
- Meldungen:
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/authority-notices.e2e-spec.ts test/daily-evaluation.e2e-spec.ts test/notifications.e2e-spec.ts test/domain-event-dispatcher.e2e-spec.ts test/worker-runtime.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 12: Prüfbereitschaft I – Gerüst, Quittungen, Punkte Stammdaten und Behörden
Files
- Create:
src/compliance/readiness/readiness.types.ts,fingerprint.ts(+ Spec),readiness-context.ts,readiness.service.ts(+ Spec),readiness.controller.ts,readiness.module.ts,acknowledgements.service.ts(+ Spec),dto/create-acknowledgement.dto.ts,rules/index.ts,rules/holder-data.rule.ts,food-business-data.rule.ts,vet-office.rule.ts,registration.rule.ts,herd-change.rule.ts,tsk-report.rule.ts,restriction-active.rule.ts(je +.rule.spec.ts) - Modify:
src/app.module.ts(EintragReadinessModule) - Test: neu
test/compliance-readiness.e2e-spec.ts - Docs: neue Annahme „Prüfbereitschaft“ (live berechnet, Quittung gilt nur bei passendem Fingerabdruck, jeder offene Punkt rot).
Interfaces
- Consumes: Task 8 (Sperren), Task 9 (
herdStatusIn, Anzeigen, Einstellungen), Task 11 (ComplianceModulekomplett). - Produces:
export type ReadinessSeverity = 'PFLICHT' | 'PRUEFEN';
export type ReadinessArea = 'STAMMDATEN' | 'BEHOERDEN' | 'TIERARZNEIMITTEL' | 'ETIKETTEN' | 'ERNTE';
export interface ReadinessSubject { type: string; id: string; label: string; missing?: string[]; parentId?: string } // parentId: z. B. colonyId einer Behandlung (Link-Ziel; Erweiterung der Spec)
export interface ReadinessFinding { subjectId: string; title?: string; detail: string; fingerprintParts: unknown; subjects: ReadinessSubject[]; more?: number }
export interface ReadinessRule { readonly key: string; readonly area: ReadinessArea; readonly severity: ReadinessSeverity; readonly title: string;
readonly legalBasis: string | null; readonly acknowledgeable: boolean; readonly subjectPermission?: PermissionKey; evaluate(ctx: ReadinessContext): ReadinessFinding[] }
export interface ReadinessItem { key: string; area: ReadinessArea; severity: ReadinessSeverity; subjectId: string; title: string; detail: string; legalBasis: string | null;
acknowledgeable: boolean; fingerprint: string; subjects: ReadinessSubject[]; more: number;
state: 'OPEN' | 'DONE' | 'ACKNOWLEDGED'; acknowledgement: { id: string; note: string; userName: string; createdAt: string } | null }
export interface ReadinessReport { status: 'READY' | 'OPEN'; generatedAt: string; openCount: number; items: ReadinessItem[] }
export interface ReadinessContext { now: Date; today: string; site: SiteSettings; settings: ComplianceSettings; herd: HerdStatus;
notices: AuthorityNotice[]; restrictions: (ApiaryRestriction & { apiary: { id: string; name: string } })[] } // Task 13 ergänzt weitere Felder
export function fingerprintOf(parts: unknown): string; // sha256 (hex) des kanonischen JSON (sortierte Schlüssel)
ReadinessService.evaluate(now: Date, permissions: ReadonlySet<string>): Promise<ReadinessReport>;
ReadinessService.summary(now: Date): Promise<{ status: 'READY' | 'OPEN'; openCount: number; firstItemKey: string | null }>;
AcknowledgementsService.create(dto: { itemKey: string; subjectId: string; fingerprint: string; note: string }, userId: string): Promise<ReadinessItem>;
AcknowledgementsService.revoke(id: string, userId: string): Promise<void>;
// GET /compliance/readiness, GET /compliance/readiness/summary (compliance:read); POST /compliance/acknowledgements (@Idempotent), POST …/:id/revoke (compliance:manage)
export const READINESS_RULES: readonly ReadinessRule[]; // rules/index.ts; Task 13 hängt die übrigen elf an
- Schritt 1: Failing Tests
// readiness.service.spec.ts
it('is READY when no rule has an open finding', ...);
it('a rule without findings yields one DONE item with subjectId global', ...);
it('overall status is OPEN as long as one item is OPEN, an ACKNOWLEDGED item does not count', ...);
it('limits subjects to 50 and reports the rest in more', ...);
it('hides subject labels without the rule subjectPermission and keeps the count', ...);
it('summary names the first open PFLICHT item, else the first open item', ...);
// fingerprint.spec.ts
it('is stable for the same parts regardless of key order and differs when a value changes', ...);
// acknowledgements.service.spec.ts
it('refuses a stale fingerprint with 409', ...);
it('refuses a PFLICHT item and an unknown item key with 400', ...);
it('a note under 10 characters is a 400', ...);
it('a revoked acknowledgement reopens the item', ...);
it('a new fingerprint after changed data reopens an acknowledged item', ...);
it('revoke locks the acknowledgement row and refuses an already revoked one', ...);
// rules/*.rule.spec.ts – je Regel: offen, erledigt (und quittiert, wo acknowledgeable)
// holder-data: open when name or address is empty | food-business-data: open when either is empty | vet-office: PRUEFEN, open without office name
// registration: open without a SENT REGISTRATION | herd-change: open when the deviation is triggered, not without a baseline
// tsk-report: open from the reference day without a SENT report; "überfällig" after tskReportDueDay; a DRAFT of last year keeps last year's item open
// restriction-active: one finding listing the apiaries with an active restriction
// test/compliance-readiness.e2e-spec.ts
it('without site data and without a SENT registration the Stammdaten and registration items are open and the status is OPEN', ...); // T6-Tabellen vorher leeren, SiteSettings im Test setzen/leeren; nichts über fremde Daten behaupten
it('complete master data and a SENT registration make those items DONE', ...);
it('READONLY may read the report but gets 403 acknowledging; STAFF gets 403 on acknowledgements; API_CLIENT gets 403 everywhere', ...);
- Schritt 2:
npx jest src/compliance/readinessundtest/compliance-readiness.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren
todaydes Kontexts istberlinToday(); „Stichtag erreicht“, „überfällig“ und die Rückblickfenster rechnen mit Berliner Tagen (addDays,daysBetween); nurrestriction-activenutzt denselben Tag wie der Wächter (dbUtcDay, Frage 1), damit Anzeige und Sperre nie auseinanderlaufen.evaluate: Kontext einmal laden (begrenzt, nur benötigte Spalten), jede Regel ausführen, Findings zu Items (fingerprint = fingerprintOf({ key, subjectId, parts })), gültige Quittung (gleicheritemKey,subjectId,fingerprint,revokedAtnull; neueste gewinnt) →ACKNOWLEDGED. Regeln ohne Findings → einDONE-Item mit dem Regeltitel.- Maskierung: Hat der Aufrufer
rule.subjectPermissionnicht, bleibensubjectsleer undmore= Anzahl; Titel und Detailtexte der Regeln enthalten keine Namen. - Quittieren: Item aus der aktuellen Auswertung nehmen (
key+subjectId),acknowledgeableprüfen, Fingerabdruck vergleichen, in einer Transaktion schreiben mit AuditCREATE(entityType: 'ComplianceAcknowledgement').revoke: Sperre auf der Quittung,revokedAt = now()(SQL),revokedByUserId, Audit. - Detailtexte deutsch, ein kurzer Satz;
legalBasisaus der Spec-Tabelle.ReadinessModuleimportiertComplianceModule.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/compliance-readiness.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Task 13: Prüfbereitschaft II – Punkte Tierarzneimittel, Etiketten, Ernte, Prüfbericht
Files
- Create:
src/compliance/readiness/rules/treatment-log-gaps.rule.ts,treatment-location-uncertain.rule.ts,lot-gaps.rule.ts,lot-prescription.rule.ts,medicine-withdrawal-missing.rule.ts,lot-expired-stock.rule.ts,lot-negative.rule.ts,treatment-expired-lot.rule.ts,label-template-gaps.rule.ts,label-template-form.rule.ts,harvest-withdrawal.rule.ts(je +.rule.spec.ts),src/compliance/readiness/readiness-report.ts(+ Spec),src/compliance/readiness/loaders/treatments.loader.ts,lots.loader.ts,labels.loader.ts,harvests.loader.ts(je + Spec) - Modify:
src/compliance/readiness/readiness-context.ts,rules/index.ts,readiness.controller.ts(report.pdf),readiness.module.ts - Test:
test/compliance-readiness.e2e-spec.ts(ergänzen);test/export.e2e-spec.ts(Lücken-Helfer und PDF stimmen überein)
Interfaces
- Consumes: Task 3 (
resolveSourceValues,treatmentLogGaps,groupTreatmentLog), Task 5/6 (checkLabel,label-check.loader), Task 2 (summarizeLot), T4 (withdrawalWindowOf,isInWindow),summarizeStock, Task 12. - Produces:
// ReadinessContext += treatments (letzte 6 Kalenderjahre, nur benötigte Spalten), moves, lots (mit Rest), medicines (aktive), templates (aktive) mit Prüfziel, harvests (letzte 2 Jahre) mit Wartezeit-Fenstern
export const HARVEST_WITHDRAWAL_LOOKBACK_YEARS = 2;
export const TREATMENT_LOOKBACK_CALENDAR_YEARS = 6; // laufendes Jahr + 5
// Schlüssel – Bereich – Schwere – quittierbar – subjectPermission:
// treatment-log-gaps TIERARZNEIMITTEL PFLICHT nein 'treatments:read' (subjectId = Jahr) | treatment-location-uncertain PRUEFEN nein 'treatments:read'
// lot-gaps PFLICHT nein 'veterinary-medicines:read' | lot-prescription PFLICHT nein | medicine-withdrawal-missing PRUEFEN nein
// lot-expired-stock PRUEFEN ja (subjectId = Packung) | lot-negative PRUEFEN nein | treatment-expired-lot PRUEFEN ja (subjectId = Behandlung)
// label-template-gaps ETIKETTEN PFLICHT nein (subjectId = Vorlage) | label-template-form PRUEFEN ja (subjectId = Vorlage)
// harvest-withdrawal ERNTE PRUEFEN ja 'treatments:read' (subjectId = Ernte)
export function renderReadinessReportPdf(report: ReadinessReport, holder: { name: string | null; address: string | null }): Promise<Buffer>;
// GET /compliance/readiness/report.pdf (compliance:read), Datei pruefbericht.pdf, no-store
- Schritt 1: Failing Tests
// rules/treatment-log-gaps.rule.spec.ts
it('opens one item per year with gap rows', ...);
it('reports a lot with missing supplier once as a lot subject, not once per treatment', ...);
it('reports treatments without a lot as treatment subjects with the missing fields and the colony as parentId', ...);
it('the gap codes equal the codes treatmentLogGaps reports for the same row', ...); // Übereinstimmung mit dem PDF
it('requires the vet only for prescription-only medicines', ...);
it('looks back over 6 calendar years and not further', ...);
// weitere Regeln je offen | erledigt | quittiert
// treatment-location-uncertain: row flagged with * | lot-gaps: lot without supplier or proof | lot-prescription: prescription-only medicine, lot without vet
// medicine-withdrawal-missing: active medicine without withdrawal | lot-expired-stock: expired with rest > 0, acknowledgeable per lot, new fingerprint when the rest changes
// lot-negative: rest < 0, not acknowledgeable | treatment-expired-lot: treatment date after expiry, acknowledgeable per treatment
// label-template-gaps: uses the newest bottling with remaining stock, else the newest, else the sample
// label-template-form: warnings only, acknowledgeable, new fingerprint when the warning codes change
// harvest-withdrawal: harvest inside an active window of a participating colony; harvest after the window is clean; window logic equals withdrawalWindowOf
// readiness.service.spec.ts
it('hides subject labels of the medicine area without veterinary-medicines:read and keeps the count', ...);
// readiness-report.spec.ts
it('lists status, areas, items with severity word, legal basis and acknowledgement note, user and date', ...);
it('prints FEHLT for an empty holder', ...);
// e2e
it('complete data in every area leaves no open item for the subjects of this test and no open item of the Stammdaten, Behörden and Etiketten rules', ...); // Messkriterium 4; die ganze Test-DB kann fremde Lücken enthalten, darum Prüfung je key + subjectId; der Gesamtstatus READY wird als Unit-Test von ReadinessService mit leerem Kontext belegt (Task 12)
it('a treatment from a complete lot gives a Bestandsbuch row without FEHLT and no treatment-log-gaps item', ...); // Messkriterium 1
it('GET /compliance/readiness/report.pdf answers application/pdf and no-store', ...);
it('a negative rest after a treatment from a too small lot opens lot-negative', ...);
- Schritt 2:
npx jest src/compliance/readinessund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
treatment-log-gaps: dieselbe Funktionskette wie das PDF (resolveSourceValues→groupTreatmentLog→treatmentLogGaps); je Quelle ohne Packung Subjekttreatment(parentId=colonyId), mit Packung und nur Packungsfeldern Subjektlot.missingnennt deutsche Feldnamen („Lieferant“, „Beleg“, „Menge“, „Wartezeit“, „Tierarzt“).harvest-withdrawalbaut keine zweite Fensterlogik: Fenster überwithdrawalWindowOf(treatment)/isInWindow(window, berlinDayOf(harvest.date)), Konflikte der Ernten überWithdrawalCheckService.conflictsForHarvests(src/withdrawal/), Text überwithdrawalRangeText/lastBlockedDay(src/withdrawal/withdrawal-text.ts); der Regel-Test vergleicht gegen genau diese Helfer. BerührtReadinessModuleden Dienst, importiert esWithdrawalModule.- Lader: Behandlungen mit
selectder benötigten Spalten unddate >= 1. Januar (Jahr − 5); Packungen mit einer Gruppen-Abfrage der Bewegungssummen; Ernten abheute − 2 Jahre; Vorlagen mit der Zielabfüllung (jüngste mit Restbestand übersummarizeStockderBatchMovement, sonst jüngste, sonstSAMPLE_BOTTLING). label-template-*:checkLabelmit den Textelementen der gespeicherten Vorlage (Bild-, Linien- und QR-Elemente bleiben außen vor).- Bericht: A4 hoch, Kopf mit Halter und Datum, Gesamtstatus, je Bereich eine Tabelle (Status „Offen“/„Erledigt“/„Quittiert“, Schwere, Titel, Norm, bei Quittungen Notiz, Nutzer, Datum).
- Schritt 4: → PASS.
- Schritt 5: Verifikation
- typecheck, lint,
npm test -- --ci npx jest --config ./test/jest-e2e.json test/compliance-readiness.e2e-spec.ts test/export.e2e-spec.ts test/label-compliance.e2e-spec.ts --runInBand→ PASS- kein Commit
- typecheck, lint,
Lieferung T6b – Frontend
Gemeinsam für alle Frontend-Tasks: Vor Next.js-Code node_modules/next/dist/docs/ lesen. Die Typen spiegeln die Backend-Interfaces der Tasks 2–13 (Namen gleich). Server-Fehler 409/403 zeigen die deutsche Meldung des Servers. Offline gilt die Behandlungs-Warteschlange aus T3; Packungsbuchungen entstehen erst beim Synchronisieren auf dem Server.
Task 14: Prüfbereitschaft – Typen, Seite, Quittieren, Navigation, Dashboard-Zeile
Files
- Create:
src/lib/compliance/types.ts,src/lib/compliance/api.ts(+ Spec),src/lib/compliance/fix-links.ts(+ Spec),src/lib/compliance/use-readiness.ts,src/app/(app)/compliance/page.tsx(+page.spec.tsx),src/app/(app)/compliance/readiness-item-card.tsx(+ Spec),src/app/(app)/compliance/acknowledge-dialog.tsx(+ Spec),src/components/today/compliance-line.tsx(+ Spec) - Create (zusätzlich):
src/lib/compliance/errors.ts(+ Spec; liest die 409-KörperDRAFT_EXISTS→existingId,RESTRICTED_ZONE→restrictions, veralteter Fingerabdruck; baut aufdescribeSaveError/saveErrorTextaussrc/lib/honey-batches/errors.tsauf, sieheconfirmationRequiredals Vorbild, keine zweite Fehlerlogik) - Modify:
src/app/(app)/nav-config.ts(+nav-config.spec.ts),src/app/(app)/dashboard/page.tsx(die Zeile),src/lib/system/labels.ts(+labels.spec.ts:DOMAIN_EVENT_LABELS+=ColonyCreated„Volk angelegt“,ColonyMoved„Volk umgesetzt“,ApiaryChanged„Stand geändert“,TskReferenceDayReached„TSK-Stichtag erreicht“),public/sw-routes.js+public/sw.js+src/lib/offline/sw-routes.spec.ts(siehe unten)
Interfaces
- Consumes: Task 12/13 (
ReadinessReport,ReadinessItem,ReadinessSubject),DetailHeader,FormSection,ConfirmDialog. - Produces:
export type { ReadinessReport, ReadinessItem, ReadinessSubject, ReadinessSeverity, ReadinessArea }; // types.ts
export function fetchReadiness(): Promise<ReadinessReport>; export function fetchReadinessSummary(): Promise<{ status: 'READY' | 'OPEN'; openCount: number; firstItemKey: string | null }>;
export function acknowledge(input: { itemKey: string; subjectId: string; fingerprint: string; note: string }): Promise<void>; export function revokeAcknowledgement(id: string): Promise<void>;
export function fixLinkFor(itemKey: string, subject: { type: string; id: string; parentId?: string } | null): { href: string; label: string }; // die EINE Stelle, die Subjekte auf Seiten abbildet
// Zuordnung: lot -> /medicines/lots/{id}; treatment -> /colonies/{parentId}?editTreatment={id}; template -> /settings/label-templates/{id}; harvest -> /harvests/{id};
// notice -> /compliance/notices/{id}; apiary/restriction -> /compliance/restrictions; global je itemKey: holder-data, food-business-data -> Stammdaten & Web (Verwaltung > Automatisierung);
// vet-office -> /compliance/settings; registration, herd-change, tsk-report -> /compliance/notices; medicine-withdrawal-missing -> /medicines
-
Nav: Eintrag „Prüfbereitschaft“ (Symbol
ShieldCheck, Gruppe „Betrieb“, Rechtcompliance:read). -
Service Worker (nie zwischenspeichern). Belege, Anschriften, Koordinaten und Aktenzeichen dürfen nicht im Cache des Geräts landen (gleiche Begründung wie
/apiaries,/export/,/backup).NEVER_CACHED_API_PATHSinpublic/sw-routes.jsbekommt (Tests insw-routes.spec.tszuerst):/api/v1/compliance(/|$),/api/v1/authority-notices(/|$),/api/v1/apiary-restrictions(/|$),/api/v1/apiaries/[^/]+/restrictions/?$,/api/v1/veterinary-medicines(/|$),/api/v1/veterinary-medicine-lots(/|$),/api/v1/documents(/|$)(Anhänge zu Packung, Anzeige und Verfügung; hat der Datei-Download eine andere Wurzel, die prüfen) und/api/v1/label-templates/[^/]+/check/(enthält Anschrift). DanachSW_ROUTES_VERSIONinpublic/sw.jsauf den neuen Inhalts-Hash heben (der Specsw-routes.spec.tsprüft, dass die Version zum Inhalt passt). Die Formulare der Tasks 15 bis 19 laufen online; die Offline-Warteschlange der Behandlung (Task 16) bleibt unberührt. -
Wiederverwenden statt neu bauen (gilt für 14 bis 19):
describeSaveError/saveErrorText(Fehlerzeile),PrefillStatus(Task 16, Vorbelegung aus dem Katalog; Speichern bleibt erlaubt, Annahme 53),ConfirmDialogmittriggerDescribedBy/triggerDisabled(Rückfragen, Task 19),ToggleChipmitdisabled(Mehrfachauswahl der gezählten Völkerarten in Task 18),DetailHeader,FormSection,ChoiceGroup,NumberStepper,RecordSelect,DocumentUploadCard.StepUpDialogundrefreshOn401: falsesind für Nachweis-Aufrufe (Passwort, Backup-Import, Telegram) da; die Spec verlangt für die T6 keinen Nachweis (siehe Frage 5), also hier nicht einbauen. -
Schritt 1: Failing Tests
// page.spec.tsx
it('shows Prüfbereit in green when nothing is open and offers Bestandsbuch drucken as the main action', ...);
it('shows "3 offen" in red with the symbol and the word Offen for every open item', ...);
it('the main action jumps to the first open PFLICHT item', ...);
it('shows the severity as a word, Pflicht or Prüfen, next to the title', ...);
it('lists affected records collapsed with a Beheben link each', ...);
it('shows Erledigt (n) collapsed', ...);
it('shows the more-count when subjects are cut off', ...);
// acknowledge-dialog.spec.tsx
it('asks for a note of at least 10 characters before it submits', ...);
it('offers Quittieren only for acknowledgeable items and Zurücknehmen for acknowledged ones', ...);
it('shows the server message on a 409 stale fingerprint and reloads the report', ...);
// fix-links.spec.ts
it.each([...])('maps %s to %s', ...); // jede Zeile der Zuordnung oben
// compliance-line.spec.tsx
it('renders only when openCount > 0 and the user holds compliance:read', ...);
// nav-config.spec.ts
it('shows Prüfbereitschaft only with compliance:read', ...);
// sw-routes.spec.ts
it.each([...die neuen Pfade])('never caches %s', ...); // inkl. Großschreibung, Schrägstrich am Ende, Query
it('keeps caching the unrelated routes it cached before', ...);
it('SW_ROUTES_VERSION in sw.js matches the content of sw-routes.js', ...); // bestehender Test, bleibt grün
// labels.spec.ts
it('DOMAIN_EVENT_LABELS names ColonyCreated, ColonyMoved, ApiaryChanged and TskReferenceDayReached', ...);
// errors.spec.ts
it('reads DRAFT_EXISTS with existingId, RESTRICTED_ZONE with its restrictions and a stale fingerprint', ...);
- Schritt 2:
npx vitest run src/lib/compliance src/lib/offline src/lib/system "src/app/(app)/compliance" src/components/today "src/app/(app)/nav-config.spec.ts"→ FAIL. - Schritt 3: Implementieren
- Seite
/compliancenach Spec 9: Status oben, Punkte nach Bereich (fünf Abschnitte), offene Punkte rot mit Symbol + „Offen“ + Schwere-Chip + Titel + eine Zeile Detail + Norm klein; „Mehr“-Menü mit Links auf Bericht (PDF, authentifizierter Download über den vorhandenen Export-Helfer), Bestandsbuch,/compliance/notices,/compliance/restrictions,/compliance/settings. - Hauptaktion „Nächsten Punkt lösen“ springt auf das
fixLinkFordes ersten offenenPFLICHT-Punkts; alles grün: „Bestandsbuch {Jahr} drucken“. - Links auf Seiten, die spätere Tasks liefern, sind einfache
Links; die Seiten entstehen in den Tasks 15, 18, 19.
- Seite
- Schritt 4: → PASS.
- Schritt 5: Verifikation
npm run lint && npm run typecheck && npx vitest run src/lib/compliance src/lib/offline src/lib/system "src/app/(app)/compliance" src/components/today "src/app/(app)/nav-config.spec.ts"→ PASS- Browser: Desktop und 390 px, Leerzustand „Prüfbereit“, Tastatur (Tab-Reihenfolge, sichtbarer Fokus),
prefers-reduced-motion - kein Commit
Task 15: Tierarzneimittel-Katalog, Packungen, Bewegungsbuch, Lager-Karte
Files
- Create:
src/lib/medicines/types.ts,src/lib/medicines/api.ts(+ Spec),src/app/(app)/medicines/page.tsx(+ Spec),src/app/(app)/medicines/medicine-form-dialog.tsx(+ Spec),src/app/(app)/medicines/[id]/page.tsx,src/app/(app)/medicines/[id]/lot-form-dialog.tsx,src/app/(app)/medicines/lots/[id]/page.tsx,src/app/(app)/medicines/lots/[id]/inventory-dialog.tsx,src/app/(app)/medicines/lots/[id]/disposal-dialog.tsx(je + Spec) - Modify:
src/app/(app)/storage/page.tsx(Karte „Tierarzneimittel“ mit Link auf/medicines),src/lib/documents/types.ts(DocumentEntityType+=MEDICINE_LOT|AUTHORITY_NOTICE|APIARY_RESTRICTION, einmal für 15, 18, 19; Spec insrc/lib/documents) - Fehlerzeilen über
saveErrorText/describeSaveError(src/lib/honey-batches/errors.ts), nicht neu schreiben.
Interfaces
- Consumes: Task 2 (
MedicineView,LotView,LotDetailView,MedicineSuggestion),DocumentUploadCard(DokumentartMEDICINE_LOT),ChoiceGroup,NumberStepper. - Produces:
export function fetchMedicines(active?: boolean): Promise<MedicineView[]>; export function fetchMedicineSuggestions(): Promise<MedicineSuggestion[]>;
export function fetchMedicine(id: string): Promise<MedicineView & { lots: LotView[] }>; export function createMedicine(input: MedicineInput): Promise<MedicineView>;
export function updateMedicine(id: string, input: Partial<MedicineInput>): Promise<MedicineView>; export function setMedicineActive(id: string, active: boolean): Promise<MedicineView>;
export function fetchSelectableLots(medicineId: string): Promise<LotView[]>; // Task 16 nutzt sie
export function fetchLot(id: string): Promise<LotDetailView>; export function createLot(input: LotInput): Promise<LotView>; export function updateLot(id: string, input: Partial<LotInput>): Promise<LotView>;
export function bookCorrection(lotId: string, actualRemaining: number): Promise<LotDetailView>; export function bookDisposal(lotId: string, quantity: number, reason: string): Promise<LotDetailView>;
- Schritt 1: Failing Tests
it('the empty catalogue offers "Aus deinen Behandlungen übernehmen?" with the suggestions', ...);
it('applying a suggestion opens the form prefilled with the values of the latest treatment', ...);
it('lists name, rest and next expiry per medicine', ...);
it('hides the unit field once lots exist and shows the server message "Einheit ist festgelegt, sobald Packungen erfasst sind"', ...);
it('the lot page shows the rest, the movement book and the receipt upload card', ...);
it('Inventur asks for the actual rest and shows the delta before saving', ...);
it('Entsorgung needs a reason', ...);
it('a negative rest is shown in red with the word Negativ', ...);
it('the main action is Mittel anlegen on the list, Packung erfassen on a medicine and Inventur on a lot', ...);
it('hides create buttons without veterinary-medicines:create', ...);
it('the storage page shows the Tierarzneimittel card only with veterinary-medicines:read', ...);
- Schritt 2:
npx vitest run src/lib/medicines "src/app/(app)/medicines" "src/app/(app)/storage"→ FAIL. - Schritt 3: Implementieren
- Seiten nach Spec 9 (Zeile
/medicines…); Felder 44 px, seltene Felder (Temperaturfenster, Dosis-Hinweis, Notizen) inFormSectioneingeklappt; Beleg-Upload überDocumentUploadCardmit DokumentartMEDICINE_LOT. Kein eigener Nav-Eintrag. - Anlege-Anfragen tragen einen
Idempotency-Keywie die anderen Formulare.
- Seiten nach Spec 9 (Zeile
- Schritt 4: → PASS.
- Schritt 5: Verifikation
npm run lint && npm run typecheck && npx vitest run src/lib/medicines "src/app/(app)/medicines" "src/app/(app)/storage"→ PASS- Browser: Desktop und 390 px, Leer- und Fehlerzustand, Tastatur
- kein Commit
Task 16: Behandlungsformular und Sammelerfassung mit Mittel, Packung und Dosis
Files
- Create:
src/components/treatments/medicine-lot-fields.tsx(+ Spec),src/lib/treatments/booking-line.ts(+ Spec),src/lib/treatments/temperature-hint.ts(+ Spec) - Modify:
src/app/(app)/treatments/new/page.tsx(+ Spec),src/app/(app)/treatments/new/treatment-form-values.ts,src/lib/treatments/types.ts,src/lib/treatments/api.ts,src/app/(app)/colonies/[id]/treatment-edit-dialog.tsx,src/app/(app)/colonies/[id]/page.tsx(nur?editTreatment=<id>öffnet den Dialog)
Interfaces
- Consumes: Task 3 (DTO-Felder), Task 15 (
fetchMedicines,fetchSelectableLots), T3 (applyPrefill,PrefillNote,useCaptureMutation, Sammelerfassung). - Produces:
export function bookingLine(dose: number | null, unit: MedicineUnit | null, colonyCount: number): string | null; // "Bucht 30 ml je Volk ab (× 6 = 180 ml)."; null ohne Packung oder Dosis; deutsches Dezimalkomma
export function temperatureHint(today: { min: number | null; max: number | null }, window: { min: number | null; max: number | null }): { text: string; warn: boolean } | null; // "Heute 9–17 °C. Laut Katalog 12–25 °C."; warn, wenn außerhalb
// TreatmentFormValues += medicineId, medicineLotId, doseQuantity; Payload lässt supplier/purchaseProof/batchNumber/prescribingVet weg, sobald eine Packung gewählt ist (Server lehnt sie sonst mit 400 ab)
- Schritt 1: Failing Tests
it('offers the catalogue medicines and the last option "Anderes Mittel" with the free-text field as today', ...);
it('shows only "Anderes Mittel" without veterinary-medicines:read and raises no error', ...);
it('a catalogue medicine prefills ingredient, withdrawal, method and dose with the note "aus Katalog"', ...);
it('the catalogue prefill wins over the last-treatment prefill for the same field', ...);
it('preselects the oldest lot and says so in a note', ...);
it('shows lots as "Ch.-B. 4711 · Rest 180 ml · verw. bis 03/2027"', ...);
it('replaces supplier, proof and vet by one line "aus Packung: …" when a lot is chosen', ...);
it('shows the booking line above Speichern, multiplied by the number of colonies in bulk mode', ...);
it('warns before saving when the lot is expired or too small, but does not block', ...);
it('shows the temperature hint with a warning icon when today is outside the catalogue window', ...);
it('sends medicineId, medicineLotId and doseQuantity and omits the lot-sourced text fields', ...);
it('bulk mode sends one POST /treatments/bulk with the shared medicine fields', ...);
it('the colony page opens the edit dialog for ?editTreatment=<id>', ...);
// booking-line.spec.ts / temperature-hint.spec.ts: die Beispiele der Interfaces, null-Fälle, Grenzwerte inklusive
- Schritt 2:
npx vitest run src/components/treatments src/lib/treatments "src/app/(app)/treatments" "src/app/(app)/colonies"→ FAIL. - Schritt 3: Implementieren
- Ladezustand und Fehler der Katalog-Vorbelegung zeigt
PrefillStatus(src/components/warnings/prefill-status.tsx) wie bei der Behandlungs-Vorbelegung aus T3; Speichern bleibt erlaubt (Annahme 53). Das MusterPrefillNote/useCaptureMutation/applyPrefillaus T3 gilt unverändert, die Katalog-Vorbelegung kommt als weitere Quelle dazu. - Die Vorbelegung bleibt sichtbar im Formular (Server füllt nie still nach). Das Wetter für den Temperaturhinweis kommt aus
GET /apiaries/:id/weather(apiaries:read); fehlt es, entfällt der Hinweis ohne Fehler. - Dosis im
NumberSteppermit Einheit des Mittels; Offline wie T3 (Warteschlange); Idempotency-Key unverändert.
- Ladezustand und Fehler der Katalog-Vorbelegung zeigt
- Schritt 4: → PASS.
- Schritt 5: Verifikation
npm run lint && npm run typecheck && npx vitest run src/components/treatments src/lib/treatments "src/app/(app)/treatments" "src/app/(app)/colonies"→ PASS- Browser: Behandlung mit Packung (Desktop und 390 px), Sammelerfassung, Tastatur
- kein Commit
Task 17: Etikett – Pflichtangaben-Panel, Druckkarte, Stammdaten, Produkt
Files
- Create:
src/lib/label-templates/check.ts(+ Spec; TypenLabelCheckResult, Funktionen, Hook),src/lib/label-templates/placeholder-keys.ts(+placeholder-keys.spec.ts),src/lib/label-templates/compliance-block.ts(+ Spec),src/components/labels/label-compliance-panel.tsx(+ Spec) - Modify:
src/app/(app)/settings/label-templates/designer/label-designer.tsx(+ Spec; nur Panel einhängen, Feld „Mindest-x-Höhe“, Knopf „Pflichtangaben-Block einfügen“),src/app/(app)/bottling/[id]/print-labels-card.tsx(+ Spec),src/lib/label-templates/types.ts,src/lib/label-templates/api.ts,src/app/(app)/settings/automation/social-media-tab.tsx(+ Spec; Felder Lebensmittelunternehmer und „Vom Bienenhalter übernehmen“),src/app/(app)/products/*(Formular: „Bezeichnung auf dem Etikett (z. B. Blütenhonig)“,legalName),src/lib/products/*(Typ)
Interfaces
- Consumes: Task 5 (
LabelCheckResult,PLACEHOLDER_KEYS,ApiCore-Backend/test/fixtures/placeholder-keys.json), Task 6 (Endpunkte). - Produces:
export function checkDraftTemplate(input: { elements: LabelElement[]; widthMm: number; heightMm: number; minXHeightMm: number; bottlingBatchId?: string }): Promise<LabelCheckResult>; // filtert auf Text-Elemente
export function checkTemplateForBottling(templateId: string, bottlingBatchId: string): Promise<LabelCheckResult>;
export function useDebouncedLabelCheck(draft: DraftInput, delayMs?: number): { result: LabelCheckResult | null; pending: boolean; error: string | null }; // 500 ms
export function buildComplianceBlock(template: { widthMm: number; heightMm: number }, requirements: LabelCheckResult['requirements']): LabelTextElement[]; // alle neuen Platzhalter, Schriftgrößen aus requirements, keine Rechtswerte im Frontend
export function printLabels(templateId: string, bottlingBatchId: string, options: { startPosition?: number; proof?: boolean }): Promise<Blob>; // wirft ApiError mit body.gaps bei 409
- Schritt 1: Failing Tests
// placeholder-keys.spec.ts
it('the frontend list equals the backend fixture ApiCore-Backend/test/fixtures/placeholder-keys.json', ...); // Monorepo-Pfad, per fs gelesen
// label-compliance-panel.spec.tsx
it('shows each particular with a check mark or a cross and the word Erfüllt or Fehlt', ...);
it('lists warnings separately and announces changes via aria-live polite', ...);
it('shows "Beispielwerte" when the result is a sample', ...);
// compliance-block.spec.ts
it('inserts one text element per new placeholder, sized to meet the required x-height', ...);
it('uses the digit height requirement for the net quantity element when present', ...);
// label-designer.spec.tsx
it('debounces the check by 500 ms and sends text elements only', ...);
it('Pflichtangaben-Block einfügen adds the block and Rückgängig removes it again', ...);
it('the minimum x-height field accepts 0.5 to 5 and mentions 0,9 mm for small jars', ...);
// print-labels-card.spec.tsx
it('the main button is aria-disabled with the reason in aria-describedby while a particular is missing, and stays focusable', ...);
it('shows the red gap list with links to Stammdaten, Produkt and Designer', ...);
it('Probedruck (MUSTER) stays possible and requests proof=true', ...);
it('moves focus to the gap list after a 409 LABEL_PARTICULARS_MISSING', ...);
it('prints the PDF when the check has no gaps; warnings do not block', ...);
// social-media-tab.spec.tsx
it('Vom Bienenhalter übernehmen fills the fields visibly and saves only on Speichern', ...);
- Schritt 2:
npx vitest run src/lib/label-templates src/components/labels "src/app/(app)/settings" "src/app/(app)/bottling" "src/app/(app)/products"→ FAIL. - Schritt 3: Implementieren
LabelCompliancePanelist eine eigene Datei (der Designer hat schon 695 Zeilen) und bekommt nur das Ergebnis; der Designer ruft den Hook mit den aktuellen Textelementen.- Fehlerzeilen und Feldfehler über
describeSaveError/saveErrorText; das 409LABEL_PARTICULARS_MISSINGliestcheck.ts(MusterconfirmationRequired). Das Ergebnis voncheckTemplateForBottlingenthält die Anschrift: nie in Query-Keys mit Persistenz oder im Service-Worker-Cache ablegen (Pfad/label-templates/:id/check/…steht in Task 14 inNEVER_CACHED_API_PATHS). - Druckkarte: ruft vor dem Anzeigen
checkTemplateForBottling; bei Lücken rote Liste mit Links (Stammdaten → Verwaltung > Automatisierung, Produkt →/products/{id}, Designer →/settings/label-templates/{id}); Hauptknopf „Etiketten als PDF“ mitaria-disabled, daneben „Probedruck (MUSTER)“. - Stammdaten-Tab: Felder
foodBusinessName(200) undfoodBusinessAddress(300), Knopf übernimmtholderName/holderAddressin die Formularwerte, ohne zu speichern.
- Schritt 4: → PASS.
- Schritt 5: Verifikation
npm run lint && npm run typecheck && npx vitest run src/lib/label-templates src/components/labels "src/app/(app)/settings" "src/app/(app)/bottling" "src/app/(app)/products"→ PASS- Browser: Designer, Druckkarte (Lücke und vollständig), Desktop und 390 px, Tastatur,
prefers-reduced-motion - kein Commit
Task 18: Anzeigen und Einstellungen (Behörden)
Files
- Create:
src/lib/compliance/notices-api.ts(+ Spec),src/lib/compliance/settings-api.ts(+ Spec),src/app/(app)/compliance/notices/page.tsx,src/app/(app)/compliance/notices/[id]/page.tsx,src/app/(app)/compliance/notices/create-notice-dialog.tsx,src/app/(app)/compliance/settings/page.tsx(je + Spec)
Interfaces
- Consumes: Task 9/10/11 (Endpunkte), Task 14 (
types.tsnur lesen). - Produces:
export function fetchNotices(q?: { kind?: AuthorityNoticeKind; year?: number }): Promise<AuthorityNotice[]>; export function fetchNotice(id: string): Promise<AuthorityNotice>;
export function createNotice(input: { kind: AuthorityNoticeKind; year?: number; referenceDate?: string; sentOn?: string }): Promise<AuthorityNotice>; // 409 DRAFT_EXISTS liefert existingId
export function noticeAction(id: string, action: 'recount' | 'mark-sent' | 'reopen' | 'cancel', body?: { sentOn?: string; sentVia?: string; reference?: string }): Promise<AuthorityNotice>;
export function updateNotice(id: string, input: Partial<NoticeEdit>): Promise<AuthorityNotice>; export function openNoticePdf(id: string): Promise<void>; // authentifizierter Download, kein Link mit Token
export function fetchComplianceSettings(): Promise<ComplianceSettings>; export function updateComplianceSettings(input: Partial<ComplianceSettings>): Promise<ComplianceSettings>;
- Schritt 1: Failing Tests
it('the detail page shows three steps: 1. PDF öffnen, 2. ans Amt schicken, 3. hier bestätigen, and the main action follows the step', ...);
it('Als gesendet markieren asks for date, channel and reference and sends them', ...);
it('a 409 DRAFT_EXISTS links to the existing draft', ...);
it('"bereits angezeigt am …" creates a SENT registration with the entered date', ...);
it('recount is offered only for drafts and cancel only for drafts', ...);
it('the list shows kind, year, status, count and reference date', ...);
it('settings show the note "Annahme – bitte prüfen" at the trigger switches, the reference day and the due day', ...);
it('settings keep counted colony types and digit heights collapsed under Erweitert', ...);
it('an invalid reference day like 02-29 shows the server message', ...);
it('STAFF sees the pages read-only without action buttons', ...);
- Schritt 2:
npx vitest run src/lib/compliance "src/app/(app)/compliance"→ FAIL. - Schritt 3: Implementieren
- Fehler über
saveErrorTextundlib/compliance/errors.ts(Task 14); gezählte Völkerarten in „Erweitert“ alsToggleChip-Gruppe; Rückfragen („Anzeige stornieren“) mitConfirmDialog. Kein Nachweis-Dialog (Frage 5). - Detailseite nach Spec 9 mit Schritt-Anzeige (Symbol + Wort je Schritt); Formulare mit
FormSection; „Annahme – bitte prüfen“ als sichtbarer Vermerk am Feld, nicht nur als Tooltip. Anlege-Anfragen mitIdempotency-Key. Dokument-Upload (Kopie, Eingangsbestätigung) überDocumentUploadCardmitAUTHORITY_NOTICE.
- Fehler über
- Schritt 4: → PASS.
- Schritt 5: Verifikation
npm run lint && npm run typecheck && npx vitest run src/lib/compliance "src/app/(app)/compliance"→ PASS- Browser: Desktop und 390 px, Fehlerzustände (409), Tastatur
- kein Commit
Task 19: Sperrbezirk – Seite, Dialoge, Stand-Band, Ernte-Warnung
Files
- Create:
src/lib/compliance/restrictions-api.ts(+ Spec),src/lib/compliance/use-active-restrictions.ts,src/components/compliance/restriction-notice.tsx(+ Spec),src/components/compliance/restriction-override.tsx(+ Spec),src/app/(app)/compliance/restrictions/page.tsx(+ Spec),src/app/(app)/compliance/restrictions/create-restriction-dialog.tsx(+ Spec) - Modify:
src/app/(app)/colonies/[id]/colony-actions.tsx(Umsetzen, Vereinigen),src/app/(app)/colonies/[id]/colony-edit-card.tsx(Standkorrektur),src/app/(app)/colonies/new/page.tsx,src/app/(app)/apiaries/[id]/page.tsx(rotes Band),src/app/(app)/harvests/new/page.tsxundsrc/app/(app)/harvests/[id]/harvest-edit-dialog.tsx(Warnung) – je mit Specs
Interfaces
- Consumes: Task 8 (
GET /apiary-restrictions?active=true,POST/PATCH,RESTRICTED_ZONE-409), Task 14 (Typen lesen). - Produces:
export function fetchActiveRestrictions(): Promise<ActiveRestriction[]>; export function useActiveRestrictions(): { restrictions: ActiveRestriction[]; isLoading: boolean };
export function createRestrictions(input: { apiaryIds: string[]; disease: string; authority?: string; reference?: string; startsOn: string; endsOn?: string; notes?: string }): Promise<ApiaryRestriction[]>;
export function endRestriction(id: string, endsOn: string): Promise<ApiaryRestriction>;
export function restrictionsAmong(apiaryIds: readonly string[], active: readonly ActiveRestriction[]): ActiveRestriction[];
// <RestrictionNotice restrictions /> rote Box "Stand {Name} liegt im Sperrbezirk ({Krankheit}, seit {Datum}). Umsetzen ist gesperrt." + fester BienSeuchV-§-5-Hinweis (nur im Umsetzen-Dialog)
// <RestrictionOverride onReason={(reason | null) => void} /> nur mit compliance:manage: aufklappbar "Ausnahme mit Begründung", Rückfrage im ConfirmDialog
- Schritt 1: Failing Tests
it('the move dialog shows the red notice and disables Umsetzen while the source or target apiary is restricted', ...);
it('the exact BienSeuchV § 5 hint appears in the move dialog', ...); // "Beim Umsetzen an einen anderen Standort kann eine Gesundheitsbescheinigung nötig sein (BienSeuchV § 5). Im Zweifel beim Veterinäramt nachfragen."
it('the override section shows only with compliance:manage and enables Umsetzen after a reason of 10+ characters and a confirmation', ...);
it('sends restrictionOverrideReason with the move request only when the override is used', ...);
it('shows the server 409 RESTRICTED_ZONE message when a race slips past the client check', ...);
it('new colony, merge and apiary correction show the same notice', ...);
it('the apiary page shows a red band with a link while a restriction is active', ...);
it('the harvest form warns "Im Sperrbezirk gelten Auflagen auch für Honig und Waben. Verfügung prüfen." and still saves', ...);
it('the restrictions page lists active and ended restrictions and creates one for several apiaries', ...);
it('the disease is chosen via ChoiceGroup (Amerikanische Faulbrut, Kleiner Beutenkäfer, Andere mit Freitext)', ...);
it('ending a restriction sets endsOn and keeps the entry', ...);
it('the Verfügung is uploaded via DocumentUploadCard for APIARY_RESTRICTION', ...);
- Schritt 2:
npx vitest run src/lib/compliance src/components/compliance "src/app/(app)/compliance" "src/app/(app)/colonies" "src/app/(app)/apiaries" "src/app/(app)/harvests"→ FAIL. - Schritt 3: Implementieren
- Die Rückfrage der Ausnahme ist ein
ConfirmDialog; ist der Umsetzen-Knopf gesperrt, nutzt ertriggerDisabledplustriggerDescribedBy(Verweis auf die rote Meldung).RESTRICTED_ZONEund die Mitteilung „Volk wurde inzwischen umgesetzt“ (409 aus Task 8) liestlib/compliance/errors.ts. Der Mutations-Aufruf mitrestrictionOverrideReasonnutzt den bestehendenapiFetch; keinrefreshOn401: false(kein Nachweis-Aufruf). Die Stand-Antworten mit Anschrift stehen im Service-Worker-Pfad der Stand-Seite bereits unterNEVER_CACHED_API_PATHS,/apiaries/:id/restrictionsneu (Task 14). - Aktive Sperren einmal je Seite laden (
useActiveRestrictions, gemeinsamer Query-Key). Der Server entscheidet endgültig; die Client-Prüfung blendet nur früh aus. Die Berechtigung für die Ausnahme kommt aus dem vorhandenen Auth-Kontext. - Roter Text mit Token
destructive, Symbol + Wort; Fokus springt bei einer 409-Antwort auf die Meldung.
- Die Rückfrage der Ausnahme ist ein
- Schritt 4: → PASS.
- Schritt 5: Verifikation
npm run lint && npm run typecheck && npx vitest run src/lib/compliance src/components/compliance "src/app/(app)/compliance" "src/app/(app)/colonies" "src/app/(app)/apiaries" "src/app/(app)/harvests"→ PASS- Browser: Umsetzen im Sperrbezirk (Desktop und 390 px), Tastatur, Fehlerzustand
- kein Commit
Abschluss und Rollout (Orchestrator)
- Gesamtprüfung Backend:
npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci,npm run test:e2e -- --runInBand(gegen die Test-DB 17 auf Port 5433, 20 s je Test),npm run build; Frontend:npm run lint && npm run typecheck && npm run test && npm run build. Drift-Checknpx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code→ 0. - Wächter prüfen: Backup-Modellabdeckung,
handler-contract.spec.ts,after-commit-usage.spec.ts, Null-Vertrags-Specs der Tasks 2, 3, 5, 8, 9, Platzhalter-Fixture (Task 5 und 17) und der Seed-Wächtersrc/seed-no-medicines.spec.ts(Task 2 legt ihn an:prisma/seed.tsenthält keinveterinaryMedicine). - Docs aus den Berichten:
ARCHITECTURE.mdvier neue Annahmen mit je der nächsten freien Nummer (Stand 2026-10-15 ist 58 die letzte, also ab 59: Katalog und Packungen; Etikett-Pflichtangaben; Behördenpaket und Sperrbezirk; Prüfbereitschaft; dazu die Sperr-Reihenfolge Stand → Volk in §10/Annahme zu Zeilensperren und die Ausnahme „Sperrbezirk-Tag = UTC“, falls bestätigt), §2 Seitenstruktur (compliance/,medicines/) und Service-Worker-Absatz (neueNEVER_CACHED_API_PATHS), Annahme 15 ergänzen, §10 um die vier Ereignisse und die beiden Handler, Dokumente-Rechte je Art;docs/pruefung-2026-09.mdAbschnitt „Prüfbereitschaft“;TASKS.md(T6). - Rollout nach Spec 13 (jeder Schritt mit Freigabe des Inhabers): Voraussetzung Rückgrat-Runbook bis Schritt 3 und T4 live; Image mit den drei Migrationen (alles additiv, Rückweg ohne Verlust); danach Checkliste für den Inhaber: 1. Stammdaten Lebensmittelunternehmer, 2. Produkte Bezeichnung, 3. Etikettenvorlagen im Designer („Pflichtangaben-Block einfügen“), 4. Einstellungen Veterinäramt, Registriernummer, Stichtag und Frist der Tierseuchenkasse (Annahme), 5. Katalog aus den bisherigen Behandlungen übernehmen und aktuelle Packungen erfassen, 6. Erstanzeige „bereits angezeigt am …“, 7. Prüfbereitschaft durchgehen, bis sie grün ist. Vor dem Rollout einmal die Paragraphen-Nummern der Spec gegen gesetze-im-internet.de bzw. EUR-Lex abgleichen.
- Ein zentraler Commit, Nachricht endet mit
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>; Push gebündelt.
Abdeckung der Spec
| Spec-Punkt | Tasks |
|---|---|
| 1 Messkriterium 1 (Behandlung aus vollständiger Packung ohne „FEHLT“) | 3, 13 |
1 Messkriterium 2 (print 409 mit Lückenliste, sonst PDF) |
5, 6 |
| 1 Messkriterium 3 (eine ungelesene Meldung je berechtigtem Nutzer) | 7, 9, 11 |
1 Messkriterium 4 (GET /compliance/readiness leer bei vollständigen Daten) |
12, 13 |
| 1 Entscheidungen (Packungen, live aus Packung, Katalog nur im Formular, Sperren, Meldungen) | 2, 3, 6, 8, 11, 16 |
| 3 Rechtliche Leitplanken (Annahmen als Einstellungen) | 5, 9, 18 |
| 4.1 Tierarzneimittel-Modelle | 1, 2 |
4.2 Behandlung (medicineId, medicineLotId, doseQuantity, apiaryId im Update) |
1, 3 |
| 4.3 Etikett-Felder, Platzhalter | 1, 5, 17 |
| 4.4 Behörden und Sperrbezirk | 1, 8, 9 |
| 4.5 Quittungen | 1, 12 |
| 4.6 Dokumente (drei Arten) | 1, 4 |
| 4.7 Migrationen | 1 |
| 4.8 Rechte | 1 |
| 4.9 Backup | 1 |
| 5.1 Katalog, Packungen, Bewegungen, Vorschläge | 2 |
| 5.2 Behandlung | 3 |
| 5.3 Bestandsbuch, Lücken-Helfer | 3, 13 |
| 5.4 Etikett-Pflichtangaben, Druck | 5, 6 |
| 5.5 Behördenpaket | 9, 10, 11 |
| 5.6 Sperrbezirk | 8 |
| 5.7 Prüfbereitschaft (18 Punkte, Quittungen) | 12, 13 |
| 6.1 Ereignisse | 7 |
| 6.2 Handler | 11 |
| 6.3 Tägliche Auswertung | 11 |
| 7 API | 2, 3, 6, 8, 9, 10, 12, 13 |
| 8.1–8.2 Anzeige-PDFs | 10 |
| 8.3 Bestandsbuch | 3 |
| 8.4 Probedruck | 6 |
| 8.5 Prüfbericht | 13 |
| 9 Oberfläche | 14–19 |
| 10 Fehlerfälle | über die Tests der jeweiligen Tasks (Review Focus) |
| 11 Sicherheit | Global Constraints; 4, 5, 8, 12, 13 |
| 12 Tests und Wächter | in den Tasks benannt; Seed-Wächter und Platzhalter-Fixture im Abschluss |
| 13 Rollout | Abschluss |
Self-Review
Spec-Lücken und Widersprüche, hier aufgelöst (bitte beim Review bestätigen)
- Dokumente und Rechte: Spec 11 verlangt
compliance:readbzw.veterinary-medicines:readfür Belege, der Dokument-Endpunkt prüft aber nurdocuments:*. Gelöst in Task 4: Fachrecht je Dokumentart zusätzlich zum Dokumentrecht, auch beim Umhängen eines Dokuments. - Meldungen „höchstens eine ungelesene“ gegen T4: T4 vergibt
dedupeKeydauerhaft eindeutig (nach dem Lesen käme nie wieder eine Meldung). Gelöst:recordForPermissionOnceohnededupeKey, Dublette nur über „ungelesen“ unter Advisory-Lock (Task 11). T4 hat keine Kategorie für Behörden; die Meldungen tragenSYSTEM, damitNotificationCategoryund die Telegram-Kategorienliste unverändert bleiben. totalColonies„korrigierbar“, aber ungenutzt: Die Abweichung vergleicht mitbaseline.totalColonies, nicht mit dem Bestandsbild. Sonst hielte „bereits angezeigt am …“ den heutigen Stand statt der tatsächlich angezeigten Zahl für angezeigt.- Ableger: Spec 6.1 lässt
splitColonyCreatedschreiben, T4 Task 10ColonySplit. Gelöst: beide Ereignisse (Task 7). restrictionOverrideReasongegen{ ...dto }: Der Service reicht das ganze DTO an Prisma; das neue Feld würde als unbekanntes Argument scheitern, undUpdateColonyDto(perPickType) kennt es nicht. Gelöst in Task 8: Feld ausdrücklich ergänzen und vor dem Schreiben herauslösen; Test hält es fest.- Worker-DI:
ComplianceModulewird vomWorkerModuleimportiert, der Worker hat nur den resolver-only Provider. Die Ausnahme-Berechtigung liegt darum inColoniesService(nur AppModule), nicht im Modul mit den Handlern. - Prüfpunkte je Gegenstand: Die Spec-Tabelle nennt 18 Punkte, Quittung und
ReadinessItemgelten aber jesubjectId. Gelöst: Eine Regel liefert Findings; jedes Finding ist ein Item (lot-expired-stockje Packung,label-template-*je Vorlage,treatment-log-gapsje Jahr); eine Regel ohne Finding erscheint einmal als „Erledigt“.ReadinessItembekommtstateundacknowledgement. - Subjekte brauchen den Eltern-Bezug: Eine Behandlung ist nur über ihr Volk erreichbar.
ReadinessSubject.parentId(Erweiterung der Spec) trägt die Volk-ID;fix-links.tsbildet auf/colonies/{id}?editTreatment=…ab (Tasks 12, 14, 16). - Leck über Prüfpunkt-Texte:
compliance:readallein soll keine Arzneimittel- oder Behandlungsnamen zeigen. Regeln mitsubjectPermissionliefern ohne dieses Recht nur die Anzahl (Tasks 12, 13). - Schriftmetriken: Die AFM-Werte stehen am privaten pdfkit-Feld
_font. Gelöst: eine Kapsel (font-metrics.ts) und ein Wächter-Test mit 523/718 (Task 5). - Keine Rechtswerte im Frontend: Der Designer braucht Mindestgrößen für den Pflichtangaben-Block. Gelöst:
LabelCheckResult.requirementsliefert x-Höhe und Ziffernhöhe vom Server (Erweiterung der Spec, Annahme 10 bleibt gewahrt). productist Pflicht im DTO, Spec 5.2 lässt es weg:CreateTreatmentDto.productwird nur ohne Katalogeintrag und Packung verlangt (Task 3).- Testdatei der Spec existiert nicht:
test/treatment-log.e2e-spec.tsgibt es nicht; die Erweiterung steht intest/export.e2e-spec.ts(Tasks 3, 13). - Parallele Tasks gegen den zentralen Null-Vertrags-Spec: Task 1 zieht den Rumpf in
describeNullContracts; jede T6-Task legt ihre Verträge in einer eigenen Spec-Datei ab. Die Projektregel („steht inupdate-dto-null-handling.spec.ts“) gilt damit über den gemeinsamen Helfer. - Read-modify-write in angefassten Altservices:
SiteSettingsService,ProductsService,LabelTemplatesService,ApiariesServicelesenbeforeaußerhalb der Transaktion. Die Tasks 5 und 7 stellen sie auf die Sperre um; die Tabellen kommen in Task 1 inLOCKABLE_TABLES. - Migrationsstempel: T6 liegt hinter
20261015110000_t4_telegram. - Nicht spezifiziert, hier festgelegt: Der Prüfpunkt
tsk-reportwertet das laufende Jahr und Jahre mit offenem TSK-Entwurf aus;mark-sentohnesentOnnimmt den DB-Tag;GET /veterinary-medicine-lotsohneselectableliefert alle Packungen; Probedruck-Aufdruck pro Etikett; die Vorlage-Prüfung in der Prüfbereitschaft nutzt nur Textelemente.
Geprüft
- Jeder Spec-Abschnitt hat einen Task (Tabelle oben). Kein Task enthält „TBD“; Funktionsrümpfe stehen nur als Signaturen, Testnamen und festgelegte Formeln.
- Typen und Namen über die Tasks gleich:
roundToThousandths,treatmentBookingDelta,treatmentLogGaps/resolveSourceValues,checkLabel/LabelCheckResult/SAMPLE_BOTTLING,assertNoActiveRestriction/RestrictionOverride,HerdStatus/herdStatusIn/createDraftIn,recordForPermissionOnce,ReadinessRule/ReadinessFinding/ReadinessItem/READINESS_RULES,fixLinkFor. Die Consumes-Zeilen stimmen mit den Produces-Blöcken überein. - Jeder Task schließt mit Verifikation und „kein Commit“; Doku-Texte gehen über den Bericht an den Orchestrator.
- Geteilte Dateien je Welle höchstens einmal (Liste unter „Abhängigkeiten und Parallelisierung“). Paare, die nur durch Wellen getrennt sind: 2/9/12 (
app.module.ts), 7/8 (colonies.service.ts), 9/11 (compliance.module.ts), 9/10 (authority-notices.controller.ts), 12/13 (readiness-context.ts,rules/index.ts), 5/6 (label-templates.service.ts). - Proportion: Code steht nur als Signaturen, Testnamen mit Kern-Assertions und gepinnte Formeln und Texte; die Spec bleibt die Quelle der Einzelheiten.
Aktualisierung 2026-10-15 (nach T4)
Stand: HEAD 9760ce1 (T4 komplett). Der Plan wurde Punkt für Punkt gegen den Code geprüft; nur diese Datei wurde geändert, kein Code, kein Commit. Neben dem Plan läuft eine Nacharbeit, die die Durchsicht-Intervall-Funktion in src/season/inspection-due.ts teilt und intervalDays zu GET /phenology/state ergänzt; beides wird als vorhanden behandelt, T6 berührt es nicht.
Änderungen (je eine Zeile)
- Tech Stack: Postgres der Test-DB ist 17 (Port 5433), Betrieb 18.6; keine 18-only-Funktionen.
- Voraussetzungen T3:
treatmentRowist eine freie Modulfunktion (spreizt...dto),recordIn(tx, data, actingUserId, bulkId?)private Methode;createsperrt mitlockRowForUpdate;updatekenntapiaryIdnoch nicht. - Voraussetzungen T4 vollständig und mit echten Namen:
NotificationCategoryals Prisma-Enum,recipientsist privat,planInplant Telegram in der Schreib-Transaktion,notificationDedupeKey,DbClient,WithdrawalWindow-Felder,withdrawal-text.ts,WithdrawalCheckService,TimeEventsStep, Berliner Tage (Annahme 41). - Tagesregel:
DailyRule.occurrences(db: PrismaService, today, { seasons, day })statt erfundenerevaluate; UnionDailyEventTypeerweitern;ctx.dayist ein Berliner Tag;CATCH_UP_DAYSexportiert. - Migrationen: Stempel geprüft (alle hinter
20261015110000_t4_telegram); kein Konflikt mit T4 (Product.shelfLifeMonthsexistiert,AuditAction.PRINT,DocumentEntityType-Werte und Rechte noch nicht). - Rechte:
sortOrder3700 und 3800 (höchster Wert ist 3600). - Lock-Reihenfolge:
Apiaryan die erste Stelle vonLOCKABLE_TABLES(Task 1);Productsteht schon in der Map und wird nicht doppelt eingetragen; acht neue Tabellen (LabelTemplate,SiteSettings,VeterinaryMedicine,VeterinaryMedicineLot,ComplianceSettings,AuthorityNotice,ApiaryRestriction,ComplianceAcknowledgement). - Lock-Reihenfolge, Aufrufer (vollständig): heute sperrt nur
AutopilotService.syncApiaryInStand und Volk zusammen (schon Stand zuerst, keine Codeänderung); zu ändern sindColoniesService.create/update/move/merge,ApiariesService.update/deactivate/reactivateundApiaryRestrictionsService.create/update;InspectionsServiceruftupdateInunter Volkssperre weiter,updateInsperrt keinen Stand. - Task 1 Tests:
row-lock.spec.tsReihenfolge-Test ersetzt, Einmaligkeit jeder Tabelle,it.eachum T6-Tabellen; Autopilot-Spec als Wächter. - Task 8: Sperrmuster Stand (FOR SHARE, aufsteigend) vor Volk, Neuvergleich des Standes unter der Sperre (409),
assertApiaryActivein die Transaktion,ApiaryRestrictionsServicesperrt Stände FOR UPDATE; vier neue Tests. - Task 7:
AggregateTypekennt'Apiary'schon (nur'Compliance'ergänzen);ApiariesServicehat heute keine Transaktion, alle vier Pfade werden umgebaut; Handler-Registrierung in einem Modul, das App und Worker importieren, festgehalten. - Task 14:
DOMAIN_EVENT_LABELS(src/lib/system/labels.ts) bekommt die vier Ereignisse mit deutschen Namen; Test dazu. - Task 14: Service Worker darf Compliance-Daten nie zwischenspeichern; acht neue Pfade in
NEVER_CACHED_API_PATHS,SW_ROUTES_VERSIONheben, Tests insw-routes.spec.ts. - Task 14: neue
src/lib/compliance/errors.tsauf Basis vondescribeSaveError/saveErrorText(MusterconfirmationRequired) statt eigener Fehlerlogik; 18 und 19 lesen nur. - Tasks 14 bis 19: Wiederverwendungsliste (
PrefillStatus,ConfirmDialogtriggerDisabled/triggerDescribedBy,ToggleChipdisabled,describeSaveError,DocumentUploadCard);StepUpDialogundrefreshOn401: falsebewusst nicht (Frage 5). - Task 15:
src/lib/documents/types.ts(DocumentEntityTypeplus drei Arten) fehlte im Plan; gehört Task 15, 18 und 19 lesen. - Task 16: Katalog-Vorbelegung zeigt Lade-/Fehlerzustand mit
PrefillStatus(Annahme 53). - Task 17: Fehler über
describeSaveError, 409LABEL_PARTICULARS_MISSINGüber Leser incheck.ts; Prüfantwort enthält die Anschrift und kommt nie in Persistenz oder Cache. - Task 18/19: Fehler- und Dialogmuster der Wiederverwendungsliste eingetragen.
- Task 2:
roundKggibt es seit T4 exportiert inhoney-plausibility.ts; die Kopie delegiert anroundToThousandths, keine dritte Kopie;test/extractions.e2e-spec.tsin die Verifikation. - Task 3: Beschreibung von
treatmentRow/updatean den Code angepasst; Reihenfolge Volk vor Packung. - Task 4:
create/update/deleteder Dokumente habenactingUserIdschon; neu nurfindAllundreadFile. - Task 6: Signaturwechsel von
renderLabelsPdf(templateId, bottlingBatchId, startPosition = 1)samt Aufrufern (Controller, Spec) benannt. - Task 9: Standardtage
referenceDate/sentOnundtodayder Prüfbereitschaft sind Berliner Tage (berlinToday()), nicht UTC der DB-Uhr (Annahme 41). - Sperrbezirk-Tag bleibt UTC (Spec 5.6 sagt es ausdrücklich), im Plan markiert, Wechsel ist eine Zeile in
dbUtcDay; Frage 1. - Task 11:
recipientsForgestrichen (die privaterecipientsexistiert);recordForPermissionOncenutzt sie; Test für Telegram-Planung mit/ohne KategorieSYSTEM. - Task 11: Kategorie
SYSTEMbestätigt; Texte (unter 600 Zeichen, ohne Adresse, Link im Feldlink) passen ins Telegram-Format; Zustellung nur bei gewählter Kategorie (Frage 2). - Task 11: Worker = API:
ComplianceModulein beiden Wurzeln, keinPermissionsModulein der Kette, Tests inworker-runtime.e2e-spec.tsunddomain-event-registry.spec.ts. - Task 13:
harvest-withdrawalnutztwithdrawalWindowOf/isInWindow/WithdrawalCheckService.conflictsForHarvests/withdrawalRangeTextstatt eigener Fensterlogik. - Geteilte Dateien je Welle:
labels.ts,sw-routes.js/sw.js/sw-routes.spec.ts,compliance/errors.tsnur Task 14;documents/types.tsnur Task 15;honey-plausibility.tsnur Task 2;inspections.service.tsundautopilot.service.tsnur gelesen. - Gates:
npm run linthat--fix, der Plan nutztnpx eslint; e2etestTimeout20 s,maxWorkers: 4, Gesamtlauf mit--runInBand; Frontendnpm run testistvitest run. - Test-DB ist nicht leer: neue Projektregel mit Aufräumen der T6-Tabellen, Differenzen statt absoluten Zahlen, Prüfung je
key+subjectId; Tests in Tasks 9, 11, 12, 13 umformuliert („leeres System“, „READY“). - Abschluss: Annahmen ab Nummer 59, zusätzlich §2 (Seiten) und Service-Worker-Absatz, Sperr-Reihenfolge als Annahme.
Geprüft und unverändert: DomainEventsService, @OnDomainEvent, dailyDedupeKey, utcDay, inCatchUpWindow, AuditLogService.record(entry, tx), lockRowForUpdate/lockRowsForShare/lockRowsForUpdate, runBulk (Callback (tx, entry, colony, bulkId)), lockColony/colonyTransitionEvents, LIVING_COLONY_WHERE, loggableError, drainDomainEvents, @Idempotent, IsOptionalNotNull, optionalDate, fifoOrder, pdf-table.ts-Helfer, JobPermissionsModule, groupTreatmentLog, summarizeStock, ColonySplit, GET /treatments/last-by-product, PrefillNote; Annahmen 10, 11, 15, 20 stimmen mit ARCHITECTURE überein. Spec-Abdeckung: jeder Abschnitt hat weiter einen Task; nichts, was T4 geliefert hat, wird doppelt gebaut (Haltbarkeit, Meldungs-Fan-out, Fenster-Helfer, roundKg wurden zusammengeführt oder wiederverwendet).
Offene Fragen an den Inhaber
- Sperrbezirk-Tag: Spec 5.6 sagt UTC-Tag; die Tagesauswertung nutzt seit T4 Berliner Tage. Kurz nach Mitternacht Berliner Zeit gilt eine Sperre, die heute beginnt, dann noch nicht, und eine, die gestern endete, noch. Vorschlag: Berliner Tag (Spec 5.6 anpassen). Bis zur Antwort bleibt UTC.
- Die Meldungen „Bestand geändert“ und „TSK-Stichtag“ tragen die Kategorie
SYSTEM; Telegram bekommt sie nur, wenn der NutzerSYSTEMgewählt hat (Vorgabe nurTASK_DUEundDIGEST). Reicht das, oder soll es eine eigene KategorieCOMPLIANCEgeben (Migration, Profil-UI, Telegram-Auswahl)? - Service Worker: Der Plan schließt alle Compliance-, Packungs- und Dokument-Pfade vom Cache aus (dort kein Offline-Lesen). Gewollt? Das Präfix
/api/v1/documentstrifft damit auch Fotos der Durchsicht. - Lock-Reihenfolge Stand vor Volk ist wie beschlossen umgesetzt; bitte bestätigen, dass ein Umsetzen, das zwischen Lesen und Sperren den Stand wechselt, mit 409 „bitte neu laden“ enden darf (statt automatisch zu wiederholen).
- Soll die Ausnahme vom Sperrbezirk (
restrictionOverrideReason) einen Nachweis (Step-up wie beim Backup-Import) verlangen? Die Spec sieht keinen vor; der Plan baut keinen ein. - Weiterhin offen aus der Spec: Paragraphen-Nummern, Fristen und Landesrecht Bayern (Annahmen als Einstellungen, vor dem Rollout gegen gesetze-im-internet.de/EUR-Lex abgleichen); es wurden keine Rechtswerte in den Plan oder Code geschrieben.
Inhaber-Entscheidungen 2026-10-15 (bindend, gehen Spec und Plantext oben vor)
- Sperrbezirk-Tag = Berliner Kalendertag (
berlinToday()), nicht UTC (Spec 5.6 wird angepasst). Aktiv beistartsOn≤ Berliner Tag ≤endsOn. Gilt für Task 8 (Wächter-Helfer), Task 12/13 (Prüfbereitschaft) und Task 19 (Stand-Band). Die ZeiledbUtcDayentfällt; Vergleich über den Berliner Tag wie in der Tagesauswertung. - Eigene Meldungs-Kategorie
COMPLIANCE(„Behörden & Prüfung“) für die Hinweise „Bestand geändert“ und „TSK-Stichtag“.- Task 1:
NotificationCategory+COMPLIANCE(eigene Migration, Wert nicht in derselben Migration benutzt). Standardmäßig für neue und bestehende Nutzer in der App und für Telegram ausgewählt: Vorgabe-Liste erweitern; bestehendeNotificationPreference-Zeilen bekommen den Wert per Daten-Migration dazu. - Task 11 nutzt
COMPLIANCE. - Frontend (Task 18): Label „Behörden & Prüfung“ in
lib/notifications/labels.tsund in der Kategorienauswahl der Telegram-Karte.
- Task 1:
- Ausnahme vom Sperrbezirk ohne Passwort-Abfrage: Begründung 10–500 Zeichen, nur
compliance:manage, Audit + Standhistorie (wie Spec).
Orchestrator-Rulings 2026-10-15
- Service Worker: Nicht
/api/v1/documentsals ganzes Präfix sperren, sonst sind Durchsichtsfotos offline weg. Nie gecacht werden die Compliance-, Arzneimittel- und Sperrbezirk-Pfade. Dokumente nur, wenn der Pfad die neuen Arten (MEDICINE_LOT,AUTHORITY_NOTICE,APIARY_RESTRICTION) eindeutig erkennen lässt. Sonst schützt der Server mitCache-Control: no-storeauf diesen Downloads und Listen; der SW respektiertno-store. Task 14 prüft das und testet es. - Gleichzeitige Standänderung zwischen Lesen und Sperre: 409 mit „Der Stand wurde gerade geändert. Bitte neu laden.“, kein automatischer Neuversuch (wie bisher in der App).