Files
ApiCore/docs/superpowers/plans/2026-10-13-t4-logik-ketten.md
Nick SzaunigandClaude Opus 5.5 ec8c95056d
Backend Image / build-and-push (push) Successful in 6m43s
Frontend Image / build-and-push (push) Failing after 12m42s
Manifests / validate (push) Successful in 18s
docs: README im Root, Backend/Frontend-READMEs, Statuszeilen (2026-10-16)
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]>
2026-10-16 08:12:49 +02:00

257 KiB
Raw Permalink Blame History

T4 Logik-Ketten und Saison-Autopilot Implementation Plan

Status (2026-10-16): T4a (Tasks 1–22), T4b (23–33) und T4c (34–38) umgesetzt und gemergt (Wellen 1–11, zuletzt 9760ce1, Browser-Fixes 7a14801). Nicht ausgerollt. Issues: #15, Folgepunkt packagingSize #36, 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: Was der Imker erfasst, wirkt weiter: Aus Durchsicht, Messung, Ableger, Behandlung und Pflanzenentwicklung entstehen datierte Vorschläge, je Volk „die nächsten 3 Schritte“ mit Begründung, Warnungen entlang der Honig-Kette, ein Bewegungsbuch der Honigcharge und Erinnerungen in der App und per Telegram. Automatik plant nie heimlich: Sie schlägt vor, der Imker übernimmt oder verwirft.

Architecture: Alle Folgen hängen an Handlern des Ereignis-Rückgrats (@OnDomainEvent, Schreiben nur über tx) oder an Schritten des Jobs daily-evaluation. Netz gibt es nur in drei neuen Worker-Schleifen (phenology-weather, telegram-send, telegram-updates). Ein Modell Suggestion mit einem SuggestionsService trägt Regel-, Ketten- und Autopilot-Vorschläge; Titel und Gründe entstehen beim Lesen aus reasonCode + evidence (nur IDs, Zahlen, Tage). Jeder Fachwert kommt aus AutomationRule.config über die Parser in src/automation/rule-config/. Die Saison kommt je Stand aus Meldung, Grünlandtemperatursumme oder Kalender (SeasonService). Benachrichtigungen werden je Nutzer mit Rechteprüfung geschrieben (Fan-out) und optional über die Outbox NotificationDelivery an Telegram zugestellt.

Tech Stack: NestJS 11, Prisma 7.9 (@prisma/adapter-pg), PostgreSQL 18, Jest 30 (Unit + e2e gegen Test-DB), Next.js 16.3.1 App Router, React 19.2, TanStack Query 5, idb 8, Vitest 4 + RTL. Keine neue Abhängigkeit (Netz über das eingebaute fetch).

Spec: docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md (bindend, mit Inhaber-Entscheidungen und -Antworten). Bausteine aus docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md (Rückgrat) und docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md (T3).


Global Constraints

