feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms

- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

@@ -0,0 +1,19 @@
-- T4 migration 1/2: new enum values only. PostgreSQL cannot use a value in
-- the transaction that adds it, so everything that uses them (the Suggestion
-- table, rule rows) comes in later migrations.
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'INSPECTION_INTERVAL';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_CELLS_SWARM_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_ACCEPTANCE_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SPLIT_SCHEDULE';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_ENDED_CLEANUP';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'WITHDRAWAL_ENDED';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_WATER_CONTENT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SEASON_AUTOPILOT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PHENOLOGY';
-- AlterEnum
ALTER TYPE "BatchMovementType" ADD VALUE 'BOTTLING';
-- AlterEnum
ALTER TYPE "CalendarSyncSourceType" ADD VALUE 'WITHDRAWAL';
@@ -0,0 +1,71 @@
-- T4 migration 2/2: suggestions (rule, chain and autopilot proposals the
-- beekeeper decides on). dedupeKey is nullable and unique: Postgres allows many
-- NULLs, so EXPIRED/RESOLVED rows release their key.
-- CreateEnum
CREATE TYPE "SuggestionStatus" AS ENUM ('OPEN', 'ACCEPTED', 'DISMISSED', 'RESOLVED', 'EXPIRED', 'OBSOLETE');
-- CreateEnum
CREATE TYPE "SuggestionMode" AS ENUM ('PLAN', 'DO');
-- CreateEnum
CREATE TYPE "SuggestionSource" AS ENUM ('RULE', 'CHAIN', 'AUTOPILOT');
-- CreateEnum
CREATE TYPE "SuggestionIntent" AS ENUM ('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');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceSuggestionId" TEXT;
-- CreateTable
CREATE TABLE "Suggestion" (
"id" TEXT NOT NULL,
"ruleKey" "AutomationTriggerType" NOT NULL,
"source" "SuggestionSource" NOT NULL,
"mode" "SuggestionMode" NOT NULL,
"intent" "SuggestionIntent" NOT NULL,
"status" "SuggestionStatus" NOT NULL DEFAULT 'OPEN',
"priority" "TaskPriority" NOT NULL DEFAULT 'NORMAL',
"relatedType" "TaskRelatedType" NOT NULL,
"relatedId" TEXT,
"colonyId" TEXT,
"reasonCode" TEXT NOT NULL,
"evidence" JSONB NOT NULL,
"requiredPermission" TEXT,
"dueDate" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"plan" JSONB,
"dedupeKey" TEXT,
"sourceEventId" TEXT,
"decidedAt" TIMESTAMP(3),
"decidedByUserId" TEXT,
"decisionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Suggestion_dedupeKey_key" ON "Suggestion"("dedupeKey");
-- CreateIndex
CREATE INDEX "Suggestion_status_colonyId_idx" ON "Suggestion"("status", "colonyId");
-- CreateIndex
CREATE INDEX "Suggestion_status_dueDate_idx" ON "Suggestion"("status", "dueDate");
-- CreateIndex
CREATE INDEX "Suggestion_ruleKey_relatedType_relatedId_status_idx" ON "Suggestion"("ruleKey", "relatedType", "relatedId", "status");
-- CreateIndex
CREATE INDEX "Task_sourceSuggestionId_idx" ON "Task"("sourceSuggestionId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_sourceSuggestionId_fkey" FOREIGN KEY ("sourceSuggestionId") REFERENCES "Suggestion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_decidedByUserId_fkey" FOREIGN KEY ("decidedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
-- T4 migration 3/n: one vocabulary for the feed condition. Inspection.feedStatus
-- was free text; it becomes ColonyCondition, like the colony card. Known words
-- (ausreichend, niedrig, kritisch; trimmed of spaces, tabs and line breaks like
-- JS trim, any case) map to the enum, any other text moves to the end of
-- "notes" as 'Futter (alt): <text>'. Nothing is lost.
-- AlterTable
ALTER TABLE "Inspection" ADD COLUMN "feedCondition" "ColonyCondition";
-- Known words map to the enum.
UPDATE "Inspection"
SET "feedCondition" = CASE lower(btrim("feedStatus", E' \t\r\n'))
WHEN 'ausreichend' THEN 'GOOD'::"ColonyCondition"
WHEN 'niedrig' THEN 'WEAK'::"ColonyCondition"
WHEN 'kritisch' THEN 'CRITICAL'::"ColonyCondition"
END
WHERE "feedStatus" IS NOT NULL;
-- Every other non-empty text goes to the notes.
DO $$
DECLARE
moved integer;
BEGIN
UPDATE "Inspection"
SET "notes" = concat_ws(E'\n', NULLIF("notes", ''), 'Futter (alt): ' || btrim("feedStatus", E' \t\r\n'))
WHERE "feedCondition" IS NULL
AND btrim("feedStatus", E' \t\r\n') <> '';
GET DIAGNOSTICS moved = ROW_COUNT;
RAISE NOTICE 't4_feed_vocabulary: % free-text feed values moved into notes', moved;
END $$;
-- AlterTable
ALTER TABLE "Inspection" DROP COLUMN "feedStatus";
ALTER TABLE "Inspection" RENAME COLUMN "feedCondition" TO "feedStatus";
@@ -0,0 +1,8 @@
-- T4 migration 5/n: where the queen of a split colony comes from. Optional:
-- an observation is never filled in for old rows.
-- CreateEnum
CREATE TYPE "SplitQueenSource" AS ENUM ('RAISE_OWN', 'QUEEN_CELL', 'QUEEN');
-- AlterTable
ALTER TABLE "ColonyLineage" ADD COLUMN "queenSource" "SplitQueenSource";
@@ -0,0 +1,5 @@
-- T4 migration 7/n (part sampleBees): number of bees in a powdered-sugar or
-- washing sample, so that the infestation percentage can be computed.
-- AlterTable
ALTER TABLE "VarroaMeasurement" ADD COLUMN "sampleBees" INTEGER;
+118 -9
View File
@@ -115,6 +115,7 @@ model User {
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[]
@@ -312,6 +313,7 @@ model Colony {
feedings Feeding[]
harvests HarvestColony[]
queenEvaluations QueenEvaluation[]
suggestions Suggestion[]
@@index([apiaryId])
@@index([status])
@@ -340,16 +342,23 @@ enum LineageType {
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
enum SplitQueenSource {
RAISE_OWN
QUEEN_CELL
QUEEN
}
model ColonyLineage {
id String @id @default(cuid())
id String @id @default(cuid())
type LineageType
fromColonyId String
fromColony Colony @relation("LineageFrom", fields: [fromColonyId], references: [id])
fromColony Colony @relation("LineageFrom", fields: [fromColonyId], references: [id])
toColonyId String
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
date DateTime
queenSource SplitQueenSource?
notes String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([fromColonyId])
@@index([toColonyId])
@@ -447,11 +456,11 @@ model QueenColonyHistory {
// ---------------------------------------------------------------------------
model Inspection {
id String @id @default(cuid())
id String @id @default(cuid())
colonyId String
colony Colony @relation(fields: [colonyId], references: [id])
colony Colony @relation(fields: [colonyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
date DateTime
weather String?
temperature Float?
@@ -467,13 +476,13 @@ model Inspection {
swarmMood Int?
gentleness Int?
combBehavior Int?
feedStatus String?
feedStatus ColonyCondition?
honeySuperStatus String?
healthIssues String?
varroaIssues String?
measures String?
notes String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
tasks Task[]
@@ -529,12 +538,99 @@ model Task {
// rule see that it already left an open task for the same object instead
// of piling up a duplicate on every further trigger.
sourceAutomationTrigger AutomationTriggerType?
// Set when the task was created by accepting a suggestion.
sourceSuggestionId String?
sourceSuggestion Suggestion? @relation(fields: [sourceSuggestionId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
completedAt DateTime?
@@index([status, dueDate])
@@index([relatedType, relatedId])
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
@@index([sourceSuggestionId])
}
// ---------------------------------------------------------------------------
// Vorschlaege (T4): Regeln, Ketten und der Saison-Autopilot schlagen vor, der
// Imker entscheidet. Uebernehmen legt eine Aufgabe an (Task.sourceSuggestionId).
// ---------------------------------------------------------------------------
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
// Only IDs, numbers and YYYY-MM-DD.
evidence Json
// e.g. 'varroa:read'; without that permission the suggestion is invisible.
requiredPermission String?
dueDate DateTime?
expiresAt DateTime?
// PLAN with several dates: [{ step, offsetDays, windowDays? }]
plan Json?
// '<ruleKey>:<relatedId|none>:<anchor>'; EXPIRED/RESOLVED clear it.
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])
}
enum TaskTemplateRecurrenceType {
@@ -589,6 +685,7 @@ model VarroaMeasurement {
method VarroaMethod
durationMinutes Int?
miteCount Int?
sampleBees Int?
calculatedValue Float?
assessment String?
notes String?
@@ -1172,6 +1269,8 @@ enum BatchMovementType {
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
CONSUMPTION
// Abfuellung entnimmt Honig aus der Charge (Task 23: Bewegungsbuch)
BOTTLING
}
enum WithdrawalReason {
@@ -1358,6 +1457,15 @@ enum AutomationTriggerType {
RAW_MATERIAL_LOT_NUMBER_FORMAT
PRODUCTION_BATCH_NUMBER_FORMAT
RAW_MATERIAL_LOW
INSPECTION_INTERVAL
QUEEN_CELLS_SWARM_CHECK
QUEEN_ACCEPTANCE_CHECK
SPLIT_SCHEDULE
COLONY_ENDED_CLEANUP
WITHDRAWAL_ENDED
HONEY_WATER_CONTENT
SEASON_AUTOPILOT
PHENOLOGY
}
model AutomationRule {
@@ -1446,6 +1554,7 @@ enum CalendarSyncSourceType {
FEEDING
HARVEST
QUEEN_ASSIGNMENT
WITHDRAWAL
}
enum CalendarSyncStatus {