Files
ApiCore/docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 11:36:38 +02:00

1384 lines
114 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# T4 Logik-Ketten und Saison-Autopilot — Design
Stand: 2026-10-13 · Betrifft: ApiCore (Backend, Frontend) · Teilprojekt T4 aus `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md` §10
## 1. Ziel
Was der Imker erfasst, wirkt weiter. ApiCore zieht daraus die Folgen selbst:
- Aus einer Durchsicht, einer Messung oder einem Ableger entstehen datierte Folgeschritte.
- Jedes Volk hat „die nächsten 3 Schritte“, jeweils mit einer kurzen Begründung.
- Die Saison kommt aus der Pflanzenentwicklung am Stand, nicht aus einem starren Kalender.
- Automatik plant nie heimlich. Sie schlägt vor. Der Imker übernimmt oder verwirft mit einem Tipp.
Technisch heißt das:
- Alle Folgen hängen an Handlern des Ereignis-Rückgrats (`@OnDomainEvent`, `events.record(tx, …)`) oder am Job `daily-evaluation`. Kein T4-Code ruft Folgen direkt nach dem Commit auf.
- Ein Regelwerk im Code, ohne ML. Jeder fachliche Wert ist je Regel einstellbar und nennt seine Quelle.
- Ein Muster für alle Vorschläge: ein Modell, ein Service, eine Liste.
**Voraussetzungen:**
- Rückgrat-Plan `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md`: Tasks 3, 4, 9, 10, 11–15, 17 und 18 sind fertig. T4 baut genau auf `TasksService.createInTx`, `AutomationService.…In(tx, …)`, `JobRunService`, `dailyDedupeKey` und dem Handler-Vertrag auf.
- T3-Plan `docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md`: Tasks 2, 3, 9, 11 und 14 sind fertig. T4 ändert deren Ampeln und ergänzt das Durchsichtsformular. T4 baut nichts davon neu.
### Inhaber-Entscheidungen (2026-10-13), bindend
- Nur eigener deutscher Honig. Keine Wanderung. Keine Stockwaage. Alles andere aus §10 T4 ist gewollt.
- Automatische Aufgaben bleiben Vorschläge mit „Übernehmen“ und „Verwerfen“ (Annahme 12).
- Wartezeit-Prüfungen warnen, sperren nie (Annahme 10).
- Beobachtungen werden nie still vorbelegt (T3-Regel).
- Sicherheit hat Priorität 1. Ein Muster, gemeinsame Helfer. Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20).
- Deutsche Oberfläche, kurze Sätze, ADHS-freundlich: ein Hauptknopf je Ansicht, wenig kognitive Last. WCAG 2.2 AA.
### Antworten des Inhabers auf die Spec-Fragen (2026-10-13), bindend
1. **Volksstärke** an der Volkskarte = besetzte Waben, 0–40, dieselbe Einheit wie in der Durchsicht. Alte Werte (Skala 1–10) werden nicht umgerechnet. Sie bleiben mit ihrer Einheit lesbar (4.14).
2. **Wetter für die GTS:** freie Open-Meteo-API. Der Inhaber weiß, dass sie laut Anbieter nur für nicht-kommerzielle Nutzung frei ist, und trägt dieses Risiko. Die Quelle steckt hinter einem kleinen Adapter, damit ein Wechsel später eine Einstellung ist (6.3).
3. **Erinnerungen:** beides, eine Meldung je fälliger Aufgabe **und** eine Morgenübersicht je Nutzer. Zugestellt in der App **und** über einen Telegram-Bot als neuen Kanal (Abschnitt 9a).
### Entscheidungen
| Frage | Entscheidung |
|---|---|
| Wie werden Vorschläge gespeichert? | **Eigenes Modell `Suggestion`**, kein neuer `TaskStatus`. Eine Aufgabe bleibt „was ich mir vorgenommen habe“. Kalender, Webhooks, Export, Suche und Offline-Paket sehen nur echte Aufgaben. Ein Vorschlag trägt Regel, Begründung, Belege, Anker und Entscheidung. Diese Felder gehören nicht an `Task`. |
| Ein oder zwei Eingänge? | **Ein Eingang „Vorschläge“** für Regel-, Ketten- und Autopilot-Vorschläge. Zwei Arten: `PLAN` („Übernehmen“ legt eine Aufgabe mit Termin an) und `DO` („Erfassen“ öffnet das Formular; der Vorschlag erledigt sich, sobald der Datensatz da ist). |
| Woher kommen Titel und Begründung? | Aus `reasonCode` plus `evidence` (nur IDs, Zahlen, Daten), **beim Lesen serverseitig gerendert**. So bleibt „vor 9 Tagen“ immer aktuell, und Texte lassen sich ohne Datenmigration verbessern. |
| Wie entsteht der Autopilot? | Regeltabelle im Code (Abschnitt 8), Werte in `AutomationRule.config`. Täglicher Abgleich aller Völker im Job und sofortiger Abgleich je Volk in einem Handler nach jedem fachlichen Ereignis. |
| Woher kommt die Saison? | Je Stand die weiteste von drei Quellen: eigene Meldung („Salweide blüht“), Schätzung aus der Grünlandtemperatursumme (GTS), Kalender-Rückfall. Feste GTS-Schwelle nur dort, wo der DWD sie definiert (200 °C). Spätere Schwellen lernt die App aus den eigenen Meldungen der Vorjahre, statt sie zu erfinden. |
| Wer holt die Wetterdaten? | **Eine Worker-Schleife.** Der Worker darf schon heute über 443 ins Internet. Jobs bleiben ohne Internet (NetworkPolicy unverändert). Koordinaten gehen auf 0,01° gerundet hinaus. |
| Welche Wetterquelle? | **Inhaber-Entscheidung: freie Open-Meteo-API**, Risiko der Nutzungsbedingungen (nur nicht-kommerziell) bewusst übernommen. Zugriff nur über den Adapter `WeatherHistorySource`; Wechsel zu bezahltem Open-Meteo per Einstellung, zu DWD/Bright Sky per neuem Adapter plus Einstellung (6.3). |
| Volksstärke | **Besetzte Waben, 0–40**, an Volkskarte und Durchsicht gleich. Neues Feld `Colony.occupiedFrames`. Der alte Wert `Colony.strength` (Skala 1–10) bleibt unverändert gespeichert und heißt in der Oberfläche „Volksstärke (alt, Skala 1–10)“. Keine Umrechnung (4.14). |
| Erinnerungen | **Je fällige Aufgabe eine Meldung und je Nutzer eine Morgenübersicht** (Inhaber-Entscheidung). Beides in der App und, wenn der Nutzer es einschaltet, über Telegram. |
| Telegram-Zustellung | **Outbox `NotificationDelivery`** wie `WebhookDelivery`. Handler und Jobs schreiben nur Zeilen. Eine Worker-Schleife sendet. Eine zweite empfängt `/start` und `/stop` per **Long Polling**, also ohne öffentlichen Eingang (Abschnitt 9a). |
| Alter einer Königin | **Eine Schwelle: `queenMaxAgeYears = 2`** für Dashboard (heute 3), `QueenAged` und Autopilot. Ab dem zweiten vollen Jahr nach dem Schlupf steht die Königin in ihrer dritten Saison. Wer dann warnt, hat in dieser Saison noch Zeit für die Nachzucht. Bei 3 käme der Hinweis erst in der vierten Saison. Der Wert ist eine Annahme nach Imkerpraxis und einstellbar. |
| Durchsicht → Volkskarte | **Im selben Request wie die Durchsicht**, über `ColoniesService.updateIn(tx, …)`. Atomar, offline-fähig, ein Hauptknopf „Speichern“. Kein zweiter Dialog nach dem Speichern. |
| Futterzustand | **Ein Vokabular:** `Inspection.feedStatus` wird `ColonyCondition`, wie an der Volkskarte. |
| Menge der Honigcharge | `HoneyBatch.amount` bleibt der aktuelle Rest, damit API und Shop stabil bleiben. Neu: `initialAmount` und Buchungen im Bewegungsbuch. Jede Änderung von `amount` läuft nur über `HoneyBatchLedgerService`. |
| Wartezeit und Plausibilität | Warnungen in der Antwort plus eine Meldung je Konflikt. Nie eine Sperre (Annahme 10). Einzige Ausnahme: Wassergehalt über der gesetzlichen Grenze verlangt beim Abfüllen eine Bestätigung (§10). |
| Benachrichtigungen je Nutzer | **Bewusste Abweichung von Rückgrat-Spec §4** (Checkliste „`NotificationRead` je Nutzer“): **je Nutzer eine eigene Zeile (Fan-out) mit Rechteprüfung beim Schreiben**, keine Tabelle `NotificationRead`. Grund: Heute sieht jeder angemeldete Nutzer jede Rundmeldung (`userId = null`), auch ohne Leserecht auf Völker oder Varroa. Eine Lese-Tabelle würde nur das gemeinsame `read`-Flag lösen, nicht dieses Leck. Fan-out löst beides in einem Schritt, braucht keinen Join beim Lesen und ist zugleich die Grundlage für die Zustellung je Nutzer (Telegram). |
| Rechte | Vorschläge nutzen die Aufgaben-Rechte (`tasks:read/create/update`). Neue Gruppe „Pflanzenentwicklung“ (`phenology:read/create/update`). Inhalte werden nach Leserechten gefiltert (Abschnitt 12). |
| Nachzügler des Rückgrats | `queens`, `extractions` und `honey-batches` stellen auf Ereignisse um, soweit T4 sie anfasst. Damit entfallen ihre Altaufrufe `webhooks.dispatch` und `calendarSync.enqueue`. |
## 2. Umfang
### 2.1 Im Umfang (§10 T4 → Lösung)
| Punkt aus §10 T4 | Lösung | Abschnitt |
|---|---|---|
| Durchsicht → Volkskarte, einzeln abwählbar, über `ColoniesService.update` | Abschnitt „Volkskarte“ im Durchsichtsformular, `applyToColony` im selben Request | 7.9 |
| Ein Vokabular für den Futterzustand | `Inspection.feedStatus` → `ColonyCondition`, Migration | 4.4 |
| Varroa-Ampel nach Jahreszeit (LWG), Jungvölker, nach Maßnahme 14 Tage | Bänder je Methode, Monat, Volkstyp; Regel `VARROA_THRESHOLD` erweitert | 7.1 |
| Fälligkeit der Durchsicht je Volk, Heute-Ansicht, §30 | Intervall je Saisonabschnitt, Karte „Durchsicht fällig“ mit „Runde starten“ | 7.2, 11 |
| Wartezeit-Kette: Spanne im Kalender, „Volk erntefähig“, Prüfung bei Ernte/Schleuderung/Abfüllung | `WithdrawalCheckService`, Warnungen, Meldungen, Kalenderquelle `WITHDRAWAL` | 7.5 |
| Ernte → Schleuderung → Charge vorbelegt, Plausibilität | Vorbelegungs-Endpunkte, Warnungen | 7.6 |
| `HoneyBatch.amount` nur über das Bewegungsbuch | `initialAmount`, `BatchMovement.honeyBatchId`, Ledger | 7.7 |
| Qualität vor der Abfüllung: Wassergehalt, MHD, Glasgröße | Regel `HONEY_WATER_CONTENT`, `Product.shelfLifeMonths` | 7.8 |
| Biotechnik-Fristen | Ketten-Vorschläge `QUEEN_CELLS_SWARM_CHECK`, `QUEEN_ACCEPTANCE_CHECK`, `SPLIT_SCHEDULE` | 7.4 |
| Volk beendet: Aufgaben anbieten, Königin-Zuordnung beenden (auch Auswinterung) | gemeinsamer Helfer `releaseQueenIn`, Aufräum-Vorschlag | 7.10 |
| Saison-Autopilot: nächste 3 Schritte mit Begründung, GTS oder Meldung, kein ML, täglich | Regeln A1–A10, Abgleich, Rangfolge | 8 |
| Vorschlagsliste mit „Übernehmen“/„Verwerfen“ | Modell `Suggestion`, Seite „Vorschläge“ | 9, 11.3 |
| §11 T4-Befunde: Automatik-Aufgaben fehlen im Kalender; Vorlagen nur beim Lesen; ein `read`-Flag; `amount` am Bewegungsbuch vorbei; kein Grenzwert Wassergehalt | Kalender und Vorlagen löst das Rückgrat (Tasks 9, 10, 15). T4 löst die übrigen drei. | 4.6, 4.8, 7.8 |
| Inhaber-Antwort 1: Volksstärke in besetzten Waben | `Colony.occupiedFrames`, alter Wert bleibt | 4.14 |
| Inhaber-Antwort 3: Erinnerungen je Aufgabe und Morgenübersicht, in der App und per Telegram | Handler `notifications.task-due`, Job-Schritt `digest`, Outbox `NotificationDelivery`, Telegram-Schleifen | 9a |
### 2.2 Befunde aus dem Code, die T4 behebt
| Befund | Lösung | Abschnitt |
|---|---|---|
| Rundmeldungen (`userId = null`) sieht jeder Nutzer, und alle teilen ein `read`-Flag | Fan-out je Nutzer mit Rechteprüfung; bewusste Abweichung von Rückgrat-Spec §4 | 4.8 |
| Stirbt ein Volk bei der Auswinterung (`markColonyDeceased`) oder wird es über „Bearbeiten“ auf `SOLD`/`DECEASED` gesetzt, bleibt die Königin zugeordnet. `dissolve` und `merge` beenden die Zuordnung schon. | ein Helfer `releaseQueenIn` für alle vier Wege, in derselben Transaktion | 5.4, 7.10 |
| Alter der Königin: Dashboard warnt ab 3 Jahren, `QueenAged` (Rückgrat) ab 2 | eine Schwelle `queenMaxAgeYears = 2` für alle | Entscheidungen, 6.2 |
| ARCHITECTURE hat die Annahmen 22 und 23 schon belegt, der T3-Plan plant 22–27 | Neue Annahmen nur unter der „nächsten freien Nummer“ zum Zeitpunkt des Baus | 15 |
### 2.3 Nicht im Umfang
- Wanderung, Stockwaage, Sensoren (Inhaber-Entscheidung; T7).
- Tierarzneimittel-Katalog, Etikett-Pflichtangaben, Behördenpaket (T6).
- Bienenjahr-Rad, Waben-Standkarte, Lebenslinie (T5).
- Mischungen, Importhonig, Backhonig-Logik (nur eigener deutscher Honig).
- Web-Push und E-Mail. Telegram ist der einzige zusätzliche Kanal.
- Telegram-Befehle außer `/start`, `/stop` und `/hilfe`. Kein Erfassen oder Abfragen über den Bot.
- Drohnenrahmen-Regeln, Ertragsprognose, Änderungen an der Trachtprognose.
- Bestand an Rohhonig vor der Charge (Ernte bis Schleuderung wird nicht gebucht).
### 2.4 Abgrenzung zu T3 und Rückgrat
| Baustein | Liefert | T4 ändert |
|---|---|---|
| T3 Task 2 `GET /varroa-measurements/threshold`, `varroaThresholdOf` | eine Schwelle (Milben/Tag) | ersetzt durch Bänder (`/thresholds`); `varroaThresholdOf` liest das neue Config-Format |
| T3 Task 11 `varroaLight(rate, threshold)` | gelb ab halber Schwelle | `varroaLight(value, band)` mit Bändern vom Server |
| T3 Task 14 `colonyStatusLight`, `INSPECTION_STALE_DAYS = 14` | feste 14 Tage | gelb ab Überschreiten von `inspectionDueOn` |
| T3 Task 3 `ColonyOverviewRow.openWarnings/openCritical` | aus Benachrichtigungen | aus offenen Vorschlägen (Priorität `HIGH`/`URGENT`) |
| T3 Task 9 Durchsichtsformular, `takeover.ts` | „Letztes Mal“, Freitext-Futter | Abschnitt „Volkskarte“; Freitext-Sonderfall entfällt nach Migration |
| T3 Task 3 `ColonyOverviewRow.strength`, `OfflinePackV1.colonies` | alte Skala 1–10 | zusätzlich `occupiedFrames`; `strength` bleibt als Altwert |
| T3 Task 1/9 `Inspection.colonyStrength` (besetzte Waben, `@Min(0)`) | ohne Obergrenze | `@Max(40)`, wie an der Volkskarte |
| Rückgrat Task 10 `triggerActionIn` | Aufgabe über `createInTx` | Vorschlag über `SuggestionsService.proposeIn` |
| Rückgrat Task 18 `INSPECTION_INTERVAL_DAYS = 21`, `ACTIVE_SEASON_MONTHS` | fester Takt | Intervall je Saisonabschnitt (7.2) |
## 3. Begriffe
- **Vorschlag** (`Suggestion`): eine Empfehlung zu genau einem Objekt (meist ein Volk). Art `PLAN` oder `DO`.
- **Schritt** (`NextStep`): ein Eintrag in „die nächsten 3 Schritte“. Quelle ist eine offene Aufgabe oder ein offener Vorschlag des Volks.
- **Anker**: die Sache, an der ein Vorschlag hängt, z. B. die ID der letzten Durchsicht oder das Jahr. Ein verworfener Vorschlag kommt für denselben Anker nicht wieder.
- **Phase** (`PhenologyPhase`): Stand der Pflanzenentwicklung, z. B. `KIRSCHE`.
- **Saisonabschnitt** (`SeasonSegment`): aus der Phase abgeleitet, z. B. `SCHWARMZEIT`. Er bestimmt Intervalle und Regeln.
- **Heute**: Kalendertag in `Europe/Berlin` (`berlinToday()`, neuer Helfer in `src/common/dates.ts`). Der Job läuft um 04:30 Berliner Zeit; der UTC-Tag von `dailyDedupeKey` ist dann derselbe Kalendertag.
## 4. Datenmodell
Alle Namen ladungstragend. Datumswerte wie bisher als `DateTime` (Mitternacht UTC), wie `Task.dueDate`.
### 4.1 Vorschläge
```prisma
enum SuggestionStatus { OPEN ACCEPTED DISMISSED RESOLVED EXPIRED OBSOLETE }
enum SuggestionMode { PLAN DO }
enum SuggestionSource { RULE CHAIN AUTOPILOT }
enum 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
}
model Suggestion {
id String @id @default(cuid())
ruleKey AutomationTriggerType
source SuggestionSource
mode SuggestionMode
intent SuggestionIntent
status SuggestionStatus @default(OPEN)
priority TaskPriority @default(NORMAL)
relatedType TaskRelatedType
relatedId String?
colonyId String?
colony Colony? @relation(fields: [colonyId], references: [id])
reasonCode String
evidence Json // nur IDs, Zahlen, YYYY-MM-DD
requiredPermission String? // z. B. 'varroa:read'; fehlt das Recht, ist der Vorschlag unsichtbar
dueDate DateTime?
expiresAt DateTime?
plan Json? // PLAN mit mehreren Terminen: [{ step, offsetDays, windowDays? }]
dedupeKey String? @unique
sourceEventId String?
decidedAt DateTime?
decidedByUserId String?
decidedByUser User? @relation("SuggestionDecider", fields: [decidedByUserId], references: [id])
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
@@index([status, colonyId])
@@index([status, dueDate])
@@index([ruleKey, relatedType, relatedId, status])
}
```
- `Task` bekommt `sourceSuggestionId String?` (Relation, Index). `sourceAutomationTrigger` bleibt und wird beim Übernehmen gesetzt.
- `dedupeKey` ist nullbar und `@unique`, wie `DomainEvent.dedupeKey`. Kein partieller Index (Rückgrat-Spec §3.5).
- Format: `<ruleKey>:<relatedId>:<anker>`, z. B. `INSPECTION_INTERVAL:col_1:insp_9`.
### 4.2 Pflanzenentwicklung
```prisma
enum PhenologyPhase { SALWEIDE KIRSCHE APFEL HOLUNDER LINDE TRACHTENDE HERBST WINTER }
enum SeasonSegment { WINTERRUHE FRUEHJAHR SCHWARMZEIT SOMMER SPAETSOMMER HERBST }
enum PhaseSource { REPORT GTS CALENDAR }
model PhenologyReport { // eigene Meldung per Antippen
id String @id @default(cuid())
apiaryId String? // null = alle Stände
apiary Apiary? @relation(fields: [apiaryId], references: [id])
phase PhenologyPhase
date DateTime
notes String?
userId String
user User @relation(fields: [userId], references: [id])
revokedAt DateTime? // zurückgezogen statt gelöscht
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([date])
@@index([apiaryId, phase])
}
model ApiaryPhenology { // abgeleiteter Zustand, schreibt nur der Job/Handler
apiaryId String
year Int
gts Float?
gtsThroughDay DateTime?
missingDays Int @default(0)
estimatedPhase PhenologyPhase?
reportedPhase PhenologyPhase?
effectivePhase PhenologyPhase?
phaseSource PhaseSource?
segment SeasonSegment
segmentSince DateTime
updatedAt DateTime @updatedAt
@@id([apiaryId, year])
}
model ApiaryWeatherDay { // Tagesmittel je Stand, schreibt nur die Worker-Schleife
apiaryId String
day DateTime // Kalendertag Europe/Berlin
meanTempC Float?
source String // 'forecast' | 'archive'
fetchedAt DateTime @default(now())
@@id([apiaryId, day])
}
```
### 4.3 Ableger
- `enum SplitQueenSource { RAISE_OWN QUEEN_CELL QUEEN }` (Nachschaffung, reife Weiselzelle, Königin zugesetzt).
- `ColonyLineage.queenSource SplitQueenSource?`. `null` heißt „unbekannt“. Dann gibt es keine Kette, nichts wird angenommen.
- `SplitColonyDto` bekommt `date?` (heute: fest `new Date()`) und `queenSource?`.
### 4.4 Futter-Vokabular
- `Inspection.feedStatus String?` → `ColonyCondition?`. Volkskarte und Durchsicht sprechen dieselbe Sprache.
- Abbildung (ein Helfer `feedConditionFromLegacy` in `src/common/feed-vocabulary.ts`, auch für den Backup-Import alter Bundles):
| alter Wert (getrimmt, ohne Groß-/Kleinschreibung) | neu |
|---|---|
| `ausreichend` | `GOOD` |
| `niedrig` | `WEAK` |
| `kritisch` | `CRITICAL` |
| leer | `null` |
| jeder andere Text | `null`, Text wandert an das Ende von `notes` als „Futter (alt): …“ |
- Labels für Futter überall gleich (`FEED_CONDITION_LABELS`): `EXCELLENT` „Reichlich“, `GOOD` „Ausreichend“, `AVERAGE` „Mittel“, `WEAK` „Niedrig“, `CRITICAL` „Kritisch“.
- Das Durchsichtsformular bietet wie bisher drei Werte plus „Nicht geprüft“. Ein gespeicherter Wert außerhalb dieser drei erscheint im Bearbeiten-Dialog trotzdem.
### 4.5 Varroa-Probe
- `VarroaMeasurement.sampleBees Int?` (Probengröße in Bienen).
- Bei `POWDERED_SUGAR` und `WASHING_METHOD` wird `calculatedValue` = `miteCount / sampleBees × 100` (Befall in %), sonst wie bisher. Die Regel für manuelle Werte (Annahme 12) gilt unverändert.
- Das Formular bietet den Chip „500 Bienen (≈ 50 g)“, vorgewählt ist nichts (wie T3 bei den Tagen).
### 4.6 Bewegungsbuch der Honigcharge
- `HoneyBatch.initialAmount Float?` (kg, Ausgangsmenge).
- `BatchMovement.honeyBatchId String?` (FK, `onDelete: Restrict`) und `BatchMovement.sourceBottlingBatchId String?` (FK, `onDelete: SetNull`, Rückverweis auf die verursachende Abfüllung).
- `BatchMovementType` + `BOTTLING`.
- `quantity` ist bei Honig in Gramm (Int), mit Vorzeichen.
- CHECK-Constraint neu: `num_nonnulls("bottlingBatchId","productionBatchId","rawMaterialLotId","honeyBatchId") = 1`.
- Invariante: `amount = initialAmount + Σ quantity / 1000`, gerundet auf Gramm. Ist `initialAmount` `null`, wird nicht gebucht (Menge nicht erfasst, wie heute).
### 4.7 Produkt
- `Product.shelfLifeMonths Int?` (Haltbarkeit in Monaten, kein Standardwert). `packagingSize` gilt als Gramm wie `BottlingBatch.jarSize`; das prüft der Bau am Produktformular.
### 4.8 Benachrichtigungen
Bewusste Abweichung von Rückgrat-Spec §4: Fan-out statt `NotificationRead` (Begründung in den Entscheidungen).
- `Notification.userId` wird Pflicht. Rundmeldungen (`userId = null`) gibt es nicht mehr.
- `Notification.dedupeKey String? @unique`. Format `<art>:<objekt…>:<userId>`, bei täglichen Meldungen mit Tag, z. B. `withdrawal-ended:<treatmentId>:<userId>` oder `job-failed:<job>:<YYYY-MM-DD>:<userId>`.
- `Notification.category NotificationCategory` (neu): `TASK_DUE`, `DIGEST`, `SUGGESTION`, `WITHDRAWAL`, `SEASON`, `SYSTEM`. Altzeilen bekommen `SUGGESTION` (Automatik) bzw. `SYSTEM` (Job-Fehler).
- `Notification.link String?`: relativer App-Pfad (beginnt mit `/`, kein `//`, kein Schema), z. B. `/colonies/<id>`. Wird beim Schreiben geprüft.
- `Notification.requiredPermissions String[] @default([])`: alle Leserechte, die der Inhalt voraussetzt (die Morgenübersicht nutzt mehrere). Der Telegram-Versand prüft sie beim Senden erneut (9a.6).
- `NotificationsService.record` verlangt `userId`. Neu `recordForUsers(userIds, input, tx)`; `recordForPermission` bleibt der Normalweg und vergibt den Schlüssel je Empfänger. Beide legen in derselben Transaktion die Telegram-Zustellungen an (9a.4).
### 4.9 Kalender
- `CalendarSyncSourceType` + `WITHDRAWAL` (Quelle: Behandlungs-ID).
- `CalendarSyncSettings.syncWithdrawal Boolean @default(true)`.
- `GraphCalendarEventInput` bekommt optional `endDate` (ganztägig, mehrtägig; Graph-Ende exklusiv).
### 4.10 Rechte
- Neue Gruppe „Pflanzenentwicklung“, Ressource `phenology`: `read` „Ansehen“, `create` „Melden“, `update` „Bearbeiten“.
- Die Migration vergibt sie wie bei `winter-checks` an Nicht-System-Rollen (Annahme 16): `read` an Rollen mit `colonies:read`, `create`/`update` an Rollen mit `inspections:create`.
### 4.11 Regeln (`AutomationTriggerType`)
Neue Werte, alle mit Eintrag in `DEFAULT_RULES`, Config-Parser und Texten:
| Trigger | Art | Standard aktiv | Config (Standard) |
|---|---|---|---|
| `INSPECTION_INTERVAL` | Autopilot-Intervall | ja | Tage je Abschnitt, siehe 7.2 |
| `QUEEN_CELLS_SWARM_CHECK` | Kette | ja | `{ dueInDays: 7 }` |
| `QUEEN_ACCEPTANCE_CHECK` | Kette | ja | `{ dueInDays: 7 }` |
| `SPLIT_SCHEDULE` | Kette | ja | Schritte je `queenSource`, siehe 7.4 |
| `COLONY_ENDED_CLEANUP` | Kette | ja | `{}` |
| `WITHDRAWAL_ENDED` | Meldung | ja | `{ notify: true }` |
| `HONEY_WATER_CONTENT` | Qualität | ja | siehe 7.8 |
| `SEASON_AUTOPILOT` | Autopilot | ja | siehe 8.2 |
| `PHENOLOGY` | Saison | ja | siehe 7.3 |
- `VARROA_THRESHOLD` behält seinen Namen, die Config wird zum Bänder-Format (7.1). Ein alter Wert `thresholdMitesPerDay` wird als `naturalFall.redAbove` gelesen.
- Jede Regel bekommt einen Parser `parse<Trigger>Config(json)` in `src/automation/rule-config/`. Ungültige Werte fallen auf den Standard zurück, wie `varroaThresholdOf`. `PATCH /automation/rules/:id` lehnt ungültige bekannte Schlüssel mit 400 ab.
- Bestehende vorschlagende Regeln bekommen `config.notify` (Standard: `true` für `QUEENLESS_NO_EGGS`, `VARROA_THRESHOLD`, `COLONY_HEALTH_CRITICAL`; sonst `false`).
### 4.12 Migrationen
Zeitstempel vergibt der Bau. Die Reihenfolge ist bindend. Enum-Werte kommen in eigene Migrationen vor jeder Datenänderung, die sie benutzt: Postgres kann einen neuen Enum-Wert erst nach dem Commit verwenden.
| Nr | Name | Inhalt | Prüfung danach |
|---|---|---|---|
| 1 | `<ts>_t4_enum_values` | `AutomationTriggerType` (9 Werte), `BatchMovementType.BOTTLING`, `CalendarSyncSourceType.WITHDRAWAL` | `migrate diff --exit-code` |
| 2 | `<ts>_t4_suggestions` | Enums und `Suggestion`, `Task.sourceSuggestionId` | Wächter Backup |
| 3 | `<ts>_t4_feed_vocabulary` | neue Spalte, Abbildung nach 4.4, Freitext nach `notes`, alte Spalte weg, Umbenennen | SQL: Zahl umgezogener Freitexte im Migrationslog; keine Zeile mit Text und ohne Notiz |
| 4 | `<ts>_t4_phenology` | Enums, drei Modelle, Rechte und Rollen-Zuordnung | Rechte-Abdeckung e2e |
| 5 | `<ts>_t4_split_queen_source` | `SplitQueenSource`, `ColonyLineage.queenSource` | – |
| 6 | `<ts>_t4_honey_ledger` | Spalten, CHECK neu, Rückrechnung (siehe unten) | Invarianten-Abfrage liefert 0 Zeilen |
| 7 | `<ts>_t4_quality_fields` | `VarroaMeasurement.sampleBees`, `Product.shelfLifeMonths` | Null-Regel-Spec |
| 8 | `<ts>_t4_notification_fanout` | Rundmeldungen je aktivem Nutzer kopieren (mit gleichem `read`), Originale löschen, `userId NOT NULL`, `dedupeKey` | keine Zeile mit `userId IS NULL` |
| 9 | `<ts>_t4_calendar_withdrawal` | `syncWithdrawal` | – |
| 10 | `<ts>_t4_occupied_frames` | `Colony.occupiedFrames Int?` mit CHECK 0–40; keine Datenänderung an `strength` | Prüfabfrage: keine `Inspection.colonyStrength` über 40 (sonst vor dem Deploy klären, nicht kürzen) |
| 11 | `<ts>_t4_notification_channels` | `NotificationCategory`, `Notification.category/link/requiredPermissions`, `NotificationPreference`, `TelegramLink`, `TelegramLinkToken`, `NotificationDelivery`, `TelegramPollState` | Wächter Backup; `migrate diff --exit-code` |
Migration 8 läuft vor 11. Migration 11 belegt `category` der Altzeilen nach der Regel aus 4.8 und setzt danach `NOT NULL`.
Rückrechnung in Migration 6, je Charge mit `amount IS NOT NULL`:
1. `initialAmount = amount + Σ totalAmount` aller Abfüllungen der Charge.
2. Je Abfüllung eine Buchung `BOTTLING` mit `−round(totalAmount × 1000)` g, `sourceBottlingBatchId`, `createdAt` der Abfüllung, Notiz „Übernahme aus Abfüllung (T4)“.
Frühere Handkorrekturen von `amount` stecken danach in `initialAmount`. Das steht in der Notiz der Charge nicht; es steht in ARCHITECTURE (Annahme „Bewegungsbuch der Honigcharge“).
### 4.13 Backup
| Modell | Backup | Begründung (englisch in `MODELS_EXCLUDED_FROM_BACKUP`) |
|---|---|---|
| `Suggestion` | ja | Entscheidungen des Imkers; ohne sie kämen verworfene Vorschläge nach einem Restore wieder |
| `PhenologyReport` | ja | eigene Beobachtungen |
| `ApiaryPhenology` | nein | 'Derived season state, recomputed by daily-evaluation.' |
| `ApiaryWeatherDay` | nein | 'Weather cache, refetched by the worker.' |
| `NotificationPreference` | ja | Einstellungen des Nutzers |
| `TelegramLink` | nein | 'Chat binding to this deployment\'s bot - users re-link after a restore (like ApiKey).' |
| `TelegramLinkToken` | nein | 'Single-use link tokens, valid for minutes.' |
| `NotificationDelivery` | nein | 'Delivery queue - restoring it would resend old messages.' |
| `TelegramPollState` | nein | 'Update offset and poll lease of this deployment.' |
Der Import alter Bundles bildet ab: Futter-Freitext (4.4), `Notification` ohne `userId` (Fan-out wie Migration 8) und ohne `category` (wie Migration 11), `HoneyBatch` ohne `initialAmount` (Rückrechnung wie Migration 6). `Colony.strength` und `Colony.occupiedFrames` werden unverändert übernommen.
### 4.14 Volksstärke (Inhaber-Antwort 1)
- **Neu:** `Colony.occupiedFrames Int?`, Einheit besetzte Waben, 0–40. Dieselbe Einheit und dieselben Grenzen wie `Inspection.colonyStrength` (dort kommt `@Max(40)` hinzu).
- **Alt:** `Colony.strength` (1–10, ohne Einheit) bleibt mit allen Werten, wie sie sind. Keine Umrechnung: Eine „7 von 10“ lässt sich nicht verlässlich in Waben übersetzen.
- Bedeutung ab T4:
- `strength` heißt in der Oberfläche „Volksstärke (alt, Skala 1–10)“. Das Bearbeiten-Formular zeigt es nur, wenn ein Wert gespeichert ist (wie „Brutmenge (alt)“ in T3). Leeren geht (Null-Regel), Ändern geht im Bereich 1–10.
- Neue Völker bekommen im Formular nur `occupiedFrames`. Die API nimmt `strength` weiter an, damit nichts bricht; Swagger markiert es als veraltet.
- Anzeige auf der Volksseite: „{n} besetzte Waben“. Fehlt der neue Wert, aber der alte ist da: „Volksstärke (alt): 7 von 10“.
- Historie bleibt lesbar: Audit-Einträge und Exporte behalten die alten Werte mit ihrem alten Feldnamen. CSV-Export/-Import und Backup führen beide Spalten.
- Der Autopilot (A7) und die Statistik „Entwicklung Volksstärke“ nutzen die Durchsichten (`colonyStrength`), nicht die Volkskarte.
### 4.15 Benachrichtigungskanäle (Inhaber-Antwort 3)
```prisma
enum NotificationCategory { TASK_DUE DIGEST SUGGESTION WITHDRAWAL SEASON SYSTEM }
enum NotificationChannel { TELEGRAM }
enum NotificationDeliveryStatus { PENDING PROCESSING DELIVERED SKIPPED FAILED }
model NotificationPreference { // je Nutzer, Standard beim ersten Lesen angelegt
userId String @id
user User @relation(fields: [userId], references: [id])
taskDueEnabled Boolean @default(true) // Meldung je fälliger Aufgabe
digestEnabled Boolean @default(true) // Morgenübersicht
digestTime String @default("06:30") // HH:MM, Europe/Berlin, 05:00–12:00
quietFrom String? @default("21:00") // Ruhezeit für Telegram; null = keine
quietUntil String? @default("06:30")
telegramEnabled Boolean @default(false)
telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST])
telegramDetail Boolean @default(true) // false: nur „Neue Meldung in ApiCore“ plus Link
updatedAt DateTime @updatedAt
}
model TelegramLink {
userId String @id
user User @relation(fields: [userId], references: [id])
chatId BigInt @unique // privater Chat; ein Chat gehört zu genau einem Nutzer
telegramFirstName String? // nur zur Anzeige „Verbunden mit …“
linkedAt DateTime @default(now())
}
model TelegramLinkToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
tokenHash String @unique // SHA-256 des Tokens, nie der Klartext
expiresAt DateTime // 10 Minuten
consumedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
model NotificationDelivery { // Outbox wie WebhookDelivery
id String @id @default(cuid())
channel NotificationChannel
userId String? // gesetzt bei Meldungen; Chat wird beim Senden aus TelegramLink gelesen
user User? @relation(fields: [userId], references: [id])
chatId BigInt? // nur für Systemantworten an einen Chat (auch unverbundene)
notificationId String?
notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade)
templateCode String? // Systemantworten des Bots (Verbunden, Ungültig, Abgemeldet, Hilfe)
status NotificationDeliveryStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
lastStatusCode Int?
lastError String?
deliveredAt DateTime?
createdAt DateTime @default(now())
@@index([status, availableAt])
@@index([userId, createdAt])
}
model TelegramPollState { // eine Zeile: Offset und Lease des Empfängers
id String @id @default("singleton")
updateOffset BigInt @default(0)
leaseOwner String?
leaseUntil DateTime?
lastPollAt DateTime?
lastError String?
}
```
- Der Text einer Zustellung wird **nicht** gespeichert. Der Versand rendert ihn aus der `Notification` bzw. aus `templateCode` (9a.6).
- CHECK-Constraint: genau eins von (`notificationId` mit `userId`) oder (`templateCode` mit `chatId`).
- Ruhezeit und Morgenzeit sind Annahmen (Standard 21:00–06:30 bzw. 06:30) und je Nutzer änderbar.
- `chatId` ist kein Geheimnis (ohne Bot-Token nutzlos), aber personenbezogen. Darum nicht im Backup und nie in Logs.
## 5. Ereignisse und Handler
### 5.1 Neue Ereignisse
T4 erweitert `DomainEventPayloads` bewusst. Der Kommentar „vollständig“ im Rückgrat-Plan meint dessen Stufe.
| Ereignis | Payload | Aggregat | Erzeuger |
|---|---|---|---|
| `ColonySplit` | `{ parentColonyId, lineageId, queenSource: SplitQueenSource \| null, splitOn }` | `Colony` (Ableger) | `ColoniesService.split` |
| `QueenAssigned` | `{ colonyId, historyId, assignedOn, previousColonyId: string \| null }` | `Queen` | `QueensService.assignColony` |
| `ExtractionRecorded` / `ExtractionCorrected` | `{ harvestIds: string[] }` | `Extraction` | `ExtractionsService` |
| `HoneyBatchRecorded` / `HoneyBatchCorrected` | `{ extractionId }` | `HoneyBatch` | `HoneyBatchesService` |
| `PhenologyReported` / `PhenologyCorrected` | `{ apiaryId: string \| null, phase, reportedOn }` | `PhenologyReport` | `PhenologyService` |
| `SeasonPhaseChanged` (zeitbasiert) | `{ apiaryId, phase, segment, previousPhase, previousSegment, source }` | `Apiary` | `daily-evaluation`, Handler `phenology.recompute` |
- `AggregateType` + `Extraction`, `HoneyBatch`, `PhenologyReport`, `Apiary`.
- `SeasonPhaseChanged` hat `dedupeKey = dailyDedupeKey('SeasonPhaseChanged', apiaryId, Tag)`.
- Payloads tragen nur IDs, Enum-Werte und Daten. Keine Namen, keine Notizen.
### 5.2 Handler
| Ereignis | Erzeuger | Handler | Wirkung |
|---|---|---|---|
| `InspectionRecorded` | inspections | `automation.inspection-rules` (Rückgrat, geändert) | `QUEENLESS_NO_EGGS`, `FEED_STATUS_CRITICAL` (jetzt `feedStatus = CRITICAL`) → Vorschlag statt Aufgabe |
| `InspectionRecorded` | inspections | `chains.queen-cells` | `queenCellsPresent = true` → PLAN „Schwarmkontrolle“, fällig +7 |
| `InspectionRecorded`, `InspectionCorrected` | inspections | `autopilot.colony-sync` | Schritte des Volks neu abgleichen |
| `VarroaMeasured`, `VarroaCorrected` | varroa | `automation.varroa-threshold` (geändert) | Band rot → PLAN „Behandlung prüfen“; nicht bis 14 Tage nach Behandlungsende |
| `VarroaMeasured`, `VarroaCorrected` | varroa | `autopilot.colony-sync` | – |
| `TreatmentRecorded`, `TreatmentCorrected` | treatments | `automation.treatment-follow-up` (Rückgrat), `calendar-sync.withdrawal` (neu), `autopilot.colony-sync` | Wartefenster in die Kalender-Outbox |
| `FeedingRecorded`, `FeedingCorrected` | feedings | `automation.feeding-follow-up` (Rückgrat), `autopilot.colony-sync` | – |
| `HarvestRecorded`, `HarvestCorrected` | harvests | `withdrawal.harvest-check`, `autopilot.colony-sync` (je Volk) | WARNUNG je Konflikt (Volk, Ernte), dedupliziert |
| `ExtractionRecorded`, `ExtractionCorrected` | extractions | `withdrawal.extraction-check` | Meldung nur für noch nicht gemeldete Konflikte |
| `HoneyBatchRecorded` | honey-batches | `webhooks.forward` | Webhook `batch.created` (bisher Altpfad) |
| `BatchBottled`, `BottlingCorrected` | bottling | `withdrawal.bottling-check` | wie oben |
| `ColonyStatusChanged` | colonies | `automation.colony-health` (Rückgrat, geändert), `autopilot.colony-sync` | KRITISCH → Vorschlag |
| `ColonyEnded` | colonies, winter-checks | `suggestions.colony-ended` | offene Vorschläge → `OBSOLETE`; Aufräum-Vorschlag (7.10) |
| `ColonySplit` | colonies | `chains.split-schedule`, `autopilot.colony-sync` (beide Völker) | PLAN mit Terminen je `queenSource` |
| `QueenAssigned` | queens | `chains.queen-acceptance`, `calendar-sync.queen-assignment`, `webhooks.forward`, `autopilot.colony-sync` | PLAN „Annahme prüfen“ +7; Kalender und Webhook `queen.replaced` statt Altpfad |
| `TaskCompleted`, `TaskUpdated` | tasks | `autopilot.colony-sync` (nur `relatedType = COLONY`) | – |
| `WithdrawalEnded` | daily-evaluation | `notifications.withdrawal-ended`, `autopilot.colony-sync` | INFO „B3 ist wieder erntefähig“ an `harvests:create`, nur wenn kein anderes Fenster mehr läuft |
| `QueenAged`, `InspectionOverdue`, `VarroaMeasurementDue` | daily-evaluation | `autopilot.colony-sync` | – |
| `TaskDue` | daily-evaluation | `notifications.task-due` | HINWEIS „Heute fällig: {Titel}“ mit Link an den Zuständigen, ohne Zuständigen an Inhaber von `tasks:complete`; nur bei `taskDueEnabled`; Schlüssel `task-due:<taskId>:<Tag>:<userId>`; Telegram-Zustellung nach 9a.4 |
| `PhenologyReported`, `PhenologyCorrected` | phenology | `phenology.recompute` | Zustand des Stands bzw. aller Stände neu; schreibt bei Wechsel `SeasonPhaseChanged` |
| `SeasonPhaseChanged` | daily-evaluation, phenology | `autopilot.apiary-sync`, `notifications.season-segment` | alle Völker des Stands; INFO nur bei Wechsel des Abschnitts |
### 5.3 Regeln für alle T4-Handler
- Handler-IDs folgen `HANDLER_ID_PATTERN`. Alle stehen in Modulen, die `WorkerModule` importiert (Carry-forward Rückgrat Task 6).
- Nur Schreiben über `tx`, keine Netzwerkaufrufe (Wächter `handler-contract.spec.ts`).
- `autopilot.colony-sync` sperrt zuerst die Volkszeile (`SELECT … FROM "Colony" WHERE id = $1 FOR UPDATE`). Zwei Ereignisse desselben Volks in zwei Workern laufen so nacheinander.
- Vorschläge entstehen über `createMany({ skipDuplicates: true })` mit `dedupeKey`. Eine erneute Zustellung erzeugt nichts doppelt.
- Ein Handler für ein beendetes Volk tut nichts (außer `suggestions.colony-ended`).
### 5.4 Erzeuger-Umstellungen
- `ColoniesService.updateIn(tx, id, dto, actingUserId)` wird der gemeinsame Kern von `update`. Ihn nutzt auch die Durchsicht (7.9).
- `releaseQueenIn(tx, colonyId, endDate)` in `src/colonies/colony-lifecycle.ts`: offene `QueenColonyHistory` mit `endDate` beenden, `currentQueenId = null`. Pflicht in **jedem** Weg, auf dem ein Volk endet:
| Weg | heute | T4 |
|---|---|---|
| `dissolve` (`DISSOLVED`) | beendet die Zuordnung | über den Helfer |
| `merge` (`MERGED`) | beendet die Zuordnung | über den Helfer |
| `update` auf `SOLD` oder `DECEASED` | **Königin bleibt zugeordnet** | über den Helfer, Enddatum = Tag der Änderung |
| Auswinterung mit `markColonyDeceased` (`DECEASED`) | **Königin bleibt zugeordnet** | über den Helfer, Enddatum = Datum der Auswinterung |
Ein Wächter-Test prüft: Jede Stelle, die `status` auf einen Wert aus `TERMINAL_COLONY_STATUSES` setzt, ruft `releaseQueenIn` in derselben Transaktion auf.
- `ColoniesService.split` schreibt `ColonySplit`, Audit in der Transaktion.
- `QueensService.assignColony` schreibt `QueenAssigned`; `webhooks.dispatch('queen.replaced')` und `calendarSync.enqueue('QUEEN_ASSIGNMENT')` entfallen.
- `ExtractionsService` und `HoneyBatchesService`: Audit in die Transaktion, Ereignisse wie in 5.1. `webhooks.dispatch('batch.created')` entfällt.
- Wächter `after-commit-usage.spec.ts`: Die Liste der Altpfade verliert `queens` und `honey-batches`.
## 6. daily-evaluation und Wetter-Schleife
### 6.1 Ablauf des Jobs
Der Job aus Rückgrat Task 18 bekommt Schritte. Jeder Schritt läuft in eigenen Transaktionen. Scheitert einer, laufen die anderen; `JobRun` ist dann `FAILED` mit den Zählern.
| Schritt | Inhalt | Transaktion |
|---|---|---|
| 1 `phenology` | je aktivem Stand GTS, Phasen, Abschnitt berechnen (7.3), `ApiaryPhenology` schreiben, bei Wechsel `SeasonPhaseChanged` | je Stand |
| 2 Zeit-Ereignisse | `WithdrawalEnded`, `TaskDue`, `QueenAged`, `VarroaMeasurementDue`, `InspectionOverdue` mit den Änderungen aus 6.2 | je Typ (wie Rückgrat) |
| 3 `autopilot` | je lebendem Volk `AutopilotService.syncColonyIn(tx, colonyId, today)` | je Volk, Volkszeile gesperrt |
| 4 `suggestions-expiry` | `OPEN` mit `expiresAt < today` → `EXPIRED` | eine Anweisung |
| 5 `digest` | je aktivem Nutzer mit `digestEnabled` eine Morgenübersicht (9a.3) samt Telegram-Zustellung zur eingestellten Uhrzeit | je Nutzer |
`JobResult` zusätzlich: `phenologyApiaries`, `seasonChanges`, `autopilotColonies`, `suggestionsOpened`, `suggestionsResolved`, `suggestionsExpired`, `digests`.
Schritt 5 läuft nach Schritt 3, damit die Übersicht die frischen Schritte kennt. `TaskDue` aus Schritt 2 verarbeitet der Worker gleich danach; die Meldungen je Aufgabe sind also vor der frühesten Übersichts-Uhrzeit (05:00) da.
### 6.2 Änderungen an den zeitbasierten Ereignissen
| Ereignis | Rückgrat heute | T4 |
|---|---|---|
| `InspectionOverdue` | Intervall 21 Tage, nur März–Oktober | Tag nach `inspectionDueOn()` (7.2); keine Ereignisse in `WINTERRUHE` |
| `VarroaMeasurementDue` | Intervall 28 Tage, nur März–Oktober | Regel A2 (8.2): 28 Tage außerhalb `WINTERRUHE`, dazu Winterfenster |
| `QueenAged` | ab 2 Jahren, einmal im Jahr | Schwelle aus `SEASON_AUTOPILOT.queenMaxAgeYears` (Standard 2, Begründung in den Entscheidungen). `DashboardService` nutzt dieselbe Zahl; die Konstante `OLD_QUEEN_AGE_YEARS = 3` entfällt. |
| `WithdrawalEnded` | `(endDate ?? date) + withdrawalPeriodDays` | unverändert; Fensterrechnung aus dem gemeinsamen Helfer `withdrawal-window.ts` (auch für `getWithdrawalStatus`) |
`INSPECTION_INTERVAL_DAYS` und `ACTIVE_SEASON_MONTHS` entfallen.
### 6.3 Worker-Schleife `phenology-weather`
- Neue Zeile in der Loop-Tabelle von `WorkerRuntime`: Takt 60 min, kein Weckkanal, respektiert das Stop-Signal.
- Je Takt: für jeden aktiven Stand mit Koordinaten fehlende Tage vom 1. Januar bis gestern (Europe/Berlin) suchen.
- Fehlende Tage der letzten 90 Tage: Forecast-API mit `past_days=92`, `daily=temperature_2m_mean`, `timezone=Europe/Berlin`.
- Ältere Lücken: Archiv-API mit `start_date`/`end_date`.
- Koordinaten auf 0,01° gerundet. Stände mit demselben gerundeten Punkt teilen sich eine Anfrage.
- Feste Hosts aus Konstanten, Timeout 8 s, Antwortgröße begrenzt, Werte nur zwischen −50 und 50 °C, sonst `null`.
- Fehler: loggen über `loggableError()`, nächster Takt versucht es wieder. Kein Ereignis, keine Meldung.
- `cleanup` löscht `ApiaryWeatherDay` älter als 4 Kalenderjahre (für die Kalibrierung, 7.3).
- **Quelle (Inhaber-Entscheidung):** freie Open-Meteo-API. Laut Anbieter ist sie nur für nicht-kommerzielle Nutzung frei; der Inhaber trägt dieses Risiko bewusst. Unser Bedarf liegt weit unter der Tagesgrenze des Anbieters (eine Anfrage je gerundetem Punkt und Tag).
- **Adapter:** Die Schleife kennt nur `WeatherHistorySource.dailyMeans(point, fromDay, toDay): Promise<DailyMean[]>`. Welche Umsetzung läuft, bestimmen Umgebungsvariablen aus der ConfigMap:
| Variable | Standard | Zweck |
|---|---|---|
| `WEATHER_HISTORY_SOURCE` | `open-meteo` | Auswahl der Umsetzung |
| `OPEN_METEO_FORECAST_URL` | `https://api.open-meteo.com/v1/forecast` | Basis-URL; für das bezahlte Angebot die Kunden-URL |
| `OPEN_METEO_ARCHIVE_URL` | `https://archive-api.open-meteo.com/v1/archive` | dto |
| `OPEN_METEO_API_KEY` | leer | Secret; nur für das bezahlte Angebot |
- Wechsel zu bezahltem Open-Meteo: nur Einstellungen (URL und Schlüssel), kein Code.
- Wechsel zu DWD/Bright Sky: eine neue Umsetzung (etwa 0,5 PT), danach `WEATHER_HISTORY_SOURCE=bright-sky`.
- Die bestehende Wetteranzeige (`WeatherService`) liest dieselben URL- und Schlüssel-Variablen. Ein Wechsel des Angebots gilt so für beide.
- URLs nur `https:` und nur aus der Konfiguration, nie aus Nutzereingaben. Der Schlüssel erscheint nie in Logs oder `lastError` (URL wird vor dem Loggen entfernt).
## 7. Fachregeln
Alle Werte stehen in `AutomationRule.config` und sind in „Verwaltung > Automatisierung“ änderbar. „Annahme“ heißt: Standardwert ohne belastbare Quelle, vor dem Bau zu prüfen.
### 7.1 Varroa-Ampel nach Jahreszeit
| Methode | Zeitraum | Grün | Gelb | Rot | Quelle |
|---|---|---|---|---|---|
| Natürlicher Milbenfall (Milben/Tag) | März–Oktober | unter 5 | 5 bis 10 | über 10 | LWG Veitshöchheim, Tabelle aus Fahrplan §10; Gültigkeit ab März = Annahme |
| Natürlicher Milbenfall (Milben/Tag) | November–Februar | bis 0,5 | – | über 0,5 | LWG (Fahrplan §10: „ab November Behandlung schon über 0,5“); Ende Februar = Annahme |
| Puderzucker (Milben je 100 Bienen, %) | Mai–Juli | unter 1 % | 1–5 % | über 5 % | LWG „Juli“; Mai und Juni = Annahme |
| Puderzucker | August–Oktober | unter 2 % | 2–5 % | über 5 % | LWG „September“; August und Oktober = Annahme |
| Puderzucker | November–April | keine Ampel | | | – |
| Auswaschung | wie Puderzucker | | | | Annahme (gleiche Befallsrate) |
| Drohnenbrut | keine Ampel | | | | – |
- **Grenzen:** rot heißt `Wert > redAbove`. Gelb heißt `Wert ≥ yellowFrom`. Genau 10,0 Milben/Tag sind also gelb. Heute (T3) ist 10,0 rot; das ändert sich mit der Quelle.
- **Jungvölker** (`YOUNG_COLONY`, `NUCLEUS_COLONY`, `MATING_NUCLEUS`): Schwellen des natürlichen Milbenfalls × 0,5 (Fahrplan §10). Nur beim Milbenfall, weil Prozentwerte schon auf die Probe bezogen sind (Annahme).
- **Nach einer Behandlung:** Liegt das Messdatum bis 14 Tage nach `endDate ?? date` einer Behandlung, gibt es keine Ampel. Der Text lautet „Behandlungseffekt – Nachkontrolle ab {Datum}“. Die Regel schlägt dann nichts vor (Fahrplan §10).
- **Wirkung:** Rot → PLAN-Vorschlag `VARROA_TREAT` „Behandlung prüfen“, Priorität `HIGH`, Anker = Mess-ID.
- **Config:** `{ naturalFall: { seasonMonths, 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, recheckAfterDays: 14, notify: true }`.
- Ein Helfer `varroa-bands.ts` (`bandsFor(date, colonyType, config)`, `classify(value, band)`) liefert Ampel, Endpunkt und Regel. Das Frontend klassifiziert nur mit dem gelieferten Band.
### 7.2 Fälligkeit der Durchsicht
`inspectionDueOn(colony, lastInspection, segment, config)` = Datum der letzten Durchsicht (sonst `colony.createdAt`) plus Intervall des heutigen Abschnitts. In `WINTERRUHE` `null`.
| Abschnitt | Intervall (Standard) | erlaubt | Quelle |
|---|---|---|---|
| `FRUEHJAHR` | 14 Tage | 7–35 | Fahrplan §10 („sonst 14–21“) |
| `SCHWARMZEIT` | 7 Tage | 7–9 | Fahrplan §10; Bieneninstitute (z. B. LLH Kirchhain, LWG): Weiselzellen sind nach 8–9 Tagen verdeckelt, darum Kontrolle im 7-Tage-Takt |
| `SOMMER` | 14 Tage | 7–35 | Fahrplan §10 |
| `SPAETSOMMER` | 21 Tage | 7–35 | Fahrplan §10 |
| `HERBST` | 21 Tage | 7–35 | Fahrplan §10 |
| `WINTERRUHE` | keins | – | Winterruhe; Einwinterung und Auswinterung laufen über Regeln A9/A10 |
- Erste Durchsicht im Jahr: Liegt die letzte Durchsicht vor dem Beginn von `FRUEHJAHR`, ist das Volk ab Abschnittsbeginn fällig. Die Begründung sagt „Erste Durchsicht im Jahr“.
- Ausgabe: `inspectionDueOn` in `ColonyOverviewRow` (T3), in `GET /colonies/:id/next-steps` und in `GET /autopilot/today`. Das beantwortet §30 „Welche Völker wurden zu lange nicht kontrolliert?“.
### 7.3 Pflanzenentwicklung und Saisonabschnitt
**Grünlandtemperatursumme (DWD):** Summe der positiven Tagesmitteltemperaturen ab 1. Januar. Januar zählt mit Faktor 0,5, Februar mit 0,75, ab März voll. Ab 200 °C gilt die Vegetation als nachhaltig begonnen. Fehlen mehr als 10 % der Tage, gilt die Summe als lückenhaft und wird nicht benutzt (Text „Wetterdaten lückenhaft“).
**Phasen:**
| Phase | Meldung (ein Tipp) | Bezug | GTS-Schwelle | Kalender-Rückfall |
|---|---|---|---|---|
| `SALWEIDE` | „Salweide blüht“ | Imkerpraxis: erste Pollentracht, Frühjahrsdurchsicht | 200 °C (DWD-Vegetationsbeginn; gleichgesetzt mit der Salweidenblüte = Annahme) | 20.03. |
| `KIRSCHE` | „Kirsche blüht“ | Imkerpraxis: Honigraum, Beginn der Schwarmkontrollen | aus eigenen Meldungen der Vorjahre, sonst keine | 15.04. |
| `APFEL` | „Apfel blüht“ | DWD-Vollfrühling | aus eigenen Meldungen der Vorjahre, sonst keine | 28.04. |
| `HOLUNDER` | „Holunder blüht“ | DWD-Frühsommer | keine | 30.05. |
| `LINDE` | „Linde blüht“ | DWD-Hochsommer | keine | 20.06. |
| `TRACHTENDE` | „Tracht vorbei“ | Imkerpraxis: letzte Ernte | keine | 01.08. |
| `HERBST` | „Efeu blüht“ | Imkerpraxis: letzte Tracht | keine | 15.09. |
| `WINTER` | „Winterruhe“ | brutarm bis brutfrei | keine | 15.11. |
Alle Kalender-Rückfälle sind Annahmen für Schwaben (rund 450–500 m). Vor dem Bau gegen DWD-Phänologiedaten der Region prüfen.
**Effektive Phase** je Stand und Jahr, als Maximum dreier Werte:
1. **Gemeldet:** höchste Phase aller nicht zurückgezogenen Meldungen dieses Jahres für den Stand oder für alle Stände.
2. **Geschätzt (GTS):** höchste Phase mit Schwelle ≤ heutiger GTS, nur wenn die GTS nicht lückenhaft ist.
3. **Kalender:** höchste Phase mit Rückfall-Datum ≤ heute, nur für Phasen ohne GTS-Schwelle oder ohne Wetterdaten am Stand.
`phaseSource` nennt die Quelle des Maximums. Innerhalb eines Jahres läuft die Phase nur vorwärts. Einzige Ausnahme: Eine zurückgezogene Meldung kann sie zurücksetzen.
**Kalibrierung:** Für `KIRSCHE` und `APFEL` (und auf Wunsch `SALWEIDE`) nimmt der Job als Schwelle den Median der GTS an den Tagen der ersten eigenen Meldung dieser Phase in bis zu drei Vorjahren. Ohne Vorjahresmeldung gibt es keine Schwelle; dann gilt der Kalender. So erfindet ApiCore keine Schwellen.
**Saisonabschnitte:**
| Abschnitt | ab Phase | bis vor Phase |
|---|---|---|
| `WINTERRUHE` | `WINTER` oder vor `SALWEIDE` | `SALWEIDE` |
| `FRUEHJAHR` | `SALWEIDE` | Beginn Schwarmzeit |
| `SCHWARMZEIT` | `KIRSCHE` (einstellbar: `APFEL`) | `LINDE` (einstellbar) |
| `SOMMER` | `LINDE` | `TRACHTENDE` |
| `SPAETSOMMER` | `TRACHTENDE` | `HERBST` |
| `HERBST` | `HERBST` | `WINTER` |
Beginn der Schwarmzeit ab Kirschblüte ist eine Annahme nach Bieneninstitut-Praxis. Früher prüfen kostet eine Durchsicht, ein verpasster Schwarm kostet die Ernte eines Volks.
**Config `PHENOLOGY`:** `{ useGts: true, gtsThresholds: { SALWEIDE: 200 }, calibrateFromReports: true, calendarFallback: { SALWEIDE: '03-20', … }, swarmSeason: { from: 'KIRSCHE', until: 'LINDE' }, maxGapShare: 0.1 }`.
Stände ohne Koordinaten: nur Meldungen und Kalender. Die Begründung sagt „Ohne Koordinaten keine Schätzung“.
### 7.4 Biotechnik-Ketten
| Auslöser | Schritt (Vorschlag, Art) | fällig ab Auslöser-Datum | Quelle |
|---|---|---|---|
| Durchsicht mit `queenCellsPresent = true` | „Schwarmkontrolle“ (PLAN) | +7 Tage | Fahrplan §10; Bieneninstitute (Verdeckelung nach 8–9 Tagen) |
| `QueenAssigned` | „Annahme der Königin prüfen“ (PLAN) | +7 Tage | Fahrplan §10 |
| Ableger `RAISE_OWN` | „Ableger-Plan übernehmen“ (PLAN, drei Termine) | +9 „Nachschaffungszellen prüfen“; +21 „Begattung prüfen“ (Fenster bis +24); +28 „Stifte prüfen“ | Fahrplan §10 |
| Ableger `QUEEN_CELL` | „Stifte prüfen“ (PLAN) | +21 Tage | Annahme |
| Ableger `QUEEN` | „Annahme der Königin prüfen“ (PLAN) | +7 Tage | wie Königin zugesetzt |
| Ableger ohne Angabe | keine Kette | – | keine stille Annahme |
- Ein Ableger-Plan ist **ein** Vorschlag. „Übernehmen“ legt drei Aufgaben mit Terminen an. Eine Entscheidung, drei Kalendereinträge.
- Dubletten: Ein offener Vorschlag oder eine offene Aufgabe mit derselben Absicht für dasselbe Volk verhindert einen zweiten. Ein Ableger mit `QUEEN` plus spätere Zuweisung der Königin ergibt also nur einen Annahme-Vorschlag.
- Fälligkeit zählt ab dem fachlichen Datum (Durchsicht, Zuweisung, Ableger), nicht ab heute.
### 7.5 Wartezeit-Kette
- Ein Helfer `withdrawal-window.ts`: Fenster einer Behandlung = `(endDate ?? date)` bis `(endDate ?? date) + withdrawalPeriodDays`. Nutzer: `getWithdrawalStatus`, `withdrawalEndDay` (Rückgrat), `WithdrawalCheckService`, Kalender.
- `WithdrawalCheckService.conflictsForHarvests(db, harvestIds)`: je Ernte und Volk die Behandlung, deren Fenster das **Erntedatum** einschließt (Annahme 10). Ohne eingetragene Wartezeit keine Warnung (Annahme 10).
- **Ernte, Schleuderung, Abfüllung:** Die Antwort trägt `warnings` mit Code `WITHDRAWAL_ACTIVE` (Volk, Mittel, „bis“). Nie 4xx.
- **Meldung:** ein Handler je Stufe. Schlüssel `withdrawal-conflict:<harvestId>:<colonyId>:<userId>`. Jeder Konflikt wird nur einmal gemeldet, egal auf welcher Stufe er zuerst auffällt.
- **„Volk erntefähig“:** `WithdrawalEnded` → INFO „{Volk} ist wieder erntefähig. Wartezeit nach {Mittel} endete am {Datum}.“ an Inhaber von `harvests:create`. Nur, wenn für das Volk kein weiteres Fenster läuft.
- **Kalender:** Quelle `WITHDRAWAL` je Behandlung mit Wartezeit, ganztägig vom Fensterbeginn bis zum Ende, Titel „Wartezeit {Volk} · {Mittel}“. In-App-Kalender: Typ `WITHDRAWAL` mit `endDate`. Ohne Wartezeit wird die Quelle abgemeldet (Graph-Event gelöscht, Rückgrat Task 8).
### 7.6 Honig-Kette: Vorbelegung und Plausibilität
Vorbelegung ist kein Messwert. Was eine Messung ist (Menge, Wassergehalt), erscheint als Hinweis mit Übernahme-Knopf, nicht still im Feld. Dafür wird `PreviousValueHint` (T3) um ein Präfix erweitert: „Aus 2 Ernten: 42,5 kg · übernehmen“.
| Formular | Vorschlag | Herkunft |
|---|---|---|
| Schleuderung | Menge = Σ `netWeight` der gewählten Ernten | `GET /extractions/prefill` |
| Schleuderung | Sorte, wenn alle Ernten dieselbe `honeyType` haben | dto |
| Honigcharge | Menge = Schleudermenge − Σ `initialAmount` vorhandener Chargen der Schleuderung | `GET /honey-batches/prefill` |
| Honigcharge | Sorte, Wassergehalt der Schleuderung | dto |
| Prüfung | Code | Stufe |
|---|---|---|
| Schleudermenge > Σ Netto der Ernten (alle Netto bekannt) | `EXTRACTION_EXCEEDS_HARVEST` | WARNUNG |
| Netto fehlt bei mindestens einer Ernte | `HARVEST_NET_MISSING` | HINWEIS |
| Σ Ausgangsmenge der Chargen > Schleudermenge | `BATCHES_EXCEED_EXTRACTION` | WARNUNG |
Geprüft wird beim Anlegen und Ändern. Antwort `201/200` mit `warnings`, nie eine Sperre.
### 7.7 Bewegungsbuch der Honigcharge
`HoneyBatchLedgerService.bookIn(tx, { honeyBatchId, deltaKg, type, reason?, notes?, sourceBottlingBatchId?, actingUserId })`:
1. `SELECT … FROM "HoneyBatch" WHERE id = $1 FOR UPDATE`.
2. Bei negativem Delta: Rest ≥ |Delta| − 0,5 g, sonst 400 „Abfüllmenge übersteigt die verfügbare Menge der Honigcharge“ (Text wie heute).
3. `BatchMovement` in Gramm schreiben, `amount` auf Gramm gerundet setzen, nie unter 0.
| Vorgang | Buchung |
|---|---|
| Charge anlegen mit Menge | `initialAmount = amount`, keine Buchung |
| Abfüllung anlegen | `BOTTLING`, −Menge, mit `sourceBottlingBatchId` |
| Abfüllung ändern | `BOTTLING`, Differenz |
| Abfüllung entfernen | `BOTTLING`, +Menge, Notiz „Abfüllung entfernt“ |
| `PATCH /honey-batches/:id` mit `amount` | `CORRECTION`, Differenz neu − aktuell |
| Entnahme (lose Ware, Probe, Bruch) | `WITHDRAWAL` mit `WithdrawalReason` |
| `amount: null` bei vorhandenen Buchungen | 400 „Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.“ |
| `amount` von `null` auf einen Wert | setzt `initialAmount`, keine Buchung |
`takeFromHoneyBatch`, `giveBackToHoneyBatch` und `normalizeAmount` in `bottling.service.ts` entfallen. Ein Wächter verbietet Schreibzugriffe auf `honeyBatch.amount` außerhalb des Ledgers.
### 7.8 Qualität vor der Abfüllung
| Grenze | Standard | Quelle | Wirkung |
|---|---|---|---|
| Warnung | über 18,0 % Wasser | D.I.B., Warenzeichen „Echter Deutscher Honig“ | `WATER_CONTENT_HIGH`, WARNUNG |
| Bestätigung | über 20,0 % Wasser | HonigV Anlage 2 (RL 2001/110/EG Anhang II): im Allgemeinen höchstens 20 % | `WATER_CONTENT_ABOVE_LEGAL`, Abfüllen nur mit Bestätigung |
| Heidekrauthonig | Bestätigung über 23 % | HonigV Anlage 2 | nur, wenn die Sorte in der Config so heißt; keine Vorbelegung |
| kein Messwert | – | – | `WATER_CONTENT_MISSING`, HINWEIS |
- Maßgeblich ist die jüngste `HoneyMeasurement` (nach `measuredAt`), sonst `HoneyBatch.waterContent`.
- Sorten-Abgleich: `honeyType` ohne Groß-/Kleinschreibung, exakt. Config `{ warnAbovePercent: 18, confirmAbovePercent: 20, byHoneyType: {} }`.
- Dieselbe Prüfung meldet sich auch beim Erfassen einer Honigmessung (nur Warnung).
- **Bestätigung:** `POST /bottling` ohne `acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL']` → 409 `{ code: 'CONFIRMATION_REQUIRED', warnings }`. Der Idempotenz-Interceptor gibt den Schlüssel bei einem Fehler frei; die bestätigte Wiederholung darf denselben Schlüssel nutzen.
- **MHD und Glas:** Mit gewähltem Artikel sind `jarSize` und `bestBeforeDate` optional. Der Server nimmt `product.packagingSize` und `date + shelfLifeMonths` (Monatsende abgeschnitten, z. B. 31.01. + 1 Monat = 28./29.02.). Ein gesendeter Wert gewinnt. Weicht `jarSize` vom Artikel ab: `JAR_SIZE_DIFFERS`, HINWEIS. Für das MHD gibt es keinen Standardwert; der Imker trägt die Haltbarkeit am Artikel ein.
- Das Formular füllt Glasgröße und MHD direkt aus dem Artikel (Stammdaten, keine Beobachtung) und nennt die Herkunft: „aus Artikel: 24 Monate“.
### 7.9 Durchsicht → Volkskarte
`CreateInspectionDto.applyToColony?: ('occupiedFrames' | 'broodStatus' | 'feedStatus')[]` (`@ArrayUnique`, höchstens 3).
| Feld der Volkskarte | aus der Durchsicht | Abbildung |
|---|---|---|
| `occupiedFrames` | `colonyStrength` (besetzte Waben) | gleich, 0–40 (4.14). Der Altwert `strength` wird nie geschrieben. |
| `broodStatus` | `broodPattern` (1–5, 5 = gut) | 5 `EXCELLENT`, 4 `GOOD`, 3 `AVERAGE`, 2 `WEAK`, 1 `CRITICAL` (Brutbild als Brutstatus = Annahme) |
| `feedStatus` | `feedStatus` | gleich (4.4) |
- Der Server nimmt die Werte aus der Durchsicht selbst, nie aus dem Request. Es gibt keine zweite Wahrheit.
- In derselben Transaktion: Durchsicht, Audit, `InspectionRecorded`, dann `ColoniesService.updateIn` (eigener Audit-Eintrag `Colony UPDATE`).
- Übersprungen und gemeldet in `colonyTakeover.skipped` wird ein Feld bei:
- `NO_VALUE`: Feld in der Durchsicht leer.
- `NO_PERMISSION`: kein `colonies:update`. Kein 403, damit die Offline-Warteschlange nicht hängen bleibt.
- `NEWER_INSPECTION`: Es gibt eine neuere Durchsicht des Volks (nach `date`), z. B. bei einem spät gesendeten Offline-Eintrag.
- `OUT_OF_RANGE`: Wert passt nicht zur Volkskarte.
- Antwort: `{ …inspection, colonyTakeover: { applied: Field[], skipped: { field, reason }[] } }`.
- Beim Bearbeiten einer Durchsicht wird nichts übernommen. Eine Korrektur löst keine Regel aus (wie Rückgrat Task 11).
- Das Formular zeigt den Abschnitt „Volkskarte“ direkt über „Speichern“: je Feld eine Zeile „Futter: Ausreichend → Niedrig“ mit Haken. Angehakt sind nur Felder, die in dieser Durchsicht ausgefüllt wurden. Ohne `colonies:update` fehlt der Abschnitt.
### 7.10 Volk beendet
1. **In der Transaktion des Endes:** `releaseQueenIn` (5.4). Das gilt auch beim Verlust über die Auswinterung und bei `update` auf `SOLD`/`DECEASED`. Der Status der Königin ändert sich hier nicht.
2. **Handler `suggestions.colony-ended`:**
- offene Vorschläge des Volks → `OBSOLETE`;
- gibt es offene Aufgaben des Volks oder eine Königin mit Status `ACTIVE`, die eben freigegeben wurde: ein Vorschlag `CLEANUP` (DO), Priorität `NORMAL`, Begründung „B3 ist eingegangen (Auswinterung 12.03.). 2 Aufgaben sind noch offen.“
3. **„Aufräumen“** öffnet ein Sheet mit Vorauswahl, alles sichtbar und änderbar:
- Aufgaben: „Abbrechen“ (`CANCELLED`) vorgewählt; je Aufgabe umschaltbar auf „Erledigt“ oder „Behalten“.
- Königin: bei `DECEASED` „verstorben“, bei `SOLD` „verkauft“ vorgewählt; bei `DISSOLVED`/`MERGED` „unverändert“ (Annahme).
- Ein Hauptknopf „Aufräumen“.
## 8. Saison-Autopilot
### 8.1 Eingaben (je lebendem Volk)
- Volk: Status, Typ, Volkskarte, Stand, `createdAt`.
- Letzte Durchsicht: Datum, `colonyStrength`, `feedStatus`, `queenCellsPresent`.
- Letzte Varroa-Messung und ihr Band (7.1); letzte Behandlung und ihr Fenster.
- Fütterungen seit Beginn von `SPAETSOMMER` bzw. seit der letzten Durchsicht.
- Königin: `hatchYear`. Überwinterungseinträge (`WinterCheck`) des laufenden und des letzten Winters.
- Offene Aufgaben (`relatedType = COLONY`) und offene Vorschläge des Volks.
- `ApiaryPhenology` des Stands: Phase, Abschnitt, Quelle, Datum der Meldung.
- Heute (Europe/Berlin) und die Config von `SEASON_AUTOPILOT`, `INSPECTION_INTERVAL`, `PHENOLOGY`.
Keine Eingabe aus Wetter-Prognosen, keine Waage, kein ML.
### 8.2 Regeln
Ausgabe jeder Regel ist ein Kandidat für einen Vorschlag mit `source = AUTOPILOT`, `ruleKey = SEASON_AUTOPILOT` (A1: `INSPECTION_INTERVAL`).
| Nr | Absicht | Wann (alle Bedingungen) | Schritt, Art | Begründung (Beispiel) | Anker | Quelle |
|---|---|---|---|---|---|---|
| A1 | `INSPECT` | Abschnitt ≠ `WINTERRUHE`; `inspectionDueOn` ≤ heute + 2 | „Durchsicht“, DO | „Letzte Durchsicht vor 8 Tagen. In der Schwarmzeit alle 7 Tage.“ | ID der letzten Durchsicht oder `none` | 7.2 |
| A2 | `VARROA_MEASURE` | Abschnitt ≠ `WINTERRUHE` und letzte Messung älter als 28 Tage; oder `WINTERRUHE`, 15.11.–20.12., keine Messung seit 01.11. | „Varroa messen“, DO | „Letzte Messung vor 35 Tagen.“ / „Vor der Winterbehandlung den Milbenfall messen.“ | Mess-ID bzw. `winter-<Jahr>` | 28 Tage aus dem Rückgrat; Winterfenster = Annahme nach LWG-Konzept (Restentmilbung im brutfreien Volk) |
| A3 | `VARROA_RECHECK` | Behandlungsende vor ≥ 14 Tagen, keine Messung seither | „Behandlungserfolg messen“, DO | „Behandlung mit Oxuvar endete am 02.08. Nachkontrolle ab 16.08.“ | Behandlungs-ID | Fahrplan §10 (LWG) |
| A4 | `SUMMER_TREATMENT` | Abschnitt `SPAETSOMMER`; keine Behandlung seit Abschnittsbeginn | „Sommerbehandlung planen“, PLAN | „Tracht vorbei (gemeldet am 25.07.). Letzte Ernte am 20.07.“ | Jahr | LWG-Varroakonzept: Behandlung nach der letzten Ernte |
| A5 | `FEED` | Abschnitt `SPAETSOMMER` oder `HERBST`; keine Fütterung seit Beginn `SPAETSOMMER` | „Einfüttern“, DO | „Tracht vorbei. Noch keine Fütterung erfasst.“ | Jahr | Imkerpraxis; bewusst ohne Zielmenge |
| A6 | `FEED` | Abschnitt ≠ `WINTERRUHE`; Futter der letzten Durchsicht `WEAK`/`CRITICAL` oder Volkskarte `CRITICAL`; keine Fütterung seither | „Füttern“, DO | „Futter bei der Durchsicht am 03.04.: niedrig.“ | Durchsicht-ID | Lastenheft §23 |
| A7 | `HONEY_SUPER` | Phase ≥ `KIRSCHE` und < `LINDE`; `colonyStrength` der letzten Durchsicht ≥ 8 | „Honigraum prüfen“, DO | „Kirsche blüht (geschätzt). B3 besetzt 9 Waben.“ | Jahr | Imkerpraxis; 8 Waben = Annahme (`honeySuperMinFrames`) |
| A8 | `QUEEN_REPLACE` | Abschnitt `SCHWARMZEIT` oder `SOMMER`; `hatchYear` ≤ Jahr − 2 | „Umweiseln planen“, PLAN | „Königin Q-12 ist von 2024.“ | Königin-ID und Jahr | Imkerpraxis; 2 Jahre = Annahme (`queenMaxAgeYears`) |
| A9 | `WINTER_CHECK` | Abschnitt `HERBST`; keine Einwinterung für den Winter dieses Jahres | „Einwinterung erfassen“, DO | „Für den Winter 2026/27 fehlt die Einwinterung.“ | `<winterYear>-AUTUMN` | Annahme 16 |
| A10 | `WINTER_CHECK` | Abschnitt `FRUEHJAHR`; Einwinterung des Vorwinters da, Auswinterung fehlt | „Auswinterung erfassen“, DO | „Salweide blüht. Auswinterung 2025/26 fehlt.“ | `<winterYear>-SPRING` | Annahme 16 |
- `VARROA_TREAT` entsteht nur aus der Varroa-Regel (7.1), nicht aus dem Autopilot.
- Fälligkeit: A1 `inspectionDueOn`; A2/A3 der Tag, ab dem die Bedingung gilt; sonst heute.
- Priorität: A1 überfällig `HIGH`, sonst `NORMAL`; A4 `HIGH`; übrige `NORMAL`.
- Ablauf: `expiresAt` = Fälligkeit + 30 Tage (`suggestionTtlDays`, Annahme für eine kurze Liste).
- Config `SEASON_AUTOPILOT`: `{ honeySuperMinFrames: 8, varroaMeasureIntervalDays: 28, winterVarroaWindow: { from: '11-15', to: '12-20' }, queenMaxAgeYears: 2, suggestionTtlDays: 30, maxStepsPerColony: 3, rules: { A1: true, …, A10: true } }`. Jede Regel einzeln abschaltbar.
### 8.3 Abgleich (`AutopilotService.syncColonyIn(tx, colonyId, today)`)
1. Volkszeile sperren. Volk beendet → Ende.
2. Eingaben über `tx` laden (eine feste Folge von Abfragen, keine N+1).
3. `evaluateAutopilot(input, config, today)` ist eine reine Funktion. Sie liefert Kandidaten `{ intent, mode, reasonCode, evidence, dueDate, priority, anchor }`.
4. Kandidat verwerfen, wenn für das Volk schon eine offene Aufgabe oder ein offener Vorschlag derselben Absicht besteht.
5. Neue Kandidaten anlegen (`skipDuplicates` über `dedupeKey`). Offene Autopilot-Vorschläge mit gleichem Schlüssel bekommen aktuelle Belege, Fälligkeit und Priorität.
6. Offene Autopilot-Vorschläge des Volks, deren Schlüssel nicht mehr vorkommt → `RESOLVED` („Bedingung entfällt“). Beispiel: Die Durchsicht ist erfasst, also verschwindet „Durchsicht fällig“.
7. Regel- und Kettenvorschläge fasst der Abgleich nicht an.
8. Ist `SEASON_AUTOPILOT` aus, werden alle offenen Autopilot-Vorschläge `RESOLVED` („Autopilot aus“).
### 8.4 Rangfolge (deterministisch)
Schritte eines Volks = offene Aufgaben (`OPEN`, `IN_PROGRESS`) + offene Vorschläge, nach Leserechten gefiltert. Sortiert nach diesem Tupel:
1. Dringlichkeit: `IN_PROGRESS` oder überfällig oder heute = 0; in ≤ 3 Tagen = 1; in ≤ 14 Tagen = 2; später = 3; ohne Datum = 4. Ohne Datum, aber `URGENT` = 0.
2. Priorität: `URGENT`, `HIGH`, `NORMAL`, `LOW`.
3. Absicht (feste Tabelle): `VARROA_TREAT`, `QUEEN_CHECK`, `QUEEN_ACCEPTANCE`, `SWARM_CHECK`, `SPLIT_PLAN`, `FEED`, `INSPECT`, `VARROA_RECHECK`, `HEALTH_CHECK`, `SUMMER_TREATMENT`, `VARROA_MEASURE`, `WINTER_CHECK`, `HONEY_SUPER`, `QUEEN_REPLACE`, `FOLLOW_UP`, `CLEANUP`, `STOCK_REORDER`. Eine Aufgabe ohne Vorschlag zählt wie `FOLLOW_UP`.
4. Fälligkeit aufsteigend, dann `createdAt`, dann `id`.
**Zusammenlegen:** Schritte, die dasselbe Formular öffnen (z. B. `INSPECT` und `SWARM_CHECK` → Durchsicht), werden ein Schritt. Er erbt den dringlichsten Rang und zeigt höchstens zwei Gründe: „Durchsicht mit Schwarmkontrolle. Weiselzellen am 05.05. gesehen. In der Schwarmzeit alle 7 Tage.“
### 8.5 Ausgabe „die nächsten 3 Schritte“
```ts
interface NextStep {
key: string; // 'task:<id>' | 'suggestion:<id>'
kind: 'TASK' | 'SUGGESTION';
intent: SuggestionIntent | null;
title: string; // „Durchsicht mit Schwarmkontrolle“
reasons: string[]; // höchstens 2 kurze Sätze
dueOn: string | null; // YYYY-MM-DD
urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE';
action:
| { type: 'CAPTURE'; form: 'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check'; href: string }
| { type: 'ACCEPT'; suggestionId: string }
| { type: 'CLEANUP'; suggestionId: string }
| { type: 'OPEN_TASK'; taskId: string; href: string };
dismissible: boolean;
}
interface ColonyNextSteps {
colonyId: string; segment: SeasonSegment; phase: PhenologyPhase | null; phaseSource: PhaseSource | null;
inspectionDueOn: string | null; withdrawalUntil: string | null; steps: NextStep[]; // höchstens 3
}
```
Beispiel für B3 am 12.05.: (1) „Durchsicht mit Schwarmkontrolle“ – überfällig seit 1 Tag, „Weiselzellen am 05.05. gesehen.“ – Erfassen; (2) „Behandlung prüfen“ – „12,4 Milben pro Tag am 10.05. Grenze: 10 (LWG).“ – Übernehmen; (3) „Honigraum prüfen“ – „Kirsche blüht (gemeldet am 18.04.). B3 besetzt 9 Waben.“ – Erfassen.
`withdrawalUntil` erscheint als Hinweis „Wartezeit bis 20.08.“, nicht als Schritt.
### 8.6 Was der Autopilot nicht tut
- Er legt keine Aufgaben an. Nur „Übernehmen“ tut das.
- Er ändert keine Fachdaten. Er schreibt nur Vorschläge.
- Er rät keine Mengen (Futter, Ernte) und keine Diagnosen.
- Er nutzt keine Prognosen, nur Erfasstes, Gemeldetes und gemessenes Wetter.
## 9. Vorschläge
### 9.1 Lebenszyklus
| von | nach | Auslöser |
|---|---|---|
| – | `OPEN` | Handler oder Job (`proposeIn`) |
| `OPEN` | `ACCEPTED` | „Übernehmen“ (PLAN); legt Aufgabe(n) über `TasksService.createInTx` an |
| `OPEN` | `DISMISSED` | „Verwerfen“ |
| `DISMISSED` | `OPEN` | „Rückgängig“ oder „Wieder öffnen“ (bis 30 Tage) |
| `OPEN` | `RESOLVED` | Abgleich: Bedingung entfällt (DO); Aufräumen ausgeführt (CLEANUP) |
| `OPEN` | `EXPIRED` | Job, `expiresAt` überschritten |
| `OPEN` | `OBSOLETE` | Volk beendet |
- Jeder Übergang ist ein Compare-and-Set (`UPDATE … WHERE status = 'OPEN'`), mit Audit-Eintrag (`entityType: 'Suggestion'`).
- Ein DO-Vorschlag hat im Menü „Als Aufgabe planen“. Das ist „Übernehmen“ mit Datum.
### 9.2 Dubletten und Anker
- Je Regel und Objekt gibt es höchstens eine offene Sache: Besteht schon ein offener Vorschlag oder eine offene Aufgabe, entsteht kein neuer Vorschlag. Das hält Annahme 12. Die Prüfung läuft unter der Zeilensperre der Regel wie in `triggerActionIn` und zusätzlich über `dedupeKey`.
- Ein verworfener Vorschlag kommt für denselben Anker nie wieder. Ein neuer Anker (neue Messung, neue Durchsicht, neues Jahr) ergibt einen neuen Vorschlag.
- Regel-Vorschläge: Anker = auslösender Datensatz. Zeitbasierte: Anker = Tag oder Jahr.
### 9.3 Texte
- `src/suggestions/suggestion-texts.ts`: je `reasonCode` eine Vorlage `(evidence, locale de-DE) → { title, reasons }`. Zahlen und Daten deutsch formatiert.
- Höchstens zwei Sätze, je Satz höchstens 90 Zeichen. Ein Wächter prüft jede Vorlage mit Beispielbelegen.
- `actionTemplate` einer Regel überschreibt nur den Titel, wie heute.
### 9.4 Meldungen zu Vorschlägen
- Nur Regeln mit `config.notify = true` schreiben zusätzlich eine Meldung (WARNUNG, bei `URGENT` KRITISCH) an Inhaber von `tasks:read` mit dem Leserecht des Vorschlags.
- Alle anderen Vorschläge stehen nur in der Liste. Weniger Lärm.
### 9.5 Bestehende Automatik-Aufgaben
- Offene Aufgaben mit `sourceAutomationTrigger` bleiben Aufgaben. Sie gelten als übernommen.
- Das Abzeichen `TaskSourceBadge` heißt künftig „Aus Vorschlag“ (statt „Vorschlag“). Echte Vorschläge sind keine Aufgaben mehr.
## 9a. Erinnerungen und Telegram (Inhaber-Antwort 3)
### 9a.1 Überblick
```
daily-evaluation ──TaskDue──▶ Handler notifications.task-due ──┐
daily-evaluation Schritt 5 (digest) ───────────────────────────┤
alle anderen Meldungen (recordForPermission) ──────────────────┤
▼
Notification (je Nutzer) + NotificationDelivery (TELEGRAM, PENDING)
│ gleiche Transaktion, pg_notify('notification_delivery','')
▼
Worker-Schleife telegram-send ── sendMessage ──▶ Telegram Bot API
Worker-Schleife telegram-updates ◀── getUpdates (Long Polling) ── /start <token>, /stop, /hilfe
```
- Handler und Jobs schreiben nur Zeilen. Netz gibt es nur in den zwei Worker-Schleifen (Rückgrat-Handler-Vertrag).
- `OutboxTable` bekommt `'NotificationDelivery'`. Claim, Abschluss, Freigabe und Wiederholung kommen unverändert aus `outbox-claims.ts` und `retry-policy.ts`. `WAKE_CHANNELS` bekommt `notificationDeliveries: 'notification_delivery'`.
- Ohne `TELEGRAM_BOT_TOKEN` laufen beide Schleifen nicht. Meldungen in der App gibt es trotzdem.
### 9a.2 Meldung je fälliger Aufgabe
- Auslöser: `TaskDue` (Rückgrat, einmal am Fälligkeitstag, mit Nachholfenster).
- Empfänger: der Zuständige (`assignedUserId`); ohne Zuständigen alle aktiven Nutzer mit `tasks:complete`. Nur, wer `taskDueEnabled` hat und `tasks:read` hält.
- Text: „Heute fällig: {Titel}“, Link `/tasks`. Kategorie `TASK_DUE`, `requiredPermissions: ['tasks:read']`.
- Telegram-Zeitpunkt: frühestens eine Minute nach der Morgenzeit des Empfängers, also nach der Übersicht.
- Erledigte oder abgebrochene Aufgabe vor dem Versand: Der Sender überspringt sie (`SKIPPED`, „Aufgabe schon erledigt“).
### 9a.3 Morgenübersicht
- Job-Schritt 5, je aktivem Nutzer mit `digestEnabled`. Schlüssel `digest:<YYYY-MM-DD>:<userId>`, also höchstens eine je Tag.
- Inhalt, jede Zeile nur mit dem nötigen Recht:
| Zeile | Beispiel | Recht |
|---|---|---|
| Aufgaben | „2 Aufgaben fällig, 1 davon überfällig.“ | `tasks:read` |
| Durchsicht | „Durchsicht fällig: 4 Völker am Stand Wald.“ | `colonies:read` und `inspections:read` |
| Vorschläge | „3 Vorschläge warten.“ (gezählt nach `requiredPermission`) | `tasks:read` |
| Saison | „Stand Wald: Schwarmzeit beginnt. Durchsicht alle 7 Tage.“ | `apiaries:read` |
- Erste Zeile „Guten Morgen. Heute für dich:“, Link `/dashboard`. `requiredPermissions` = alle Rechte, die eine Zeile genutzt hat.
- Ist keine Zeile zu füllen, entsteht keine Übersicht (weniger Lärm; Annahme).
- `digestTime` liegt zwischen 05:00 und 12:00. Der Job läuft um 04:30, die Übersicht ist also immer vorher fertig. Die Telegram-Zustellung bekommt `availableAt` = heute, `digestTime`, Europe/Berlin.
- Inhalt mit Stand von 04:30. Später angelegte Aufgaben kommen erst am nächsten Tag in die Übersicht; die Meldung je Aufgabe kommt trotzdem.
### 9a.4 Zustellung anlegen
In derselben Transaktion wie die `Notification` (`recordForUsers`/`recordForPermission`):
1. Telegram-Zustellung nur, wenn `telegramEnabled`, ein `TelegramLink` besteht und die Kategorie in `telegramCategories` steht. Standard: `TASK_DUE`, `DIGEST`; die übrigen schaltet der Nutzer zu.
2. `availableAt`: der spätere Wert aus „jetzt“, Kategorie-Zeit (9a.2, 9a.3) und Ende der Ruhezeit, falls „jetzt“ in der Ruhezeit liegt.
3. `pg_notify('notification_delivery', '')` nach dem Einfügen (wie Rückgrat).
Systemantworten des Bots (9a.5) umgehen die Ruhezeit, weil sie auf eine Handlung des Nutzers antworten.
### 9a.5 Verbinden und Trennen
1. **Profil > Benachrichtigungen > „Telegram verbinden“.** Erst ein Step-up wie beim Backup-Import: aktuelles Passwort, bei aktiver MFA dazu TOTP. Ein neuer Kanal nach außen ist eine sensible Änderung.
2. `POST /profile/telegram/link-token` erzeugt 32 Zufallsbytes, Base64url (43 Zeichen, erlaubt im `start`-Parameter). Gespeichert wird nur der SHA-256-Hash. Gültig 10 Minuten, nur einmal. Ein neues Token macht ältere offene Token des Nutzers ungültig. Antwort: `{ deepLink: 'https://t.me/<bot>?start=<token>', expiresAt }`. Drossel wie sensible Aktionen (10/min).
3. Die App zeigt einen großen Knopf „In Telegram öffnen“ und darunter „Gültig bis {Uhrzeit}“.
4. Telegram sendet `/start <token>`. Die Schleife `telegram-updates` verarbeitet jedes Update in einer eigenen Transaktion:
- Nur private Chats. Gruppen und Kanäle werden ignoriert.
- Token-Hash suchen; gültig heißt: nicht verbraucht, `expiresAt > now()` (DB-Uhr). Verbrauchen per Compare-and-Set.
- `TelegramLink` anlegen oder ersetzen. War der Chat mit einem anderen Nutzer verbunden, wird diese Verbindung gelöst; jener Nutzer bekommt eine Meldung in der App.
- `telegramEnabled = true`.
- Meldung in der App (`SYSTEM`): „Telegram verbunden mit „{Vorname}“ am {Datum}. Warst du das nicht? In den Einstellungen trennen.“ Das deckt einen mitgelesenen Link auf.
- Antwort `LINKED`: „Verbunden mit ApiCore. Hier kommen deine Erinnerungen. /stop beendet das.“
- Ungültiges, abgelaufenes oder benutztes Token: Antwort `LINK_INVALID`: „Der Link ist ungültig oder abgelaufen. Bitte in ApiCore neu verbinden.“ Kein Hinweis auf Konten.
- `updateOffset = update_id + 1` in derselben Transaktion. Jedes Update wirkt genau einmal.
5. **`/stop`:** Verbindung löschen, `telegramEnabled = false`, Antwort `STOPPED`: „Abgemeldet. Du bekommst keine Nachrichten mehr.“
6. **`/hilfe`, `/help` und jeder andere Text:** Antwort `HELP`: „Dieser Bot schickt Erinnerungen aus ApiCore. Verbinden geht nur in ApiCore unter Profil > Benachrichtigungen. /stop beendet.“ Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Empfänger; es gibt immer nur einen, siehe 9a.7).
7. **Trennen in der App:** `POST /profile/telegram/unlink` löscht die Verbindung und schickt `STOPPED` an den Chat.
### 9a.6 Versand (`telegram-send`)
- Neue Zeile in der Loop-Tabelle von `WorkerRuntime`: Takt 5 s, Weckkanal `notification_delivery`, Batch 25 per `claimBatch` (`FOR UPDATE SKIP LOCKED`), je Batch höchstens eine Zustellung je Nutzer bzw. Chat.
- **Vor jedem Senden neu prüfen**, sonst `SKIPPED` mit Grund:
- Nutzer aktiv, `telegramEnabled`, Verbindung besteht, Kategorie noch eingeschaltet;
- jedes Recht aus `requiredPermissions` noch vorhanden (`PermissionResolverService`);
- nicht veraltet: `TASK_DUE` und `DIGEST` nur am selben Tag (Europe/Berlin), alle anderen höchstens 24 h nach dem Anlegen.
- **Text:**
- Mit Details: `message` der Meldung, Leerzeile, `FRONTEND_ORIGIN + link` (Standard `/dashboard`).
- Ohne Details (`telegramDetail = false`): „Neue Meldung in ApiCore.“ plus Link.
- Klartext ohne `parse_mode`, also keine Formatierungs-Injektion über Aufgabentitel. Steuerzeichen entfernt, höchstens 600 Zeichen, Link-Vorschau aus.
- Nie Koordinaten, Adressen oder Notizen. Was in der Meldung steht, durfte der Nutzer schon beim Schreiben sehen und darf es beim Senden noch.
- **Grenzen der Bot API** (laut Telegram etwa 1 Nachricht je Sekunde und Chat, etwa 30 je Sekunde insgesamt): Der Sender hält höchstens 25 Nachrichten je Sekunde und je Chat mindestens 1,1 s Abstand.
- **Antworten:**
| Antwort | Wirkung |
|---|---|
| 200 | `DELIVERED`, `deliveredAt` |
| 429 mit `retry_after` | `availableAt = now() + retry_after`; zählt nicht als Fehlversuch |
| 403 (Bot blockiert) oder 400 „chat not found“ | Verbindung löschen, `telegramEnabled = false`, `SKIPPED`; Meldung in der App: „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“ |
| 401 (Token ungültig) | Zustellung bleibt `PENDING` mit Wartezeit; Status unter „Verwaltung > System“; Meldung an `system:manage` einmal je Tag |
| 5xx, Timeout (10 s), Netzfehler | Wiederholung nach `retry-policy.ts`, nach 8 Versuchen `FAILED` |
### 9a.7 Empfang: Long Polling statt Webhook
Gewählt: **Long Polling im Worker** (`getUpdates`, `timeout=25`, `allowed_updates=["message"]`).
| | Long Polling (gewählt) | Webhook |
|---|---|---|
| Öffentlicher Eingang | keiner; nur ausgehend 443, das der Worker schon darf | neue öffentliche Route über Cloudflare und Traefik, Prüfung von `secret_token`, eigene Drossel |
| Angriffsfläche | keine neue | ein unauthentifizierter POST-Endpunkt im Internet |
| NetworkPolicy, Ingress | unverändert | Änderung an Ingress und Middleware |
| Kosten | eine gehaltene HTTPS-Verbindung, rund 3 500 kurze Anfragen am Tag | nur bei Nachrichten |
| Bedingung | genau ein Empfänger je Bot, kein Webhook gesetzt | – |
- **Genau ein Empfänger:** Lease in `TelegramPollState`, nur mit DB-Uhr: `UPDATE … SET leaseOwner = $me, leaseUntil = now() + interval '60 seconds' WHERE id = 'singleton' AND (leaseUntil IS NULL OR leaseUntil < now() OR leaseOwner = $me) RETURNING updateOffset`. Jede Runde verlängert. Wer die Lease nicht hat, wartet 30 s.
- 409 von Telegram („other getUpdates request“ oder „webhook is active“): `lastError` setzen, 30 s warten, Hinweis unter „Verwaltung > System“.
- Die Schleife hört auf das Stop-Signal (`AbortController` bricht den laufenden Abruf ab).
- Eine Störung des Empfangs macht den Worker nicht „ungesund“; sie steht nur unter „Verwaltung > System“.
### 9a.8 Einstellungen (Profil > Benachrichtigungen)
- Schalter „Erinnerung je Aufgabe“, „Morgenübersicht“ mit Uhrzeit (Standard 06:30), „Ruhezeit“ von–bis (Standard 21:00–06:30).
- Bereich Telegram: Status („Verbunden mit „{Vorname}“ seit {Datum}“ oder „Nicht verbunden“), Hauptknopf „Telegram verbinden“ bzw. „Trennen“, Auswahl der Kategorien, Schalter „Mit Details“.
- Ohne eingerichteten Bot: „Telegram ist nicht eingerichtet.“, kein Knopf.
- Alle Felder bearbeitbar, Null-Regel: `quietFrom: null` und `quietUntil: null` heißen „keine Ruhezeit“ (nur beide zusammen).
### 9a.9 Verwaltung > System
Neue Karte „Telegram“ auf der Seite aus Rückgrat Task 25, Daten aus der System-API (Rückgrat Task 24):
- Bot eingerichtet ja/nein, Bot-Name.
- Empfang: Inhaber der Lease, Alter des letzten Abrufs, letzter Fehler.
- Versand: offene Zustellungen, Alter der ältesten (Warnung ab 10 Minuten), `FAILED` der letzten 24 h, letzte Fehler (Code und Beschreibung, nie Token oder Chat-ID).
- „Erneut versuchen“ für `FAILED` (einzeln und alle), Recht `system:manage`.
### 9a.10 Konfiguration und Geheimnisse
| Variable | Ort | Pod | Zweck |
|---|---|---|---|
| `TELEGRAM_BOT_TOKEN` | eigenes Secret `apicore-telegram`, `optional: true` | nur Worker | Bot-Token; der Inhaber legt den Bot bei BotFather an und das Secret selbst |
| `TELEGRAM_BOT_USERNAME` | ConfigMap | api, Worker | Name für den Deep Link |
| `FRONTEND_ORIGIN` | vorhanden | Worker | Links in Nachrichten |
- Der API-Pod bekommt das Token nicht. Er braucht es für den Deep Link nicht.
- Startprüfung, wenn gesetzt: Format `^\d{5,}:[A-Za-z0-9_-]{30,}$`, in Produktion kein Platzhalterwert (wie Annahme zum Schlüsselbund). Sonst startet der Worker nicht.
- Der Bot-Client baut die URL intern. Fehler sind `TelegramApiError { method, statusCode, description }` ohne URL. Ein Test stellt sicher, dass das Token in keinem Log und keinem `lastError` landet.
- Bei BotFather: Gruppen nicht erlauben (`/setjoingroups` aus), kein Webhook setzen.
- Datenschutz: Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt. Darum ist die Verbindung freiwillig, je Nutzer, mit Schalter „Mit Details“.
## 10. API
Alle Routen unter `/api/v1`, alle mit `@RequirePermission` (Abdeckung: `test/authorization-coverage.e2e-spec.ts`). Anlegen und Entscheiden mit `@Idempotent()`.
### 10.1 Neue Endpunkte
| Methode und Pfad | Recht | Zweck |
|---|---|---|
| `GET /suggestions?status=&colonyId=&apiaryId=` | `tasks:read` | Liste mit gerenderten Texten, gefiltert nach `requiredPermission`; Standard `OPEN` |
| `GET /suggestions/summary` | `tasks:read` | Anzahl offen je Priorität (Badge) |
| `POST /suggestions/:id/accept` | `tasks:create` | PLAN → Aufgabe(n). Body `{ dueDate?: string \| null; assignedUserId?: string \| null }`. 409 wenn nicht `OPEN` |
| `POST /suggestions/bulk-accept` | `tasks:create` | `{ ids: string[] }` (1–50), alles oder nichts |
| `POST /suggestions/:id/dismiss` | `tasks:update` | `{ note?: string }` (≤ 500) |
| `POST /suggestions/bulk-dismiss` | `tasks:update` | `{ ids: string[] }` (1–50) |
| `POST /suggestions/:id/reopen` | `tasks:update` | `DISMISSED` → `OPEN`, höchstens 30 Tage nach der Entscheidung |
| `POST /suggestions/:id/apply-cleanup` | `tasks:update`; zusätzlich `tasks:complete` für „Erledigt“, `queens:update` für die Königin | `{ tasks: { id; outcome: 'CANCEL' \| 'DONE' \| 'KEEP' }[]; queen?: { id; status: QueenStatus } \| null }`; nur IDs aus den Belegen |
| `GET /colonies/:id/next-steps` | `colonies:read` | `ColonyNextSteps` (8.5) |
| `GET /autopilot/today` | `colonies:read` | nächster Schritt über alle Völker, fällige Durchsichten je Stand, Anzahl Vorschläge, Abschnitt je Stand; Aufgaben nur mit `tasks:read` |
| `GET /phenology/state` | `apiaries:read` | je Stand Phase, Abschnitt, Quelle, GTS, Datenstand |
| `GET /phenology/reports?year=` | `phenology:read` | eigene Meldungen |
| `POST /phenology/reports` | `phenology:create` | `{ phase; date; apiaryId?: string \| null; notes? }`; Datum nicht in der Zukunft |
| `PATCH /phenology/reports/:id` | `phenology:update` | Null-Regel: `apiaryId: null` = alle Stände, `notes: null` leert; `phase`/`date` nicht leerbar |
| `POST /phenology/reports/:id/revoke` | `phenology:update` | zurückziehen statt löschen |
| `GET /varroa-measurements/thresholds?colonyId=&date=` | `varroa:read` oder `varroa:create` | Bänder je Methode und `postTreatmentUntil`; ersetzt `/varroa-measurements/threshold` (T3) |
| `GET /extractions/prefill?harvestIds=` | `extractions:create` | Σ Netto, Sorte, Quellen, Warnungen |
| `GET /honey-batches/prefill?extractionId=` | `honey-batches:create` | Restmenge, Sorte, Wassergehalt, Warnungen |
| `GET /honey-batches/:id/movements` | `honey-batches:read` | Bewegungsbuch |
| `POST /honey-batches/:id/movements` | `honey-batches:update` | `{ type: 'WITHDRAWAL' \| 'CORRECTION'; deltaKg; reason?; notes? }` |
| `GET /bottling/prefill?honeyBatchId=&productId=&date=` | `bottling:create` | Glasgröße, MHD, Wassergehalt, Warnungen |
| `POST /notifications/read-all` | angemeldet | eigene Meldungen gelesen |
| `GET /profile/notification-settings` | angemeldet (nur eigene; nie API-Schlüssel) | Einstellungen aus 9a.8 und Telegram-Status |
| `PATCH /profile/notification-settings` | angemeldet | Schalter, Zeiten (`HH:MM`, Morgenzeit 05:00–12:00), Kategorien, Details |
| `POST /profile/telegram/link-token` | angemeldet + Step-up (Passwort, ggf. TOTP), Drossel 10/min | Deep Link, 10 min gültig (9a.5) |
| `POST /profile/telegram/unlink` | angemeldet | Verbindung lösen |
| `GET /system/notification-deliveries?status=` | `system:manage` | Warteschlange, Fehler, Empfangsstatus (9a.9) |
| `POST /system/notification-deliveries/:id/retry`, `POST /system/notification-deliveries/retry-failed` | `system:manage` | `FAILED` → `PENDING` |
### 10.2 Geänderte Endpunkte
| Endpunkt | Änderung |
|---|---|
| `POST /inspections` | `applyToColony`; Antwort + `colonyTakeover` (7.9); `feedStatus` ist `ColonyCondition` |
| `POST/PATCH /harvests`, `/extractions`, `/honey-batches`, `/bottling` | Antwort + `warnings` |
| `POST /bottling` | `acknowledgedWarnings`; `jarSize`/`bestBeforeDate` optional mit Artikel; 409 `CONFIRMATION_REQUIRED` |
| `PATCH /honey-batches/:id` | `amount` bucht `CORRECTION`; `null` bei Buchungen → 400 |
| `POST /colonies/:id/split` | `date?`, `queenSource?` |
| `POST /varroa-measurements` (und `/bulk`, T3) | `sampleBees`; Prozent bei Puderzucker/Auswaschung |
| `GET /colonies/:id/varroa-measurements` | je Messung `rating: { light; reasonCode; band }` |
| `GET /apiaries/:id/colony-overview` (T3) | + `inspectionDueOn`, `segment`, `nextStepTitle`; `openWarnings`/`openCritical` aus Vorschlägen |
| `GET /apiaries/:id/offline-pack` (T3) | + `nextSteps` je Volk (optional; Version bleibt 1) |
| `GET /calendar` | Typ `WITHDRAWAL` mit `endDate` |
| `GET /notifications`, `POST /notifications/:id/read` | nur eigene; Antwort + `category`, `link` |
| `POST/PATCH /colonies`, `GET /colonies/:id` | + `occupiedFrames` (0–40); `strength` bleibt als veralteter Altwert (4.14) |
| `POST/PATCH /inspections` | `colonyStrength` höchstens 40 |
| `PATCH /automation/rules/:id` | Config je Regel validiert |
| `GET /dashboard/alerts` | `OLD_QUEEN` mit `queenMaxAgeYears` |
### 10.3 Warnungen (ein Format)
```ts
interface PlausibilityWarning {
code: 'WITHDRAWAL_ACTIVE' | 'EXTRACTION_EXCEEDS_HARVEST' | 'HARVEST_NET_MISSING' | 'BATCHES_EXCEED_EXTRACTION'
| 'WATER_CONTENT_HIGH' | 'WATER_CONTENT_ABOVE_LEGAL' | 'WATER_CONTENT_MISSING' | 'JAR_SIZE_DIFFERS';
severity: 'HINWEIS' | 'WARNUNG' | 'BESTAETIGEN';
message: string; // deutsch, serverseitig gerendert
refs: { type: string; id: string }[]; // nur IDs
}
```
Ein Ort: `src/common/warnings/`. Prefill-Endpunkte liefern dieselben Warnungen schon vor dem Speichern.
## 11. Oberfläche
### 11.1 Grundsätze
- Ein Hauptknopf je Ansicht. Alles Weitere ist sekundär oder im Menü.
- Begründungen in höchstens zwei kurzen Sätzen. Zustand nie nur über Farbe: Icon plus Wort.
- Vorbelegt wird nur, was keine Beobachtung ist (Glasgröße, MHD aus dem Artikel). Messwerte kommen als Hinweis mit „übernehmen“.
- Vor Next.js-Code `node_modules/next/dist/docs/` lesen (`ApiCore-Frontend/AGENTS.md`).
### 11.2 Seiten
| Seite | Änderung | Hauptaktion |
|---|---|---|
| Start/Heute (`/dashboard`) | „Nächster Schritt“ aus `GET /autopilot/today` (offline wie bisher aus Aufgaben). Karte „Durchsicht fällig“ je Stand mit Völkerliste. Chips „Vorschläge {n}“ und Saison je Stand („Schwarmzeit · alle 7 Tage“). | der eine nächste Schritt |
| Vorschläge (`/suggestions`, neu) | Liste nach Stand gruppiert (11.3) | je Karte „Übernehmen“ oder „Erfassen“ |
| Volk (`/colonies/[id]`) | Karte „Als Nächstes“ oben (3 Schritte). Varroa-Liste mit Jahreszeit-Ampel. Aktion „Ableger bilden“ mit Datum und Herkunft der Königin. | erster Schritt |
| Stand (`/apiaries/[id]`, T3) | Kopf mit Saison und Quelle, Knopf „Pflanze blüht“ (Sheet). Zeilen zeigen „Durchsicht fällig in 2 Tagen“ bzw. „seit 3 Tagen“ und den nächsten Schritt. | „Runde starten“ (T3) |
| Durchsicht neu (T3) | Abschnitt „Volkskarte“ über „Speichern“ (7.9) | „Speichern“ |
| Varroa neu (T3) | Ampel aus Bändern; Feld „Probe (Bienen)“ bei Puderzucker/Auswaschung mit Chip „500 Bienen“; Hinweis „Behandlungseffekt“ | „Speichern“ |
| Schleuderung, Honigcharge | Hinweise „Aus 2 Ernten: 42,5 kg · übernehmen“; Warnliste über „Speichern“ | „Speichern“ |
| Honigcharge Detail | Karte „Bewegungen“ (Muster wie `components/stock/stock-card`); „Menge korrigieren“ bucht | – |
| Abfüllung neu | Glasgröße und MHD aus dem Artikel mit Herkunft; Bestätigungsdialog bei über 20 % | „Abfüllen“ |
| Kalender | Wartezeiten als Spanne | – |
| Verwaltung > Automatisierung | Formulare für die neuen Regeln und Werte; Quelle je Wert sichtbar | „Speichern“ |
| Meldungen (Glocke) | „Alle gelesen“; Meldungen mit `link` öffnen das Ziel | – |
| Volk bearbeiten | Feld „Besetzte Waben“ (`NumberStepper`, 0–40); „Volksstärke (alt, Skala 1–10)“ nur, wenn gespeichert | „Speichern“ |
| Profil > Benachrichtigungen (neu) | Schalter, Uhrzeiten, Telegram-Bereich (9a.8) | „Telegram verbinden“ bzw. „Speichern“ |
| Verwaltung > System | Karte „Telegram“ (9a.9) | – |
Die Karte „Durchsicht fällig“ startet mit „Runde starten“ eine T3-Runde nur mit den fälligen Völkern dieses Stands (`startRound` mit gefilterter Liste, Reihenfolge nach Code).
### 11.3 Vorschlagsliste
- **Kopf:** „{n} Vorschläge“ und ein Satz: „Übernehmen plant eine Aufgabe. Verwerfen blendet den Vorschlag aus.“
- **Gruppen:** je Stand, dann „Betrieb“ (Lager, Rohstoffe). In der Gruppe nach Rang (8.4).
- **Zusammengefasst:** Gleiche Absicht im selben Stand wird eine Zeile:
- `INSPECT`: „Durchsicht fällig · 6 Völker“ mit „Runde starten“.
- PLAN-Absichten: „Honigraum prüfen · 3 Völker“ mit „Alle übernehmen“ (`bulk-accept`). Aufklappen zeigt die einzelnen Völker.
- **Karte** (`SuggestionCard`): Icon der Absicht, Titel, Begründung, Fälligkeit (`DueChip`), Hauptknopf („Übernehmen“, „Erfassen“ oder „Aufräumen“), daneben „Verwerfen“ als Text-Knopf. Menü: „Als Aufgabe planen“ (DO), „Termin ändern“ (PLAN; kleines Datums-Sheet, dann Übernehmen).
- **Nach dem Übernehmen:** Toast „Aufgabe angelegt · Ansehen“.
- **Nach dem Verwerfen:** Toast „Verworfen · Rückgängig“ (8 s). Unten der Link „Verworfene (30 Tage)“ mit „Wieder öffnen“. Damit hängt nichts an der Toast-Zeit (WCAG 2.2.1).
- **Leer:** „Keine offenen Vorschläge.“
- **Navigation:** „Vorschläge“ in `nav-config.ts`, Gruppe „Arbeit“, mit Zähler, Recht `tasks:read`.
### 11.4 Bausteine
- **Wiederverwendet:** `Sheet`, `ChoiceGroup`, `ToggleChip`, `ConfirmDialog`, `DueChip`, `TaskSourceBadge`, `StatusLightBadge`, `EmptyState`, `QueryState`, `PageHeader`, `FormSection`, `StickySubmit`, `NumberStepper`, `PreviousValueHint` (Präfix-Prop), `RecordSelect`, `EnumSelect`, `useCaptureMutation`, `emptyToNull`, `toApiDate`, Runden-Funktionen aus T3.
- **Neu:** `SuggestionCard`, `SuggestionGroup`, `NextStepsCard`, `SeasonChip`, `PhenologyReportSheet`, `WarningList`, `ColonyTakeoverSection`, `HoneyBatchMovementsCard`, `NotificationSettingsCard`, `TelegramLinkCard` (Step-up über den vorhandenen Passwort-/TOTP-Dialog des Backup-Imports).
- **Neue Lib-Module:** `lib/suggestions`, `lib/autopilot`, `lib/phenology`, `lib/warnings`, `lib/notification-settings`, `lib/colonies/condition.ts` (`FEED_CONDITION_LABELS`, `ratingToCondition`).
- `PhenologyReportSheet`: Zeilen mit 56 px (`min-h-14`), je Phase Name und Pflanze. Vorgewählt ist „für alle Stände“. Ein Tipp meldet für heute. Offline über `useCaptureMutation` in die Warteschlange.
- Varroa-Diagramm (T3): Bänder als Stufenlinie je Monat. Beim Bau den `dataviz`-Skill laden.
### 11.5 Barrierefreiheit
- Touch-Ziele mindestens 44 px, Sheet-Zeilen 56 px.
- Warnungen und Live-Werte mit `role="status"` und `aria-live="polite"`; Bestätigung als Dialog mit Fokusfalle und Rückkehr des Fokus.
- Ampeln immer mit Icon und Wort („Gut“, „Achtung“, „Kritisch“, „Behandlungseffekt“).
- Zusammengefasste Gruppen sind Buttons mit `aria-expanded`.
- Kontrast über die `condition-*`-Tokens.
## 12. Sicherheit
- **Rechte:** Jede neue Route mit `@RequirePermission`. Vorschläge und Schritte werden je Eintrag nach `requiredPermission` gefiltert. Wer kein `varroa:read` hat, sieht weder „12,4 Milben pro Tag“ noch den Vorschlag. Gleiches gilt für Behandlungen (`treatments:read`) und Durchsichten (`inspections:read`).
- **Keine Rechteausweitung:** „Übernehmen“ verlangt `tasks:create`. Aufräumen prüft jedes Teilrecht einzeln. Die Volkskarte ändert sich nur mit `colonies:update`.
- **Benachrichtigungen** nur an Nutzer mit passendem Recht (Fan-out über `recordForPermission`). Kein Leck über Rundmeldungen mehr. Bewusste Abweichung von Rückgrat-Spec §4, siehe Entscheidungen.
- **Telegram:** Rechte beim Schreiben und beim Senden geprüft; Token nur im Worker, eigenes Secret, nie in Logs; Verbinden nur mit Step-up, Einmal-Token als Hash, 10 Minuten; Meldung in der App bei jeder neuen Verbindung; kein öffentlicher Eingang (Long Polling); Klartext ohne `parse_mode`; Schalter „Mit Details“. Einzelheiten 9a.5–9a.10.
- **Netz:** Nur Worker-Schleifen rufen Wetterdienste und Telegram auf. Feste Hosts aus der Konfiguration, numerische Koordinaten, also kein SSRF-Weg. Handler und Jobs bleiben ohne Netz (Wächter). NetworkPolicies unverändert.
- **Standort:** Koordinaten gehen auf 0,01° gerundet hinaus (Diebstahlschutz, wie Rückgrat-Spec §5.4). `GET /phenology/state` gibt keine Koordinaten aus.
- **Eingaben:** Config je Regel validiert; Ober- und Untergrenzen; Listen begrenzt (Bulk 50, `applyToColony` 3). Freitexte (`note`) mit Längengrenze.
- **Payloads und Logs:** Ereignisse und Belege tragen nur IDs, Enums, Zahlen, Daten. Fehler über `loggableError()`.
- **Integrität:** Entscheidungen per Compare-and-Set; Ledger per Zeilensperre; Audit-Einträge in derselben Transaktion.
- **Idempotenz:** alle neuen POSTs der Warteschlange mit `@Idempotent()`.
## 13. Fehlerfälle
| Fall | Verhalten |
|---|---|
| Worker fällt aus | Vorschläge und Schritte kommen verspätet. Die Übernahme in die Volkskarte läuft synchron und ist nicht betroffen. Der Job gleicht täglich alles nach. |
| Wetterdienst nicht erreichbar | Nächster Takt versucht es. Die Phase nutzt die letzte GTS mit „Stand: {Datum}“. Ist sie älter als 3 Tage, gilt der Kalender. |
| Stand ohne Koordinaten | Nur Meldung und Kalender; Hinweis in der Begründung. |
| Lückenhafte Wetterdaten (> 10 %) | GTS nicht benutzt, Hinweis „Wetterdaten lückenhaft“. |
| Zwei Handler für dasselbe Volk gleichzeitig | Zeilensperre auf dem Volk; der zweite sieht den Stand des ersten. |
| Doppeltes „Übernehmen“ (Doppeltipp, zwei Geräte) | Gleicher Idempotency-Key → gespeicherte Antwort. Anderer Key → 409 „Der Vorschlag ist nicht mehr offen.“ mit aktuellem Status. Genau eine Aufgabe. |
| Übernehmen nach Ende des Volks | Vorschlag ist `OBSOLETE` → 409. |
| Verworfen, Ursache besteht weiter | Kein neuer Vorschlag für denselben Anker. Neuer Anker → neuer Vorschlag. |
| Ungültige Regel-Config | `PATCH` → 400 mit Feld. Ein von Hand verdorbener Wert in der DB → Standardwert, Log-Warnung. |
| Offline-Durchsicht mit Volkskarte kommt spät | Neuere Durchsicht vorhanden → Feld übersprungen (`NEWER_INSPECTION`), Durchsicht gespeichert. |
| Volkskarte ohne `colonies:update` | Durchsicht 201, Felder übersprungen (`NO_PERMISSION`). |
| Futter-Freitext in alten Daten oder Backups | Abbildung nach 4.4, Rest in `notes`. Nichts geht verloren. |
| Zwei Abfüllungen gleichzeitig über den Rest | Ledger-Sperre: eine gelingt, die andere 400 mit Resttext. |
| Mengenkorrektur unter 0 | 400 „Die Menge kann nicht unter 0 sinken.“ |
| Wassergehalt über Grenze ohne Bestätigung | 409 `CONFIRMATION_REQUIRED`; Formular zeigt Dialog; bestätigte Wiederholung 201. Aus der Warteschlange: Eintrag bleibt mit Servertext, Tipp öffnet den Dialog. |
| Wartezeit-Konflikt auf drei Stufen | Warnung in jeder Antwort, Meldung nur einmal je (Ernte, Volk). |
| Meldung einer Phase in der Zukunft | 400 „Das Datum liegt in der Zukunft.“ |
| Falsch getippte Meldung | „Zurückziehen“; Zustand wird neu berechnet; Phase kann zurückgehen. |
| Phase rückwärts (zurückgezogene Meldung) | `SeasonPhaseChanged` mit Quelle; offene DO-Vorschläge passen sich beim Abgleich an. |
| Job scheitert in Schritt 3 | Schritte 1, 2, 4 und 5 laufen trotzdem. `JobRun` `FAILED` mit Zählern; Meldung an `automation:update` (einmal je Tag). Handler halten die betroffenen Völker aktuell. |
| Autopilot ausgeschaltet | Offene Autopilot-Vorschläge → `RESOLVED`; Regel- und Kettenvorschläge bleiben. |
| Alter Backup-Import | Abbildungen nach 4.13; Ledger-Invariante danach geprüft. Telegram-Verbindungen sind nicht im Backup; Nutzer verbinden neu. |
| Volk mit altem `strength`, ohne `occupiedFrames` | Anzeige „Volksstärke (alt): 7 von 10“; nichts wird umgerechnet. Die nächste Durchsicht mit Übernahme setzt `occupiedFrames`. |
| `colonyStrength` über 40 | 400 „Höchstens 40 besetzte Waben.“; die Prüfabfrage aus Migration 10 findet Altfälle vor dem Deploy. |
| Job läuft nicht (CronJob gescheitert) | Keine Morgenübersicht und keine Meldung je Aufgabe an diesem Tag. Die Meldung „Job fehlgeschlagen“ kommt in der App und, falls eingeschaltet, per Telegram (Kategorie `SYSTEM`). Am Folgetag holt das Nachholfenster `TaskDue` nach; veraltete Übersichten werden nicht nachgeschickt. |
| Telegram nicht erreichbar | Zustellungen bleiben `PENDING`, Wiederholung nach Rückgrat-Regel, nach 8 Versuchen `FAILED`. In der App ist alles da. |
| Telegram meldet 429 | Warten laut `retry_after`, kein Fehlversuch. |
| Nutzer blockiert den Bot | 403 → Verbindung gelöst, Zustellung `SKIPPED`, Hinweis in der App. |
| Bot-Token falsch oder widerrufen | 401 → Zustellungen warten; „Verwaltung > System“ zeigt „Bot-Token ungültig“; Meldung an `system:manage` einmal je Tag. |
| Zwei Worker | Versand über `SKIP LOCKED` ohne Dubletten; Empfang nur beim Inhaber der Lease. |
| Ein zweiter Prozess pollt denselben Bot (z. B. Testumgebung mit Produktions-Token) | 409 von Telegram, `lastError` sichtbar; Abhilfe: je Umgebung ein eigener Bot. |
| Deep Link mitgelesen, Fremder verbindet zuerst | Token einmalig und 10 Minuten gültig; der Nutzer sieht sofort „Telegram verbunden mit „{Vorname}““ in der App und trennt mit einem Tipp. |
| Rechte entzogen zwischen Schreiben und Senden | Prüfung beim Senden → `SKIPPED`. |
| Zustellung veraltet (Worker lange aus) | Übersicht und Aufgaben-Meldung nur am selben Tag, Rest höchstens 24 h; danach `SKIPPED`. |
| Morgenzeit vor 05:00 gewünscht | 400 „Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.“ |
## 14. Tests
TDD: zuerst der fehlschlagende Test. Testnamen englisch wie im Bestand.
### 14.1 Unit (Backend)
- `varroa-bands.spec.ts` als Tabelle: `10.0/Tag im August → yellow`, `10.1 → red`, `0.6 im November → red`, `Jungvolk 5.1 im Juni → red`, `Puderzucker 5.1 % im Juli → red`, `Puderzucker im April → no band`, `measurement 5 days after treatment end → no light, post-treatment`.
- `inspection-due.spec.ts`: Intervall je Abschnitt, `WINTERRUHE → null`, ohne Durchsicht ab `createdAt`, erste Durchsicht im Jahr.
- `gts.spec.ts`: Faktoren 0,5/0,75/1, negative Tage zählen 0, Lückenquote, 200 °C.
- `phenology-phase.spec.ts`: Maximum aus Meldung/GTS/Kalender, zurückgezogene Meldungen zählen nicht, „alle Stände“ gilt je Stand, Kalibrierung als Median der Vorjahre, ohne Schwelle Kalender.
- `season-segment.spec.ts`: Phase → Abschnitt inkl. eingestelltem Schwarmzeit-Beginn.
- `autopilot-rules.spec.ts`: je Regel A1–A10 eine Zeile „gegeben → erwartet“, inklusive Anker und Begründungscode.
- `next-step-ranking.spec.ts`: Tupel-Reihenfolge, `IN_PROGRESS` zuerst, Zusammenlegen gleicher Formulare, höchstens 3, stabile Reihenfolge über `id`.
- `suggestions.service.spec.ts`: `proposeIn` respektiert offene Aufgabe und verworfenen Anker; `accept` legt Aufgaben über `createInTx` an; CAS bei zweitem Übernehmen.
- `suggestion-texts.spec.ts`: jeder `reasonCode` rendert, höchstens 2 Sätze, deutsche Zahlen.
- `rule-config.spec.ts`: je Trigger Standard bei ungültigen Werten; Ablehnung beim Update.
- `colony-takeover.spec.ts`: Abbildung und alle Überspring-Gründe.
- `feed-vocabulary.spec.ts`: Tabelle aus 4.4.
- `honey-batch-ledger.spec.ts`: Buchen, zu wenig Rest, Grammrundung, Korrektur als Differenz, Null-Regeln.
- `water-content.spec.ts`: jüngste Messung gewinnt, Sorte, Bestätigungsstufe.
- `withdrawal-window.spec.ts`: ein Helfer; `getWithdrawalStatus` und `withdrawalEndDay` liefern dasselbe Fenster.
- `notifications.service.spec.ts`: `record` ohne `userId` kompiliert nicht; `recordForPermission` vergibt den Schlüssel je Empfänger; legt eine Telegram-Zustellung nur bei eingeschalteter Kategorie und bestehender Verbindung an; `availableAt` nach Ruhezeit und Kategorie-Zeit.
- `colony-lifecycle.spec.ts`: `releaseQueenIn` beendet Historie und `currentQueenId`; `update` auf `SOLD` und `DECEASED` ruft ihn auf.
- `occupied-frames.spec.ts`: 0–40, `strength` bleibt beim Schreiben von `occupiedFrames` unberührt.
- `task-due.handler.spec.ts`: Zuständiger bevorzugt; sonst `tasks:complete`; nichts bei `taskDueEnabled = false`; Schlüssel je Aufgabe, Tag und Nutzer.
- `digest.spec.ts`: Zeilen nur mit Recht; keine Übersicht ohne Inhalt; `requiredPermissions` vollständig; eine je Tag.
- `telegram-link.spec.ts`: Token 43 Zeichen Base64url, nur Hash gespeichert, abgelaufen/benutzt → `LINK_INVALID`, neues Token entwertet alte, Chat wechselt den Nutzer mit Hinweis.
- `telegram-updates.spec.ts`: `/start`, `/stop`, `/hilfe`, Gruppen ignoriert, Antwortgrenze je Chat, Offset in derselben Transaktion, Lease nur mit DB-Uhr.
- `telegram-send.spec.ts`: Rechte und Verbindung beim Senden geprüft; Klartext ohne `parse_mode`, höchstens 600 Zeichen; 429 → `retry_after` ohne Fehlversuch; 403 → Verbindung gelöst; 401 → wartet; veraltet → `SKIPPED`.
- `telegram-api-error.spec.ts`: Token erscheint weder in `TelegramApiError` noch in `loggableError()`.
- `weather-source.spec.ts`: Auswahl über `WEATHER_HISTORY_SOURCE`; nur `https:`-URLs; API-Schlüssel nie in Fehlertexten.
### 14.2 e2e (Test-DB, mit `drainDomainEvents`)
- `suggestions.e2e-spec.ts`:
- `an inspection without queen and eggs leaves one OPEN suggestion and no task`
- `accept creates exactly one task with sourceSuggestionId and a calendar delivery`
- `a second accept with another key answers 409`
- `a dismissed suggestion is not proposed again for the same inspection`
- `a later inspection with the same finding proposes again`
- `varroa-seasonal.e2e-spec.ts`: `12 mites/day in August proposes a treatment check`; `12 mites/day five days after treatment end proposes nothing`; `thresholds for a young colony are halved`.
- `chains.e2e-spec.ts`: Weiselzellen → +7; `RAISE_OWN` → Übernehmen ergibt 3 Aufgaben mit +9/+21/+28; Zuweisung → +7; Ableger `QUEEN` plus Zuweisung → ein Vorschlag.
- `colony-ended.e2e-spec.ts`: Auswinterungsverlust mit `markColonyDeceased` beendet die Königin-Zuordnung in derselben Transaktion; offene Vorschläge `OBSOLETE`; Aufräumen bricht 2 Aufgaben ab und erledigt 1; 403 ohne `tasks:complete`.
- `autopilot.e2e-spec.ts`: Schwarmzeit per Meldung, letzte Durchsicht vor 9 Tagen → Schritt 1 `INSPECT` überfällig; nach neuer Durchsicht `RESOLVED`; nie mehr als 3 Schritte; ohne `varroa:read` keine Varroa-Gründe.
- `phenology.e2e-spec.ts`: Meldung „alle Stände“ → jeder Stand `SALWEIDE`, `SeasonPhaseChanged` einmal je Stand; Zurückziehen rechnet neu; Wiederholung mit gleichem Key ergibt eine Meldung; Zukunftsdatum 400; 403 ohne Recht.
- `daily-evaluation.e2e-spec.ts` (erweitert): zweimal laufen ergibt gleiche Vorschlagszahlen; Ablauf nach `expiresAt`; `InspectionOverdue` nach Abschnitts-Intervall.
- `weather-history.e2e-spec.ts` (Fetch gemockt): fehlende Tage einmal geholt, gerundete Koordinaten, ungültige Werte `null`, Fehler ohne Zeilen.
- `withdrawal-chain.e2e-spec.ts`: Ernte im Fenster → 201 + Warnung + eine Meldung; Schleuderung und Abfüllung → Warnung, keine zweite Meldung; `WithdrawalEnded` → eine INFO; Kalender-Spanne in der Outbox.
- `honey-chain.e2e-spec.ts`: Vorbelegung summiert Netto; beide Plausibilitätswarnungen; Ledger-Invariante nach Anlegen, Abfüllen, Korrigieren, Entfernen; zwei parallele Abfüllungen über den Rest → genau eine 400.
- `honey-ledger-migration.e2e-spec.ts`: Altdaten mit Abfüllungen → Invariante hält, Buchungen tragen `sourceBottlingBatchId`.
- `bottling-quality.e2e-spec.ts`: 19 % → 201 mit Warnung; 20,5 % → 409; bestätigt → 201; MHD aus `shelfLifeMonths`; abweichendes Glas → Hinweis.
- `inspection-takeover.e2e-spec.ts`: Futter und Brut übernommen, Audit `Colony UPDATE`; neuere Durchsicht → übersprungen; ohne `colonies:update` → 201 und übersprungen.
- `notifications.e2e-spec.ts`: Lesen je Nutzer getrennt; Nutzer ohne `colonies:read` bekommt keine Volksmeldung; Migration verteilt Rundmeldungen.
- `colony-strength.e2e-spec.ts`: Altwert `strength` bleibt nach Migration und PATCH von `occupiedFrames` erhalten; Übernahme aus der Durchsicht schreibt nur `occupiedFrames`; 41 → 400.
- `reminders.e2e-spec.ts`: `TaskDue` nach Drain → genau eine Meldung je Empfänger, auch nach erneuter Zustellung; Job zweimal → eine Übersicht je Nutzer; Übersicht ohne Varroa-/Durchsichtszeile für einen Nutzer ohne die Rechte.
- `telegram.e2e-spec.ts` (Bot API gemockt, kein echter Netzverkehr):
- `link token -> /start links the chat once; a second /start with the same token answers LINK_INVALID`
- `link-token requires step-up and is throttled`
- `a due task yields one delivery after the morning time and none inside quiet hours`
- `two send loops in parallel deliver each message exactly once`
- `only the lease holder polls; a second worker waits`
- `a revoked permission skips the delivery at send time`
- `GET /system/notification-deliveries shows FAILED entries and retry moves them back to PENDING`
- `authorization-coverage.e2e-spec.ts` deckt alle neuen Routen ab (Profil-Routen nie für API-Schlüssel).
### 14.3 Wächter
- Handler-IDs unter `WorkerModule` = unter `AppModule` (inkl. aller T4-Handler).
- `honey-batch-amount-writes.spec.ts`: `amount` der Honigcharge wird nur im Ledger geschrieben.
- `no-network-outside-loops.spec.ts`: Wetter-Hosts, `api.telegram.org` und `fetch(` nur in `src/weather/` bzw. `src/telegram/`; nie in `*.handlers.ts` oder `src/jobs/`. `handler-contract.spec.ts` verbietet zusätzlich Importe von `telegram-client`.
- `colony-end-releases-queen.spec.ts`: jede Stelle, die einen beendeten Status setzt, ruft `releaseQueenIn` auf.
- `queen-age-threshold.spec.ts`: keine zweite Altersschwelle im Code (`OLD_QUEEN_AGE_YEARS` existiert nicht mehr; Dashboard und Job lesen `queenMaxAgeYears`).
- `no-broadcast-notifications.spec.ts`: kein `notification.create`/`createMany` ohne `userId`.
- Startprüfung: `TELEGRAM_BOT_TOKEN` mit falschem Format oder Platzhalter → Worker startet in Produktion nicht.
- Jeder `AutomationTriggerType` hat `DEFAULT_RULES`, Config-Parser und, falls vorschlagend, Textvorlagen.
- Jeder `SuggestionIntent` hat Textvorlage (Backend) sowie Icon und Label (Frontend, Vitest).
- Backup-Wächter für alle neuen Modelle (4.13); `update-dto-null-handling.spec.ts` für alle neuen und geänderten DTOs.
- `after-commit-usage.spec.ts` mit der verkürzten Altpfad-Liste.
### 14.4 Frontend (Vitest/RTL)
- Vorschlagsliste: Gruppierung, „Alle übernehmen“, Verwerfen mit Rückgängig, „Verworfene“, Leerzustand, Namen der Knöpfe.
- `NextStepsCard`: höchstens 3, Begründung, Aktionen führen mit `colonyId` ins richtige Formular.
- Heute: Quelle des nächsten Schritts, Karte „Durchsicht fällig“ startet eine Runde nur mit fälligen Völkern.
- `PhenologyReportSheet`: ein Tipp meldet, 56-px-Zeilen, „für alle Stände“ vorgewählt, offline in der Warteschlange.
- `ColonyTakeoverSection`: nur ausgefüllte Felder angehakt, „bisher → neu“, ohne Recht unsichtbar.
- Varroa-Ampel mit Bändern und Hinweis „Behandlungseffekt“; Abfüll-Bestätigung; MHD aus dem Artikel; Bewegungsbuch-Karte; Kalender-Spanne.
- Volk bearbeiten: „Besetzte Waben“ 0–40; Altfeld nur mit Wert; Leeren sendet `null`.
- `NotificationSettingsCard`: Uhrzeiten validiert, Ruhezeit nur paarweise leerbar, Speichern sendet nur Geändertes.
- `TelegramLinkCard`: Step-up-Dialog vor dem Link, Knopf „In Telegram öffnen“ mit Ablaufzeit, Status „Verbunden mit …“, „Trennen“, Hinweis ohne eingerichteten Bot.
- Glocke: Meldung mit `link` öffnet das Ziel.
## 15. Rollout
1. **Reihenfolge:** Rückgrat-Tasks 3–18, 24 und 25 und die T3-Tasks 2, 3, 9, 11, 14 sind gemergt. Dann T4 in drei Lieferungen (Abschnitt 16).
2. **Migrationen** in der Reihenfolge aus 4.12. Vor dem Deploy ein Backup ziehen (App-Backup und `pg_dump`). Nach dem Deploy die Prüfabfragen aus 4.12 ausführen.
3. **Regeln:** `ensureDefaults` legt die neuen Regeln an. Bestehende Configs bleiben; `VARROA_THRESHOLD` liest den alten Wert weiter. `TREATMENT_FOLLOW_UP`/`FEEDING_FOLLOW_UP` bleiben, wie sie sind.
4. **Bestehende Automatik-Aufgaben** bleiben Aufgaben (9.5).
5. **Benachrichtigungen:** Nach Migration 8 sieht jeder Nutzer seine Kopien. Ungelesene Rundmeldungen bleiben ungelesen.
6. **Worker:** neue Schleifen `phenology-weather`, `telegram-send` und `telegram-updates`. Der erste Wetter-Takt holt die Tage seit dem 1. Januar nach (je gerundetem Punkt eine Archiv- und eine Forecast-Anfrage). NetworkPolicy und CronJobs unverändert; `daily-evaluation` braucht etwas länger (Limit 1800 s reicht).
7. **Volksstärke:** Migration 10 ändert keine Daten. Vor dem Deploy die Prüfabfrage auf `colonyStrength > 40` laufen lassen; Treffer mit dem Inhaber klären, nicht kürzen.
8. **Wetterquelle:** `WEATHER_HISTORY_SOURCE=open-meteo` in der ConfigMap (Inhaber-Entscheidung, freie API). Ein späterer Wechsel ist eine Änderung der ConfigMap (bezahltes Open-Meteo) bzw. ein neuer Adapter plus ConfigMap (DWD).
9. **Telegram** (Lieferung T4c), jeder Schritt mit Freigabe des Inhabers:
1. Der Inhaber legt bei BotFather einen Bot an (Gruppen aus, kein Webhook), je Umgebung einen eigenen.
2. Der Inhaber legt das Secret `apicore-telegram` mit `TELEGRAM_BOT_TOKEN` an. Die Komponente `backend-roles` bindet es nur in den Worker ein (`optional: true`); `TELEGRAM_BOT_USERNAME` kommt in die ConfigMap.
3. Deploy; „Verwaltung > System“ muss „Bot eingerichtet“ und einen frischen Abruf zeigen.
4. Der Inhaber verbindet sein Konto und prüft eine Test-Erinnerung (Aufgabe mit Fälligkeit heute).
- Ohne Secret bleibt Telegram aus; alles andere läuft.
10. **Webhook-Payload:** `inspection.created` trägt `feedStatus` künftig als `GOOD`/`WEAK`/`CRITICAL`. Der Shop nutzt dieses Ereignis nicht; im Changelog nennen.
11. **Frontend:** T3-Funktionen `varroaLight(rate, threshold)` und `INSPECTION_STALE_DAYS` werden ersetzt, nicht parallel gehalten.
12. **Doku:**
- Bestehende Annahmen ändern: 12 (Automatische Aufgaben → Vorschläge) und die T3-Annahme „Ampeln“ (Varroa-Bänder, Fälligkeit).
- Neue Annahmen jeweils unter der **nächsten freien Nummer** zum Zeitpunkt des Baus, keine fest vergebenen Nummern: Vorschläge und Anker; Saison aus Pflanzenentwicklung und Wetterquelle (Inhaber-Entscheidung Open-Meteo); Volksstärke in besetzten Waben mit Altwert; Bewegungsbuch der Honigcharge; Benachrichtigungen je Nutzer (Abweichung von Rückgrat-Spec §4); Erinnerungen und Telegram; Wassergehalt; Alter der Königin. Hintergrund: ARCHITECTURE hat 22 und 23 schon belegt, der T3-Plan plant 22–27.
- ARCHITECTURE §7 (Secret `apicore-telegram`, nur Worker), §8 (Backup-Ausnahmen) und §10 (T4-Handler, Job-Schritte, Worker-Schleifen) ergänzen. TASKS.md um T4.
13. **Cluster:** Jeder Schritt nur mit Freigabe des Inhabers, wie im Rückgrat-Runbook.
14. **Erste Woche:** Der Autopilot läuft scharf, ist aber in „Verwaltung > Automatisierung“ mit einem Schalter abschaltbar. Die Kalender-Rückfälle und die 8-Waben-Regel nach der ersten Saison mit den eigenen Meldungen prüfen.
## 16. Aufwand
| Baustein | PT |
|---|---|
| Vorschläge: Modell, Service, Regeln umstellen, Texte, API, Rechte-Filter | 2,0 |
| Ketten: Weiselzellen, Königin zugesetzt, Ableger, Volk beendet, Ereignisse `queens`/`colonies` | 1,5 |
| Varroa-Ampel nach Jahreszeit (Bänder, Endpunkt, Probe, UI) | 1,0 |
| Fälligkeit, Autopilot-Kern (Abgleich, Rangfolge, „Als Nächstes“, Heute, Vorschlagsliste) | 2,0 |
| Durchsicht → Volkskarte, Futter-Vokabular, Migration | 1,0 |
| Wartezeit-Kette: Prüfdienst, Warnungen, Meldungen, Kalender-Spanne | 1,0 |
| Benachrichtigungen je Nutzer | 0,5 |
| Pflanzenentwicklung: Wetter-Schleife, GTS, Meldungen, Kalibrierung, UI | 2,0 |
| Autopilot-Saisonregeln A4–A10 | 1,0 |
| Honig-Kette: Vorbelegung, Plausibilität, Ereignisse | 1,0 |
| Bewegungsbuch der Honigcharge inkl. Rückrechnung | 1,5 |
| Qualität vor der Abfüllung | 0,5 |
| Volksstärke in besetzten Waben: Feld, Migration, API, Oberfläche | 0,25 |
| Wetter-Adapter mit Konfiguration (`WeatherHistorySource`) | 0,25 |
| Erinnerungen in der App: Handler `notifications.task-due`, Job-Schritt `digest`, `NotificationPreference` mit API und Einstellungen-Karte | 1,0 |
| Telegram-Versand: Outbox `NotificationDelivery`, Schleife `telegram-send`, Rendern, Rechteprüfung beim Senden, Ruhezeit, API-Grenzen, Fehlerbehandlung | 1,0 |
| Telegram-Verbinden: Einmal-Token, Step-up, Empfang per Long Polling mit Lease, `/start`, `/stop`, `/hilfe` | 1,0 |
| Telegram-Oberfläche (`TelegramLinkCard`) | 0,5 |
| Telegram-Betrieb: Karte unter „Verwaltung > System“, System-API, Secret und Manifeste, Startprüfung | 0,5 |
| Doku, Gesamtprüfung | 0,5 |
| **Summe** | **20,0** |
Der Fahrplan nannte etwa 10 PT. Mehr wird es durch Punkte, die §10 nicht einpreist: das Bewegungsbuch der Honigcharge, den Wetterspeicher für die GTS, die Rechte-Filterung von Vorschlägen und Meldungen und den neuen Kanal Telegram (Inhaber-Antwort 3, 4,0 PT inklusive Erinnerungen).
**Vorschlag: drei Lieferungen.**
- **T4a (≈ 10,25 PT):** Vorschläge, Ketten, Varroa-Ampel, Fälligkeit und Autopilot-Kern mit Kalender-Rückfall (ohne GTS), Volkskarte mit besetzten Waben, Wartezeit, Volk beendet, Benachrichtigungen je Nutzer, Erinnerungen in der App. Bringt §30, die Vorschlagsliste und die Morgenübersicht.
- **T4b (≈ 6,75 PT):** Pflanzenentwicklung mit GTS, Wetter-Adapter und Meldungen, Saisonregeln A4–A10, Honig-Kette, Bewegungsbuch, Qualität.
- **T4c (≈ 3,0 PT):** Telegram. Braucht nur T4a; kann parallel zu T4b laufen.
## 17. Offene Fragen an den Inhaber
Keine. Die drei Fragen vom 2026-10-13 sind beantwortet und eingearbeitet (Abschnitt 1, „Antworten des Inhabers“):
- Volksstärke in besetzten Waben, 0–40, Altwerte bleiben (4.14).
- Freie Open-Meteo-API hinter einem Adapter (Entscheidungen, 6.3).
- Erinnerungen je Aufgabe und als Morgenübersicht, in der App und per Telegram (9a).
Werte, die der Inhaber nach der ersten Saison prüfen sollte, sind als „Annahme“ markiert und alle einstellbar: Kalender-Rückfälle der Phasen, Beginn der Schwarmzeit, 8-Waben-Regel, Ruhezeit 21:00–06:30, Altersschwelle der Königin.