Voraussetzungen (nicht neu bauen, genau so nutzen)

  • Rückgrat Tasks 1–26 sind gemergt. Genutzt werden: TasksService.createInTx(tx, input, actingUserId), TasksService.update/complete (gemeinsames, privates markDoneIn mit privatem recordCompleted), lockRowForUpdate (liefert Promise<boolean>), lockRowsForUpdate, lockRowsForShare (src/prisma/row-lock.ts; LOCKABLE_TABLES enthält schon Colony, Task, BottlingBatch, Feeding, Harvest, Inspection, Product, ProductionBatch, RawMaterialLot, StockItem, Treatment, VarroaMeasurement), runBulk und assertLivingColonies (src/common/bulk/), die Regeltabelle DAILY_EVALUATION_RULES mit catchUpEvents/inCatchUpWindow (src/jobs/daily-evaluation.rules.ts), JobRunService.run (src/system/job-run.service.ts), DomainEventsService.record/recordAll(tx, …), @OnDomainEvent(types, '<consumer>.<name>'), HANDLER_ID_PATTERN, dailyDedupeKey und utcDay (beide src/domain-events/domain-event-types.ts), JobRunService.run, DailyEvaluationJob mit CATCH_UP_DAYS = 7, claimBatch/releaseStaleClaims/settleSuccess/settleFailure (src/common/outbox/outbox-claims.ts), retryDelayMs/MAX_ATTEMPTS = 8 (src/common/retry-policy.ts), notifyWorker/WAKE_CHANNELS, loggableError, withTimeout, DbClient, assertTransactionClient, PollingLoop, Loop-Tabelle in WorkerRuntime, DOMAIN_EVENT_WEBHOOKS, CalendarSyncService.enqueueIn, ReauthService.verify, PermissionResolverService.getPermissions, SystemQueueService, drainDomainEvents (test/utils/domain-events.ts), deliverWebhooks.
  • T3 ist gemergt: ApiaryFieldService (colony-overview, offline-pack), assertLivingColonies, Frontend PreviousValueHint, NumberStepper, StatusLightBadge, colonyStatusLight, startRound, useCaptureMutation, Warteschlange src/lib/offline/db.ts (Version 3), takeover.ts, varroaLight, fetchVarroaThreshold, nav-config.ts.
  • Alle Backend-Pfade relativ zu ApiCore-Backend/, alle Frontend-Pfade relativ zu ApiCore-Frontend/, sofern nicht anders angegeben. deploy/** und docs/** liegen im Repo-Wurzelverzeichnis ApiCore/ (Befehle mit deploy/… laufen dort, nicht in ApiCore-Backend/).

Lieferungen: T4a = Tasks 1–22, T4b = Tasks 23–33, T4c = Tasks 34–38. T4c braucht nur T4a. T4b und T4c werden parallel gebaut und gemeinsam ausgerollt (geteiltes schema.prisma, Migration 6 ändert Daten und braucht den Ledger-Code). Soll T4c früher live, läuft die Lane 34–38 vollständig vor Task 23, und die Zeitstempel-Blöcke tauschen (T4c 20261015100000, T4b ab 20261015110000).

Migrationen (Zeitstempel fest, Reihenfolge = Erzeugungsreihenfolge; Nummern aus Spec 4.12)

Zeitstempel Name Spec-Nr Task
20261014100000 t4_enum_values 1 1
20261014100100 t4_suggestions 2 1
20261014100200 t4_feed_vocabulary 3 2
20261014100300 t4_split_queen_source 5 2
20261014100400 t4_varroa_sample_size 7 (Teil sampleBees) 2
20261014100500 t4_notification_fanout 8 3
20261014100600 t4_calendar_withdrawal 9 3
20261014100700 t4_occupied_frames 10 3
20261014100800 t4_notification_settings 11 (Teil Kategorie, Link, Rechte, NotificationPreference) 3
20261015100000 t4_phenology 4 23
20261015100100 t4_honey_ledger 6 23
20261015100200 t4_product_shelf_life 7 (Teil shelfLifeMonths) 23
20261015110000 t4_telegram 11 (Teil Telegram und NotificationDelivery) 34
  • Erzeugt mit npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script gegen die Test-DB, danach von Hand nachgearbeitet (Kopfkommentar wie 20261012100000_calendar_sync_claims). Nie db push, nie migrate dev.
  • Bindend bleibt aus 4.12: Enum-Werte in eigener Migration vor jeder Nutzung; 8 vor 11; category der Altzeilen wird belegt, danach NOT NULL.
  • Läuft ein Schema-Task in einer Welle mit anderen Tasks, wendet er seine Migrationen zuerst an (migrate deploy), erst danach starten die anderen ihre e2e-Läufe (Orchestrator sequenziert).
  • 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.

Zeit

  • „Heute“ = Kalendertag Europe/Berlin (berlinToday()). Tage als YYYY-MM-DD; gespeichert als DateTime Mitternacht UTC (dayToDate), wie Task.dueDate.
  • Zeitvergleiche für Claims, Leases, Token-Ablauf, Wiedereröffnen (30 Tage) und Veraltung laufen in SQL gegen now(). Berliner Uhrzeiten (Morgenzeit, Ruhezeit) werden aus einem einmal gelesenen SELECT now() berechnet (berlinDateTime).
  • Job daily-evaluation 04:30 Europe/Berlin. Schritte in fester Reihenfolge phenology → time-events → autopilot → suggestions-expiry → digest, jeder in eigenen Transaktionen; ein gescheiterter Schritt hält die anderen nicht auf, JobRun ist dann FAILED mit allen Zählern. Zähler zusätzlich zu den bisherigen: phenologyApiaries, seasonChanges, autopilotColonies, suggestionsOpened, suggestionsResolved, suggestionsExpired, digests.

Vorschläge

  • Enums exakt wie Spec 4.1 (SuggestionStatus, SuggestionMode, SuggestionSource, SuggestionIntent).
  • dedupeKey = "<ruleKey>:<relatedId|none>:<anker>", z. B. INSPECTION_INTERVAL:col_1:insp_9. Nullbar, @unique, kein partieller Index.
  • Höchstens eine offene Sache je Absicht und Objekt: Ein offener Vorschlag oder eine offene Aufgabe (OPEN, IN_PROGRESS) derselben Absicht verhindert einen neuen Vorschlag. Absicht einer Aufgabe = sourceSuggestion.intent, sonst INTENT_OF_LEGACY_TRIGGER[sourceAutomationTrigger], sonst FOLLOW_UP.
  • Ein Vorschlag mit vorhandenem dedupeKey entsteht nie neu. Den Schlüssel behalten OPEN, ACCEPTED, DISMISSED und OBSOLETE; EXPIRED und RESOLVED setzen dedupeKey = NULL (im selben Statement wie der Statuswechsel). Sonst wäre derselbe Anker nach Ablauf (30 Tage) oder Erledigung nie wieder möglich und Erinnerungen wie A1 blieben danach aus. Neuer Anker = neuer Vorschlag; dieselbe Bedingung nach EXPIRED/RESOLVED darf beim nächsten Abgleich neu vorgeschlagen werden, ein verworfener oder übernommener Anker nicht.
  • Wieder öffnen nur bis 30 Tage nach decidedAt. Bulk 1–50 IDs, alles oder nichts. note ≤ 500 Zeichen.
  • expiresAt = Fälligkeit + suggestionTtlDays (30) für Autopilot-Vorschläge.
  • Texte: höchstens 2 Gründe, je ≤ 90 Zeichen, deutsch formatiert (12,4, 05.05.). actionTemplate einer Regel ersetzt nur den Titel, wenn es nicht leer ist.
  • Jeder Übergang ist ein Compare-and-Set (updateMany … where status = 'OPEN' bzw. 'DISMISSED') mit Audit entityType: 'Suggestion' in derselben Transaktion. Fehlschlag → 409 { message: 'Der Vorschlag ist nicht mehr offen.', status }.
  • Art: Regel- und Kettenvorschläge sind PLAN; CLEANUP ist DO; Autopilot A1, A2, A3, A5, A6, A7, A9, A10 DO, A4 und A8 PLAN.
  • Leserecht je Vorschlag (requiredPermission); Gründe nennen nur Daten aus diesem Bereich plus Volk-Code, Stand-Name und Saison:
Absicht (Quelle) requiredPermission
QUEEN_CHECK (Regel), FEED (Regel, A6), SWARM_CHECK (Kette Weiselzellen), INSPECT (A1), HONEY_SUPER (A7) inspections:read
VARROA_TREAT (Regel), VARROA_MEASURE (A2) varroa:read
VARROA_RECHECK (A3), SUMMER_TREATMENT (A4), FOLLOW_UP (Behandlung) treatments:read
FEED (A5), FOLLOW_UP (Fütterung) feedings:read
SWARM_CHECK (Regel Schwarmstimmung) swarm-control:read
QUEEN_ACCEPTANCE, QUEEN_REPLACE (A8) queens:read
HEALTH_CHECK, SPLIT_PLAN, CLEANUP colonies:read
WINTER_CHECK (A9, A10) winter-checks:read
STOCK_REORDER storage:read (Lagerartikel) bzw. raw-material:read (Rohstoff)
  • INTENT_OF_LEGACY_TRIGGER = { QUEENLESS_NO_EGGS: 'QUEEN_CHECK', VARROA_THRESHOLD: 'VARROA_TREAT', FEED_STATUS_CRITICAL: 'FEED', SWARM_MOOD_HIGH: 'SWARM_CHECK', COLONY_HEALTH_CRITICAL: 'HEALTH_CHECK', STOCK_ITEM_LOW: 'STOCK_REORDER', RAW_MATERIAL_LOW: 'STOCK_REORDER', TREATMENT_FOLLOW_UP: 'FOLLOW_UP', FEEDING_FOLLOW_UP: 'FOLLOW_UP' }.

Regeln (DEFAULT_RULES, ein Parser je Trigger; ungültige Werte → Standard + Log-Warnung; PATCH → 400)

Trigger aktiv Standard-Config
QUEENLESS_NO_EGGS, COLONY_HEALTH_CRITICAL ja { notify: true } (+ optional dueInDays)
FEED_STATUS_CRITICAL, SWARM_MOOD_HIGH, STOCK_ITEM_LOW ja { notify: false }
RAW_MATERIAL_LOW nein { notify: false } (+ minGrams je Sorte)
TREATMENT_FOLLOW_UP, FEEDING_FOLLOW_UP nein { notify: false } (+ dueInDays)
*_NUMBER_FORMAT (4) wie bisher { format } wie bisher
VARROA_THRESHOLD ja { naturalFall: { seasonMonths: [3,4,5,6,7,8,9,10], yellowFrom: 5, redAbove: 10, winterRedAbove: 0.5 }, infestationPercent: { summerMonths: [5,6,7], summer: { yellowFrom: 1, redAbove: 5 }, autumnMonths: [8,9,10], autumn: { yellowFrom: 2, redAbove: 5 } }, youngColonyFactor: 0.5, youngColonyTypes: ['YOUNG_COLONY','NUCLEUS_COLONY','MATING_NUCLEUS'], recheckAfterDays: 14, notify: true }; Altwert thresholdMitesPerDay = naturalFall.redAbove
INSPECTION_INTERVAL ja { intervalDays: { FRUEHJAHR: 14, SCHWARMZEIT: 7, SOMMER: 14, SPAETSOMMER: 21, HERBST: 21 } }; erlaubt SCHWARMZEIT 7–9, sonst 7–35
QUEEN_CELLS_SWARM_CHECK, QUEEN_ACCEPTANCE_CHECK ja { dueInDays: 7, notify: false }
SPLIT_SCHEDULE ja { RAISE_OWN: [{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 }, { step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }, { step: 'EGGS_CHECK', offsetDays: 28 }], QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21 }], QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }], notify: false }
COLONY_ENDED_CLEANUP ja {}
WITHDRAWAL_ENDED ja { notify: true }
HONEY_WATER_CONTENT ja { warnAbovePercent: 18, confirmAbovePercent: 20, byHoneyType: {} } (Heidekraut nur per Config, z. B. { "Heidehonig": { confirmAbovePercent: 23 } })
SEASON_AUTOPILOT ja { honeySuperMinFrames: 8, varroaMeasureIntervalDays: 28, winterVarroaWindow: { from: '11-15', to: '12-20' }, queenMaxAgeYears: 2, suggestionTtlDays: 30, maxStepsPerColony: 3, rules: { A1: true, …, A10: true } }
PHENOLOGY ja { useGts: true, gtsThresholds: { SALWEIDE: 200 }, calibrateFromReports: true, calibratePhases: ['KIRSCHE','APFEL'], calendarFallback: { SALWEIDE: '03-20', KIRSCHE: '04-15', APFEL: '04-28', HOLUNDER: '05-30', LINDE: '06-20', TRACHTENDE: '08-01', HERBST: '09-15', WINTER: '11-15' }, swarmSeason: { from: 'KIRSCHE', until: 'LINDE' }, maxGapShare: 0.1 }
  • Grenzen (Validierung): dueInDays 0–365; Varroa-Werte endlich ≥ 0, yellowFrom ≤ redAbove, Monate 1–12 eindeutig, youngColonyFactor (0, 1], recheckAfterDays 0–60; Wassergehalt 10–30, warn ≤ confirm; honeySuperMinFrames 1–40; varroaMeasureIntervalDays 7–90; queenMaxAgeYears 1–10; suggestionTtlDays 1–365; maxStepsPerColony 1–3; MM-DD gültig, Kalender-Rückfälle in Phasenreihenfolge nicht fallend; swarmSeason.from vor until; maxGapShare 0–0,5; GTS-Schwellen > 0; Plan-Schritte offsetDays 0–120, windowDays 0–30.
  • PATCH /automation/rules/:id mit ungültigem bekanntem Schlüssel → 400 { message: 'Die Einstellung ist ungültig.', issues: [{ field, message }] } (deutsche Feldmeldung). Unbekannte Schlüssel bleiben unverändert.

Fachwerte

  • Varroa (Spec 7.1): rot Wert > redAbove, gelb Wert ≥ yellowFrom (10,0/Tag = gelb). Natürlicher Milbenfall März–Oktober 5/10, November–Februar nur rot über 0,5. Puderzucker/Auswaschung Mai–Juli 1/5 %, August–Oktober 2/5 %, November–April keine Ampel. Drohnenbrut keine Ampel. Jungvölker (youngColonyTypes): Schwellen des natürlichen Milbenfalls × 0,5. Messtag ab Behandlungsbeginn bis vor (endDate ?? date) + 14 → keine Ampel, Text „Behandlungseffekt – Nachkontrolle ab {TT.MM.}“, die Regel schlägt nichts vor. Rot → VARROA_TREAT, HIGH, Anker Mess-ID. Probe sampleBees 1–2000; bei POWDERED_SUGAR/WASHING_METHOD ist calculatedValue = miteCount / sampleBees × 100.
  • Durchsicht fällig (Spec 7.2): inspectionDueOn = letzte Durchsicht (sonst colony.createdAt) + Intervall des heutigen Abschnitts; liegt die letzte Durchsicht vor dem Beginn von FRUEHJAHR, ist das Volk ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“); in WINTERRUHE null. A1 ab inspectionDueOn ≤ heute + 2, überfällig → HIGH.
  • Saison (Spec 7.3): GTS = Σ positiver Tagesmittel ab 1. Januar, Januar × 0,5, Februar × 0,75, ab März × 1; > 10 % fehlende Tage → „Wetterdaten lückenhaft“, nicht benutzt; GTS älter als 3 Tage → Kalender. Effektive Phase = Maximum aus Meldung, GTS, Kalender; läuft im Jahr nur vorwärts, außer nach einer zurückgezogenen Meldung. Abschnitte: WINTERRUHE (ab WINTER oder vor SALWEIDE), FRUEHJAHR (ab SALWEIDE), SCHWARMZEIT (ab swarmSeason.from), SOMMER (ab swarmSeason.until, Standard LINDE), SPAETSOMMER (ab TRACHTENDE), HERBST (ab HERBST).
  • Ketten (Spec 7.4): Weiselzellen → SWARM_CHECK +7; Königin zugesetzt → QUEEN_ACCEPTANCE +7; Ableger RAISE_OWN → SPLIT_PLAN mit +9 / +21 (Fenster bis +24) / +28; QUEEN_CELL → SPLIT_PLAN +21; QUEEN → QUEEN_ACCEPTANCE +7; ohne Angabe keine Kette. Fälligkeit ab fachlichem Datum.
  • Wartezeit-Fenster: von Behandlungsbeginn (startDate, falls vor date, sonst date) bis ausschließlich (endDate ?? date) + withdrawalPeriodDays (= erster erntefähiger Tag = WithdrawalEnded.endedOn). Ohne withdrawalPeriodDays kein Fenster, keine Warnung.
  • Alter der Königin: gealtert, wenn aktuelles Jahr − hatchYear ≥ queenMaxAgeYears (2). Gilt für QueenAged, Dashboard OLD_QUEEN und A8; OLD_QUEEN_AGE_YEARS entfällt.
  • Volksstärke: Colony.occupiedFrames und Inspection.colonyStrength 0–40 besetzte Waben, 400 „Höchstens 40 besetzte Waben.“; Colony.strength (1–10) bleibt unverändert, Swagger deprecated, nie umgerechnet.
  • Futter: FEED_CONDITION_LABELS EXCELLENT „Reichlich“, GOOD „Ausreichend“, AVERAGE „Mittel“, WEAK „Niedrig“, CRITICAL „Kritisch“; Altwerte ausreichend→GOOD, niedrig→WEAK, kritisch→CRITICAL (getrimmt, ohne Groß-/Kleinschreibung), leer → null, sonst null + „Futter (alt): {Text}“ am Ende von notes.
  • Honig: Warnungen in einem Format (PlausibilityWarning, Spec 10.3) mit Stufe je Code: WITHDRAWAL_ACTIVE WARNUNG, EXTRACTION_EXCEEDS_HARVEST WARNUNG, HARVEST_NET_MISSING HINWEIS, BATCHES_EXCEED_EXTRACTION WARNUNG, WATER_CONTENT_HIGH WARNUNG, WATER_CONTENT_ABOVE_LEGAL BESTAETIGEN, WATER_CONTENT_MISSING HINWEIS, JAR_SIZE_DIFFERS HINWEIS. Nie 4xx außer POST /bottling über der gesetzlichen Grenze ohne acknowledgedWarnings → 409 { code: 'CONFIRMATION_REQUIRED', warnings }.
  • Bewegungsbuch: quantity in Gramm (Int, Vorzeichen); amount = initialAmount + Σ quantity / 1000, auf Gramm gerundet, nie < 0; Toleranz 0,5 g; Texte „Abfüllmenge übersteigt die verfügbare Menge der Honigcharge“, „Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.“, „Die Menge kann nicht unter 0 sinken.“

Benachrichtigungen

  • Jede Zeile hat einen Empfänger (userId Pflicht). Geschrieben wird nur an aktive Nutzer mit aktiver Rolle, die jedes Recht aus requiredPermissions halten; gespeicherte requiredPermissions = Empfänger-Rechte ∪ Inhaltsrechte.
  • NotificationCategory: TASK_DUE, DIGEST, SUGGESTION, WITHDRAWAL, SEASON, SYSTEM.
  • dedupeKey = "<präfix>:<userId>"; Präfixe: task-due:<taskId>:<Tag>, digest:<Tag>, suggestion:<suggestionId>, withdrawal-conflict:<harvestId>:<colonyId>, withdrawal-ended:<treatmentId>, season-segment:<apiaryId>:<segment>:<Tag>, job-failed:<job>:<Tag>, telegram-token-invalid:<Tag>.
  • link = relativer App-Pfad: beginnt mit /, nicht mit // oder /\, kein Schema, ≤ 300 Zeichen; sonst Programmfehler (Error).
  • Erinnerungen: NotificationPreference Standard taskDueEnabled true, digestEnabled true, digestTime '06:30' (05:00–12:00, sonst 400 „Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.“), Ruhezeit 21:00–06:30 (nur paarweise leerbar), telegramEnabled false, telegramCategories [TASK_DUE, DIGEST], telegramDetail true. Fehlt die Zeile, gelten die Standards.

Telegram

  • TELEGRAM_BOT_TOKEN nur im Worker (Secret apicore-telegram, optional: true), Format ^\d{5,}:[A-Za-z0-9_-]{30,}$, in Produktion kein Platzhalter (change-me…); sonst startet der Worker nicht. TELEGRAM_BOT_USERNAME (ConfigMap) in api und Worker. Ohne Token laufen beide Telegram-Schleifen nicht. API-Seite: Der API-Pod hat nie das Token; „Telegram ist eingerichtet“ heißt dort telegramBotAvailable() = TELEGRAM_BOT_USERNAME nicht leer (Link-Token 409, NotificationSettingsView.telegram.configured, TelegramStatusView.configured). Ob der Empfang tatsächlich läuft, zeigt nur TelegramPollState (Task 37).
  • Verbinden: Step-up (Passwort, bei MFA TOTP), Drossel 10/min, Token 32 Zufallsbytes Base64url (43 Zeichen), gespeichert nur SHA-256 (hex), 10 Minuten, einmalig, neues Token entwertet ältere. Nicht @Idempotent() (Antwort enthält ein Geheimnis, Annahme 11).
  • Versand telegram-send: Takt 5 000 ms, Weckkanal notification_delivery, Batch 25, je Batch höchstens eine Zustellung je Nutzer bzw. Chat, ≤ 25 Nachrichten/s, je Chat ≥ 1 100 ms Abstand, Timeout 10 s, Klartext ohne parse_mode, Steuerzeichen entfernt, ≤ 600 Zeichen, Link-Vorschau aus. 429 → retry_after, kein Fehlversuch; 403/400 „chat not found“ → Verbindung lösen, SKIPPED; 401 → 15 min warten (kein Fehlversuch), Meldung an system:manage einmal je Tag; 5xx/Timeout/Netz → retryDelayMs, nach 8 Versuchen FAILED. Veraltet: TASK_DUE/DIGEST nur am selben Berliner Tag, sonst ≤ 24 h.
  • availableAt = spätester Wert aus jetzt, Kategorie-Zeit (DIGEST = Morgenzeit, TASK_DUE = Morgenzeit + 1 min) und Ende der Ruhezeit, falls jetzt darin liegt; Systemantworten des Bots umgehen die Ruhezeit.
  • Empfang telegram-updates: getUpdates mit timeout=25, allowed_updates=["message"], Abruf-Timeout 35 s; Lease 60 s nur mit DB-Uhr (SQL aus Spec 9a.7); ohne Lease oder nach 409 30 s warten; nur private Chats; höchstens 3 Antworten je Chat in 10 Minuten; updateOffset = update_id + 1 in derselben Transaktion wie die Wirkung.
  • Texte exakt: LINKED „Verbunden mit ApiCore. Hier kommen deine Erinnerungen. /stop beendet das.“, LINK_INVALID „Der Link ist ungültig oder abgelaufen. Bitte in ApiCore neu verbinden.“, STOPPED „Abgemeldet. Du bekommst keine Nachrichten mehr.“, HELP „Dieser Bot schickt Erinnerungen aus ApiCore. Verbinden geht nur in ApiCore unter Profil > Benachrichtigungen. /stop beendet.“

Wetter (T4b)

  • WEATHER_HISTORY_SOURCE (Standard open-meteo), OPEN_METEO_FORECAST_URL (https://api.open-meteo.com/v1/forecast), OPEN_METEO_ARCHIVE_URL (https://archive-api.open-meteo.com/v1/archive), OPEN_METEO_API_KEY (leer, Secret). Nur https:, nur aus der Konfiguration. Schlüssel und URL nie in Logs oder lastError.
  • Schleife phenology-weather: 3 600 000 ms, kein Weckkanal; Koordinaten auf 0,01° gerundet, gleiche Punkte teilen eine Anfrage; letzte 90 Tage über Forecast mit past_days=92, ältere Lücken über Archiv; Timeout 8 s; Antwort ≤ 1 000 000 Byte; Werte außerhalb −50…50 °C → null. cleanup löscht ApiaryWeatherDay vor dem 1. Januar von (Jahr − 4).

Rechte: Gruppe „Pflanzenentwicklung“, Ressource phenology: read „Ansehen“, create „Melden“, update „Bearbeiten“. Migration vergibt read an Nicht-System-Rollen mit colonies:read, create/update an Nicht-System-Rollen mit inspections:create (Muster winter-checks, Annahme 16). Jede neue Route trägt @RequirePermission oder @AnyAuthenticatedUser (Profil-Routen); test/authorization-coverage.e2e-spec.ts erfasst alle Controller automatisch.

Projektregeln

  • Sicherheit hat Priorität 1. Konsistenz ist oberstes Gesetz: ein Muster, gemeinsame Helfer, keine Kopien. Pflicht-tx als erster Parameter, optionaler tx als letzter.
  • Read-modify-write nur unter Zeilensperre: Jede Änderung, die auf dem bisherigen Zustand aufbaut (Statuswechsel, Differenzbuchung, Zusammenführen mit gespeicherten Werten, Audit mit beforeData), liest before innerhalb der Transaktion nach lockRowForUpdate(tx, table, id) aus src/prisma/row-lock.ts – nie aus einem Lesen vor der Transaktion. Neue Tabellen kommen in die feste Bezeichner-Map LOCKABLE_TABLES (nie ein Tabellenname aus Eingaben); T4a-Tabellen trägt Task 1 ein, T4b-Tabellen Task 23, T4c-Tabellen Task 34. Sperren mehrere Zeilen, dann in fester Reihenfolge Colony → Suggestion → Task → Queen → AutomationRule → übrige Tabellen in Map-Reihenfolge, innerhalb einer Tabelle Schlüssel aufsteigend (die Map steht in genau dieser Reihenfolge). Mehrere Zeilen einer Tabelle sperrt lockRowsForUpdate(tx, table, ids) (eine Anweisung, Schlüssel aufsteigend), nie eine Schleife über lockRowForUpdate. Compare-and-Set bleibt zusätzlich, wo es im Plan steht. Reine Ein-Anweisungs-Updates ohne gelesenen Vorzustand (updateMany … where status = …, Lease-SQL, Claims) brauchen keine zusätzliche Sperre.
  • Sperren bei Vorschlägen: SuggestionsService.proposeIn sperrt zuerst die Volkszeile (lockRowForUpdate(tx, 'Colony', colonyId), wenn colonyId gesetzt; syncColonyIn und der Handler suggestions.colony-ended halten sie dann schon) und danach die Regelzeile (lockRuleIn). Das serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, sodass die Absichts-Prüfung über Regeln hinweg keine Dublette durchlässt; die Reihenfolge entspricht der Map (Colony vor AutomationRule). Kein Pfad sperrt erst AutomationRule und dann Colony; accept, dismiss, reopen und apply-cleanup sperren Suggestion → Task → Queen und nie Colony.
  • Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20); jede neue oder geänderte Update-DTO steht in src/common/validation/update-dto-null-handling.spec.ts.
  • Handler schreiben nur über tx, keine Netzaufrufe; Handler-IDs <consumer>.<name>; jedes Modul mit Handlern wird von AppModule und WorkerModule importiert. Payloads und evidence tragen nur IDs, Enum-Werte, Zahlen, Tage.
  • Rechte-Auflösung in Worker und Jobs: PermissionResolverService kommt in der API aus dem globalen PermissionsModule, im Worker und in den Jobs aus dem globalen, resolver-only JobPermissionsModule (src/jobs/jobs.module.ts; Task 1 exportiert die Klasse, WorkerModule importiert sie ab Task 4, JobsModule hat sie schon). Kein Feature-Modul importiert PermissionsModule (sein PermissionCatalogSyncService schriebe den Katalog bei jedem Worker- und Job-Start neu) und keines deklariert den Resolver selbst; Module mit Handlern oder Schritten (Suggestions, Autopilot, Withdrawal, Reminders, Telegram) injizieren ihn einfach.
  • Beobachtungen werden nie still vorbelegt; vorbelegt wird nur, was keine Beobachtung ist (Glasgröße, MHD aus dem Artikel). Warnungen sperren nie (Annahme 10), außer der Bestätigung beim Wassergehalt.
  • UI deutsch, kurze Sätze, ein Hauptknopf je Ansicht, Zustand nie nur über Farbe (Icon + Wort), Touch-Ziele ≥ 44 px, Sheet-Zeilen 56 px (min-h-14), role="status"/aria-live="polite" für Warnungen und Live-Werte, WCAG 2.2 AA. Code-Bezeichner, Log- und Fehlertexte (außer Nutzertexten) englisch.
  • TDD: zuerst der fehlschlagende Test. Testnamen englisch.
  • Agents committen nicht. Jeder Task endet mit „Verifikation“ und einer Dateiliste an den Orchestrator; der Orchestrator committet zentral.
  • Backend-Checks: npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci; einzelne e2e-Datei npx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand.
  • Frontend: vor Next.js-Code node_modules/next/dist/docs/ lesen (AGENTS.md); Checks npm run lint && npm run typecheck && npx vitest run <pfade>. Diagramme: vorher den dataviz-Skill laden.
  • ARCHITECTURE.md und TASKS.md trä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

  1. Rechte-Leck über Vorschläge, Schritte, Übersicht und Meldungen. Wer kein varroa:read hat, darf weder „12,4 Milben pro Tag“ noch den Vorschlag, noch dessen Zählung, noch eine Telegram-Nachricht dazu sehen.
    • Task 5 e2e a user without varroa:read sees neither the varroa suggestion nor its count
    • Task 15 e2e next steps never carry a varroa reason without varroa:read
    • Task 3 e2e a fan-out for colonies:read never reaches a user without it
    • Task 16 e2e the digest of a user without varroa or inspection rights has no such line
    • Task 35 e2e a revoked permission skips the delivery at send time
  2. Doppelte Vorschläge oder Aufgaben bei erneuter Zustellung, Doppeltipp, zwei Geräten, parallelen Handlern oder Kette plus Autopilot.
    • Task 4 unit proposeIn with an existing dedupe key creates nothing
    • Task 5 e2e a second accept with another key answers 409 and leaves exactly one task
    • Task 7 e2e a dismissed suggestion is not proposed again for the same inspection
    • Task 11 e2e a split with QUEEN plus a later assignment yields one acceptance suggestion
    • Task 14 e2e two colony syncs in parallel leave one INSPECT suggestion
    • Task 14 e2e an expired INSPECT suggestion of the same anchor is proposed again on the next sync
  3. Datenmigrationen und Bewegungsbuch verfälschen Bestände oder verlieren Inhalte (Futter-Freitext, Rundmeldungen mit read, Altabfüllungen, parallele Abfüllungen).
    • Task 2 e2e free-text feed moves into notes and known words map to the enum
    • Task 3 e2e broadcasts are copied per active user with their read flag
    • Task 23 e2e legacy bottlings keep amount = initialAmount + movements
    • Task 29 e2e two parallel bottlings over the rest: exactly one 400
    • Task 29 guard honey batch amount is written only by the ledger
  4. Telegram: Token-Leck, doppelte Zustellung, fremde Verbindung.
    • Task 34 unit the token never appears in TelegramApiError or loggableError
    • Task 35 e2e two send loops in parallel deliver each message exactly once
    • Task 36 e2e link token -> /start links the chat once; a second /start with the same token answers LINK_INVALID
    • Task 36 e2e only the lease holder polls; a second worker waits
    • Task 36 unit a new link shows an in-app notice with the Telegram first name
  5. Zeit: Berliner Tag gegen UTC, Sommerzeit, Monatsende, Ruhezeit über Mitternacht.
    • Task 1 unit berlinToday at 2026-03-28T23:30Z is 2026-03-29
    • Task 1 unit berlinDateTime 2026-03-29 06:30 is 04:30Z on the day DST starts
    • Task 13 unit the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey
    • Task 30 unit best before 2027-01-31 plus 1 month is 2027-02-28
    • Task 35 unit a delivery created at 22:00 inside quiet hours 21:00–06:30 waits until 06:30 the next day

Abhängigkeiten und Parallelisierung

Welle Tasks (parallel, verschiedene Dateien) wartet auf
1 1 Rückgrat, T3
2 2, 4, 13, 22 1
3 3, 5, 6, 10 3 → 2; 5 → 4; 6 → 1, 2; 10 → 2
4 7, 8, 11, 14, 17, 19 7 → 3, 4, 6; 8 → 2, 3; 11 → 4, 10; 14 → 4, 10, 13; 17 → 5; 19 → 6
5 9, 12, 15, 20 9 → 4, 8; 12 → 3, 6, 10; 15 → 5, 6, 14; 20 → 8, 10
6 16, 18, 21 16 → 3, 14, 15; 18 → 9, 15; 21 → 3, 12
— Rollout T4a 1–22
7 23 T4a
8 24, 25, 28, 34 23 (34 → T4a)
9 26, 27, 29, 35 26 → 25; 27 → 25; 29 → 28; 35 → 34
10 30, 31, 32, 36, 37 30 → 29; 31 → 26; 32 → 28, 29; 36, 37 → 35
11 33, 38 33 → 30; 38 → 36, 37
— Rollout T4b + T4c 23–38

Geteilte Dateien, deshalb je Welle nur ein Task:

  • prisma/schema.prisma, src/backup/backup.service.ts (+ Spec): 1 (W1), 2 (W2), 3 (W3), 23 (W7), 34 (W8)
  • src/prisma/row-lock.ts (+ Spec): 1 (W1), 23 (W7), 34 (W8) – alle übrigen Tasks nutzen den Helfer nur
  • src/app.module.ts, src/worker/worker.module.ts: 4 (W2; Worker importiert dort auch JobPermissionsModule), 14 (W4), 12 (W5), 16 (W6), 25 (W8), 35 (W9, nur Worker)
  • src/jobs/jobs.module.ts: 1 (W1, nur export von JobPermissionsModule), 13 (W2), 14 (W4), 16 (W6), 25 (W8)
  • src/jobs/daily-evaluation.job.ts: 13 (W2), 14 (W4), 16 (W6), 25 (W8); src/jobs/daily-evaluation.rules.ts: 13 (W2), 6 (W3)
  • src/automation/automation.service.ts: 1 (W1), 2 (W2), 3 (W3), 7 (W4)
  • src/calendar-sync/calendar-sync.service.ts: 1 (W1), 3 (W3), 12 (W5); calendar-sync.handlers.ts: 10 (W3), 12 (W5)
  • src/colonies/colonies.service.ts: 10 (W3), 8 (W4), 9 (W5)
  • src/domain-events/domain-event-types.ts: 10 (W3), 9 (W5), 23 (W7)
  • src/common/validation/update-dto-null-handling.spec.ts: 3 (W3), 8 (W4), 26 (W9), 30 (W10)
  • src/apiaries/apiary-field.service.ts (+ Typen): 3 (W3), 8 (W4), 15 (W5)
  • src/system/job-run.service.ts: 13 (W2), 3 (W3)
  • src/suggestions/suggestions.module.ts: 4 (W2), 5 (W3), 11 (W4), 9 (W5); suggestions.controller.ts: 5 (W3), 9 (W5)
  • src/tasks/tasks.service.ts: 4 (W2), 9 (W5); src/queens/queens.service.ts: 10 (W3), 9 (W5)
  • src/bottling/bottling.service.ts: 12 (W5), 29 (W9), 30 (W10); src/honey-batches/honey-batches.service.ts: 28 (W8), 29 (W9), 30 (W10)
  • src/webhooks/webhook.handlers.ts: 10 (W3), 28 (W8); src/common/after-commit-usage.spec.ts: 10 (W3), 12 (W5), 28 (W8); src/withdrawal/withdrawal.handlers.ts: 12 (W5), 28 (W8)
  • src/autopilot/*: 14 (W4), 15 (W5), 27 (W9); src/season/season.service.ts: 13 (W2), 25 (W8)
  • src/notifications/notifications.service.ts: 3 (W3), 35 (W9); src/common/dates.ts: 1 (W1), 30 (W10)
  • src/worker/worker-runtime.service.ts: 24 (W8), 35 (W9), 36 (W10)
  • src/treatments/withdrawal-window.ts, src/treatments/treatments.service.ts: nur 6 (W3); Task 12 fasst weder sie noch daily-evaluation.rules.ts an
  • src/suggestions/suggestions.service.ts: 4 (W2), 7 (W4); src/jobs/cleanup.job.ts: 24 (W8), 35 (W9); src/auth/permissions/permission-resolver.service.ts: nur 35 (W9)
  • src/bottling/bottling.module.ts: 12 (W5), 29 (W9); src/harvests/harvests.module.ts: nur 12 (W5); .gitignore: nur 2 (W2)
  • .env.example, deploy/k8s/**: 24 (W8), 37 (W10)
  • Frontend: src/app/(app)/colonies/[id]/page.tsx: 20 (W5), 18 (W6); src/components/suggestions/suggestion-card.tsx: 17 (W4), 18 (W6); src/app/(app)/profile/page.tsx: 21 (W6), 38 (W11); src/app/(app)/apiaries/[id]/*: 18 (W6), 31 (W10)
  • ARCHITECTURE.md, TASKS.md: nur der Orchestrator.

Tasks

Lieferung T4a – Vorschläge, Ketten, Autopilot-Kern, Erinnerungen in der App

Task 1: Schema I – Regel-Werte, Vorschlagsmodell, Tagesrechnung

Files

  • Modify: prisma/schema.prisma
  • Create: prisma/migrations/20261014100000_t4_enum_values/migration.sql, prisma/migrations/20261014100100_t4_suggestions/migration.sql
  • Create: src/common/dates.ts (+ .spec.ts), src/season/season-types.ts
  • Create: src/automation/rule-config/ mit rule-config.types.ts, parse-helpers.ts, default-rules.ts, simple.config.ts, varroa-threshold.config.ts, inspection-interval.config.ts, chain.config.ts, split-schedule.config.ts, water-content.config.ts, season-autopilot.config.ts, phenology.config.ts, rule-config.reader.ts, index.ts, rule-config.spec.ts
  • Modify: src/automation/automation.service.ts (DEFAULT_RULES aus default-rules.ts; update validiert und liest before unter Sperre), src/automation/varroa-threshold.ts (+ Spec), src/calendar-sync/calendar-sync.service.ts (WITHDRAWAL vorläufig aus), src/backup/backup.service.ts (+ Spec), src/prisma/row-lock.ts (+ Spec; T4a-Tabellen, Schlüsselspalte je Eintrag, auch für lockRowsForUpdate/lockRowsForShare), src/jobs/jobs.module.ts (nur JobPermissionsModule wird exportiert, damit WorkerModule ihn ab Task 4 importieren kann)
  • Test: test/automation.e2e-spec.ts, neu test/t4-schema.e2e-spec.ts
  • Docs: ARCHITECTURE §8 „Export/Import“: suggestion ist Teil des Bundles (Import vor task).

Interfaces

  • Consumes: –
  • Produces:
// Migration 1: nur Enum-Werte
enum AutomationTriggerType { … INSPECTION_INTERVAL QUEEN_CELLS_SWARM_CHECK QUEEN_ACCEPTANCE_CHECK SPLIT_SCHEDULE COLONY_ENDED_CLEANUP WITHDRAWAL_ENDED HONEY_WATER_CONTENT SEASON_AUTOPILOT PHENOLOGY }
enum BatchMovementType { … BOTTLING }
enum CalendarSyncSourceType { … WITHDRAWAL }
// Migration 2: SuggestionStatus, SuggestionMode, SuggestionSource, SuggestionIntent und model Suggestion exakt wie Spec 4.1;
// Task.sourceSuggestionId String? (Relation onDelete: SetNull, @@index([sourceSuggestionId])); Gegenrelationen an User und Colony
// src/common/dates.ts
export function berlinToday(now?: Date): string;               // YYYY-MM-DD in Europe/Berlin
export function berlinDayOf(instant: Date): string;
export function berlinDateTime(day: string, hhmm: string): Date; // Berliner Ortszeit -> Instant (Sommerzeit beachtet)
export function dayToDate(day: string): Date;                   // Mitternacht UTC
export function addDays(day: string, days: number): string;
export function daysBetween(from: string, to: string): number;
// src/season/season-types.ts (die Prisma-Enums folgen in Task 23 mit denselben Werten)
export const PHENOLOGY_PHASES = ['SALWEIDE','KIRSCHE','APFEL','HOLUNDER','LINDE','TRACHTENDE','HERBST','WINTER'] as const;
export const SEASON_SEGMENTS = ['WINTERRUHE','FRUEHJAHR','SCHWARMZEIT','SOMMER','SPAETSOMMER','HERBST'] as const;
export const PHASE_SOURCES = ['REPORT','GTS','CALENDAR'] as const;
export type PhenologyPhaseKey = (typeof PHENOLOGY_PHASES)[number];
export type SeasonSegmentKey = (typeof SEASON_SEGMENTS)[number];
export type PhaseSourceKey = (typeof PHASE_SOURCES)[number];
export type SeasonNote = 'NO_COORDINATES' | 'WEATHER_GAPS' | 'WEATHER_STALE' | null;
export interface DailyMean { day: string; meanTempC: number | null }   // Tagesmittel; Task 24 (Wetterquelle) und Task 25 (GTS) importieren es von hier
export interface SeasonState {
  apiaryId: string; year: number; phase: PhenologyPhaseKey | null; phaseSource: PhaseSourceKey | null;
  reportedOn: string | null; segment: SeasonSegmentKey; segmentSince: string; springSince: string | null;
  gts: number | null; gtsThroughDay: string | null; note: SeasonNote;
}
// src/automation/rule-config
export interface RuleConfigIssue { field: string; message: string }
export interface RuleConfigParser<T> { readonly defaults: T; parse(json: unknown): T; validate(json: unknown): RuleConfigIssue[] }
export interface SimpleRuleConfig { notify: boolean; dueInDays?: number; minGrams?: Partial<Record<RawMaterialKind, number>> }
export interface NumberFormatConfig { format: string }
export interface VarroaThresholdConfig {
  naturalFall: { seasonMonths: number[]; yellowFrom: number; redAbove: number; winterRedAbove: number };
  infestationPercent: { summerMonths: number[]; summer: { yellowFrom: number; redAbove: number }; autumnMonths: number[]; autumn: { yellowFrom: number; redAbove: number } };
  youngColonyFactor: number; youngColonyTypes: ColonyType[]; recheckAfterDays: number; notify: boolean;
}
export interface InspectionIntervalConfig { intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number> }
export interface ChainConfig { dueInDays: number; notify: boolean }
export interface PlanStepConfig { step: string; offsetDays: number; windowDays?: number }
export interface SplitScheduleConfig { RAISE_OWN: PlanStepConfig[]; QUEEN_CELL: PlanStepConfig[]; QUEEN: PlanStepConfig[]; notify: boolean }
export interface WaterContentConfig { warnAbovePercent: number; confirmAbovePercent: number; byHoneyType: Record<string, { warnAbovePercent?: number; confirmAbovePercent?: number }> }
export const AUTOPILOT_RULE_IDS = ['A1','A2','A3','A4','A5','A6','A7','A8','A9','A10'] as const;
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
export interface SeasonAutopilotConfig { honeySuperMinFrames: number; varroaMeasureIntervalDays: number; winterVarroaWindow: { from: string; to: string };
  queenMaxAgeYears: number; suggestionTtlDays: number; maxStepsPerColony: number; rules: Record<AutopilotRuleId, boolean> }
export interface PhenologyConfig { useGts: boolean; gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>; calibrateFromReports: boolean;
  calibratePhases: PhenologyPhaseKey[]; calendarFallback: Record<PhenologyPhaseKey, string>; swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey }; maxGapShare: number }
export interface RuleConfigMap {   // ein Eintrag je AutomationTriggerType
  VARROA_THRESHOLD: VarroaThresholdConfig; INSPECTION_INTERVAL: InspectionIntervalConfig; QUEEN_CELLS_SWARM_CHECK: ChainConfig; QUEEN_ACCEPTANCE_CHECK: ChainConfig;
  SPLIT_SCHEDULE: SplitScheduleConfig; COLONY_ENDED_CLEANUP: Record<string, never>; WITHDRAWAL_ENDED: { notify: boolean }; HONEY_WATER_CONTENT: WaterContentConfig;
  SEASON_AUTOPILOT: SeasonAutopilotConfig; PHENOLOGY: PhenologyConfig;
  HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig; BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig; RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig; PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
  QUEENLESS_NO_EGGS: SimpleRuleConfig; FEED_STATUS_CRITICAL: SimpleRuleConfig; SWARM_MOOD_HIGH: SimpleRuleConfig; COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
  STOCK_ITEM_LOW: SimpleRuleConfig; RAW_MATERIAL_LOW: SimpleRuleConfig; TREATMENT_FOLLOW_UP: SimpleRuleConfig; FEEDING_FOLLOW_UP: SimpleRuleConfig;
}
export const RULE_CONFIG_PARSERS: { [K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]> };
export function ruleConfigOf<K extends AutomationTriggerType>(trigger: K, json: unknown): RuleConfigMap[K];
export function validateRuleConfig(trigger: AutomationTriggerType, json: unknown): RuleConfigIssue[];
export interface RuleDefaults { config: Record<string, unknown>; actionTemplate: string; active?: boolean }
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults>;   // neue Regeln mit actionTemplate ''
// rule-config.reader.ts
export interface ActiveRule<K extends AutomationTriggerType> { id: string | null; active: boolean; config: RuleConfigMap[K]; actionTemplate: string | null }
export function readRule<K extends AutomationTriggerType>(db: DbClient, trigger: K): Promise<ActiveRule<K>>; // keine Zeile -> Standards
export function lockRuleIn<K extends AutomationTriggerType>(tx: Prisma.TransactionClient, trigger: K): Promise<ActiveRule<K> & { id: string }>; // upsert aus DEFAULT_RULES, lockRowForUpdate(tx, 'AutomationRule', id), dann lesen
// src/prisma/row-lock.ts – Map-Einträge werden { table: Prisma.Sql; key: Prisma.Sql } (Standard-Schlüssel "id"); neu:
//   Suggestion, Queen, AutomationRule ("id") und NotificationPreference (Schlüssel "userId"; Tabelle entsteht in Task 3).
//   Map-Reihenfolge = Sperr-Reihenfolge: Colony, Suggestion, Task, Queen, AutomationRule, dann die bisherigen übrigen
//   (BottlingBatch, Feeding, Harvest, Inspection, Product, ProductionBatch, RawMaterialLot, StockItem, Treatment, VarroaMeasurement), NotificationPreference
//   lockRowForUpdate, lockRowsForUpdate und lockRowsForShare wählen und sortieren nach der Schlüsselspalte des Eintrags (SELECT 1 … ORDER BY <key>), nicht mehr fest nach "id"
export function lockRowForUpdate(tx: Prisma.TransactionClient, table: LockableTable, id: string): Promise<boolean>;   // Signatur unverändert (true = Zeile existiert)
  • Schritt 1: Failing Tests
// dates.spec.ts
it('berlinToday at 2026-03-28T23:30Z is 2026-03-29', () => expect(berlinToday(new Date('2026-03-28T23:30:00Z'))).toBe('2026-03-29'));
it('the 04:30 Berlin job time is the same calendar day', () => expect(berlinToday(new Date('2026-10-13T02:30:00Z'))).toBe('2026-10-13'));
it('berlinDateTime 2026-03-29 06:30 is 04:30Z on the day DST starts', () =>
  expect(berlinDateTime('2026-03-29', '06:30').toISOString()).toBe('2026-03-29T04:30:00.000Z'));
it('berlinDateTime 2026-10-25 06:30 is 05:30Z after DST ends', ...);
it('addDays crosses month ends', () => expect(addDays('2026-02-27', 2)).toBe('2026-03-01'));
it('daysBetween counts calendar days', () => expect(daysBetween('2026-05-01', '2026-05-09')).toBe(8));
// rule-config.spec.ts
it('every trigger has defaults and a parser', () => {
  for (const t of Object.values(AutomationTriggerType)) { expect(DEFAULT_RULES[t]).toBeDefined(); expect(RULE_CONFIG_PARSERS[t]).toBeDefined(); }
});
it('every default config parses to itself and validates without issues', ...);
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
  expect(ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall).toEqual({ seasonMonths: [3,4,5,6,7,8,9,10], yellowFrom: 5, redAbove: 7, winterRedAbove: 0.5 }));
it('falls back per invalid key and keeps the valid ones', () =>
  expect(ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'x', honeySuperMinFrames: 10 })).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
it('rejects SCHWARMZEIT 10 days with the field name', () =>
  expect(validateRuleConfig('INSPECTION_INTERVAL', { intervalDays: { SCHWARMZEIT: 10 } })).toEqual([{ field: 'intervalDays.SCHWARMZEIT', message: expect.stringContaining('7') }]));
it('rejects yellowFrom above redAbove', ...);
it('rejects a calendar fallback that runs backwards', ...);   // KIRSCHE '03-01' vor SALWEIDE '03-20'
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL', ...);
// varroa-threshold.spec.ts
it('reads naturalFall.redAbove from the band format', () => expect(varroaThresholdOf({ active: true, config: { naturalFall: { redAbove: 8 } } })).toBe(8));
// automation.e2e-spec.ts
it('PATCH a rule with an invalid known value answers 400 with the field', async () => {
  const res = await patch(`/api/v1/automation/rules/${seasonAutopilotId}`, { config: { queenMaxAgeYears: 0 } }).expect(400);
  expect(res.body.issues).toEqual([expect.objectContaining({ field: 'queenMaxAgeYears' })]);
});
it('lists the nine new rules with their defaults', ...);
// t4-schema.e2e-spec.ts
it('keeps at most one suggestion per dedupe key', ...);   // createMany skipDuplicates zweimal -> count 1, dann 0
// row-lock.spec.ts
it('locks NotificationPreference by "userId" and every other table by "id"', ...);   // SQL-Text prüfen, id als Parameter; gilt auch für lockRowsForUpdate und lockRowsForShare
it('rejects a table outside the map', ...);
// automation.service.spec.ts
it('update locks the rule row and reads before inside the transaction', ...);
// backup.service.spec.ts: bestehender Modell-Wächter scheitert für Suggestion; neu
it('exports and restores suggestions with their decision before tasks', ...);
  • Schritt 2: npx jest src/common/dates.spec.ts src/automation src/backup src/prisma/row-lock.spec.ts → FAIL (Module fehlen, Wächter listet Suggestion); e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • Migration 1 enthält nur ALTER TYPE … ADD VALUE (9 + 1 + 1). Migration 2: Enums, Tabelle Suggestion mit den drei Indizes aus Spec 4.1, Task.sourceSuggestionId mit FK und Index.
    • berlinToday/berlinDayOf über Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year, month, day }); berlinDateTime bestimmt den Offset am Zieltag (zweimal anwenden, damit der Umstellungstag stimmt).
    • Parser: parse-helpers.ts mit intIn, numberIn, bool, monthDay, months, enumList; parse fällt je Schlüssel auf den Standard zurück und loggt eine Warnung mit Trigger und Feld (ohne Wert); validate meldet nur vorhandene bekannte Schlüssel.
    • automation.service.ts: DEFAULT_RULES importieren; update läuft in einer Transaktion: lockRowForUpdate(tx, 'AutomationRule', id), before lesen (404 wie bisher), bei einem Objekt validateRuleConfig(before.triggerType, dto.config) (Issues → BadRequestException({ message: 'Die Einstellung ist ungültig.', issues })), schreiben, Audit mit tx.
    • row-lock.ts: Einträge mit Schlüsselspalte, neue Tabellen laut Interfaces (die zwölf bestehenden bleiben, Suggestion kommt hinter Colony, Queen und AutomationRule hinter Task); alle drei Helfer lesen die Schlüsselspalte aus dem Eintrag; bestehende Aufrufer unverändert.
    • jobs.module.ts: class JobPermissionsModule → export class JobPermissionsModule (sonst nichts).
    • varroaThresholdOf liest ruleConfigOf('VARROA_THRESHOLD', config).naturalFall.redAbove; Signatur bleibt (Task 7 löscht die Datei).
    • calendar-sync.service.ts: WITHDRAWAL: false im Schalter-Record und case 'WITHDRAWAL': return null; mit Kommentar „Schalter Task 3, Inhalt Task 12“.
    • Backup: suggestion in Export und Import, Import vor task, decidedByUserId wie andere Nutzer-IDs aufgelöst.
  • Schritt 4: gleiche Befehle → PASS; 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/automation.e2e-spec.ts test/t4-schema.e2e-spec.ts test/backup.e2e-spec.ts test/calendar-sync.e2e-spec.ts --runInBand → PASS
    • Docs-Text im Bericht; kein Commit

Task 2: Schema II – Futter-Vokabular, Ableger-Herkunft, Varroa-Probe

Files

  • Modify: prisma/schema.prisma
  • Create: prisma/migrations/20261014100200_t4_feed_vocabulary/migration.sql, …100300_t4_split_queen_source/migration.sql, …100400_t4_varroa_sample_size/migration.sql
  • Create: src/common/feed-vocabulary.ts (+ .spec.ts), test/utils/migration-harness.ts, test/t4-migrations.e2e-spec.ts
  • Modify: .gitignore (/.tmp-migrations/), src/inspections/dto/create-inspection.dto.ts (feedStatus als ColonyCondition), src/automation/automation.service.ts (feedStatus === 'CRITICAL'), src/import/import.service.ts (+ Spec), src/export/colony-record-card.ts (Label), src/export/export.service.ts (Label im CSV), src/backup/backup.service.ts (+ Spec)
  • Test: test/inspections.e2e-spec.ts
  • Docs: neue Annahme „Futter-Vokabular“ (Abbildung, Freitext nach notes, Webhook inspection.created trägt feedStatus künftig als GOOD/WEAK/CRITICAL – auch für den Changelog).

Interfaces

  • Consumes: Task 1.
  • Produces:
// Migration 3: Inspection.feedStatus ColonyCondition? (Abbildung Spec 4.4)
// Migration 5: enum SplitQueenSource { RAISE_OWN QUEEN_CELL QUEEN }; ColonyLineage.queenSource SplitQueenSource?
// Migration 7a: VarroaMeasurement.sampleBees Int?
export const FEED_CONDITION_LABELS_DE: Record<ColonyCondition, string>;
export interface LegacyFeedMapping { feedStatus: ColonyCondition | null; notesSuffix: string | null } // 'Futter (alt): <Text>'
export function feedConditionFromLegacy(text: string | null | undefined): LegacyFeedMapping;
export function appendLegacyFeedNote(notes: string | null, suffix: string | null): string | null; // Trenner '\n'
// test/utils/migration-harness.ts – frisches Postgres-Schema, migriert bis vor eine Migration
export interface ScratchDatabase {
  readonly schema: string;
  query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
  migrateThrough(migrationName: string): Promise<void>;
  drop(): Promise<void>;
}
export function createScratchDatabase(beforeMigration: string): Promise<ScratchDatabase>;
  • Schritt 1: Failing Tests
// feed-vocabulary.spec.ts – Tabelle aus Spec 4.4
it.each([[' Ausreichend ', 'GOOD', null], ['niedrig', 'WEAK', null], ['KRITISCH', 'CRITICAL', null], ['', null, null], [null, null, null],
  ['halb voll', null, 'Futter (alt): halb voll']])('maps %p', (text, feedStatus, notesSuffix) =>
  expect(feedConditionFromLegacy(text)).toEqual({ feedStatus, notesSuffix }));
// t4-migrations.e2e-spec.ts (Harness, Timeout 120 s)
it('free-text feed moves into notes and known words map to the enum', async () => {
  const db = await createScratchDatabase('20261014100200_t4_feed_vocabulary');
  // Volk + 3 Durchsichten: 'niedrig'/notes null, 'halb voll'/notes null, 'halb voll'/notes 'x'
  await db.migrateThrough('20261014100200_t4_feed_vocabulary');
  expect(await db.query('SELECT "feedStatus", notes FROM "Inspection" ORDER BY "createdAt"')).toEqual([
    { feedStatus: 'WEAK', notes: null }, { feedStatus: null, notes: 'Futter (alt): halb voll' }, { feedStatus: null, notes: 'x\nFutter (alt): halb voll' }]);
});
it('leaves no inspection whose old feed text was lost', ...);   // Prüfabfrage Spec 4.12
// inspections.e2e-spec.ts
it('rejects a free-text feedStatus and accepts WEAK', ...);    // 'niedrig' -> 400, 'WEAK' -> 201
// import.service.spec.ts
it('maps CSV feed text "niedrig" to WEAK and keeps unknown text in the notes', ...);
// backup.service.spec.ts
it('maps a legacy free-text feedStatus of an old bundle on import', ...);
  • Schritt 2: npx jest src/common/feed-vocabulary.spec.ts src/import src/backup src/export und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • Migration 3 von Hand: neue Spalte "feedCondition" "ColonyCondition", UPDATE … SET "feedCondition" = CASE lower(btrim("feedStatus")) WHEN 'ausreichend' THEN 'GOOD' WHEN 'niedrig' THEN 'WEAK' WHEN 'kritisch' THEN 'CRITICAL' END, Freitext anhängen (concat_ws(E'\n', NULLIF(notes, ''), 'Futter (alt): ' || btrim("feedStatus"))), Zahl umgezogener Freitexte per RAISE NOTICE in einem DO-Block, alte Spalte löschen, neue umbenennen.
    • Harness: legt Schema mig_<random> an, kopiert die Migrationsordner vor dem Ziel samt migration_lock.toml in .tmp-migrations/<random>/ unter ApiCore-Backend/ (git-ignoriert, nicht im OS-Temp, damit prisma.config.ts das installierte prisma-Paket auflöst), schreibt dort eine prisma.config.ts (schema absolut, migrations.path = dieses Verzeichnis, datasource.url = Test-URL mit ?schema=mig_…) und ruft npx prisma migrate deploy --config .tmp-migrations/<random>/prisma.config.ts; query über pg mit SET search_path; migrateThrough kopiert bis einschließlich Ziel und ruft erneut deploy; drop löscht Schema und das Verzeichnis unter .tmp-migrations/.
    • Regel FEED_STATUS_CRITICAL prüft inspection.feedStatus === 'CRITICAL'.
    • Stockkarte (PDF) und CSV-Export drucken FEED_CONDITION_LABELS_DE; CSV-Import und Backup-Import nutzen feedConditionFromLegacy + appendLegacyFeedNote.
  • Schritt 4: → PASS; Drift-Check → 0.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • npx jest --config ./test/jest-e2e.json test/t4-migrations.e2e-spec.ts test/inspections.e2e-spec.ts test/import.e2e-spec.ts test/export.e2e-spec.ts test/automation.e2e-spec.ts --runInBand → PASS
    • kein Commit

Task 3: Schema III – Meldungen je Nutzer, Kalender-Schalter, besetzte Waben, Einstellungen

Files

  • Modify: prisma/schema.prisma
  • Create: prisma/migrations/20261014100500_t4_notification_fanout/, …100600_t4_calendar_withdrawal/, …100700_t4_occupied_frames/, …100800_t4_notification_settings/ (je migration.sql)
  • Create: src/notifications/notification-link.ts, src/notifications/notification-preferences.service.ts (+ Spec), src/notifications/notification-settings.controller.ts, src/notifications/dto/update-notification-settings.dto.ts, src/notifications/no-broadcast-notifications.spec.ts
  • Modify: src/notifications/notifications.service.ts (+ Spec), notifications.controller.ts, notifications.module.ts, src/automation/automation.service.ts, src/system/job-run.service.ts (+ Spec), src/apiaries/apiary-field.service.ts (+ Spec), src/calendar-sync/calendar-sync.service.ts, src/calendar-sync/calendar-sync-settings.service.ts (+ Spec; update bildet die Felder von Hand ab, syncWithdrawal fehlt sonst), src/calendar-sync/dto/update-calendar-sync-settings.dto.ts, src/common/validation/update-dto-null-handling.spec.ts, src/backup/backup.service.ts (+ Spec)
  • Test: test/notifications.e2e-spec.ts, test/t4-migrations.e2e-spec.ts (ergänzen), test/calendar-sync.e2e-spec.ts
  • Docs: neue Annahme „Benachrichtigungen je Nutzer“ (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von Rückgrat-Spec §4, Begründung aus den Spec-Entscheidungen); §8: NotificationPreference im Backup.

Interfaces

  • Consumes: Task 1, 2.
  • Produces:
// Migration 8: Rundmeldungen je aktivem Nutzer kopieren (gleiches read, createdAt), Originale löschen, userId NOT NULL, dedupeKey String? @unique
// Migration 9: CalendarSyncSettings.syncWithdrawal Boolean @default(true)
// Migration 10: Colony.occupiedFrames Int? + CHECK ("occupiedFrames" BETWEEN 0 AND 40); keine Datenänderung an strength
// Migration 11a: enum NotificationCategory; Notification.category (Altzeilen: CASE WHEN "relatedType" = 'JobRun' OR "relatedType" IS NULL THEN 'SYSTEM' ELSE 'SUGGESTION' END – Fehlermeldungen der Jobs tragen relatedType 'JobRun'; danach NOT NULL),
//   link String?, requiredPermissions String[] @default([]); model NotificationPreference exakt wie Spec 4.15
export interface CreateNotificationInput {
  userId: string; priority: NotificationPriority; category: NotificationCategory; message: string;
  relatedType?: string | null; relatedId?: string | null; link?: string | null;
  requiredPermissions?: readonly PermissionKey[]; dedupeKey?: string | null;
}
export type FanOutNotificationInput = Omit<CreateNotificationInput, 'userId' | 'dedupeKey'> & { dedupeKeyPrefix?: string };
NotificationsService.record(input: CreateNotificationInput, tx?: Prisma.TransactionClient): Promise<Notification | null>; // null: Dublette oder Empfänger ohne Recht
NotificationsService.recordForUsers(userIds: readonly string[], input: FanOutNotificationInput, tx?: Prisma.TransactionClient): Promise<Notification[]>;
NotificationsService.recordForPermission(permission: PermissionKey | readonly PermissionKey[], input: FanOutNotificationInput, tx?: Prisma.TransactionClient): Promise<Notification[]>;
NotificationsService.findOwn(userId: string, options?: { unreadOnly?: boolean }): Promise<NotificationView[]>; // höchstens 100, neueste zuerst
NotificationsService.markRead(id: string, userId: string): Promise<NotificationView>;                         // fremd oder unbekannt -> 404
NotificationsService.markAllRead(userId: string): Promise<{ updated: number }>;
export interface NotificationView { id: string; priority: NotificationPriority; category: NotificationCategory; message: string;
  relatedType: string | null; relatedId: string | null; link: string | null; read: boolean; createdAt: Date }
export function assertAppLink(link: string): void;
export function notificationDedupeKey(prefix: string, userId: string): string;   // `${prefix}:${userId}`
export interface NotificationSettingsView { taskDueEnabled: boolean; digestEnabled: boolean; digestTime: string; quietFrom: string | null;
  quietUntil: string | null; telegramCategories: NotificationCategory[]; telegramDetail: boolean }       // Task 36 ergänzt `telegram`
NotificationPreferencesService.getOrCreate(userId: string, db?: DbClient): Promise<NotificationPreference>;
NotificationPreferencesService.update(userId: string, dto: UpdateNotificationSettingsDto): Promise<NotificationPreference>;
// GET/PATCH /profile/notification-settings  @AnyAuthenticatedUser (nie API-Schlüssel); POST /notifications/read-all (Route vor :id)
// JobRunService: Fehlermeldung über recordForPermission(JOB_FAILURE_PERMISSION[job], { category: 'SYSTEM', dedupeKeyPrefix: `job-failed:${job}:${tag}` }); relatedType bleibt 'JobRun' (relatedId = JobRun-ID)
  • Schritt 1: Failing Tests
// notifications.service.spec.ts
it('record without userId does not compile', () => {
  // @ts-expect-error userId fehlt
  void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
});
it('recordForPermission writes one row per active user holding every permission, with a key per recipient', ...); // 'p:u1', 'p:u2'
it('recordForUsers skips users missing a required permission', ...);
it('stores the union of recipient and content permissions', ...);
it('a repeated dedupe key writes nothing', ...);                         // createManyAndReturn skipDuplicates -> []
it('rejects a link with a scheme, // or a backslash', () => expect(() => assertAppLink('//evil.example')).toThrow());
// notification-preferences.service.spec.ts
it('creates the defaults on first read', ...);
it('rejects 04:30 as digest time with the German message', ...);         // 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.'
it('clears quiet hours only as a pair', ...);
it('update locks the preference row by userId and merges with the stored values read inside the transaction', ...);
// no-broadcast-notifications.spec.ts (Wächter)
it('Notification rows are written only by NotificationsService and the backup restore', () =>
  expect(filesMatching(/\bnotification\.(create|createMany|createManyAndReturn|upsert)\(/)).toEqual(['src/backup/backup.service.ts', 'src/notifications/notifications.service.ts']));
it('every Notification write in those two files carries a userId (spec 14.3)', ...);   // Wächter prüft im Treffer-Ausschnitt `userId`; die Wiederherstellung schreibt nie ohne
// notifications.e2e-spec.ts
it('reading is per user: marking one copy read leaves the other unread', ...);
it('a fan-out for colonies:read never reaches a user without it', ...);
it('read-all marks only own notifications', ...);
it('reading a foreign notification answers 404', ...);
it('an API key gets 403 on /profile/notification-settings', ...);
it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', ...);
// t4-migrations.e2e-spec.ts
it('broadcasts are copied per active user with their read flag', ...);    // 2 aktive, 1 inaktiver Nutzer
it('legacy job-failure rows (relatedType JobRun) and rows without relatedType get SYSTEM, all others SUGGESTION, before NOT NULL', ...);
it('occupied frames migration leaves strength untouched', ...);
// calendar-sync.e2e-spec.ts
it('syncWithdrawal is on by default and can be switched off', ...);   // PATCH speichert das Feld (calendar-sync-settings.service bildet von Hand ab)
// backup.service.spec.ts
it('restore keeps the userId of a notification and skips a row whose user does not exist in the target', ...);
it('an old bundle notification without userId is copied per active user: category SYSTEM for relatedType JobRun or none, SUGGESTION otherwise', ...);
  • Schritt 2: npx jest src/notifications src/system src/apiaries src/common/validation src/backup und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • Empfänger in einer Abfrage: user.findMany({ where: { id?: { in }, isActive: true, role: { status: 'ACTIVE', AND: keys.map((key) => ({ permissions: { some: { permission: { key } } } })) } } }); danach notification.createManyAndReturn({ data, skipDuplicates: true }) (Task 35 braucht die Zeilen).
    • assertAppLink: /^\/(?![\/\\])[A-Za-z0-9\-._~\/?=&%]{0,299}$/.
    • Controller: GET /notifications → findOwn, POST /notifications/read-all → markAllRead, POST /notifications/:id/read → markRead (eine Anweisung updateMany({ where: { id, userId } }), count 0 → 404; kein vorheriges Lesen).
    • NotificationPreferencesService.update in einer Transaktion: getOrCreate(userId, tx) (Upsert), lockRowForUpdate(tx, 'NotificationPreference', userId), gespeicherte Werte lesen, mit dem DTO zusammenführen, Paar-Regel der Ruhezeit prüfen, schreiben.
    • Bestand: record (bisher userId?, liefert Notification) und recordForPermission (bisher ein PermissionKey, liefert die Zahl) bekommen die Signaturen oben; die zwei Aufrufer automation.service.ts (Rundmeldung über record ohne userId) und job-run.service.ts (notifyFailure) werden in diesem Task angepasst, ihre Specs und findAll/markRead (bisher Forbidden für fremde Meldungen, jetzt 404) ebenso. In notifyFailure bleiben Advisory-Lock und Tagesprüfung bestehen; sie sind ein zusätzlicher Schutz vor dem dedupeKey.
    • automation.service.ts: die bisherige Rundmeldung wird recordForPermission('tasks:read', { category: 'SUGGESTION', … }, tx) (Task 7 ersetzt den Pfad).
    • apiary-field.service.ts: Meldungszählung nur userId des Aufrufers (Task 15 stellt auf Vorschläge um).
    • calendar-sync.service.ts: WITHDRAWAL: settings.syncWithdrawal; DTO-Feld syncWithdrawal mit @IsOptionalNotNull() @IsBoolean(). calendar-sync-settings.service.ts: update bildet die Felder von Hand ab (data: { enabled: dto.enabled, … syncQueenAssignment: dto.syncQueenAssignment }), dort syncWithdrawal: dto.syncWithdrawal ergänzen; sonst bliebe der Schalter wirkungslos.
    • UpdateNotificationSettingsDto: taskDueEnabled, digestEnabled, telegramDetail (@IsOptionalNotNull() @IsBoolean()); digestTime (@IsOptionalNotNull(), HH:MM, 05:00–12:00); quietFrom/quietUntil (HH:MM oder null; nach dem Zusammenführen beide gesetzt oder beide null, sonst 400 „Bitte Beginn und Ende der Ruhezeit zusammen leeren.“); telegramCategories (@IsOptionalNotNull() @IsArray() @ArrayUnique() @ArrayMaxSize(6) @IsEnum(NotificationCategory, { each: true })). telegramEnabled ist nicht änderbar (setzen Verbinden/Trennen, Task 36).
    • Backup: notificationPreference gesichert; Import alter Bundles: Notification ohne userId je aktivem Nutzer kopieren (wie Migration 8), ohne category wie Migration 11a (relatedType 'JobRun' oder ohne relatedType → SYSTEM, sonst SUGGESTION). Die Wiederherstellung (tx.notification.upsert in backup.service.ts) bleibt als einziger Schreibpfad außerhalb des NotificationsService, schreibt userId aber immer: aufgelöst über resolveOptionalUserId; ist der Nutzer im Zielsystem unbekannt, wird die Zeile übersprungen und gezählt (userId ist nach Migration 8 Pflicht, nie null schreiben).
    • Migration 10: Kopfkommentar mit Prüfabfrage SELECT count(*) FROM "Inspection" WHERE "colonyStrength" > 40 (vor dem Deploy, Treffer mit dem Inhaber klären, nicht kürzen).
  • Schritt 4: → PASS; Drift-Check → 0.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • npx jest --config ./test/jest-e2e.json test/notifications.e2e-spec.ts test/t4-migrations.e2e-spec.ts test/calendar-sync.e2e-spec.ts test/jobs.e2e-spec.ts test/apiaries.e2e-spec.ts test/backup.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand → PASS
    • kein Commit

Task 4: Vorschlags-Kern

Files

  • Create: src/suggestions/suggestion.types.ts, src/suggestions/suggestions.service.ts (+ Spec), src/suggestions/suggestion-texts.ts (+ suggestion-texts.spec.ts), src/suggestions/texts/rule-texts.ts, texts/chain-texts.ts, texts/autopilot-texts.ts, texts/cleanup-texts.ts (je leeres Register), src/suggestions/evidence-lookup.ts (+ Spec), src/suggestions/suggestions.module.ts
  • Modify: src/tasks/tasks.service.ts (NewTaskInput.sourceSuggestionId), src/app.module.ts, src/worker/worker.module.ts (importiert SuggestionsModule und JobPermissionsModule aus ../jobs/jobs.module: Task 5 injiziert den Resolver in SuggestionViewsService, und der Worker hat sonst keinen)
  • Docs: neue Annahme „Vorschläge und Anker“ (ein Modell, Arten PLAN/DO, Anker, verworfene Anker kommen nicht wieder, 30 Tage wieder öffnen, Texte beim Lesen gerendert).

Interfaces

  • Consumes: Task 1 (Suggestion, lockRuleIn, PlanStepConfig, dates.ts).
  • Produces:
export type EvidenceValue = string | number | boolean | null | readonly string[];
export type SuggestionEvidence = Readonly<Record<string, EvidenceValue>>; // nur IDs, Zahlen, YYYY-MM-DD; Plan-Basis in `baseDate`
export interface ProposeInput {
  ruleKey: AutomationTriggerType; source: SuggestionSource; mode: SuggestionMode; intent: SuggestionIntent; priority?: TaskPriority;
  relatedType: TaskRelatedType; relatedId: string | null; colonyId: string | null; reasonCode: string; evidence: SuggestionEvidence;
  requiredPermission: PermissionKey | null; dueDate?: string | null; expiresAt?: string | null; plan?: readonly PlanStepConfig[] | null;
  anchor: string; sourceEventId?: string | null;
  notify?: boolean;   // Standard false; Task 7 verdrahtet die Meldung (Regel- und Kettenvorschläge übergeben config.notify, der Autopilot nie);
}
export const INTENT_OF_LEGACY_TRIGGER: Readonly<Partial<Record<AutomationTriggerType, SuggestionIntent>>>;
export function suggestionDedupeKey(ruleKey: AutomationTriggerType, relatedId: string | null, anchor: string): string;
export interface RenderedSuggestionText { title: string; reasons: string[] }
export interface EvidenceLookup { colonyCode(id: string): string | null; queenCode(id: string): string | null; treatmentProduct(id: string): string | null;
  apiaryName(id: string): string | null; stockItemName(id: string): string | null }
export const LOOKUP_EVIDENCE_KEYS = ['colonyId', 'parentColonyId', 'queenId', 'treatmentId', 'apiaryId', 'stockItemId'] as const;
export function buildEvidenceLookup(db: DbClient, evidences: readonly SuggestionEvidence[]): Promise<EvidenceLookup>; // eine Abfrage je Modell
export interface SuggestionTextTemplate { ruleKey: AutomationTriggerType; example: SuggestionEvidence; render(evidence: SuggestionEvidence, lookup: EvidenceLookup, today: string): RenderedSuggestionText }
export type SuggestionTextRegister = Readonly<Record<string, SuggestionTextTemplate>>;
export const PLAN_STEP_TITLES: Readonly<Record<string, string>>;   // Register aus texts/chain-texts.ts
export const INTENT_TITLES: Readonly<Record<SuggestionIntent, string>>;
export function renderSuggestion(s: Pick<Suggestion, 'reasonCode' | 'evidence' | 'intent'>, lookup: EvidenceLookup, today: string, titleOverride?: string | null): RenderedSuggestionText;
export function formatDayDe(day: string, withYear?: boolean): string;           // '05.05.' bzw. '05.05.2026'
export function formatNumberDe(value: number, maxFractionDigits?: number): string; // '12,4'
export interface AcceptSuggestionInput { dueDate?: string | null; assignedUserId?: string | null }
export interface AcceptSuggestionResult { suggestionId: string; status: 'ACCEPTED'; tasks: Task[] }
export type SuggestionResolveNote = 'Bedingung entfällt' | 'Autopilot aus' | 'Aufgeräumt' | 'Mit Durchsicht erledigt';
@Injectable() class SuggestionsService {
  proposeIn(tx: Prisma.TransactionClient, input: ProposeInput): Promise<Suggestion | null>;
  openIntentsIn(db: DbClient, target: { colonyId: string | null; relatedType: TaskRelatedType; relatedId: string | null }): Promise<Set<SuggestionIntent>>;
  refreshIn(tx: Prisma.TransactionClient, id: string, patch: { evidence: SuggestionEvidence; dueDate: string | null; priority: TaskPriority; expiresAt: string | null }): Promise<void>;
  resolveIn(tx: Prisma.TransactionClient, ids: readonly string[], note: SuggestionResolveNote): Promise<number>;   // OPEN -> RESOLVED und dedupeKey = NULL in einem Statement
  obsoleteForColonyIn(tx: Prisma.TransactionClient, colonyId: string): Promise<number>;
  expireDue(today: string): Promise<number>;   // OPEN -> EXPIRED und dedupeKey = NULL in einem Statement
  accept(id: string, input: AcceptSuggestionInput, actingUserId: string): Promise<AcceptSuggestionResult>;
  bulkAccept(ids: readonly string[], actingUserId: string): Promise<AcceptSuggestionResult[]>;
  dismiss(id: string, note: string | null, actingUserId: string): Promise<Suggestion>;
  bulkDismiss(ids: readonly string[], actingUserId: string): Promise<number>;
  reopen(id: string, actingUserId: string): Promise<Suggestion>;
}
// NewTaskInput += sourceSuggestionId?: string | null
  • Schritt 1: Failing Tests
// suggestions.service.spec.ts (Prisma-tx gemockt)
it('proposeIn locks the colony row, then the rule row, and writes one suggestion with an audit entry', ...);
it('proposeIn with an existing dedupe key creates nothing', async () => {
  tx.suggestion.createManyAndReturn.mockResolvedValue([]);
  await expect(service.proposeIn(tx, input)).resolves.toBeNull();
  expect(audit.record).not.toHaveBeenCalled();
});
it('proposeIn skips when an open task of the same intent exists (via sourceSuggestion or legacy trigger)', ...);
it('proposeIn skips when an open suggestion of the same intent exists for the colony', ...);
it('accept creates one task per plan step through createInTx with sourceSuggestionId', async () => {
  // RAISE_OWN, evidence.baseDate '2026-05-01'
  expect(tasks.createInTx.mock.calls.map((c) => c[1].dueDate?.toISOString().slice(0, 10))).toEqual(['2026-05-10', '2026-05-22', '2026-05-29']);
  expect(tasks.createInTx.mock.calls[1][1].description).toContain('bis 25.05.');
});
it('accept with a new date shifts every plan step by the same distance', ...);
it('accept of a DO suggestion without a date answers 400 "Bitte ein Datum wählen."', ...);
it('accept answers 409 with the current status when the CAS finds no OPEN row', ...);
it('accept locks the suggestion row and reads it inside the transaction', ...);
it('reopen within 30 days restores OPEN; after 30 days it answers 409', ...);  // CAS mit "decidedAt" > now() - interval '30 days'
it('bulkAccept is all or nothing', ...);
it('expireDue moves OPEN past expiresAt to EXPIRED in one statement', ...);
it('expireDue and resolveIn set dedupeKey to NULL with the status change; DISMISSED and ACCEPTED keep it', ...);
it('an EXPIRED or RESOLVED suggestion of the same anchor can be proposed again; a DISMISSED one cannot', ...);
it('accept of a PLAN suggestion without plan and without a date creates a task due today (Berlin)', ...);
// suggestion-texts.spec.ts
it('every registered template renders its example in at most 2 reasons of at most 90 characters', ...);
it('no reason code is registered twice', ...);
it('formats numbers and days in German', () => { expect(formatNumberDe(12.4)).toBe('12,4'); expect(formatDayDe('2026-05-05')).toBe('05.05.'); });
it('falls back to INTENT_TITLES for an unknown reason code', ...);
  • Schritt 2: npx jest src/suggestions src/tasks → FAIL.
  • Schritt 3: Implementieren
    • proposeIn: assertTransactionClient; bei gesetzter colonyId zuerst lockRowForUpdate(tx, 'Colony', colonyId) (serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, damit die Absichts-Prüfung über Regeln hinweg hält), dann lockRuleIn(tx, ruleKey); openIntentsIn prüft offene Vorschläge (gleiche colonyId, sonst gleiche relatedType + relatedId) und offene Aufgaben (OPEN/IN_PROGRESS; Absicht nach Global Constraints); dann suggestion.createManyAndReturn({ data: [row], skipDuplicates: true }) und Audit Suggestion CREATE mit tx.
    • accept, dismiss, reopen und die Bulk-Varianten je in einer Transaktion: lockRowForUpdate(tx, 'Suggestion', id) (Bulk: lockRowsForUpdate(tx, 'Suggestion', ids)), before lesen, Status prüfen (sonst 409 mit aktuellem Status), CAS-Update, Audit mit beforeData/afterData.
    • accept: CAS OPEN → ACCEPTED (decidedAt = now(), decidedByUserId), dann Aufgaben über tasks.createInTx – Titel aus renderSuggestion (mit actionTemplate der Regel), Beschreibung = Gründe, priority, relatedType/relatedId, sourceSuggestionId, sourceAutomationTrigger = ruleKey, sourceInspectionId = evidence.inspectionId falls vorhanden. PLAN mit plan: je Schritt baseDate + offsetDays, Titel PLAN_STEP_TITLES[step], bei windowDays Zusatz „bis {TT.MM.}“. Mit input.dueDate wird die Basis so verschoben, dass der erste Schritt auf diesem Tag liegt. Ein PLAN ohne plan und ohne dueDate (Regelvorschlag ohne dueInDays, Spec 1) legt die Aufgabe auf heute (berlinToday()).
    • resolveIn und expireDue setzen "dedupeKey" = NULL im selben Statement wie den Statuswechsel; accept, dismiss, reopen und obsoleteForColonyIn lassen den Schlüssel stehen.
    • SuggestionsModule importiert TasksModule, AuditModule, NotificationsModule (Task 7 schreibt darüber die Meldung zu neuen Vorschlägen), exportiert SuggestionsService; in AppModule und WorkerModule importiert. WorkerModule importiert zusätzlich das globale JobPermissionsModule (Global Constraints, Rechte-Auflösung); test/worker-runtime.e2e-spec.ts startet WorkerModule und deckt fehlende Abhängigkeiten auf.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation
    • typecheck, lint, npm test -- --ci
    • npx jest --config ./test/jest-e2e.json test/tasks.e2e-spec.ts test/worker-runtime.e2e-spec.ts --runInBand → PASS
    • kein Commit

Task 5: Vorschläge-API

Files

  • Create: src/suggestions/suggestions.controller.ts, src/suggestions/suggestion-views.service.ts (+ Spec), src/suggestions/dto/list-suggestions.query.ts, dto/accept-suggestion.dto.ts, dto/dismiss-suggestion.dto.ts, dto/suggestion-ids.dto.ts
  • Modify: src/suggestions/suggestions.module.ts
  • Test: neu test/suggestions.e2e-spec.ts, test/utils/suggestions.ts (proposeForTest(app, input) in einer Transaktion)

Interfaces

  • Consumes: Task 4.
  • Produces:
export interface SuggestionView { id: string; ruleKey: AutomationTriggerType; source: SuggestionSource; mode: SuggestionMode; intent: SuggestionIntent;
  status: SuggestionStatus; priority: TaskPriority; relatedType: TaskRelatedType; relatedId: string | null; colonyId: string | null;
  colonyCode: string | null; apiaryId: string | null; apiaryName: string | null; title: string; reasons: string[];
  dueDate: string | null; expiresAt: string | null; plan: PlanStepConfig[] | null; decidedAt: string | null; decisionNote: string | null; createdAt: string;
  cleanup: { taskIds: string[]; queenId: string | null } | null }   // nur bei CLEANUP
export interface SuggestionSummary { open: number; byPriority: Record<TaskPriority, number> }
SuggestionViewsService.list(userId: string, query: ListSuggestionsQuery): Promise<SuggestionView[]>; // Standard OPEN; DISMISSED nur decidedAt >= now() - 30 Tage
SuggestionViewsService.summary(userId: string): Promise<SuggestionSummary>;
SuggestionViewsService.visibleWhere(permissions: ReadonlySet<string>): Prisma.SuggestionWhereInput;  // requiredPermission null oder gehalten
  • Routen (Rechte Spec 10.1, statische Routen vor :id): GET /suggestions?status=&colonyId=&apiaryId= und GET /suggestions/summary (tasks:read); POST /suggestions/:id/accept, POST /suggestions/bulk-accept (tasks:create, @Idempotent()); POST /suggestions/:id/dismiss, POST /suggestions/bulk-dismiss, POST /suggestions/:id/reopen (tasks:update, @Idempotent()).

  • DTOs: AcceptSuggestionDto { dueDate?: string | null (@IsOptional @IsDateString); assignedUserId?: string | null (@IsOptional @IsString @MinLength(1) @MaxLength(64)) } – accept prüft in der Transaktion, dass der Zuständige existiert und aktiv ist (user.isActive, aktive Rolle), sonst 400 „Der Zuständige existiert nicht oder ist nicht aktiv.“; DismissSuggestionDto { note?: string (@MaxLength(500)) }, SuggestionIdsDto { ids: string[] (@ArrayMinSize(1) @ArrayMaxSize(50) @ArrayUnique() @IsString({ each: true })) }.

  • Schritt 1: Failing Tests

// suggestion-views.service.spec.ts
it('filters by requiredPermission in the query, not after loading', ...);
it('renders title and reasons with one lookup for all rows', ...);
// test/suggestions.e2e-spec.ts
it('lists open suggestions with rendered title and reasons', ...);
it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => {
  await proposeForTest(app, { ...base, intent: 'VARROA_TREAT', requiredPermission: 'varroa:read' });
  expect((await getAs(staffWithoutVarroa, '/api/v1/suggestions')).body).toEqual([]);
  expect((await getAs(staffWithoutVarroa, '/api/v1/suggestions/summary')).body.open).toBe(0);
});
it('accept creates exactly one task with sourceSuggestionId and a calendar delivery', ...);  // drainDomainEvents -> CalendarSyncOutboxEntry TASK
it('a second accept with another key answers 409 and leaves exactly one task', ...);
it('the same Idempotency-Key replays the first accept', ...);
it('bulk-accept with one non-OPEN id answers 409 and creates no task', ...);
it('bulk endpoints reject 0 or 51 ids', ...);
it('accept with an unknown or inactive assignedUserId answers 400 and creates no task', ...);
it('dismiss then reopen restores OPEN; the dismissed one is listed for 30 days', ...);
it('403 without tasks:create on accept and without tasks:update on dismiss', ...);
  • Schritt 2: npx jest src/suggestions und test/suggestions.e2e-spec.ts → FAIL.
  • Schritt 3: Implementieren wie in Interfaces; Rechte über PermissionResolverService.getPermissions; apiaryId-Filter über colony.apiaryId; Sortierung Fälligkeit (ohne Datum zuletzt), Priorität, createdAt, id.
  • Schritt 4: → PASS, inklusive test/authorization-coverage.e2e-spec.ts.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; kein Commit.

Task 6: Varroa-Ampel nach Jahreszeit (Backend)

Files

  • Create: src/treatments/withdrawal-window.ts (+ Spec; der eine reine Behandlungsfenster-Helfer für Wartezeit, Varroa-Behandlungseffekt und Tagesregeln, aus Task 12 hierher verlegt), src/treatments/treatment-windows.ts (+ Spec; lädt die Fenster eines Volks), src/automation/varroa-bands.ts (+ varroa-bands.spec.ts), src/varroa/varroa-bands.service.ts (+ Spec), src/varroa/dto/varroa-thresholds.query.ts
  • Delete: src/varroa/varroa-threshold.service.ts (Route GET /varroa-measurements/threshold entfällt)
  • Modify: src/varroa/varroa.controller.ts, varroa.module.ts, src/varroa/varroa.service.ts (+ Spec; Prozent aus der Probe), src/varroa/dto/create-varroa-measurement.dto.ts (sampleBees), src/varroa/colony-varroa-measurements.controller.ts (rating), src/treatments/treatments.service.ts (+ Spec; getWithdrawalStatus über den Helfer), src/jobs/daily-evaluation.rules.ts (+ Spec; withdrawalEndDay über den Helfer)
  • Test: test/varroa.e2e-spec.ts, neu test/varroa-seasonal.e2e-spec.ts
  • Docs: bestehende T3-Annahme „Ampeln“ ändern: Varroa-Bänder je Methode, Monat und Volkstyp, Behandlungseffekt, Quellen LWG; Grenzwert 10,0 ist jetzt gelb.

Interfaces

  • Consumes: Task 1 (VarroaThresholdConfig, readRule, dates.ts), Task 2 (sampleBees).
  • Produces:
export type VarroaLightKey = 'green' | 'yellow' | 'red';
export type VarroaBandReason = 'NATURAL_FALL_SEASON' | 'NATURAL_FALL_WINTER' | 'INFESTATION_SUMMER' | 'INFESTATION_AUTUMN';
export interface VarroaBand { method: VarroaMethod; unit: 'MITES_PER_DAY' | 'PERCENT'; yellowFrom: number | null; redAbove: number; reason: VarroaBandReason; youngColony: boolean }
export interface VarroaBands { day: string; bands: Partial<Record<VarroaMethod, VarroaBand>>; postTreatmentUntil: string | null }
// TreatmentWindowLite, treatmentWindowOf, WithdrawalWindow, withdrawalWindowOf, isInWindow: src/treatments/withdrawal-window.ts (unten); varroa-bands.ts importiert sie
export function bandsFor(day: string, colonyType: ColonyType | null, config: VarroaThresholdConfig, treatments: readonly TreatmentWindowLite[]): VarroaBands;
export function classify(value: number | null, band: VarroaBand | null | undefined): VarroaLightKey | null;
export interface VarroaRating { light: VarroaLightKey | 'POST_TREATMENT' | null; reasonCode: VarroaBandReason | 'POST_TREATMENT' | null; band: VarroaBand | null; postTreatmentUntil: string | null }
export function rateMeasurement(m: Pick<VarroaMeasurement, 'date' | 'method' | 'calculatedValue'>, colonyType: ColonyType | null, config: VarroaThresholdConfig, treatments: readonly TreatmentWindowLite[]): VarroaRating;
VarroaBandsService.thresholds(query: { colonyId?: string; date?: string }): Promise<VarroaBands & { active: boolean }>; // Regel aus -> bands {}, active false
// src/treatments/withdrawal-window.ts (rein, ohne Datenbank)
export interface TreatmentWindowLite { startDay: string; endDay: string }   // Behandlungsbeginn (startDate, falls vor date, sonst date), endDate ?? date
export function treatmentWindowOf(t: Pick<Treatment, 'date' | 'startDate' | 'endDate'>): TreatmentWindowLite;
export interface WithdrawalWindow { treatmentId: string; colonyId: string; product: string; fromDay: string; untilDay: string } // untilDay exklusiv = erntefähig ab
export function withdrawalWindowOf(t: Pick<Treatment, 'id' | 'colonyId' | 'product' | 'date' | 'startDate' | 'endDate' | 'withdrawalPeriodDays'>): WithdrawalWindow | null;
export function isInWindow(window: WithdrawalWindow, day: string): boolean;   // fromDay <= day < untilDay
// src/treatments/treatment-windows.ts (Funktion, kein Service-Export; Task 7 und VarroaBandsService nutzen sie, Task 7 braucht kein VarroaModule)
export function treatmentWindowsIn(db: DbClient, colonyId: string): Promise<TreatmentWindowLite[]>;
// GET /varroa-measurements/thresholds?colonyId=&date=  @RequirePermission('varroa:read', 'varroa:create'); unbekanntes Volk 404; ungültiges Datum 400
// GET /colonies/:id/varroa-measurements -> je Messung zusätzlich rating: VarroaRating
// CreateVarroaMeasurementDto.sampleBees?: number (@IsOptional @IsInt @Min(1) @Max(2000)); das Bulk-DTO erbt es
  • Schritt 1: Failing Tests
// varroa-bands.spec.ts – Tabelle aus Spec 14.1
it.each([
  ['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.0, 'yellow'],
  ['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.1, 'red'],
  ['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.6, 'red'],
  ['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 5.1, 'red'],
  ['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 5.1, 'red'],
  ['2026-04-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 9, null],
  ['2026-07-10', 'PRODUCTION_COLONY', 'DRONE_BROOD_CONTROL', 50, null],
])('%s %s %s %p -> %p', (day, type, method, value, light) => expect(classify(value, bandsFor(day, type, DEFAULTS, []).bands[method])).toBe(light));
it('a measurement 5 days after treatment end has no light and names the recheck day', () =>
  expect(rateMeasurement({ date: d('2026-08-07'), method: 'NATURAL_MITE_FALL', calculatedValue: 12 }, 'PRODUCTION_COLONY', DEFAULTS, [{ startDay: '2026-07-20', endDay: '2026-08-02' }]))
    .toEqual({ light: 'POST_TREATMENT', reasonCode: 'POST_TREATMENT', band: null, postTreatmentUntil: '2026-08-16' }));
// withdrawal-window.spec.ts (aus Task 12 hierher verlegt)
it('runs from the treatment start to (endDate ?? date) + days, exclusive', () =>
  expect(withdrawalWindowOf({ id: 't', colonyId: 'c', product: 'Oxuvar', date: d('2026-09-28'), startDate: null, endDate: d('2026-10-01'), withdrawalPeriodDays: 14 }))
    .toEqual({ treatmentId: 't', colonyId: 'c', product: 'Oxuvar', fromDay: '2026-09-28', untilDay: '2026-10-15' }));
it('getWithdrawalStatus and withdrawalEndDay deliver the same window', ...);
it('no window without withdrawalPeriodDays', ...);
it('treatmentWindowOf starts at startDate when it is before date and ends at endDate ?? date', ...);
// treatment-windows.spec.ts
it('maps every treatment of the colony to a window and ignores other colonies', ...);
// varroa.service.spec.ts
it('update with sampleBees null clears the sample and keeps a manually entered value', ...);
it('stores mites per 100 bees for powdered sugar with a sample size', ...);   // 10 Milben, 500 Bienen -> 2
// test/varroa-seasonal.e2e-spec.ts
it('thresholds for a young colony are halved', ...);
it('thresholds return no band for powdered sugar in April', ...);
it('the old /threshold route is gone (404)', ...);
it('colony measurements carry a rating with band and light', ...);
  • Schritt 2: npx jest src/automation/varroa-bands.spec.ts src/varroa src/treatments src/jobs und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • bandsFor: Monat aus day; natürlicher Milbenfall in seasonMonths → { yellowFrom, redAbove }, sonst { yellowFrom: null, redAbove: winterRedAbove }; Jungvolk: alle Werte des Milbenfalls × youngColonyFactor; Prozent-Bänder nur in summerMonths/autumnMonths. postTreatmentUntil = endDay + recheckAfterDays der jüngsten Behandlung mit startDay ≤ day < endDay + recheckAfterDays.
    • calculatedValue bei POWDERED_SUGAR/WASHING_METHOD mit sampleBees über den vorhandenen Ableitungs-Helfer (gleiche Rundung; ein manueller Wert gewinnt wie bisher). update leitet aus dem Vorzustand ab: lockRowForUpdate(tx, 'VarroaMeasurement', id), before in der Transaktion lesen.
    • withdrawal-window.ts: TreatmentsService.getWithdrawalStatus und withdrawalEndDay (daily-evaluation.rules.ts) rechnen über withdrawalWindowOf; das Verhalten des Bestands (Ende exklusiv) bleibt, der Vergleichstest steht oben. Task 7, 12 und 15 importieren den Helfer, es gibt kein zweites Fenster-Rechnen. Null-Regel für sampleBees: Eintrag in update-dto-null-handling.spec.ts macht Task 8 (dieselbe Datei, Welle 4).
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/varroa.e2e-spec.ts, test/varroa-seasonal.e2e-spec.ts, test/treatments.e2e-spec.ts, test/daily-evaluation.e2e-spec.ts, test/authorization-coverage.e2e-spec.ts → PASS; kein Commit.

Task 7: Regeln schlagen vor statt Aufgaben anzulegen

Files

  • Modify: src/automation/automation.service.ts (+ Spec), src/automation/automation.handlers.ts (+ Spec), src/automation/automation.module.ts (importiert SuggestionsModule), src/suggestions/texts/rule-texts.ts, src/suggestions/suggestions.service.ts (+ Spec; notify in proposeIn)
  • Delete: src/automation/varroa-threshold.ts (+ Spec)
  • Test: test/suggestions.e2e-spec.ts (Regel-Fälle ergänzen), test/varroa-seasonal.e2e-spec.ts (Regel-Fälle), test/automation.e2e-spec.ts, test/post-commit-side-effects.e2e-spec.ts, test/follow-up-controls.e2e-spec.ts, test/raw-materials.e2e-spec.ts, test/storage.e2e-spec.ts
  • Docs: Annahme 12 neu fassen: Regeln schreiben Vorschläge (Übernehmen/Verwerfen); bestehende Automatik-Aufgaben bleiben Aufgaben („Aus Vorschlag“); Meldung nur bei config.notify; Anker je Regel (Tabelle unten).

Interfaces

  • Consumes: Task 1, 3, 4, 6 (rateMeasurement, treatmentWindowsIn aus src/treatments/treatment-windows.ts – eine Funktion, kein Export des VarroaBandsService, kein Import von VarroaModule); Rückgrat Task 10 (Handler und …In-Methoden).
  • Produces:
export interface RuleSuggestionTarget { relatedType: TaskRelatedType; relatedId: string; colonyId: string | null; intent: SuggestionIntent;
  reasonCode: string; evidence: SuggestionEvidence; anchor: string; requiredPermission: PermissionKey; dueDate?: string | null; sourceEventId?: string | null }
AutomationService.proposeForRuleIn(tx: Prisma.TransactionClient, trigger: AutomationTriggerType, target: RuleSuggestionTarget): Promise<Suggestion | null>; // ersetzt triggerActionIn
Regel Auslöser intent reasonCode Anker evidence
QUEENLESS_NO_EGGS Durchsicht queenSeen = false, eggsPresent = false QUEEN_CHECK QUEENLESS_NO_EGGS Durchsicht-ID colonyId, inspectionId, inspectedOn
FEED_STATUS_CRITICAL Durchsicht feedStatus = CRITICAL FEED FEED_STATUS_CRITICAL Durchsicht-ID colonyId, inspectionId, inspectedOn
VARROA_THRESHOLD Messung, Band rot, kein Behandlungseffekt VARROA_TREAT VARROA_RED Mess-ID colonyId, measurementId, measuredOn, value, redAbove, unit
SWARM_MOOD_HIGH Schwarmkontrolle HIGH (Altpfad) SWARM_CHECK SWARM_MOOD_HIGH Kontroll-ID colonyId, checkId, checkedOn
COLONY_HEALTH_CRITICAL Übergang nach CRITICAL HEALTH_CHECK COLONY_HEALTH_CRITICAL Ereignis-ID colonyId, changedOn
STOCK_ITEM_LOW Lagerartikel unter Minimum (Altpfad) STOCK_REORDER STOCK_ITEM_LOW ID der jüngsten IN-Bewegung, sonst none stockItemId, quantity, minThreshold
RAW_MATERIAL_LOW Rohstoff unter minGrams STOCK_REORDER RAW_MATERIAL_LOW ID der jüngsten Charge der Sorte kind, remainingGrams, minGrams
TREATMENT_FOLLOW_UP Behandlung erfasst FOLLOW_UP TREATMENT_FOLLOW_UP Behandlungs-ID colonyId, treatmentId, endedOn
FEEDING_FOLLOW_UP Fütterung erfasst FOLLOW_UP FEEDING_FOLLOW_UP Fütterungs-ID colonyId, feedingId, fedOn
  • Alle PLAN, Priorität HIGH wie die bisherigen Automatik-Aufgaben, requiredPermission nach Global Constraints; dueDate aus config.dueInDays ab dem fachlichen Datum, sonst null.

  • Die Meldung (Spec 9.4) sitzt in SuggestionsService.proposeIn (nicht in AutomationService), damit sie für alle Quellen gilt, auch für Kettenvorschläge (Task 11): ProposeInput.notify (Regeln und Ketten übergeben config.notify, der Autopilot nie), proposeForRuleIn reicht notify: rule.config.notify durch. Bei neu eingefügter Zeile und notify: Titel über renderSuggestion mit buildEvidenceLookup(tx, [evidence]), dann recordForPermission(['tasks:read', requiredPermission], { priority: priority === 'URGENT' ? 'KRITISCH' : 'WARNUNG', category: 'SUGGESTION', message: '<Code>: <Titel>', link: colonyId ? '/colonies/<id>' : '/suggestions', relatedType: 'Suggestion', relatedId: id, dedupeKeyPrefix: 'suggestion:<id>' }, tx). Audit AutomationRule CREATE mit suggestionId wie bisher mit taskId.

  • Schritt 1: Failing Tests

// automation.service.spec.ts / automation.handlers.spec.ts
it('proposeForRuleIn writes a suggestion under the rule lock and an audit entry with suggestionId', ...);
it('writes a WARNUNG only when config.notify is true', ...);
it('the varroa rule proposes nothing inside the post-treatment window', ...);
it('the stock rule anchors on the newest IN movement', ...);
it('rule handlers write nothing for an ended colony', ...);   // Spec 5.3
// suggestions.service.spec.ts
it('proposeIn with notify and a new row writes one SUGGESTION notification to tasks:read plus the required permission; notify false or a skipped row writes none', ...);
// test/suggestions.e2e-spec.ts
it('an inspection without queen and eggs leaves one OPEN suggestion and no task', ...);
it('a dismissed suggestion is not proposed again for the same inspection', ...);  // Delivery zurücksetzen, erneut drainen -> nichts
it('a later inspection with the same finding proposes again', ...);
it('accepting a rule suggestion without dueInDays creates a task due today', ...);
it('a chain suggestion proposed with notify true notifies like a rule suggestion', ...);   // proposeForTest mit source CHAIN
it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', ...);
// test/varroa-seasonal.e2e-spec.ts
it('12 mites/day in August proposes a treatment check', ...);
it('12 mites/day five days after treatment end proposes nothing', ...);
it('10.0 mites/day in August proposes nothing (yellow)', ...);
// test/post-commit-side-effects.e2e-spec.ts (bisheriger Aufgaben-Fall)
it('creates exactly one automation suggestion when the handler fails once and is retried', ...);
// follow-up-controls / raw-materials / storage: Erwartungen von Aufgabe auf Vorschlag umgestellt
  • Schritt 2: npx jest src/automation und die genannten e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • Alle Regel-Pfade (Handler und die Altpfade Schwarmkontrolle und Lagerartikel) rufen proposeForRuleIn; tasks.createInTx und die Rundmeldung entfallen in der Automatik.
    • Varroa: Regel aktiv → rateMeasurement(…) mit den Fenstern aus treatmentWindowsIn(tx, colonyId) (Task 6); nur light === 'red' schlägt vor. varroa-threshold.ts entfällt.
    • Beendetes Volk (Spec 5.3): Jeder Regel-Handler lädt das Volk über tx und kehrt zurück, wenn es nicht lebt (isLivingColonyStatus); die Altpfade (Schwarmkontrolle, Lagerartikel) prüfen dasselbe, soweit sie ein Volk haben.
    • dueDate kommt aus config.dueInDays; fehlt dueInDays, bleibt es null, und accept legt die Aufgabe auf heute (Task 4, Spec 1).
    • rule-texts.ts: je reasonCode eine Vorlage mit Beispielbeleg, z. B. VARROA_RED → Titel „Behandlung prüfen“, Grund „12,4 Milben pro Tag am 10.05. Grenze: 10 (LWG).“
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; alle genannten e2e-Dateien → PASS; kein Commit.

Task 8: Durchsicht → Volkskarte und besetzte Waben

Files

  • Create: src/colonies/colony-takeover.ts (+ .spec.ts)
  • Modify: src/colonies/colonies.service.ts (+ Spec; updateIn als Kern von update), src/colonies/dto/create-colony.dto.ts (occupiedFrames, strength veraltet), src/inspections/inspections.service.ts (+ Spec), src/inspections/dto/create-inspection.dto.ts (applyToColony, colonyStrength ≤ 40), src/inspections/inspections.controller.ts, src/inspections/inspections.module.ts (importiert ColoniesModule; der Resolver kommt global, kein PermissionsModule-Import), src/export/export.service.ts, src/import/import.service.ts (+ Spec), src/apiaries/apiary-field.service.ts + apiary-field.types.ts (occupiedFrames in Zeile und Paket), src/common/validation/update-dto-null-handling.spec.ts
  • Test: neu test/inspection-takeover.e2e-spec.ts, neu test/colony-strength.e2e-spec.ts
  • Docs: neue Annahme „Volksstärke in besetzten Waben“ (0–40, Altwert strength 1–10 bleibt, keine Umrechnung, Anzeige); Annahme zur Übernahme Durchsicht → Volkskarte (im selben Request, Server nimmt die Werte aus der Durchsicht, Überspring-Gründe, Brutbild → Brutstatus = Annahme).

Interfaces

  • Consumes: Task 2 (ColonyCondition-Futter), Task 3 (Colony.occupiedFrames); Rückgrat Task 11, 13 (Ereignisse in create/update).
  • Produces:
export const COLONY_TAKEOVER_FIELDS = ['occupiedFrames', 'broodStatus', 'feedStatus'] as const;
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
export type TakeoverSkipReason = 'NO_VALUE' | 'NO_PERMISSION' | 'NEWER_INSPECTION' | 'OUT_OF_RANGE';
export interface ColonyTakeover { applied: ColonyTakeoverField[]; skipped: { field: ColonyTakeoverField; reason: TakeoverSkipReason }[] }
export function broodConditionFromPattern(pattern: number | null): ColonyCondition | null;  // 5 EXCELLENT … 1 CRITICAL
export function planColonyTakeover(inspection: Pick<Inspection, 'colonyStrength' | 'broodPattern' | 'feedStatus'>, requested: readonly ColonyTakeoverField[],
  ctx: { canUpdateColony: boolean; hasNewerInspection: boolean }): { patch: Partial<Pick<Colony, 'occupiedFrames' | 'broodStatus' | 'feedStatus'>>; takeover: ColonyTakeover };
ColoniesService.updateIn(tx: Prisma.TransactionClient, id: string, dto: UpdateColonyDto, actingUserId: string): Promise<{ before: Colony; after: Colony }>; // Audit, ColonyStatusChanged/ColonyEnded
export type InspectionWithTakeover = Inspection & { colonyTakeover: ColonyTakeover };
InspectionsService.create(dto: CreateInspectionDto, actingUserId: string): Promise<InspectionWithTakeover>;
// CreateInspectionDto.applyToColony?: ColonyTakeoverField[] (@IsOptional @IsArray @ArrayUnique @ArrayMaxSize(3) @IsIn(COLONY_TAKEOVER_FIELDS, { each: true }))
// colonyStrength und occupiedFrames: @IsInt @Min(0) @Max(40, { message: 'Höchstens 40 besetzte Waben.' })
// CreateColonyDto.strength: @ApiPropertyOptional({ deprecated: true, description: 'Volksstärke (alt, Skala 1–10)' }); UpdateColonyDto: occupiedFrames leerbar
  • Schritt 1: Failing Tests
// colony-takeover.spec.ts
it.each([[5, 'EXCELLENT'], [4, 'GOOD'], [3, 'AVERAGE'], [2, 'WEAK'], [1, 'CRITICAL'], [null, null]])('brood pattern %p -> %p', ...);
it('takes over only requested fields that have a value', ...);                       // feedStatus null -> skipped NO_VALUE
it('skips everything with NO_PERMISSION when the caller may not update colonies', ...);
it('skips everything with NEWER_INSPECTION when a later inspection exists', ...);
it('skips OUT_OF_RANGE for a value outside the colony card range', ...);            // Altdaten colonyStrength 45
// colonies.service.spec.ts
it('writing occupiedFrames leaves strength untouched', ...);
it('updateIn locks the colony row before reading before', ...);
// test/inspection-takeover.e2e-spec.ts
it('takes over feed and brood in the same request and writes an audit entry Colony UPDATE', ...);
it('skips with NEWER_INSPECTION when a newer inspection of the colony exists', ...);
it('answers 201 and skips with NO_PERMISSION without colonies:update', ...);
it('a correction (PATCH) takes nothing over', ...);
it('applyToColony with 4 entries or duplicates answers 400', ...);
// test/colony-strength.e2e-spec.ts
it('PATCH occupiedFrames keeps the legacy strength', ...);
it('a takeover from an inspection writes only occupiedFrames', ...);
it('colonyStrength 41 answers 400 "Höchstens 40 besetzte Waben."', ...);
it('CSV export and import carry strength and occupiedFrames', ...);
// update-dto-null-handling.spec.ts
it('contract: occupiedFrames and the varroa sampleBees (Task 6) accept null and clear the value', ...);
  • Schritt 2: npx jest src/colonies src/inspections src/import src/apiaries src/common/validation und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • ColoniesService.update wird prisma.$transaction((tx) => this.updateIn(tx, …)); updateIn beginnt mit lockRowForUpdate(tx, 'Colony', id) und liest before erst danach (404 wie bisher), dann die bisherige Logik samt Standort-Historie, Audit und Ereignissen.
    • InspectionsService.create in einer Transaktion: Durchsicht, Audit, InspectionRecorded; bei applyToColony dann hasNewerInspection (date > diese und id ≠), canUpdateColony über PermissionResolverService.getPermissions(actingUserId), planColonyTakeover, bei nicht leerem patch colonies.updateIn(tx, colonyId, patch, actingUserId). Werte nur aus der gespeicherten Durchsicht, nie aus dem Request. Ohne applyToColony → colonyTakeover: { applied: [], skipped: [] }.
    • occupiedFrames in ColonyOverviewRow und OfflinePackV1.colonies; CSV-Export/-Import der Völker mit beiden Spalten.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/inspection-takeover.e2e-spec.ts, test/colony-strength.e2e-spec.ts, test/inspections.e2e-spec.ts, test/colonies.e2e-spec.ts, test/apiaries.e2e-spec.ts, test/export.e2e-spec.ts, test/import.e2e-spec.ts → PASS; kein Commit.

Task 9: Volk beendet – Königin freigeben und aufräumen

Files

  • Create: src/colonies/colony-lifecycle.ts (+ .spec.ts), src/colonies/colony-end-releases-queen.spec.ts (Wächter), src/suggestions/suggestions.handlers.ts (+ Spec), src/suggestions/cleanup.service.ts (+ Spec), src/suggestions/dto/apply-cleanup.dto.ts
  • Modify: src/colonies/colonies.service.ts (+ Spec), src/colonies/colony-events.ts (+ colony-events.spec.ts; colonyTransitionEvents bekommt den Kontext des Endes), src/winter-checks/winter-checks.service.ts (+ Spec; Payload-Erwartung), src/domain-events/domain-event-types.ts (ColonyEnded-Payload), src/suggestions/suggestions.controller.ts, src/suggestions/suggestions.module.ts, src/suggestions/texts/cleanup-texts.ts, src/tasks/tasks.service.ts (+ Spec; cancelIn, completeIn), src/queens/queens.service.ts (+ Spec; updateStatusIn)
  • Test: neu test/colony-ended.e2e-spec.ts, test/winter-checks.e2e-spec.ts, test/colonies.e2e-spec.ts
  • Docs: ARCHITECTURE Annahme 13/16 ergänzen: Jeder Weg, auf dem ein Volk endet (Auflösen, Vereinigen, Bearbeiten auf SOLD/DECEASED, Auswinterungsverlust), beendet die Königin-Zuordnung in derselben Transaktion; Aufräum-Vorschlag.

Interfaces

  • Consumes: Task 4, 5, 8 (updateIn).
  • Produces:
export function releaseQueenIn(tx: Prisma.TransactionClient, colonyId: string, endDate: Date): Promise<string | null>; // offene QueenColonyHistory beenden, currentQueenId = null; liefert die Königin-ID
// DomainEventPayloads.ColonyEnded wird: { status: ColonyStatus; endedOn: string; releasedQueenId: string | null; via: 'DISSOLVE' | 'MERGE' | 'EDIT' | 'WINTER_CHECK' }
TasksService.cancelIn(tx: Prisma.TransactionClient, id: string, actingUserId: string): Promise<Task>;                 // sperrt Task, + Audit, TaskUpdated
TasksService.completeIn(tx: Prisma.TransactionClient, id: string, actingUserId: string): Promise<Task>;               // lockRowForUpdate(tx, 'Task', id), before lesen, markDoneIn (CAS); nur beim Übergang Audit Task UPDATE (beforeData/afterData) + TaskCompleted, schon DONE -> unverändert zurück. complete() ruft completeIn (kein zweiter Pfad), markDoneIn bleibt privat
export type ColonyEndVia = 'DISSOLVE' | 'MERGE' | 'EDIT' | 'WINTER_CHECK';
// colonyTransitionEvents(before, after, actorUserId, ended: { endedOn: string; releasedQueenId: string | null; via: ColonyEndVia } | null) – ColonyEnded trägt diese Felder; die Aufrufer rufen releaseQueenIn, bevor sie die Ereignisse bauen
QueensService.updateStatusIn(tx: Prisma.TransactionClient, id: string, status: QueenStatus, actingUserId: string): Promise<Queen>; // + Audit
export interface ApplyCleanupDto { tasks: { id: string; outcome: 'CANCEL' | 'DONE' | 'KEEP' }[]; queen?: { id: string; status: QueenStatus } | null }
export interface CleanupResult { cancelled: number; completed: number; kept: number; queenStatus: QueenStatus | null }
CleanupService.apply(suggestionId: string, dto: ApplyCleanupDto, actor: { id: string; permissions: ReadonlySet<string> }): Promise<CleanupResult>;
class SuggestionEventHandlers { @OnDomainEvent('ColonyEnded', 'suggestions.colony-ended') colonyEnded(event, tx): Promise<void> }
// POST /suggestions/:id/apply-cleanup  @RequirePermission('tasks:update') @Idempotent(); zusätzlich tasks:complete für DONE, queens:update für die Königin (sonst 403)
  • Schritt 1: Failing Tests
// colony-lifecycle.spec.ts
it('releaseQueenIn ends the open history with the end date and clears currentQueenId', ...);
it('releaseQueenIn returns null for a colony without a queen', ...);
// colonies.service.spec.ts / winter-checks.service.spec.ts (Payload-Erwartung `{ status, endedOn, releasedQueenId, via }` statt `{ status }`)
it('update to SOLD releases the queen with the day of the change and records ColonyEnded via EDIT', ...);
it('update to DECEASED releases the queen', ...);
it('dissolve and merge use releaseQueenIn', ...);
it('a winter loss with markColonyDeceased releases the queen with the check date', ...);
// colony-events.spec.ts
it('ColonyEnded carries status, endedOn, releasedQueenId and via', ...);
// tasks.service.spec.ts
it('completeIn locks the task, writes the audit entry and TaskCompleted once, and complete() uses it', ...);
// colony-end-releases-queen.spec.ts (Wächter)
it('every file that writes a terminal colony status calls releaseQueenIn', ...);   // Muster: status: 'DISSOLVED'|'SOLD'|'DECEASED'|'MERGED' bzw. Status aus dto
// suggestions.handlers.spec.ts
it('colony-ended turns open suggestions OBSOLETE and proposes CLEANUP when tasks are open', ...);
it('proposes CLEANUP when an ACTIVE queen was released, even without tasks', ...);
it('proposes nothing when nothing is left to tidy up', ...);
// test/colony-ended.e2e-spec.ts
it('a winter loss with markColonyDeceased ends the queen assignment in the same transaction', ...);
it('open suggestions become OBSOLETE after draining', ...);
it('apply-cleanup cancels 2 tasks and completes 1, then the suggestion is RESOLVED', ...);
it('apply-cleanup DONE writes the audit entry and TaskCompleted like a normal completion (calendar entry removed after draining)', ...);
it('403 without tasks:complete when one task is DONE', ...);
it('400 for a task id that is not in the evidence', ...);   // 'Nur Aufgaben und Königin aus dem Vorschlag.'
  • Schritt 2: npx jest src/colonies src/winter-checks src/suggestions src/tasks src/queens und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • dissolve, merge, updateIn (Status aus dem DTO in TERMINAL_COLONY_STATUSES, Enddatum = heute) und markDeceasedIfRequested (Enddatum = Datum der Auswinterung) rufen releaseQueenIn; der Status der Königin ändert sich dabei nicht. Danach colonyTransitionEvents(before, after, actor, { endedOn, releasedQueenId, via }) mit via DISSOLVE, MERGE, EDIT bzw. WINTER_CHECK; Handler und Tests lesen endedOn aus der Payload, nie aus der Uhr. dissolve, merge (alle Völker in einer Anweisung) und markDeceasedIfRequested sperren die Volkszeilen mit lockRowForUpdate(tx, 'Colony', id) bzw. lockRowsForUpdate(tx, 'Colony', ids) und lesen den Zustand danach; releaseQueenIn liest currentQueenId erst unter dieser Sperre.
    • cancelIn und completeIn sperren Task, updateStatusIn sperrt Queen, CleanupService.apply sperrt Suggestion, dann die Aufgaben (lockRowsForUpdate(tx, 'Task', ids)), dann die Königin (Reihenfolge laut Global Constraints); before jeweils erst danach lesen.
    • Handler: zuerst lockRowForUpdate(tx, 'Colony', colonyId) (Sperr-Reihenfolge Colony → Suggestion; obsoleteForColonyIn und proposeIn laufen danach), dann Volk über tx; suggestions.obsoleteForColonyIn; offene Aufgaben relatedType = COLONY; releasedQueenId mit Status ACTIVE; dann proposeIn mit ruleKey COLONY_ENDED_CLEANUP, CLEANUP, DO, NORMAL, requiredPermission 'colonies:read', anchor 'ended-<endedOn>', evidence { colonyId, status, endedOn, via, openTaskIds, queenId }. Regel aus → nur OBSOLETE.
    • Text COLONY_ENDED: „B3 ist eingegangen (Auswinterung 12.03.). 2 Aufgaben sind noch offen.“ (Wort und Herkunft je status/via).
    • CleanupService.apply in einer Transaktion: CAS prüft OPEN und intent = CLEANUP; IDs nur aus evidence; CANCEL → cancelIn, DONE → completeIn (Audit und TaskCompleted wie bei einer normalen Erledigung; das private markDoneIn überspränge beides), KEEP → nichts; Königin → updateStatusIn; danach resolveIn(…, 'Aufgeräumt').
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/colony-ended.e2e-spec.ts, test/winter-checks.e2e-spec.ts, test/colonies.e2e-spec.ts, test/queens.e2e-spec.ts, test/tasks.e2e-spec.ts → PASS; kein Commit.

Task 10: Ereignisse für Ableger und Königin-Zuweisung

Files

  • Modify: src/domain-events/domain-event-types.ts (ColonySplit, QueenAssigned), src/colonies/colonies.service.ts (+ Spec; split), src/colonies/dto/split-colony.dto.ts, src/queens/queens.service.ts (+ Spec; assignColony), src/queens/queens.module.ts, src/calendar-sync/calendar-sync.handlers.ts (+ Spec), src/webhooks/webhook.handlers.ts (+ Spec), src/common/after-commit-usage.spec.ts
  • Test: test/queens.e2e-spec.ts, test/colonies.e2e-spec.ts
  • Docs: ARCHITECTURE §10 „Ereignisse“: ColonySplit, QueenAssigned; Kalender QUEEN_ASSIGNMENT und Webhook queen.replaced aus Handlern; Annahme 11: Altpfad-Liste ohne Kalender.

Interfaces

  • Consumes: Task 2 (SplitQueenSource).
  • Produces:
// DomainEventPayloads +=
ColonySplit: { parentColonyId: string; lineageId: string; queenSource: SplitQueenSource | null; splitOn: string };   // Aggregat Colony (Ableger)
QueenAssigned: { colonyId: string; historyId: string; assignedOn: string; previousColonyId: string | null };          // Aggregat Queen
// SplitColonyDto += date?: string (@IsOptional @IsDateString, nicht nach heute -> 400 'Das Datum liegt in der Zukunft.'), queenSource?: SplitQueenSource (@IsOptional @IsEnum)
@OnDomainEvent('QueenAssigned', 'calendar-sync.queen-assignment')  // enqueueIn(tx, 'QUEEN_ASSIGNMENT', historyId)
// DOMAIN_EVENT_WEBHOOKS += QueenAssigned: 'queen.replaced' (data = Königin, über tx geladen)
  • Schritt 1: Failing Tests
// colonies.service.spec.ts
it('split records ColonySplit with queenSource and the given date, audit inside the transaction', ...);
it('split with a future date answers 400', ...);
// queens.service.spec.ts
it('assignColony records QueenAssigned with previousColonyId and calls neither webhooks.dispatch nor calendarSync', ...);
// calendar-sync.handlers.spec.ts / webhook.handlers.spec.ts
it('QueenAssigned enqueues QUEEN_ASSIGNMENT with the history id', ...);
it('QueenAssigned forwards queen.replaced with the queen', ...);
// after-commit-usage.spec.ts
expect(filesCalling('calendarSync.enqueue(')).toEqual([]);
expect(filesCalling('webhooks.dispatch(')).toEqual(['src/colonies/colonies.service.ts', 'src/honey-batches/honey-batches.service.ts', 'src/queens/queens.service.ts', 'src/storage/storage.service.ts']); // queens nur noch queen.created
// test/queens.e2e-spec.ts
it('assigning a queen leaves a calendar entry and a queen.replaced delivery after draining', ...);
  • Schritt 2: npx jest src/colonies src/queens src/calendar-sync src/webhooks src/common/after-commit-usage.spec.ts und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren
    • split: in der Transaktion lockRowForUpdate(tx, 'Colony', parentId), Muttervolk danach lesen und „lebt“ prüfen; Lineage mit date (Standard heute) und queenSource; Audit und ColonySplit in der Transaktion.
    • assignColony: in der Transaktion zuerst lockRowsForUpdate(tx, 'Colony', [Zielvolk, bisheriges Volk der Königin]) (eine Anweisung, Schlüssel aufsteigend), dann lockRowForUpdate(tx, 'Queen', id); Königin und Völker danach lesen und die Prüfungen (verfügbar, lebt, nicht schon zugewiesen) dort ausführen; Audit und QueenAssigned in der Transaktion, assignedOn = utcDay(startDate); webhooks.dispatch('queen.replaced') und calendarSync.enqueue entfallen; queen.created bleibt Altpfad.
    • CalendarSyncService.enqueue (Altpfad) hat danach keine Aufrufer mehr; Task 12 entfernt ihn (gleiche Welle wie Task 3 wäre ein Dateikonflikt).
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/queens.e2e-spec.ts, test/colonies.e2e-spec.ts, test/calendar-sync.e2e-spec.ts, test/webhooks.e2e-spec.ts → PASS; kein Commit.

Task 11: Biotechnik-Ketten

Files

  • Create: src/suggestions/chains/chains.handlers.ts (+ Spec), src/suggestions/chains/chain-planner.ts (+ Spec)
  • Modify: src/suggestions/suggestions.module.ts, src/suggestions/texts/chain-texts.ts
  • Test: neu test/chains.e2e-spec.ts
  • Docs: ARCHITECTURE §10 Handler-Tabelle: chains.queen-cells, chains.queen-acceptance, chains.split-schedule.

Interfaces

  • Consumes: Task 1 (ChainConfig, SplitScheduleConfig), Task 4, Task 10 (Ereignisse).
  • Produces:
export interface ChainCandidate { ruleKey: 'QUEEN_CELLS_SWARM_CHECK' | 'QUEEN_ACCEPTANCE_CHECK' | 'SPLIT_SCHEDULE'; intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN';
  reasonCode: string; anchor: string; baseDate: string; dueDate: string; plan: PlanStepConfig[] | null; requiredPermission: PermissionKey; evidence: SuggestionEvidence }
export function queenCellsCandidate(inspection: Pick<Inspection, 'id' | 'colonyId' | 'date' | 'queenCellsPresent'>, config: ChainConfig): ChainCandidate | null;
export function queenAcceptanceCandidate(payload: DomainEventPayloads['QueenAssigned'], queenId: string, config: ChainConfig): ChainCandidate;
export function splitScheduleCandidate(colonyId: string, payload: DomainEventPayloads['ColonySplit'], config: SplitScheduleConfig): ChainCandidate | null; // queenSource null -> null
class ChainEventHandlers {
  @OnDomainEvent('InspectionRecorded', 'chains.queen-cells')   queenCells(event, tx): Promise<void>;
  @OnDomainEvent('QueenAssigned', 'chains.queen-acceptance')   queenAcceptance(event, tx): Promise<void>;
  @OnDomainEvent('ColonySplit', 'chains.split-schedule')       splitSchedule(event, tx): Promise<void>;
}
// PLAN_STEP_TITLES: RAISE_CELLS_CHECK 'Nachschaffungszellen prüfen', MATING_CHECK 'Begattung prüfen', EGGS_CHECK 'Stifte prüfen', ACCEPTANCE_CHECK 'Annahme der Königin prüfen'
  • Absichten: Weiselzellen → SWARM_CHECK (inspections:read, Anker Durchsicht-ID); Zuweisung → QUEEN_ACCEPTANCE (queens:read, Anker historyId); Ableger RAISE_OWN/QUEEN_CELL → SPLIT_PLAN (colonies:read, Anker lineageId), QUEEN → QUEEN_ACCEPTANCE (queens:read, Anker lineageId).

  • Dazu (Lösung Spec-Lücke, siehe Self-Review) räumt chains.queen-cells bei jeder Durchsicht des lebenden Volks zuerst auf (resolveIn(…, 'Mit Durchsicht erledigt')) und schlägt erst danach vor, weil sonst die Absichts-Prüfung den neuen Vorschlag wegen des noch offenen alten blockiert:

    • SWARM_CHECK-Kettenvorschläge des Volks: bei queenCellsPresent = true alle offenen (jedes Fälligkeitsdatum) – danach folgt der neue Vorschlag mit dem Anker dieser Durchsicht und Fälligkeit +7; sonst die mit dueDate ≤ Durchsichtsdatum.
    • QUEEN_ACCEPTANCE-Kettenvorschläge des Volks mit dueDate ≤ Durchsichtsdatum: nur wenn queenSeen oder eggsPresent true ist (Annahme der Königin beobachtet); sonst bleiben sie offen.
  • Meldung (Spec 9.4): Die Handler übergeben notify: config.notify an proposeIn; geschrieben wird die Meldung von SuggestionsService (Task 7), die Ketten bauen keine eigene.

  • Schritt 1: Failing Tests

// chain-planner.spec.ts
it('queen cells on 2026-05-05 propose a swarm check due 2026-05-12', ...);
it('RAISE_OWN yields one SPLIT_PLAN with offsets 9, 21 (window 3) and 28 from the split date', ...);
it('QUEEN_CELL yields one step at +21; QUEEN yields an acceptance check at +7', ...);
it('a split without queenSource yields nothing', ...);
// test/chains.e2e-spec.ts
it('queen cells -> one PLAN suggestion due +7', ...);
it('RAISE_OWN -> accept creates 3 tasks at +9, +21 and +28', ...);
it('a queen assignment -> acceptance check +7', ...);
it('a split with QUEEN plus a later assignment yields one acceptance suggestion', ...);
it('an inspection on the due day resolves the open swarm check', ...);
it('a second inspection with queen cells replaces the open swarm check by a new one due +7', ...);
it('an inspection with neither queen seen nor eggs leaves the open acceptance check open; with eggs it resolves it', ...);
// chain-planner.spec.ts / chains.handlers.spec.ts
it('the handlers pass notify from the chain config to proposeIn', ...);
it('nothing for an ended colony or an inactive rule', ...);
  • Schritt 2: npx jest src/suggestions/chains und test/chains.e2e-spec.ts → FAIL.
  • Schritt 3: Implementieren – Jeder Handler sperrt zuerst die Volkszeile (lockRowForUpdate(tx, 'Colony', colonyId); das Auflösen berührt Suggestion-Zeilen und darf nie vor der Volkssperre liegen, sonst kehrt sich die Reihenfolge Colony → Suggestion gegenüber syncColonyIn um). Dann laden die Handler über tx, prüfen „Volk lebt“ und readRule(tx, …).active, dann suggestions.proposeIn(tx, { source: 'CHAIN', mode: 'PLAN', priority: 'NORMAL', relatedType: 'COLONY', relatedId: colonyId, colonyId, evidence: { …, baseDate } }). Dubletten verhindert proposeIn über die Absicht. Reihenfolge je Durchsicht: erst auflösen, dann proposeIn; alle Aufrufe mit notify: config.notify.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/chains.e2e-spec.ts, test/worker-runtime.e2e-spec.ts (Handler-IDs Worker = App) → PASS; kein Commit.

Task 12: Wartezeit-Kette

Files

  • Create: src/common/warnings/plausibility-warning.ts (+ Spec), src/withdrawal/withdrawal-check.service.ts (+ Spec), src/withdrawal/withdrawal.handlers.ts (+ Spec), src/withdrawal/withdrawal.module.ts
  • Modify: src/harvests/harvests.service.ts + harvests.controller.ts + harvests.module.ts (Antwort mit warnings, importiert WithdrawalModule), src/bottling/bottling.service.ts + bottling.controller.ts + bottling.module.ts (Antwort mit warnings, importiert WithdrawalModule), src/calendar-sync/calendar-sync.service.ts (+ Spec; Quelle WITHDRAWAL, ungenutzter Altpfad enqueue samt runAfterCommit entfällt), src/common/after-commit-usage.spec.ts (runAfterCommit-Liste ohne Kalender), src/calendar-sync/calendar-sync.handlers.ts (+ Spec), src/calendar-sync/graph-client.service.ts (+ Spec; endDate), src/calendar/calendar.service.ts (+ Spec; Typ WITHDRAWAL), src/app.module.ts, src/worker/worker.module.ts
  • Test: neu test/withdrawal-chain.e2e-spec.ts, test/calendar.e2e-spec.ts, test/harvests.e2e-spec.ts, test/bottling.e2e-spec.ts
  • Docs: Annahme 10 ergänzen (ein Fenster-Helfer, Prüfung bei Ernte und Abfüllung – Schleuderung folgt mit T4b –, Meldung einmal je Ernte und Volk, „wieder erntefähig“, Spanne im Kalender).

Interfaces

  • Consumes: Task 1 (readRule), Task 3 (Meldungen, syncWithdrawal), Task 6 (withdrawalWindowOf, isInWindow, WithdrawalWindow aus src/treatments/withdrawal-window.ts, seit dem Plan-Review dort), Task 10 (Handler-Datei).
  • Produces:
// WithdrawalWindow, withdrawalWindowOf und isInWindow liefert Task 6 (src/treatments/withdrawal-window.ts, Welle 3); Task 12 importiert sie nur.
export type PlausibilityWarningCode = 'WITHDRAWAL_ACTIVE' | 'EXTRACTION_EXCEEDS_HARVEST' | 'HARVEST_NET_MISSING' | 'BATCHES_EXCEED_EXTRACTION'
  | 'WATER_CONTENT_HIGH' | 'WATER_CONTENT_ABOVE_LEGAL' | 'WATER_CONTENT_MISSING' | 'JAR_SIZE_DIFFERS';
export type WarningSeverity = 'HINWEIS' | 'WARNUNG' | 'BESTAETIGEN';
export interface PlausibilityWarning { code: PlausibilityWarningCode; severity: WarningSeverity; message: string; refs: { type: string; id: string }[] }
export const WARNING_SEVERITY: Readonly<Record<PlausibilityWarningCode, WarningSeverity>>;   // Werte aus Global Constraints
export type WithWarnings<T> = T & { warnings: PlausibilityWarning[] };
export function withWarnings<T extends object>(record: T, warnings: readonly PlausibilityWarning[]): WithWarnings<T>;
export interface WithdrawalConflict { harvestId: string; harvestedOn: string; colonyId: string; treatmentId: string; product: string; untilDay: string }
WithdrawalCheckService.conflictsForHarvests(db: DbClient, harvestIds: readonly string[]): Promise<WithdrawalConflict[]>;
WithdrawalCheckService.conflictsForHoneyBatch(db: DbClient, honeyBatchId: string): Promise<WithdrawalConflict[]>;  // Charge -> Schleuderung -> Ernten
WithdrawalCheckService.warningsFor(db: DbClient, conflicts: readonly WithdrawalConflict[]): Promise<PlausibilityWarning[]>; // 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.'
WithdrawalCheckService.notifyConflictsIn(tx: Prisma.TransactionClient, conflicts: readonly WithdrawalConflict[]): Promise<number>;
class WithdrawalEventHandlers {
  @OnDomainEvent(['HarvestRecorded', 'HarvestCorrected'], 'withdrawal.harvest-check')
  @OnDomainEvent(['BatchBottled', 'BottlingCorrected'], 'withdrawal.bottling-check')     // removed: true -> nichts
  @OnDomainEvent('WithdrawalEnded', 'notifications.withdrawal-ended')
}
@OnDomainEvent(['TreatmentRecorded', 'TreatmentCorrected'], 'calendar-sync.withdrawal')   // enqueueIn(tx, 'WITHDRAWAL', treatmentId)
// GraphCalendarEventInput += endDate?: Date  (ganztägig, exklusiv); CalendarEventType += 'WITHDRAWAL' mit endDate (letzter Tag einschließlich), nur mit treatments:read
// POST/PATCH /harvests und POST/PATCH /bottling: Antwort WithWarnings<…>, nie 4xx wegen Wartezeit
  • Meldung „Konflikt“: WARNUNG, Kategorie WITHDRAWAL, Empfänger mit harvests:read und treatments:read, Text „Wartezeit: {Volk} am {TT.MM.} geerntet, Wartezeit nach {Mittel} endet am {TT.MM.JJJJ}.“, Link /harvests/<id>, Präfix withdrawal-conflict:<harvestId>:<colonyId>.

  • Meldung „erntefähig“ (nur bei WITHDRAWAL_ENDED.notify und ohne weiteres laufendes Fenster des Volks): INFO, Kategorie WITHDRAWAL, Empfänger mit harvests:create und treatments:read, Text „{Volk} ist wieder erntefähig. Wartezeit nach {Mittel} endete am {TT.MM.JJJJ}.“, Link /colonies/<id>, Präfix withdrawal-ended:<treatmentId>.

  • Kalender: Titel „Wartezeit {Volk} · {Mittel}“, date = fromDay, endDate = untilDay; ohne Wartezeit → null (Event wird gelöscht, Rückgrat Task 8).

  • Schritt 1: Failing Tests

// withdrawal-window.spec.ts: seit dem Plan-Review in Task 6 (Fenster-Helfer ist dort, Welle 3)
// withdrawal-check.service.spec.ts
it('finds the treatment whose window includes the harvest date per colony', ...);
it('reports each (harvest, colony) conflict only once across stages', ...);   // Präfix gleich -> zweite Meldung schreibt nichts
// test/withdrawal-chain.e2e-spec.ts
it('a harvest inside the window answers 201 with a warning and leaves one notification', ...);
it('a bottling of that harvest warns again but notifies no second time', ...);
it('WithdrawalEnded yields one INFO only when no other window is running', ...);
it('a treatment with withdrawal days leaves a WITHDRAWAL calendar entry; removing the days unregisters it', ...);
// test/calendar.e2e-spec.ts
it('lists WITHDRAWAL as a span with endDate for users with treatments:read', ...);
  • Schritt 2: npx jest src/treatments src/withdrawal src/common/warnings src/calendar-sync src/calendar src/jobs und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren wie in Interfaces; WithdrawalModule (importiert NotificationsModule; kein PermissionsModule, Global Constraints) in AppModule und WorkerModule; HarvestsModule und BottlingModule importieren WithdrawalModule für die Antwort-Warnungen (Moduldateien stehen in der Dateiliste oben). Das Fenster kommt aus withdrawalWindowOf (Task 6); getWithdrawalStatus und withdrawalEndDay rechnen dort schon darüber.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/withdrawal-chain.e2e-spec.ts, test/calendar.e2e-spec.ts, test/calendar-sync.e2e-spec.ts, test/harvests.e2e-spec.ts, test/bottling.e2e-spec.ts, test/daily-evaluation.e2e-spec.ts, test/treatments.e2e-spec.ts → PASS; kein Commit.

Task 13: Saison nach Kalender, Fälligkeit der Durchsicht, Zeit-Ereignisse

Files

  • Create: src/season/season-calendar.ts (+ Spec), src/season/season-segment.ts (+ Spec), src/season/inspection-due.ts (+ Spec), src/season/varroa-due.ts (+ Spec), src/season/season.service.ts (+ Spec), src/season/season.module.ts, src/season/queen-age-threshold.spec.ts (Wächter), src/jobs/daily-evaluation-steps.ts (+ Spec), src/jobs/steps/time-events.step.ts (+ Spec)
  • Modify: src/jobs/daily-evaluation.job.ts, src/jobs/daily-evaluation.rules.ts (+ Spec; INSPECTION_INTERVAL_DAYS, ACTIVE_SEASON_MONTHS, QUEEN_AGED_MIN_YEARS, VARROA_MEASUREMENT_INTERVAL_DAYS samt colonyCyclesDue/latestCycleOccurrence/inActiveSeason entfallen; queenAged, varroaMeasurementDue und inspectionOverdue in DAILY_EVALUATION_RULES rechnen neu über SeasonService und die Regeln, withdrawalEnded und taskDue bleiben; CATCH_UP_DAYS, catchUpEvents, inCatchUpWindow bleiben), src/jobs/jobs.module.ts, src/system/job-run.service.ts (+ Spec; JobPartialFailure), src/dashboard/dashboard.service.ts (+ Spec; OLD_QUEEN_AGE_YEARS entfällt)
  • Test: test/daily-evaluation.e2e-spec.ts
  • Docs: neue Annahme „Saison aus Pflanzenentwicklung“ (in T4a nur Kalender-Rückfall, Daten für Schwaben = Annahme), neue Annahme „Alter der Königin“ (queenMaxAgeYears = 2, Begründung aus den Spec-Entscheidungen); ARCHITECTURE §10 „Zeitbasierte Ereignisse“ und „Jobs“: Schritte und Zähler.

Interfaces

  • Consumes: Task 1 (SeasonState, Configs, readRule, dates.ts).
  • Produces:
export function calendarPhase(today: string, fallback: Record<PhenologyPhaseKey, string>): { phase: PhenologyPhaseKey | null; since: string | null }; // Phase des Vorjahres-WINTER vor SALWEIDE
export function segmentOf(phase: PhenologyPhaseKey | null, swarmSeason: PhenologyConfig['swarmSeason']): SeasonSegmentKey;
export function calendarSeasonState(apiaryId: string, today: string, config: PhenologyConfig): SeasonState;   // phaseSource 'CALENDAR'
export interface InspectionDue { dueOn: string; firstOfYear: boolean; intervalDays: number }
export function inspectionDueOn(input: { createdOn: string; lastInspectedOn: string | null }, season: Pick<SeasonState, 'segment' | 'springSince'>, config: InspectionIntervalConfig): InspectionDue | null;
export interface VarroaDue { dueOn: string; kind: 'INTERVAL' | 'WINTER_WINDOW'; anchor: string }
export function varroaMeasurementDue(input: { createdOn: string; lastMeasuredOn: string | null; lastMeasurementId: string | null }, season: Pick<SeasonState, 'segment'>, config: SeasonAutopilotConfig, today: string): VarroaDue | null;
export function isQueenAged(hatchYear: number | null, today: string, queenMaxAgeYears: number): boolean;
SeasonService.statesFor(db: DbClient, apiaryIds: readonly string[], today: string): Promise<Map<string, SeasonState>>; // T4a: Kalender; Task 25: ApiaryPhenology
export const DAILY_EVALUATION_STEP_ORDER = ['phenology', 'time-events', 'autopilot', 'suggestions-expiry', 'digest'] as const;
export type DailyEvaluationStepName = (typeof DAILY_EVALUATION_STEP_ORDER)[number];
export interface DailyEvaluationStep { readonly name: DailyEvaluationStepName; run(today: string): Promise<JobResult> }
export function runDailyEvaluationSteps(steps: readonly DailyEvaluationStep[], today: string, logger: Logger): Promise<{ result: JobResult; failed: DailyEvaluationStepName[] }>;
export class JobPartialFailure extends Error { constructor(readonly result: JobResult, readonly failedSteps: readonly string[]) }  // JobRunService speichert result bei FAILED
TimeEventsStep.run(today: string): Promise<JobResult>;   // WithdrawalEnded, TaskDue, QueenAged, VarroaMeasurementDue, InspectionOverdue; übernimmt die Schleife aus DailyEvaluationJob.evaluate über DAILY_EVALUATION_RULES
// DailyRule.occurrences(db: PrismaService, today: Date, ctx: { seasons: SeasonService; day: string }): today = dayToDate(day), day = Berliner Tag des Laufs; Ergebnis je Typ weiter { Typ: Zahl neuer Ereignisse }
  • Regeln time-events (Nachholfenster 7 Tage, dailyDedupeKey, je Typ eine Transaktion wie bisher): InspectionOverdue am Tag nach inspectionDueOn, nie in WINTERRUHE; VarroaMeasurementDue am dueOn aus varroaMeasurementDue; QueenAged nicht mehr nur am <Jahr>-01-01 (das Nachholfenster von 7 Tagen ließ eine später zugewiesene oder später gealterte Königin das ganze Jahr aus; Entscheidung zum Übertrag aus Rückgrat Task 18): an jedem Jobtag für jedes lebende Volk, dessen aktuelle Königin isQueenAged(hatchYear, heute, queenMaxAgeYears) ist, einmal je Königin und Jahr – Schlüssel dailyDedupeKey('QueenAged', queenId, '<Jahr>-01-01') (Jahresanker), Ereignis am Jobtag; das deckt Zuweisung im Jahr, Wechsel des Schlupfjahres und Jahreswechsel ab. Fälligkeiten im Winter (November–Februar) bleiben ohne Ereignis in WINTERRUHE; inspectionDueOn setzt sie auf den Beginn von FRUEHJAHR („Erste Durchsicht im Jahr“), InspectionOverdue folgt am Tag danach und A1 (Task 14) schlägt INSPECTION_FIRST_OF_YEAR vor; der Saisonbeginn selbst erscheint als Meldung season-segment (Task 26), eine feste Märzmeldung entfällt; WithdrawalEnded unverändert.

  • A2-Winterfenster: Abschnitt WINTERRUHE, heute zwischen 11-15 und 12-20, keine Messung seit 11-01 → dueOn = <Jahr>-11-15, anchor = 'winter-<Jahr>'; sonst außerhalb WINTERRUHE (letzte Messung oder createdOn) + varroaMeasureIntervalDays, anchor = Mess-ID oder 'none'.

  • Schritt 1: Failing Tests

// season-segment.spec.ts
it.each([[null, 'WINTERRUHE'], ['SALWEIDE', 'FRUEHJAHR'], ['KIRSCHE', 'SCHWARMZEIT'], ['APFEL', 'SCHWARMZEIT'], ['LINDE', 'SOMMER'],
  ['TRACHTENDE', 'SPAETSOMMER'], ['HERBST', 'HERBST'], ['WINTER', 'WINTERRUHE']])('%p -> %p', ...);
it('honours a configured swarm season start at APFEL', () => expect(segmentOf('KIRSCHE', { from: 'APFEL', until: 'LINDE' })).toBe('FRUEHJAHR'));
// season-calendar.spec.ts
it('2026-03-10 is WINTERRUHE since the WINTER date of 2025', ...);
it('2026-05-12 is SCHWARMZEIT since 2026-04-15 by calendar', ...);
// inspection-due.spec.ts
it('adds the interval of the current segment to the last inspection', ...);       // SCHWARMZEIT 7
it('returns null in WINTERRUHE', ...);
it('counts from createdAt without any inspection', ...);
it('is due from the start of FRUEHJAHR when the last inspection was before it (first of the year)', ...);
// varroa-due.spec.ts
it('is due 28 days after the last measurement outside WINTERRUHE', ...);
it('is due on 11-15 in the winter window without a measurement since 11-01', ...);
// time-events.step.spec.ts
it('the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey', ...);
it('emits InspectionOverdue on the day after inspectionDueOn and none in WINTERRUHE', ...);
it('QueenAged uses queenMaxAgeYears', ...);
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', ...);
it('QueenAged is not limited to January 1–8', ...);
it('a due date inside the winter yields InspectionOverdue the day after the start of FRUEHJAHR and none inside WINTERRUHE', ...);
// daily-evaluation-steps.spec.ts
it('a failing step leaves the others running and the run FAILED with all counters', ...);
// dashboard.service.spec.ts
it('OLD_QUEEN starts at queenMaxAgeYears (2)', ...);
// queen-age-threshold.spec.ts (Wächter)
it('there is no second queen age threshold in src', () => expect(offenders(/OLD_QUEEN_AGE_YEARS|QUEEN_AGED_MIN_YEARS/)).toEqual([]));
// test/daily-evaluation.e2e-spec.ts
it('InspectionOverdue follows the segment interval', async () => { await app.get(TimeEventsStep).run('2026-05-12'); … });
  • Schritt 2: npx jest src/season src/jobs src/system src/dashboard und die e2e-Datei → FAIL.
  • Schritt 3: Implementieren
    • time-events.step.ts verschiebt evaluate/evaluateRule aus DailyEvaluationJob (eine Transaktion je Regel, ein scheiternder Typ wird geloggt, hält die anderen nicht auf und lässt den Schritt scheitern); DailyEvaluationJob.evaluate(today: Date) entfällt, die bestehenden Aufrufe in daily-evaluation.job.spec.ts und test/daily-evaluation.e2e-spec.ts rufen TimeEventsStep.run('<Tag>') bzw. den Job.
    • DailyEvaluationJob.run(): statt SELECT now() (UTC) today = berlinToday(), Schritte in DAILY_EVALUATION_STEP_ORDER (Konstruktor injiziert die vorhandenen Schritte; Tasks 14, 16, 25 ergänzen ihre), runDailyEvaluationSteps; bei failed.length > 0 → throw new JobPartialFailure(result, failed); JobRunService.run speichert dann result und error.
    • SeasonService.statesFor liest PHENOLOGY einmal und baut calendarSeasonState je Stand.
    • Dashboard liest queenMaxAgeYears über readRule(prisma, 'SEASON_AUTOPILOT').
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/daily-evaluation.e2e-spec.ts, test/jobs.e2e-spec.ts, test/dashboard.e2e-spec.ts → PASS; kein Commit.

Task 14: Saison-Autopilot – Kern mit A1–A3

Files

  • Create: src/autopilot/autopilot.types.ts, src/autopilot/autopilot-rules.ts (+ Spec), src/autopilot/autopilot-input.ts (+ Spec), src/autopilot/autopilot.service.ts (+ Spec), src/autopilot/autopilot.handlers.ts (+ Spec), src/autopilot/autopilot.step.ts, src/autopilot/suggestions-expiry.step.ts, src/autopilot/autopilot.module.ts
  • Modify: src/suggestions/texts/autopilot-texts.ts, src/app.module.ts, src/worker/worker.module.ts, src/jobs/jobs.module.ts, src/jobs/daily-evaluation.job.ts
  • Test: neu test/autopilot.e2e-spec.ts, test/daily-evaluation.e2e-spec.ts
  • Docs: ARCHITECTURE §10: Handler autopilot.colony-sync, Job-Schritte autopilot und suggestions-expiry, Abgleich (Spec 8.3).

Interfaces

  • Consumes: Task 4, 10, 13.
  • Produces:
export interface AutopilotInput {
  colony: { id: string; code: string; status: ColonyStatus; colonyType: ColonyType; apiaryId: string; createdOn: string; feedStatus: ColonyCondition | null };
  lastInspection: { id: string; date: string; colonyStrength: number | null; feedStatus: ColonyCondition | null; queenCellsPresent: boolean | null } | null;
  lastVarroa: { id: string; date: string } | null;
  lastTreatment: { id: string; startDay: string; endDay: string } | null;
  varroaSinceLastTreatmentEnd: boolean;
  feedingsSinceInspection: number; feedingsSinceLateSummer: number; treatmentsSinceLateSummer: number;   // A4–A6 (T4b)
  queen: { id: string; hatchYear: number | null } | null;
  winterChecks: { winterYear: number; season: WinterCheckSeason }[];
  season: SeasonState;
}
export interface AutopilotConfigs { autopilot: ActiveRule<'SEASON_AUTOPILOT'>; interval: ActiveRule<'INSPECTION_INTERVAL'>; varroa: ActiveRule<'VARROA_THRESHOLD'>; phenology: PhenologyConfig }   // varroa: nur recheckAfterDays für A3
export interface AutopilotCandidate { rule: AutopilotRuleId; ruleKey: 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL'; intent: SuggestionIntent; mode: SuggestionMode;
  reasonCode: string; evidence: SuggestionEvidence; dueDate: string; priority: TaskPriority; anchor: string; requiredPermission: PermissionKey }
export type AutopilotRule = (input: AutopilotInput, configs: AutopilotConfigs, today: string) => AutopilotCandidate | null;
export const AUTOPILOT_RULES: Readonly<Partial<Record<AutopilotRuleId, AutopilotRule>>>;   // A1–A3 hier, A4–A10 in Task 27
export function evaluateAutopilot(input: AutopilotInput, configs: AutopilotConfigs, today: string): AutopilotCandidate[];
export function loadAutopilotInputIn(tx: Prisma.TransactionClient, colonyId: string, season: SeasonState): Promise<AutopilotInput | null>; // feste Folge von Abfragen
export interface AutopilotSyncResult { opened: number; refreshed: number; resolved: number }
AutopilotService.syncColonyIn(tx: Prisma.TransactionClient, colonyId: string, today: string): Promise<AutopilotSyncResult>;
class AutopilotEventHandlers { @OnDomainEvent([...], 'autopilot.colony-sync') colonySync(event, tx): Promise<void> }
AutopilotStep.run(today): Promise<JobResult>;            // { autopilotColonies, suggestionsOpened, suggestionsResolved }, je Volk eine Transaktion
SuggestionsExpiryStep.run(today): Promise<JobResult>;    // { suggestionsExpired }
  • autopilot.colony-sync hört auf: InspectionRecorded, InspectionCorrected, VarroaMeasured, VarroaCorrected, TreatmentRecorded, TreatmentCorrected, FeedingRecorded, FeedingCorrected, HarvestRecorded, HarvestCorrected (je Volk), ColonyStatusChanged, ColonySplit (Ableger und Muttervolk), QueenAssigned (Volk und previousColonyId), TaskCompleted, TaskUpdated (Aufgabe über tx laden, nur relatedType = COLONY), WithdrawalEnded, QueenAged (colonyId), InspectionOverdue, VarroaMeasurementDue. today = berlinToday().

  • Regeln: A1 INSPECT/DO/INSPECTION_INTERVAL, Bedingung segment ≠ WINTERRUHE und dueOn ≤ heute + 2, Fälligkeit dueOn, HIGH wenn dueOn < heute, Anker letzte Durchsicht-ID oder none, reasonCode INSPECTION_DUE bzw. INSPECTION_FIRST_OF_YEAR. A2 VARROA_MEASURE/DO aus varroaMeasurementDue, reasonCode VARROA_MEASURE_DUE bzw. VARROA_WINTER_MEASURE. A3 VARROA_RECHECK/DO, Behandlungsende vor ≥ varroa.config.recheckAfterDays (Standard 14), keine Messung seither, Fälligkeit endDay + recheckAfterDays, Anker Behandlungs-ID, reasonCode VARROA_RECHECK_DUE. Alle expiresAt = dueDate + suggestionTtlDays.

  • Abgleich exakt Spec 8.3: Volkszeile über lockRowForUpdate(tx, 'Colony', colonyId), Eingaben erst danach laden; beendet → Ende; SEASON_AUTOPILOT aus → alle offenen AUTOPILOT → RESOLVED („Autopilot aus“); Kandidat mit offenem Vorschlag gleichen Schlüssels → refreshIn; sonst offene Sache gleicher Absicht → verwerfen; sonst proposeIn (Kandidaten immer in der Reihenfolge A1…A10, damit die Regelsperren von proposeIn bei parallelen Abgleichen in gleicher Reihenfolge fallen); offene Autopilot-Vorschläge ohne Kandidat → RESOLVED („Bedingung entfällt“). resolveIn und expireDue geben den dedupeKey frei (Task 4), sodass dieselbe Bedingung nach Ablauf oder Erledigung neu vorgeschlagen werden darf; DISMISSED hält ihn. Regel- und Kettenvorschläge bleiben unberührt.

  • Schritt 1: Failing Tests

// autopilot-rules.spec.ts – je Regel „gegeben → erwartet“ mit Anker und reasonCode
it('A1: last inspection 8 days ago in SCHWARMZEIT -> INSPECT overdue, HIGH, anchor = inspection id', ...);
it('A1: due in 2 days -> NORMAL; due in 3 days -> nothing; WINTERRUHE -> nothing', ...);
it('A1: last inspection before spring start -> INSPECTION_FIRST_OF_YEAR', ...);
it('A2: last measurement 35 days ago -> VARROA_MEASURE due day 28', ...);
it('A2: winter window without a measurement since 11-01 -> anchor winter-2026', ...);
it('A3: treatment ended 14 days ago without a measurement since -> VARROA_RECHECK, anchor treatment id', ...);
it('a rule switched off in config.rules yields nothing', ...);
// autopilot.service.spec.ts
it('locks the colony row first and does nothing for an ended colony', ...);
it('refreshes an open suggestion with the same key instead of creating one', ...);
it('drops a candidate when an open task of the same intent exists', ...);
it('resolves open autopilot suggestions whose key vanished, never rule or chain suggestions', ...);
it('resolves every open autopilot suggestion when SEASON_AUTOPILOT is off', ...);
// test/autopilot.e2e-spec.ts (Abgleich mit festem Tag)
it('swarm season by calendar, last inspection 9 days ago -> step INSPECT overdue', ...);   // syncColonyIn(tx, id, '2026-05-12')
it('after a new inspection the INSPECT suggestion is RESOLVED', ...);
it('two colony syncs in parallel leave one INSPECT suggestion', ...);
// test/daily-evaluation.e2e-spec.ts
it('running autopilot and expiry twice yields the same suggestion counts', ...);
it('an OPEN suggestion past expiresAt becomes EXPIRED', ...);
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', ...);
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', ...);
  • Schritt 2: npx jest src/autopilot und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren wie oben; AutopilotModule importiert SeasonModule, SuggestionsModule; Texte in autopilot-texts.ts (z. B. INSPECTION_DUE → „Durchsicht“ / „Letzte Durchsicht vor 8 Tagen. In der Schwarmzeit alle 7 Tage.“).
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/autopilot.e2e-spec.ts, test/daily-evaluation.e2e-spec.ts, test/worker-runtime.e2e-spec.ts → PASS; kein Commit.

Task 15: Die nächsten 3 Schritte und „Heute“

Files

  • Create: src/autopilot/next-step-ranking.ts (+ Spec), src/autopilot/next-steps.service.ts (+ Spec), src/autopilot/colony-next-steps.controller.ts, src/autopilot/autopilot-today.controller.ts
  • Modify: src/autopilot/autopilot.module.ts, src/apiaries/apiary-field.service.ts (+ Spec), src/apiaries/apiary-field.types.ts, src/apiaries/apiaries.module.ts (importiert AutopilotModule)
  • Test: neu test/next-steps.e2e-spec.ts, test/apiaries.e2e-spec.ts
  • Docs: T3-Annahme „Ampeln“: Stand-Ampel gelb ab überschrittenem inspectionDueOn; Warnungen/Kritisch aus offenen Vorschlägen HIGH/URGENT.

Interfaces

  • Consumes: Task 4, 5 (visibleWhere), 6 (withdrawalWindowOf, seit dem Plan-Review dort), 13, 14.
  • Produces (Spec 8.5 wörtlich, dazu):
export interface NextStep { key: string; kind: 'TASK' | 'SUGGESTION'; intent: SuggestionIntent | null; title: string; reasons: string[]; dueOn: string | null;
  urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE';
  action: { type: 'CAPTURE'; form: NextStepForm; href: string } | { type: 'ACCEPT'; suggestionId: string }
    | { type: 'CLEANUP'; suggestionId: string } | { type: 'OPEN_TASK'; taskId: string; href: string };
  dismissible: boolean }
export interface ColonyNextSteps { colonyId: string; segment: SeasonSegmentKey; phase: PhenologyPhaseKey | null; phaseSource: PhaseSourceKey | null;
  inspectionDueOn: string | null; withdrawalUntil: string | null; steps: NextStep[] }
export interface StepSource { kind: 'TASK' | 'SUGGESTION'; id: string; intent: SuggestionIntent | null; mode: SuggestionMode | null; status: string;
  priority: TaskPriority; dueOn: string | null; createdAt: Date; title: string; reasons: string[]; colonyId: string }
export const INTENT_ORDER: readonly SuggestionIntent[];   // Spec 8.4 Punkt 3; Aufgabe ohne Vorschlag = FOLLOW_UP
export type NextStepForm = 'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check';
export const FORM_OF_INTENT: Readonly<Partial<Record<SuggestionIntent, NextStepForm>>>;
// INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE, HONEY_SUPER, HEALTH_CHECK -> inspection; VARROA_MEASURE, VARROA_RECHECK -> varroa;
// FEED -> feeding; VARROA_TREAT, SUMMER_TREATMENT -> treatment; WINTER_CHECK -> winter-check
export function rankSteps(items: readonly StepSource[], today: string): StepSource[];
export function mergeSameForm(ranked: readonly StepSource[]): StepSource[][];    // nur Gruppen mit mindestens einem DO-Vorschlag
export function toNextStep(group: readonly StepSource[], today: string): NextStep;
export interface AutopilotToday {
  nextStep: (NextStep & { colonyId: string; colonyCode: string }) | null;
  inspectionDue: { apiaryId: string; apiaryName: string; segment: SeasonSegmentKey; intervalDays: number | null; colonies: { id: string; code: string; dueOn: string; overdueDays: number }[] }[];
  suggestionsOpen: number; seasons: { apiaryId: string; apiaryName: string; segment: SeasonSegmentKey; phase: PhenologyPhaseKey | null; phaseSource: PhaseSourceKey | null }[];
  tasksVisible: boolean;
}
NextStepsService.forColony(colonyId: string, userId: string, today?: string): Promise<ColonyNextSteps>;   // höchstens maxStepsPerColony (3)
NextStepsService.forColonies(colonyIds: readonly string[], userId: string, today?: string): Promise<Map<string, ColonyNextSteps>>; // feste Abfragen, kein N+1
NextStepsService.today(userId: string, today?: string): Promise<AutopilotToday>;
// GET /colonies/:id/next-steps (colonies:read), GET /autopilot/today (colonies:read; Aufgaben nur mit tasks:read)
// ColonyOverviewRow += inspectionDueOn: string | null; segment: SeasonSegmentKey; nextStepTitle: string | null; openWarnings/openCritical = offene sichtbare Vorschläge HIGH/URGENT
// OfflinePackV1 += nextSteps?: Record<string, NextStep[]>  (version bleibt 1)
  • Rang (Spec 8.4): Dringlichkeit (IN_PROGRESS, überfällig oder heute = 0; ≤ 3 Tage = 1; ≤ 14 = 2; später = 3; ohne Datum = 4, ohne Datum und URGENT = 0), Priorität, INTENT_ORDER, Fälligkeit, createdAt, id. Zusammengelegte Gruppe: dringlichster Rang, höchstens zwei Gründe, Aktion CAPTURE, Titel „Durchsicht mit Schwarmkontrolle“ (Formular-Titel + „mit“ + zweiter Titel).

  • href: /inspections/new?colonyId=<id>, /varroa/new?colonyId=<id>, /feedings/new?colonyId=<id>, /treatments/new?colonyId=<id>, /winter?colonyId=<id>&season=AUTUMN|SPRING; Aufgabe /tasks.

  • Sichtbarkeit: Vorschläge über visibleWhere und nur mit tasks:read (wie GET /suggestions; ohne tasks:read gibt es keine Vorschlags-Schritte, die Aktion ACCEPT zusätzlich nur mit tasks:create, sonst höchstens der CAPTURE-Link, falls die Absicht ein Formular hat), Aufgaben nur mit tasks:read; withdrawalUntil = jüngstes laufendes untilDay nur mit treatments:read.

  • Schritt 1: Failing Tests

// next-step-ranking.spec.ts
it('IN_PROGRESS comes first, then overdue, then by priority and the intent table', ...);
it('a dateless URGENT step counts as urgent', ...);
it('INSPECT (DO) and SWARM_CHECK merge into one step with two reasons and CAPTURE', ...);
it('two PLAN steps of the same form do not merge', ...);
it('never returns more than 3 and is stable by id', ...);
// test/next-steps.e2e-spec.ts – Beispiel B3 am 2026-05-12 aus Spec 8.5
it('returns the three steps of the spec example in order', ...);
it('next steps never carry a varroa reason without varroa:read', ...);
it('a user without tasks:read gets no suggestion steps and no ACCEPT action', ...);
it('withdrawalUntil appears as a hint, not as a step', ...);
it('GET /autopilot/today lists due inspections per apiary and hides tasks without tasks:read', ...);
// test/apiaries.e2e-spec.ts
it('colony-overview carries inspectionDueOn, segment and nextStepTitle; warnings come from suggestions', ...);
  • Schritt 2: npx jest src/autopilot src/apiaries und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren wie oben; Texte über renderSuggestion mit einem EvidenceLookup für alle Völker.
  • Schritt 4: → PASS, inklusive test/authorization-coverage.e2e-spec.ts.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; kein Commit.

Task 16: Erinnerungen in der App – Aufgabe fällig und Morgenübersicht

Files

  • Create: src/reminders/task-due.handlers.ts (+ Spec), src/reminders/digest.service.ts (+ Spec), src/reminders/digest.step.ts, src/reminders/reminders.module.ts
  • Modify: src/app.module.ts, src/worker/worker.module.ts, src/jobs/jobs.module.ts, src/jobs/daily-evaluation.job.ts
  • Test: neu test/reminders.e2e-spec.ts
  • Docs: neue Annahme „Erinnerungen“ (je fällige Aufgabe und Morgenübersicht, Empfänger, Inhalt mit Stand 04:30, keine leere Übersicht, Morgenzeit 05:00–12:00); ARCHITECTURE §10: Handler notifications.task-due, Job-Schritt digest.

Interfaces

  • Consumes: Task 3, 13, 14, 15 (NextStepsService.today-Daten für Durchsicht und Saison).
  • Produces:
class TaskDueHandlers { @OnDomainEvent('TaskDue', 'notifications.task-due') taskDue(event, tx): Promise<void> }
export interface DigestLine { text: string; permissions: PermissionKey[] }
export interface DigestData { today: string; tasks: { assignedUserId: string | null; overdue: boolean }[]; inspectionDue: AutopilotToday['inspectionDue'];
  suggestions: { requiredPermission: string | null }[]; seasonChanges: { apiaryName: string; segment: SeasonSegmentKey; intervalDays: number | null }[] }
export function buildDigest(userId: string, permissions: ReadonlySet<string>, data: DigestData): { message: string; requiredPermissions: PermissionKey[] } | null;
DigestService.runFor(today: string): Promise<number>;   // je aktivem Nutzer mit digestEnabled eine Transaktion
DigestStep.run(today: string): Promise<JobResult>;      // { digests }
  • notifications.task-due: Aufgabe über tx; nur OPEN/IN_PROGRESS; Empfänger = Zuständiger, sonst alle aktiven Nutzer mit tasks:complete; immer zusätzlich tasks:read und taskDueEnabled (fehlende Präferenz = an); HINWEIS, TASK_DUE, „Heute fällig: {Titel}“ (Titel ≤ 200 Zeichen), Link /tasks, relatedType 'Task', relatedId, requiredPermissions ['tasks:read'], Präfix task-due:<taskId>:<dueOn>.

  • Morgenübersicht: erste Zeile „Guten Morgen. Heute für dich:“, Zeilen nur mit Recht (Spec 9a.3): Aufgaben „{n} Aufgaben fällig, {k} davon überfällig.“ (Zuständige = Nutzer oder ohne Zuständigen bei tasks:complete; tasks:read), Durchsicht „Durchsicht fällig: {n} Völker am Stand {Name}.“ (colonies:read + inspections:read), Vorschläge „{n} Vorschläge warten.“ (tasks:read, gezählt nach requiredPermission), Saison „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“ (apiaries:read, nur bei segmentSince = heute). Keine Zeile → keine Übersicht. INFO, DIGEST, Link /dashboard, Präfix digest:<Tag>.

  • Schritt 1: Failing Tests

// task-due.handlers.spec.ts
it('notifies the assignee only', ...);
it('without an assignee notifies holders of tasks:complete', ...);
it('writes nothing for a user with taskDueEnabled false', ...);
it('uses the key task-due:<taskId>:<day>:<userId>', ...);
// digest.service.spec.ts
it('includes a line only with its permission and lists every permission used', ...);
it('builds no digest without content', ...);
it('writes at most one digest per user and day', ...);
// test/reminders.e2e-spec.ts
it('TaskDue after draining yields exactly one notification per recipient, also after redelivery', ...);
it('running the digest step twice yields one digest per user', ...);
it('the digest of a user without varroa or inspection rights has no such line', ...);
  • Schritt 2: npx jest src/reminders und die e2e-Datei → FAIL.
  • Schritt 3: Implementieren wie oben; DigestService lädt die Daten einmal je Lauf und filtert je Nutzer. Er injiziert PermissionResolverService, und RemindersModule importiert dafür kein PermissionsModule (Global Constraints, Rechte-Auflösung): in AppModule ist PermissionsModule global, in WorkerModule und JobsModule das resolver-only JobPermissionsModule (aus src/jobs/jobs.module.ts, WorkerModule importiert es seit Task 4). RemindersModule wird von AppModule, WorkerModule (Handler notifications.task-due) und JobsModule (DigestStep) importiert. test/worker-runtime.e2e-spec.ts startet WorkerModule und löst die Abhängigkeit auf.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/reminders.e2e-spec.ts, test/daily-evaluation.e2e-spec.ts, test/worker-runtime.e2e-spec.ts → PASS; kein Commit.

Task 17: Frontend – Seite „Vorschläge“

Files (alle in ApiCore-Frontend/)

  • Create: src/lib/suggestions/types.ts, api.ts, labels.ts (+ labels.spec.ts), grouping.ts (+ grouping.spec.ts), use-suggestion-summary.ts
  • Create: src/components/suggestions/suggestion-card.tsx (+ Spec), suggestion-group.tsx (+ Spec), due-date-sheet.tsx
  • Create: src/app/(app)/suggestions/page.tsx (+ page.spec.tsx)
  • Modify: src/app/(app)/nav-config.ts (+ Spec), src/app/(app)/sidebar.tsx, src/app/(app)/more-sheet.tsx, src/components/task-status.tsx (TaskSourceBadge), src/lib/tasks/types.ts (sourceSuggestionId)

Interfaces

  • Consumes: Task 5 (Routen, SuggestionView, SuggestionSummary).
  • Produces:
export type SuggestionIntent = 'INSPECT' | 'SWARM_CHECK' | 'QUEEN_CHECK' | 'QUEEN_ACCEPTANCE' | 'QUEEN_REPLACE' | 'SPLIT_PLAN' | 'VARROA_MEASURE' | 'VARROA_RECHECK'
  | 'VARROA_TREAT' | 'SUMMER_TREATMENT' | 'FEED' | 'HONEY_SUPER' | 'WINTER_CHECK' | 'HEALTH_CHECK' | 'FOLLOW_UP' | 'STOCK_REORDER' | 'CLEANUP';
export interface Suggestion { /* Spiegel von SuggestionView */ }
export function fetchSuggestions(query?: { status?: 'OPEN' | 'DISMISSED'; colonyId?: string; apiaryId?: string }): Promise<Suggestion[]>; // Key ["suggestions", query]
export function fetchSuggestionSummary(): Promise<SuggestionSummary>;                                            // Key ["suggestions", "summary"]
export function acceptSuggestion(id: string, input: { dueDate?: string | null }, options: CreateOptions): Promise<AcceptSuggestionResult>;
export function bulkAcceptSuggestions(ids: string[], options: CreateOptions): Promise<AcceptSuggestionResult[]>;
export function dismissSuggestion(id: string, note: string | null, options: CreateOptions): Promise<Suggestion>;
export function bulkDismissSuggestions(ids: string[], options: CreateOptions): Promise<{ dismissed: number }>;
export function reopenSuggestion(id: string, options: CreateOptions): Promise<Suggestion>;
export const INTENT_LABELS: Record<SuggestionIntent, string>;
export const INTENT_ICONS: Record<SuggestionIntent, LucideIcon>;
export interface SuggestionGroupModel { key: string; label: string; apiaryId: string | null;
  rows: ({ kind: 'single'; suggestion: Suggestion } | { kind: 'combined'; intent: SuggestionIntent; suggestions: Suggestion[] })[] }
export function groupSuggestions(list: readonly Suggestion[]): SuggestionGroupModel[];  // je Stand, dann „Betrieb“; gleiche Absicht je Stand zusammengefasst
// NavItem += counter?: 'suggestions'
  • Bedienung (Spec 11.3): Kopf „{n} Vorschläge“ + „Übernehmen plant eine Aufgabe. Verwerfen blendet den Vorschlag aus.“; INSPECT-Gruppe „Durchsicht fällig · 6 Völker“ mit „Runde starten“ (startRound mit den Völkern, Reihenfolge nach Code, dann Navigation zur ersten Durchsicht); PLAN-Gruppe „Honigraum prüfen · 3 Völker“ mit „Alle übernehmen“ (bulk-accept), Aufklappen als Button mit aria-expanded; Karte: Icon, Titel, Gründe, DueChip, Hauptknopf („Übernehmen“, „Erfassen“ → CAPTURE-Link, „Aufräumen“ → Task 18), Text-Knopf „Verwerfen“, Menü „Als Aufgabe planen“ (DO) bzw. „Termin ändern“ (PLAN) über DueDateSheet; Toasts „Aufgabe angelegt · Ansehen“ und „Verworfen · Rückgängig“ (8 s, Rückgängig = reopen); unten Link „Verworfene (30 Tage)“ mit „Wieder öffnen“; leer „Keine offenen Vorschläge.“

  • Navigation: „Vorschläge“ in Gruppe „Arbeit“, Icon Lightbulb, Recht tasks:read, Zähler aus summary.open. TaskSourceBadge: „Aus Vorschlag“ (Titel „Aus einem übernommenen Vorschlag“) bei sourceSuggestionId oder sourceAutomationTrigger.

  • Schritt 1: Failing Tests

// labels.spec.ts
it('every intent has a label and an icon', () => { for (const i of INTENTS) { expect(INTENT_LABELS[i]).toBeTruthy(); expect(INTENT_ICONS[i]).toBeTruthy(); } });
// grouping.spec.ts
it('groups by apiary, then „Betrieb“, and combines the same intent per apiary', ...);
// page.spec.tsx
it('shows „3 Vorschläge“ and the explanation', ...);
it('„Alle übernehmen“ sends one bulk-accept with the group ids', ...);
it('„Verwerfen“ shows „Verworfen · Rückgängig“ and Rückgängig reopens', ...);
it('lists dismissed suggestions with „Wieder öffnen“', ...);
it('shows „Keine offenen Vorschläge.“ when empty', ...);
it('„Runde starten“ starts a round with the due colonies in code order', ...);
// suggestion-card.spec.tsx
it('the main button is named „Übernehmen“ for PLAN and „Erfassen“ for DO; touch targets are 44 px', ...);
// nav-config.spec.ts
it('„Vorschläge“ needs tasks:read and shows the open count', ...);
  • Schritt 2: npx vitest run src/lib/suggestions src/components/suggestions "src/app/(app)/suggestions" "src/app/(app)/nav-config.spec.ts" → FAIL.
  • Schritt 3: Implementieren – vorher node_modules/next/dist/docs/ lesen; Bausteine PageHeader, QueryState, EmptyState, Sheet, DueChip, ConfirmDialog, Idempotency-Key je Aktion wie im Bestand.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src/lib/suggestions src/components/suggestions "src/app/(app)"; kein Commit.

Task 18: Frontend – „Als Nächstes“, Heute, Stand und Aufräumen

Files (alle in ApiCore-Frontend/)

  • Create: src/lib/autopilot/types.ts, src/lib/autopilot/api.ts, src/components/autopilot/next-steps-card.tsx (+ Spec), src/components/autopilot/season-chip.tsx, src/components/today/inspection-due-card.tsx (+ Spec), src/components/suggestions/cleanup-sheet.tsx (+ Spec)
  • Modify: src/app/(app)/dashboard/page.tsx, src/components/today/today-panel.tsx, src/app/(app)/colonies/[id]/page.tsx (+ Spec), src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx (+ Spec), src/lib/apiaries/field-types.ts, src/lib/colonies/status-light.ts (+ Spec), src/components/suggestions/suggestion-card.tsx, src/app/(app)/winter/page.tsx (+ Spec)

Interfaces

  • Consumes: Task 15 (ColonyNextSteps, AutopilotToday, Übersichtsfelder), Task 9 (apply-cleanup, SuggestionView.cleanup), Task 17.
  • Produces:
export function fetchColonyNextSteps(colonyId: string): Promise<ColonyNextSteps>;   // Key ["colonies", colonyId, "next-steps"]
export function fetchAutopilotToday(): Promise<AutopilotToday>;                     // Key ["autopilot", "today"]
export function applyCleanup(suggestionId: string, input: ApplyCleanupInput, options: CreateOptions): Promise<CleanupResult>;
export const SEGMENT_LABELS: Record<SeasonSegment, string>;  // WINTERRUHE 'Winterruhe', FRUEHJAHR 'Frühjahr', SCHWARMZEIT 'Schwarmzeit', SOMMER 'Sommer', SPAETSOMMER 'Spätsommer', HERBST 'Herbst'
export function seasonChipText(s: { segment: SeasonSegment; intervalDays: number | null }): string;  // 'Schwarmzeit · alle 7 Tage'
export function colonyStatusLight(row: ColonyOverviewRow, options: { today: string; inspectionsVisible: boolean }): StatusLightResult;
// gelb ab überschrittenem row.inspectionDueOn („Durchsicht seit 3 Tagen fällig“); INSPECTION_STALE_DAYS entfällt
// field-types.ts: ColonyOverviewRow += occupiedFrames, inspectionDueOn, segment, nextStepTitle (openWarnings/openCritical zählen Vorschläge); OfflinePackV1 += nextSteps?
  • NextStepsCard oben auf der Volksseite: höchstens 3 Schritte mit Titel, ≤ 2 Gründen, DueChip, Aktion (CAPTURE → Link mit colonyId, ACCEPT → Übernehmen, CLEANUP → CleanupSheet, OPEN_TASK → Link), Hinweis „Wartezeit bis {TT.MM.}“ aus withdrawalUntil.

  • Heute: „Nächster Schritt“ aus GET /autopilot/today (offline wie bisher aus Aufgaben); Karte „Durchsicht fällig“ je Stand mit Völkerliste und „Runde starten“ (startRound nur mit den fälligen Völkern, nach Code); Chips „Vorschläge {n}“ und Saison je Stand.

  • Stand: Zeilen „Durchsicht fällig in 2 Tagen“ bzw. „seit 3 Tagen“ und der Titel des nächsten Schritts.

  • CleanupSheet: Aufgaben mit Vorauswahl „Abbrechen“, umschaltbar „Erledigt“/„Behalten“ (ChoiceGroup); Königin bei DECEASED „verstorben“, bei SOLD „verkauft“, sonst „unverändert“; ein Hauptknopf „Aufräumen“; ohne tasks:complete ist „Erledigt“ deaktiviert mit Hinweis.

  • Überwinterung: /winter?colonyId=&season= öffnet den Erfassungsdialog für dieses Volk.

  • Schritt 1: Failing Tests

it('NextStepsCard shows at most 3 steps with reasons and links CAPTURE to the form with colonyId', ...);
it('NextStepsCard shows the withdrawal hint, not a step', ...);
it('the dashboard shows the next step from /autopilot/today and falls back to tasks offline', ...);
it('„Durchsicht fällig“ starts a round only with the due colonies of that apiary', ...);
it('status light turns yellow once inspectionDueOn has passed', ...);
it('apiary rows say „Durchsicht fällig in 2 Tagen“ and „seit 3 Tagen“', ...);
it('CleanupSheet preselects „Abbrechen“ and the queen status by colony status and sends apply-cleanup', ...);
it('/winter?colonyId=c1&season=SPRING opens the dialog for that colony', ...);
  • Schritt 2: npx vitest run src/components/autopilot src/components/today src/components/suggestions src/lib/colonies "src/app/(app)/colonies" "src/app/(app)/apiaries" "src/app/(app)/winter" "src/app/(app)/dashboard" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src; kein Commit.

Task 19: Frontend – Varroa-Ampel mit Bändern und Probe

Files (alle in ApiCore-Frontend/)

  • Create: src/lib/varroa/bands.ts (+ Spec)
  • Modify: src/lib/varroa/rate.ts (+ Spec; altes varroaLight und VARROA_WARN_FRACTION entfallen), src/lib/varroa/api.ts (fetchVarroaThresholds), src/lib/varroa/use-varroa-threshold.ts → use-varroa-bands.ts, src/lib/varroa/types.ts (sampleBees, rating), src/components/varroa/varroa-rate-light.tsx, src/app/(app)/varroa/new/page.tsx + varroa-form-values.ts (+ Spec), src/app/(app)/colonies/[id]/colony-varroa.tsx (+ Spec), src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx (+ Spec)

Interfaces

  • Consumes: Task 6.
  • Produces:
export type VarroaLight = 'green' | 'yellow' | 'red';
export interface VarroaBand { method: VarroaMethod; unit: 'MITES_PER_DAY' | 'PERCENT'; yellowFrom: number | null; redAbove: number; reason: string; youngColony: boolean }
export interface VarroaBands { day: string; bands: Partial<Record<VarroaMethod, VarroaBand>>; postTreatmentUntil: string | null; active: boolean }
export function fetchVarroaThresholds(colonyId: string | null, date: string): Promise<VarroaBands>;  // Key ["varroa", "thresholds", colonyId, date], staleTime 1 h
export function varroaLight(value: number | null, band: VarroaBand | null | undefined): VarroaLight | null; // > redAbove rot, >= yellowFrom gelb
export function infestationPercent(miteCount: number | null, sampleBees: number | null): number | null;
export const SAMPLE_BEES_CHOICE = 500;  // Chip „500 Bienen (≈ 50 g)“, nichts vorgewählt
  • Texte (live, role="status"): Icon + Wort „Gut“, „Achtung“, „Kritisch“ bzw. „Behandlungseffekt – Nachkontrolle ab {TT.MM.}“; z. B. „12,4 Milben/Tag – Kritisch (Grenze 10, LWG)“; ohne Band nur der Wert.

  • Feld „Probe (Bienen)“ nur bei Puderzucker/Auswaschung; Trend: Bänder als Stufenlinie je Monat (vorher dataviz-Skill laden).

  • Schritt 1: Failing Tests

it('10,0/Tag in August is yellow, 10,1 red', ...);
it('powdered sugar in April has no light', ...);
it('shows „Behandlungseffekt – Nachkontrolle ab 16.08.“ inside the post-treatment window', ...);
it('the sample field appears only for powdered sugar and washing, the chip sets 500 without preselection', ...);
it('a stored sampleBees is sent back unchanged from the edit dialog', ...);
it('the chart draws the band as a monthly step line with an accessible summary', ...);
  • Schritt 2: npx vitest run src/lib/varroa src/components/varroa "src/app/(app)/varroa" "src/app/(app)/colonies" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src/lib/varroa src/components/varroa "src/app/(app)"; kein Commit.

Task 20: Frontend – Volkskarte in der Durchsicht, Futter-Vokabular, besetzte Waben

Files (alle in ApiCore-Frontend/)

  • Create: src/lib/colonies/condition.ts (+ Spec), src/components/colonies/colony-takeover-section.tsx (+ Spec)
  • Modify: src/lib/inspections/types.ts (feedStatus: ColonyCondition | null, applyToColony, colonyTakeover), src/lib/colonies/types.ts (occupiedFrames), src/app/(app)/inspections/new/inspection-form-values.ts (+ Spec), src/app/(app)/inspections/new/page.tsx (+ Spec), src/lib/inspections/takeover.ts (+ Spec), src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx (+ Spec), src/app/(app)/colonies/[id]/colony-edit-card.tsx (+ Spec), src/app/(app)/colonies/[id]/colony-actions.tsx (+ Spec; Ableger-Dialog mit Datum und Herkunft der Königin), src/lib/colonies/api.ts (splitColony), src/app/(app)/colonies/[id]/page.tsx, src/app/(app)/colonies/new/page.tsx + colony-form-values.ts (+ Spec)

Interfaces

  • Consumes: Task 2, 8, 10 (SplitColonyDto.date/queenSource).
  • Produces:
export const FEED_CONDITION_LABELS: Record<ColonyCondition, string>;          // wie Backend
export const INSPECTION_FEED_OPTIONS: readonly ['GOOD', 'WEAK', 'CRITICAL'];  // plus „Nicht geprüft“
export function ratingToCondition(rating: number | null): ColonyCondition | null; // Brutbild 5..1
export type SplitQueenSource = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN';          // lib/colonies/types.ts: SplitColonyInput += date?: string; queenSource?: SplitQueenSource
export const SPLIT_QUEEN_SOURCE_LABELS: Record<SplitQueenSource, string>;      // 'Eigene Zellen ziehen', 'Weiselzelle', 'Königin'
export interface ColonyTakeoverSectionProps { colony: Pick<Colony, 'occupiedFrames' | 'broodStatus' | 'feedStatus'>;
  values: Pick<InspectionFormValues, 'colonyStrength' | 'broodPattern' | 'feedStatus'>; selected: ColonyTakeoverField[]; onChange: (fields: ColonyTakeoverField[]) => void; canUpdateColony: boolean }
// InspectionFormValues += applyToColony: ColonyTakeoverField[] (Standard: genau die in dieser Durchsicht ausgefüllten Felder)
  • Abschnitt „Volkskarte“ direkt über „Speichern“: je Feld „Futter: Ausreichend → Niedrig“ mit Haken; ohne colonies:update fehlt er. Nach dem Speichern bei NEWER_INSPECTION Toast „Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.“

  • Ableger-Dialog (Spec 11.2, in colony-actions.tsx): zusätzlich zu Code und Typ ein Datumsfeld „Datum des Ablegers“ (Standard heute, nicht in der Zukunft; die Servermeldung „Das Datum liegt in der Zukunft.“ erscheint am Feld) und eine ChoiceGroup „Woher kommt die Königin?“ mit „Eigene Zellen ziehen“ (RAISE_OWN), „Weiselzelle“ (QUEEN_CELL), „Königin“ (QUEEN) und „Weiß ich noch nicht“ (sendet kein queenSource, keine Kette); nichts ist vorgewählt (Herkunft ist eine Beobachtung). Ein Satz unter der Wahl nennt die Folge, z. B. „Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte.“

  • Bearbeiten-Dialog zeigt einen gespeicherten Futterwert außerhalb der drei trotzdem. takeover.ts verliert den Freitext-Sonderfall.

  • Volk bearbeiten: „Besetzte Waben“ (NumberStepper 0–40); „Volksstärke (alt, Skala 1–10)“ nur, wenn gespeichert; Leeren sendet null. Neues Volk nur occupiedFrames. Volksseite „{n} besetzte Waben“, sonst „Volksstärke (alt): 7 von 10“.

  • Schritt 1: Failing Tests

it('checks only fields filled in this inspection and shows „bisher → neu“', ...);
it('hides the section without colonies:update', ...);
it('sends feedStatus as WEAK and applyToColony with the checked fields', ...);
it('the edit dialog shows a stored AVERAGE feed value', ...);
it('„Besetzte Waben“ accepts 0–40 and clearing sends null', ...);
it('shows the legacy strength field only when a value is stored', ...);
// colony-actions.spec.tsx
it('the split dialog sends date and queenSource with nothing preselected', ...);
it('„Weiß ich noch nicht“ sends no queenSource', ...);
it('a future date shows the server message at the date field', ...);
it('the colony page shows „Volksstärke (alt): 7 von 10“ without occupiedFrames', ...);
  • Schritt 2: npx vitest run src/lib/colonies src/lib/inspections src/components/colonies "src/app/(app)/inspections" "src/app/(app)/colonies" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src/lib src/components "src/app/(app)"; kein Commit.

Task 21: Frontend – Warnungen, Wartezeit im Kalender, Meldungen, Benachrichtigungs-Einstellungen

Files (alle in ApiCore-Frontend/)

  • Create: src/lib/warnings/types.ts, src/lib/warnings/toast-warnings.ts, src/components/warnings/warning-list.tsx (+ Spec), src/lib/notification-settings/types.ts, api.ts, validation.ts (+ Spec), src/app/(app)/profile/notification-settings-card.tsx (+ Spec)
  • Modify: src/lib/notifications/types.ts + api.ts (category, link, markAllNotificationsRead), src/components/notification-bell.tsx (+ neue Spec), src/lib/calendar/types.ts, src/app/(app)/calendar/page.tsx (+ neue Spec), src/app/(app)/bottling/new/page.tsx, src/app/(app)/harvests/new/page.tsx, src/app/(app)/profile/page.tsx

Interfaces

  • Consumes: Task 3, 12.
  • Produces:
export interface PlausibilityWarning { code: string; severity: 'HINWEIS' | 'WARNUNG' | 'BESTAETIGEN'; message: string; refs: { type: string; id: string }[] }
export interface WarningListProps { warnings: readonly PlausibilityWarning[]; title?: string }   // role="status" aria-live="polite", Icon + Wort je Stufe
export function toastWarnings(warnings: readonly PlausibilityWarning[]): void;                 // nach dem Speichern: „Gespeichert · 1 Warnung“ + Texte
export function markAllNotificationsRead(): Promise<{ updated: number }>;
export function fetchNotificationSettings(): Promise<NotificationSettings>;
export function updateNotificationSettings(patch: Partial<NotificationSettings>): Promise<NotificationSettings>; // nur Geändertes
export function validateDigestTime(value: string): string | null;  // 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.'
// CalendarEvent += endDate?: string (letzter Tag einschließlich), type 'WITHDRAWAL'
  • Glocke: „Alle gelesen“; Meldung mit link öffnet das Ziel (Next Link), sonst wie bisher.

  • Einstellungen-Karte (Profil > Benachrichtigungen): Schalter „Erinnerung je Aufgabe“, „Morgenübersicht“ mit Uhrzeit (Standard 06:30), „Ruhezeit“ von–bis (Standard 21:00–06:30, nur gemeinsam leerbar), Hauptknopf „Speichern“. Den Telegram-Bereich ergänzt Task 38.

  • Kalender: Wartezeit als Spanne „Wartezeit B3 · Oxuvar“ über alle Tage bis endDate.

  • Schritt 1: Failing Tests

it('WarningList announces warnings with icon and word in a status region', ...);
it('after a bottling with WITHDRAWAL_ACTIVE the toast names the colony and the end day', ...);
it('„Alle gelesen“ calls read-all and a notification with link navigates there', ...);
it('rejects 04:30 with the German message and clears quiet hours only as a pair', ...);
it('saving sends only changed fields', ...);
it('the calendar renders a withdrawal span across its days', ...);
  • Schritt 2: npx vitest run src/lib/warnings src/components/warnings src/lib/notification-settings src/components/notification-bell.spec.tsx "src/app/(app)/profile" "src/app/(app)/calendar" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src; kein Commit.

Task 22: Frontend – Verwaltung > Automatisierung für die neuen Regeln

Files (alle in ApiCore-Frontend/)

  • Create: src/lib/automation/rule-configs.ts (+ Spec), src/app/(app)/settings/automation/rule-forms/varroa-threshold-form.tsx, inspection-interval-form.tsx, chain-form.tsx, split-schedule-form.tsx, water-content-form.tsx, season-autopilot-form.tsx, phenology-form.tsx, notify-form.tsx (je + Spec, wo Logik)
  • Modify: src/lib/automation/types.ts (neue Trigger und Labels), src/app/(app)/settings/automation/automation-rules-tab.tsx

Interfaces

  • Consumes: Task 1 (Config-Formate, 400 mit issues).
  • Produces:
export const RULE_LABELS: Record<AutomationTriggerType, string>;   // z. B. SEASON_AUTOPILOT 'Saison-Autopilot', PHENOLOGY 'Pflanzenentwicklung'
export interface ValueSource { label: string; kind: 'SOURCE' | 'ASSUMPTION' }   // 'LWG Veitshöchheim', 'DWD', 'D.I.B.', 'HonigV Anlage 2', 'Annahme'
export const RULE_VALUE_SOURCES: Partial<Record<AutomationTriggerType, Record<string, ValueSource>>>; // Pfad -> Quelle
export function issuesByField(error: unknown): Record<string, string>;            // ApiError.body.issues -> Feldfehler
  • Je neue Regel ein Formular mit allen Werten aus Global Constraints; Quelle je Wert sichtbar (Text, kein Tooltip allein); A1–A10 einzeln schaltbar; Kalender-Rückfälle als MM-DD-Felder; notify als Schalter „Zusätzlich melden“; Hauptknopf „Speichern“.

  • Schritt 1: Failing Tests

it('the varroa form saves bands and shows „LWG Veitshöchheim“ next to the values', ...);
it('a 400 issue appears under its field', ...);
it('autopilot rules A1–A10 switch individually', ...);
it('every trigger has a label', ...);
  • Schritt 2: npx vitest run src/lib/automation "src/app/(app)/settings/automation" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src/lib/automation "src/app/(app)/settings"; kein Commit.

Abschluss T4a (Orchestrator)

  • Backend: npm run lint && npm run typecheck && npm test && npm run build; Test-DB migrieren, dann npm run test:e2e komplett.
  • Frontend: npm run lint && npm run typecheck && npm test && npm run build.
  • Drift-Check npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code → 0.
  • Wächter grün: handler-contract.spec.ts, Handler-IDs Worker = App, no-broadcast-notifications.spec.ts, colony-end-releases-queen.spec.ts, queen-age-threshold.spec.ts, after-commit-usage.spec.ts, Backup-Modell-Wächter, update-dto-null-handling.spec.ts, Text-Wächter, Intent-Labels.
  • Docs aus den Berichten in ARCHITECTURE.md (Annahmen mit nächster freier Nummer; Annahme 12, T3-„Ampeln“, 10, 13/16 geändert; §8, §10) und TASKS.md (Abschnitt T4a) eintragen und auf Widersprüche lesen.
  • Vor dem Deploy: App-Backup und pg_dump; Prüfabfrage colonyStrength > 40 (Migration 10); nach dem Deploy die Prüfabfragen aus Spec 4.12 (Migrationen 3, 8). Jeder Cluster-Schritt nur mit Freigabe des Inhabers.
  • Ein zentraler Commit, Nachricht endet mit Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>.

Lieferung T4b – Pflanzenentwicklung, Saisonregeln, Honig-Kette, Qualität

Task 23: Schema T4b – Pflanzenentwicklung, Bewegungsbuch, Haltbarkeit, Ereigniskatalog

Files

  • Modify: prisma/schema.prisma
  • Create: prisma/migrations/20261015100000_t4_phenology/, …100100_t4_honey_ledger/, …100200_t4_product_shelf_life/ (je migration.sql)
  • Modify: src/auth/permissions/permission-catalog.ts (Gruppe „Pflanzenentwicklung“), src/domain-events/domain-event-types.ts (T4b-Ereignisse), src/backup/backup.service.ts (+ Spec), src/prisma/row-lock.ts (+ Spec; Apiary, PhenologyReport, Extraction, HoneyBatch am Ende der Map, Schlüssel "id")
  • Create: src/season/season-enums.spec.ts (Wächter), test/honey-ledger-migration.e2e-spec.ts
  • Docs: neue Annahme „Bewegungsbuch der Honigcharge“ (Gramm, Invariante, Rückrechnung: frühere Handkorrekturen stecken in initialAmount); §8: PhenologyReport gesichert, ApiaryPhenology und ApiaryWeatherDay ausgenommen (Begründungen aus Spec 4.13).

Interfaces

  • Consumes: Task 2 (createScratchDatabase), Task 1 (season-types.ts).
  • Produces:
// Migration 4: enums PhenologyPhase, SeasonSegment, PhaseSource; models PhenologyReport, ApiaryPhenology, ApiaryWeatherDay exakt wie Spec 4.2;
//   INSERT Permission phenology:read/create/update (Gruppe 'Pflanzenentwicklung', sortOrder 860–862) und RolePermission für Nicht-System-Rollen
//   (read bei colonies:read, create/update bei inspections:create) – Muster 20260928091348_winter_checks
// Migration 6: HoneyBatch.initialAmount Float?; BatchMovement.honeyBatchId String? (FK Restrict, Index), sourceBottlingBatchId String? (FK SetNull);
//   CHECK neu num_nonnulls("bottlingBatchId","productionBatchId","rawMaterialLotId","honeyBatchId") = 1; Rückrechnung Spec 4.12
// Migration 7b: Product.shelfLifeMonths Int?
// DomainEventPayloads += (Spec 5.1)
ExtractionRecorded: { harvestIds: string[] };  ExtractionCorrected: { harvestIds: string[] };
HoneyBatchRecorded: { extractionId: string };   HoneyBatchCorrected: { extractionId: string };
PhenologyReported: { apiaryId: string | null; phase: PhenologyPhase; reportedOn: string };
PhenologyCorrected: { apiaryId: string | null; phase: PhenologyPhase; reportedOn: string };
SeasonPhaseChanged: { apiaryId: string; phase: PhenologyPhase | null; segment: SeasonSegment; previousPhase: PhenologyPhase | null; previousSegment: SeasonSegment | null; source: PhaseSource | null };
// AggregateType += 'Extraction' | 'HoneyBatch' | 'PhenologyReport' | 'Apiary'
  • Schritt 1: Failing Tests
// season-enums.spec.ts
it('Prisma phenology enums equal the TS constants of T4a', () => {
  expect(Object.values(PhenologyPhase)).toEqual([...PHENOLOGY_PHASES]);
  expect(Object.values(SeasonSegment)).toEqual([...SEASON_SEGMENTS]);
  expect(Object.values(PhaseSource)).toEqual([...PHASE_SOURCES]);
});
// test/honey-ledger-migration.e2e-spec.ts (Harness)
it('legacy bottlings keep amount = initialAmount + movements', async () => {
  // Charge amount 20, Abfüllungen 7,5 und 2,5 kg; danach migrateThrough('20261015100100_t4_honey_ledger')
  // initialAmount 30; zwei BOTTLING-Buchungen -7500 und -2500 g mit sourceBottlingBatchId und createdAt der Abfüllung
  expect(await db.query(INVARIANT_SQL)).toEqual([]);
});
it('a batch without amount gets no initialAmount and no booking', ...);
// backup.service.spec.ts
it('backs up PhenologyReport and excludes ApiaryPhenology and ApiaryWeatherDay with reasons', ...);
it('an old bundle without initialAmount is recalculated like migration 6', ...);
  • Schritt 2: npx jest src/season src/backup und die e2e-Datei → FAIL.
  • Schritt 3: Implementieren
    • Rückrechnung in Migration 6 je Charge mit amount IS NOT NULL: initialAmount = amount + Σ totalAmount ihrer Abfüllungen; je Abfüllung BOTTLING mit -round(totalAmount * 1000), sourceBottlingBatchId, createdAt der Abfüllung, Notiz „Übernahme aus Abfüllung (T4)“. Den Namen des alten CHECK aus 20261005100000_raw_materials_production übernehmen.
    • INVARIANT_SQL als exportierte Konstante in test/utils/honey-ledger.ts: Chargen mit abs(amount − (initialAmount + coalesce(Σ quantity, 0) / 1000)) > 0.0005.
    • Katalog-Eintrag phenology in permission-catalog.ts; Backup-Import: Chargen ohne initialAmount nach derselben Regel.
  • Schritt 4: → PASS; Drift-Check → 0.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/honey-ledger-migration.e2e-spec.ts, test/backup.e2e-spec.ts, test/roles.e2e-spec.ts, test/authorization-coverage.e2e-spec.ts → PASS; kein Commit.

Task 24: Wetterquelle und Worker-Schleife phenology-weather

Files

  • Create: src/weather/history/weather-history-source.ts, src/weather/history/weather-history.config.ts (+ Spec), src/weather/history/open-meteo-history.source.ts (+ Spec), src/weather/history/weather-history.service.ts (+ Spec), src/weather/history/weather-history.module.ts, test/weather-history.e2e-spec.ts
  • Modify: src/worker/worker-runtime.service.ts (+ Spec; Schleifen nach Name, Weckkanal optional), src/worker/worker-runtime.module.ts, src/weather/weather.service.ts (+ Spec; die dort fest verdrahtete Konstante OPEN_METEO_FORECAST_URL entfällt, URL und Schlüssel kommen aus weatherHistoryConfig(), damit beide Pfade dieselben Variablen lesen), src/jobs/cleanup.job.ts (+ Spec), .env.example, deploy/k8s/base/backend/configmap.example.yaml (Bedeutung der Schlüssel; der Overlay verweist darauf), deploy/k8s/base/backend/secret.example.yaml (OPEN_METEO_API_KEY, leer), deploy/k8s/overlays/encry/configmap-backend.yaml
  • Docs: neue Annahme „Wetterquelle“ (Inhaber-Entscheidung freie Open-Meteo-API mit bewusst übernommenem Lizenzrisiko, Adapter, Wechsel per Einstellung bzw. neuem Adapter); ARCHITECTURE §10: Schleife phenology-weather.

Interfaces

  • Consumes: Task 23 (ApiaryWeatherDay), Task 1 (DailyMean aus src/season/season-types.ts).
  • Produces:
export interface GeoPoint { lat: number; lon: number }                     // auf 0,01° gerundet
import type { DailyMean } from '../../season/season-types';   // Tagesmittel, definiert in Task 1 (nicht hier: Task 25 liegt in derselben Welle)
export interface WeatherHistorySource { readonly name: string; dailyMeans(point: GeoPoint, fromDay: string, toDay: string, signal?: AbortSignal): Promise<DailyMean[]> }
export const WEATHER_HISTORY_SOURCE = Symbol('WEATHER_HISTORY_SOURCE');
export interface WeatherHistoryConfig { source: 'open-meteo'; forecastUrl: URL; archiveUrl: URL; apiKey: string | null }
export function weatherHistoryConfig(env?: NodeJS.ProcessEnv): WeatherHistoryConfig;   // nur https:, unbekannte Quelle -> Fehler beim Start
export function roundPoint(lat: number, lon: number): GeoPoint;
export const WEATHER_HISTORY_POLL_MS = 3_600_000; export const WEATHER_FETCH_TIMEOUT_MS = 8_000;
export const WEATHER_MAX_RESPONSE_BYTES = 1_000_000; export const FORECAST_PAST_DAYS = 92; export const RECENT_WINDOW_DAYS = 90;
export const WEATHER_RETENTION_YEARS = 4;
WeatherHistoryService.syncOnce(today: string, signal: AbortSignal): Promise<{ points: number; daysWritten: number; failedPoints: number }>;
// WorkerRuntime: interface WorkerLoop { name: string; intervalMs: number; channel?: WakeChannel; tick: () => Promise<unknown> }; Map nach name
  • Je Takt: aktive Stände mit Koordinaten; fehlende Tage vom 1. Januar bis gestern (Berlin) = keine Zeile, oder meanTempC null in den letzten 90 Tagen; letzte 90 Tage über Forecast (past_days=92, daily=temperature_2m_mean, timezone=Europe/Berlin), ältere Lücken über Archiv (start_date, end_date); apikey nur, wenn gesetzt; Upsert je Stand und Tag mit source 'forecast'/'archive'. Fehler je Punkt über loggableError ohne URL, nächster Takt versucht es neu; kein Ereignis, keine Meldung.

  • Schritt 1: Failing Tests

it('selects open-meteo by WEATHER_HISTORY_SOURCE and refuses http: URLs', ...);
it('never puts the API key or the URL into an error message', ...);
it('maps values outside -50..50 °C to null', ...);
it('stops reading after 1 000 000 bytes and aborts after 8 s', ...);
it('apiaries on the same rounded point share one request', ...);
it('runs the loop without a wake channel and stops on shutdown', ...);   // worker-runtime.service.spec
it('cleanup deletes weather days before January 1st four years back', ...);
// test/weather-history.e2e-spec.ts (fetch gemockt)
it('fetches missing days once, with rounded coordinates, and writes nothing on an error', ...);
  • Schritt 2: npx jest src/weather src/worker src/jobs und die e2e-Datei → FAIL.
  • Schritt 3: Implementieren wie oben; Antwort über response.body mit Byte-Zähler lesen; AbortSignal.any([signal, AbortSignal.timeout(8000)]).
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/weather-history.e2e-spec.ts, test/weather.e2e-spec.ts, test/worker-runtime.e2e-spec.ts → PASS; kein Commit.

Task 25: Pflanzenentwicklung – GTS, Phase, Saisonzustand, Job-Schritt

Files

  • Create: src/phenology/gts.ts (+ Spec), src/phenology/phenology-phase.ts (+ Spec), src/phenology/phenology-calibration.ts (+ Spec), src/phenology/phenology-state.service.ts (+ Spec), src/phenology/phenology.step.ts, src/phenology/phenology.module.ts
  • Modify: src/season/season.service.ts (+ Spec; liest ApiaryPhenology), src/app.module.ts, src/worker/worker.module.ts, src/jobs/jobs.module.ts, src/jobs/daily-evaluation.job.ts
  • Test: test/daily-evaluation.e2e-spec.ts
  • Docs: Annahme „Saison aus Pflanzenentwicklung“ erweitern (GTS nach DWD, 200 °C = Salweide als Annahme, Kalibrierung als Median der Vorjahre, keine erfundenen Schwellen, Rückfall-Daten für Schwaben prüfen); ARCHITECTURE §10: Job-Schritt phenology, Ereignis SeasonPhaseChanged.

Interfaces

  • Consumes: Task 13 (segmentOf, calendarPhase, SeasonService), Task 1 (DailyMean aus src/season/season-types.ts; nicht aus Task 24, gleiche Welle), Task 23, Task 24 (nur zur Laufzeit: ApiaryWeatherDay gefüllt).
  • Produces:
export const GTS_MONTH_FACTORS: Readonly<Record<number, number>>;   // 1: 0.5, 2: 0.75, sonst 1
export interface GtsResult { gts: number; throughDay: string | null; expectedDays: number; missingDays: number; usable: boolean }
export function grasslandTemperatureSum(days: readonly DailyMean[], year: number, throughDay: string, maxGapShare: number): GtsResult;
export interface PhaseInputs { today: string; reports: { phase: PhenologyPhaseKey; date: string; revoked: boolean }[]; gts: GtsResult | null;
  hasCoordinates: boolean; thresholds: Partial<Record<PhenologyPhaseKey, number>>; config: PhenologyConfig; previous: { phase: PhenologyPhaseKey | null } | null; allowBackward: boolean }
export interface EffectivePhase { reportedPhase: PhenologyPhaseKey | null; reportedOn: string | null; estimatedPhase: PhenologyPhaseKey | null;
  effectivePhase: PhenologyPhaseKey | null; phaseSource: PhaseSourceKey | null; note: SeasonNote }
export function effectivePhaseOf(inputs: PhaseInputs): EffectivePhase;
export function calibrateThresholds(years: { firstReportDay: Partial<Record<PhenologyPhaseKey, string>>; gtsOn: (day: string) => number | null }[], phases: readonly PhenologyPhaseKey[]): Partial<Record<PhenologyPhaseKey, number>>; // Median, höchstens 3 Vorjahre
PhenologyStateService.recomputeIn(tx: Prisma.TransactionClient, apiaryId: string, today: string, options?: { allowBackward?: boolean }): Promise<{ changed: boolean; state: SeasonState }>;
PhenologyStateService.recomputeAll(today: string): Promise<{ apiaries: number; changes: number }>;   // je Stand eine Transaktion
PhenologyStep.run(today: string): Promise<JobResult>;   // { phenologyApiaries, seasonChanges }
  • recomputeIn: zuerst lockRowForUpdate(tx, 'Apiary', apiaryId), dann den bisherigen ApiaryPhenology-Zustand lesen (mehrere Stände: lockRowsForUpdate(tx, 'Apiary', ids)); Meldungen des Jahres für den Stand und für alle Stände (apiaryId null), ohne zurückgezogene; GTS aus ApiaryWeatherDay bis gestern; GTS älter als 3 Tage → nicht benutzt (WEATHER_STALE); ohne Koordinaten NO_COORDINATES; Schwellen = gtsThresholds (useGts) plus Kalibrierung für calibratePhases (calibrateFromReports); Kalender nur für Phasen ohne GTS-Schwelle oder ohne Wetterdaten. Maximum der drei, nie rückwärts außer allowBackward. Upsert ApiaryPhenology (apiaryId, year); bei geänderter Phase oder Abschnitt SeasonPhaseChanged mit dailyDedupeKey('SeasonPhaseChanged', apiaryId, today); segmentSince = heute bei Wechsel.

  • SeasonService.statesFor: Zeile des laufenden Jahres, wenn vorhanden, sonst Kalender (T4a-Verhalten).

  • Schritt 1: Failing Tests

// gts.spec.ts
it('weights January 0.5, February 0.75 and March 1', ...);
it('negative daily means count 0', ...);
it('is unusable with more than 10 % missing days', ...);
it('reaches 200 °C on the expected day', ...);
// phenology-phase.spec.ts
it('takes the maximum of report, GTS and calendar', ...);
it('ignores revoked reports', ...);
it('a report for all apiaries counts for each apiary', ...);
it('without a previous-year report there is no KIRSCHE threshold, the calendar applies', ...);
it('never moves backwards unless a report was revoked', ...);
// phenology-calibration.spec.ts
it('uses the median GTS on the first report day of up to three previous years', ...);
// phenology-state.service.spec.ts
it('records SeasonPhaseChanged once per apiary and day on a change', ...);
// test/daily-evaluation.e2e-spec.ts
it('the phenology step writes ApiaryPhenology for every active apiary', ...);
  • Schritt 2: npx jest src/phenology src/season und die e2e-Datei → FAIL.
  • Schritt 3: Implementieren wie oben; PhenologyModule in AppModule, WorkerModule, JobsModule.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/daily-evaluation.e2e-spec.ts, test/autopilot.e2e-spec.ts, test/worker-runtime.e2e-spec.ts → PASS; kein Commit.

Task 26: Pflanzen-Meldungen – API, Neuberechnung, Saison-Meldung

Files

  • Create: src/phenology/phenology.service.ts (+ Spec), src/phenology/phenology.controller.ts, src/phenology/dto/create-phenology-report.dto.ts, dto/update-phenology-report.dto.ts, dto/list-phenology-reports.query.ts, src/phenology/phenology.handlers.ts (+ Spec)
  • Modify: src/phenology/phenology.module.ts, src/common/validation/update-dto-null-handling.spec.ts
  • Test: neu test/phenology.e2e-spec.ts
  • Docs: ARCHITECTURE §10: Handler phenology.recompute, notifications.season-segment; Rechte-Gruppe „Pflanzenentwicklung“.

Interfaces

  • Consumes: Task 3, 23, 25.
  • Produces:
PhenologyService.create(dto: CreatePhenologyReportDto, userId: string): Promise<PhenologyReport>;   // Audit + PhenologyReported in einer Transaktion
PhenologyService.update(id: string, dto: UpdatePhenologyReportDto, userId: string): Promise<PhenologyReport>; // PhenologyCorrected
PhenologyService.revoke(id: string, userId: string): Promise<PhenologyReport>;                      // revokedAt; schon zurückgezogen -> 409
PhenologyService.list(year: number): Promise<PhenologyReportView[]>;                                 // alle Meldungen des Jahres, mit revokedAt
PhenologyService.state(): Promise<{ apiaryId: string; apiaryName: string; phase: PhenologyPhase | null; segment: SeasonSegment; phaseSource: PhaseSource | null;
  reportedOn: string | null; gts: number | null; gtsThroughDay: string | null; note: SeasonNote }[]>;   // nie Koordinaten
class PhenologyEventHandlers {
  @OnDomainEvent(['PhenologyReported', 'PhenologyCorrected'], 'phenology.recompute')       // apiaryId null -> alle aktiven Stände; Corrected -> allowBackward
  @OnDomainEvent('SeasonPhaseChanged', 'notifications.season-segment')                     // nur bei Wechsel des Abschnitts
}
// Routen (Spec 10.1): GET /phenology/state (apiaries:read); GET /phenology/reports?year= (phenology:read);
// POST /phenology/reports (phenology:create, @Idempotent); PATCH /phenology/reports/:id, POST /phenology/reports/:id/revoke (phenology:update, revoke @Idempotent)
// CreatePhenologyReportDto { phase: PhenologyPhase; date: string (nicht nach berlinToday -> 400 'Das Datum liegt in der Zukunft.'); apiaryId?: string | null; notes?: string (≤ 500) }
// UpdatePhenologyReportDto: apiaryId null = alle Stände, notes null leert; phase/date @IsOptionalNotNull
  • update und revoke: lockRowForUpdate(tx, 'PhenologyReport', id), before danach lesen (404, bei revoke schon zurückgezogen → 409), schreiben, Audit mit beforeData, Ereignis.

  • Saison-Meldung: INFO, SEASON, an apiaries:read, „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“ (ohne Satz 2, wenn der Abschnitt kein Intervall hat), Link /apiaries/<id>, Präfix season-segment:<apiaryId>:<segment>:<Tag>.

  • Schritt 1: Failing Tests

// test/phenology.e2e-spec.ts
it('a report for all apiaries sets SALWEIDE on every apiary and records SeasonPhaseChanged once per apiary', ...);
it('revoking recomputes and the phase can go back', ...);
it('a retry with the same key yields one report', ...);
it('a date in the future answers 400 "Das Datum liegt in der Zukunft."', ...);
it('403 without phenology:create; /phenology/state never contains coordinates', ...);
it('a segment change notifies apiaries:read holders once', ...);
  • Schritt 2: npx jest src/phenology src/common/validation und die e2e-Datei → FAIL.
  • Schritt 3: Implementieren wie oben.
  • Schritt 4: → PASS, inklusive test/authorization-coverage.e2e-spec.ts.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; kein Commit.

Task 27: Saison-Autopilot – Regeln A4–A10 und Abgleich je Stand

Files

  • Modify: src/autopilot/autopilot-rules.ts (+ Spec), src/autopilot/autopilot-input.ts (+ Spec), src/autopilot/autopilot.handlers.ts (+ Spec), src/autopilot/autopilot.service.ts (syncApiaryIn), src/suggestions/texts/autopilot-texts.ts
  • Test: test/autopilot.e2e-spec.ts
  • Create: src/suggestions/suggesting-triggers.spec.ts (Wächter, Spec 14.3)
  • Docs: ARCHITECTURE §10: Handler autopilot.apiary-sync.

Interfaces

  • Consumes: Task 14, 25 (SeasonPhaseChanged, ApiaryPhenology).
  • Produces:
AutopilotService.syncApiaryIn(tx: Prisma.TransactionClient, apiaryId: string, today: string): Promise<AutopilotSyncResult>; // alle lebenden Völker des Stands
@OnDomainEvent('SeasonPhaseChanged', 'autopilot.apiary-sync')
// AUTOPILOT_RULES += A4..A10 (Spec 8.2)
Regel intent / Art Bedingung Anker reasonCode Recht
A4 SUMMER_TREATMENT / PLAN, HIGH SPAETSOMMER, keine Behandlung seit segmentSince Jahr SUMMER_TREATMENT_DUE („Tracht vorbei (gemeldet am 25.07.). Noch keine Behandlung seit 25.07.“) treatments:read
A5 FEED / DO SPAETSOMMER oder HERBST, keine Fütterung seit Beginn SPAETSOMMER Jahr AUTUMN_FEED_MISSING („Einfüttern“) feedings:read
A6 FEED / DO ≠ WINTERRUHE, Futter der letzten Durchsicht WEAK/CRITICAL oder Volkskarte CRITICAL, keine Fütterung seither Durchsicht-ID FEED_LOW inspections:read
A7 HONEY_SUPER / DO Phase ≥ KIRSCHE und < LINDE, colonyStrength der letzten Durchsicht ≥ honeySuperMinFrames Jahr HONEY_SUPER_CHECK („Kirsche blüht (geschätzt). B3 besetzt 9 Waben.“) inspections:read
A8 QUEEN_REPLACE / PLAN SCHWARMZEIT oder SOMMER, isQueenAged <queenId>-<Jahr> QUEEN_OLD („Königin Q-12 ist von 2024.“) queens:read
A9 WINTER_CHECK / DO HERBST, keine Einwinterung für winterYear = Jahr <Jahr>-AUTUMN WINTER_CHECK_AUTUMN_MISSING („Einwinterung erfassen“) winter-checks:read
A10 WINTER_CHECK / DO FRUEHJAHR, Einwinterung des Vorwinters da, Auswinterung fehlt <Jahr−1>-SPRING WINTER_CHECK_SPRING_MISSING („Auswinterung erfassen“) winter-checks:read
  • Fälligkeit heute; expiresAt = dueDate + suggestionTtlDays; jede Regel einzeln über config.rules schaltbar; Phase und „gemeldet/geschätzt/Kalender“ im Grund aus SeasonState.

  • Schritt 1: Failing Tests

// autopilot-rules.spec.ts – je Regel A4–A10 „gegeben -> erwartet“ mit Anker und reasonCode
it('A4: SPAETSOMMER without treatment since segment start -> SUMMER_TREATMENT, HIGH, anchor year', ...);
it('A5 and A6 never fire in WINTERRUHE; A6 anchors on the inspection', ...);
it('A7: KIRSCHE and 9 frames -> HONEY_SUPER; 7 frames -> nothing; LINDE -> nothing', ...);
it('A8: hatchYear 2024 in 2026 SCHWARMZEIT -> QUEEN_REPLACE', ...);
it('A9 and A10 follow the WinterCheck rows', ...);
// suggesting-triggers.spec.ts (Wächter)
it('every suggesting AutomationTriggerType has at least one registered reason text template', ...);   // Liste der vorschlagenden Trigger im Wächter; ruleKey je Vorlage gegen die vier Register
it('every reasonCode passed to proposeIn in src is registered', ...);                                // Quelltext-Scan auf reasonCode gegen die Register
// test/autopilot.e2e-spec.ts
it('swarm season by report, last inspection 9 days ago -> step 1 INSPECT overdue', ...);   // Meldung KIRSCHE, drain, next-steps
it('a SeasonPhaseChanged syncs every living colony of the apiary', ...);
it('never more than 3 steps per colony', ...);
  • Schritt 2: npx jest src/autopilot und die e2e-Datei → FAIL.
  • Schritt 3: Implementieren wie oben (Eingaben aus loadAutopilotInputIn, keine zusätzlichen Abfragen je Regel).
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/autopilot.e2e-spec.ts, test/next-steps.e2e-spec.ts, test/daily-evaluation.e2e-spec.ts → PASS; kein Commit.

Task 28: Honig-Kette – Ereignisse, Vorbelegung, Plausibilität, Wartezeit bei der Schleuderung

Files

  • Create: src/honey-batches/honey-plausibility.ts (+ Spec), src/extractions/dto/extraction-prefill.query.ts, src/honey-batches/dto/honey-batch-prefill.query.ts
  • Modify: src/extractions/extractions.service.ts (+ Spec), src/extractions/extractions.controller.ts, src/extractions/extractions.module.ts, src/honey-batches/honey-batches.service.ts (+ Spec), src/honey-batches/honey-batches.controller.ts, src/honey-batches/honey-batches.module.ts, src/webhooks/webhook.handlers.ts (+ Spec), src/withdrawal/withdrawal.handlers.ts (+ Spec), src/withdrawal/withdrawal-check.service.ts (conflictsForExtraction), src/common/after-commit-usage.spec.ts
  • Test: neu test/honey-chain.e2e-spec.ts, test/extractions.e2e-spec.ts, test/honey-batches.e2e-spec.ts, test/webhooks.e2e-spec.ts
  • Docs: ARCHITECTURE §10: Ereignisse ExtractionRecorded/Corrected, HoneyBatchRecorded/Corrected, Webhook batch.created aus Handler, Handler withdrawal.extraction-check; Annahme 11: Altpfad-Liste ohne Honigchargen.

Interfaces

  • Consumes: Task 12 (PlausibilityWarning, WithdrawalCheckService), Task 23.
  • Produces:
export function extractionWarnings(amountKg: number | null, harvests: readonly { id: string; netWeight: number | null }[]): PlausibilityWarning[]; // EXTRACTION_EXCEEDS_HARVEST, HARVEST_NET_MISSING
export function batchWarnings(extraction: { id: string; amount: number | null }, batches: readonly { id: string; initialAmount: number | null }[]): PlausibilityWarning[]; // BATCHES_EXCEED_EXTRACTION
export interface ExtractionPrefill { amountKg: number | null; honeyType: string | null; sources: { harvestId: string; date: string; netWeight: number | null }[]; warnings: PlausibilityWarning[] }
export interface HoneyBatchPrefill { remainingKg: number | null; honeyType: string | null; waterContent: number | null; warnings: PlausibilityWarning[] }
ExtractionsService.prefill(harvestIds: readonly string[]): Promise<ExtractionPrefill>;   // GET /extractions/prefill?harvestIds=a,b (extractions:create; 1–50 IDs)
HoneyBatchesService.prefill(extractionId: string): Promise<HoneyBatchPrefill>;            // GET /honey-batches/prefill?extractionId= (honey-batches:create)
WithdrawalCheckService.conflictsForExtraction(db: DbClient, extractionId: string): Promise<WithdrawalConflict[]>;
@OnDomainEvent(['ExtractionRecorded', 'ExtractionCorrected'], 'withdrawal.extraction-check')
// DOMAIN_EVENT_WEBHOOKS += HoneyBatchRecorded: 'batch.created' (data = Charge über tx)
// POST/PATCH /extractions und /honey-batches: Antwort WithWarnings<…>
  • Vorbelegung (Spec 7.6): Schleudermenge = Σ netWeight (nur wenn alle bekannt), Sorte nur bei gleicher honeyType; Chargenmenge = Schleudermenge − Σ initialAmount vorhandener Chargen (null zählt nicht), Sorte und Wassergehalt der Schleuderung. Warnungen beim Anlegen und Ändern, nie eine Sperre.

  • create/update beider Services in einer Transaktion mit Audit (mit tx) und Ereignis; update sperrt zuerst (lockRowForUpdate(tx, 'Extraction' | 'HoneyBatch', id)) und liest before erst danach; webhooks.dispatch('batch.created') entfällt; der Handler lädt die Charge über tx mit demselben honeyBatchInclude wie bisher, die Webhook-Payload data bleibt also unverändert.

  • Schritt 1: Failing Tests

// honey-plausibility.spec.ts
it('warns when the extraction exceeds the known net harvest', ...);
it('hints when a net weight is missing and does not compare then', ...);
it('warns when the initial amounts of the batches exceed the extraction', ...);
// test/honey-chain.e2e-spec.ts
it('prefill sums the net weights of the chosen harvests and keeps a shared honey type', ...);
it('extraction and batch answers carry both plausibility warnings, never 4xx', ...);
it('an extraction of a harvest inside a withdrawal window warns but notifies no second time', ...);
it('a new honey batch delivers batch.created through the handler', ...);
it('the batch.created payload has the same shape as before (honeyBatchInclude)', ...);
// after-commit-usage.spec.ts
it('webhooks.dispatch( is no longer called from honey-batches.service.ts', () =>
  expect(filesCalling('webhooks.dispatch(')).toEqual(['src/colonies/colonies.service.ts', 'src/queens/queens.service.ts', 'src/storage/storage.service.ts']));  // seit Task 10: colonies, honey-batches, queens, storage
  • Schritt 2: npx jest src/extractions src/honey-batches src/webhooks src/withdrawal src/common/after-commit-usage.spec.ts und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren wie oben.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; alle genannten e2e-Dateien, test/withdrawal-chain.e2e-spec.ts, test/authorization-coverage.e2e-spec.ts → PASS; kein Commit.

Task 29: Bewegungsbuch der Honigcharge

Files

  • Create: src/honey-batches/honey-batch-ledger.service.ts (+ Spec), src/honey-batches/dto/create-honey-batch-movement.dto.ts, src/honey-batches/honey-batch-amount-writes.spec.ts (Wächter), test/utils/honey-ledger.ts (aus Task 23 erweitern)
  • Modify: src/bottling/bottling.service.ts (+ Spec; takeFromHoneyBatch, giveBackToHoneyBatch, normalizeAmount entfallen), src/bottling/bottling.module.ts, src/honey-batches/honey-batches.service.ts (+ Spec), src/honey-batches/honey-batches.controller.ts, src/honey-batches/honey-batches.module.ts
  • Test: neu test/honey-ledger.e2e-spec.ts, test/bottling.e2e-spec.ts, test/data-integrity.e2e-spec.ts („Bottling quantities“)

Interfaces

  • Consumes: Task 23 (Spalten), Task 28 (Datei-Reihenfolge).
  • Produces:
export interface LedgerBooking { honeyBatchId: string; deltaKg: number; type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL'; reason?: WithdrawalReason | null;
  notes?: string | null; sourceBottlingBatchId?: string | null; actingUserId: string | null }
export interface LedgerResult { amountKg: number | null; movementId: string | null }
HoneyBatchLedgerService.bookIn(tx: Prisma.TransactionClient, booking: LedgerBooking): Promise<LedgerResult>;
HoneyBatchLedgerService.setInitialIn(tx: Prisma.TransactionClient, honeyBatchId: string, amountKg: number): Promise<void>; // null -> Wert, keine Buchung
HoneyBatchLedgerService.movements(honeyBatchId: string): Promise<HoneyBatchMovementView[]>;
export interface HoneyBatchMovementView { id: string; type: BatchMovementType; quantityGrams: number; reason: WithdrawalReason | null; notes: string | null;
  sourceBottlingBatchId: string | null; createdAt: Date; userId: string | null }
// GET /honey-batches/:id/movements (honey-batches:read); POST /honey-batches/:id/movements (honey-batches:update, @Idempotent)
// CreateHoneyBatchMovementDto { type: 'WITHDRAWAL' | 'CORRECTION'; deltaKg: number (≠ 0, |x| ≤ 100000); reason?: WithdrawalReason (Pflicht bei WITHDRAWAL); notes?: string (≤ 500) }
  • bookIn (Spec 7.7): lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId), Charge danach lesen; initialAmount null → keine Buchung, { amountKg: null }; negatives Delta nur bei Rest ≥ |Delta| − 0,0005 kg, sonst 400 „Abfüllmenge übersteigt die verfügbare Menge der Honigcharge“ (bei CORRECTION/WITHDRAWAL „Die Menge kann nicht unter 0 sinken.“); BatchMovement mit quantity = round(deltaKg × 1000); amount = max(0, round((initialAmount + Σ quantity / 1000) × 1000) / 1000).

  • Abfüllung ändern/entfernen: zuerst lockRowForUpdate(tx, 'BottlingBatch', id), Abfüllung danach lesen, dann bookIn (sperrt die Charge; bei Chargenwechsel beide Chargen über lockRowsForUpdate(tx, 'HoneyBatch', ids)). PATCH /honey-batches/:id liest den aktuellen Rest erst unter der Chargensperre.

  • Vorgänge (Tabelle Spec 7.7): Charge mit Menge anlegen → initialAmount = amount; Abfüllung anlegen BOTTLING −, ändern BOTTLING Differenz (Wechsel der Charge: zurück auf alte, ab von neuer), entfernen BOTTLING + mit Notiz „Abfüllung entfernt“; Status BOTTLED wie bisher; PATCH amount → CORRECTION (neu − aktuell); amount: null bei vorhandenen Buchungen → 400 „Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.“; null → Wert → setInitialIn. create und update in honey-batches.service.ts reichen amount nie per { ...dto } an Prisma (const { amount, ...rest } = dto; Anlegen setzt amount und initialAmount gemeinsam über setInitialIn); der Wächter-Regex erfasst die Spread-Form nicht, deshalb die Unit-Tests oben.

  • Schritt 1: Failing Tests

// honey-batch-ledger.service.spec.ts
it('books in grams and rounds the amount to the gram', ...);
it('refuses a withdrawal larger than the rest with the German message', ...);
it('a correction books the difference new minus current', ...);
it('books nothing for a batch without initialAmount', ...);
// honey-batch-amount-writes.spec.ts (Wächter)
it('honey batch amount is written only by the ledger', () => expect(writersOf(/honeyBatch\.(update|updateMany|upsert)\([^)]*amount/s, /UPDATE "HoneyBatch"[^;]*"amount"/)).toEqual(['src/honey-batches/honey-batch-ledger.service.ts']));
// honey-batches.service.spec.ts (der Wächter-Regex sieht `data: { ...dto }` nicht)
it('update never passes amount to Prisma, even when the dto carries it', ...);
it('create writes amount and initialAmount together', ...);
// test/honey-ledger.e2e-spec.ts
it('keeps the invariant after create, bottle, correct and remove', ...);    // INVARIANT_SQL -> []
it('two parallel bottlings over the rest: exactly one 400', ...);
it('PATCH amount null with movements answers 400', ...);
it('GET movements lists BOTTLING with its source bottling', ...);
  • Schritt 2: npx jest src/honey-batches src/bottling und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren wie oben.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/honey-ledger.e2e-spec.ts, test/bottling.e2e-spec.ts, test/data-integrity.e2e-spec.ts, test/honey-batches.e2e-spec.ts, test/shop-sync.e2e-spec.ts → PASS; kein Commit.

Task 30: Qualität vor der Abfüllung

Files

  • Create: src/bottling/water-content.ts (+ Spec), src/bottling/bottling-prefill.service.ts (+ Spec), src/bottling/dto/bottling-prefill.query.ts
  • Modify: src/bottling/bottling.service.ts (+ Spec), src/bottling/bottling.controller.ts, src/bottling/dto/create-bottling-batch.dto.ts, src/honey-batches/honey-batches.service.ts (Warnung bei Honigmessung), src/products/dto/create-product.dto.ts, src/common/dates.ts (+ Spec; addMonthsClamped), src/common/validation/update-dto-null-handling.spec.ts
  • Test: neu test/bottling-quality.e2e-spec.ts, test/products.e2e-spec.ts
  • Docs: neue Annahme „Wassergehalt“ (18 % Warnung nach D.I.B., über 20 % Bestätigung nach HonigV Anlage 2, Heidehonig nur per Config, jüngste Messung maßgeblich, MHD aus shelfLifeMonths ohne Standardwert).

Interfaces

  • Consumes: Task 1 (WaterContentConfig), Task 12 (Warnungen), Task 29.
  • Produces:
export function latestWaterContent(batch: Pick<HoneyBatch, 'waterContent'>, measurements: readonly Pick<HoneyMeasurement, 'waterContent' | 'measuredAt'>[]): number | null;
export function waterContentWarnings(value: number | null, honeyType: string | null, config: WaterContentConfig, refs: { type: string; id: string }[]): PlausibilityWarning[];
// > confirm -> WATER_CONTENT_ABOVE_LEGAL (BESTAETIGEN); > warn -> WATER_CONTENT_HIGH; null -> WATER_CONTENT_MISSING; byHoneyType ohne Groß-/Kleinschreibung exakt
export function addMonthsClamped(day: string, months: number): string;   // 2027-01-31 + 1 -> 2027-02-28
export interface BottlingPrefill { jarSize: number | null; jarSizeSource: 'PRODUCT' | null; bestBeforeDate: string | null; shelfLifeMonths: number | null;
  waterContent: number | null; warnings: PlausibilityWarning[] }
BottlingPrefillService.prefill(query: { honeyBatchId: string; productId?: string; date?: string }): Promise<BottlingPrefill>; // GET /bottling/prefill (bottling:create)
// CreateBottlingBatchDto: jarSize?, bestBeforeDate? optional wenn productId gesetzt; acknowledgedWarnings?: 'WATER_CONTENT_ABOVE_LEGAL'[] (@ArrayMaxSize(1))
// CreateProductDto.shelfLifeMonths?: number (@IsOptional @IsInt @Min(1) @Max(60)); UpdateProductDto: leerbar
  • POST /bottling: Glasgröße = gesendet, sonst product.packagingSize; MHD = gesendet, sonst date + shelfLifeMonths; ohne beides 400 „Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.“ bzw. ohne Haltbarkeit 400 „Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.“; abweichendes Glas → JAR_SIZE_DIFFERS; Wassergehalt über der Bestätigungsgrenze ohne Bestätigung → 409 { code: 'CONFIRMATION_REQUIRED', warnings } (der Idempotenz-Interceptor gibt den Schlüssel frei, die bestätigte Wiederholung darf ihn wieder nutzen). PATCH /bottling liefert nur Warnungen. Honigmessung anlegen → Antwort mit Warnungen, nie 409.

  • Schritt 1: Failing Tests

it('best before 2027-01-31 plus 1 month is 2027-02-28', () => expect(addMonthsClamped('2027-01-31', 1)).toBe('2027-02-28'));
it('the newest measurement wins over the batch value', ...);
it('a configured heather honey confirms only above 23 %', ...);
// test/bottling-quality.e2e-spec.ts
it('19 % -> 201 with WATER_CONTENT_HIGH', ...);
it('20.5 % -> 409 CONFIRMATION_REQUIRED; confirmed retry with the same key -> 201', ...);
it('best before comes from shelfLifeMonths and a different jar size gives a hint', ...);
it('prefill returns jar size and best before from the product', ...);
  • Schritt 2: npx jest src/bottling src/common/dates.spec.ts src/products src/common/validation und die e2e-Dateien → FAIL.
  • Schritt 3: Implementieren wie oben.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/bottling-quality.e2e-spec.ts, test/bottling.e2e-spec.ts, test/products.e2e-spec.ts, test/honey-batches.e2e-spec.ts, test/authorization-coverage.e2e-spec.ts → PASS; kein Commit.

Task 31: Frontend – Pflanzenentwicklung

Files (alle in ApiCore-Frontend/)

  • Create: src/lib/phenology/types.ts, api.ts, labels.ts (+ Spec), src/components/phenology/phenology-report-sheet.tsx (+ Spec)
  • Modify: src/lib/offline/db.ts (+ Spec; DB_VERSION = 4, Store pending-phenology-reports), src/lib/offline/sync.ts (+ Spec), src/components/autopilot/season-chip.tsx, src/app/(app)/apiaries/[id]/page.tsx

Interfaces

  • Consumes: Task 26, Task 18 (SeasonChip).
  • Produces:
export const PHASE_REPORT_LABELS: Record<PhenologyPhase, string>;   // 'Salweide blüht', 'Kirsche blüht', 'Apfel blüht', 'Holunder blüht', 'Linde blüht', 'Tracht vorbei', 'Efeu blüht', 'Winterruhe'
export const PHASE_SOURCE_LABELS: Record<PhaseSource, string>;      // 'gemeldet', 'geschätzt', 'Kalender'
export function createPhenologyReport(input: CreatePhenologyReportInput, options: CreateOptions): Promise<PhenologyReport>;
export function revokePhenologyReport(id: string, options: CreateOptions): Promise<PhenologyReport>;
export function fetchPhenologyState(): Promise<PhenologyState[]>; export function fetchPhenologyReports(year: number): Promise<PhenologyReport[]>;
export function queuePhenologyReport(input: CreatePhenologyReportInput, idempotencyKey: string, ownerUserId: string): Promise<void>;
  • Stand-Seite: Kopf mit SeasonChip und Quelle („Schwarmzeit · alle 7 Tage · gemeldet am 18.04.“), Knopf „Pflanze blüht“ (nur mit phenology:create) öffnet PhenologyReportSheet: Zeilen min-h-14 je Phase mit Name, „für alle Stände“ vorgewählt (umschaltbar auf diesen Stand), ein Tipp meldet für heute über useCaptureMutation (offline in die Warteschlange); darunter die Meldungen des Jahres mit „Zurückziehen“ (phenology:update).

  • Schritt 1: Failing Tests

it('one tap reports the phase for today for all apiaries', ...);
it('rows are 56 px and „für alle Stände“ is preselected', ...);
it('offline the report is queued and synced later', ...);
it('db upgrade 3 -> 4 keeps queued inspections', ...);
it('shows the season with its source on the apiary page', ...);
  • Schritt 2: npx vitest run src/lib/phenology src/components/phenology src/lib/offline "src/app/(app)/apiaries" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src; kein Commit.

Task 32: Frontend – Honig-Kette und Bewegungsbuch

Files (alle in ApiCore-Frontend/)

  • Create: src/lib/honey-batches/movements.ts, src/app/(app)/honey-batches/[id]/honey-batch-movements-card.tsx (+ Spec), src/app/(app)/honey-batches/[id]/correct-amount-dialog.tsx (+ Spec)
  • Modify: src/components/form/previous-value-hint.tsx (+ Spec; Prop prefix), src/lib/extractions/api.ts + types.ts (Vorbelegung, warnings), src/lib/honey-batches/api.ts + types.ts, src/app/(app)/extractions/new/page.tsx (+ Spec), src/app/(app)/honey-batches/create-honey-batch-dialog.tsx (+ neue Spec), src/app/(app)/honey-batches/[id]/page.tsx

Interfaces

  • Consumes: Task 28, 29, Task 21 (WarningList, toastWarnings).
  • Produces:
// PreviousValueHintProps += prefix?: string   // Standard 'Letztes Mal'; z. B. 'Aus 2 Ernten' -> „Aus 2 Ernten: 42,5 kg · übernehmen“
export function fetchExtractionPrefill(harvestIds: string[]): Promise<ExtractionPrefill>;  // Key ["extractions", "prefill", ids]
export function fetchHoneyBatchPrefill(extractionId: string): Promise<HoneyBatchPrefill>;
export function fetchHoneyBatchMovements(id: string): Promise<HoneyBatchMovement[]>;
export function bookHoneyBatchMovement(id: string, input: { type: 'WITHDRAWAL' | 'CORRECTION'; deltaKg: number; reason?: WithdrawalReason; notes?: string }, options: CreateOptions): Promise<HoneyBatchMovement>;
  • Mengen erscheinen nur als Hinweis mit „übernehmen“ (nie still im Feld); Warnliste über „Speichern“; Karte „Bewegungen“ nach dem Muster components/stock/stock-card; „Menge korrigieren“ bucht CORRECTION, „Entnahme“ bucht WITHDRAWAL mit Grund.

  • Schritt 1: Failing Tests

it('the extraction form offers „Aus 2 Ernten: 42,5 kg · übernehmen“ and fills only on tap', ...);
it('shows the plausibility warnings above „Speichern“', ...);
it('the movements card lists bottlings and corrections; „Menge korrigieren“ books the difference', ...);
it('PreviousValueHint keeps „Letztes Mal“ as default prefix', ...);
  • Schritt 2: npx vitest run src/components/form "src/app/(app)/extractions" "src/app/(app)/honey-batches" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src; kein Commit.

Task 33: Frontend – Abfüllung mit Artikel-Werten und Bestätigung, Haltbarkeit am Artikel

Files (alle in ApiCore-Frontend/)

  • Modify: src/lib/bottling/api.ts + types.ts (fetchBottlingPrefill, acknowledgedWarnings), src/app/(app)/bottling/new/page.tsx + bottling-form-values.ts (+ Spec), src/lib/products/types.ts, Artikel-Formulare unter src/app/(app)/products/ (+ Spec)

Interfaces

  • Consumes: Task 30, Task 21.

  • Produces: fetchBottlingPrefill(query): Promise<BottlingPrefill>; Formularwerte acknowledgedWarnings: 'WATER_CONTENT_ABOVE_LEGAL'[].

  • Glasgröße und MHD direkt aus dem Artikel mit Herkunft „aus Artikel: 24 Monate“ (Stammdaten, keine Beobachtung), weiter änderbar; bei 409 CONFIRMATION_REQUIRED ConfirmDialog „Wassergehalt über 20 %. Trotzdem abfüllen?“ (Fokusfalle, Fokus zurück), Bestätigen sendet erneut mit gleichem Idempotency-Key und acknowledgedWarnings; Artikel-Formular: Feld „Haltbarkeit (Monate)“, leerbar. Spec 4.7: packagingSize gilt als Gramm wie BottlingBatch.jarSize; das Feld heißt im Artikel-Formular „Gewicht pro Glas (g)“ und prüft ganze Gramm (1–10000). Trug es bisher eine andere Beschriftung oder Einheit, wird sie hier korrigiert; vorhandene Werte werden nicht umgerechnet.

  • Schritt 1: Failing Tests

it('fills jar size and best before from the product and names the source', ...);
it('a 409 opens the confirmation and the confirmed retry sends acknowledgedWarnings with the same key', ...);
it('the product form saves and clears shelfLifeMonths', ...);
it('the product form labels packagingSize as grams and rejects 0 and values above 10000', ...);
  • Schritt 2: npx vitest run "src/app/(app)/bottling" "src/app/(app)/products" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npx vitest run src; kein Commit.

Lieferung T4c – Telegram

Task 34: Schema T4c, Bot-Client und Startprüfung

Files

  • Modify: prisma/schema.prisma
  • Create: prisma/migrations/20261015110000_t4_telegram/migration.sql
  • Create: src/telegram/telegram-config.ts (+ Spec), src/telegram/telegram-api-error.ts, src/telegram/telegram-client.ts (+ Spec), src/telegram/telegram-api-error.spec.ts, src/telegram/telegram.module.ts, src/common/no-network-outside-loops.spec.ts (Wächter)
  • Modify: src/common/outbox/outbox-claims.ts (+ Spec), src/common/worker-wakeup.ts, src/runtime/startup-checks.ts (+ Spec), src/domain-events/handler-contract.spec.ts, src/backup/backup.service.ts (+ Spec), src/prisma/row-lock.ts (+ Spec; NotificationDelivery am Ende der Map, Schlüssel "id")
  • Docs: §8: TelegramLink, TelegramLinkToken, NotificationDelivery, TelegramPollState ausgenommen (Begründungen Spec 4.13); §9 „Ausgehende Aufrufe“: Telegram nur aus dem Worker, Klartext, Token nie in Logs.

Interfaces

  • Consumes: Task 3.
  • Produces:
// enums NotificationChannel, NotificationDeliveryStatus; models TelegramLink, TelegramLinkToken, NotificationDelivery, TelegramPollState exakt wie Spec 4.15;
// CHECK ((notificationId IS NOT NULL AND userId IS NOT NULL AND templateCode IS NULL AND chatId IS NULL) OR (notificationId IS NULL AND templateCode IS NOT NULL AND chatId IS NOT NULL));
// INSERT INTO "TelegramPollState" (id) VALUES ('singleton')
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery' | 'NotificationDelivery';   // successStatus DELIVERED, settledAt deliveredAt
export function deferClaim(db: DbClient, table: 'NotificationDelivery', id: string, claimedAt: Date, delayMs: number): Promise<boolean>; // PENDING, availableAt = now() + delay, attempts unverändert
export function settleSkipped(db: DbClient, table: 'NotificationDelivery', id: string, claimedAt: Date, reason: string): Promise<boolean>; // SKIPPED, lastError = reason
// WAKE_CHANNELS += notificationDeliveries: 'notification_delivery'
export const TELEGRAM_TOKEN_PATTERN = /^\d{5,}:[A-Za-z0-9_-]{30,}$/;
export interface TelegramConfig { token: string | null; botUsername: string | null }
export function telegramConfig(env?: NodeJS.ProcessEnv): TelegramConfig;
export function telegramBotAvailable(env?: NodeJS.ProcessEnv): boolean;   // API und Worker: TELEGRAM_BOT_USERNAME nicht leer (getrimmt); liest nie das Token (der API-Pod hat es nicht)
export function assertTelegramToken(role: ApicoreRole, env?: NodeJS.ProcessEnv): void; // worker/all: Format; Produktion: kein 'change-me…'
export class TelegramApiError extends Error { constructor(readonly method: string, readonly statusCode: number | null, readonly description: string, readonly retryAfterSec: number | null) }
export interface TelegramUpdate { updateId: bigint; chatId: bigint; chatType: string; text: string | null; firstName: string | null }
export const TELEGRAM_SEND_TIMEOUT_MS = 10_000; export const TELEGRAM_POLL_TIMEOUT_SEC = 25; export const TELEGRAM_POLL_FETCH_TIMEOUT_MS = 35_000;
@Injectable() class TelegramClient {
  isConfigured(): boolean;   // nur Worker: Token vorhanden und gültig geformt
  sendMessage(chatId: bigint, text: string, signal?: AbortSignal): Promise<void>;     // ohne parse_mode, link_preview_options.is_disabled
  getUpdates(offset: bigint, signal?: AbortSignal): Promise<TelegramUpdate[]>;          // allowed_updates ["message"]
}
  • URL https://api.telegram.org/bot<token>/<method> nur intern; jeder Fehler wird TelegramApiError ohne URL; Antwortkörper nur auf ok, description, parameters.retry_after gelesen; Größe begrenzt (256 kB).

  • Wächter no-network-outside-loops.spec.ts: Wetter-Hosts nur in src/weather/, api.telegram.org nur in src/telegram/; fetch( nur in src/weather/, src/telegram/ und den Bestandsdateien src/calendar-sync/graph-client.service.ts, src/common/password/password-policy.ts; nie in *.handlers.ts oder src/jobs/. handler-contract.spec.ts verbietet zusätzlich Importe von telegram-client.

  • Schritt 1: Failing Tests

it('the token never appears in TelegramApiError or loggableError', async () => {
  fetchMock.mockRejectedValue(new TypeError(`connect failed https://api.telegram.org/bot${TOKEN}/sendMessage`));
  const error = await client.sendMessage(1n, 'x').catch((e) => e);
  expect(JSON.stringify({ ...error, message: error.message, log: loggableError(error) })).not.toContain(TOKEN);
});
it('sends plain text without parse_mode and with link preview disabled', ...);
it('telegramBotAvailable is true with the username set and the token unset (API pod) and false with an empty username', ...);
it('maps 429 with retry_after into TelegramApiError.retryAfterSec', ...);
it('the worker refuses a malformed or placeholder token in production', ...);   // startup-checks.spec
it('settleSkipped and deferClaim are compare-and-set on id and claimedAt', ...);  // outbox-claims.spec
it('network calls stay inside their loop directories', ...);                     // Wächter
// backup.service.spec.ts: die vier Modelle sind ausgenommen
  • Schritt 2: npx jest src/telegram src/common src/runtime src/domain-events src/backup → FAIL.
  • Schritt 3: Implementieren wie oben.
  • Schritt 4: → PASS; Drift-Check → 0.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/backup.e2e-spec.ts, test/platform-schema.e2e-spec.ts → PASS; kein Commit.

Task 35: Telegram-Zustellung anlegen und senden

Files

  • Create: src/notifications/delivery-time.ts (+ Spec), src/notifications/delivery-planner.ts (+ Spec), src/telegram/telegram-texts.ts (+ Spec), src/telegram/telegram-sender.ts (+ Spec), test/telegram.e2e-spec.ts, test/utils/telegram.ts (Bot-API-Attrappe)
  • Modify: src/notifications/notifications.service.ts (+ Spec; Planer nach jedem Einfügen), src/notifications/notifications.module.ts, src/auth/permissions/permission-resolver.service.ts (+ Spec; getFreshPermissions), src/jobs/cleanup.job.ts (+ Spec), src/telegram/telegram.module.ts, src/worker/worker-runtime.service.ts (+ Spec; Schleife telegram-send), src/worker/worker-runtime.module.ts, src/worker/worker.module.ts
  • Docs: neue Annahme „Erinnerungen und Telegram“ (Teil Zustellung: Outbox, Ruhezeit, Kategorien, Prüfung beim Senden, Grenzen, Fehlerbilder); ARCHITECTURE §10: Schleife telegram-send.

Interfaces

  • Consumes: Task 3, 16 (Kategorien), Task 34.
  • Produces:
export interface DeliveryPreferences { digestTime: string; quietFrom: string | null; quietUntil: string | null }
export function deliveryAvailableAt(now: Date, category: NotificationCategory, prefs: DeliveryPreferences): Date; // max(now, Kategorie-Zeit, Ende Ruhezeit)
export function isInQuietHours(now: Date, prefs: DeliveryPreferences): boolean;                                 // über Mitternacht
NotificationDeliveryPlanner.planIn(db: DbClient, notifications: readonly Notification[]): Promise<number>;      // TELEGRAM-Zeilen + notifyWorker('notification_delivery')
export type TelegramTemplateCode = 'LINKED' | 'LINK_INVALID' | 'STOPPED' | 'HELP';
export const TELEGRAM_TEMPLATES: Record<TelegramTemplateCode, string>;                                           // Texte aus Global Constraints
export function renderTelegramText(n: Pick<Notification, 'message' | 'link'>, detail: boolean, frontendOrigin: string): string; // ≤ 600 Zeichen
export const TELEGRAM_SEND_POLL_MS = 5_000; export const TELEGRAM_SEND_BATCH = 25; export const TELEGRAM_MAX_PER_SECOND = 25;
export const TELEGRAM_CHAT_SPACING_MS = 1_100; export const TELEGRAM_TOKEN_INVALID_PAUSE_MS = 900_000;
PermissionResolverService.getFreshPermissions(userId: string): Promise<ReadonlySet<string>>;   // liest immer frisch (wie isTokenValid, ohne den 30-s-Cache) und füllt den Cache; leer bei inaktivem Nutzer oder Rolle
@Injectable() class TelegramSender { runOnce(signal: AbortSignal): Promise<number>; drain(signal: AbortSignal, maxRounds?: number): Promise<number>; releaseStaleClaims(): Promise<number> }
  • Planen (Spec 9a.4) in derselben Transaktion wie die Meldung: nur bei telegramEnabled, bestehendem TelegramLink und Kategorie in telegramCategories; now einmal per SELECT now().

  • Senden (Spec 9a.6): claimBatch(…, 'NotificationDelivery', { limit: 25, orderBy: 'createdAt' }); je Nutzer bzw. Chat nur die älteste, die übrigen deferClaim(…, 1100); vor jedem Senden in einer SQL-Abfrage prüfen: Nutzer aktiv, telegramEnabled, Verbindung, Kategorie, Veraltung (TASK_DUE/DIGEST gleicher Berliner Tag über to_char(… AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD'), sonst now() - "createdAt" <= interval '24 hours'), bei TASK_DUE Aufgabe noch offen; Rechte über PermissionResolverService.getFreshPermissions (nicht getPermissions: der Worker bekommt kein invalidateUser der API-Pods, der 30-s-Cache ließe ein entzogenes Recht bis zu 30 s weiter senden) ⊇ requiredPermissions; sonst settleSkipped mit user inactive | telegram disabled | not linked | category off | permission revoked | stale | task already done. Systemantworten (templateCode) ohne diese Prüfungen.

  • Antworten: 200 → settleSuccess(…, 'DELIVERED'); 429 → deferClaim(retry_after × 1000); 403 oder 400 „chat not found“ → Verbindung nur löschen, wenn sie noch auf diesen Chat zeigt (telegramLink.deleteMany({ where: { userId, chatId } }); ein neuerer Link desselben Nutzers auf einen anderen Chat bleibt unberührt); nur bei count 1 telegramEnabled = false (unter lockRowForUpdate(tx, 'NotificationPreference', userId) wie Task 36) und die Meldung an den Nutzer, bei count 0 nichts davon; settleSkipped('chat unavailable'), Meldung SYSTEM an den Nutzer „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“; 401 → deferClaim(900000), Schleife pausiert 15 min, recordForPermission('system:manage', { category: 'SYSTEM', priority: 'WARNUNG', message: 'Telegram: Bot-Token ungültig.', link: '/settings/system', dedupeKeyPrefix: 'telegram-token-invalid:<Tag>' }); 5xx/Timeout/Netz → settleFailure (loggableError).

  • Schritt 1: Failing Tests

// delivery-time.spec.ts
it('a delivery created at 22:00 inside quiet hours 21:00–06:30 waits until 06:30 the next day', ...);
it('TASK_DUE waits until one minute after the digest time, DIGEST until the digest time', ...);
it('without quiet hours a SUGGESTION is available now', ...);
// telegram-sender.spec.ts
it('checks permissions and the link at send time and skips with a reason', ...);
it('sends plain text of at most 600 characters with the app link', ...);
it('429 defers by retry_after without counting an attempt', ...);
it('403 removes the link, switches Telegram off and notifies the user in the app', ...);
it('a 403 for an old chat does not remove a newer link of the same user', ...);
it('the permission check reads fresh, not the 30 s cache', ...);
it('401 keeps the delivery pending, pauses 15 min and notifies system:manage once a day', ...);
it('a stale DIGEST from yesterday is SKIPPED', ...);
it('keeps 1.1 s between two messages to the same chat', ...);
// cleanup.job.spec.ts
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows expired for 30 days', ...);
// test/telegram.e2e-spec.ts (Bot API gemockt)
it('a due task yields one delivery after the morning time and none inside quiet hours', ...);
it('two send loops in parallel deliver each message exactly once', ...);
it('a revoked permission skips the delivery at send time', ...);
  • Schritt 2: npx jest src/notifications src/telegram src/worker und test/telegram.e2e-spec.ts → FAIL.
  • Schritt 3: Implementieren wie oben; Schleifenzeile telegram-send (5 000 ms, notification_delivery) nur, wenn TelegramClient.isConfigured(). TelegramSender injiziert PermissionResolverService; TelegramModule importiert dafür kein PermissionsModule (Global Constraints, Rechte-Auflösung): der Worker bekommt den Resolver aus dem globalen JobPermissionsModule (Task 4), die API aus PermissionsModule, und der Katalogabgleich läuft nicht im Worker. cleanup.job.ts räumt wie bei WebhookDelivery auf: DELETE FROM "NotificationDelivery" für DELIVERED/SKIPPED älter als WEBHOOK_DELIVERY_RETENTION_DAYS und FAILED älter als FAILED_RETENTION_DAYS; DELETE FROM "TelegramLinkToken" mit "expiresAt" < now() - interval '30 days'.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/telegram.e2e-spec.ts, test/notifications.e2e-spec.ts, test/reminders.e2e-spec.ts, test/jobs.e2e-spec.ts, test/worker-runtime.e2e-spec.ts → PASS; kein Commit.

Task 36: Telegram verbinden, trennen und empfangen

Files

  • Create: src/telegram/telegram-link.service.ts (+ Spec), src/telegram/telegram-updates.service.ts (+ Spec), src/profile/telegram.controller.ts, src/profile/dto/telegram-link-token.dto.ts
  • Modify: src/profile/profile.module.ts, src/notifications/notification-settings.controller.ts + notification-preferences.service.ts (Telegram-Status in der Antwort), src/telegram/telegram.module.ts, src/worker/worker-runtime.service.ts (+ Spec; Schleife telegram-updates)
  • Test: test/telegram.e2e-spec.ts (ergänzen)
  • Docs: Annahme „Erinnerungen und Telegram“ ergänzen (Verbinden mit Step-up, Einmal-Token als Hash, Long Polling mit Lease, kein öffentlicher Eingang, Datenschutz-Hinweis, je Umgebung ein Bot); ARCHITECTURE §10: Schleife telegram-updates.

Interfaces

  • Consumes: Task 3, 34, 35.
  • Produces:
export const LINK_TOKEN_TTL_MS = 600_000; export const LINK_TOKEN_BYTES = 32;
export const HELP_REPLIES_PER_CHAT = 3; export const HELP_WINDOW_MS = 600_000; export const LEASE_SECONDS = 60; export const NO_LEASE_WAIT_MS = 30_000;
TelegramLinkService.createLinkToken(userId: string): Promise<{ deepLink: string; expiresAt: Date }>;   // https://t.me/<bot>?start=<token>; 409 'Telegram ist nicht eingerichtet.' genau dann, wenn `telegramBotAvailable()` falsch ist (Benutzername leer); nie am Token prüfen, der API-Pod hat es nicht
TelegramLinkService.unlink(userId: string): Promise<void>;                                            // Verbindung löschen, telegramEnabled false, STOPPED an den Chat
TelegramLinkService.linkFromStartIn(tx: Prisma.TransactionClient, token: string, chatId: bigint, firstName: string | null): Promise<'LINKED' | 'LINK_INVALID'>;
TelegramLinkService.stopIn(tx: Prisma.TransactionClient, chatId: bigint): Promise<void>;
TelegramUpdatesService.pollOnce(signal: AbortSignal): Promise<'disabled' | 'no-lease' | 'polled' | 'conflict' | 'error'>;
// NotificationSettingsView += telegram (configured = telegramBotAvailable(), nie das Token): { configured: boolean; botUsername: string | null; linked: boolean; firstName: string | null; linkedAt: string | null; enabled: boolean }
// POST /profile/telegram/link-token  @AnyAuthenticatedUser @Throttle(SENSITIVE_ACTION_THROTTLE), Body { password: string; mfaCode?: string } -> ReauthService.verify; NICHT @Idempotent
// POST /profile/telegram/unlink      @AnyAuthenticatedUser
  • Verbindung ändern (linkFromStartIn, stopIn, unlink, 403-Pfad in Task 35 unverändert): erst getOrCreate der Präferenz, dann lockRowsForUpdate(tx, 'NotificationPreference', userIds) für alle betroffenen Nutzer (eine Anweisung, Schlüssel aufsteigend), TelegramLink und Präferenz erst danach lesen und ändern.

  • Token: randomBytes(32).toString('base64url') (43 Zeichen), gespeichert sha256 hex, expiresAt = now() + 10 min per SQL, ältere offene Token des Nutzers consumedAt = now().

  • Empfang (Spec 9a.5, 9a.7): Lease-SQL wörtlich; je Update eine Transaktion mit Wirkung und updateOffset = update_id + 1 als Compare-and-Set: UPDATE "TelegramPollState" SET "updateOffset" = $neu WHERE id = 'singleton' AND "leaseOwner" = $ich AND "leaseUntil" > now() AND "updateOffset" = $alt; count 0 (Lease verloren oder anderer Stand) bricht die Transaktion samt Wirkung ab und beendet den Zyklus mit 'no-lease'; nur chat.type = 'private'; /start <token> → CAS UPDATE "TelegramLinkToken" SET "consumedAt" = now() WHERE "tokenHash" = $1 AND "consumedAt" IS NULL AND "expiresAt" > now() RETURNING "userId" (Nutzer aktiv), TelegramLink anlegen/ersetzen, war der Chat einem anderen Nutzer zugeordnet: dessen Verbindung lösen, telegramEnabled = false, Meldung SYSTEM „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“; telegramEnabled = true; Meldung SYSTEM „Telegram verbunden mit „{Vorname}“ am {TT.MM.JJJJ}. Warst du das nicht? In den Einstellungen trennen.“ (Link /profile); Antwort LINKED. Ungültig → LINK_INVALID. /start ohne Token, /hilfe, /help, anderer Text → HELP. /stop → stopIn, STOPPED. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher). Vorname getrimmt, ohne Steuerzeichen, ≤ 64 Zeichen.

  • Schleife telegram-updates: Intervall 30 000 ms, kein Weckkanal; nach 'polled' sofort wake(); Stop-Signal bricht den laufenden Abruf ab; 409 → lastError, 30 s warten; Störungen machen den Worker nicht ungesund.

  • Schritt 1: Failing Tests

// telegram-link.service.spec.ts
it('creates a 43-character base64url token, stores only its SHA-256 and invalidates older open tokens', ...);
it('an expired or used token answers LINK_INVALID without naming an account', ...);
it('a chat that moves to another user unlinks the previous user with an in-app notice', ...);
it('a new link shows an in-app notice with the Telegram first name', ...);
it('createLinkToken answers 409 only when the bot username is empty, not when the token is unset', ...);
it('NotificationSettingsView.telegram.configured is true with the username set and no token in the API process', ...);
// telegram-updates.service.spec.ts
it('handles /start, /stop and /hilfe and ignores groups', ...);
it('answers at most 3 times per chat in 10 minutes', ...);
it('moves the offset in the same transaction as the effect', ...);
it('the offset advance is a compare-and-set on leaseOwner and the old offset; a lost lease rolls the effect back', ...);
it('takes the lease with the database clock only', ...);   // keine Date-Parameter im SQL
// test/telegram.e2e-spec.ts
it('link token -> /start links the chat once; a second /start with the same token answers LINK_INVALID', ...);
it('link-token requires step-up and is throttled', ...);
it('only the lease holder polls; a second worker waits', ...);
it('unlink sends STOPPED and switches Telegram off', ...);
  • Schritt 2: npx jest src/telegram src/profile src/notifications src/worker und test/telegram.e2e-spec.ts → FAIL.
  • Schritt 3: Implementieren wie oben.
  • Schritt 4: → PASS, inklusive test/authorization-coverage.e2e-spec.ts.
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; kein Commit.

Task 37: Telegram-Betrieb – System-API und Manifeste

Files

  • Create: src/system/notification-deliveries.service.ts (+ Spec), src/system/dto/notification-deliveries.query.ts
  • Modify: src/system/system.controller.ts, src/system/system.module.ts, .env.example, deploy/k8s/components/backend-roles/worker-deployment.yaml, deploy/k8s/base/backend/configmap.example.yaml, deploy/k8s/overlays/encry/configmap-backend.yaml, deploy/k8s/tests/manifest-policy.test.mjs (Manifeste liegen unter ApiCore/deploy/, nicht unter ApiCore-Backend/)
  • Test: test/system.e2e-spec.ts (ergänzen; nicht telegram.e2e-spec.ts, das Task 36 in derselben Welle ändert)
  • Docs: ARCHITECTURE §7: Secret apicore-telegram (optional: true) nur im Worker, TELEGRAM_BOT_USERNAME in der ConfigMap, BotFather ohne Gruppen und ohne Webhook, je Umgebung ein Bot; §10 „Verwaltung > System“: Telegram-Karte.

Interfaces

  • Consumes: Task 34, 35; Rückgrat Task 24. (Task 36 liegt in derselben Welle: die Karte liest nur TelegramPollState und NotificationDelivery aus Task 34/35 und telegramBotAvailable() aus Task 34.)
  • Produces:
export interface TelegramStatusView {   // configured = telegramBotAvailable() (TELEGRAM_BOT_USERNAME nicht leer; der API-Pod kennt das Token nicht), ob der Empfang läuft, zeigt receiver
  configured: boolean; botUsername: string | null;
  receiver: { leaseOwner: string | null; lastPollAgeSeconds: number | null; lastError: string | null };
  sender: { pending: number; oldestPendingAgeSeconds: number | null; failed24h: number; lastErrors: { statusCode: number | null; error: string; at: Date }[] };
}
export interface NotificationDeliveryView { id: string; status: NotificationDeliveryStatus; category: NotificationCategory | null; templateCode: string | null;
  attempts: number; availableAt: Date; lastStatusCode: number | null; lastError: string | null; createdAt: Date; deliveredAt: Date | null } // nie chatId, nie Text
NotificationDeliveriesService.overview(status?: NotificationDeliveryStatus): Promise<{ telegram: TelegramStatusView; deliveries: NotificationDeliveryView[] }>; // Alter per SQL
NotificationDeliveriesService.retry(id: string, actingUserId: string): Promise<void>;          // lockRowForUpdate(tx, 'NotificationDelivery', id), dann lesen; 404, 409 nicht FAILED; PENDING, attempts 0, Audit, notifyWorker
NotificationDeliveriesService.retryFailed(actingUserId: string): Promise<{ retried: number }>;
// GET /system/notification-deliveries?status= (system:manage); POST /system/notification-deliveries/:id/retry (204), POST /system/notification-deliveries/retry-failed (system:manage)
  • Manifeste: Worker-Container env TELEGRAM_BOT_TOKEN aus secretKeyRef { name: apicore-telegram, key: TELEGRAM_BOT_TOKEN, optional: true }; api bekommt das Secret nicht; ConfigMap TELEGRAM_BOT_USERNAME: "" (Platzhalter, Runbook) und die Wetter-Variablen aus Task 24 bleiben.

  • Schritt 1: Failing Tests

it('shows the oldest pending age from the database clock and never a chat id or token', ...);
it('configured follows the bot username, not the token, and receiver.lastPollAgeSeconds is null while the worker never polled', ...);
it('retry moves FAILED back to PENDING, writes an audit entry and wakes the worker', ...);
// test/system.e2e-spec.ts
it('GET /system/notification-deliveries shows FAILED entries and retry moves them back to PENDING', ...);
it('staff without system:manage gets 403', ...);
// manifest-policy.test.mjs
test('only the worker references the apicore-telegram secret, and only optionally', ...);
  • Schritt 2: npx jest src/system und die e2e-Dateien → FAIL; node --test deploy/k8s/tests/manifest-policy.test.mjs (mit gerendertem Overlay) → FAIL.
  • Schritt 3: Implementieren wie oben.
  • Schritt 4: → PASS; kubectl kustomize deploy/k8s/overlays/encry | kubeconform -strict -summary - → 0 Fehler (fehlt kubectl lokal, übernimmt der Orchestrator bzw. die CI).
  • Schritt 5: Verifikation – typecheck, lint, npm test -- --ci; test/authorization-coverage.e2e-spec.ts → PASS; kein Apply gegen den Cluster, kein Commit.

Task 38: Frontend – Telegram verbinden und Systemkarte

Files (alle in ApiCore-Frontend/)

  • Create: src/components/auth/step-up-dialog.tsx (+ Spec), src/app/(app)/profile/telegram-link-card.tsx (+ Spec), src/app/(app)/settings/system/telegram-card.tsx (+ Spec), src/lib/telegram/api.ts, src/lib/telegram/types.ts
  • Modify: src/app/(app)/settings/backup/page.tsx (+ Spec; nutzt StepUpDialog statt eigener Felder), src/app/(app)/profile/page.tsx, src/app/(app)/profile/notification-settings-card.tsx (Kategorien, „Mit Details“), src/lib/notification-settings/types.ts, src/app/(app)/settings/system/page.tsx, src/lib/system/api.ts + types.ts, src/lib/system/labels.ts (+ labels.spec.ts; DOMAIN_EVENT_LABELS um die neuen Ereignisse ergänzen: ColonySplit „Ableger angelegt“, QueenAssigned „Königin zugewiesen“, ExtractionRecorded/ExtractionCorrected „Schleuderung erfasst/korrigiert“, HoneyBatchRecorded/HoneyBatchCorrected „Honigcharge angelegt/korrigiert“, PhenologyReported/PhenologyCorrected „Pflanzenmeldung erfasst/korrigiert“, SeasonPhaseChanged „Saisonabschnitt gewechselt“; die Seite fällt sonst auf den Typnamen zurück)

Interfaces

  • Consumes: Task 36, 37, Task 21.
  • Produces:
export interface StepUpProof { password: string; mfaCode?: string }
export interface StepUpDialogProps { open: boolean; title: string; confirmLabel: string; needsMfa: boolean; onConfirm: (proof: StepUpProof) => Promise<void>; onOpenChange: (open: boolean) => void }
export function createTelegramLinkToken(proof: StepUpProof): Promise<{ deepLink: string; expiresAt: string }>;
export function unlinkTelegram(): Promise<void>;
export function fetchNotificationDeliveries(status?: NotificationDeliveryStatus): Promise<{ telegram: TelegramStatusView; deliveries: NotificationDeliveryView[] }>;
export function retryNotificationDelivery(id: string): Promise<void>; export function retryFailedNotificationDeliveries(): Promise<{ retried: number }>;
  • TelegramLinkCard (Spec 9a.8): Status „Verbunden mit „{Vorname}“ seit {Datum}“ oder „Nicht verbunden“; Hauptknopf „Telegram verbinden“ → StepUpDialog → großer Knopf „In Telegram öffnen“ (target="_blank" rel="noopener noreferrer") und „Gültig bis {HH:MM}“; Status wird bis zum Ablauf alle 5 s neu geladen; „Trennen“ mit ConfirmDialog; Kategorien als ToggleChip, Schalter „Mit Details“ mit Datenschutz-Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; ohne Bot „Telegram ist nicht eingerichtet.“ ohne Knopf.

  • Systemkarte „Telegram“ (Spec 9a.9): Bot eingerichtet/Name, Empfang (Lease, Alter des letzten Abrufs, letzter Fehler), Versand (offen, ältestes mit Warnung ab 10 Minuten, FAILED 24 h, letzte Fehler), „Erneut versuchen“ einzeln und für alle nur mit system:manage.

  • Schritt 1: Failing Tests

it('asks for password (and TOTP with MFA) before creating the link', ...);
it('shows „In Telegram öffnen“ with the expiry time and later „Verbunden mit „Nick““', ...);
it('„Trennen“ asks and unlinks', ...);
it('without a bot shows „Telegram ist nicht eingerichtet.“ and no button', ...);
it('the backup import still asks for the password through StepUpDialog', ...);
it('the system card warns when the oldest delivery waits longer than 10 minutes and retries with system:manage only', ...);
it('DOMAIN_EVENT_LABELS names ColonySplit, QueenAssigned, the extraction, honey batch and phenology events and SeasonPhaseChanged', ...);   // labels.spec.ts
  • Schritt 2: npx vitest run src/components/auth src/lib/system "src/app/(app)/profile" "src/app/(app)/settings" → FAIL.
  • Schritt 3: Implementieren.
  • Schritt 4: → PASS.
  • Schritt 5: Verifikation – npm run lint && npm run typecheck && npm test && npm run build; kein Commit.

Abschluss T4b + T4c (Orchestrator)

  • Backend: npm run lint && npm run typecheck && npm test && npm run build; Test-DB migrieren, dann npm run test:e2e komplett.
  • Frontend: npm run lint && npm run typecheck && npm test && npm run build.
  • Drift-Check → 0; Manifeste: kubectl kustomize deploy/k8s/overlays/encry | kubeconform -strict -summary - und node --test deploy/k8s/tests/manifest-policy.test.mjs.
  • Wächter grün: season-enums.spec.ts, honey-batch-amount-writes.spec.ts, no-network-outside-loops.spec.ts, handler-contract.spec.ts, Handler-IDs Worker = App, Backup-Modell-Wächter, update-dto-null-handling.spec.ts, Text-Wächter.
  • Docs aus den Berichten in ARCHITECTURE.md (§7, §8, §9, §10, neue Annahmen mit nächster freier Nummer) und TASKS.md (T4b, T4c) eintragen und auf Widersprüche lesen.
  • Rollout nach Spec 15 (jeder Schritt mit Freigabe des Inhabers): Backup und pg_dump vorher; nach Migration 6 die Invarianten-Abfrage (INVARIANT_SQL) → 0 Zeilen; WEATHER_HISTORY_SOURCE=open-meteo in der ConfigMap; Telegram-Schritte 9.1–9.4 (BotFather, Secret durch den Inhaber, Deploy, Test-Erinnerung).
  • Ein zentraler Commit, Nachricht endet mit Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>.

Abdeckung der Spec

Spec-Punkt Tasks
1 Entscheidungen: Modell Suggestion, ein Eingang, Texte beim Lesen 1, 4, 5, 17
1 Entscheidungen: Regelwerk im Code, Werte in AutomationRule.config, Parser 1, 22
1 Entscheidungen: Saison aus Meldung, GTS, Kalender; Worker-Schleife Wetter; Open-Meteo hinter Adapter 13, 23, 24, 25, 26
1 Entscheidungen: Volksstärke occupiedFrames 3, 8, 20
1 Entscheidungen: Erinnerungen je Aufgabe und Morgenübersicht, in der App und per Telegram 3, 16, 21, 34–38
1 Entscheidungen: queenMaxAgeYears = 2 überall 1, 13, 27
1 Entscheidungen: Durchsicht → Volkskarte im selben Request 8, 20
1 Entscheidungen: Bewegungsbuch, Warnungen statt Sperren, Bestätigung Wassergehalt 12, 28, 29, 30, 33
1 Entscheidungen: Fan-out je Nutzer statt NotificationRead 3
1 Entscheidungen: Nachzügler queens, extractions, honey-batches auf Ereignisse 10, 28
2.2 Befunde: Rundmeldungen, Königin bleibt zugeordnet, zwei Altersschwellen, Annahme-Nummern 3, 9, 13, Global Constraints (Docs)
2.4 Änderungen an T3-Bausteinen 6, 15, 18, 19, 20
4.1 Suggestion, Task.sourceSuggestionId 1
4.2 PhenologyReport, ApiaryPhenology, ApiaryWeatherDay 23
4.3 SplitQueenSource, SplitColonyDto.date/queenSource 2, 10
4.4 Futter-Vokabular, FEED_CONDITION_LABELS 2, 20
4.5 sampleBees, Prozent 2, 6, 19
4.6 Bewegungsbuch-Spalten und CHECK 23, 29
4.7 Product.shelfLifeMonths 23, 30, 33
4.8 Benachrichtigungen je Nutzer (userId, dedupeKey, category, link, requiredPermissions, recordForUsers) 3, 35
4.9 Kalender WITHDRAWAL, syncWithdrawal, endDate 1, 3, 12, 21
4.10 Rechte phenology 23, 26
4.11 neue Trigger, DEFAULT_RULES, Parser, notify, PATCH 400 1, 7, 22
4.12 Migrationen 1–11 und Prüfungen 1, 2, 3, 23, 34; Abschlüsse
4.13 Backup und Import alter Bundles 1, 2, 3, 23, 34
4.14 Volksstärke 3, 8, 20
4.15 Benachrichtigungskanäle 3, 34
5.1 neue Ereignisse 10, 23
5.2 Handler-Tabelle 7 (Regeln), 9, 10, 11, 12, 14, 16, 26, 27, 28
5.3 Regeln für Handler (Zeilensperre, skipDuplicates, beendetes Volk) 4, 9, 11, 14
5.4 Erzeuger-Umstellungen, releaseQueenIn, Wächter after-commit-usage 8, 9, 10, 28
6.1 Job-Schritte und Zähler 13, 14, 16, 25
6.2 zeitbasierte Ereignisse 12, 13
6.3 Schleife phenology-weather, Adapter, Konfiguration 24
7.1 Varroa-Ampel 6, 7, 19
7.2 Fälligkeit der Durchsicht 13, 14, 15, 18
7.3 Pflanzenentwicklung, Saisonabschnitte, Kalibrierung 13, 25, 26, 31
7.4 Biotechnik-Ketten 10, 11
7.5 Wartezeit-Kette 6 (Fenster-Helfer), 12, 28
7.6 Vorbelegung und Plausibilität 28, 32
7.7 Bewegungsbuch 29, 32
7.8 Qualität vor der Abfüllung 30, 33
7.9 Durchsicht → Volkskarte 8, 20
7.10 Volk beendet, Aufräumen 9, 18
8.1–8.3 Autopilot-Eingaben, Regeln A1–A10, Abgleich 14, 27
8.4–8.5 Rangfolge, Zusammenlegen, „die nächsten 3 Schritte“ 15, 18
8.6 Was der Autopilot nicht tut 14 (nur Vorschläge, keine Fachdaten)
9.1–9.3 Lebenszyklus, Dubletten, Texte 4, 5
9.4 Meldungen zu Vorschlägen 4, 7 (Meldung in SuggestionsService, gilt für Ketten)
9.5 bestehende Automatik-Aufgaben, „Aus Vorschlag“ 7, 17
9a.1–9a.4 Erinnerungen, Zustellung anlegen 16, 35
9a.5, 9a.7 Verbinden, Trennen, Long Polling 36
9a.6 Versand 35
9a.8 Einstellungen 3, 21, 36, 38
9a.9 Verwaltung > System 37, 38
9a.10 Konfiguration und Geheimnisse 34, 37
10.1 neue Endpunkte 3, 5, 6, 9, 15, 26, 28, 29, 30, 36, 37
10.2 geänderte Endpunkte 2, 3, 6, 8, 10, 12, 13, 15, 28, 29, 30
10.3 Warnungsformat 12
11 Oberfläche (Seiten, Liste, Bausteine, Barrierefreiheit) 17–22, 31–33, 38
11.2 Ableger-Dialog (Datum, Herkunft der Königin) 20 (mit Backend aus Task 10)
12 Sicherheit Global Constraints; 3, 5, 15, 16, 34–37
13 Fehlerfälle über die Tests der jeweiligen Tasks (Review Focus)
14.1–14.4 Tests und Wächter in den Tasks benannt; 14.3 Textvorlagen je vorschlagendem Trigger: 27; no-broadcast-notifications samt Wiederherstellungspfad: 3
15 Rollout Abschlüsse T4a sowie T4b + T4c

Self-Review

Spec-Lücken und Widersprüche, hier aufgelöst (bitte beim Review bestätigen)

  • Migrationsreihenfolge gegen Lieferungen: 4.12 ordnet 1–11 global, die Lieferungen verteilen sie. Gelöst: Reihenfolge 4.12 innerhalb jeder Lieferung, feste Zeitstempel in Erzeugungsreihenfolge; Migration 7 (sampleBees T4a, shelfLifeMonths T4b) und 11 (Kategorie/Einstellungen T4a, Telegram T4c) geteilt; bindende Abhängigkeiten (Enum-Werte zuerst, 8 vor 11) bleiben.
  • T4b ∥ T4c in einem Arbeitsbaum: geteiltes schema.prisma und die Datenmigration 6 erlauben kein getrenntes Ausrollen. Gelöst: parallel bauen, gemeinsam ausrollen; soll T4c früher live, läuft die Lane 34–38 komplett vor Task 23 und die Zeitstempel-Blöcke tauschen (T4c 20261015100000, T4b ab 20261015110000).
  • Saison ohne GTS in T4a, Prisma-Enums erst in Migration 4 (T4b): T4a nutzt TS-Konstanten (season-types.ts), Task 23 legt gleichnamige Prisma-Enums an, ein Wächter hält sie gleich.
  • Wartezeit-Fenster: 7.5 nennt (endDate ?? date) als Beginn; der Bestand und Annahme 10 zählen ab Behandlungsbeginn (eine Ernte während der Behandlung ist ein Konflikt). Gelöst: Beginn = Behandlungsbeginn wie in getWithdrawalStatus, Ende exklusiv (endDate ?? date) + Tage.
  • „Bis 14 Tage nach Behandlung“ (7.1) gegen „Nachkontrolle ab 16.08.“ (A3): gleich gemacht – keine Ampel bis vor Ende + 14, Nachkontrolle ab Ende + 14.
  • Zusammenlegen INSPECT (DO) mit SWARM_CHECK (PLAN) (8.4), aber der Abgleich fasst Kettenvorschläge nicht an (8.3): Gelöst: zusammengelegt wird nur mit mindestens einem DO-Schritt, Aktion CAPTURE; chains.queen-cells setzt offene SWARM_CHECK/QUEEN_ACCEPTANCE-Kettenvorschläge bei einer Durchsicht am oder nach ihrer Fälligkeit auf RESOLVED.
  • Wächter „fetch( nur in src/weather/ und src/telegram/“ widerspricht dem Bestand (Graph-Client, HIBP-Prüfung). Gelöst: diese zwei Dateien stehen auf der Erlaubtliste; Handler und Jobs bleiben ohne Netz.
  • after-commit-usage: „Liste verliert queens“ – queen.created bleibt Altpfad ohne Ereignis. Gelöst: queens.service.ts bleibt in der webhooks.dispatch-Liste, die calendarSync.enqueue-Liste wird leer.
  • ColonyEnded kennt die freigegebene Königin nicht: Payload um endedOn, releasedQueenId, via erweitert (Spec 5.1 erlaubt Erweiterungen).
  • requiredPermission ist ein einzelnes Recht: Gründe nennen nur Daten aus diesem Bereich (z. B. A4 ohne Erntedatum, Text „Noch keine Behandlung seit …“).
  • Projektregel Zeilensperre (nachgereicht 2026-10-14): Jedes Read-modify-write liest before nach lockRowForUpdate in der Transaktion; eingearbeitet in Tasks 1, 3, 4, 6, 8, 9, 10, 14, 25, 26, 28, 29, 36, 37; neue Tabellen nur über Task 1 (T4a), 23 (T4b), 34 (T4c) in die Map; feste Sperr-Reihenfolge in Global Constraints. Die Spec nennt an zwei Stellen rohes SELECT … FOR UPDATE (5.3, 7.7) – beide laufen jetzt über den Helfer.
  • Nicht spezifiziert, hier festgelegt: Empfänger der Konflikt-Meldung (harvests:read + treatments:read); „erntefähig“ zusätzlich nur mit treatments:read (Inhalt nennt das Mittel); Anker für Lagerregeln (jüngste Zugangsbuchung bzw. jüngste Charge); TREATMENT_FOLLOW_UP/FEEDING_FOLLOW_UP werden FOLLOW_UP-Vorschläge mit unveränderter Config; 401 von Telegram pausiert 15 min; /start ohne Token → HELP; link-token nicht @Idempotent() (Geheimnis in der Antwort, Annahme 11); Validierungsgrenzen der Configs (Global Constraints); calibratePhases als Config-Schlüssel für „auf Wunsch SALWEIDE“.

Nachgereicht nach dem Plan-Review (2026-10-14)

  • Fenster-Helfer früh: src/treatments/withdrawal-window.ts (+ treatment-windows.ts) wandert von Task 12 (W5) in Task 6 (W3). Task 15 (W5) lag sonst in derselben Welle wie sein Lieferant, und Task 7 (W4) braucht dadurch keinen VarroaBandsService-Export.
  • DailyMean steht in season-types.ts (Task 1), nicht in Task 24; Task 25 (gleiche Welle) importiert von dort. Task 37 führt Task 36 nicht mehr unter Consumes (gleiche Welle).
  • Telegram „eingerichtet“ auf der API-Seite = TELEGRAM_BOT_USERNAME nicht leer (telegramBotAvailable()), nie das Token (das gibt es nur im Worker).
  • no-broadcast-notifications erlaubt den Wiederherstellungspfad (backup.service.ts), prüft aber, dass jede Schreibstelle userId trägt; die Wiederherstellung überspringt Zeilen unbekannter Nutzer, statt userId = null zu schreiben.
  • Migration 11a belegt Altzeilen mit relatedType 'JobRun' oder ohne relatedType als SYSTEM, den Rest als SUGGESTION; dieselbe Regel im Backup-Import.
  • Aufräumen erledigt Aufgaben über die öffentliche TasksService.completeIn (Audit + TaskCompleted), nicht über das private markDoneIn.
  • dedupeKey bleibt nur bei OPEN, ACCEPTED, DISMISSED und OBSOLETE; EXPIRED und RESOLVED setzen ihn auf NULL, damit Erinnerungen nach 30 Tagen wiederkommen.
  • Sperren und Meldungen: proposeIn sperrt erst Colony, dann die Regelzeile; die Meldung zu Vorschlägen sitzt in SuggestionsService (Task 7) und gilt für alle Quellen; Telegram prüft Rechte beim Senden frisch (getFreshPermissions); der Offset-Fortschritt ist ein Compare-and-Set mit leaseOwner; der 403-Pfad löscht nur den Link desselben Chats.
  • Task 15/17: Vorschlags-Schritte brauchen tasks:read. Task 5: assignedUserId wird validiert und auf „existiert und aktiv“ geprüft.
  • Übertrag aus Rückgrat Task 18: QueenAged einmal je Königin und Jahr am ersten Jobtag mit gealterter Königin (Jahresanker im dedupeKey); Winter-Fälligkeiten kommen über „Erste Durchsicht im Jahr“ zu Frühjahrsbeginn (A1), keine eigene Märzmeldung.
  • Ableger-Dialog (Spec 11.2) in Task 20; Aufräum-Job löscht alte TelegramLinkToken und NotificationDelivery (Task 35); Migrations-Harness nutzt .tmp-migrations/ statt OS-Temp (Task 2).

Konsistenzdurchgang nach den Textersetzungen (2026-10-14)

  • Resolver in Worker und Jobs: kein Feature-Modul importiert PermissionsModule; Task 1 exportiert JobPermissionsModule, Task 4 importiert es in WorkerModule (schon vor Task 5, dessen SuggestionViewsService den Resolver braucht). Tasks 8, 12, 16 und 35 entsprechend bereinigt.
  • Sperr-Reihenfolge: die Handler der Ketten (Task 11) und der Aufräum-Handler (Task 9) sperren zuerst die Volkszeile; Autopilot-Kandidaten werden in der Reihenfolge A1…A10 vorgeschlagen. Neue Tabellen gehen in die vorhandene Map (zwölf Bestandseinträge, Product ist schon darin); lockRowForUpdate liefert boolean, alle drei Helfer nutzen die Schlüsselspalte.
  • Tagesauswertung: Task 13 baut auf der vorhandenen Regeltabelle DAILY_EVALUATION_RULES auf (Schleife wandert in TimeEventsStep, evaluate(Date) entfällt), statt sie neu zu erfinden.
  • Bestand korrigiert: Wetterdienst liest URL und Schlüssel künftig aus der gemeinsamen Konfiguration (Task 24; vorher feste Konstante), deploy/ liegt im Repo-Wurzelverzeichnis, Systemseite bekommt Ereignis-Labels (Task 38), A3 nutzt recheckAfterDays statt fester 14.

Geprüft

  • Jeder Spec-Abschnitt hat einen Task (Tabelle oben). Kein Task enthält „TBD“ oder unbestimmte Schritte; Funktionsrümpfe stehen nur dort, wo Signatur und Test sie nicht festlegen (SQL-Kerne, Formeln).
  • Typen und Namen über die Tasks gleich: ProposeInput, SuggestionEvidence, PlanStepConfig, SeasonState, readRule/lockRuleIn, PlausibilityWarning, WithdrawalWindow (fromDay/untilDay), NextStep/ColonyNextSteps, AutopilotToday, CreateNotificationInput/FanOutNotificationInput, recordForPermission(permission | permissions[], …), DailyEvaluationStep, JobPartialFailure, deferClaim/settleSkipped, TelegramApiError. Aufgaben-Bezüge stimmen mit den Interfaces-Blöcken (Consumes) ü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: Tasks 1/2/3/23/34 (schema.prisma, Backup), 1/23/34 (row-lock.ts), 4/14/12/16/25/35 (app.module.ts, worker.module.ts), 13/14/16/25 (jobs.module.ts, daily-evaluation.job.ts), 1/2/3/7 (automation.service.ts), 10/8/9 (colonies.service.ts), 10/9/23 (domain-event-types.ts), 3/8/26/30 (update-dto-null-handling.spec.ts), 3/8/15 (apiary-field.service.ts), 13/3 (job-run.service.ts), 4/5/11/9 (suggestions.module.ts), 5/9 (suggestions.controller.ts), 1/3/12 (calendar-sync.service.ts), 10/12 (calendar-sync.handlers.ts), 12/29/30 (bottling.service.ts), 28/29/30 (honey-batches.service.ts), 10/28 (webhook.handlers.ts), 10/12/28 (after-commit-usage.spec.ts), 12/28 (withdrawal.handlers.ts), 14/15/27 (src/autopilot/*), 13/25 (season.service.ts), 3/35 (notifications.service.ts), 1/30 (dates.ts), 24/35/36 (worker-runtime.service.ts), 24/37 (.env.example, deploy/k8s), Frontend 20/18 (Volksseite), 17/18 (suggestion-card.tsx), 21/38 (Profilseite), 18/31 (Stand-Seite).
  • Proportion: Code steht nur als Signaturen, Testnamen mit Kern-Assertions und gepinnte SQL-Fragmente.