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:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11207
-769
No files matched your search
@@ -18,3 +18,6 @@ prisma/seed.d.ts
|
|||||||
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
||||||
/uploads/
|
/uploads/
|
||||||
/test-uploads/
|
/test-uploads/
|
||||||
|
|
||||||
|
# T4 migration test harness: scratch copies of the migration folders
|
||||||
|
/.tmp-migrations/
|
||||||
@@ -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;
|
||||||
@@ -115,6 +115,7 @@ model User {
|
|||||||
stockMovements StockMovement[]
|
stockMovements StockMovement[]
|
||||||
assignedTasks Task[] @relation("TaskAssignee")
|
assignedTasks Task[] @relation("TaskAssignee")
|
||||||
createdTasks Task[] @relation("TaskCreator")
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
|
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
|
||||||
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
|
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
|
||||||
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
|
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
|
||||||
colonyMoves ColonyApiaryHistory[]
|
colonyMoves ColonyApiaryHistory[]
|
||||||
@@ -312,6 +313,7 @@ model Colony {
|
|||||||
feedings Feeding[]
|
feedings Feeding[]
|
||||||
harvests HarvestColony[]
|
harvests HarvestColony[]
|
||||||
queenEvaluations QueenEvaluation[]
|
queenEvaluations QueenEvaluation[]
|
||||||
|
suggestions Suggestion[]
|
||||||
|
|
||||||
@@index([apiaryId])
|
@@index([apiaryId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -340,6 +342,12 @@ enum LineageType {
|
|||||||
|
|
||||||
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
|
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
|
||||||
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
|
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
|
||||||
|
enum SplitQueenSource {
|
||||||
|
RAISE_OWN
|
||||||
|
QUEEN_CELL
|
||||||
|
QUEEN
|
||||||
|
}
|
||||||
|
|
||||||
model ColonyLineage {
|
model ColonyLineage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
type LineageType
|
type LineageType
|
||||||
@@ -348,6 +356,7 @@ model ColonyLineage {
|
|||||||
toColonyId String
|
toColonyId String
|
||||||
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
|
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
|
||||||
date DateTime
|
date DateTime
|
||||||
|
queenSource SplitQueenSource?
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -467,7 +476,7 @@ model Inspection {
|
|||||||
swarmMood Int?
|
swarmMood Int?
|
||||||
gentleness Int?
|
gentleness Int?
|
||||||
combBehavior Int?
|
combBehavior Int?
|
||||||
feedStatus String?
|
feedStatus ColonyCondition?
|
||||||
honeySuperStatus String?
|
honeySuperStatus String?
|
||||||
healthIssues String?
|
healthIssues String?
|
||||||
varroaIssues String?
|
varroaIssues String?
|
||||||
@@ -529,12 +538,99 @@ model Task {
|
|||||||
// rule see that it already left an open task for the same object instead
|
// rule see that it already left an open task for the same object instead
|
||||||
// of piling up a duplicate on every further trigger.
|
// of piling up a duplicate on every further trigger.
|
||||||
sourceAutomationTrigger AutomationTriggerType?
|
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())
|
createdAt DateTime @default(now())
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
|
|
||||||
@@index([status, dueDate])
|
@@index([status, dueDate])
|
||||||
@@index([relatedType, relatedId])
|
@@index([relatedType, relatedId])
|
||||||
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
|
@@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 {
|
enum TaskTemplateRecurrenceType {
|
||||||
@@ -589,6 +685,7 @@ model VarroaMeasurement {
|
|||||||
method VarroaMethod
|
method VarroaMethod
|
||||||
durationMinutes Int?
|
durationMinutes Int?
|
||||||
miteCount Int?
|
miteCount Int?
|
||||||
|
sampleBees Int?
|
||||||
calculatedValue Float?
|
calculatedValue Float?
|
||||||
assessment String?
|
assessment String?
|
||||||
notes String?
|
notes String?
|
||||||
@@ -1172,6 +1269,8 @@ enum BatchMovementType {
|
|||||||
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
|
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
|
||||||
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
|
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
|
||||||
CONSUMPTION
|
CONSUMPTION
|
||||||
|
// Abfuellung entnimmt Honig aus der Charge (Task 23: Bewegungsbuch)
|
||||||
|
BOTTLING
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WithdrawalReason {
|
enum WithdrawalReason {
|
||||||
@@ -1358,6 +1457,15 @@ enum AutomationTriggerType {
|
|||||||
RAW_MATERIAL_LOT_NUMBER_FORMAT
|
RAW_MATERIAL_LOT_NUMBER_FORMAT
|
||||||
PRODUCTION_BATCH_NUMBER_FORMAT
|
PRODUCTION_BATCH_NUMBER_FORMAT
|
||||||
RAW_MATERIAL_LOW
|
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 {
|
model AutomationRule {
|
||||||
@@ -1446,6 +1554,7 @@ enum CalendarSyncSourceType {
|
|||||||
FEEDING
|
FEEDING
|
||||||
HARVEST
|
HARVEST
|
||||||
QUEEN_ASSIGNMENT
|
QUEEN_ASSIGNMENT
|
||||||
|
WITHDRAWAL
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CalendarSyncStatus {
|
enum CalendarSyncStatus {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { BackupModule } from './backup/backup.module';
|
|||||||
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||||
import { ErpExportModule } from './erp-export/erp-export.module';
|
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
|
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
@@ -106,6 +107,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
|||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
|
SuggestionsModule,
|
||||||
LabelTemplatesModule,
|
LabelTemplatesModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PermissionResolverService } from './permission-resolver.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PermissionResolverService for the job and worker context (BackupService
|
||||||
|
* clears its cache after a restore, which no job does). Not
|
||||||
|
* PermissionsModule: its PermissionCatalogSyncService would rewrite the
|
||||||
|
* permission catalog on every job or worker start. Global like
|
||||||
|
* PermissionsModule, so modules that inject the resolver load here unchanged.
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PermissionResolverService],
|
||||||
|
exports: [PermissionResolverService],
|
||||||
|
})
|
||||||
|
export class JobPermissionsModule {}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { AutomationService } from './automation.service';
|
import { AutomationService } from './automation.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
@@ -26,6 +26,7 @@ describe('AutomationService', () => {
|
|||||||
createMany: jest.Mock;
|
createMany: jest.Mock;
|
||||||
findMany: jest.Mock;
|
findMany: jest.Mock;
|
||||||
findUnique: jest.Mock;
|
findUnique: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
task: { findFirst: jest.Mock };
|
task: { findFirst: jest.Mock };
|
||||||
$queryRaw: jest.Mock;
|
$queryRaw: jest.Mock;
|
||||||
@@ -84,6 +85,7 @@ describe('AutomationService', () => {
|
|||||||
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
task: { findFirst: jest.fn().mockResolvedValue(null) },
|
task: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
@@ -438,7 +440,7 @@ describe('AutomationService', () => {
|
|||||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('triggers when feed status is kritisch, case-insensitive', async () => {
|
it('triggers when feed status is CRITICAL', async () => {
|
||||||
tx.automationRule.findMany.mockResolvedValue([feedRule]);
|
tx.automationRule.findMany.mockResolvedValue([feedRule]);
|
||||||
|
|
||||||
await service.evaluateInspectionIn(asTx(), {
|
await service.evaluateInspectionIn(asTx(), {
|
||||||
@@ -446,7 +448,7 @@ describe('AutomationService', () => {
|
|||||||
colonyId: 'colony-1',
|
colonyId: 'colony-1',
|
||||||
queenSeen: true,
|
queenSeen: true,
|
||||||
eggsPresent: true,
|
eggsPresent: true,
|
||||||
feedStatus: 'Kritisch',
|
feedStatus: 'CRITICAL',
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
|
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
|
||||||
@@ -461,7 +463,7 @@ describe('AutomationService', () => {
|
|||||||
|
|
||||||
await service.evaluateInspectionIn(asTx(), {
|
await service.evaluateInspectionIn(asTx(), {
|
||||||
...queenlessInspection,
|
...queenlessInspection,
|
||||||
feedStatus: 'kritisch',
|
feedStatus: 'CRITICAL',
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||||
@@ -917,9 +919,55 @@ describe('AutomationService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('records an audit log entry with before/after data', async () => {
|
// The rule row is locked first, then read, so "before" is the committed
|
||||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
// state at the moment of the write.
|
||||||
prisma.automationRule.update.mockResolvedValue({
|
beforeEach(() => {
|
||||||
|
tx.$queryRaw.mockResolvedValue([{ locked: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks the rule row and reads before inside the transaction', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
tx.$queryRaw.mockImplementation(() => {
|
||||||
|
calls.push('lock');
|
||||||
|
return Promise.resolve([{ locked: 1 }]);
|
||||||
|
});
|
||||||
|
tx.automationRule.findUnique.mockImplementation(() => {
|
||||||
|
calls.push('read');
|
||||||
|
return Promise.resolve(varroaRule);
|
||||||
|
});
|
||||||
|
tx.automationRule.update.mockImplementation(() => {
|
||||||
|
calls.push('write');
|
||||||
|
return Promise.resolve({ ...varroaRule, active: false });
|
||||||
|
});
|
||||||
|
auditLog.record.mockImplementation(() => {
|
||||||
|
calls.push('audit');
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update('rule-3', { active: false }, 'user-1');
|
||||||
|
|
||||||
|
expect(calls).toEqual(['lock', 'read', 'write', 'audit']);
|
||||||
|
const [[lockQuery]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
|
||||||
|
expect(lockQuery.sql).toContain('FROM "AutomationRule"');
|
||||||
|
expect(lockQuery.sql).toContain('FOR UPDATE');
|
||||||
|
expect(lockQuery.values).toEqual(['rule-3']);
|
||||||
|
// Nothing is read or written outside the transaction.
|
||||||
|
expect(prisma.automationRule.findUnique).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.automationRule.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 when the rule does not exist', async () => {
|
||||||
|
tx.$queryRaw.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('missing', { active: false }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(tx.automationRule.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records an audit log entry with before/after data in the transaction', async () => {
|
||||||
|
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||||
|
tx.automationRule.update.mockResolvedValue({
|
||||||
...varroaRule,
|
...varroaRule,
|
||||||
active: false,
|
active: false,
|
||||||
});
|
});
|
||||||
@@ -931,20 +979,22 @@ describe('AutomationService', () => {
|
|||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
entityId: 'rule-3',
|
entityId: 'rule-3',
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
|
beforeData: varroaRule,
|
||||||
}),
|
}),
|
||||||
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the config on null so the rule falls back to its defaults', async () => {
|
it('clears the config on null so the rule falls back to its defaults', async () => {
|
||||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||||
prisma.automationRule.update.mockResolvedValue({
|
tx.automationRule.update.mockResolvedValue({
|
||||||
...varroaRule,
|
...varroaRule,
|
||||||
config: null,
|
config: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.update('rule-3', { config: null as never }, 'user-1');
|
await service.update('rule-3', { config: null as never }, 'user-1');
|
||||||
|
|
||||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
const [[updateArg]] = tx.automationRule.update.mock.calls as [
|
||||||
[{ data: { config?: unknown } }],
|
[{ data: { config?: unknown } }],
|
||||||
];
|
];
|
||||||
// A Json column is cleared with DbNull - a plain null fails the write.
|
// A Json column is cleared with DbNull - a plain null fails the write.
|
||||||
@@ -952,16 +1002,60 @@ describe('AutomationService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the config untouched when it is omitted', async () => {
|
it('leaves the config untouched when it is omitted', async () => {
|
||||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||||
prisma.automationRule.update.mockResolvedValue(varroaRule);
|
tx.automationRule.update.mockResolvedValue(varroaRule);
|
||||||
|
|
||||||
await service.update('rule-3', { active: true }, 'user-1');
|
await service.update('rule-3', { active: true }, 'user-1');
|
||||||
|
|
||||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
const [[updateArg]] = tx.automationRule.update.mock.calls as [
|
||||||
[{ data: { config?: unknown } }],
|
[{ data: { config?: unknown } }],
|
||||||
];
|
];
|
||||||
expect(updateArg.data.config).toBeUndefined();
|
expect(updateArg.data.config).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid known value with the field and writes nothing', async () => {
|
||||||
|
tx.automationRule.findUnique.mockResolvedValue({
|
||||||
|
...varroaRule,
|
||||||
|
id: 'rule-9',
|
||||||
|
triggerType: 'SEASON_AUTOPILOT',
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.update(
|
||||||
|
'rule-9',
|
||||||
|
{ config: { queenMaxAgeYears: 0, somethingElse: true } },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(result).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(result).rejects.toMatchObject({
|
||||||
|
response: {
|
||||||
|
message: 'Die Einstellung ist ungültig.',
|
||||||
|
issues: [{ field: 'queenMaxAgeYears' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(tx.automationRule.update).not.toHaveBeenCalled();
|
||||||
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps unknown keys of a valid config as sent', async () => {
|
||||||
|
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||||
|
tx.automationRule.update.mockResolvedValue(varroaRule);
|
||||||
|
|
||||||
|
await service.update(
|
||||||
|
'rule-3',
|
||||||
|
{ config: { thresholdMitesPerDay: 7, somethingElse: true } },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[updateArg]] = tx.automationRule.update.mock.calls as [
|
||||||
|
[{ data: { config?: unknown } }],
|
||||||
|
];
|
||||||
|
expect(updateArg.data.config).toEqual({
|
||||||
|
thresholdMitesPerDay: 7,
|
||||||
|
somethingElse: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateNumber', () => {
|
describe('generateNumber', () => {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
||||||
import { runAfterCommit } from '../common/after-commit';
|
import { runAfterCommit } from '../common/after-commit';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
@@ -21,6 +27,7 @@ import {
|
|||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||||
import { formatGeneratedNumber } from './number-format';
|
import { formatGeneratedNumber } from './number-format';
|
||||||
|
import { DEFAULT_RULES, validateRuleConfig } from './rule-config';
|
||||||
import { varroaThresholdOf } from './varroa-threshold';
|
import { varroaThresholdOf } from './varroa-threshold';
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@@ -56,84 +63,6 @@ function isUniqueViolation(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuleDefaults {
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
actionTemplate: string;
|
|
||||||
/** Rules without a sensible built-in setting start switched off. */
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
|
||||||
QUEENLESS_NO_EGGS: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate:
|
|
||||||
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
|
|
||||||
},
|
|
||||||
VARROA_THRESHOLD: {
|
|
||||||
config: { thresholdMitesPerDay: 10 },
|
|
||||||
actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
|
|
||||||
},
|
|
||||||
FEED_STATUS_CRITICAL: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
|
|
||||||
},
|
|
||||||
SWARM_MOOD_HIGH: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate:
|
|
||||||
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
|
|
||||||
},
|
|
||||||
COLONY_HEALTH_CRITICAL: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
|
|
||||||
},
|
|
||||||
STOCK_ITEM_LOW: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
|
|
||||||
},
|
|
||||||
// Not task-generating rules like the ones above - `active` here toggles
|
|
||||||
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
|
|
||||||
// number from `config.format` instead of requiring manual entry.
|
|
||||||
HONEY_BATCH_NUMBER_FORMAT: {
|
|
||||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
|
||||||
actionTemplate: '',
|
|
||||||
},
|
|
||||||
BOTTLING_LOT_NUMBER_FORMAT: {
|
|
||||||
config: { format: 'WH-{YYYY}-{SEQ:4}' },
|
|
||||||
actionTemplate: '',
|
|
||||||
},
|
|
||||||
// {ART} (WA/PR) gives wax and propolis a counter each.
|
|
||||||
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
|
|
||||||
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
|
|
||||||
actionTemplate: '',
|
|
||||||
},
|
|
||||||
PRODUCTION_BATCH_NUMBER_FORMAT: {
|
|
||||||
config: { format: 'HS-{YYYY}-{SEQ:4}' },
|
|
||||||
actionTemplate: '',
|
|
||||||
},
|
|
||||||
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
|
|
||||||
// no minimum of their own, so the minimum per kind (config.minGrams) lives
|
|
||||||
// here - and without one there is nothing to compare, hence off by default.
|
|
||||||
RAW_MATERIAL_LOW: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
// Follow-up checks: how many days after a treatment or feeding a check
|
|
||||||
// makes sense depends on the product and the season, so there is no
|
|
||||||
// built-in interval - the beekeeper switches the rule on and sets
|
|
||||||
// config.dueInDays.
|
|
||||||
TREATMENT_FOLLOW_UP: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Behandlungserfolg kontrollieren',
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
FEEDING_FOLLOW_UP: {
|
|
||||||
config: {},
|
|
||||||
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional per-rule follow-up interval (config.dueInDays). There is no
|
* Optional per-rule follow-up interval (config.dueInDays). There is no
|
||||||
* built-in default: how soon a queenless colony or a high mite fall needs a
|
* built-in default: how soon a queenless colony or a high mite fall needs a
|
||||||
@@ -223,13 +152,34 @@ export class AutomationService {
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row is locked before it is read, so "before" (and the audit entry)
|
||||||
|
* is the state this write replaces. A config with an invalid known value
|
||||||
|
* is refused with the fields named; unknown keys pass unchanged.
|
||||||
|
*/
|
||||||
async update(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateAutomationRuleDto,
|
dto: UpdateAutomationRuleDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<AutomationRule> {
|
): Promise<AutomationRule> {
|
||||||
const before = await this.findById(id);
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const rule = await this.prisma.automationRule.update({
|
const locked = await lockRowForUpdate(tx, 'AutomationRule', id);
|
||||||
|
const before = locked
|
||||||
|
? await tx.automationRule.findUnique({ where: { id } })
|
||||||
|
: null;
|
||||||
|
if (!before) {
|
||||||
|
throw new NotFoundException('Automation rule not found');
|
||||||
|
}
|
||||||
|
if (typeof dto.config === 'object' && dto.config !== null) {
|
||||||
|
const issues = validateRuleConfig(before.triggerType, dto.config);
|
||||||
|
if (issues.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'Die Einstellung ist ungültig.',
|
||||||
|
issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rule = await tx.automationRule.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...dto,
|
...dto,
|
||||||
@@ -241,15 +191,19 @@ export class AutomationService {
|
|||||||
: (dto.config as Prisma.InputJsonValue | undefined),
|
: (dto.config as Prisma.InputJsonValue | undefined),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: rule,
|
afterData: rule,
|
||||||
});
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
return rule;
|
return rule;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -287,7 +241,7 @@ export class AutomationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
if (feedRule && inspection.feedStatus === 'CRITICAL') {
|
||||||
await this.triggerActionIn(
|
await this.triggerActionIn(
|
||||||
tx,
|
tx,
|
||||||
feedRule,
|
feedRule,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { bool, intIn, makeParser } from './parse-helpers';
|
||||||
|
import type { ChainConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
const MAX_DUE_IN_DAYS = 365;
|
||||||
|
|
||||||
|
/** Follow-up chains (queen cells, queen acceptance): one check some days later. */
|
||||||
|
export function chainParser(trigger: string, defaults: ChainConfig) {
|
||||||
|
return makeParser<ChainConfig>(trigger, defaults, (scope) => ({
|
||||||
|
dueInDays: intIn(
|
||||||
|
scope,
|
||||||
|
'dueInDays',
|
||||||
|
defaults.dueInDays,
|
||||||
|
0,
|
||||||
|
MAX_DUE_IN_DAYS,
|
||||||
|
),
|
||||||
|
notify: bool(scope, 'notify', defaults.notify),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CHAIN_CONFIG: ChainConfig = {
|
||||||
|
dueInDays: 7,
|
||||||
|
notify: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_WITHDRAWAL_ENDED_CONFIG = { notify: true };
|
||||||
|
|
||||||
|
export const withdrawalEndedParser = makeParser<{ notify: boolean }>(
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
DEFAULT_WITHDRAWAL_ENDED_CONFIG,
|
||||||
|
(scope) => ({
|
||||||
|
notify: bool(scope, 'notify', DEFAULT_WITHDRAWAL_ENDED_CONFIG.notify),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The cleanup rule has no setting; it is only switched on or off. */
|
||||||
|
export const colonyEndedCleanupParser = makeParser<Record<string, never>>(
|
||||||
|
'COLONY_ENDED_CLEANUP',
|
||||||
|
{},
|
||||||
|
() => ({}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { RULE_CONFIG_PARSERS } from './rule-config.registry';
|
||||||
|
|
||||||
|
export interface RuleDefaults {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
actionTemplate: string;
|
||||||
|
/** Rules without a sensible built-in setting start switched off. */
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texts of the rules that create a task; the new rules have none yet. */
|
||||||
|
const ACTION_TEMPLATES: Record<AutomationTriggerType, string> = {
|
||||||
|
QUEENLESS_NO_EGGS:
|
||||||
|
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
|
||||||
|
VARROA_THRESHOLD: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
|
||||||
|
FEED_STATUS_CRITICAL: 'Fütterung erforderlich: Futterstatus kritisch.',
|
||||||
|
SWARM_MOOD_HIGH:
|
||||||
|
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
|
||||||
|
COLONY_HEALTH_CRITICAL: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
|
||||||
|
STOCK_ITEM_LOW: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
|
||||||
|
// Not task-generating rules like the ones above - `active` here toggles
|
||||||
|
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
|
||||||
|
// number from `config.format` instead of requiring manual entry.
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: '',
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: '',
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: '',
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: '',
|
||||||
|
RAW_MATERIAL_LOW: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
||||||
|
TREATMENT_FOLLOW_UP: 'Behandlungserfolg kontrollieren',
|
||||||
|
FEEDING_FOLLOW_UP: 'Futterabnahme und Futtervorrat kontrollieren',
|
||||||
|
INSPECTION_INTERVAL: '',
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: '',
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: '',
|
||||||
|
SPLIT_SCHEDULE: '',
|
||||||
|
COLONY_ENDED_CLEANUP: '',
|
||||||
|
WITHDRAWAL_ENDED: '',
|
||||||
|
HONEY_WATER_CONTENT: '',
|
||||||
|
SEASON_AUTOPILOT: '',
|
||||||
|
PHENOLOGY: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// - RAW_MATERIAL_LOW: raw material lots carry no minimum of their own, so
|
||||||
|
// the minimum per kind (config.minGrams) lives on the rule - and without
|
||||||
|
// one there is nothing to compare, hence off by default.
|
||||||
|
// - Follow-up checks: how many days after a treatment or feeding a check
|
||||||
|
// makes sense depends on the product and the season, so there is no
|
||||||
|
// built-in interval - the beekeeper switches the rule on and sets
|
||||||
|
// config.dueInDays.
|
||||||
|
const INACTIVE_BY_DEFAULT: ReadonlySet<AutomationTriggerType> = new Set([
|
||||||
|
'RAW_MATERIAL_LOW',
|
||||||
|
'TREATMENT_FOLLOW_UP',
|
||||||
|
'FEEDING_FOLLOW_UP',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function defaultsOf(trigger: AutomationTriggerType): RuleDefaults {
|
||||||
|
return {
|
||||||
|
config: structuredClone(RULE_CONFIG_PARSERS[trigger].defaults) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
actionTemplate: ACTION_TEMPLATES[trigger],
|
||||||
|
...(INACTIVE_BY_DEFAULT.has(trigger) ? { active: false } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rule rows created on first use; the config is the parser's default. */
|
||||||
|
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> =
|
||||||
|
Object.fromEntries(
|
||||||
|
(Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map(
|
||||||
|
(trigger) => [trigger, defaultsOf(trigger)],
|
||||||
|
),
|
||||||
|
) as Record<AutomationTriggerType, RuleDefaults>;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './rule-config.types';
|
||||||
|
export {
|
||||||
|
RULE_CONFIG_PARSERS,
|
||||||
|
ruleConfigOf,
|
||||||
|
validateRuleConfig,
|
||||||
|
} from './rule-config.registry';
|
||||||
|
export { DEFAULT_RULES, type RuleDefaults } from './default-rules';
|
||||||
|
export { lockRuleIn, readRule, type ActiveRule } from './rule-config.reader';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { SeasonSegmentKey } from '../../season/season-types';
|
||||||
|
import { childScope, intIn, makeParser } from './parse-helpers';
|
||||||
|
import type { InspectionIntervalConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
type IntervalSegment = Exclude<SeasonSegmentKey, 'WINTERRUHE'>;
|
||||||
|
|
||||||
|
const MIN_INTERVAL_DAYS = 7;
|
||||||
|
const MAX_INTERVAL_DAYS = 35;
|
||||||
|
/** In the swarm season a colony is looked at weekly, at the latest every 9 days. */
|
||||||
|
const MAX_SWARM_INTERVAL_DAYS = 9;
|
||||||
|
|
||||||
|
export const DEFAULT_INSPECTION_INTERVAL_CONFIG: InspectionIntervalConfig = {
|
||||||
|
intervalDays: {
|
||||||
|
FRUEHJAHR: 14,
|
||||||
|
SCHWARMZEIT: 7,
|
||||||
|
SOMMER: 14,
|
||||||
|
SPAETSOMMER: 21,
|
||||||
|
HERBST: 21,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEGMENTS = Object.keys(
|
||||||
|
DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays,
|
||||||
|
) as IntervalSegment[];
|
||||||
|
|
||||||
|
export const inspectionIntervalParser = makeParser<InspectionIntervalConfig>(
|
||||||
|
'INSPECTION_INTERVAL',
|
||||||
|
DEFAULT_INSPECTION_INTERVAL_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const days = childScope(scope, 'intervalDays');
|
||||||
|
const defaults = DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays;
|
||||||
|
const intervalDays = { ...defaults };
|
||||||
|
for (const segment of SEGMENTS) {
|
||||||
|
intervalDays[segment] = intIn(
|
||||||
|
days,
|
||||||
|
segment,
|
||||||
|
defaults[segment],
|
||||||
|
MIN_INTERVAL_DAYS,
|
||||||
|
segment === 'SCHWARMZEIT' ? MAX_SWARM_INTERVAL_DAYS : MAX_INTERVAL_DAYS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { intervalDays };
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { RuleConfigIssue, RuleConfigParser } from './rule-config.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a value is read from: the object, its field path (for messages) and
|
||||||
|
* the list the issues go to. Helpers never throw - an invalid or missing key
|
||||||
|
* gives the fallback, and an invalid one that is present is reported.
|
||||||
|
*/
|
||||||
|
export interface Scope {
|
||||||
|
readonly raw: Readonly<Record<string, unknown>>;
|
||||||
|
readonly path: string;
|
||||||
|
readonly issues: RuleConfigIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_VALUES: Readonly<Record<string, unknown>> = Object.freeze({});
|
||||||
|
|
||||||
|
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rootScope(json: unknown, issues: RuleConfigIssue[]): Scope {
|
||||||
|
return { raw: asRecord(json) ?? NO_VALUES, path: '', issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fieldPath(scope: Scope, key: string): string {
|
||||||
|
return scope.path === '' ? key : `${scope.path}.${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function report(scope: Scope, key: string, message: string): void {
|
||||||
|
scope.issues.push({ field: fieldPath(scope, key), message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A key counts as given when it is present - an explicit null too, which the
|
||||||
|
* readers then report as invalid instead of silently using the default.
|
||||||
|
*/
|
||||||
|
export function has(scope: Scope, key: string): boolean {
|
||||||
|
if (!Object.hasOwn(scope.raw, key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return scope.raw[key] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An explicit null: on an optional key it clears the value. */
|
||||||
|
export function isCleared(scope: Scope, key: string): boolean {
|
||||||
|
return Object.hasOwn(scope.raw, key) && scope.raw[key] === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nested object under `key`; a non-object is reported and read as empty. */
|
||||||
|
export function childScope(scope: Scope, key: string): Scope {
|
||||||
|
const path = fieldPath(scope, key);
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return { raw: NO_VALUES, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
const nested = asRecord(scope.raw[key]);
|
||||||
|
if (nested === null) {
|
||||||
|
report(scope, key, 'Bitte die Angaben als Gruppe senden.');
|
||||||
|
return { raw: NO_VALUES, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
return { raw: nested, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `read`; if it reported anything, `fallback()` replaces its result. */
|
||||||
|
export function orFallback<T>(
|
||||||
|
scope: Scope,
|
||||||
|
read: () => T,
|
||||||
|
fallback: () => T,
|
||||||
|
): T {
|
||||||
|
const before = scope.issues.length;
|
||||||
|
const value = read();
|
||||||
|
return scope.issues.length > before ? fallback() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberFormat = new Intl.NumberFormat('de-DE', {
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDe(value: number): string {
|
||||||
|
return numberFormat.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'number' &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= min &&
|
||||||
|
value <= max
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
`Bitte eine ganze Zahl zwischen ${formatDe(min)} und ${formatDe(max)} wählen.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like intIn, but an absent key stays undefined. */
|
||||||
|
export function optionalIntIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
): number | undefined {
|
||||||
|
if (!has(scope, key) || isCleared(scope, key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = intIn(scope, key, Number.NaN, min, max);
|
||||||
|
return Number.isNaN(value) ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberOptions {
|
||||||
|
/** The lower limit itself is not allowed (min < x). */
|
||||||
|
exclusiveMin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
options: NumberOptions = {},
|
||||||
|
): number {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'number' &&
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
(options.exclusiveMin ? value > min : value >= min) &&
|
||||||
|
value <= max
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const lower = options.exclusiveMin
|
||||||
|
? `größer als ${formatDe(min)}`
|
||||||
|
: `mindestens ${formatDe(min)}`;
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
Number.isFinite(max)
|
||||||
|
? `Bitte eine Zahl ${lower} und höchstens ${formatDe(max)} angeben.`
|
||||||
|
: `Bitte eine Zahl ${lower} angeben.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like numberIn, but an absent key stays undefined. */
|
||||||
|
export function optionalNumberIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
options: NumberOptions = {},
|
||||||
|
): number | undefined {
|
||||||
|
if (!has(scope, key) || isCleared(scope, key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = numberIn(scope, key, Number.NaN, min, max, options);
|
||||||
|
return Number.isNaN(value) ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bool(scope: Scope, key: string, fallback: boolean): boolean {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte Ja oder Nein wählen.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringIn(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: string,
|
||||||
|
minLength: number,
|
||||||
|
maxLength: number,
|
||||||
|
pattern?: RegExp,
|
||||||
|
): string {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
value.length >= minLength &&
|
||||||
|
value.length <= maxLength &&
|
||||||
|
(pattern === undefined || pattern.test(value))
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
key,
|
||||||
|
`Bitte einen Text mit ${minLength} bis ${maxLength} Zeichen angeben.`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_DAY = /^(\d{2})-(\d{2})$/;
|
||||||
|
|
||||||
|
/** 'MM-DD' that exists in a leap year (02-29 is allowed). */
|
||||||
|
export function isMonthDay(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const match = MONTH_DAY.exec(value);
|
||||||
|
if (!match) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const month = Number(match[1]);
|
||||||
|
const day = Number(match[2]);
|
||||||
|
const date = new Date(Date.UTC(2024, month - 1, day));
|
||||||
|
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthDay(scope: Scope, key: string, fallback: string): string {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (isMonthDay(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte ein Datum als MM-TT angeben, zum Beispiel 03-20.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function months(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: readonly number[],
|
||||||
|
): number[] {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(month): month is number =>
|
||||||
|
typeof month === 'number' &&
|
||||||
|
Number.isInteger(month) &&
|
||||||
|
month >= 1 &&
|
||||||
|
month <= 12,
|
||||||
|
) &&
|
||||||
|
new Set(value).size === value.length
|
||||||
|
) {
|
||||||
|
return [...value];
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte Monate von 1 bis 12 angeben, jeden nur einmal.');
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enumValue<T extends string>(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: T,
|
||||||
|
allowed: readonly T[],
|
||||||
|
): T {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
(allowed as readonly string[]).includes(value)
|
||||||
|
) {
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte einen gültigen Wert wählen.');
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enumList<T extends string>(
|
||||||
|
scope: Scope,
|
||||||
|
key: string,
|
||||||
|
fallback: readonly T[],
|
||||||
|
allowed: readonly T[],
|
||||||
|
): T[] {
|
||||||
|
if (!has(scope, key)) {
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
const value = scope.raw[key];
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.every(
|
||||||
|
(entry): entry is T =>
|
||||||
|
typeof entry === 'string' &&
|
||||||
|
(allowed as readonly string[]).includes(entry),
|
||||||
|
) &&
|
||||||
|
new Set(value).size === value.length
|
||||||
|
) {
|
||||||
|
return [...value];
|
||||||
|
}
|
||||||
|
report(scope, key, 'Bitte nur gültige Werte wählen, jeden nur einmal.');
|
||||||
|
return [...fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logger = new Logger('RuleConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the parser of one rule from its reader. The reader turns the scope
|
||||||
|
* into a complete config and reports every invalid key; `parse` logs those
|
||||||
|
* (trigger and field, never the value) and returns the config, `validate`
|
||||||
|
* returns them.
|
||||||
|
*/
|
||||||
|
export function makeParser<T>(
|
||||||
|
trigger: string,
|
||||||
|
defaults: T,
|
||||||
|
read: (scope: Scope) => T,
|
||||||
|
): RuleConfigParser<T> {
|
||||||
|
return {
|
||||||
|
defaults,
|
||||||
|
parse(json) {
|
||||||
|
const issues: RuleConfigIssue[] = [];
|
||||||
|
const value = read(rootScope(json, issues));
|
||||||
|
for (const issue of issues) {
|
||||||
|
logger.warn(
|
||||||
|
`Invalid setting ${trigger}.${issue.field}; using the default`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
validate(json) {
|
||||||
|
const issues: RuleConfigIssue[] = [];
|
||||||
|
read(rootScope(json, issues));
|
||||||
|
return issues;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
type PhenologyPhaseKey,
|
||||||
|
} from '../../season/season-types';
|
||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
enumList,
|
||||||
|
enumValue,
|
||||||
|
has,
|
||||||
|
isCleared,
|
||||||
|
makeParser,
|
||||||
|
monthDay,
|
||||||
|
numberIn,
|
||||||
|
report,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { PhenologyConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
/** Upper limit of a growing-degree-day threshold; far above any phase. */
|
||||||
|
const MAX_GTS_THRESHOLD = 5000;
|
||||||
|
const MAX_GAP_SHARE = 0.5;
|
||||||
|
|
||||||
|
export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = {
|
||||||
|
useGts: true,
|
||||||
|
gtsThresholds: { SALWEIDE: 200 },
|
||||||
|
calibrateFromReports: true,
|
||||||
|
calibratePhases: ['KIRSCHE', 'APFEL'],
|
||||||
|
calendarFallback: {
|
||||||
|
SALWEIDE: '03-20',
|
||||||
|
KIRSCHE: '04-15',
|
||||||
|
APFEL: '04-28',
|
||||||
|
HOLUNDER: '05-30',
|
||||||
|
LINDE: '06-20',
|
||||||
|
TRACHTENDE: '08-01',
|
||||||
|
HERBST: '09-15',
|
||||||
|
WINTER: '11-15',
|
||||||
|
},
|
||||||
|
swarmSeason: { from: 'KIRSCHE', until: 'LINDE' },
|
||||||
|
maxGapShare: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] {
|
||||||
|
// Without the group the defaults apply; with it, only the phases given
|
||||||
|
// have a threshold - leaving one out removes it.
|
||||||
|
if (!has(scope, 'gtsThresholds')) {
|
||||||
|
return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds };
|
||||||
|
}
|
||||||
|
const thresholds = childScope(scope, 'gtsThresholds');
|
||||||
|
const result: PhenologyConfig['gtsThresholds'] = {};
|
||||||
|
for (const phase of PHENOLOGY_PHASES) {
|
||||||
|
if (isCleared(thresholds, phase)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = numberIn(
|
||||||
|
thresholds,
|
||||||
|
phase,
|
||||||
|
Number.NaN,
|
||||||
|
0,
|
||||||
|
MAX_GTS_THRESHOLD,
|
||||||
|
{ exclusiveMin: true },
|
||||||
|
);
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
result[phase] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar dates of the phases must not run backwards in phase order.
|
||||||
|
* One violation sends the whole map back to the defaults, so it stays
|
||||||
|
* consistent; the issue names a key the caller actually sent.
|
||||||
|
*/
|
||||||
|
function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] {
|
||||||
|
const calendar = childScope(scope, 'calendarFallback');
|
||||||
|
const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
|
||||||
|
const before = scope.issues.length;
|
||||||
|
const result = { ...defaults };
|
||||||
|
for (const phase of PHENOLOGY_PHASES) {
|
||||||
|
result[phase] = monthDay(calendar, phase, defaults[phase]);
|
||||||
|
}
|
||||||
|
if (scope.issues.length > before) {
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) {
|
||||||
|
const previous = PHENOLOGY_PHASES[index - 1];
|
||||||
|
const phase = PHENOLOGY_PHASES[index];
|
||||||
|
if (result[phase] < result[previous]) {
|
||||||
|
const culprit = has(calendar, phase) ? phase : previous;
|
||||||
|
report(
|
||||||
|
calendar,
|
||||||
|
culprit,
|
||||||
|
`Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`,
|
||||||
|
);
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] {
|
||||||
|
const season = childScope(scope, 'swarmSeason');
|
||||||
|
const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason;
|
||||||
|
const from: PhenologyPhaseKey = enumValue(
|
||||||
|
season,
|
||||||
|
'from',
|
||||||
|
defaults.from,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
);
|
||||||
|
const until: PhenologyPhaseKey = enumValue(
|
||||||
|
season,
|
||||||
|
'until',
|
||||||
|
defaults.until,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
);
|
||||||
|
if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) {
|
||||||
|
report(
|
||||||
|
season,
|
||||||
|
has(season, 'from') ? 'from' : 'until',
|
||||||
|
'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.',
|
||||||
|
);
|
||||||
|
return { ...defaults };
|
||||||
|
}
|
||||||
|
return { from, until };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const phenologyParser = makeParser<PhenologyConfig>(
|
||||||
|
'PHENOLOGY',
|
||||||
|
DEFAULT_PHENOLOGY_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_PHENOLOGY_CONFIG;
|
||||||
|
return {
|
||||||
|
useGts: bool(scope, 'useGts', d.useGts),
|
||||||
|
gtsThresholds: readThresholds(scope),
|
||||||
|
calibrateFromReports: bool(
|
||||||
|
scope,
|
||||||
|
'calibrateFromReports',
|
||||||
|
d.calibrateFromReports,
|
||||||
|
),
|
||||||
|
calibratePhases: enumList(
|
||||||
|
scope,
|
||||||
|
'calibratePhases',
|
||||||
|
d.calibratePhases,
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
),
|
||||||
|
calendarFallback: readCalendar(scope),
|
||||||
|
swarmSeason: readSwarmSeason(scope),
|
||||||
|
maxGapShare: numberIn(
|
||||||
|
scope,
|
||||||
|
'maxGapShare',
|
||||||
|
d.maxGapShare,
|
||||||
|
0,
|
||||||
|
MAX_GAP_SHARE,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Prisma } from '../../../generated/prisma';
|
||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { assertTransactionClient, type DbClient } from '../../prisma/db-client';
|
||||||
|
import { lockRowForUpdate } from '../../prisma/row-lock';
|
||||||
|
import { DEFAULT_RULES } from './default-rules';
|
||||||
|
import { ruleConfigOf } from './rule-config.registry';
|
||||||
|
import type { RuleConfigMap } from './rule-config.types';
|
||||||
|
|
||||||
|
export interface ActiveRule<K extends AutomationTriggerType> {
|
||||||
|
/** null while no row exists yet: the built-in setting applies. */
|
||||||
|
id: string | null;
|
||||||
|
active: boolean;
|
||||||
|
config: RuleConfigMap[K];
|
||||||
|
actionTemplate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuleRow {
|
||||||
|
id: string;
|
||||||
|
active: boolean;
|
||||||
|
config: Prisma.JsonValue;
|
||||||
|
actionTemplate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULE_COLUMNS = {
|
||||||
|
id: true,
|
||||||
|
active: true,
|
||||||
|
config: true,
|
||||||
|
actionTemplate: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function toActiveRule<K extends AutomationTriggerType>(
|
||||||
|
trigger: K,
|
||||||
|
row: RuleRow | null,
|
||||||
|
): ActiveRule<K> {
|
||||||
|
if (row === null) {
|
||||||
|
const defaults = DEFAULT_RULES[trigger];
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
active: defaults.active ?? true,
|
||||||
|
config: ruleConfigOf(trigger, defaults.config),
|
||||||
|
actionTemplate: defaults.actionTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
active: row.active,
|
||||||
|
config: ruleConfigOf(trigger, row.config),
|
||||||
|
actionTemplate: row.actionTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule as it is in force: its row, or the built-in setting while there is
|
||||||
|
* none. The config is parsed, so every key is valid.
|
||||||
|
*/
|
||||||
|
export async function readRule<K extends AutomationTriggerType>(
|
||||||
|
db: DbClient,
|
||||||
|
trigger: K,
|
||||||
|
): Promise<ActiveRule<K>> {
|
||||||
|
const row = await db.automationRule.findUnique({
|
||||||
|
where: { triggerType: trigger },
|
||||||
|
select: RULE_COLUMNS,
|
||||||
|
});
|
||||||
|
return toActiveRule(trigger, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the rule row if it is missing (without touching an existing one),
|
||||||
|
* locks it until the transaction ends and reads it. Serialises everything
|
||||||
|
* that decides "is there already an open task/suggestion" under the rule.
|
||||||
|
*/
|
||||||
|
export async function lockRuleIn<K extends AutomationTriggerType>(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
trigger: K,
|
||||||
|
): Promise<ActiveRule<K> & { id: string }> {
|
||||||
|
assertTransactionClient(tx, 'lockRuleIn');
|
||||||
|
const defaults = DEFAULT_RULES[trigger];
|
||||||
|
await tx.automationRule.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
triggerType: trigger,
|
||||||
|
active: defaults.active ?? true,
|
||||||
|
config: defaults.config as Prisma.InputJsonValue,
|
||||||
|
actionTemplate: defaults.actionTemplate,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
const { id } = await tx.automationRule.findUniqueOrThrow({
|
||||||
|
where: { triggerType: trigger },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await lockRowForUpdate(tx, 'AutomationRule', id);
|
||||||
|
// Read after the lock: a concurrent edit of the rule is visible now.
|
||||||
|
const row = await tx.automationRule.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: RULE_COLUMNS,
|
||||||
|
});
|
||||||
|
return { ...toActiveRule(trigger, row), id };
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
chainParser,
|
||||||
|
colonyEndedCleanupParser,
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
withdrawalEndedParser,
|
||||||
|
} from './chain.config';
|
||||||
|
import { inspectionIntervalParser } from './inspection-interval.config';
|
||||||
|
import { phenologyParser } from './phenology.config';
|
||||||
|
import { seasonAutopilotParser } from './season-autopilot.config';
|
||||||
|
import { numberFormatParser, simpleParser } from './simple.config';
|
||||||
|
import { splitScheduleParser } from './split-schedule.config';
|
||||||
|
import { varroaThresholdParser } from './varroa-threshold.config';
|
||||||
|
import { waterContentParser } from './water-content.config';
|
||||||
|
import type {
|
||||||
|
RuleConfigIssue,
|
||||||
|
RuleConfigMap,
|
||||||
|
RuleConfigParser,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
/** The number-format rules keep the defaults they always had. */
|
||||||
|
const DEFAULT_BATCH_FORMAT = 'WH-{YYYY}-{SEQ:4}';
|
||||||
|
|
||||||
|
export const RULE_CONFIG_PARSERS: {
|
||||||
|
[K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]>;
|
||||||
|
} = {
|
||||||
|
VARROA_THRESHOLD: varroaThresholdParser,
|
||||||
|
INSPECTION_INTERVAL: inspectionIntervalParser,
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: chainParser(
|
||||||
|
'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
),
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: chainParser(
|
||||||
|
'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
DEFAULT_CHAIN_CONFIG,
|
||||||
|
),
|
||||||
|
SPLIT_SCHEDULE: splitScheduleParser,
|
||||||
|
COLONY_ENDED_CLEANUP: colonyEndedCleanupParser,
|
||||||
|
WITHDRAWAL_ENDED: withdrawalEndedParser,
|
||||||
|
HONEY_WATER_CONTENT: waterContentParser,
|
||||||
|
SEASON_AUTOPILOT: seasonAutopilotParser,
|
||||||
|
PHENOLOGY: phenologyParser,
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'HONEY_BATCH_NUMBER_FORMAT',
|
||||||
|
DEFAULT_BATCH_FORMAT,
|
||||||
|
),
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'BOTTLING_LOT_NUMBER_FORMAT',
|
||||||
|
DEFAULT_BATCH_FORMAT,
|
||||||
|
),
|
||||||
|
// {ART} (WA/PR) gives wax and propolis a counter each.
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||||
|
'{ART}-{YYYY}-{SEQ:4}',
|
||||||
|
),
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: numberFormatParser(
|
||||||
|
'PRODUCTION_BATCH_NUMBER_FORMAT',
|
||||||
|
'HS-{YYYY}-{SEQ:4}',
|
||||||
|
),
|
||||||
|
QUEENLESS_NO_EGGS: simpleParser('QUEENLESS_NO_EGGS', { notify: true }),
|
||||||
|
FEED_STATUS_CRITICAL: simpleParser('FEED_STATUS_CRITICAL', {
|
||||||
|
notify: false,
|
||||||
|
}),
|
||||||
|
SWARM_MOOD_HIGH: simpleParser('SWARM_MOOD_HIGH', { notify: false }),
|
||||||
|
COLONY_HEALTH_CRITICAL: simpleParser('COLONY_HEALTH_CRITICAL', {
|
||||||
|
notify: true,
|
||||||
|
}),
|
||||||
|
STOCK_ITEM_LOW: simpleParser('STOCK_ITEM_LOW', { notify: false }),
|
||||||
|
RAW_MATERIAL_LOW: simpleParser('RAW_MATERIAL_LOW', {
|
||||||
|
notify: false,
|
||||||
|
withMinGrams: true,
|
||||||
|
}),
|
||||||
|
TREATMENT_FOLLOW_UP: simpleParser('TREATMENT_FOLLOW_UP', { notify: false }),
|
||||||
|
FEEDING_FOLLOW_UP: simpleParser('FEEDING_FOLLOW_UP', { notify: false }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The effective setting of a rule: valid keys as stored, the rest default. */
|
||||||
|
export function ruleConfigOf<K extends AutomationTriggerType>(
|
||||||
|
trigger: K,
|
||||||
|
json: unknown,
|
||||||
|
): RuleConfigMap[K] {
|
||||||
|
return RULE_CONFIG_PARSERS[trigger].parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The invalid known keys of a config a client wants to save. */
|
||||||
|
export function validateRuleConfig(
|
||||||
|
trigger: AutomationTriggerType,
|
||||||
|
json: unknown,
|
||||||
|
): RuleConfigIssue[] {
|
||||||
|
return RULE_CONFIG_PARSERS[trigger].validate(json);
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { AutomationTriggerType } from '../../../generated/prisma';
|
||||||
|
import { DEFAULT_RULES } from './default-rules';
|
||||||
|
import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index';
|
||||||
|
|
||||||
|
const ALL_TRIGGERS = Object.values(AutomationTriggerType);
|
||||||
|
|
||||||
|
function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] {
|
||||||
|
return validateRuleConfig(trigger, config).map((issue) => issue.field);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rule config', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('every trigger has defaults and a parser', () => {
|
||||||
|
for (const t of ALL_TRIGGERS) {
|
||||||
|
expect(DEFAULT_RULES[t]).toBeDefined();
|
||||||
|
expect(RULE_CONFIG_PARSERS[t]).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(ALL_TRIGGERS)(
|
||||||
|
'the default config of %s parses to itself and validates without issues',
|
||||||
|
(trigger) => {
|
||||||
|
const { config } = DEFAULT_RULES[trigger];
|
||||||
|
expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config);
|
||||||
|
expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config);
|
||||||
|
expect(validateRuleConfig(trigger, config)).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(ALL_TRIGGERS)(
|
||||||
|
'%s falls back to its defaults for null, arrays and scalars',
|
||||||
|
(trigger) => {
|
||||||
|
const { config } = DEFAULT_RULES[trigger];
|
||||||
|
for (const junk of [null, undefined, [], 'x', 5]) {
|
||||||
|
expect(ruleConfigOf(trigger, junk)).toEqual(config);
|
||||||
|
expect(validateRuleConfig(trigger, junk)).toEqual([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall,
|
||||||
|
).toEqual({
|
||||||
|
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
yellowFrom: 5,
|
||||||
|
redAbove: 7,
|
||||||
|
winterRedAbove: 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('lets the band format win over the legacy key', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 7,
|
||||||
|
naturalFall: { redAbove: 9 },
|
||||||
|
}).naturalFall.redAbove,
|
||||||
|
).toBe(9));
|
||||||
|
|
||||||
|
it('ignores an invalid legacy threshold', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall
|
||||||
|
.redAbove,
|
||||||
|
).toBe(10));
|
||||||
|
|
||||||
|
it('reports an invalid legacy threshold with field and message', () => {
|
||||||
|
const issues = validateRuleConfig('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 0,
|
||||||
|
});
|
||||||
|
expect(issues).toEqual([
|
||||||
|
{
|
||||||
|
field: 'thresholdMitesPerDay',
|
||||||
|
message: expect.any(String) as string,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
|
||||||
|
expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fields({ infestationPercent: { summer: { redAbove: 101 } } }),
|
||||||
|
).toEqual(['infestationPercent.summer.redAbove']);
|
||||||
|
expect(
|
||||||
|
fields({ infestationPercent: { autumn: { redAbove: 100 } } }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an explicit null on a known key', () => {
|
||||||
|
expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([
|
||||||
|
'queenMaxAgeYears',
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([
|
||||||
|
'swarmSeason',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts null on optional keys as clearing them', () => {
|
||||||
|
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]);
|
||||||
|
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('RAW_MATERIAL_LOW', {
|
||||||
|
minGrams: { BEESWAX: null, PROPOLIS: 50 },
|
||||||
|
}),
|
||||||
|
).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } });
|
||||||
|
expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SPLIT_SCHEDULE', {
|
||||||
|
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }],
|
||||||
|
}).QUEEN,
|
||||||
|
).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } })
|
||||||
|
.gtsThresholds,
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a client remove a gts threshold by leaving it out', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } })
|
||||||
|
.gtsThresholds,
|
||||||
|
).toEqual({ KIRSCHE: 400 });
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds,
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps yellowFrom at or below a legacy threshold under five', () => {
|
||||||
|
const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', {
|
||||||
|
thresholdMitesPerDay: 3,
|
||||||
|
});
|
||||||
|
expect(naturalFall.redAbove).toBe(3);
|
||||||
|
expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back per invalid key and keeps the valid ones', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', {
|
||||||
|
queenMaxAgeYears: 'x',
|
||||||
|
honeySuperMinFrames: 10,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
|
||||||
|
|
||||||
|
it('logs a warning with trigger and field but without the value', () => {
|
||||||
|
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' });
|
||||||
|
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n');
|
||||||
|
expect(logged).toContain('SEASON_AUTOPILOT');
|
||||||
|
expect(logged).toContain('queenMaxAgeYears');
|
||||||
|
expect(logged).not.toContain('secret-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects SCHWARMZEIT 10 days with the field name', () =>
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('INSPECTION_INTERVAL', {
|
||||||
|
intervalDays: { SCHWARMZEIT: 10 },
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
field: 'intervalDays.SCHWARMZEIT',
|
||||||
|
message: expect.stringContaining('7') as string,
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
|
||||||
|
it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => {
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('INSPECTION_INTERVAL', {
|
||||||
|
intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 },
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }),
|
||||||
|
).toEqual(['intervalDays.SOMMER']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }),
|
||||||
|
).toEqual(['intervalDays.HERBST']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges a partial interval config over the defaults', () =>
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }),
|
||||||
|
).toEqual({
|
||||||
|
intervalDays: {
|
||||||
|
FRUEHJAHR: 14,
|
||||||
|
SCHWARMZEIT: 7,
|
||||||
|
SOMMER: 10,
|
||||||
|
SPAETSOMMER: 21,
|
||||||
|
HERBST: 21,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('rejects yellowFrom above redAbove', () =>
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
naturalFall: { yellowFrom: 12, redAbove: 8 },
|
||||||
|
}),
|
||||||
|
).toEqual(['naturalFall.yellowFrom']));
|
||||||
|
|
||||||
|
it('rejects percent bands where yellowFrom is above redAbove', () =>
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } },
|
||||||
|
}),
|
||||||
|
).toEqual(['infestationPercent.summer.yellowFrom']));
|
||||||
|
|
||||||
|
it('rejects duplicate or out-of-range months', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }),
|
||||||
|
).toEqual(['naturalFall.seasonMonths']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('VARROA_THRESHOLD', {
|
||||||
|
infestationPercent: { summerMonths: [0, 13] },
|
||||||
|
}),
|
||||||
|
).toEqual(['infestationPercent.summerMonths']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the varroa limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
|
||||||
|
expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']);
|
||||||
|
expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']);
|
||||||
|
expect(fields({ youngColonyFactor: 1 })).toEqual([]);
|
||||||
|
expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']);
|
||||||
|
expect(fields({ recheckAfterDays: 0 })).toEqual([]);
|
||||||
|
expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([
|
||||||
|
'youngColonyTypes',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([
|
||||||
|
'naturalFall.redAbove',
|
||||||
|
]);
|
||||||
|
expect(fields({ notify: 'yes' })).toEqual(['notify']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no issue for keys it does not know', () =>
|
||||||
|
expect(
|
||||||
|
validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }),
|
||||||
|
).toEqual([]));
|
||||||
|
|
||||||
|
it('rejects a calendar fallback that runs backwards', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }),
|
||||||
|
).toEqual(['calendarFallback.KIRSCHE']);
|
||||||
|
// The whole map falls back so the phases stay in order.
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } })
|
||||||
|
.calendarFallback,
|
||||||
|
).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid month-day', () => {
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }),
|
||||||
|
).toEqual(['calendarFallback.SALWEIDE']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }),
|
||||||
|
).toEqual(['calendarFallback.SALWEIDE']);
|
||||||
|
expect(
|
||||||
|
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the phenology limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('PHENOLOGY', config);
|
||||||
|
expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']);
|
||||||
|
expect(fields({ maxGapShare: 0.5 })).toEqual([]);
|
||||||
|
expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([
|
||||||
|
'gtsThresholds.SALWEIDE',
|
||||||
|
]);
|
||||||
|
expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]);
|
||||||
|
expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([
|
||||||
|
'calibratePhases',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }),
|
||||||
|
).toEqual(['swarmSeason.from']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults gtsThresholds to SALWEIDE 200 only', () =>
|
||||||
|
expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({
|
||||||
|
SALWEIDE: 200,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('checks the season autopilot limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config);
|
||||||
|
expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']);
|
||||||
|
expect(fields({ honeySuperMinFrames: 41 })).toEqual([
|
||||||
|
'honeySuperMinFrames',
|
||||||
|
]);
|
||||||
|
expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([
|
||||||
|
'varroaMeasureIntervalDays',
|
||||||
|
]);
|
||||||
|
expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']);
|
||||||
|
expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']);
|
||||||
|
expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']);
|
||||||
|
expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']);
|
||||||
|
expect(
|
||||||
|
fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }),
|
||||||
|
).toEqual(['winterVarroaWindow.from']);
|
||||||
|
expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']);
|
||||||
|
expect(fields({ rules: { A3: false } })).toEqual([]);
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules,
|
||||||
|
).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the water content limits', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config);
|
||||||
|
expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']);
|
||||||
|
expect(fields({ confirmAbovePercent: 31 })).toEqual([
|
||||||
|
'confirmAbovePercent',
|
||||||
|
]);
|
||||||
|
expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']);
|
||||||
|
expect(
|
||||||
|
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }),
|
||||||
|
).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the split schedule steps', () => {
|
||||||
|
const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config);
|
||||||
|
expect(
|
||||||
|
fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }),
|
||||||
|
).toEqual(['QUEEN[0].offsetDays']);
|
||||||
|
expect(
|
||||||
|
fields({
|
||||||
|
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }],
|
||||||
|
}),
|
||||||
|
).toEqual(['QUEEN_CELL[0].windowDays']);
|
||||||
|
expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([
|
||||||
|
'RAISE_OWN[0].step',
|
||||||
|
]);
|
||||||
|
expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']);
|
||||||
|
expect(
|
||||||
|
fields({
|
||||||
|
RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks dueInDays of chains and simple rules', () => {
|
||||||
|
expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([
|
||||||
|
'dueInDays',
|
||||||
|
]);
|
||||||
|
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([
|
||||||
|
'dueInDays',
|
||||||
|
]);
|
||||||
|
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({
|
||||||
|
notify: false,
|
||||||
|
dueInDays: 14,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads minGrams per raw material kind', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }),
|
||||||
|
).toEqual({ notify: false, minGrams: { BEESWAX: 500 } });
|
||||||
|
expect(
|
||||||
|
fieldsOf('RAW_MATERIAL_LOW', {
|
||||||
|
minGrams: { BEESWAX: -5, PROPOLIS: 'x' },
|
||||||
|
}),
|
||||||
|
).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a number format and rejects an empty or oversized one', () => {
|
||||||
|
expect(
|
||||||
|
ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }),
|
||||||
|
).toEqual({ format: 'HB-{SEQ:3}' });
|
||||||
|
expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([
|
||||||
|
'format',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }),
|
||||||
|
).toEqual(['format']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => {
|
||||||
|
const withNotify = ALL_TRIGGERS.filter(
|
||||||
|
(trigger) =>
|
||||||
|
(ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true,
|
||||||
|
);
|
||||||
|
expect([...withNotify].sort()).toEqual(
|
||||||
|
[
|
||||||
|
'COLONY_HEALTH_CRITICAL',
|
||||||
|
'QUEENLESS_NO_EGGS',
|
||||||
|
'VARROA_THRESHOLD',
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the optional rules switched off', () => {
|
||||||
|
const off = ALL_TRIGGERS.filter(
|
||||||
|
(trigger) => DEFAULT_RULES[trigger].active === false,
|
||||||
|
).sort();
|
||||||
|
expect(off).toEqual([
|
||||||
|
'FEEDING_FOLLOW_UP',
|
||||||
|
'RAW_MATERIAL_LOW',
|
||||||
|
'TREATMENT_FOLLOW_UP',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every new rule an empty action template', () => {
|
||||||
|
for (const trigger of [
|
||||||
|
'INSPECTION_INTERVAL',
|
||||||
|
'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
'SPLIT_SCHEDULE',
|
||||||
|
'COLONY_ENDED_CLEANUP',
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
'HONEY_WATER_CONTENT',
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
'PHENOLOGY',
|
||||||
|
] as const) {
|
||||||
|
expect(DEFAULT_RULES[trigger].actionTemplate).toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type { ColonyType, RawMaterialKind } from '../../../generated/prisma';
|
||||||
|
import type {
|
||||||
|
PhenologyPhaseKey,
|
||||||
|
SeasonSegmentKey,
|
||||||
|
} from '../../season/season-types';
|
||||||
|
|
||||||
|
/** One invalid setting of a rule: the field path and a German message. */
|
||||||
|
export interface RuleConfigIssue {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleConfigParser<T> {
|
||||||
|
/** The built-in setting; also what an empty or unusable config reads as. */
|
||||||
|
readonly defaults: T;
|
||||||
|
/** Never throws: each invalid key falls back to its default (and is logged). */
|
||||||
|
parse(json: unknown): T;
|
||||||
|
/** Reports the known keys that are present and invalid; nothing else. */
|
||||||
|
validate(json: unknown): RuleConfigIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimpleRuleConfig {
|
||||||
|
notify: boolean;
|
||||||
|
dueInDays?: number;
|
||||||
|
minGrams?: Partial<Record<RawMaterialKind, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberFormatConfig {
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VarroaThresholdConfig {
|
||||||
|
naturalFall: {
|
||||||
|
seasonMonths: number[];
|
||||||
|
yellowFrom: number;
|
||||||
|
redAbove: number;
|
||||||
|
winterRedAbove: number;
|
||||||
|
};
|
||||||
|
infestationPercent: {
|
||||||
|
summerMonths: number[];
|
||||||
|
summer: { yellowFrom: number; redAbove: number };
|
||||||
|
autumnMonths: number[];
|
||||||
|
autumn: { yellowFrom: number; redAbove: number };
|
||||||
|
};
|
||||||
|
youngColonyFactor: number;
|
||||||
|
youngColonyTypes: ColonyType[];
|
||||||
|
recheckAfterDays: number;
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionIntervalConfig {
|
||||||
|
intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainConfig {
|
||||||
|
dueInDays: number;
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanStepConfig {
|
||||||
|
step: string;
|
||||||
|
offsetDays: number;
|
||||||
|
windowDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitScheduleConfig {
|
||||||
|
RAISE_OWN: PlanStepConfig[];
|
||||||
|
QUEEN_CELL: PlanStepConfig[];
|
||||||
|
QUEEN: PlanStepConfig[];
|
||||||
|
notify: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaterContentConfig {
|
||||||
|
warnAbovePercent: number;
|
||||||
|
confirmAbovePercent: number;
|
||||||
|
byHoneyType: Record<
|
||||||
|
string,
|
||||||
|
{ warnAbovePercent?: number; confirmAbovePercent?: number }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTOPILOT_RULE_IDS = [
|
||||||
|
'A1',
|
||||||
|
'A2',
|
||||||
|
'A3',
|
||||||
|
'A4',
|
||||||
|
'A5',
|
||||||
|
'A6',
|
||||||
|
'A7',
|
||||||
|
'A8',
|
||||||
|
'A9',
|
||||||
|
'A10',
|
||||||
|
] as const;
|
||||||
|
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
|
||||||
|
|
||||||
|
export interface SeasonAutopilotConfig {
|
||||||
|
honeySuperMinFrames: number;
|
||||||
|
varroaMeasureIntervalDays: number;
|
||||||
|
winterVarroaWindow: { from: string; to: string };
|
||||||
|
queenMaxAgeYears: number;
|
||||||
|
suggestionTtlDays: number;
|
||||||
|
maxStepsPerColony: number;
|
||||||
|
rules: Record<AutopilotRuleId, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhenologyConfig {
|
||||||
|
useGts: boolean;
|
||||||
|
gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>;
|
||||||
|
calibrateFromReports: boolean;
|
||||||
|
calibratePhases: PhenologyPhaseKey[];
|
||||||
|
calendarFallback: Record<PhenologyPhaseKey, string>;
|
||||||
|
swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey };
|
||||||
|
maxGapShare: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry per AutomationTriggerType. */
|
||||||
|
export interface RuleConfigMap {
|
||||||
|
VARROA_THRESHOLD: VarroaThresholdConfig;
|
||||||
|
INSPECTION_INTERVAL: InspectionIntervalConfig;
|
||||||
|
QUEEN_CELLS_SWARM_CHECK: ChainConfig;
|
||||||
|
QUEEN_ACCEPTANCE_CHECK: ChainConfig;
|
||||||
|
SPLIT_SCHEDULE: SplitScheduleConfig;
|
||||||
|
COLONY_ENDED_CLEANUP: Record<string, never>;
|
||||||
|
WITHDRAWAL_ENDED: { notify: boolean };
|
||||||
|
HONEY_WATER_CONTENT: WaterContentConfig;
|
||||||
|
SEASON_AUTOPILOT: SeasonAutopilotConfig;
|
||||||
|
PHENOLOGY: PhenologyConfig;
|
||||||
|
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||||||
|
QUEENLESS_NO_EGGS: SimpleRuleConfig;
|
||||||
|
FEED_STATUS_CRITICAL: SimpleRuleConfig;
|
||||||
|
SWARM_MOOD_HIGH: SimpleRuleConfig;
|
||||||
|
COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
|
||||||
|
STOCK_ITEM_LOW: SimpleRuleConfig;
|
||||||
|
RAW_MATERIAL_LOW: SimpleRuleConfig;
|
||||||
|
TREATMENT_FOLLOW_UP: SimpleRuleConfig;
|
||||||
|
FEEDING_FOLLOW_UP: SimpleRuleConfig;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
has,
|
||||||
|
intIn,
|
||||||
|
makeParser,
|
||||||
|
monthDay,
|
||||||
|
report,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import {
|
||||||
|
AUTOPILOT_RULE_IDS,
|
||||||
|
type SeasonAutopilotConfig,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
export const DEFAULT_SEASON_AUTOPILOT_CONFIG: SeasonAutopilotConfig = {
|
||||||
|
honeySuperMinFrames: 8,
|
||||||
|
varroaMeasureIntervalDays: 28,
|
||||||
|
winterVarroaWindow: { from: '11-15', to: '12-20' },
|
||||||
|
queenMaxAgeYears: 2,
|
||||||
|
suggestionTtlDays: 30,
|
||||||
|
maxStepsPerColony: 3,
|
||||||
|
rules: {
|
||||||
|
A1: true,
|
||||||
|
A2: true,
|
||||||
|
A3: true,
|
||||||
|
A4: true,
|
||||||
|
A5: true,
|
||||||
|
A6: true,
|
||||||
|
A7: true,
|
||||||
|
A8: true,
|
||||||
|
A9: true,
|
||||||
|
A10: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seasonAutopilotParser = makeParser<SeasonAutopilotConfig>(
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
DEFAULT_SEASON_AUTOPILOT_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_SEASON_AUTOPILOT_CONFIG;
|
||||||
|
|
||||||
|
const windowScope = childScope(scope, 'winterVarroaWindow');
|
||||||
|
let winterVarroaWindow = {
|
||||||
|
from: monthDay(windowScope, 'from', d.winterVarroaWindow.from),
|
||||||
|
to: monthDay(windowScope, 'to', d.winterVarroaWindow.to),
|
||||||
|
};
|
||||||
|
if (winterVarroaWindow.from > winterVarroaWindow.to) {
|
||||||
|
report(
|
||||||
|
windowScope,
|
||||||
|
has(windowScope, 'from') ? 'from' : 'to',
|
||||||
|
'Das Fenster muss innerhalb eines Jahres liegen: Beginn vor Ende.',
|
||||||
|
);
|
||||||
|
winterVarroaWindow = { ...d.winterVarroaWindow };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleScope = childScope(scope, 'rules');
|
||||||
|
const rules = { ...d.rules };
|
||||||
|
for (const id of AUTOPILOT_RULE_IDS) {
|
||||||
|
rules[id] = bool(ruleScope, id, d.rules[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
honeySuperMinFrames: intIn(
|
||||||
|
scope,
|
||||||
|
'honeySuperMinFrames',
|
||||||
|
d.honeySuperMinFrames,
|
||||||
|
1,
|
||||||
|
40,
|
||||||
|
),
|
||||||
|
varroaMeasureIntervalDays: intIn(
|
||||||
|
scope,
|
||||||
|
'varroaMeasureIntervalDays',
|
||||||
|
d.varroaMeasureIntervalDays,
|
||||||
|
7,
|
||||||
|
90,
|
||||||
|
),
|
||||||
|
winterVarroaWindow,
|
||||||
|
queenMaxAgeYears: intIn(
|
||||||
|
scope,
|
||||||
|
'queenMaxAgeYears',
|
||||||
|
d.queenMaxAgeYears,
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
suggestionTtlDays: intIn(
|
||||||
|
scope,
|
||||||
|
'suggestionTtlDays',
|
||||||
|
d.suggestionTtlDays,
|
||||||
|
1,
|
||||||
|
365,
|
||||||
|
),
|
||||||
|
maxStepsPerColony: intIn(
|
||||||
|
scope,
|
||||||
|
'maxStepsPerColony',
|
||||||
|
d.maxStepsPerColony,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
rules,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { RawMaterialKind } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
has,
|
||||||
|
isCleared,
|
||||||
|
makeParser,
|
||||||
|
numberIn,
|
||||||
|
optionalIntIn,
|
||||||
|
stringIn,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type {
|
||||||
|
NumberFormatConfig,
|
||||||
|
RuleConfigParser,
|
||||||
|
SimpleRuleConfig,
|
||||||
|
} from './rule-config.types';
|
||||||
|
|
||||||
|
const MAX_DUE_IN_DAYS = 365;
|
||||||
|
/** Far above any minimum stock of wax or propolis. */
|
||||||
|
const MAX_MIN_GRAMS = 1_000_000_000;
|
||||||
|
const MAX_NUMBER_FORMAT_LENGTH = 100;
|
||||||
|
const RAW_MATERIAL_KINDS = Object.values(RawMaterialKind);
|
||||||
|
|
||||||
|
export interface SimpleRuleOptions {
|
||||||
|
/** Whether the rule notifies by default. */
|
||||||
|
notify: boolean;
|
||||||
|
/** RAW_MATERIAL_LOW reads a minimum per kind. */
|
||||||
|
withMinGrams?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules that create one task: notify, an optional follow-up interval
|
||||||
|
* (dueInDays) and, for the raw material rule, minGrams per kind.
|
||||||
|
*/
|
||||||
|
export function simpleParser(
|
||||||
|
trigger: string,
|
||||||
|
options: SimpleRuleOptions,
|
||||||
|
): RuleConfigParser<SimpleRuleConfig> {
|
||||||
|
return makeParser<SimpleRuleConfig>(
|
||||||
|
trigger,
|
||||||
|
{ notify: options.notify },
|
||||||
|
(scope) => {
|
||||||
|
const config: SimpleRuleConfig = {
|
||||||
|
notify: bool(scope, 'notify', options.notify),
|
||||||
|
};
|
||||||
|
const dueInDays = optionalIntIn(scope, 'dueInDays', 0, MAX_DUE_IN_DAYS);
|
||||||
|
if (dueInDays !== undefined) {
|
||||||
|
config.dueInDays = dueInDays;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.withMinGrams &&
|
||||||
|
has(scope, 'minGrams') &&
|
||||||
|
!isCleared(scope, 'minGrams')
|
||||||
|
) {
|
||||||
|
const minScope = childScope(scope, 'minGrams');
|
||||||
|
const minGrams: Partial<Record<RawMaterialKind, number>> = {};
|
||||||
|
for (const kind of RAW_MATERIAL_KINDS) {
|
||||||
|
if (isCleared(minScope, kind)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const grams = numberIn(minScope, kind, Number.NaN, 0, MAX_MIN_GRAMS);
|
||||||
|
if (!Number.isNaN(grams)) {
|
||||||
|
minGrams[kind] = grams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(minGrams).length > 0) {
|
||||||
|
config.minGrams = minGrams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The batch/lot number rules: one format string with placeholders. */
|
||||||
|
export function numberFormatParser(
|
||||||
|
trigger: string,
|
||||||
|
defaultFormat: string,
|
||||||
|
): RuleConfigParser<NumberFormatConfig> {
|
||||||
|
return makeParser<NumberFormatConfig>(
|
||||||
|
trigger,
|
||||||
|
{ format: defaultFormat },
|
||||||
|
(scope) => ({
|
||||||
|
format: stringIn(
|
||||||
|
scope,
|
||||||
|
'format',
|
||||||
|
defaultFormat,
|
||||||
|
1,
|
||||||
|
MAX_NUMBER_FORMAT_LENGTH,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
asRecord,
|
||||||
|
bool,
|
||||||
|
fieldPath,
|
||||||
|
has,
|
||||||
|
intIn,
|
||||||
|
makeParser,
|
||||||
|
optionalIntIn,
|
||||||
|
report,
|
||||||
|
stringIn,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { PlanStepConfig, SplitScheduleConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
type SplitVariant = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN';
|
||||||
|
|
||||||
|
const MAX_STEPS_PER_VARIANT = 10;
|
||||||
|
const MAX_OFFSET_DAYS = 120;
|
||||||
|
const MAX_WINDOW_DAYS = 30;
|
||||||
|
const STEP_NAME = /^[A-Z][A-Z_]{1,39}$/;
|
||||||
|
|
||||||
|
export const DEFAULT_SPLIT_SCHEDULE_CONFIG: SplitScheduleConfig = {
|
||||||
|
RAISE_OWN: [
|
||||||
|
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
||||||
|
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
|
||||||
|
{ step: 'EGGS_CHECK', offsetDays: 28 },
|
||||||
|
],
|
||||||
|
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21 }],
|
||||||
|
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }],
|
||||||
|
notify: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function clonedSteps(variant: SplitVariant): PlanStepConfig[] {
|
||||||
|
return DEFAULT_SPLIT_SCHEDULE_CONFIG[variant].map((step) => ({ ...step }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scope of one list entry: its path is `VARIANT[index]`. */
|
||||||
|
function stepScope(
|
||||||
|
scope: Scope,
|
||||||
|
variant: SplitVariant,
|
||||||
|
index: number,
|
||||||
|
raw: unknown,
|
||||||
|
): Scope | null {
|
||||||
|
const record = asRecord(raw);
|
||||||
|
const path = `${fieldPath(scope, variant)}[${index}]`;
|
||||||
|
if (record === null) {
|
||||||
|
scope.issues.push({
|
||||||
|
field: path,
|
||||||
|
message: 'Bitte jeden Schritt als Gruppe senden.',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { raw: record, path, issues: scope.issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSteps(scope: Scope, variant: SplitVariant): PlanStepConfig[] {
|
||||||
|
if (!has(scope, variant)) {
|
||||||
|
return clonedSteps(variant);
|
||||||
|
}
|
||||||
|
const list = scope.raw[variant];
|
||||||
|
if (!Array.isArray(list) || list.length > MAX_STEPS_PER_VARIANT) {
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
variant,
|
||||||
|
`Bitte eine Liste mit höchstens ${MAX_STEPS_PER_VARIANT} Schritten senden.`,
|
||||||
|
);
|
||||||
|
return clonedSteps(variant);
|
||||||
|
}
|
||||||
|
const before = scope.issues.length;
|
||||||
|
const steps: PlanStepConfig[] = [];
|
||||||
|
list.forEach((entry: unknown, index) => {
|
||||||
|
const item = stepScope(scope, variant, index, entry);
|
||||||
|
if (item === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!has(item, 'step')) {
|
||||||
|
scope.issues.push({
|
||||||
|
field: `${item.path}.step`,
|
||||||
|
message: 'Bitte den Schritt angeben.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const step: PlanStepConfig = {
|
||||||
|
step: stringIn(item, 'step', '', 2, 40, STEP_NAME),
|
||||||
|
offsetDays: intIn(item, 'offsetDays', 0, 0, MAX_OFFSET_DAYS),
|
||||||
|
};
|
||||||
|
const windowDays = optionalIntIn(item, 'windowDays', 0, MAX_WINDOW_DAYS);
|
||||||
|
if (windowDays !== undefined) {
|
||||||
|
step.windowDays = windowDays;
|
||||||
|
}
|
||||||
|
steps.push(step);
|
||||||
|
});
|
||||||
|
// One bad entry makes the whole list unreliable: back to the default plan.
|
||||||
|
return scope.issues.length > before ? clonedSteps(variant) : steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const splitScheduleParser = makeParser<SplitScheduleConfig>(
|
||||||
|
'SPLIT_SCHEDULE',
|
||||||
|
DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||||
|
(scope) => ({
|
||||||
|
RAISE_OWN: readSteps(scope, 'RAISE_OWN'),
|
||||||
|
QUEEN_CELL: readSteps(scope, 'QUEEN_CELL'),
|
||||||
|
QUEEN: readSteps(scope, 'QUEEN'),
|
||||||
|
notify: bool(scope, 'notify', DEFAULT_SPLIT_SCHEDULE_CONFIG.notify),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { ColonyType } from '../../../generated/prisma';
|
||||||
|
import {
|
||||||
|
bool,
|
||||||
|
childScope,
|
||||||
|
enumList,
|
||||||
|
has,
|
||||||
|
intIn,
|
||||||
|
makeParser,
|
||||||
|
months,
|
||||||
|
numberIn,
|
||||||
|
report,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { VarroaThresholdConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
const MAX_RECHECK_DAYS = 60;
|
||||||
|
|
||||||
|
export const DEFAULT_VARROA_THRESHOLD_CONFIG: VarroaThresholdConfig = {
|
||||||
|
naturalFall: {
|
||||||
|
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||||
|
yellowFrom: 5,
|
||||||
|
redAbove: 10,
|
||||||
|
winterRedAbove: 0.5,
|
||||||
|
},
|
||||||
|
infestationPercent: {
|
||||||
|
summerMonths: [5, 6, 7],
|
||||||
|
summer: { yellowFrom: 1, redAbove: 5 },
|
||||||
|
autumnMonths: [8, 9, 10],
|
||||||
|
autumn: { yellowFrom: 2, redAbove: 5 },
|
||||||
|
},
|
||||||
|
youngColonyFactor: 0.5,
|
||||||
|
youngColonyTypes: ['YOUNG_COLONY', 'NUCLEUS_COLONY', 'MATING_NUCLEUS'],
|
||||||
|
recheckAfterDays: 14,
|
||||||
|
notify: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Upper limits: mites per day cannot get near 1000; a percentage stops at 100. */
|
||||||
|
const MAX_MITES_PER_DAY = 1000;
|
||||||
|
const MAX_PERCENT = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key before the bands existed: natural mite fall that counts as red.
|
||||||
|
* A present but unusable value is reported.
|
||||||
|
*/
|
||||||
|
function legacyThreshold(scope: Scope): number | undefined {
|
||||||
|
if (!has(scope, 'thresholdMitesPerDay')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const value = scope.raw.thresholdMitesPerDay;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
'thresholdMitesPerDay',
|
||||||
|
'Bitte eine Zahl größer als 0 angeben.',
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Band {
|
||||||
|
yellowFrom: number;
|
||||||
|
redAbove: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BandLimits {
|
||||||
|
max: number;
|
||||||
|
/** The red limit itself must be above zero. */
|
||||||
|
redAboveZero?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yellowFrom and redAbove of one band. An absent yellowFrom never ends above
|
||||||
|
* redAbove; a given one that does is reported and replaced.
|
||||||
|
*/
|
||||||
|
function readBand(
|
||||||
|
scope: Scope,
|
||||||
|
defaults: Band,
|
||||||
|
limits: BandLimits,
|
||||||
|
legacyRed?: number,
|
||||||
|
): Band {
|
||||||
|
const redAbove = numberIn(
|
||||||
|
scope,
|
||||||
|
'redAbove',
|
||||||
|
legacyRed ?? defaults.redAbove,
|
||||||
|
0,
|
||||||
|
limits.max,
|
||||||
|
{ exclusiveMin: limits.redAboveZero },
|
||||||
|
);
|
||||||
|
const yellowDefault = Math.min(defaults.yellowFrom, redAbove);
|
||||||
|
const yellowFrom = numberIn(
|
||||||
|
scope,
|
||||||
|
'yellowFrom',
|
||||||
|
yellowDefault,
|
||||||
|
0,
|
||||||
|
limits.max,
|
||||||
|
);
|
||||||
|
if (yellowFrom > redAbove) {
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
'yellowFrom',
|
||||||
|
'Der gelbe Wert darf nicht über dem roten Wert liegen.',
|
||||||
|
);
|
||||||
|
return { yellowFrom: yellowDefault, redAbove };
|
||||||
|
}
|
||||||
|
return { yellowFrom, redAbove };
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLONY_TYPES = Object.values(ColonyType);
|
||||||
|
|
||||||
|
export const varroaThresholdParser = makeParser<VarroaThresholdConfig>(
|
||||||
|
'VARROA_THRESHOLD',
|
||||||
|
DEFAULT_VARROA_THRESHOLD_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_VARROA_THRESHOLD_CONFIG;
|
||||||
|
|
||||||
|
const legacy = legacyThreshold(scope);
|
||||||
|
const fall = childScope(scope, 'naturalFall');
|
||||||
|
const fallBand = readBand(
|
||||||
|
fall,
|
||||||
|
d.naturalFall,
|
||||||
|
{ max: MAX_MITES_PER_DAY, redAboveZero: true },
|
||||||
|
legacy,
|
||||||
|
);
|
||||||
|
const naturalFall = {
|
||||||
|
seasonMonths: months(fall, 'seasonMonths', d.naturalFall.seasonMonths),
|
||||||
|
...fallBand,
|
||||||
|
winterRedAbove: numberIn(
|
||||||
|
fall,
|
||||||
|
'winterRedAbove',
|
||||||
|
d.naturalFall.winterRedAbove,
|
||||||
|
0,
|
||||||
|
MAX_MITES_PER_DAY,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const percent = childScope(scope, 'infestationPercent');
|
||||||
|
const infestationPercent = {
|
||||||
|
summerMonths: months(
|
||||||
|
percent,
|
||||||
|
'summerMonths',
|
||||||
|
d.infestationPercent.summerMonths,
|
||||||
|
),
|
||||||
|
summer: readBand(
|
||||||
|
childScope(percent, 'summer'),
|
||||||
|
d.infestationPercent.summer,
|
||||||
|
{ max: MAX_PERCENT },
|
||||||
|
),
|
||||||
|
autumnMonths: months(
|
||||||
|
percent,
|
||||||
|
'autumnMonths',
|
||||||
|
d.infestationPercent.autumnMonths,
|
||||||
|
),
|
||||||
|
autumn: readBand(
|
||||||
|
childScope(percent, 'autumn'),
|
||||||
|
d.infestationPercent.autumn,
|
||||||
|
{ max: MAX_PERCENT },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
naturalFall,
|
||||||
|
infestationPercent,
|
||||||
|
youngColonyFactor: numberIn(
|
||||||
|
scope,
|
||||||
|
'youngColonyFactor',
|
||||||
|
d.youngColonyFactor,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
{ exclusiveMin: true },
|
||||||
|
),
|
||||||
|
youngColonyTypes: enumList(
|
||||||
|
scope,
|
||||||
|
'youngColonyTypes',
|
||||||
|
d.youngColonyTypes,
|
||||||
|
COLONY_TYPES,
|
||||||
|
),
|
||||||
|
recheckAfterDays: intIn(
|
||||||
|
scope,
|
||||||
|
'recheckAfterDays',
|
||||||
|
d.recheckAfterDays,
|
||||||
|
0,
|
||||||
|
MAX_RECHECK_DAYS,
|
||||||
|
),
|
||||||
|
notify: bool(scope, 'notify', d.notify),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
childScope,
|
||||||
|
has,
|
||||||
|
makeParser,
|
||||||
|
numberIn,
|
||||||
|
optionalNumberIn,
|
||||||
|
report,
|
||||||
|
type Scope,
|
||||||
|
} from './parse-helpers';
|
||||||
|
import type { WaterContentConfig } from './rule-config.types';
|
||||||
|
|
||||||
|
const MIN_WATER_PERCENT = 10;
|
||||||
|
const MAX_WATER_PERCENT = 30;
|
||||||
|
const MAX_HONEY_TYPES = 50;
|
||||||
|
const MAX_HONEY_TYPE_LENGTH = 100;
|
||||||
|
|
||||||
|
export const DEFAULT_WATER_CONTENT_CONFIG: WaterContentConfig = {
|
||||||
|
warnAbovePercent: 18,
|
||||||
|
confirmAbovePercent: 20,
|
||||||
|
byHoneyType: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
function percent(scope: Scope, key: string, fallback: number): number {
|
||||||
|
return numberIn(scope, key, fallback, MIN_WATER_PERCENT, MAX_WATER_PERCENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_MESSAGE =
|
||||||
|
'Die Warnschwelle darf nicht über der Bestätigungsschwelle liegen.';
|
||||||
|
|
||||||
|
function readHoneyTypes(scope: Scope): WaterContentConfig['byHoneyType'] {
|
||||||
|
const types = childScope(scope, 'byHoneyType');
|
||||||
|
const names = Object.keys(types.raw);
|
||||||
|
if (names.length > MAX_HONEY_TYPES) {
|
||||||
|
report(scope, 'byHoneyType', `Höchstens ${MAX_HONEY_TYPES} Sorten.`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const entries: [
|
||||||
|
string,
|
||||||
|
{ warnAbovePercent?: number; confirmAbovePercent?: number },
|
||||||
|
][] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
if (name.length === 0 || name.length > MAX_HONEY_TYPE_LENGTH) {
|
||||||
|
report(types, name.slice(0, 30), 'Der Sortenname ist ungültig.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = childScope(types, name);
|
||||||
|
const before = entry.issues.length;
|
||||||
|
const value: { warnAbovePercent?: number; confirmAbovePercent?: number } =
|
||||||
|
{};
|
||||||
|
const warn = optionalNumberIn(
|
||||||
|
entry,
|
||||||
|
'warnAbovePercent',
|
||||||
|
MIN_WATER_PERCENT,
|
||||||
|
MAX_WATER_PERCENT,
|
||||||
|
);
|
||||||
|
const confirm = optionalNumberIn(
|
||||||
|
entry,
|
||||||
|
'confirmAbovePercent',
|
||||||
|
MIN_WATER_PERCENT,
|
||||||
|
MAX_WATER_PERCENT,
|
||||||
|
);
|
||||||
|
if (warn !== undefined && confirm !== undefined && warn > confirm) {
|
||||||
|
report(entry, 'warnAbovePercent', ORDER_MESSAGE);
|
||||||
|
}
|
||||||
|
if (entry.issues.length > before) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (warn !== undefined) {
|
||||||
|
value.warnAbovePercent = warn;
|
||||||
|
}
|
||||||
|
if (confirm !== undefined) {
|
||||||
|
value.confirmAbovePercent = confirm;
|
||||||
|
}
|
||||||
|
entries.push([name, value]);
|
||||||
|
}
|
||||||
|
// fromEntries defines own properties, so a name like "__proto__" is harmless.
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const waterContentParser = makeParser<WaterContentConfig>(
|
||||||
|
'HONEY_WATER_CONTENT',
|
||||||
|
DEFAULT_WATER_CONTENT_CONFIG,
|
||||||
|
(scope) => {
|
||||||
|
const d = DEFAULT_WATER_CONTENT_CONFIG;
|
||||||
|
let warnAbovePercent = percent(
|
||||||
|
scope,
|
||||||
|
'warnAbovePercent',
|
||||||
|
d.warnAbovePercent,
|
||||||
|
);
|
||||||
|
let confirmAbovePercent = percent(
|
||||||
|
scope,
|
||||||
|
'confirmAbovePercent',
|
||||||
|
d.confirmAbovePercent,
|
||||||
|
);
|
||||||
|
if (warnAbovePercent > confirmAbovePercent) {
|
||||||
|
report(
|
||||||
|
scope,
|
||||||
|
has(scope, 'warnAbovePercent')
|
||||||
|
? 'warnAbovePercent'
|
||||||
|
: 'confirmAbovePercent',
|
||||||
|
ORDER_MESSAGE,
|
||||||
|
);
|
||||||
|
warnAbovePercent = d.warnAbovePercent;
|
||||||
|
confirmAbovePercent = d.confirmAbovePercent;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
warnAbovePercent,
|
||||||
|
confirmAbovePercent,
|
||||||
|
byHoneyType: readHoneyTypes(scope),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -26,6 +26,24 @@ describe('varroaThresholdOf', () => {
|
|||||||
).toBe(7);
|
).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads naturalFall.redAbove from the band format', () => {
|
||||||
|
expect(
|
||||||
|
varroaThresholdOf({
|
||||||
|
active: true,
|
||||||
|
config: { naturalFall: { redAbove: 8 } },
|
||||||
|
}),
|
||||||
|
).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the band format over the legacy key', () => {
|
||||||
|
expect(
|
||||||
|
varroaThresholdOf({
|
||||||
|
active: true,
|
||||||
|
config: { thresholdMitesPerDay: 7, naturalFall: { redAbove: 8 } },
|
||||||
|
}),
|
||||||
|
).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['a string', { thresholdMitesPerDay: 'x' }],
|
['a string', { thresholdMitesPerDay: 'x' }],
|
||||||
['zero', { thresholdMitesPerDay: 0 }],
|
['zero', { thresholdMitesPerDay: 0 }],
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import type { AutomationRule } from '../../generated/prisma';
|
import type { AutomationRule } from '../../generated/prisma';
|
||||||
|
import { ruleConfigOf } from './rule-config';
|
||||||
|
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
|
||||||
|
|
||||||
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
|
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
|
||||||
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY = 10;
|
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY =
|
||||||
|
DEFAULT_VARROA_THRESHOLD_CONFIG.naturalFall.redAbove;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effective varroa threshold of the VARROA_THRESHOLD rule.
|
* Effective varroa threshold of the VARROA_THRESHOLD rule: the red limit of
|
||||||
|
* the natural mite fall (naturalFall.redAbove; the legacy key
|
||||||
|
* thresholdMitesPerDay reads the same). Task 7 replaces this with the bands.
|
||||||
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
|
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
|
||||||
* - inactive rule: null (no threshold in force)
|
* - inactive rule: null (no threshold in force)
|
||||||
* - active rule: the configured value, or the default if it is not a
|
* - active rule: the configured value, or the default if it is not usable
|
||||||
* positive finite number (a hand-edited config must never disable the check).
|
* (a hand-edited config must never disable the check).
|
||||||
*/
|
*/
|
||||||
export function varroaThresholdOf(
|
export function varroaThresholdOf(
|
||||||
rule: Pick<AutomationRule, 'active' | 'config'> | null,
|
rule: Pick<AutomationRule, 'active' | 'config'> | null,
|
||||||
@@ -19,12 +24,5 @@ export function varroaThresholdOf(
|
|||||||
if (!rule.active) {
|
if (!rule.active) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const config = rule.config;
|
return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove;
|
||||||
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
||||||
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
|
||||||
}
|
|
||||||
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
||||||
? value
|
|
||||||
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ const BUSINESS_ENTITY_KEYS = [
|
|||||||
'queenColonyHistory',
|
'queenColonyHistory',
|
||||||
'inspection',
|
'inspection',
|
||||||
'taskTemplate',
|
'taskTemplate',
|
||||||
|
'suggestion',
|
||||||
'task',
|
'task',
|
||||||
'varroaMeasurement',
|
'varroaMeasurement',
|
||||||
'swarmControlCheck',
|
'swarmControlCheck',
|
||||||
@@ -237,6 +238,157 @@ describe('BackupService', () => {
|
|||||||
];
|
];
|
||||||
expect(call.create.broodFrames).toBe(5);
|
expect(call.create.broodFrames).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps a legacy free-text feedStatus of an old bundle on import', async () => {
|
||||||
|
const base = { colonyId: 'colony-1', userId: 'known-user' };
|
||||||
|
const bundle = {
|
||||||
|
version: 3,
|
||||||
|
data: {
|
||||||
|
inspection: [
|
||||||
|
{ ...base, id: 'i-1', feedStatus: ' Niedrig ', notes: null },
|
||||||
|
{ ...base, id: 'i-2', feedStatus: 'halb voll', notes: 'x' },
|
||||||
|
{ ...base, id: 'i-3', feedStatus: 'CRITICAL', notes: null },
|
||||||
|
{ ...base, id: 'i-4', feedStatus: '', notes: 'y' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.importAll(bundle as never, 'admin-1', TRUSTED);
|
||||||
|
|
||||||
|
const created = (
|
||||||
|
tx.inspection.upsert.mock.calls as [
|
||||||
|
[{ where: { id: string }; create: Record<string, unknown> }],
|
||||||
|
]
|
||||||
|
).map(([call]) => call.create);
|
||||||
|
expect(created).toEqual([
|
||||||
|
expect.objectContaining({ id: 'i-1', feedStatus: 'WEAK', notes: null }),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'i-2',
|
||||||
|
feedStatus: null,
|
||||||
|
notes: 'x\nFutter (alt): halb voll',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'i-3',
|
||||||
|
feedStatus: 'CRITICAL',
|
||||||
|
notes: null,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ id: 'i-4', feedStatus: null, notes: 'y' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legacy feedStatus of unexpected type', () => {
|
||||||
|
it('keeps a non-string feedStatus in the notes as JSON instead of dropping it', async () => {
|
||||||
|
const base = { colonyId: 'colony-1', userId: 'known-user' };
|
||||||
|
const bundle = {
|
||||||
|
version: 3,
|
||||||
|
data: {
|
||||||
|
inspection: [
|
||||||
|
{ ...base, id: 'i-1', feedStatus: 3, notes: null },
|
||||||
|
{ ...base, id: 'i-2', feedStatus: { level: 'low' }, notes: 'x' },
|
||||||
|
{ ...base, id: 'i-3', feedStatus: null, notes: 'y' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.importAll(bundle as never, 'admin-1', TRUSTED);
|
||||||
|
|
||||||
|
const created = (
|
||||||
|
tx.inspection.upsert.mock.calls as [
|
||||||
|
[{ create: Record<string, unknown> }],
|
||||||
|
]
|
||||||
|
).map(([call]) => call.create);
|
||||||
|
expect(created).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
feedStatus: null,
|
||||||
|
notes: 'Futter (alt): 3',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
feedStatus: null,
|
||||||
|
notes: 'x\nFutter (alt): {"level":"low"}',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ feedStatus: null, notes: 'y' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('suggestions', () => {
|
||||||
|
it('exports and restores suggestions with their decision before tasks', async () => {
|
||||||
|
const suggestion = {
|
||||||
|
id: 'suggestion-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
status: 'ACCEPTED',
|
||||||
|
dedupeKey: 'INSPECTION_INTERVAL:colony-1:insp-9',
|
||||||
|
decidedAt: '2026-10-14T08:00:00.000Z',
|
||||||
|
decidedByUserId: 'known-user',
|
||||||
|
decisionNote: 'Passt',
|
||||||
|
};
|
||||||
|
const orphanDecision = {
|
||||||
|
...suggestion,
|
||||||
|
id: 'suggestion-2',
|
||||||
|
dedupeKey: null,
|
||||||
|
decidedByUserId: 'deleted-user',
|
||||||
|
};
|
||||||
|
const open = {
|
||||||
|
...suggestion,
|
||||||
|
id: 'suggestion-3',
|
||||||
|
status: 'OPEN',
|
||||||
|
decidedAt: null,
|
||||||
|
decidedByUserId: null,
|
||||||
|
decisionNote: null,
|
||||||
|
};
|
||||||
|
prisma.suggestion.findMany.mockResolvedValue([
|
||||||
|
suggestion,
|
||||||
|
orphanDecision,
|
||||||
|
open,
|
||||||
|
]);
|
||||||
|
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
|
||||||
|
prisma.task.findMany.mockResolvedValue([
|
||||||
|
{ id: 'task-1', sourceSuggestionId: 'suggestion-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bundle = await service.exportAll();
|
||||||
|
expect(bundle.data.suggestion).toEqual([
|
||||||
|
suggestion,
|
||||||
|
orphanDecision,
|
||||||
|
open,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||||
|
|
||||||
|
expect(summary.imported.suggestion).toBe(3);
|
||||||
|
const creates = (
|
||||||
|
tx.suggestion.upsert.mock.calls as [
|
||||||
|
{ create: { id: string; decidedByUserId: string | null } },
|
||||||
|
][]
|
||||||
|
).map(([call]) => call.create);
|
||||||
|
// A decider that is gone resolves like any other user id; no decision
|
||||||
|
// stays without one.
|
||||||
|
expect(creates.map((row) => row.decidedByUserId)).toEqual([
|
||||||
|
'known-user',
|
||||||
|
'admin-1',
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
const suggestionOrder = tx.suggestion.upsert.mock.invocationCallOrder[0];
|
||||||
|
const taskOrder = tx.task.upsert.mock.invocationCallOrder[0];
|
||||||
|
expect(suggestionOrder).toBeLessThan(taskOrder);
|
||||||
|
const colonyOrder = tx.colony.upsert.mock.invocationCallOrder[0];
|
||||||
|
expect(colonyOrder).toBeLessThan(suggestionOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restore suggestions from an unsealed file', async () => {
|
||||||
|
await service.importAll(
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
exportedAt: '2026-10-14T00:00:00.000Z',
|
||||||
|
data: { suggestion: [{ id: 'suggestion-1' }] },
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
{ trusted: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.suggestion.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('importAll', () => {
|
describe('importAll', () => {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
isAllowedDocumentMimeType,
|
isAllowedDocumentMimeType,
|
||||||
} from '../documents/document-mime-types';
|
} from '../documents/document-mime-types';
|
||||||
import { matchesFileSignature } from '../common/file-signature';
|
import { matchesFileSignature } from '../common/file-signature';
|
||||||
|
import {
|
||||||
|
appendLegacyFeedNote,
|
||||||
|
feedConditionFromInput,
|
||||||
|
} from '../common/feed-vocabulary';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { DocumentEntityType, Prisma } from '../../generated/prisma';
|
import { DocumentEntityType, Prisma } from '../../generated/prisma';
|
||||||
import {
|
import {
|
||||||
@@ -162,6 +166,28 @@ function asString(row: Row, key: string): string | null {
|
|||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bundles written before the feed vocabulary change carry free text in
|
||||||
|
// Inspection.feedStatus: known words map to the enum, the rest goes to notes.
|
||||||
|
function withCurrentFeedVocabulary(row: Row): Row {
|
||||||
|
if (!('feedStatus' in row)) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const raw = row.feedStatus;
|
||||||
|
// A value that is neither text nor empty cannot map; keep it in the notes.
|
||||||
|
const { feedStatus, notesSuffix } =
|
||||||
|
raw === null || raw === undefined || typeof raw === 'string'
|
||||||
|
? feedConditionFromInput(raw)
|
||||||
|
: {
|
||||||
|
feedStatus: null,
|
||||||
|
notesSuffix: `Futter (alt): ${JSON.stringify(raw)}`,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
feedStatus,
|
||||||
|
notes: appendLegacyFeedNote(asString(row, 'notes'), notesSuffix),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function documentsStorageRoot(): string {
|
function documentsStorageRoot(): string {
|
||||||
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
||||||
}
|
}
|
||||||
@@ -233,6 +259,7 @@ export class BackupService {
|
|||||||
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
|
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
|
||||||
data.inspection = await this.prisma.inspection.findMany();
|
data.inspection = await this.prisma.inspection.findMany();
|
||||||
data.taskTemplate = await this.prisma.taskTemplate.findMany();
|
data.taskTemplate = await this.prisma.taskTemplate.findMany();
|
||||||
|
data.suggestion = await this.prisma.suggestion.findMany();
|
||||||
data.task = await this.prisma.task.findMany();
|
data.task = await this.prisma.task.findMany();
|
||||||
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||||
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||||
@@ -538,16 +565,14 @@ export class BackupService {
|
|||||||
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
|
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
|
||||||
|
|
||||||
for (const row of rowsOf('inspection')) {
|
for (const row of rowsOf('inspection')) {
|
||||||
|
const inspection = {
|
||||||
|
...withCurrentFeedVocabulary(row),
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
};
|
||||||
await tx.inspection.upsert({
|
await tx.inspection.upsert({
|
||||||
where: { id: row.id as string },
|
where: { id: row.id as string },
|
||||||
create: {
|
create: inspection as never,
|
||||||
...row,
|
update: updateWith(inspection) as never,
|
||||||
userId: resolveUserId(asString(row, 'userId')),
|
|
||||||
} as never,
|
|
||||||
update: updateWith({
|
|
||||||
...row,
|
|
||||||
userId: resolveUserId(asString(row, 'userId')),
|
|
||||||
}) as never,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
imported.inspection = rowsOf('inspection').length;
|
imported.inspection = rowsOf('inspection').length;
|
||||||
@@ -577,6 +602,23 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
imported.taskTemplate = rowsOf('taskTemplate').length;
|
imported.taskTemplate = rowsOf('taskTemplate').length;
|
||||||
|
|
||||||
|
// Before the tasks: Task.sourceSuggestionId points at them. After the
|
||||||
|
// colonies: a suggestion may hang on one.
|
||||||
|
for (const row of rowsOf('suggestion')) {
|
||||||
|
const data = {
|
||||||
|
...row,
|
||||||
|
decidedByUserId: resolveOptionalUserId(
|
||||||
|
asString(row, 'decidedByUserId'),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await tx.suggestion.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: data as never,
|
||||||
|
update: updateWith(data) as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.suggestion = rowsOf('suggestion').length;
|
||||||
|
|
||||||
for (const row of rowsOf('task')) {
|
for (const row of rowsOf('task')) {
|
||||||
await tx.task.upsert({
|
await tx.task.upsert({
|
||||||
where: { id: row.id as string },
|
where: { id: row.id as string },
|
||||||
|
|||||||
@@ -122,6 +122,14 @@ describe('CalendarSyncService', () => {
|
|||||||
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not enqueue a WITHDRAWAL entry yet (the switch arrives with Task 3)', async () => {
|
||||||
|
settingsService.get.mockResolvedValue(enabledSettings);
|
||||||
|
|
||||||
|
await service.enqueue('WITHDRAWAL', 'treatment-1');
|
||||||
|
|
||||||
|
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('does nothing when the source type toggle is off', async () => {
|
it('does nothing when the source type toggle is off', async () => {
|
||||||
settingsService.get.mockResolvedValue({
|
settingsService.get.mockResolvedValue({
|
||||||
...enabledSettings,
|
...enabledSettings,
|
||||||
@@ -640,6 +648,16 @@ describe('CalendarSyncService', () => {
|
|||||||
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
|
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes no Graph event for a WITHDRAWAL entry before Task 12', async () => {
|
||||||
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||||
|
pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.processOutbox();
|
||||||
|
|
||||||
|
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('builds titles per source type matching the read-only calendar view', async () => {
|
it('builds titles per source type matching the read-only calendar view', async () => {
|
||||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||||
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
|
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export class CalendarSyncService {
|
|||||||
FEEDING: settings.syncFeeding,
|
FEEDING: settings.syncFeeding,
|
||||||
HARVEST: settings.syncHarvest,
|
HARVEST: settings.syncHarvest,
|
||||||
QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
|
QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
|
||||||
|
// Switch in the settings arrives with Task 3.
|
||||||
|
WITHDRAWAL: false,
|
||||||
};
|
};
|
||||||
return flagBySourceType[sourceType];
|
return flagBySourceType[sourceType];
|
||||||
}
|
}
|
||||||
@@ -499,6 +501,9 @@ export class CalendarSyncService {
|
|||||||
date: assignment.startDate,
|
date: assignment.startDate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'WITHDRAWAL':
|
||||||
|
// Switch Task 3, content Task 12: nothing to write before that.
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
berlinDateTime,
|
||||||
|
berlinDayOf,
|
||||||
|
berlinToday,
|
||||||
|
dayToDate,
|
||||||
|
daysBetween,
|
||||||
|
} from './dates';
|
||||||
|
|
||||||
|
describe('dates', () => {
|
||||||
|
it('berlinToday at 2026-03-28T23:30Z is 2026-03-29', () => {
|
||||||
|
expect(berlinToday(new Date('2026-03-28T23:30:00Z'))).toBe('2026-03-29');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the 04:30 Berlin job time is the same calendar day', () => {
|
||||||
|
expect(berlinToday(new Date('2026-10-13T02:30:00Z'))).toBe('2026-10-13');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('berlinToday defaults to the current instant', () => {
|
||||||
|
expect(berlinToday()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('berlinDayOf uses the Berlin calendar day, not the UTC one', () => {
|
||||||
|
expect(berlinDayOf(new Date('2026-12-31T23:30:00Z'))).toBe('2027-01-01');
|
||||||
|
expect(berlinDayOf(new Date('2026-06-30T21:59:00Z'))).toBe('2026-06-30');
|
||||||
|
expect(berlinDayOf(new Date('2026-06-30T22:00:00Z'))).toBe('2026-07-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('berlinDateTime 2026-03-29 06:30 is 04:30Z on the day DST starts', () => {
|
||||||
|
expect(berlinDateTime('2026-03-29', '06:30').toISOString()).toBe(
|
||||||
|
'2026-03-29T04:30:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('berlinDateTime 2026-10-25 06:30 is 05:30Z after DST ends', () => {
|
||||||
|
expect(berlinDateTime('2026-10-25', '06:30').toISOString()).toBe(
|
||||||
|
'2026-10-25T05:30:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('berlinDateTime uses winter and summer offsets on ordinary days', () => {
|
||||||
|
expect(berlinDateTime('2026-01-15', '06:30').toISOString()).toBe(
|
||||||
|
'2026-01-15T05:30:00.000Z',
|
||||||
|
);
|
||||||
|
expect(berlinDateTime('2026-07-15', '06:30').toISOString()).toBe(
|
||||||
|
'2026-07-15T04:30:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('berlinDateTime handles midnight and the evening before a DST change', () => {
|
||||||
|
expect(berlinDateTime('2026-03-29', '00:00').toISOString()).toBe(
|
||||||
|
'2026-03-28T23:00:00.000Z',
|
||||||
|
);
|
||||||
|
expect(berlinDateTime('2026-10-25', '00:00').toISOString()).toBe(
|
||||||
|
'2026-10-24T22:00:00.000Z',
|
||||||
|
);
|
||||||
|
expect(berlinDateTime('2026-03-28', '21:00').toISOString()).toBe(
|
||||||
|
'2026-03-28T20:00:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('berlinDateTime rejects malformed input', () => {
|
||||||
|
expect(() => berlinDateTime('2026-3-29', '06:30')).toThrow();
|
||||||
|
expect(() => berlinDateTime('2026-03-29', '6:30')).toThrow();
|
||||||
|
expect(() => berlinDateTime('2026-03-29', '25:00')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dayToDate is midnight UTC', () => {
|
||||||
|
expect(dayToDate('2026-05-05').toISOString()).toBe(
|
||||||
|
'2026-05-05T00:00:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dayToDate rejects impossible days', () => {
|
||||||
|
expect(() => dayToDate('2026-02-30')).toThrow();
|
||||||
|
expect(() => dayToDate('2026-13-01')).toThrow();
|
||||||
|
expect(() => dayToDate('2026-04-31')).toThrow();
|
||||||
|
expect(dayToDate('2024-02-29').toISOString()).toBe(
|
||||||
|
'2024-02-29T00:00:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addDays crosses month ends', () => {
|
||||||
|
expect(addDays('2026-02-27', 2)).toBe('2026-03-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addDays goes backwards and over year ends', () => {
|
||||||
|
expect(addDays('2026-01-01', -1)).toBe('2025-12-31');
|
||||||
|
expect(addDays('2026-12-31', 1)).toBe('2027-01-01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daysBetween counts calendar days', () => {
|
||||||
|
expect(daysBetween('2026-05-01', '2026-05-09')).toBe(8);
|
||||||
|
expect(daysBetween('2026-05-09', '2026-05-01')).toBe(-8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daysBetween counts whole days across a DST change', () => {
|
||||||
|
expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const BERLIN_TIME_ZONE = 'Europe/Berlin';
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
|
|
||||||
|
// en-CA formats a date as YYYY-MM-DD.
|
||||||
|
const berlinDayFormat = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: BERLIN_TIME_ZONE,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const berlinPartsFormat = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: BERLIN_TIME_ZONE,
|
||||||
|
hourCycle: 'h23',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The calendar day (YYYY-MM-DD) of an instant in Europe/Berlin. */
|
||||||
|
export function berlinDayOf(instant: Date): string {
|
||||||
|
return berlinDayFormat.format(instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's calendar day (YYYY-MM-DD) in Europe/Berlin. */
|
||||||
|
export function berlinToday(now: Date = new Date()): string {
|
||||||
|
return berlinDayOf(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored day (YYYY-MM-DD) as the DateTime used for dates: midnight UTC. */
|
||||||
|
export function dayToDate(day: string): Date {
|
||||||
|
if (!DAY_PATTERN.test(day)) {
|
||||||
|
throw new Error(`Invalid day: ${day}`);
|
||||||
|
}
|
||||||
|
const date = new Date(`${day}T00:00:00.000Z`);
|
||||||
|
// 2026-02-30 parses to March 2nd: only a day that round-trips exists.
|
||||||
|
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== day) {
|
||||||
|
throw new Error(`Invalid day: ${day}`);
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayNumber(day: string): number {
|
||||||
|
return Math.round(dayToDate(day).getTime() / MS_PER_DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar arithmetic on YYYY-MM-DD strings; no time zone involved. */
|
||||||
|
export function addDays(day: string, days: number): string {
|
||||||
|
const result = new Date(dayToDate(day).getTime() + days * MS_PER_DAY);
|
||||||
|
return result.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
|
||||||
|
export function daysBetween(from: string, to: string): number {
|
||||||
|
return dayNumber(to) - dayNumber(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offset of Europe/Berlin to UTC at an instant, in milliseconds. */
|
||||||
|
function berlinOffsetMs(instant: Date): number {
|
||||||
|
const parts: Record<string, number> = {};
|
||||||
|
for (const part of berlinPartsFormat.formatToParts(instant)) {
|
||||||
|
if (part.type !== 'literal') {
|
||||||
|
parts[part.type] = Number(part.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const asUtc = Date.UTC(
|
||||||
|
parts.year,
|
||||||
|
parts.month - 1,
|
||||||
|
parts.day,
|
||||||
|
parts.hour,
|
||||||
|
parts.minute,
|
||||||
|
parts.second,
|
||||||
|
);
|
||||||
|
// The instant without its milliseconds: the formatter works in seconds.
|
||||||
|
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant at which the Berlin wall clock shows `day` `hhmm`. The offset
|
||||||
|
* is taken at the target day itself (applied twice, so the day a clock change
|
||||||
|
* happens comes out right): 06:30 is 04:30Z in summer and 05:30Z in winter.
|
||||||
|
*/
|
||||||
|
export function berlinDateTime(day: string, hhmm: string): Date {
|
||||||
|
const time = TIME_PATTERN.exec(hhmm);
|
||||||
|
if (!time) {
|
||||||
|
throw new Error(`Invalid time: ${hhmm}`);
|
||||||
|
}
|
||||||
|
const wallClockAsUtc =
|
||||||
|
dayToDate(day).getTime() +
|
||||||
|
(Number(time[1]) * 60 + Number(time[2])) * 60_000;
|
||||||
|
let instant = new Date(
|
||||||
|
wallClockAsUtc - berlinOffsetMs(new Date(wallClockAsUtc)),
|
||||||
|
);
|
||||||
|
instant = new Date(wallClockAsUtc - berlinOffsetMs(instant));
|
||||||
|
return instant;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
FEED_CONDITION_LABELS_DE,
|
||||||
|
appendLegacyFeedNote,
|
||||||
|
feedConditionFromInput,
|
||||||
|
feedConditionFromLegacy,
|
||||||
|
} from './feed-vocabulary';
|
||||||
|
|
||||||
|
describe('feed vocabulary', () => {
|
||||||
|
it.each([
|
||||||
|
[' Ausreichend ', 'GOOD', null],
|
||||||
|
['niedrig', 'WEAK', null],
|
||||||
|
['KRITISCH', 'CRITICAL', null],
|
||||||
|
['', null, null],
|
||||||
|
[' ', null, null],
|
||||||
|
[null, null, null],
|
||||||
|
[undefined, null, null],
|
||||||
|
['halb voll', null, 'Futter (alt): halb voll'],
|
||||||
|
])('maps %p', (text, feedStatus, notesSuffix) => {
|
||||||
|
expect(feedConditionFromLegacy(text)).toEqual({ feedStatus, notesSuffix });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels every condition in German', () => {
|
||||||
|
expect(FEED_CONDITION_LABELS_DE).toEqual({
|
||||||
|
EXCELLENT: 'Reichlich',
|
||||||
|
GOOD: 'Ausreichend',
|
||||||
|
AVERAGE: 'Mittel',
|
||||||
|
WEAK: 'Niedrig',
|
||||||
|
CRITICAL: 'Kritisch',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['critical', 'CRITICAL', null],
|
||||||
|
[' EXCELLENT ', 'EXCELLENT', null],
|
||||||
|
['niedrig', 'WEAK', null],
|
||||||
|
['constructor', null, 'Futter (alt): constructor'],
|
||||||
|
['halb voll', null, 'Futter (alt): halb voll'],
|
||||||
|
])(
|
||||||
|
'accepts condition names or legacy text: %p',
|
||||||
|
(text, feedStatus, notesSuffix) => {
|
||||||
|
expect(feedConditionFromInput(text)).toEqual({ feedStatus, notesSuffix });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('appendLegacyFeedNote', () => {
|
||||||
|
it.each([
|
||||||
|
[null, null, null],
|
||||||
|
['x', null, 'x'],
|
||||||
|
[null, 'Futter (alt): a', 'Futter (alt): a'],
|
||||||
|
['', 'Futter (alt): a', 'Futter (alt): a'],
|
||||||
|
['x', 'Futter (alt): a', 'x\nFutter (alt): a'],
|
||||||
|
])('joins %p and %p', (notes, suffix, expected) => {
|
||||||
|
expect(appendLegacyFeedNote(notes, suffix)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ColonyCondition } from '../../generated/prisma';
|
||||||
|
|
||||||
|
/** German display labels for the one feed vocabulary (colony card and inspection). */
|
||||||
|
export const FEED_CONDITION_LABELS_DE: Record<ColonyCondition, string> = {
|
||||||
|
EXCELLENT: 'Reichlich',
|
||||||
|
GOOD: 'Ausreichend',
|
||||||
|
AVERAGE: 'Mittel',
|
||||||
|
WEAK: 'Niedrig',
|
||||||
|
CRITICAL: 'Kritisch',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LegacyFeedMapping {
|
||||||
|
feedStatus: ColonyCondition | null;
|
||||||
|
/** 'Futter (alt): <Text>' for text that maps to no condition, else null. */
|
||||||
|
notesSuffix: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_WORDS: ReadonlyMap<string, ColonyCondition> = new Map([
|
||||||
|
['ausreichend', 'GOOD'],
|
||||||
|
['niedrig', 'WEAK'],
|
||||||
|
['kritisch', 'CRITICAL'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LEGACY_NOTE_PREFIX = 'Futter (alt): ';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the old free-text feed status (trimmed, case-insensitive) onto the
|
||||||
|
* enum. Empty text maps to nothing; unknown text is kept for the notes.
|
||||||
|
*/
|
||||||
|
export function feedConditionFromLegacy(
|
||||||
|
text: string | null | undefined,
|
||||||
|
): LegacyFeedMapping {
|
||||||
|
const trimmed = (text ?? '').trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
return { feedStatus: null, notesSuffix: null };
|
||||||
|
}
|
||||||
|
const mapped = LEGACY_WORDS.get(trimmed.toLowerCase());
|
||||||
|
if (mapped) {
|
||||||
|
return { feedStatus: mapped, notesSuffix: null };
|
||||||
|
}
|
||||||
|
return { feedStatus: null, notesSuffix: `${LEGACY_NOTE_PREFIX}${trimmed}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like feedConditionFromLegacy, but a value that already is a condition name
|
||||||
|
* (any case) is taken as it is. For import paths that see both old and new data.
|
||||||
|
*/
|
||||||
|
export function feedConditionFromInput(
|
||||||
|
text: string | null | undefined,
|
||||||
|
): LegacyFeedMapping {
|
||||||
|
const upper = (text ?? '').trim().toUpperCase();
|
||||||
|
if (Object.hasOwn(FEED_CONDITION_LABELS_DE, upper)) {
|
||||||
|
return { feedStatus: upper as ColonyCondition, notesSuffix: null };
|
||||||
|
}
|
||||||
|
return feedConditionFromLegacy(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends the legacy suffix to the notes, separated by a newline. */
|
||||||
|
export function appendLegacyFeedNote(
|
||||||
|
notes: string | null,
|
||||||
|
suffix: string | null,
|
||||||
|
): string | null {
|
||||||
|
if (!suffix) {
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
return notes ? `${notes}\n${suffix}` : suffix;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { berlinToday } from '../common/dates';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
describe('DashboardService', () => {
|
describe('DashboardService', () => {
|
||||||
@@ -11,6 +12,7 @@ describe('DashboardService', () => {
|
|||||||
honeyBatch: { count: jest.Mock };
|
honeyBatch: { count: jest.Mock };
|
||||||
stockItem: { findMany: jest.Mock };
|
stockItem: { findMany: jest.Mock };
|
||||||
swarmControlCheck: { findMany: jest.Mock };
|
swarmControlCheck: { findMany: jest.Mock };
|
||||||
|
automationRule: { findUnique: jest.Mock };
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -28,6 +30,8 @@ describe('DashboardService', () => {
|
|||||||
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
|
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
|
||||||
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
|
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
|
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
// No stored SEASON_AUTOPILOT rule: the built-in setting applies.
|
||||||
|
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||||
};
|
};
|
||||||
service = new DashboardService(prisma as unknown as PrismaService);
|
service = new DashboardService(prisma as unknown as PrismaService);
|
||||||
});
|
});
|
||||||
@@ -137,33 +141,44 @@ describe('DashboardService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('OLD_QUEEN', () => {
|
describe('OLD_QUEEN', () => {
|
||||||
it('flags a colony whose queen is at least 3 years old', async () => {
|
const currentYear = Number(berlinToday().slice(0, 4));
|
||||||
|
const queenedColony = (hatchYear: number) => [
|
||||||
|
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear } },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('OLD_QUEEN starts at queenMaxAgeYears (2)', async () => {
|
||||||
prisma.colony.findMany
|
prisma.colony.findMany
|
||||||
.mockResolvedValueOnce([]) // attentionColonies
|
.mockResolvedValueOnce([]) // attentionColonies
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce(queenedColony(currentYear - 2));
|
||||||
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear: 2020 } },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const alerts = await service.alerts();
|
const alerts = await service.alerts();
|
||||||
|
|
||||||
expect(alerts).toHaveLength(1);
|
expect(alerts).toHaveLength(1);
|
||||||
expect(alerts[0].type).toBe('OLD_QUEEN');
|
expect(alerts[0].type).toBe('OLD_QUEEN');
|
||||||
expect(alerts[0].relatedId).toBe('colony-1');
|
expect(alerts[0].relatedId).toBe('colony-1');
|
||||||
|
expect(alerts[0].message).toContain('2 Jahre');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not flag a queen younger than the threshold', async () => {
|
it('does not flag a queen younger than the threshold', async () => {
|
||||||
const currentYear = new Date().getFullYear();
|
prisma.colony.findMany
|
||||||
prisma.colony.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
.mockResolvedValueOnce([])
|
||||||
{
|
.mockResolvedValueOnce(queenedColony(currentYear - 1));
|
||||||
id: 'colony-1',
|
|
||||||
code: 'V-001',
|
|
||||||
currentQueen: { hatchYear: currentYear },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const alerts = await service.alerts();
|
expect(await service.alerts()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
expect(alerts).toHaveLength(0);
|
it('follows queenMaxAgeYears of the SEASON_AUTOPILOT rule', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue({
|
||||||
|
id: 'rule-1',
|
||||||
|
active: true,
|
||||||
|
actionTemplate: null,
|
||||||
|
config: { queenMaxAgeYears: 4 },
|
||||||
|
});
|
||||||
|
prisma.colony.findMany
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce(queenedColony(currentYear - 3));
|
||||||
|
|
||||||
|
expect(await service.alerts()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { readRule } from '../automation/rule-config';
|
||||||
|
import { berlinToday } from '../common/dates';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
|
import { isQueenAged } from '../season/queen-age';
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
apiariesActive: number;
|
apiariesActive: number;
|
||||||
@@ -24,10 +27,6 @@ export interface DashboardAlert {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
|
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
|
||||||
// Queens rarely stay reliably productive past their third season - laying
|
|
||||||
// rate and swarming risk both worsen well before then, so this flags a
|
|
||||||
// colony for evaluation rather than demanding immediate requeening.
|
|
||||||
const OLD_QUEEN_AGE_YEARS = 3;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DashboardService {
|
export class DashboardService {
|
||||||
@@ -158,15 +157,23 @@ export class DashboardService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const currentYear = now.getFullYear();
|
// The age comes from the rule, like QueenAged and the autopilot: a flag
|
||||||
|
// for evaluation rather than a demand for immediate requeening.
|
||||||
|
const { config: autopilot } = await readRule(
|
||||||
|
this.prisma,
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
);
|
||||||
|
const today = berlinToday(now);
|
||||||
|
const currentYear = Number(today.slice(0, 4));
|
||||||
for (const colony of queenedColonies) {
|
for (const colony of queenedColonies) {
|
||||||
// The where clause above already requires currentQueen and its
|
// The where clause above already requires currentQueen and its
|
||||||
// hatchYear to exist, but the relation is still nullable in the type.
|
// hatchYear to exist, but the relation is still nullable in the type.
|
||||||
if (!colony.currentQueen?.hatchYear) {
|
if (!colony.currentQueen?.hatchYear) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const ageYears = currentYear - colony.currentQueen.hatchYear;
|
const { hatchYear } = colony.currentQueen;
|
||||||
if (ageYears >= OLD_QUEEN_AGE_YEARS) {
|
if (isQueenAged(hatchYear, today, autopilot.queenMaxAgeYears)) {
|
||||||
|
const ageYears = currentYear - hatchYear;
|
||||||
alerts.push({
|
alerts.push({
|
||||||
type: 'OLD_QUEEN',
|
type: 'OLD_QUEEN',
|
||||||
message: `Königin in Volk ${colony.code} ist ${ageYears} Jahre alt - Nachzucht prüfen`,
|
message: `Königin in Volk ${colony.code} ist ${ageYears} Jahre alt - Nachzucht prüfen`,
|
||||||
|
|||||||
@@ -56,8 +56,18 @@ export interface DomainEventPayloads {
|
|||||||
WithdrawalEnded: { colonyId: string; endedOn: string };
|
WithdrawalEnded: { colonyId: string; endedOn: string };
|
||||||
TaskDue: { dueOn: string };
|
TaskDue: { dueOn: string };
|
||||||
QueenAged: { hatchYear: number; colonyId: string | null };
|
QueenAged: { hatchYear: number; colonyId: string | null };
|
||||||
VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null };
|
// kind/anchor/firstOfYear came with T4a: absent on older events.
|
||||||
InspectionOverdue: { dueOn: string; lastInspectedOn: string | null };
|
VarroaMeasurementDue: {
|
||||||
|
dueOn: string;
|
||||||
|
lastMeasuredOn: string | null;
|
||||||
|
kind?: 'INTERVAL' | 'WINTER_WINDOW';
|
||||||
|
anchor?: string;
|
||||||
|
};
|
||||||
|
InspectionOverdue: {
|
||||||
|
dueOn: string;
|
||||||
|
lastInspectedOn: string | null;
|
||||||
|
firstOfYear?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DomainEventType = keyof DomainEventPayloads;
|
export type DomainEventType = keyof DomainEventPayloads;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { ColonyCondition } from '../../generated/prisma';
|
||||||
|
import { FEED_CONDITION_LABELS_DE } from '../common/feed-vocabulary';
|
||||||
import {
|
import {
|
||||||
PDF_MARGIN,
|
PDF_MARGIN,
|
||||||
drawSection,
|
drawSection,
|
||||||
@@ -83,7 +85,7 @@ export interface ColonyRecordCard {
|
|||||||
queenCellsPresent: boolean | null;
|
queenCellsPresent: boolean | null;
|
||||||
gentleness: number | null;
|
gentleness: number | null;
|
||||||
swarmMood: number | null;
|
swarmMood: number | null;
|
||||||
feedStatus: string | null;
|
feedStatus: ColonyCondition | null;
|
||||||
measures: string | null;
|
measures: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
}[];
|
}[];
|
||||||
@@ -348,7 +350,7 @@ export function renderColonyRecordCardPdf(
|
|||||||
yesNo(i.queenCellsPresent),
|
yesNo(i.queenCellsPresent),
|
||||||
orDash(i.gentleness),
|
orDash(i.gentleness),
|
||||||
orDash(i.swarmMood),
|
orDash(i.swarmMood),
|
||||||
orDash(i.feedStatus),
|
orDash(i.feedStatus ? FEED_CONDITION_LABELS_DE[i.feedStatus] : null),
|
||||||
orDash(i.measures),
|
orDash(i.measures),
|
||||||
orDash(i.notes),
|
orDash(i.notes),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -133,6 +133,42 @@ describe('ImportService', () => {
|
|||||||
expect(createArg.data.code).toBe('V-100');
|
expect(createArg.data.code).toBe('V-100');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps CSV feed text "niedrig" to WEAK and keeps unknown text in the notes', async () => {
|
||||||
|
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
|
prisma.colony.create.mockResolvedValue({ id: 'colony-1' });
|
||||||
|
|
||||||
|
const report = await service.importCsv(
|
||||||
|
'colonies',
|
||||||
|
Buffer.from(
|
||||||
|
[
|
||||||
|
'code,apiaryName,colonyType,feedStatus,notes',
|
||||||
|
'V-1,Teststand,PRODUCTION_COLONY,niedrig,',
|
||||||
|
'V-2,Teststand,PRODUCTION_COLONY,halb voll,Hinweis',
|
||||||
|
'V-3,Teststand,PRODUCTION_COLONY,CRITICAL,',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.created).toBe(3);
|
||||||
|
const data = (
|
||||||
|
prisma.colony.create.mock.calls as [
|
||||||
|
[{ data: { feedStatus?: string; notes?: string } }],
|
||||||
|
]
|
||||||
|
).map(([call]) => call.data);
|
||||||
|
expect(data[0]).toMatchObject({ feedStatus: 'WEAK' });
|
||||||
|
expect(data[0].notes).toBeUndefined();
|
||||||
|
expect(data[1].feedStatus).toBeUndefined();
|
||||||
|
expect(data[1].notes).toBe('Hinweis\nFutter (alt): halb voll');
|
||||||
|
expect(data[2]).toMatchObject({ feedStatus: 'CRITICAL' });
|
||||||
|
expect(report.rows.map((row) => row.message)).toEqual([
|
||||||
|
undefined,
|
||||||
|
'Futterangabe „halb voll“ nicht erkannt, in die Notizen übernommen.',
|
||||||
|
undefined,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports a validation error for an invalid colonyType', async () => {
|
it('reports a validation error for an invalid colonyType', async () => {
|
||||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { AuditLogService } from '../audit/audit-log.service';
|
|||||||
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
|
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
|
||||||
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
|
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
|
||||||
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
|
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
|
||||||
|
import {
|
||||||
|
appendLegacyFeedNote,
|
||||||
|
feedConditionFromInput,
|
||||||
|
} from '../common/feed-vocabulary';
|
||||||
import { parseCsv } from './csv';
|
import { parseCsv } from './csv';
|
||||||
|
|
||||||
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
|
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
|
||||||
@@ -166,6 +170,8 @@ export class ImportService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Old exports carry free text here; known words map to the enum.
|
||||||
|
const feed = feedConditionFromInput(record.feedStatus);
|
||||||
const plain = {
|
const plain = {
|
||||||
code: emptyToUndefined(record.code),
|
code: emptyToUndefined(record.code),
|
||||||
apiaryId: apiary.id,
|
apiaryId: apiary.id,
|
||||||
@@ -174,9 +180,13 @@ export class ImportService {
|
|||||||
originText: emptyToUndefined(record.originText),
|
originText: emptyToUndefined(record.originText),
|
||||||
strength: parseNumberField(record.strength),
|
strength: parseNumberField(record.strength),
|
||||||
broodStatus: emptyToUndefined(record.broodStatus),
|
broodStatus: emptyToUndefined(record.broodStatus),
|
||||||
feedStatus: emptyToUndefined(record.feedStatus),
|
feedStatus: feed.feedStatus ?? undefined,
|
||||||
healthStatus: emptyToUndefined(record.healthStatus),
|
healthStatus: emptyToUndefined(record.healthStatus),
|
||||||
notes: emptyToUndefined(record.notes),
|
notes:
|
||||||
|
appendLegacyFeedNote(
|
||||||
|
emptyToUndefined(record.notes) ?? null,
|
||||||
|
feed.notesSuffix,
|
||||||
|
) ?? undefined,
|
||||||
};
|
};
|
||||||
const dto = plainToInstance(CreateColonyDto, plain);
|
const dto = plainToInstance(CreateColonyDto, plain);
|
||||||
const errors = await validate(dto);
|
const errors = await validate(dto);
|
||||||
@@ -199,7 +209,16 @@ export class ImportService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const colony = await this.prisma.colony.create({ data: { ...dto } });
|
const colony = await this.prisma.colony.create({ data: { ...dto } });
|
||||||
return { row: rowNumber, status: 'created', id: colony.id };
|
return {
|
||||||
|
row: rowNumber,
|
||||||
|
status: 'created',
|
||||||
|
id: colony.id,
|
||||||
|
...(feed.notesSuffix
|
||||||
|
? {
|
||||||
|
message: `Futterangabe „${record.feedStatus}“ nicht erkannt, in die Notizen übernommen.`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importQueenRow(
|
private async importQueenRow(
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { CreateInspectionDto } from './create-inspection.dto';
|
||||||
|
import { UpdateInspectionDto } from './update-inspection.dto';
|
||||||
|
|
||||||
|
async function check(
|
||||||
|
dtoClass: typeof CreateInspectionDto | typeof UpdateInspectionDto,
|
||||||
|
feedStatus: unknown,
|
||||||
|
) {
|
||||||
|
const dto = plainToInstance(dtoClass, {
|
||||||
|
colonyId: 'c',
|
||||||
|
date: '2026-05-01',
|
||||||
|
feedStatus,
|
||||||
|
});
|
||||||
|
return { dto, errors: await validate(dto) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspection DTOs feedStatus', () => {
|
||||||
|
it.each([
|
||||||
|
['niedrig', 'WEAK'],
|
||||||
|
[' Kritisch ', 'CRITICAL'],
|
||||||
|
['AUSREICHEND', 'GOOD'],
|
||||||
|
['EXCELLENT', 'EXCELLENT'],
|
||||||
|
['average', 'AVERAGE'],
|
||||||
|
['', null],
|
||||||
|
[' ', null],
|
||||||
|
])('accepts %p as %p', async (input, expected) => {
|
||||||
|
for (const dtoClass of [CreateInspectionDto, UpdateInspectionDto]) {
|
||||||
|
const { dto, errors } = await check(dtoClass, input);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
expect(dto.feedStatus).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['halb voll', 'constructor', 5, {}])('rejects %p', async (input) => {
|
||||||
|
const { errors } = await check(CreateInspectionDto, input);
|
||||||
|
expect(errors.map((e) => e.property)).toContain('feedStatus');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -9,6 +10,9 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ColonyCondition } from '../../../generated/prisma';
|
||||||
|
import { feedConditionFromInput } from '../../common/feed-vocabulary';
|
||||||
|
|
||||||
export class CreateInspectionDto {
|
export class CreateInspectionDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -99,10 +103,24 @@ export class CreateInspectionDto {
|
|||||||
@Max(5)
|
@Max(5)
|
||||||
combBehavior?: number;
|
combBehavior?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional({
|
||||||
|
enum: ColonyCondition,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Feed condition. Also accepts the legacy German words ausreichend (GOOD), niedrig (WEAK) and kritisch (CRITICAL), ignoring case and surrounding whitespace; a blank string counts as null. Other text is rejected.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@Transform(({ value }: { value: unknown }) => {
|
||||||
feedStatus?: string;
|
if (typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value.trim() === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return feedConditionFromInput(value).feedStatus ?? value;
|
||||||
|
})
|
||||||
|
@IsEnum(ColonyCondition)
|
||||||
|
feedStatus?: ColonyCondition;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { JobPartialFailure } from '../system/job-run.service';
|
||||||
|
import {
|
||||||
|
DAILY_EVALUATION_STEP_ORDER,
|
||||||
|
DailyEvaluationStepName,
|
||||||
|
runDailyEvaluationSteps,
|
||||||
|
} from './daily-evaluation-steps';
|
||||||
|
import type { JobResult } from './job';
|
||||||
|
|
||||||
|
function step(
|
||||||
|
name: DailyEvaluationStepName,
|
||||||
|
run: (today: string) => Promise<JobResult>,
|
||||||
|
): { name: DailyEvaluationStepName; run: jest.Mock } {
|
||||||
|
return { name, run: jest.fn(run) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runDailyEvaluationSteps', () => {
|
||||||
|
const logger = new Logger('test');
|
||||||
|
let errorSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('has the steps of the plan in this order', () =>
|
||||||
|
expect(DAILY_EVALUATION_STEP_ORDER).toEqual([
|
||||||
|
'phenology',
|
||||||
|
'time-events',
|
||||||
|
'autopilot',
|
||||||
|
'suggestions-expiry',
|
||||||
|
'digest',
|
||||||
|
]));
|
||||||
|
|
||||||
|
it('runs the steps in the fixed order with the day and merges their counters', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const digest = step('digest', () => {
|
||||||
|
order.push('digest');
|
||||||
|
return Promise.resolve({ digests: 3 });
|
||||||
|
});
|
||||||
|
const timeEvents = step('time-events', () => {
|
||||||
|
order.push('time-events');
|
||||||
|
return Promise.resolve({ TaskDue: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, failed } = await runDailyEvaluationSteps(
|
||||||
|
[digest, timeEvents],
|
||||||
|
'2026-06-10',
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(order).toEqual(['time-events', 'digest']);
|
||||||
|
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||||
|
expect(result).toEqual({ TaskDue: 1, digests: 3 });
|
||||||
|
expect(failed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failing step leaves the others running and the run FAILED with all counters', async () => {
|
||||||
|
const phenology = step('phenology', () =>
|
||||||
|
Promise.resolve({ phenologyApiaries: 2 }),
|
||||||
|
);
|
||||||
|
const timeEvents = step('time-events', () =>
|
||||||
|
Promise.reject(
|
||||||
|
new JobPartialFailure({ WithdrawalEnded: 1, TaskDue: 0 }, ['TaskDue']),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const autopilot = step('autopilot', () =>
|
||||||
|
Promise.reject(new Error('connection reset')),
|
||||||
|
);
|
||||||
|
const digest = step('digest', () => Promise.resolve({ digests: 4 }));
|
||||||
|
|
||||||
|
const { result, failed, failures } = await runDailyEvaluationSteps(
|
||||||
|
[phenology, timeEvents, autopilot, digest],
|
||||||
|
'2026-06-10',
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(digest.run).toHaveBeenCalled();
|
||||||
|
expect(failed).toEqual(['time-events', 'autopilot']);
|
||||||
|
expect(failures).toEqual(['time-events: TaskDue', 'autopilot']);
|
||||||
|
expect(result).toEqual({
|
||||||
|
phenologyApiaries: 2,
|
||||||
|
WithdrawalEnded: 1,
|
||||||
|
TaskDue: 0,
|
||||||
|
digests: 4,
|
||||||
|
});
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
'Step "autopilot" failed: connection reset',
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Logger } from '@nestjs/common';
|
||||||
|
import { loggableError } from '../common/loggable-error';
|
||||||
|
import { JobPartialFailure } from '../system/job-run.service';
|
||||||
|
import type { JobResult } from './job';
|
||||||
|
|
||||||
|
/** The steps of the daily evaluation, in the order they run. */
|
||||||
|
export const DAILY_EVALUATION_STEP_ORDER = [
|
||||||
|
'phenology',
|
||||||
|
'time-events',
|
||||||
|
'autopilot',
|
||||||
|
'suggestions-expiry',
|
||||||
|
'digest',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DailyEvaluationStepName =
|
||||||
|
(typeof DAILY_EVALUATION_STEP_ORDER)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One step of the daily evaluation. `today` is the Berlin day of the run
|
||||||
|
* (YYYY-MM-DD). A step commits in transactions of its own; it reports its
|
||||||
|
* counters, and fails by throwing - a JobPartialFailure if it still has
|
||||||
|
* counters to report.
|
||||||
|
*/
|
||||||
|
export interface DailyEvaluationStep {
|
||||||
|
readonly name: DailyEvaluationStepName;
|
||||||
|
run(today: string): Promise<JobResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the steps in DAILY_EVALUATION_STEP_ORDER, whatever order they come
|
||||||
|
* in. A failing step is logged and does not stop the others; the counters of
|
||||||
|
* all steps - also the partial ones of a failed step - are merged into
|
||||||
|
* `result`, `failed` names the steps that failed and `failures` says why.
|
||||||
|
*/
|
||||||
|
export async function runDailyEvaluationSteps(
|
||||||
|
steps: readonly DailyEvaluationStep[],
|
||||||
|
today: string,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<{
|
||||||
|
result: JobResult;
|
||||||
|
failed: DailyEvaluationStepName[];
|
||||||
|
/** One entry per failed step, with the failed parts: "time-events: QueenAged". */
|
||||||
|
failures: string[];
|
||||||
|
}> {
|
||||||
|
const ordered = [...steps].sort(
|
||||||
|
(a, b) =>
|
||||||
|
DAILY_EVALUATION_STEP_ORDER.indexOf(a.name) -
|
||||||
|
DAILY_EVALUATION_STEP_ORDER.indexOf(b.name),
|
||||||
|
);
|
||||||
|
let result: JobResult = {};
|
||||||
|
const failed: DailyEvaluationStepName[] = [];
|
||||||
|
const failures: string[] = [];
|
||||||
|
for (const step of ordered) {
|
||||||
|
try {
|
||||||
|
result = { ...result, ...(await step.run(today)) };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const { message, stack } = loggableError(error);
|
||||||
|
logger.error(`Step "${step.name}" failed: ${message}`, stack);
|
||||||
|
failed.push(step.name);
|
||||||
|
if (error instanceof JobPartialFailure) {
|
||||||
|
result = { ...result, ...error.result };
|
||||||
|
failures.push(`${step.name}: ${error.failedSteps.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
failures.push(step.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { result, failed, failures };
|
||||||
|
}
|
||||||
@@ -1,146 +1,64 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Prisma } from '../../generated/prisma';
|
import { JobPartialFailure } from '../system/job-run.service';
|
||||||
import { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
|
||||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { DailyEvaluationJob } from './daily-evaluation.job';
|
import { DailyEvaluationJob } from './daily-evaluation.job';
|
||||||
|
import type { TimeEventsStep } from './steps/time-events.step';
|
||||||
type RawCall = [readonly string[], ...unknown[]];
|
|
||||||
|
|
||||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
|
||||||
|
|
||||||
describe('DailyEvaluationJob', () => {
|
describe('DailyEvaluationJob', () => {
|
||||||
const tx = { tx: true } as unknown as Prisma.TransactionClient;
|
const COUNTS = {
|
||||||
/** The database clock: a day the JS clock of the test never shows. */
|
|
||||||
const dbNow = new Date('2026-06-10T02:30:00.000Z');
|
|
||||||
let queryRaw: jest.Mock<Promise<unknown[]>, RawCall>;
|
|
||||||
let taskFindMany: jest.Mock;
|
|
||||||
let transaction: jest.Mock;
|
|
||||||
let recordAll: jest.Mock<
|
|
||||||
Promise<number>,
|
|
||||||
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
|
|
||||||
>;
|
|
||||||
let job: DailyEvaluationJob;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
queryRaw = jest.fn<Promise<unknown[]>, RawCall>(([sql]) =>
|
|
||||||
Promise.resolve(
|
|
||||||
sql.includes('now()')
|
|
||||||
? [{ now: dbNow }]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: 'treatment-1',
|
|
||||||
colonyId: 'colony-1',
|
|
||||||
date: d('2026-05-25'),
|
|
||||||
endDate: null,
|
|
||||||
withdrawalPeriodDays: 14,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
taskFindMany = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
|
|
||||||
transaction = jest.fn(
|
|
||||||
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
|
|
||||||
);
|
|
||||||
// Like createMany(skipDuplicates) on the unique dedupeKey.
|
|
||||||
const stored = new Set<string>();
|
|
||||||
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
|
|
||||||
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
|
|
||||||
fresh.forEach((event) => stored.add(event.dedupeKey!));
|
|
||||||
return Promise.resolve(fresh.length);
|
|
||||||
});
|
|
||||||
const prisma = {
|
|
||||||
$queryRaw: queryRaw,
|
|
||||||
$transaction: transaction,
|
|
||||||
task: { findMany: taskFindMany },
|
|
||||||
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
|
||||||
colony: { findMany: jest.fn().mockResolvedValue([]) },
|
|
||||||
varroaMeasurement: { groupBy: jest.fn().mockResolvedValue([]) },
|
|
||||||
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
|
|
||||||
} as unknown as PrismaService;
|
|
||||||
job = new DailyEvaluationJob(prisma, {
|
|
||||||
recordAll,
|
|
||||||
} as unknown as DomainEventsService);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => jest.restoreAllMocks());
|
|
||||||
|
|
||||||
it('is the daily-evaluation job', () => {
|
|
||||||
expect(job.name).toBe('daily-evaluation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('evaluates the day of the database clock and reports new events per type', async () => {
|
|
||||||
const result = await job.run();
|
|
||||||
|
|
||||||
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
|
|
||||||
// window of 2026-06-10 - not in the one of the test's own clock.
|
|
||||||
expect(result).toEqual({
|
|
||||||
WithdrawalEnded: 1,
|
WithdrawalEnded: 1,
|
||||||
TaskDue: 1,
|
TaskDue: 1,
|
||||||
QueenAged: 0,
|
QueenAged: 0,
|
||||||
VarroaMeasurementDue: 0,
|
VarroaMeasurementDue: 0,
|
||||||
InspectionOverdue: 0,
|
InspectionOverdue: 0,
|
||||||
});
|
};
|
||||||
expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()');
|
let timeEvents: { name: 'time-events'; run: jest.Mock };
|
||||||
|
let job: DailyEvaluationJob;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
|
||||||
|
timeEvents = { name: 'time-events', run: jest.fn() };
|
||||||
|
timeEvents.run.mockResolvedValue(COUNTS);
|
||||||
|
job = new DailyEvaluationJob(timeEvents as unknown as TimeEventsStep);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the daily-evaluation job', () => {
|
||||||
|
expect(job.name).toBe('daily-evaluation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
|
||||||
|
expect(await job.run()).toEqual(COUNTS);
|
||||||
|
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the Berlin day, not the UTC day, around midnight', async () => {
|
||||||
|
jest.setSystemTime(new Date('2026-06-09T22:30:00.000Z'));
|
||||||
|
|
||||||
await job.run();
|
await job.run();
|
||||||
|
|
||||||
// Types without an event open no transaction.
|
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||||
expect(transaction).toHaveBeenCalledTimes(2);
|
|
||||||
expect(recordAll.mock.calls).toEqual([
|
|
||||||
[
|
|
||||||
tx,
|
|
||||||
[
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'WithdrawalEnded',
|
|
||||||
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
tx,
|
|
||||||
[
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'TaskDue',
|
|
||||||
dedupeKey: 'TaskDue:task-1:2026-06-09',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a second run on the same day records nothing new', async () => {
|
it('a failing step fails the run with the counters of all steps', async () => {
|
||||||
await job.run();
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
timeEvents.run.mockRejectedValue(
|
||||||
expect(await job.run()).toEqual({
|
new JobPartialFailure({ WithdrawalEnded: 1 }, ['TaskDue']),
|
||||||
WithdrawalEnded: 0,
|
|
||||||
TaskDue: 0,
|
|
||||||
QueenAged: 0,
|
|
||||||
VarroaMeasurementDue: 0,
|
|
||||||
InspectionOverdue: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a failing rule does not stop the others, but fails the run', async () => {
|
|
||||||
const logged = jest
|
|
||||||
.spyOn(Logger.prototype, 'error')
|
|
||||||
.mockImplementation(() => undefined);
|
|
||||||
taskFindMany.mockRejectedValue(new Error('connection reset'));
|
|
||||||
|
|
||||||
await expect(job.evaluate(dbNow)).rejects.toThrow(
|
|
||||||
'Daily evaluation failed for: TaskDue',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(recordAll).toHaveBeenCalledWith(tx, [
|
const failure = await job.run().then(
|
||||||
expect.objectContaining({ type: 'WithdrawalEnded' }),
|
() => null,
|
||||||
]);
|
(error: unknown) => error,
|
||||||
expect(logged).toHaveBeenCalledWith(
|
|
||||||
'Rule "TaskDue" failed: connection reset',
|
|
||||||
expect.any(String),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(JobPartialFailure);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
failedSteps: ['time-events'],
|
||||||
|
result: { WithdrawalEnded: 1 },
|
||||||
|
message: 'Daily evaluation failed for: time-events: TaskDue',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,72 +1,39 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { loggableError } from '../common/loggable-error';
|
import { berlinToday } from '../common/dates';
|
||||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
import { JobPartialFailure } from '../system/job-run.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import {
|
import {
|
||||||
catchUpEvents,
|
DailyEvaluationStep,
|
||||||
DAILY_EVALUATION_RULES,
|
runDailyEvaluationSteps,
|
||||||
DailyRule,
|
} from './daily-evaluation-steps';
|
||||||
} from './daily-evaluation.rules';
|
|
||||||
import { Job, JobResult } from './job';
|
import { Job, JobResult } from './job';
|
||||||
import { JobName } from './job-names';
|
import { JobName } from './job-names';
|
||||||
|
import { TimeEventsStep } from './steps/time-events.step';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records the time-based domain events (CronJob 04:30): one rule per type,
|
* The daily evaluation (CronJob 04:30 Europe/Berlin): its steps run in the
|
||||||
* see DAILY_EVALUATION_RULES. An occurrence is recorded on every run within
|
* fixed order of DAILY_EVALUATION_STEP_ORDER, each in transactions of its
|
||||||
* CATCH_UP_DAYS after its day under the same daily dedupe key, so a missed
|
* own, and a failing step does not stop the others. The result holds the
|
||||||
* run is caught up and a repeated one adds nothing. The result counts the
|
* counters of all steps; if a step failed, the run fails with that result
|
||||||
* events that were new, per type.
|
* (JobPartialFailure), so it shows on the System page.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DailyEvaluationJob implements Job {
|
export class DailyEvaluationJob implements Job {
|
||||||
readonly name: JobName = 'daily-evaluation';
|
readonly name: JobName = 'daily-evaluation';
|
||||||
private readonly logger = new Logger(DailyEvaluationJob.name);
|
private readonly logger = new Logger(DailyEvaluationJob.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly timeEvents: TimeEventsStep) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly events: DomainEventsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Evaluates the current day of the database clock. */
|
/** Evaluates the current Berlin day. */
|
||||||
async run(): Promise<JobResult> {
|
async run(): Promise<JobResult> {
|
||||||
const [{ now }] = await this.prisma.$queryRaw<
|
const steps: DailyEvaluationStep[] = [this.timeEvents];
|
||||||
{ now: Date }[]
|
const { result, failed, failures } = await runDailyEvaluationSteps(
|
||||||
>`SELECT now() AS "now"`;
|
steps,
|
||||||
return this.evaluate(now);
|
berlinToday(),
|
||||||
}
|
this.logger,
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs every rule for `today`. A failing rule is logged and does not stop
|
|
||||||
* the others - their events are committed - but fails the run, so it
|
|
||||||
* shows on the System page; the next run catches up.
|
|
||||||
*/
|
|
||||||
async evaluate(today: Date): Promise<JobResult> {
|
|
||||||
const counts: [string, number][] = [];
|
|
||||||
const failed: string[] = [];
|
|
||||||
for (const rule of DAILY_EVALUATION_RULES) {
|
|
||||||
try {
|
|
||||||
counts.push([rule.type, await this.evaluateRule(rule, today)]);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const { message, stack } = loggableError(error);
|
|
||||||
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
|
|
||||||
failed.push(rule.type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failed.length > 0) {
|
|
||||||
throw new Error(`Daily evaluation failed for: ${failed.join(', ')}`);
|
|
||||||
}
|
|
||||||
return Object.fromEntries(counts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One transaction per type; returns how many events were new. */
|
|
||||||
private async evaluateRule(rule: DailyRule, today: Date): Promise<number> {
|
|
||||||
const events = catchUpEvents(
|
|
||||||
await rule.occurrences(this.prisma, today),
|
|
||||||
today,
|
|
||||||
);
|
);
|
||||||
if (events.length === 0) {
|
if (failed.length > 0) {
|
||||||
return 0;
|
throw new JobPartialFailure(result, failed, failures);
|
||||||
}
|
}
|
||||||
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SeasonService } from '../season/season.service';
|
||||||
import {
|
import {
|
||||||
ACTIVE_SEASON_MONTHS,
|
|
||||||
CATCH_UP_DAYS,
|
CATCH_UP_DAYS,
|
||||||
catchUpEvents,
|
catchUpEvents,
|
||||||
DAILY_EVALUATION_RULES,
|
DAILY_EVALUATION_RULES,
|
||||||
DailyEvent,
|
DailyEvent,
|
||||||
inCatchUpWindow,
|
inCatchUpWindow,
|
||||||
INSPECTION_INTERVAL_DAYS,
|
|
||||||
latestCycleOccurrence,
|
|
||||||
QUEEN_AGED_MIN_YEARS,
|
|
||||||
VARROA_MEASUREMENT_INTERVAL_DAYS,
|
|
||||||
withdrawalEndDay,
|
withdrawalEndDay,
|
||||||
} from './daily-evaluation.rules';
|
} from './daily-evaluation.rules';
|
||||||
|
|
||||||
@@ -17,8 +13,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|||||||
|
|
||||||
/** Midnight UTC of a 'YYYY-MM-DD' day. */
|
/** Midnight UTC of a 'YYYY-MM-DD' day. */
|
||||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
|
|
||||||
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
|
|
||||||
|
|
||||||
/** Every day from `from` to `to`, both included. */
|
/** Every day from `from` to `to`, both included. */
|
||||||
function daysBetween(from: string, to: string): string[] {
|
function daysBetween(from: string, to: string): string[] {
|
||||||
@@ -38,23 +32,25 @@ interface FakeRows {
|
|||||||
withdrawalPeriodDays: number | null;
|
withdrawalPeriodDays: number | null;
|
||||||
}[];
|
}[];
|
||||||
tasks?: { id: string; dueDate: Date | null }[];
|
tasks?: { id: string; dueDate: Date | null }[];
|
||||||
queens?: {
|
/** Living colonies, all in one apiary. */
|
||||||
id: string;
|
|
||||||
hatchYear: number | null;
|
|
||||||
colony: { id: string } | null;
|
|
||||||
}[];
|
|
||||||
colonies?: { id: string; createdAt: Date }[];
|
colonies?: { id: string; createdAt: Date }[];
|
||||||
|
/** Living colonies with their current queen (rows the queen query returns). */
|
||||||
|
queenedColonies?: {
|
||||||
|
id: string;
|
||||||
|
currentQueen: { id: string; hatchYear: number | null } | null;
|
||||||
|
}[];
|
||||||
/** The latest varroa measurement per colony. */
|
/** The latest varroa measurement per colony. */
|
||||||
varroa?: { colonyId: string; date: Date }[];
|
varroa?: { id: string; colonyId: string; date: Date }[];
|
||||||
/** The latest inspection per colony. */
|
/** The latest inspection per colony. */
|
||||||
inspections?: { colonyId: string; date: Date }[];
|
inspections?: { colonyId: string; date: Date }[];
|
||||||
|
/** Stored rule configs by trigger type; others read as the built-in setting. */
|
||||||
|
rules?: Record<string, object>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FakeDb {
|
interface FakeDb {
|
||||||
db: PrismaService;
|
db: PrismaService;
|
||||||
queryRaw: jest.Mock;
|
queryRaw: jest.Mock;
|
||||||
taskFindMany: jest.Mock;
|
taskFindMany: jest.Mock;
|
||||||
queenFindMany: jest.Mock;
|
|
||||||
colonyFindMany: jest.Mock;
|
colonyFindMany: jest.Mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,32 +59,60 @@ interface FakeDb {
|
|||||||
* day themselves, the database filters only narrow the candidates.
|
* day themselves, the database filters only narrow the candidates.
|
||||||
*/
|
*/
|
||||||
function fakeDb(rows: FakeRows): FakeDb {
|
function fakeDb(rows: FakeRows): FakeDb {
|
||||||
const latest = (records: { colonyId: string; date: Date }[] = []) =>
|
|
||||||
records.map(({ colonyId, date }) => ({ colonyId, _max: { date } }));
|
|
||||||
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
|
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
|
||||||
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
|
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
|
||||||
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
|
const colonyFindMany = jest.fn(
|
||||||
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
|
({ select }: { select: Record<string, unknown> }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
'currentQueen' in select
|
||||||
|
? (rows.queenedColonies ?? [])
|
||||||
|
: (rows.colonies ?? []).map((colony) => ({
|
||||||
|
...colony,
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
const db = {
|
const db = {
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
task: { findMany: taskFindMany },
|
task: { findMany: taskFindMany },
|
||||||
queen: { findMany: queenFindMany },
|
|
||||||
colony: { findMany: colonyFindMany },
|
colony: { findMany: colonyFindMany },
|
||||||
varroaMeasurement: {
|
varroaMeasurement: {
|
||||||
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
|
findMany: jest.fn().mockResolvedValue(rows.varroa ?? []),
|
||||||
},
|
},
|
||||||
inspection: {
|
inspection: {
|
||||||
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
|
groupBy: jest.fn().mockResolvedValue(
|
||||||
|
(rows.inspections ?? []).map(({ colonyId, date }) => ({
|
||||||
|
colonyId,
|
||||||
|
_max: { date },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
automationRule: {
|
||||||
|
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
|
||||||
|
const config = rows.rules?.[where.triggerType];
|
||||||
|
return Promise.resolve(
|
||||||
|
config === undefined
|
||||||
|
? null
|
||||||
|
: { id: 'rule-1', active: true, actionTemplate: null, config },
|
||||||
|
);
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
} as unknown as PrismaService;
|
} as unknown as PrismaService;
|
||||||
return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany };
|
return { db, queryRaw, taskFindMany, colonyFindMany };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seasons = new SeasonService();
|
||||||
|
|
||||||
/** One job run on `day`: every rule's events in the catch-up window. */
|
/** One job run on `day`: every rule's events in the catch-up window. */
|
||||||
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
|
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
|
||||||
const events: DailyEvent[] = [];
|
const events: DailyEvent[] = [];
|
||||||
for (const rule of DAILY_EVALUATION_RULES) {
|
for (const rule of DAILY_EVALUATION_RULES) {
|
||||||
events.push(...catchUpEvents(await rule.occurrences(db, at(day)), at(day)));
|
events.push(
|
||||||
|
...catchUpEvents(
|
||||||
|
await rule.occurrences(db, d(day), { seasons, day }),
|
||||||
|
d(day),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
@@ -113,13 +137,12 @@ async function runDaily(
|
|||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keysOf = (store: Map<string, DailyEvent>, type: string): string[] =>
|
||||||
|
[...store.keys()].filter((key) => key.startsWith(`${type}:`));
|
||||||
|
|
||||||
describe('daily evaluation rules', () => {
|
describe('daily evaluation rules', () => {
|
||||||
it('keeps the values of the backbone plan', () => {
|
it('keeps the catch-up window of the backbone plan', () => {
|
||||||
expect(CATCH_UP_DAYS).toBe(7);
|
expect(CATCH_UP_DAYS).toBe(7);
|
||||||
expect(QUEEN_AGED_MIN_YEARS).toBe(2);
|
|
||||||
expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28);
|
|
||||||
expect(INSPECTION_INTERVAL_DAYS).toBe(21);
|
|
||||||
expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs one rule per time-based event type', () => {
|
it('runs one rule per time-based event type', () => {
|
||||||
@@ -170,50 +193,9 @@ describe('daily evaluation rules', () => {
|
|||||||
).toBeNull());
|
).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('latestCycleOccurrence', () => {
|
|
||||||
it('is the latest base + k × interval up to today, with k ≥ 1', () => {
|
|
||||||
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe(
|
|
||||||
'2026-06-12',
|
|
||||||
);
|
|
||||||
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe(
|
|
||||||
'2026-05-22',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is null before the first full interval and for a base in the future', () => {
|
|
||||||
expect(
|
|
||||||
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts whole UTC days, not hours', () =>
|
|
||||||
expect(
|
|
||||||
latestCycleOccurrence(
|
|
||||||
new Date('2026-05-01T20:00:00.000Z'),
|
|
||||||
21,
|
|
||||||
at('2026-05-22'),
|
|
||||||
),
|
|
||||||
).toBe('2026-05-22'));
|
|
||||||
|
|
||||||
it('refuses an interval that is not a positive whole number of days', () => {
|
|
||||||
expect(() =>
|
|
||||||
latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')),
|
|
||||||
).toThrow(RangeError);
|
|
||||||
expect(() =>
|
|
||||||
latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')),
|
|
||||||
).toThrow(RangeError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('inCatchUpWindow', () => {
|
describe('inCatchUpWindow', () => {
|
||||||
it('spans today and the 7 days before it', () => {
|
it('spans today and the 7 days before it', () => {
|
||||||
const today = at('2026-06-10');
|
const today = d('2026-06-10');
|
||||||
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
|
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
|
||||||
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
|
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
|
||||||
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
|
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
|
||||||
@@ -222,8 +204,8 @@ describe('daily evaluation rules', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('works across a year boundary', () => {
|
it('works across a year boundary', () => {
|
||||||
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
|
expect(inCatchUpWindow('2026-12-29', d('2027-01-04'))).toBe(true);
|
||||||
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
|
expect(inCatchUpWindow('2026-12-27', d('2027-01-04'))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,10 +227,11 @@ describe('daily evaluation rules', () => {
|
|||||||
// Never measured or inspected: they count from its creation.
|
// Never measured or inspected: they count from its creation.
|
||||||
{ id: 'colony-2', createdAt: d('2026-04-12') },
|
{ id: 'colony-2', createdAt: d('2026-04-12') },
|
||||||
],
|
],
|
||||||
varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }],
|
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-05-01') }],
|
||||||
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
|
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2026-05-29 is SCHWARMZEIT (inspection every 7 days).
|
||||||
expect(await eventsOn(db, '2026-05-29')).toEqual([
|
expect(await eventsOn(db, '2026-05-29')).toEqual([
|
||||||
{
|
{
|
||||||
type: 'WithdrawalEnded',
|
type: 'WithdrawalEnded',
|
||||||
@@ -269,16 +252,26 @@ describe('daily evaluation rules', () => {
|
|||||||
type: 'VarroaMeasurementDue',
|
type: 'VarroaMeasurementDue',
|
||||||
aggregateType: 'Colony',
|
aggregateType: 'Colony',
|
||||||
aggregateId: 'colony-1',
|
aggregateId: 'colony-1',
|
||||||
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
|
payload: {
|
||||||
|
dueOn: '2026-05-29',
|
||||||
|
lastMeasuredOn: '2026-05-01',
|
||||||
|
kind: 'INTERVAL',
|
||||||
|
anchor: 'm-1',
|
||||||
|
},
|
||||||
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
|
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
|
||||||
},
|
},
|
||||||
// colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due.
|
// colony-1: 2026-05-20 + 7 = 2026-05-27 is due, overdue the day after.
|
||||||
|
// colony-2: 2026-04-12 + 7, overdue on 2026-04-20, outside the window.
|
||||||
{
|
{
|
||||||
type: 'InspectionOverdue',
|
type: 'InspectionOverdue',
|
||||||
aggregateType: 'Colony',
|
aggregateType: 'Colony',
|
||||||
aggregateId: 'colony-2',
|
aggregateId: 'colony-1',
|
||||||
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
|
payload: {
|
||||||
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
|
dueOn: '2026-05-27',
|
||||||
|
lastInspectedOn: '2026-05-20',
|
||||||
|
firstOfYear: false,
|
||||||
|
},
|
||||||
|
dedupeKey: 'InspectionOverdue:colony-1:2026-05-28',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -334,82 +327,300 @@ describe('daily evaluation rules', () => {
|
|||||||
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
|
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits no inspection or varroa event outside March–October', async () => {
|
describe('QueenAged', () => {
|
||||||
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
|
const queen = (hatchYear: number | null) => ({
|
||||||
const { db } = fakeDb({
|
id: 'queen-1',
|
||||||
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
|
hatchYear,
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
|
it('fires on the job day, once per queen and year under the January 1 key', async () => {
|
||||||
const dueDays = (type: string) =>
|
|
||||||
[...store.values()]
|
|
||||||
.filter((event) => event.type === type)
|
|
||||||
.map((event) => event.dedupeKey.split(':')[2]);
|
|
||||||
|
|
||||||
// Not 2026-02-07, 2026-11-14 or 2026-12-12.
|
|
||||||
expect(dueDays('VarroaMeasurementDue')).toEqual([
|
|
||||||
'2026-03-07',
|
|
||||||
'2026-04-04',
|
|
||||||
'2026-05-02',
|
|
||||||
'2026-05-30',
|
|
||||||
'2026-06-27',
|
|
||||||
'2026-07-25',
|
|
||||||
'2026-08-22',
|
|
||||||
'2026-09-19',
|
|
||||||
'2026-10-17',
|
|
||||||
]);
|
|
||||||
// Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12.
|
|
||||||
expect(dueDays('InspectionOverdue')).toEqual([
|
|
||||||
'2026-03-14',
|
|
||||||
'2026-04-04',
|
|
||||||
'2026-04-25',
|
|
||||||
'2026-05-16',
|
|
||||||
'2026-06-06',
|
|
||||||
'2026-06-27',
|
|
||||||
'2026-07-18',
|
|
||||||
'2026-08-08',
|
|
||||||
'2026-08-29',
|
|
||||||
'2026-09-19',
|
|
||||||
'2026-10-10',
|
|
||||||
'2026-10-31',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
|
|
||||||
const { db } = fakeDb({
|
const { db } = fakeDb({
|
||||||
queens: [
|
queenedColonies: [
|
||||||
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
|
{ id: 'colony-1', currentQueen: queen(2024) },
|
||||||
{ id: 'queen-2027', hatchYear: 2027, colony: null },
|
{ id: 'colony-2', currentQueen: { id: 'queen-2', hatchYear: 2025 } },
|
||||||
{ id: 'queen-unknown', hatchYear: null, colony: null },
|
{ id: 'colony-3', currentQueen: { id: 'queen-3', hatchYear: null } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
|
expect(await eventsOn(db, '2026-09-09')).toEqual([
|
||||||
|
|
||||||
expect([...store.values()]).toEqual([
|
|
||||||
{
|
{
|
||||||
type: 'QueenAged',
|
type: 'QueenAged',
|
||||||
aggregateType: 'Queen',
|
aggregateType: 'Queen',
|
||||||
aggregateId: 'queen-2026',
|
aggregateId: 'queen-1',
|
||||||
payload: { hatchYear: 2026, colonyId: 'colony-1' },
|
payload: { hatchYear: 2024, colonyId: 'colony-1' },
|
||||||
dedupeKey: 'QueenAged:queen-2026:2028-01-01',
|
dedupeKey: 'QueenAged:queen-1:2026-01-01',
|
||||||
},
|
|
||||||
expect.objectContaining({
|
|
||||||
dedupeKey: 'QueenAged:queen-2026:2029-01-01',
|
|
||||||
}) as DailyEvent,
|
|
||||||
{
|
|
||||||
type: 'QueenAged',
|
|
||||||
aggregateType: 'Queen',
|
|
||||||
aggregateId: 'queen-2027',
|
|
||||||
payload: { hatchYear: 2027, colonyId: null },
|
|
||||||
dedupeKey: 'QueenAged:queen-2027:2029-01-01',
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('QueenAged is not limited to January 1–8', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
queenedColonies: [{ id: 'colony-1', currentQueen: queen(2024) }],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const day of ['2026-01-01', '2026-03-15', '2026-11-30']) {
|
||||||
|
expect((await eventsOn(db, day)).map((e) => e.dedupeKey)).toEqual([
|
||||||
|
'QueenAged:queen-1:2026-01-01',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('QueenAged uses queenMaxAgeYears', async () => {
|
||||||
|
const queened = [{ id: 'colony-1', currentQueen: queen(2025) }];
|
||||||
|
const { db: byDefault } = fakeDb({ queenedColonies: queened });
|
||||||
|
const { db: stricter, colonyFindMany } = fakeDb({
|
||||||
|
queenedColonies: queened,
|
||||||
|
rules: { SEASON_AUTOPILOT: { queenMaxAgeYears: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await eventsOn(byDefault, '2026-06-10')).toEqual([]);
|
||||||
|
expect(await eventsOn(stricter, '2026-06-10')).toHaveLength(1);
|
||||||
|
expect(colonyFindMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
currentQueen: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
|
||||||
|
}) as object,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => {
|
||||||
|
const { db, colonyFindMany } = fakeDb({});
|
||||||
|
// The queen (hatched 2024) is assigned to the colony on 2026-06-15.
|
||||||
|
let assigned = false;
|
||||||
|
colonyFindMany.mockImplementation(
|
||||||
|
({ select }: { select: Record<string, unknown> }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
'currentQueen' in select && assigned
|
||||||
|
? [{ id: 'colony-1', currentQueen: queen(2024) }]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const store = await runDaily(db, daysBetween('2026-06-01', '2026-06-14'));
|
||||||
|
expect([...store.keys()]).toEqual([]);
|
||||||
|
|
||||||
|
assigned = true;
|
||||||
|
expect(
|
||||||
|
(await eventsOn(db, '2026-06-15')).map((event) => event.dedupeKey),
|
||||||
|
).toEqual(['QueenAged:queen-1:2026-01-01']);
|
||||||
|
|
||||||
|
await runDaily(db, daysBetween('2026-06-15', '2026-12-31'), store);
|
||||||
|
expect([...store.keys()]).toEqual(['QueenAged:queen-1:2026-01-01']);
|
||||||
|
|
||||||
|
// The next year is a new anchor.
|
||||||
|
await runDaily(db, ['2027-01-02'], store);
|
||||||
|
expect([...store.keys()]).toEqual([
|
||||||
|
'QueenAged:queen-1:2026-01-01',
|
||||||
|
'QueenAged:queen-1:2027-01-01',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InspectionOverdue', () => {
|
||||||
|
it('emits InspectionOverdue on the day after inspectionDueOn by the segment interval', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
// SCHWARMZEIT since 2026-04-15: 2026-04-28 + 7 = 2026-05-05.
|
||||||
|
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await eventsOn(db, '2026-05-05')).filter(
|
||||||
|
(event) => event.type === 'InspectionOverdue',
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
(await eventsOn(db, '2026-05-06')).filter(
|
||||||
|
(event) => event.type === 'InspectionOverdue',
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: 'InspectionOverdue',
|
||||||
|
aggregateType: 'Colony',
|
||||||
|
aggregateId: 'colony-1',
|
||||||
|
payload: {
|
||||||
|
dueOn: '2026-05-05',
|
||||||
|
lastInspectedOn: '2026-04-28',
|
||||||
|
firstOfYear: false,
|
||||||
|
},
|
||||||
|
dedupeKey: 'InspectionOverdue:colony-1:2026-05-06',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a due date inside the winter yields InspectionOverdue the day after the start of FRUEHJAHR and none inside WINTERRUHE', async () => {
|
||||||
|
// Last inspected in autumn: the next one is due in the winter.
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
inspections: [{ colonyId: 'colony-1', date: d('2025-10-20') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const winter = await runDaily(
|
||||||
|
db,
|
||||||
|
daysBetween('2026-01-01', '2026-03-19'),
|
||||||
|
);
|
||||||
|
expect([...winter.keys()]).toEqual([]);
|
||||||
|
|
||||||
|
// FRUEHJAHR begins 2026-03-20: due that day, overdue the day after.
|
||||||
|
const spring = await runDaily(
|
||||||
|
db,
|
||||||
|
daysBetween('2026-03-20', '2026-04-10'),
|
||||||
|
);
|
||||||
|
expect(keysOf(spring, 'InspectionOverdue')).toEqual([
|
||||||
|
'InspectionOverdue:colony-1:2026-03-21',
|
||||||
|
]);
|
||||||
|
expect(spring.get('InspectionOverdue:colony-1:2026-03-21')).toMatchObject(
|
||||||
|
{
|
||||||
|
payload: {
|
||||||
|
dueOn: '2026-03-20',
|
||||||
|
lastInspectedOn: '2025-10-20',
|
||||||
|
firstOfYear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits nothing for a due date more than a week back (one event per due date)', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
|
||||||
|
});
|
||||||
|
const inspections = async (day: string) =>
|
||||||
|
(await eventsOn(db, day)).filter((e) => e.type === 'InspectionOverdue');
|
||||||
|
|
||||||
|
// Due 2026-05-05, overdue 2026-05-06, last caught up on 2026-05-13.
|
||||||
|
expect(await inspections('2026-05-13')).toHaveLength(1);
|
||||||
|
// Due + 9 days.
|
||||||
|
expect(await inspections('2026-05-14')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the same dedupe key on consecutive days while nothing changes', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
|
||||||
|
});
|
||||||
|
const keys = async (day: string) =>
|
||||||
|
(await eventsOn(db, day)).map((event) => event.dedupeKey);
|
||||||
|
|
||||||
|
expect(await keys('2026-05-08')).toEqual(
|
||||||
|
expect.arrayContaining(['InspectionOverdue:colony-1:2026-05-06']),
|
||||||
|
);
|
||||||
|
expect(await keys('2026-05-09')).toEqual(await keys('2026-05-08'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a segment interval change gives a second event with another key', async () => {
|
||||||
|
// Inspected 2026-06-05: SCHWARMZEIT (7 days) is overdue on 06-13; from
|
||||||
|
// SOMMER on 2026-06-20 (14 days) the due date is 06-19, overdue 06-20.
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
inspections: [{ colonyId: 'colony-1', date: d('2026-06-05') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await runDaily(db, daysBetween('2026-06-06', '2026-06-25'));
|
||||||
|
|
||||||
|
expect(keysOf(store, 'InspectionOverdue')).toEqual([
|
||||||
|
'InspectionOverdue:colony-1:2026-06-13',
|
||||||
|
'InspectionOverdue:colony-1:2026-06-20',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a HERBST due date inside the winter gives no event on WINTER and a first-of-year one in spring', async () => {
|
||||||
|
// HERBST: 21 days. 2026-10-24 + 21 = 2026-11-14, overdue on 11-15 -
|
||||||
|
// but 11-15 is WINTERRUHE, so nothing.
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
inspections: [{ colonyId: 'colony-1', date: d('2026-10-24') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
keysOf(
|
||||||
|
await runDaily(db, daysBetween('2026-11-10', '2027-03-19')),
|
||||||
|
'InspectionOverdue',
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
const spring = await runDaily(
|
||||||
|
db,
|
||||||
|
daysBetween('2027-03-20', '2027-03-25'),
|
||||||
|
);
|
||||||
|
expect(spring.get('InspectionOverdue:colony-1:2027-03-21')).toMatchObject(
|
||||||
|
{ payload: { dueOn: '2027-03-20', firstOfYear: true } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits no inspection event inside WINTERRUHE although the due date passes', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
inspections: [{ colonyId: 'colony-1', date: d('2026-11-01') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const winter = await runDaily(
|
||||||
|
db,
|
||||||
|
daysBetween('2026-11-15', '2027-03-19'),
|
||||||
|
);
|
||||||
|
expect(keysOf(winter, 'InspectionOverdue')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VarroaMeasurementDue', () => {
|
||||||
|
it('is due on the winter window start without a measurement since 11-01 and not in the rest of WINTERRUHE', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-09-20') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
|
||||||
|
|
||||||
|
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
|
||||||
|
'VarroaMeasurementDue:colony-1:2026-11-15',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not due in the winter window after a measurement since 11-01', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
varroa: [{ id: 'm-2', colonyId: 'colony-1', date: d('2026-11-03') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
|
||||||
|
|
||||||
|
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes up a due date inside the winter at the start of FRUEHJAHR', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
|
||||||
|
// Measured 2026-11-20 (inside the winter window of the year): the
|
||||||
|
// next interval day, 12-18, lies in WINTERRUHE.
|
||||||
|
varroa: [{ id: 'm-9', colonyId: 'colony-1', date: d('2026-11-20') }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await runDaily(db, daysBetween('2026-11-21', '2027-03-25'));
|
||||||
|
|
||||||
|
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
|
||||||
|
'VarroaMeasurementDue:colony-1:2027-03-20',
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
store.get('VarroaMeasurementDue:colony-1:2027-03-20'),
|
||||||
|
).toMatchObject({ payload: { kind: 'INTERVAL', anchor: 'm-9' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows varroaMeasureIntervalDays outside WINTERRUHE', async () => {
|
||||||
|
const { db } = fakeDb({
|
||||||
|
colonies: [{ id: 'colony-1', createdAt: d('2026-04-20') }],
|
||||||
|
rules: { SEASON_AUTOPILOT: { varroaMeasureIntervalDays: 14 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await runDaily(db, daysBetween('2026-05-01', '2026-05-10'));
|
||||||
|
|
||||||
|
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
|
||||||
|
'VarroaMeasurementDue:colony-1:2026-05-04',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('asks the database only for the candidates of the catch-up window', async () => {
|
it('asks the database only for the candidates of the catch-up window', async () => {
|
||||||
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
|
const { db, queryRaw, taskFindMany, colonyFindMany } = fakeDb({});
|
||||||
fakeDb({});
|
|
||||||
|
|
||||||
await eventsOn(db, '2026-06-10');
|
await eventsOn(db, '2026-06-10');
|
||||||
|
|
||||||
@@ -434,14 +645,5 @@ describe('daily evaluation rules', () => {
|
|||||||
];
|
];
|
||||||
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
|
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
|
||||||
expect(values).toEqual(['2026-06-03', '2026-06-10']);
|
expect(values).toEqual(['2026-06-03', '2026-06-10']);
|
||||||
// QueenAged only looks on January 1-8.
|
|
||||||
expect(queenFindMany).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await eventsOn(db, '2027-01-08');
|
|
||||||
expect(queenFindMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import { TaskStatus, Treatment } from '../../generated/prisma';
|
import { TaskStatus, Treatment } from '../../generated/prisma';
|
||||||
|
import { readRule } from '../automation/rule-config';
|
||||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
|
import { addDays, berlinDayOf } from '../common/dates';
|
||||||
import {
|
import {
|
||||||
AnyNewDomainEvent,
|
AnyNewDomainEvent,
|
||||||
dailyDedupeKey,
|
dailyDedupeKey,
|
||||||
utcDay,
|
utcDay,
|
||||||
} from '../domain-events/domain-event-types';
|
} from '../domain-events/domain-event-types';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { inspectionDueOn } from '../season/inspection-due';
|
||||||
|
import { isQueenAged } from '../season/queen-age';
|
||||||
|
import type { SeasonService } from '../season/season.service';
|
||||||
|
import { varroaMeasurementDue } from '../season/varroa-due';
|
||||||
|
|
||||||
/** How many days back a run still records an occurrence it missed. */
|
/** How many days back a run still records an occurrence it missed. */
|
||||||
export const CATCH_UP_DAYS = 7;
|
export const CATCH_UP_DAYS = 7;
|
||||||
/** A queen counts as aged from this many years after her hatch year. */
|
|
||||||
export const QUEEN_AGED_MIN_YEARS = 2;
|
|
||||||
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
|
|
||||||
export const INSPECTION_INTERVAL_DAYS = 21;
|
|
||||||
/** March to October: varroa and inspection reminders only in these months. */
|
|
||||||
export const ACTIVE_SEASON_MONTHS: readonly number[] = [
|
|
||||||
3, 4, 5, 6, 7, 8, 9, 10,
|
|
||||||
];
|
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS'];
|
const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS'];
|
||||||
@@ -37,18 +35,35 @@ export type DailyEvent<T extends DailyEventType = DailyEventType> =
|
|||||||
export interface DailyOccurrence<T extends DailyEventType = DailyEventType> {
|
export interface DailyOccurrence<T extends DailyEventType = DailyEventType> {
|
||||||
day: string;
|
day: string;
|
||||||
event: AnyNewDomainEvent<T>;
|
event: AnyNewDomainEvent<T>;
|
||||||
|
/**
|
||||||
|
* The day of the dedupe key when it is not `day`: an occurrence that
|
||||||
|
* repeats on every job day but must be recorded once (QueenAged: the
|
||||||
|
* January 1 of the year).
|
||||||
|
*/
|
||||||
|
anchorDay?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a rule needs besides the database: the seasons and the run's day. */
|
||||||
|
export interface DailyRuleContext {
|
||||||
|
seasons: SeasonService;
|
||||||
|
/** The Berlin day of the run, YYYY-MM-DD. */
|
||||||
|
day: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row of the daily evaluation. `occurrences` reads the candidates and
|
* One row of the daily evaluation. `occurrences` reads the candidates and
|
||||||
* names the day of each; the job keeps those in the catch-up window
|
* names the day of each; the step keeps those in the catch-up window
|
||||||
* (catchUpEvents) and records them under their daily dedupe key. A rule
|
* (catchUpEvents) and records them under their daily dedupe key. A rule
|
||||||
* may narrow its query to the window, but must not rely on that: the day
|
* may narrow its query to the window, but must not rely on that: the day
|
||||||
* it reports decides.
|
* it reports decides. `today` is dayToDate(ctx.day).
|
||||||
*/
|
*/
|
||||||
export interface DailyRule<T extends DailyEventType = DailyEventType> {
|
export interface DailyRule<T extends DailyEventType = DailyEventType> {
|
||||||
readonly type: T;
|
readonly type: T;
|
||||||
occurrences(db: PrismaService, today: Date): Promise<DailyOccurrence<T>[]>;
|
occurrences(
|
||||||
|
db: PrismaService,
|
||||||
|
today: Date,
|
||||||
|
ctx: DailyRuleContext,
|
||||||
|
): Promise<DailyOccurrence<T>[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Midnight UTC of the UTC day of `date`. */
|
/** Midnight UTC of the UTC day of `date`. */
|
||||||
@@ -73,39 +88,11 @@ export function withdrawalEndDay(
|
|||||||
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
|
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The latest day base + k × intervalDays (k ≥ 1) that is not after today,
|
|
||||||
* in whole UTC days; null if not even the first interval has passed.
|
|
||||||
*/
|
|
||||||
export function latestCycleOccurrence(
|
|
||||||
base: Date,
|
|
||||||
intervalDays: number,
|
|
||||||
today: Date,
|
|
||||||
): string | null {
|
|
||||||
if (!Number.isInteger(intervalDays) || intervalDays < 1) {
|
|
||||||
throw new RangeError(
|
|
||||||
`Interval must be a positive whole number of days, got ${intervalDays}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const elapsedDays = Math.round(
|
|
||||||
(dayStart(today).getTime() - dayStart(base).getTime()) / DAY_MS,
|
|
||||||
);
|
|
||||||
const cycles = Math.floor(elapsedDays / intervalDays);
|
|
||||||
if (cycles < 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return utcDay(dayAfter(base, cycles * intervalDays));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
|
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
|
||||||
export function inCatchUpWindow(day: string, today: Date): boolean {
|
export function inCatchUpWindow(day: string, today: Date): boolean {
|
||||||
return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today);
|
return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today);
|
||||||
}
|
}
|
||||||
|
|
||||||
function inActiveSeason(day: string): boolean {
|
|
||||||
return ACTIVE_SEASON_MONTHS.includes(Number(day.slice(5, 7)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The occurrences in the catch-up window, as events with their daily dedupe
|
* The occurrences in the catch-up window, as events with their daily dedupe
|
||||||
* key: every run within the window repeats the same key, so the unique
|
* key: every run within the window repeats the same key, so the unique
|
||||||
@@ -117,9 +104,13 @@ export function catchUpEvents(
|
|||||||
): DailyEvent[] {
|
): DailyEvent[] {
|
||||||
return occurrences
|
return occurrences
|
||||||
.filter(({ day }) => inCatchUpWindow(day, today))
|
.filter(({ day }) => inCatchUpWindow(day, today))
|
||||||
.map(({ day, event }) => ({
|
.map(({ day, anchorDay, event }) => ({
|
||||||
...event,
|
...event,
|
||||||
dedupeKey: dailyDedupeKey(event.type, event.aggregateId, day),
|
dedupeKey: dailyDedupeKey(
|
||||||
|
event.type,
|
||||||
|
event.aggregateId,
|
||||||
|
anchorDay ?? day,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,34 +188,47 @@ const taskDue: DailyRule<'TaskDue'> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Active queen from her second year after hatching: once a year, on January 1. */
|
/**
|
||||||
|
* The current queen of a living colony once she is aged
|
||||||
|
* (SEASON_AUTOPILOT.queenMaxAgeYears): on the first job day she is, once per
|
||||||
|
* queen and year (the key is anchored on January 1), so a queen assigned
|
||||||
|
* later in the year, or a changed hatch year, is caught too.
|
||||||
|
*/
|
||||||
const queenAged: DailyRule<'QueenAged'> = {
|
const queenAged: DailyRule<'QueenAged'> = {
|
||||||
type: 'QueenAged',
|
type: 'QueenAged',
|
||||||
async occurrences(db, today) {
|
async occurrences(db, _today, { day }) {
|
||||||
const year = today.getUTCFullYear();
|
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
|
||||||
const day = `${year}-01-01`;
|
const year = Number(day.slice(0, 4));
|
||||||
if (!inCatchUpWindow(day, today)) {
|
const colonies = await db.colony.findMany({
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const queens = await db.queen.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
|
...LIVING_COLONY_WHERE,
|
||||||
|
currentQueen: {
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS },
|
hatchYear: { lte: year - config.queenMaxAgeYears },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
currentQueen: { select: { id: true, hatchYear: true } },
|
||||||
},
|
},
|
||||||
select: { id: true, hatchYear: true, colony: { select: { id: true } } },
|
|
||||||
});
|
});
|
||||||
return queens.flatMap(({ id, hatchYear, colony }) => {
|
return colonies.flatMap(({ id, currentQueen }) => {
|
||||||
if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) {
|
if (
|
||||||
|
currentQueen === null ||
|
||||||
|
currentQueen.hatchYear === null ||
|
||||||
|
!isQueenAged(currentQueen.hatchYear, day, config.queenMaxAgeYears)
|
||||||
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
day,
|
day,
|
||||||
|
anchorDay: `${year}-01-01`,
|
||||||
event: {
|
event: {
|
||||||
type: 'QueenAged',
|
type: 'QueenAged',
|
||||||
aggregateType: 'Queen',
|
aggregateType: 'Queen',
|
||||||
aggregateId: id,
|
aggregateId: currentQueen.id,
|
||||||
payload: { hatchYear, colonyId: colony?.id ?? null },
|
payload: { hatchYear: currentQueen.hatchYear, colonyId: id },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -232,98 +236,146 @@ const queenAged: DailyRule<'QueenAged'> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CycleDue {
|
interface SeasonalColony {
|
||||||
colonyId: string;
|
id: string;
|
||||||
day: string;
|
apiaryId: string;
|
||||||
/** The UTC day of the last record, null if the colony has none. */
|
/** The Berlin day the colony was created on. */
|
||||||
lastOn: string | null;
|
createdOn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function livingColonies(db: PrismaService): Promise<SeasonalColony[]> {
|
||||||
|
const colonies = await db.colony.findMany({
|
||||||
|
where: LIVING_COLONY_WHERE,
|
||||||
|
select: { id: true, apiaryId: true, createdAt: true },
|
||||||
|
});
|
||||||
|
return colonies.map(({ id, apiaryId, createdAt }) => ({
|
||||||
|
id,
|
||||||
|
apiaryId,
|
||||||
|
createdOn: berlinDayOf(createdAt),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Living colonies whose cycle comes due in the active season: counted from
|
* Living colony: varroa measurement due, by the season of its apiary
|
||||||
* the last record, or from the colony's creation if it has none.
|
* (varroaMeasurementDue): the interval after the last measurement, or the
|
||||||
|
* winter window. One event per due date, on the due day; the 7-day catch-up
|
||||||
|
* only covers job outages (the A2 suggestion carries the reminder after).
|
||||||
*/
|
*/
|
||||||
async function colonyCyclesDue(
|
const varroaDue: DailyRule<'VarroaMeasurementDue'> = {
|
||||||
db: PrismaService,
|
type: 'VarroaMeasurementDue',
|
||||||
lastRecordOn: ReadonlyMap<string, Date>,
|
async occurrences(db, _today, { seasons, day }) {
|
||||||
intervalDays: number,
|
const colonies = await livingColonies(db);
|
||||||
today: Date,
|
const latest = await db.varroaMeasurement.findMany({
|
||||||
): Promise<CycleDue[]> {
|
where: { colony: LIVING_COLONY_WHERE },
|
||||||
const colonies = await db.colony.findMany({
|
distinct: ['colonyId'],
|
||||||
where: LIVING_COLONY_WHERE,
|
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }, { id: 'desc' }],
|
||||||
select: { id: true, createdAt: true },
|
select: { id: true, colonyId: true, date: true },
|
||||||
});
|
});
|
||||||
return colonies.flatMap(({ id, createdAt }) => {
|
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
|
||||||
const last = lastRecordOn.get(id) ?? null;
|
const states = await seasons.statesFor(
|
||||||
const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today);
|
db,
|
||||||
if (day === null || !inActiveSeason(day)) {
|
colonies.map((colony) => colony.apiaryId),
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
const lastByColony = new Map(latest.map((row) => [row.colonyId, row]));
|
||||||
|
return colonies.flatMap((colony) => {
|
||||||
|
const season = states.get(colony.apiaryId);
|
||||||
|
const last = lastByColony.get(colony.id);
|
||||||
|
const lastMeasuredOn = last === undefined ? null : utcDay(last.date);
|
||||||
|
const due =
|
||||||
|
season === undefined
|
||||||
|
? null
|
||||||
|
: varroaMeasurementDue(
|
||||||
|
{
|
||||||
|
createdOn: colony.createdOn,
|
||||||
|
lastMeasuredOn,
|
||||||
|
lastMeasurementId: last?.id ?? null,
|
||||||
|
},
|
||||||
|
season,
|
||||||
|
config,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
if (due === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }];
|
return [
|
||||||
});
|
{
|
||||||
}
|
day: due.dueOn,
|
||||||
|
|
||||||
function latestByColony(
|
|
||||||
rows: { colonyId: string; _max: { date: Date | null } }[],
|
|
||||||
): ReadonlyMap<string, Date> {
|
|
||||||
return new Map(
|
|
||||||
rows.flatMap(({ colonyId, _max }) =>
|
|
||||||
_max.date === null ? [] : [[colonyId, _max.date] as const],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Living colony: varroa measurement due again in the active season. */
|
|
||||||
const varroaMeasurementDue: DailyRule<'VarroaMeasurementDue'> = {
|
|
||||||
type: 'VarroaMeasurementDue',
|
|
||||||
async occurrences(db, today) {
|
|
||||||
const latest = await db.varroaMeasurement.groupBy({
|
|
||||||
by: ['colonyId'],
|
|
||||||
where: { colony: LIVING_COLONY_WHERE },
|
|
||||||
_max: { date: true },
|
|
||||||
});
|
|
||||||
const due = await colonyCyclesDue(
|
|
||||||
db,
|
|
||||||
latestByColony(latest),
|
|
||||||
VARROA_MEASUREMENT_INTERVAL_DAYS,
|
|
||||||
today,
|
|
||||||
);
|
|
||||||
return due.map(({ colonyId, day, lastOn }) => ({
|
|
||||||
day,
|
|
||||||
event: {
|
event: {
|
||||||
type: 'VarroaMeasurementDue',
|
type: 'VarroaMeasurementDue',
|
||||||
aggregateType: 'Colony',
|
aggregateType: 'Colony',
|
||||||
aggregateId: colonyId,
|
aggregateId: colony.id,
|
||||||
payload: { dueOn: day, lastMeasuredOn: lastOn },
|
payload: {
|
||||||
|
dueOn: due.dueOn,
|
||||||
|
lastMeasuredOn,
|
||||||
|
kind: due.kind,
|
||||||
|
anchor: due.anchor,
|
||||||
},
|
},
|
||||||
}));
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Living colony: inspection overdue in the active season. */
|
/**
|
||||||
|
* Living colony: inspection overdue the day after inspectionDueOn - by the
|
||||||
|
* interval of the season segment of its apiary, never in WINTERRUHE; after
|
||||||
|
* the winter on the day after FRUEHJAHR begins ("first of the year"). One
|
||||||
|
* event per due date; the 7-day catch-up only covers job outages (A1 carries
|
||||||
|
* the reminder after). A changed interval (new segment) yields a new due
|
||||||
|
* date and so a second event with another key.
|
||||||
|
*/
|
||||||
const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
|
const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
|
||||||
type: 'InspectionOverdue',
|
type: 'InspectionOverdue',
|
||||||
async occurrences(db, today) {
|
async occurrences(db, _today, { seasons, day }) {
|
||||||
|
const colonies = await livingColonies(db);
|
||||||
const latest = await db.inspection.groupBy({
|
const latest = await db.inspection.groupBy({
|
||||||
by: ['colonyId'],
|
by: ['colonyId'],
|
||||||
where: { colony: LIVING_COLONY_WHERE },
|
where: { colony: LIVING_COLONY_WHERE },
|
||||||
_max: { date: true },
|
_max: { date: true },
|
||||||
});
|
});
|
||||||
const due = await colonyCyclesDue(
|
const lastInspected = new Map(
|
||||||
db,
|
latest.flatMap(({ colonyId, _max }) =>
|
||||||
latestByColony(latest),
|
_max.date === null ? [] : [[colonyId, utcDay(_max.date)] as const],
|
||||||
INSPECTION_INTERVAL_DAYS,
|
),
|
||||||
today,
|
|
||||||
);
|
);
|
||||||
return due.map(({ colonyId, day, lastOn }) => ({
|
const { config } = await readRule(db, 'INSPECTION_INTERVAL');
|
||||||
|
const states = await seasons.statesFor(
|
||||||
|
db,
|
||||||
|
colonies.map((colony) => colony.apiaryId),
|
||||||
day,
|
day,
|
||||||
|
);
|
||||||
|
return colonies.flatMap((colony) => {
|
||||||
|
const season = states.get(colony.apiaryId);
|
||||||
|
const lastInspectedOn = lastInspected.get(colony.id) ?? null;
|
||||||
|
const due =
|
||||||
|
season === undefined
|
||||||
|
? null
|
||||||
|
: inspectionDueOn(
|
||||||
|
{ createdOn: colony.createdOn, lastInspectedOn },
|
||||||
|
season,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
if (due === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
day: addDays(due.dueOn, 1),
|
||||||
event: {
|
event: {
|
||||||
type: 'InspectionOverdue',
|
type: 'InspectionOverdue',
|
||||||
aggregateType: 'Colony',
|
aggregateType: 'Colony',
|
||||||
aggregateId: colonyId,
|
aggregateId: colony.id,
|
||||||
payload: { dueOn: day, lastInspectedOn: lastOn },
|
payload: {
|
||||||
|
dueOn: due.dueOn,
|
||||||
|
lastInspectedOn,
|
||||||
|
firstOfYear: due.firstOfYear,
|
||||||
},
|
},
|
||||||
}));
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -332,6 +384,6 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
|
|||||||
withdrawalEnded,
|
withdrawalEnded,
|
||||||
taskDue,
|
taskDue,
|
||||||
queenAged,
|
queenAged,
|
||||||
varroaMeasurementDue,
|
varroaDue,
|
||||||
inspectionOverdue,
|
inspectionOverdue,
|
||||||
];
|
];
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
|
||||||
import { BackupModule } from '../backup/backup.module';
|
import { BackupModule } from '../backup/backup.module';
|
||||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { SeasonModule } from '../season/season.module';
|
||||||
import { SystemModule } from '../system/system.module';
|
import { SystemModule } from '../system/system.module';
|
||||||
import { TasksModule } from '../tasks/tasks.module';
|
import { TasksModule } from '../tasks/tasks.module';
|
||||||
import { AppBackupJob } from './app-backup.job';
|
import { AppBackupJob } from './app-backup.job';
|
||||||
@@ -15,22 +16,9 @@ import { DailyEvaluationJob } from './daily-evaluation.job';
|
|||||||
import { Job } from './job';
|
import { Job } from './job';
|
||||||
import { JobRegistry, JOBS } from './job-registry';
|
import { JobRegistry, JOBS } from './job-registry';
|
||||||
import { RekeyJob } from './rekey.job';
|
import { RekeyJob } from './rekey.job';
|
||||||
|
import { TimeEventsStep } from './steps/time-events.step';
|
||||||
import { TaskTemplatesJob } from './task-templates.job';
|
import { TaskTemplatesJob } from './task-templates.job';
|
||||||
|
|
||||||
/**
|
|
||||||
* PermissionResolverService for the job context (BackupService clears its
|
|
||||||
* cache after a restore, which no job does). Not PermissionsModule: its
|
|
||||||
* PermissionCatalogSyncService would rewrite the permission catalog on
|
|
||||||
* every job start. Global like PermissionsModule, so modules that inject
|
|
||||||
* the resolver load here unchanged.
|
|
||||||
*/
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [PermissionResolverService],
|
|
||||||
exports: [PermissionResolverService],
|
|
||||||
})
|
|
||||||
class JobPermissionsModule {}
|
|
||||||
|
|
||||||
const JOB_CLASSES = [
|
const JOB_CLASSES = [
|
||||||
DailyEvaluationJob,
|
DailyEvaluationJob,
|
||||||
TaskTemplatesJob,
|
TaskTemplatesJob,
|
||||||
@@ -58,6 +46,7 @@ const JOB_CLASSES = [
|
|||||||
JobPermissionsModule,
|
JobPermissionsModule,
|
||||||
BackupModule,
|
BackupModule,
|
||||||
SystemModule,
|
SystemModule,
|
||||||
|
SeasonModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
...JOB_CLASSES,
|
...JOB_CLASSES,
|
||||||
@@ -66,6 +55,7 @@ const JOB_CLASSES = [
|
|||||||
useFactory: (...jobs: Job[]): Job[] => jobs,
|
useFactory: (...jobs: Job[]): Job[] => jobs,
|
||||||
inject: JOB_CLASSES,
|
inject: JOB_CLASSES,
|
||||||
},
|
},
|
||||||
|
TimeEventsStep,
|
||||||
JobRegistry,
|
JobRegistry,
|
||||||
IdempotencyCleanupService,
|
IdempotencyCleanupService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '../../../generated/prisma';
|
||||||
|
import { berlinToday } from '../../common/dates';
|
||||||
|
import { AnyNewDomainEvent } from '../../domain-events/domain-event-types';
|
||||||
|
import { DomainEventsService } from '../../domain-events/domain-events.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { SeasonService } from '../../season/season.service';
|
||||||
|
import { JobPartialFailure } from '../../system/job-run.service';
|
||||||
|
import { TimeEventsStep } from './time-events.step';
|
||||||
|
|
||||||
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
describe('TimeEventsStep', () => {
|
||||||
|
const tx = { tx: true } as unknown as Prisma.TransactionClient;
|
||||||
|
let taskFindMany: jest.Mock;
|
||||||
|
let transaction: jest.Mock;
|
||||||
|
let recordAll: jest.Mock<
|
||||||
|
Promise<number>,
|
||||||
|
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
|
||||||
|
>;
|
||||||
|
let step: TimeEventsStep;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
taskFindMany = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
|
||||||
|
transaction = jest.fn(
|
||||||
|
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
|
||||||
|
);
|
||||||
|
// Like createMany(skipDuplicates) on the unique dedupeKey.
|
||||||
|
const stored = new Set<string>();
|
||||||
|
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
|
||||||
|
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
|
||||||
|
fresh.forEach((event) => stored.add(event.dedupeKey!));
|
||||||
|
return Promise.resolve(fresh.length);
|
||||||
|
});
|
||||||
|
const prisma = {
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'treatment-1',
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
date: d('2026-05-25'),
|
||||||
|
endDate: null,
|
||||||
|
withdrawalPeriodDays: 14,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
$transaction: transaction,
|
||||||
|
task: { findMany: taskFindMany },
|
||||||
|
colony: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
|
||||||
|
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||||
|
} as unknown as PrismaService;
|
||||||
|
step = new TimeEventsStep(
|
||||||
|
prisma,
|
||||||
|
{ recordAll } as unknown as DomainEventsService,
|
||||||
|
new SeasonService(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('is the time-events step', () => {
|
||||||
|
expect(step.name).toBe('time-events');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the new events per type', async () => {
|
||||||
|
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
|
||||||
|
// window of 2026-06-10.
|
||||||
|
expect(await step.run('2026-06-10')).toEqual({
|
||||||
|
WithdrawalEnded: 1,
|
||||||
|
TaskDue: 1,
|
||||||
|
QueenAged: 0,
|
||||||
|
VarroaMeasurementDue: 0,
|
||||||
|
InspectionOverdue: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
|
||||||
|
await step.run('2026-06-10');
|
||||||
|
|
||||||
|
// Types without an event open no transaction.
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(recordAll.mock.calls).toEqual([
|
||||||
|
[
|
||||||
|
tx,
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'WithdrawalEnded',
|
||||||
|
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
tx,
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'TaskDue',
|
||||||
|
dedupeKey: 'TaskDue:task-1:2026-06-09',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second run on the same day records nothing new', async () => {
|
||||||
|
await step.run('2026-06-10');
|
||||||
|
|
||||||
|
expect(await step.run('2026-06-10')).toEqual({
|
||||||
|
WithdrawalEnded: 0,
|
||||||
|
TaskDue: 0,
|
||||||
|
QueenAged: 0,
|
||||||
|
VarroaMeasurementDue: 0,
|
||||||
|
InspectionOverdue: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['summer', '2026-06-10T02:30:00.000Z', '2026-06-10'],
|
||||||
|
['winter', '2026-12-05T03:30:00.000Z', '2026-12-05'],
|
||||||
|
])(
|
||||||
|
'the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey (%s)',
|
||||||
|
async (_season, instant, day) => {
|
||||||
|
taskFindMany.mockResolvedValue([{ id: 'task-1', dueDate: d(day) }]);
|
||||||
|
|
||||||
|
expect(berlinToday(new Date(instant))).toBe(day);
|
||||||
|
await step.run(berlinToday(new Date(instant)));
|
||||||
|
|
||||||
|
expect(recordAll).toHaveBeenCalledWith(tx, [
|
||||||
|
expect.objectContaining({ dedupeKey: `TaskDue:task-1:${day}` }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('a failing rule does not stop the others, but fails the step with their counters', async () => {
|
||||||
|
const logged = jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
taskFindMany.mockRejectedValue(new Error('connection reset'));
|
||||||
|
|
||||||
|
const failure = await step.run('2026-06-10').then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(JobPartialFailure);
|
||||||
|
expect(failure).toMatchObject({
|
||||||
|
failedSteps: ['TaskDue'],
|
||||||
|
result: {
|
||||||
|
WithdrawalEnded: 1,
|
||||||
|
QueenAged: 0,
|
||||||
|
VarroaMeasurementDue: 0,
|
||||||
|
InspectionOverdue: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(recordAll).toHaveBeenCalledWith(tx, [
|
||||||
|
expect.objectContaining({ type: 'WithdrawalEnded' }),
|
||||||
|
]);
|
||||||
|
expect(logged).toHaveBeenCalledWith(
|
||||||
|
'Rule "TaskDue" failed: connection reset',
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a malformed day', async () => {
|
||||||
|
await expect(step.run('2026-02-30')).rejects.toThrow('Invalid day');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { dayToDate } from '../../common/dates';
|
||||||
|
import { loggableError } from '../../common/loggable-error';
|
||||||
|
import { DomainEventsService } from '../../domain-events/domain-events.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { SeasonService } from '../../season/season.service';
|
||||||
|
import { JobPartialFailure } from '../../system/job-run.service';
|
||||||
|
import {
|
||||||
|
catchUpEvents,
|
||||||
|
DAILY_EVALUATION_RULES,
|
||||||
|
DailyRule,
|
||||||
|
DailyRuleContext,
|
||||||
|
} from '../daily-evaluation.rules';
|
||||||
|
import type { DailyEvaluationStep } from '../daily-evaluation-steps';
|
||||||
|
import type { JobResult } from '../job';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step "time-events": records the time-based domain events, one rule per
|
||||||
|
* type, see DAILY_EVALUATION_RULES. An occurrence is recorded on every run
|
||||||
|
* within CATCH_UP_DAYS after its day under the same daily dedupe key, so a
|
||||||
|
* missed run is caught up and a repeated one adds nothing. The result counts
|
||||||
|
* the events that were new, per type.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TimeEventsStep implements DailyEvaluationStep {
|
||||||
|
readonly name = 'time-events';
|
||||||
|
private readonly logger = new Logger(TimeEventsStep.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly events: DomainEventsService,
|
||||||
|
private readonly seasons: SeasonService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs every rule for `day` (the Berlin day of the run). A failing rule is
|
||||||
|
* logged and does not stop the others - their events are committed - but
|
||||||
|
* fails the step with the counters of the rules that ran; the next run
|
||||||
|
* catches up.
|
||||||
|
*/
|
||||||
|
async run(day: string): Promise<JobResult> {
|
||||||
|
const today = dayToDate(day);
|
||||||
|
const context: DailyRuleContext = { seasons: this.seasons, day };
|
||||||
|
const counts: JobResult = {};
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (const rule of DAILY_EVALUATION_RULES) {
|
||||||
|
try {
|
||||||
|
counts[rule.type] = await this.evaluateRule(rule, today, context);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const { message, stack } = loggableError(error);
|
||||||
|
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
|
||||||
|
failed.push(rule.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed.length > 0) {
|
||||||
|
throw new JobPartialFailure(counts, failed);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One transaction per type; returns how many events were new. */
|
||||||
|
private async evaluateRule(
|
||||||
|
rule: DailyRule,
|
||||||
|
today: Date,
|
||||||
|
context: DailyRuleContext,
|
||||||
|
): Promise<number> {
|
||||||
|
const events = catchUpEvents(
|
||||||
|
await rule.occurrences(this.prisma, today, context),
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
if (events.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Prisma } from '../../generated/prisma';
|
import { Prisma } from '../../generated/prisma';
|
||||||
import {
|
import {
|
||||||
|
LOCK_ORDER,
|
||||||
lockRowForUpdate,
|
lockRowForUpdate,
|
||||||
lockRowsForShare,
|
lockRowsForShare,
|
||||||
lockRowsForUpdate,
|
lockRowsForUpdate,
|
||||||
@@ -143,3 +144,98 @@ describe('lockRowsForUpdate', () => {
|
|||||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('lock keys per table', () => {
|
||||||
|
const lockers = [
|
||||||
|
[
|
||||||
|
'lockRowForUpdate',
|
||||||
|
(tx: unknown) =>
|
||||||
|
lockRowForUpdate(tx as never, 'NotificationPreference', 'u1'),
|
||||||
|
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ? FOR UPDATE',
|
||||||
|
['u1'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'lockRowsForUpdate',
|
||||||
|
(tx: unknown) =>
|
||||||
|
lockRowsForUpdate(tx as never, 'NotificationPreference', ['u2', 'u1']),
|
||||||
|
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ANY(?) ORDER BY "userId" FOR UPDATE',
|
||||||
|
[['u1', 'u2']],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'lockRowsForShare',
|
||||||
|
(tx: unknown) =>
|
||||||
|
lockRowsForShare(tx as never, 'NotificationPreference', ['u2', 'u1']),
|
||||||
|
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ANY(?) ORDER BY "userId" FOR SHARE',
|
||||||
|
[['u1', 'u2']],
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
it.each(lockers)(
|
||||||
|
'%s locks NotificationPreference by "userId" with the id as parameter',
|
||||||
|
async (_name, lock, sql, values) => {
|
||||||
|
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
|
await lock(tx);
|
||||||
|
|
||||||
|
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
|
||||||
|
expect(query.sql).toBe(sql);
|
||||||
|
expect(query.values).toEqual(values);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'Colony',
|
||||||
|
'Suggestion',
|
||||||
|
'Task',
|
||||||
|
'Queen',
|
||||||
|
'AutomationRule',
|
||||||
|
'BottlingBatch',
|
||||||
|
'Feeding',
|
||||||
|
'Harvest',
|
||||||
|
'Inspection',
|
||||||
|
'Product',
|
||||||
|
'ProductionBatch',
|
||||||
|
'RawMaterialLot',
|
||||||
|
'StockItem',
|
||||||
|
'Treatment',
|
||||||
|
'VarroaMeasurement',
|
||||||
|
] as const)('locks %s by "id"', async (table) => {
|
||||||
|
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
|
await lockRowForUpdate(tx as never, table, 'x1');
|
||||||
|
|
||||||
|
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
|
||||||
|
expect(query.sql).toBe(`SELECT id FROM "${table}" WHERE id = ? FOR UPDATE`);
|
||||||
|
expect(query.values).toEqual(['x1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a table outside the map', async () => {
|
||||||
|
const tx = { $queryRaw: jest.fn() };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
lockRowsForShare(tx as never, 'Notification' as never, ['n1']),
|
||||||
|
).rejects.toThrow('lockRowsForShare: table not lockable');
|
||||||
|
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the map in lock order: Colony, Suggestion, Task, Queen, AutomationRule, then the rest', () => {
|
||||||
|
expect(LOCK_ORDER).toEqual([
|
||||||
|
'Colony',
|
||||||
|
'Suggestion',
|
||||||
|
'Task',
|
||||||
|
'Queen',
|
||||||
|
'AutomationRule',
|
||||||
|
'BottlingBatch',
|
||||||
|
'Feeding',
|
||||||
|
'Harvest',
|
||||||
|
'Inspection',
|
||||||
|
'Product',
|
||||||
|
'ProductionBatch',
|
||||||
|
'RawMaterialLot',
|
||||||
|
'StockItem',
|
||||||
|
'Treatment',
|
||||||
|
'VarroaMeasurement',
|
||||||
|
'NotificationPreference',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,57 @@
|
|||||||
import { Prisma } from '../../generated/prisma';
|
import { Prisma } from '../../generated/prisma';
|
||||||
import { assertTransactionClient } from './db-client';
|
import { assertTransactionClient } from './db-client';
|
||||||
|
|
||||||
// Fixed identifier map: a table name is never taken from input.
|
// `key` is the SQL identifier of the key column: plain id, quoted for a
|
||||||
|
// camelCase name.
|
||||||
|
function lockable(table: string, key = 'id') {
|
||||||
|
return {
|
||||||
|
table: Prisma.raw(`"${table}"`),
|
||||||
|
key: Prisma.raw(key === 'id' ? 'id' : `"${key}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed identifier map: a table or key column name is never taken from input.
|
||||||
|
// The order is the lock order: a transaction that locks rows of several
|
||||||
|
// tables takes them in this order (and ascending key within a table), so two
|
||||||
|
// of them cannot wait for each other.
|
||||||
const LOCKABLE_TABLES = {
|
const LOCKABLE_TABLES = {
|
||||||
Colony: Prisma.raw('"Colony"'),
|
Colony: lockable('Colony'),
|
||||||
Task: Prisma.raw('"Task"'),
|
Suggestion: lockable('Suggestion'),
|
||||||
BottlingBatch: Prisma.raw('"BottlingBatch"'),
|
Task: lockable('Task'),
|
||||||
Feeding: Prisma.raw('"Feeding"'),
|
Queen: lockable('Queen'),
|
||||||
Harvest: Prisma.raw('"Harvest"'),
|
AutomationRule: lockable('AutomationRule'),
|
||||||
Inspection: Prisma.raw('"Inspection"'),
|
BottlingBatch: lockable('BottlingBatch'),
|
||||||
Product: Prisma.raw('"Product"'),
|
Feeding: lockable('Feeding'),
|
||||||
ProductionBatch: Prisma.raw('"ProductionBatch"'),
|
Harvest: lockable('Harvest'),
|
||||||
RawMaterialLot: Prisma.raw('"RawMaterialLot"'),
|
Inspection: lockable('Inspection'),
|
||||||
StockItem: Prisma.raw('"StockItem"'),
|
Product: lockable('Product'),
|
||||||
Treatment: Prisma.raw('"Treatment"'),
|
ProductionBatch: lockable('ProductionBatch'),
|
||||||
VarroaMeasurement: Prisma.raw('"VarroaMeasurement"'),
|
RawMaterialLot: lockable('RawMaterialLot'),
|
||||||
|
StockItem: lockable('StockItem'),
|
||||||
|
Treatment: lockable('Treatment'),
|
||||||
|
VarroaMeasurement: lockable('VarroaMeasurement'),
|
||||||
|
// One row per user, keyed by the user (the table arrives with Task 3).
|
||||||
|
NotificationPreference: lockable('NotificationPreference', 'userId'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
||||||
|
|
||||||
|
/** The tables in the order a transaction must lock them. */
|
||||||
|
export const LOCK_ORDER = Object.keys(LOCKABLE_TABLES) as LockableTable[];
|
||||||
|
|
||||||
|
function entryOf(table: LockableTable, caller: string) {
|
||||||
|
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
|
||||||
|
throw new Error(`${caller}: table not lockable`);
|
||||||
|
}
|
||||||
|
return LOCKABLE_TABLES[table];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locks one row (SELECT ... FOR UPDATE) until the transaction ends, so a
|
* Locks one row (SELECT ... FOR UPDATE) until the transaction ends, so a
|
||||||
* read-modify-write on it is serialised with concurrent writers. Re-read the
|
* read-modify-write on it is serialised with concurrent writers. Re-read the
|
||||||
* row after locking and use that copy as "before". Resolves to whether the
|
* row after locking and use that copy as "before". Resolves to whether the
|
||||||
* row exists.
|
* row exists. `id` is the value of the table's key column ("id", for
|
||||||
|
* NotificationPreference "userId").
|
||||||
*/
|
*/
|
||||||
export async function lockRowForUpdate(
|
export async function lockRowForUpdate(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
@@ -31,20 +59,18 @@ export async function lockRowForUpdate(
|
|||||||
id: string,
|
id: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
assertTransactionClient(tx, 'lockRowForUpdate');
|
assertTransactionClient(tx, 'lockRowForUpdate');
|
||||||
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
|
const entry = entryOf(table, 'lockRowForUpdate');
|
||||||
throw new Error('lockRowForUpdate: table not lockable');
|
const locked = await tx.$queryRaw<unknown[]>(
|
||||||
}
|
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ${id} FOR UPDATE`,
|
||||||
const locked = await tx.$queryRaw<{ id: string }[]>(
|
|
||||||
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ${id} FOR UPDATE`,
|
|
||||||
);
|
);
|
||||||
return locked.length > 0;
|
return locked.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locks several rows (SELECT ... FOR UPDATE) until the transaction ends, with
|
* Locks several rows (SELECT ... FOR UPDATE) until the transaction ends, with
|
||||||
* ONE statement in ascending id order: it meets lockRowsForShare on the same
|
* ONE statement in ascending key order: it meets lockRowsForShare on the same
|
||||||
* order, so two transactions locking overlapping sets cannot deadlock. Unknown
|
* order, so two transactions locking overlapping sets cannot deadlock. Unknown
|
||||||
* ids lock nothing; re-read the rows after locking.
|
* keys lock nothing; re-read the rows after locking.
|
||||||
*/
|
*/
|
||||||
export async function lockRowsForUpdate(
|
export async function lockRowsForUpdate(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
@@ -52,24 +78,22 @@ export async function lockRowsForUpdate(
|
|||||||
ids: readonly string[],
|
ids: readonly string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertTransactionClient(tx, 'lockRowsForUpdate');
|
assertTransactionClient(tx, 'lockRowsForUpdate');
|
||||||
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
|
const entry = entryOf(table, 'lockRowsForUpdate');
|
||||||
throw new Error('lockRowsForUpdate: table not lockable');
|
|
||||||
}
|
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sortedIds = [...ids].sort();
|
const sortedIds = [...ids].sort();
|
||||||
await tx.$queryRaw(
|
await tx.$queryRaw(
|
||||||
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR UPDATE`,
|
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ANY(${sortedIds}) ORDER BY ${entry.key} FOR UPDATE`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locks several rows (SELECT ... FOR SHARE) until the transaction ends: they
|
* Locks several rows (SELECT ... FOR SHARE) until the transaction ends: they
|
||||||
* cannot be changed or deleted meanwhile, other readers are not blocked. The
|
* cannot be changed or deleted meanwhile, other readers are not blocked. The
|
||||||
* rows are locked in ascending id order, so two transactions locking
|
* rows are locked in ascending key order, so two transactions locking
|
||||||
* overlapping sets cannot deadlock. Unknown ids lock nothing; re-read the rows
|
* overlapping sets cannot deadlock. Unknown keys lock nothing; re-read the
|
||||||
* after locking.
|
* rows after locking.
|
||||||
*/
|
*/
|
||||||
export async function lockRowsForShare(
|
export async function lockRowsForShare(
|
||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
@@ -77,14 +101,12 @@ export async function lockRowsForShare(
|
|||||||
ids: readonly string[],
|
ids: readonly string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertTransactionClient(tx, 'lockRowsForShare');
|
assertTransactionClient(tx, 'lockRowsForShare');
|
||||||
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
|
const entry = entryOf(table, 'lockRowsForShare');
|
||||||
throw new Error('lockRowsForShare: table not lockable');
|
|
||||||
}
|
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sortedIds = [...ids].sort();
|
const sortedIds = [...ids].sort();
|
||||||
await tx.$queryRaw(
|
await tx.$queryRaw(
|
||||||
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR SHARE`,
|
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ANY(${sortedIds}) ORDER BY ${entry.key} FOR SHARE`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { DEFAULT_INSPECTION_INTERVAL_CONFIG } from '../automation/rule-config/inspection-interval.config';
|
||||||
|
import { inspectionDueOn } from './inspection-due';
|
||||||
|
|
||||||
|
const CONFIG = DEFAULT_INSPECTION_INTERVAL_CONFIG;
|
||||||
|
const SCHWARMZEIT = {
|
||||||
|
segment: 'SCHWARMZEIT',
|
||||||
|
springSince: '2026-03-20',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe('inspectionDueOn', () => {
|
||||||
|
it('adds the interval of the current segment to the last inspection', () =>
|
||||||
|
expect(
|
||||||
|
inspectionDueOn(
|
||||||
|
{ createdOn: '2025-06-01', lastInspectedOn: '2026-05-05' },
|
||||||
|
SCHWARMZEIT,
|
||||||
|
CONFIG,
|
||||||
|
),
|
||||||
|
).toEqual({ dueOn: '2026-05-12', firstOfYear: false, intervalDays: 7 }));
|
||||||
|
|
||||||
|
it('uses the interval of SOMMER in SOMMER', () =>
|
||||||
|
expect(
|
||||||
|
inspectionDueOn(
|
||||||
|
{ createdOn: '2025-06-01', lastInspectedOn: '2026-06-25' },
|
||||||
|
{ segment: 'SOMMER', springSince: '2026-03-20' },
|
||||||
|
CONFIG,
|
||||||
|
),
|
||||||
|
).toEqual({ dueOn: '2026-07-09', firstOfYear: false, intervalDays: 14 }));
|
||||||
|
|
||||||
|
it('returns null in WINTERRUHE', () =>
|
||||||
|
expect(
|
||||||
|
inspectionDueOn(
|
||||||
|
{ createdOn: '2025-06-01', lastInspectedOn: '2025-10-01' },
|
||||||
|
{ segment: 'WINTERRUHE', springSince: null },
|
||||||
|
CONFIG,
|
||||||
|
),
|
||||||
|
).toBeNull());
|
||||||
|
|
||||||
|
it('counts from createdAt without any inspection', () =>
|
||||||
|
expect(
|
||||||
|
inspectionDueOn(
|
||||||
|
{ createdOn: '2026-04-20', lastInspectedOn: null },
|
||||||
|
SCHWARMZEIT,
|
||||||
|
CONFIG,
|
||||||
|
),
|
||||||
|
).toEqual({ dueOn: '2026-04-27', firstOfYear: false, intervalDays: 7 }));
|
||||||
|
|
||||||
|
it('is due from the start of FRUEHJAHR when the last inspection was before it (first of the year)', () =>
|
||||||
|
expect(
|
||||||
|
inspectionDueOn(
|
||||||
|
{ createdOn: '2025-06-01', lastInspectedOn: '2025-10-30' },
|
||||||
|
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
|
||||||
|
CONFIG,
|
||||||
|
),
|
||||||
|
).toEqual({ dueOn: '2026-03-20', firstOfYear: true, intervalDays: 14 }));
|
||||||
|
|
||||||
|
it('a colony created before the spring without an inspection is first-of-year due too', () =>
|
||||||
|
expect(
|
||||||
|
inspectionDueOn(
|
||||||
|
{ createdOn: '2025-09-01', lastInspectedOn: null },
|
||||||
|
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
|
||||||
|
CONFIG,
|
||||||
|
),
|
||||||
|
).toMatchObject({ dueOn: '2026-03-20', firstOfYear: true }));
|
||||||
|
|
||||||
|
it('an inspection on the first day of FRUEHJAHR counts as done', () =>
|
||||||
|
expect(
|
||||||
|
inspectionDueOn(
|
||||||
|
{ createdOn: '2025-06-01', lastInspectedOn: '2026-03-20' },
|
||||||
|
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
|
||||||
|
CONFIG,
|
||||||
|
),
|
||||||
|
).toEqual({ dueOn: '2026-04-03', firstOfYear: false, intervalDays: 14 }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { addDays } from '../common/dates';
|
||||||
|
import type { InspectionIntervalConfig } from '../automation/rule-config/rule-config.types';
|
||||||
|
import type { SeasonState } from './season-types';
|
||||||
|
|
||||||
|
export interface InspectionDue {
|
||||||
|
dueOn: string;
|
||||||
|
/** Due because the last inspection lies before this spring ("first of the year"). */
|
||||||
|
firstOfYear: boolean;
|
||||||
|
intervalDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day an inspection is due: the last inspection (else the creation of
|
||||||
|
* the colony) plus the interval of today's segment. A colony not inspected
|
||||||
|
* since before FRUEHJAHR began is due from that day on. Never in WINTERRUHE.
|
||||||
|
*/
|
||||||
|
export function inspectionDueOn(
|
||||||
|
input: { createdOn: string; lastInspectedOn: string | null },
|
||||||
|
season: Pick<SeasonState, 'segment' | 'springSince'>,
|
||||||
|
config: InspectionIntervalConfig,
|
||||||
|
): InspectionDue | null {
|
||||||
|
if (season.segment === 'WINTERRUHE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const intervalDays = config.intervalDays[season.segment];
|
||||||
|
const base = input.lastInspectedOn ?? input.createdOn;
|
||||||
|
if (season.springSince !== null && base < season.springSince) {
|
||||||
|
return { dueOn: season.springSince, firstOfYear: true, intervalDays };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dueOn: addDays(base, intervalDays),
|
||||||
|
firstOfYear: false,
|
||||||
|
intervalDays,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join, relative, sep } from 'node:path';
|
||||||
|
|
||||||
|
// The age at which a queen counts as aged is SEASON_AUTOPILOT.queenMaxAgeYears,
|
||||||
|
// judged by isQueenAged. No second constant may appear in src.
|
||||||
|
const SRC = join(__dirname, '..');
|
||||||
|
const THIS_FILE = 'season/queen-age-threshold.spec.ts';
|
||||||
|
|
||||||
|
function sources(dir: string): string[] {
|
||||||
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return sources(path);
|
||||||
|
}
|
||||||
|
return entry.name.endsWith('.ts') ? [path] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function offenders(pattern: RegExp): string[] {
|
||||||
|
return sources(SRC)
|
||||||
|
.filter((file) => pattern.test(readFileSync(file, 'utf8')))
|
||||||
|
.map((file) => relative(SRC, file).split(sep).join('/'))
|
||||||
|
.filter((file) => file !== THIS_FILE)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('queen age threshold', () => {
|
||||||
|
it('there is no second queen age threshold in src', () =>
|
||||||
|
expect(offenders(/OLD_QUEEN_AGE_YEARS|QUEEN_AGED_MIN_YEARS/)).toEqual([]));
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { isQueenAged } from './queen-age';
|
||||||
|
|
||||||
|
describe('isQueenAged', () => {
|
||||||
|
it('is aged from queenMaxAgeYears after the hatch year', () => {
|
||||||
|
expect(isQueenAged(2024, '2026-06-10', 2)).toBe(true);
|
||||||
|
expect(isQueenAged(2025, '2026-06-10', 2)).toBe(false);
|
||||||
|
expect(isQueenAged(2023, '2026-06-10', 3)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes with the year, not within it', () => {
|
||||||
|
expect(isQueenAged(2025, '2026-12-31', 2)).toBe(false);
|
||||||
|
expect(isQueenAged(2025, '2027-01-01', 2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is never aged without a hatch year', () =>
|
||||||
|
expect(isQueenAged(null, '2026-06-10', 1)).toBe(false));
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* An aged queen: the current year minus her hatch year reaches
|
||||||
|
* `queenMaxAgeYears`. The one definition for QueenAged, the dashboard's
|
||||||
|
* OLD_QUEEN and autopilot A8. `today` is a YYYY-MM-DD day.
|
||||||
|
*/
|
||||||
|
export function isQueenAged(
|
||||||
|
hatchYear: number | null,
|
||||||
|
today: string,
|
||||||
|
queenMaxAgeYears: number,
|
||||||
|
): boolean {
|
||||||
|
if (hatchYear === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Number(today.slice(0, 4)) - hatchYear >= queenMaxAgeYears;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||||
|
import { calendarPhase, calendarSeasonState } from './season-calendar';
|
||||||
|
|
||||||
|
const FALLBACK = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
|
||||||
|
|
||||||
|
describe('calendarPhase', () => {
|
||||||
|
it('is the WINTER phase of the previous year before SALWEIDE', () =>
|
||||||
|
expect(calendarPhase('2026-03-10', FALLBACK)).toEqual({
|
||||||
|
phase: 'WINTER',
|
||||||
|
since: '2025-11-15',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('is the latest phase whose calendar day has been reached', () => {
|
||||||
|
expect(calendarPhase('2026-05-12', FALLBACK)).toEqual({
|
||||||
|
phase: 'APFEL',
|
||||||
|
since: '2026-04-28',
|
||||||
|
});
|
||||||
|
expect(calendarPhase('2026-04-28', FALLBACK)).toEqual({
|
||||||
|
phase: 'APFEL',
|
||||||
|
since: '2026-04-28',
|
||||||
|
});
|
||||||
|
expect(calendarPhase('2026-11-15', FALLBACK)).toEqual({
|
||||||
|
phase: 'WINTER',
|
||||||
|
since: '2026-11-15',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calendarSeasonState', () => {
|
||||||
|
const state = (today: string) =>
|
||||||
|
calendarSeasonState('apiary-1', today, DEFAULT_PHENOLOGY_CONFIG);
|
||||||
|
|
||||||
|
it('2026-03-10 is WINTERRUHE since the WINTER date of 2025', () =>
|
||||||
|
expect(state('2026-03-10')).toEqual({
|
||||||
|
apiaryId: 'apiary-1',
|
||||||
|
year: 2026,
|
||||||
|
phase: 'WINTER',
|
||||||
|
phaseSource: 'CALENDAR',
|
||||||
|
reportedOn: null,
|
||||||
|
segment: 'WINTERRUHE',
|
||||||
|
segmentSince: '2025-11-15',
|
||||||
|
springSince: null,
|
||||||
|
gts: null,
|
||||||
|
gtsThroughDay: null,
|
||||||
|
note: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('2026-05-12 is SCHWARMZEIT since 2026-04-15 by calendar', () =>
|
||||||
|
expect(state('2026-05-12')).toMatchObject({
|
||||||
|
phase: 'APFEL',
|
||||||
|
phaseSource: 'CALENDAR',
|
||||||
|
segment: 'SCHWARMZEIT',
|
||||||
|
segmentSince: '2026-04-15',
|
||||||
|
springSince: '2026-03-20',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('FRUEHJAHR starts with SALWEIDE and sets springSince', () =>
|
||||||
|
expect(state('2026-04-01')).toMatchObject({
|
||||||
|
segment: 'FRUEHJAHR',
|
||||||
|
segmentSince: '2026-03-20',
|
||||||
|
springSince: '2026-03-20',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('WINTERRUHE after WINTER keeps springSince of the year', () =>
|
||||||
|
expect(state('2026-12-01')).toMatchObject({
|
||||||
|
segment: 'WINTERRUHE',
|
||||||
|
segmentSince: '2026-11-15',
|
||||||
|
springSince: '2026-03-20',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('a segment spanning several phases begins at its first phase', () =>
|
||||||
|
expect(state('2026-06-25')).toMatchObject({
|
||||||
|
segment: 'SOMMER',
|
||||||
|
segmentSince: '2026-06-20',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('stays WINTERRUHE across the year change, springSince only after SALWEIDE', () => {
|
||||||
|
expect(state('2026-12-31')).toMatchObject({
|
||||||
|
year: 2026,
|
||||||
|
segment: 'WINTERRUHE',
|
||||||
|
segmentSince: '2026-11-15',
|
||||||
|
springSince: '2026-03-20',
|
||||||
|
});
|
||||||
|
expect(state('2027-01-01')).toMatchObject({
|
||||||
|
year: 2027,
|
||||||
|
phase: 'WINTER',
|
||||||
|
segment: 'WINTERRUHE',
|
||||||
|
segmentSince: '2026-11-15',
|
||||||
|
springSince: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||||
|
import { segmentOf } from './season-segment';
|
||||||
|
import {
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
type PhenologyPhaseKey,
|
||||||
|
type SeasonState,
|
||||||
|
} from './season-types';
|
||||||
|
|
||||||
|
type CalendarFallback = Record<PhenologyPhaseKey, string>;
|
||||||
|
|
||||||
|
interface PhaseOnDay {
|
||||||
|
phase: PhenologyPhaseKey;
|
||||||
|
day: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phases of `year` whose calendar day has been reached, in phase order. */
|
||||||
|
function reachedPhases(
|
||||||
|
year: number,
|
||||||
|
today: string,
|
||||||
|
fallback: CalendarFallback,
|
||||||
|
): PhaseOnDay[] {
|
||||||
|
return PHENOLOGY_PHASES.map((phase) => ({
|
||||||
|
phase,
|
||||||
|
day: `${year}-${fallback[phase]}`,
|
||||||
|
})).filter(({ day }) => day <= today);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phase the calendar puts `today` in: the latest phase of the year whose
|
||||||
|
* day has been reached; before SALWEIDE still the WINTER of the previous year.
|
||||||
|
*/
|
||||||
|
export function calendarPhase(
|
||||||
|
today: string,
|
||||||
|
fallback: CalendarFallback,
|
||||||
|
): { phase: PhenologyPhaseKey | null; since: string | null } {
|
||||||
|
const year = Number(today.slice(0, 4));
|
||||||
|
const reached = reachedPhases(year, today, fallback);
|
||||||
|
const latest = reached[reached.length - 1];
|
||||||
|
if (latest === undefined) {
|
||||||
|
return { phase: 'WINTER', since: `${year - 1}-${fallback.WINTER}` };
|
||||||
|
}
|
||||||
|
return { phase: latest.phase, since: latest.day };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day the segment of the latest reached phase began. */
|
||||||
|
function segmentStart(
|
||||||
|
reached: readonly PhaseOnDay[],
|
||||||
|
swarmSeason: PhenologyConfig['swarmSeason'],
|
||||||
|
fallbackStart: string,
|
||||||
|
): string {
|
||||||
|
const latest = reached[reached.length - 1];
|
||||||
|
if (latest === undefined) {
|
||||||
|
return fallbackStart;
|
||||||
|
}
|
||||||
|
const segment = segmentOf(latest.phase, swarmSeason);
|
||||||
|
let since = latest.day;
|
||||||
|
for (let i = reached.length - 2; i >= 0; i -= 1) {
|
||||||
|
if (segmentOf(reached[i].phase, swarmSeason) !== segment) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
since = reached[i].day;
|
||||||
|
}
|
||||||
|
return since;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The season of an apiary from the calendar alone (no weather, no report):
|
||||||
|
* what every apiary gets until the phenology data of Task 25 is there.
|
||||||
|
*/
|
||||||
|
export function calendarSeasonState(
|
||||||
|
apiaryId: string,
|
||||||
|
today: string,
|
||||||
|
config: PhenologyConfig,
|
||||||
|
): SeasonState {
|
||||||
|
const year = Number(today.slice(0, 4));
|
||||||
|
const { phase, since } = calendarPhase(today, config.calendarFallback);
|
||||||
|
const reached = reachedPhases(year, today, config.calendarFallback);
|
||||||
|
const springDay = `${year}-${config.calendarFallback.SALWEIDE}`;
|
||||||
|
return {
|
||||||
|
apiaryId,
|
||||||
|
year,
|
||||||
|
phase,
|
||||||
|
phaseSource: 'CALENDAR',
|
||||||
|
reportedOn: null,
|
||||||
|
segment: segmentOf(phase, config.swarmSeason),
|
||||||
|
segmentSince: segmentStart(reached, config.swarmSeason, since ?? today),
|
||||||
|
springSince: springDay <= today ? springDay : null,
|
||||||
|
gts: null,
|
||||||
|
gtsThroughDay: null,
|
||||||
|
note: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { segmentOf } from './season-segment';
|
||||||
|
import type { PhenologyPhaseKey } from './season-types';
|
||||||
|
|
||||||
|
const DEFAULT_SWARM_SEASON = { from: 'KIRSCHE', until: 'LINDE' } as const;
|
||||||
|
|
||||||
|
describe('segmentOf', () => {
|
||||||
|
it.each<[PhenologyPhaseKey | null, string]>([
|
||||||
|
[null, 'WINTERRUHE'],
|
||||||
|
['SALWEIDE', 'FRUEHJAHR'],
|
||||||
|
['KIRSCHE', 'SCHWARMZEIT'],
|
||||||
|
['APFEL', 'SCHWARMZEIT'],
|
||||||
|
['HOLUNDER', 'SCHWARMZEIT'],
|
||||||
|
['LINDE', 'SOMMER'],
|
||||||
|
['TRACHTENDE', 'SPAETSOMMER'],
|
||||||
|
['HERBST', 'HERBST'],
|
||||||
|
['WINTER', 'WINTERRUHE'],
|
||||||
|
])('%p -> %p', (phase, segment) => {
|
||||||
|
expect(segmentOf(phase, DEFAULT_SWARM_SEASON)).toBe(segment);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a configured swarm season start at APFEL', () =>
|
||||||
|
expect(segmentOf('KIRSCHE', { from: 'APFEL', until: 'LINDE' })).toBe(
|
||||||
|
'FRUEHJAHR',
|
||||||
|
));
|
||||||
|
|
||||||
|
it('honours a configured swarm season end at HOLUNDER', () => {
|
||||||
|
expect(segmentOf('APFEL', { from: 'KIRSCHE', until: 'HOLUNDER' })).toBe(
|
||||||
|
'SCHWARMZEIT',
|
||||||
|
);
|
||||||
|
expect(segmentOf('HOLUNDER', { from: 'KIRSCHE', until: 'HOLUNDER' })).toBe(
|
||||||
|
'SOMMER',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
|
||||||
|
import {
|
||||||
|
PHENOLOGY_PHASES,
|
||||||
|
type PhenologyPhaseKey,
|
||||||
|
type SeasonSegmentKey,
|
||||||
|
} from './season-types';
|
||||||
|
|
||||||
|
const phaseIndex = (phase: PhenologyPhaseKey): number =>
|
||||||
|
PHENOLOGY_PHASES.indexOf(phase);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The season segment a phase belongs to: WINTERRUHE from WINTER (and before
|
||||||
|
* the first phase), FRUEHJAHR from SALWEIDE, SCHWARMZEIT from the configured
|
||||||
|
* start, SOMMER from its end, SPAETSOMMER from TRACHTENDE, HERBST from HERBST.
|
||||||
|
*/
|
||||||
|
export function segmentOf(
|
||||||
|
phase: PhenologyPhaseKey | null,
|
||||||
|
swarmSeason: PhenologyConfig['swarmSeason'],
|
||||||
|
): SeasonSegmentKey {
|
||||||
|
switch (phase) {
|
||||||
|
case null:
|
||||||
|
case 'WINTER':
|
||||||
|
return 'WINTERRUHE';
|
||||||
|
case 'HERBST':
|
||||||
|
return 'HERBST';
|
||||||
|
case 'TRACHTENDE':
|
||||||
|
return 'SPAETSOMMER';
|
||||||
|
default: {
|
||||||
|
const index = phaseIndex(phase);
|
||||||
|
if (index >= phaseIndex(swarmSeason.until)) {
|
||||||
|
return 'SOMMER';
|
||||||
|
}
|
||||||
|
return index >= phaseIndex(swarmSeason.from)
|
||||||
|
? 'SCHWARMZEIT'
|
||||||
|
: 'FRUEHJAHR';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Season vocabulary shared by rule settings, the weather sources and the
|
||||||
|
// season state. The Prisma enums follow in Task 23 with the same values.
|
||||||
|
|
||||||
|
export const PHENOLOGY_PHASES = [
|
||||||
|
'SALWEIDE',
|
||||||
|
'KIRSCHE',
|
||||||
|
'APFEL',
|
||||||
|
'HOLUNDER',
|
||||||
|
'LINDE',
|
||||||
|
'TRACHTENDE',
|
||||||
|
'HERBST',
|
||||||
|
'WINTER',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SEASON_SEGMENTS = [
|
||||||
|
'WINTERRUHE',
|
||||||
|
'FRUEHJAHR',
|
||||||
|
'SCHWARMZEIT',
|
||||||
|
'SOMMER',
|
||||||
|
'SPAETSOMMER',
|
||||||
|
'HERBST',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PHASE_SOURCES = ['REPORT', 'GTS', 'CALENDAR'] as const;
|
||||||
|
|
||||||
|
export type PhenologyPhaseKey = (typeof PHENOLOGY_PHASES)[number];
|
||||||
|
export type SeasonSegmentKey = (typeof SEASON_SEGMENTS)[number];
|
||||||
|
export type PhaseSourceKey = (typeof PHASE_SOURCES)[number];
|
||||||
|
|
||||||
|
export type SeasonNote =
|
||||||
|
'NO_COORDINATES' | 'WEATHER_GAPS' | 'WEATHER_STALE' | null;
|
||||||
|
|
||||||
|
/** Daily mean temperature of one day (YYYY-MM-DD); null when unknown. */
|
||||||
|
export interface DailyMean {
|
||||||
|
day: string;
|
||||||
|
meanTempC: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonState {
|
||||||
|
apiaryId: string;
|
||||||
|
year: number;
|
||||||
|
phase: PhenologyPhaseKey | null;
|
||||||
|
phaseSource: PhaseSourceKey | null;
|
||||||
|
reportedOn: string | null;
|
||||||
|
segment: SeasonSegmentKey;
|
||||||
|
segmentSince: string;
|
||||||
|
springSince: string | null;
|
||||||
|
gts: number | null;
|
||||||
|
gtsThroughDay: string | null;
|
||||||
|
note: SeasonNote;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SeasonService } from './season.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [SeasonService],
|
||||||
|
exports: [SeasonService],
|
||||||
|
})
|
||||||
|
export class SeasonModule {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
|
||||||
|
import type { DbClient } from '../prisma/db-client';
|
||||||
|
import { SeasonService } from './season.service';
|
||||||
|
|
||||||
|
describe('SeasonService', () => {
|
||||||
|
const findUnique = jest.fn();
|
||||||
|
const db = { automationRule: { findUnique } } as unknown as DbClient;
|
||||||
|
const service = new SeasonService();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
findUnique.mockReset().mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
|
||||||
|
const states = await service.statesFor(
|
||||||
|
db,
|
||||||
|
['apiary-1', 'apiary-2', 'apiary-1'],
|
||||||
|
'2026-05-12',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([...states.keys()]).toEqual(['apiary-1', 'apiary-2']);
|
||||||
|
expect(states.get('apiary-2')).toMatchObject({
|
||||||
|
apiaryId: 'apiary-2',
|
||||||
|
phaseSource: 'CALENDAR',
|
||||||
|
segment: 'SCHWARMZEIT',
|
||||||
|
});
|
||||||
|
expect(findUnique).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads no rule without apiaries', async () => {
|
||||||
|
expect((await service.statesFor(db, [], '2026-05-12')).size).toBe(0);
|
||||||
|
expect(findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the stored calendar of the PHENOLOGY rule', async () => {
|
||||||
|
findUnique.mockResolvedValue({
|
||||||
|
id: 'rule-1',
|
||||||
|
active: true,
|
||||||
|
actionTemplate: null,
|
||||||
|
config: {
|
||||||
|
...DEFAULT_PHENOLOGY_CONFIG,
|
||||||
|
calendarFallback: {
|
||||||
|
...DEFAULT_PHENOLOGY_CONFIG.calendarFallback,
|
||||||
|
SALWEIDE: '03-01',
|
||||||
|
KIRSCHE: '04-01',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const states = await service.statesFor(db, ['apiary-1'], '2026-04-05');
|
||||||
|
|
||||||
|
expect(states.get('apiary-1')).toMatchObject({
|
||||||
|
segment: 'SCHWARMZEIT',
|
||||||
|
segmentSince: '2026-04-01',
|
||||||
|
springSince: '2026-03-01',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { readRule } from '../automation/rule-config';
|
||||||
|
import type { DbClient } from '../prisma/db-client';
|
||||||
|
import { calendarSeasonState } from './season-calendar';
|
||||||
|
import type { SeasonState } from './season-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The season of apiaries. Until the phenology data exists (Task 25) every
|
||||||
|
* apiary gets the calendar state; then ApiaryPhenology takes over here, and
|
||||||
|
* the callers stay as they are.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SeasonService {
|
||||||
|
/** The season state of each given apiary on `today` (YYYY-MM-DD, Berlin). */
|
||||||
|
async statesFor(
|
||||||
|
db: DbClient,
|
||||||
|
apiaryIds: readonly string[],
|
||||||
|
today: string,
|
||||||
|
): Promise<Map<string, SeasonState>> {
|
||||||
|
const states = new Map<string, SeasonState>();
|
||||||
|
if (apiaryIds.length === 0) {
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
const { config } = await readRule(db, 'PHENOLOGY');
|
||||||
|
for (const apiaryId of new Set(apiaryIds)) {
|
||||||
|
states.set(apiaryId, calendarSeasonState(apiaryId, today, config));
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { DEFAULT_SEASON_AUTOPILOT_CONFIG } from '../automation/rule-config/season-autopilot.config';
|
||||||
|
import { varroaMeasurementDue } from './varroa-due';
|
||||||
|
|
||||||
|
const CONFIG = DEFAULT_SEASON_AUTOPILOT_CONFIG;
|
||||||
|
const SOMMER = { segment: 'SOMMER', springSince: '2026-03-20' } as const;
|
||||||
|
const WINTERRUHE = {
|
||||||
|
segment: 'WINTERRUHE',
|
||||||
|
springSince: '2026-03-20',
|
||||||
|
} as const;
|
||||||
|
const NEVER_MEASURED = {
|
||||||
|
createdOn: '2025-01-01',
|
||||||
|
lastMeasuredOn: null,
|
||||||
|
lastMeasurementId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('varroaMeasurementDue', () => {
|
||||||
|
it('is due 28 days after the last measurement outside WINTERRUHE', () =>
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(
|
||||||
|
{
|
||||||
|
createdOn: '2025-01-01',
|
||||||
|
lastMeasuredOn: '2026-06-01',
|
||||||
|
lastMeasurementId: 'm-1',
|
||||||
|
},
|
||||||
|
SOMMER,
|
||||||
|
CONFIG,
|
||||||
|
'2026-06-30',
|
||||||
|
),
|
||||||
|
).toEqual({ dueOn: '2026-06-29', kind: 'INTERVAL', anchor: 'm-1' }));
|
||||||
|
|
||||||
|
it('counts from createdOn without a measurement and anchors on none', () =>
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(
|
||||||
|
{ ...NEVER_MEASURED, createdOn: '2026-04-20' },
|
||||||
|
SOMMER,
|
||||||
|
CONFIG,
|
||||||
|
'2026-06-30',
|
||||||
|
),
|
||||||
|
).toEqual({ dueOn: '2026-05-18', kind: 'INTERVAL', anchor: 'none' }));
|
||||||
|
|
||||||
|
it('uses the configured interval', () =>
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(
|
||||||
|
{ ...NEVER_MEASURED, createdOn: '2026-04-20' },
|
||||||
|
SOMMER,
|
||||||
|
{ ...CONFIG, varroaMeasureIntervalDays: 14 },
|
||||||
|
'2026-06-30',
|
||||||
|
),
|
||||||
|
).toMatchObject({ dueOn: '2026-05-04' }));
|
||||||
|
|
||||||
|
it('is due on 11-15 in the winter window without a measurement since 11-01', () =>
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(
|
||||||
|
{
|
||||||
|
createdOn: '2025-01-01',
|
||||||
|
lastMeasuredOn: '2026-09-20',
|
||||||
|
lastMeasurementId: 'm-1',
|
||||||
|
},
|
||||||
|
WINTERRUHE,
|
||||||
|
CONFIG,
|
||||||
|
'2026-11-20',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
dueOn: '2026-11-15',
|
||||||
|
kind: 'WINTER_WINDOW',
|
||||||
|
anchor: 'winter-2026',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('is not due in the winter window after a measurement since 11-01', () =>
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(
|
||||||
|
{
|
||||||
|
createdOn: '2025-01-01',
|
||||||
|
lastMeasuredOn: '2026-11-03',
|
||||||
|
lastMeasurementId: 'm-2',
|
||||||
|
},
|
||||||
|
WINTERRUHE,
|
||||||
|
CONFIG,
|
||||||
|
'2026-11-20',
|
||||||
|
),
|
||||||
|
).toBeNull());
|
||||||
|
|
||||||
|
it('is not due in WINTERRUHE outside the winter window', () => {
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-12-21'),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-02-10'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the winter window on its first and last day', () => {
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-11-15'),
|
||||||
|
).toMatchObject({ kind: 'WINTER_WINDOW' });
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-12-20'),
|
||||||
|
).toMatchObject({ kind: 'WINTER_WINDOW' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an interval due day inside the winter to the start of FRUEHJAHR', () =>
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(
|
||||||
|
{
|
||||||
|
createdOn: '2025-01-01',
|
||||||
|
lastMeasuredOn: '2026-11-20',
|
||||||
|
lastMeasurementId: 'm-3',
|
||||||
|
},
|
||||||
|
{ segment: 'FRUEHJAHR', springSince: '2027-03-20' },
|
||||||
|
CONFIG,
|
||||||
|
'2027-03-25',
|
||||||
|
),
|
||||||
|
).toEqual({ dueOn: '2027-03-20', kind: 'INTERVAL', anchor: 'm-3' }));
|
||||||
|
|
||||||
|
it('keeps a due day after the start of FRUEHJAHR', () =>
|
||||||
|
expect(
|
||||||
|
varroaMeasurementDue(
|
||||||
|
{
|
||||||
|
createdOn: '2025-01-01',
|
||||||
|
lastMeasuredOn: '2027-03-10',
|
||||||
|
lastMeasurementId: 'm-4',
|
||||||
|
},
|
||||||
|
{ segment: 'FRUEHJAHR', springSince: '2027-03-20' },
|
||||||
|
CONFIG,
|
||||||
|
'2027-04-01',
|
||||||
|
),
|
||||||
|
).toMatchObject({ dueOn: '2027-04-07' }));
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { addDays } from '../common/dates';
|
||||||
|
import type { SeasonAutopilotConfig } from '../automation/rule-config/rule-config.types';
|
||||||
|
import type { SeasonState } from './season-types';
|
||||||
|
|
||||||
|
export interface VarroaDue {
|
||||||
|
dueOn: string;
|
||||||
|
kind: 'INTERVAL' | 'WINTER_WINDOW';
|
||||||
|
/** Changes with every new measurement (or winter), so one due date stays one reminder. */
|
||||||
|
anchor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Invariant: the winter window (SEASON_AUTOPILOT.winterVarroaWindow, default
|
||||||
|
* 11-15) lies inside WINTERRUHE, i.e. it does not begin before the WINTER
|
||||||
|
* date of PHENOLOGY.calendarFallback (default 11-15). Before WINTER the
|
||||||
|
* interval rule applies instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A measurement from this day on counts for the winter window of the year. */
|
||||||
|
const WINTER_MEASUREMENT_FROM = '11-01';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day a varroa measurement is due. Outside WINTERRUHE: the last
|
||||||
|
* measurement (else the creation of the colony) plus the interval. In
|
||||||
|
* WINTERRUHE only inside the winter window, once, if there was no
|
||||||
|
* measurement since 11-01.
|
||||||
|
*/
|
||||||
|
export function varroaMeasurementDue(
|
||||||
|
input: {
|
||||||
|
createdOn: string;
|
||||||
|
lastMeasuredOn: string | null;
|
||||||
|
lastMeasurementId: string | null;
|
||||||
|
},
|
||||||
|
season: Pick<SeasonState, 'segment' | 'springSince'>,
|
||||||
|
config: SeasonAutopilotConfig,
|
||||||
|
today: string,
|
||||||
|
): VarroaDue | null {
|
||||||
|
if (season.segment !== 'WINTERRUHE') {
|
||||||
|
const base = input.lastMeasuredOn ?? input.createdOn;
|
||||||
|
const intervalDue = addDays(base, config.varroaMeasureIntervalDays);
|
||||||
|
// A due day that fell into the winter is taken up at the start of
|
||||||
|
// FRUEHJAHR, like the first inspection of the year.
|
||||||
|
const dueOn =
|
||||||
|
season.springSince !== null && intervalDue < season.springSince
|
||||||
|
? season.springSince
|
||||||
|
: intervalDue;
|
||||||
|
return {
|
||||||
|
dueOn,
|
||||||
|
kind: 'INTERVAL',
|
||||||
|
anchor: input.lastMeasurementId ?? 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const year = today.slice(0, 4);
|
||||||
|
const { from, to } = config.winterVarroaWindow;
|
||||||
|
const inWindow = today >= `${year}-${from}` && today <= `${year}-${to}`;
|
||||||
|
const measuredThisWinter =
|
||||||
|
input.lastMeasuredOn !== null &&
|
||||||
|
input.lastMeasuredOn >= `${year}-${WINTER_MEASUREMENT_FROM}`;
|
||||||
|
if (!inWindow || measuredThisWinter) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dueOn: `${year}-${from}`,
|
||||||
|
kind: 'WINTER_WINDOW',
|
||||||
|
anchor: `winter-${year}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,6 +40,21 @@ describe('summarizeStock', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves a BOTTLING movement out: the honey batch ledger is not product stock', () => {
|
||||||
|
const summary = summarizeStock(10, [
|
||||||
|
{ type: 'BOTTLING', quantity: -500 },
|
||||||
|
{ type: 'SHOP_SALE', quantity: -2 },
|
||||||
|
]);
|
||||||
|
expect(summary).toEqual({
|
||||||
|
initial: 10,
|
||||||
|
soldInShop: 2,
|
||||||
|
withdrawn: 0,
|
||||||
|
corrected: 0,
|
||||||
|
consumed: 0,
|
||||||
|
remaining: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('counts raw material used in production, net of give-backs', () => {
|
it('counts raw material used in production, net of give-backs', () => {
|
||||||
const summary = summarizeStock(4200, [
|
const summary = summarizeStock(4200, [
|
||||||
{ type: 'CONSUMPTION', quantity: -800 },
|
{ type: 'CONSUMPTION', quantity: -800 },
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
* always derived, never stored, so it cannot drift from the ledger.
|
* always derived, never stored, so it cannot drift from the ledger.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type MovementType =
|
import type { BatchMovementType } from '../../generated/prisma';
|
||||||
'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION' | 'CONSUMPTION';
|
|
||||||
|
export type MovementType = BatchMovementType;
|
||||||
|
|
||||||
export interface LedgerEntry {
|
export interface LedgerEntry {
|
||||||
type: MovementType;
|
type: MovementType;
|
||||||
@@ -45,6 +46,10 @@ export function summarizeStock(
|
|||||||
withdrawn -= entry.quantity;
|
withdrawn -= entry.quantity;
|
||||||
} else if (entry.type === 'CONSUMPTION') {
|
} else if (entry.type === 'CONSUMPTION') {
|
||||||
consumed -= entry.quantity;
|
consumed -= entry.quantity;
|
||||||
|
} else if (entry.type === 'BOTTLING') {
|
||||||
|
// Takes honey out of a honey batch (the grams ledger, Task 23); it is
|
||||||
|
// never booked on a product batch, so it does not count here.
|
||||||
|
continue;
|
||||||
} else {
|
} else {
|
||||||
corrected += entry.quantity;
|
corrected += entry.quantity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { buildEvidenceLookup } from './evidence-lookup';
|
||||||
|
|
||||||
|
function db() {
|
||||||
|
return {
|
||||||
|
colony: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
treatment: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
apiary: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildEvidenceLookup', () => {
|
||||||
|
it('reads each model once for all evidences', async () => {
|
||||||
|
const client = db();
|
||||||
|
client.colony.findMany.mockResolvedValue([
|
||||||
|
{ id: 'c1', code: 'V-1' },
|
||||||
|
{ id: 'c2', code: 'V-2' },
|
||||||
|
]);
|
||||||
|
client.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-9' }]);
|
||||||
|
client.treatment.findMany.mockResolvedValue([
|
||||||
|
{ id: 't1', product: 'Oxalsäure' },
|
||||||
|
]);
|
||||||
|
client.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hausstand' }]);
|
||||||
|
client.stockItem.findMany.mockResolvedValue([{ id: 's1', name: 'Gläser' }]);
|
||||||
|
|
||||||
|
const lookup = await buildEvidenceLookup(client as never, [
|
||||||
|
{ colonyId: 'c1', parentColonyId: 'c2', queenId: 'q1' },
|
||||||
|
{ colonyId: 'c1', treatmentId: 't1', apiaryId: 'a1', stockItemId: 's1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const model of Object.values(client)) {
|
||||||
|
expect(model.findMany).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
(client.colony.findMany.mock.calls as { where: unknown }[][])[0][0].where,
|
||||||
|
).toEqual({
|
||||||
|
id: { in: ['c1', 'c2'] },
|
||||||
|
});
|
||||||
|
expect(lookup.colonyCode('c1')).toBe('V-1');
|
||||||
|
expect(lookup.colonyCode('c2')).toBe('V-2');
|
||||||
|
expect(lookup.queenCode('q1')).toBe('K-9');
|
||||||
|
expect(lookup.treatmentProduct('t1')).toBe('Oxalsäure');
|
||||||
|
expect(lookup.apiaryName('a1')).toBe('Hausstand');
|
||||||
|
expect(lookup.stockItemName('s1')).toBe('Gläser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for no model the evidence does not mention', async () => {
|
||||||
|
const client = db();
|
||||||
|
|
||||||
|
const lookup = await buildEvidenceLookup(client as never, [
|
||||||
|
{ baseDate: '2026-05-01', count: 3, flag: true, none: null },
|
||||||
|
{ colonyId: 42 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const model of Object.values(client)) {
|
||||||
|
expect(model.findMany).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
expect(lookup.colonyCode('c1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers null for an id that no longer exists', async () => {
|
||||||
|
const client = db();
|
||||||
|
|
||||||
|
const lookup = await buildEvidenceLookup(client as never, [
|
||||||
|
{ queenId: 'gone' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(lookup.queenCode('gone')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { DbClient } from '../prisma/db-client';
|
||||||
|
import type { SuggestionEvidence } from './suggestion.types';
|
||||||
|
|
||||||
|
/** Evidence keys whose value is an ID the texts show as a name. */
|
||||||
|
export const LOOKUP_EVIDENCE_KEYS = [
|
||||||
|
'colonyId',
|
||||||
|
'parentColonyId',
|
||||||
|
'queenId',
|
||||||
|
'treatmentId',
|
||||||
|
'apiaryId',
|
||||||
|
'stockItemId',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type LookupKey = (typeof LOOKUP_EVIDENCE_KEYS)[number];
|
||||||
|
|
||||||
|
/** Names for the IDs in evidence; null for an ID that no longer exists. */
|
||||||
|
export interface EvidenceLookup {
|
||||||
|
colonyCode(id: string): string | null;
|
||||||
|
queenCode(id: string): string | null;
|
||||||
|
treatmentProduct(id: string): string | null;
|
||||||
|
apiaryName(id: string): string | null;
|
||||||
|
stockItemName(id: string): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NameMap = ReadonlyMap<string, string>;
|
||||||
|
|
||||||
|
function idsOf(
|
||||||
|
evidences: readonly SuggestionEvidence[],
|
||||||
|
keys: readonly LookupKey[],
|
||||||
|
): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const evidence of evidences) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = evidence[key];
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
ids.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No query at all for a model no evidence refers to. */
|
||||||
|
async function namesOf(
|
||||||
|
ids: readonly string[],
|
||||||
|
read: (ids: string[]) => Promise<{ id: string; name: string }[]>,
|
||||||
|
): Promise<NameMap> {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
const rows = await read([...ids]);
|
||||||
|
return new Map(rows.map((row) => [row.id, row.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the names the texts of these suggestions mention: one query per
|
||||||
|
* model.
|
||||||
|
*/
|
||||||
|
export async function buildEvidenceLookup(
|
||||||
|
db: DbClient,
|
||||||
|
evidences: readonly SuggestionEvidence[],
|
||||||
|
): Promise<EvidenceLookup> {
|
||||||
|
const [colonies, queens, treatments, apiaries, stockItems] =
|
||||||
|
await Promise.all([
|
||||||
|
namesOf(idsOf(evidences, ['colonyId', 'parentColonyId']), async (ids) =>
|
||||||
|
(
|
||||||
|
await db.colony.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true, code: true },
|
||||||
|
})
|
||||||
|
).map(({ id, code }) => ({ id, name: code })),
|
||||||
|
),
|
||||||
|
namesOf(idsOf(evidences, ['queenId']), async (ids) =>
|
||||||
|
(
|
||||||
|
await db.queen.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true, code: true },
|
||||||
|
})
|
||||||
|
).map(({ id, code }) => ({ id, name: code })),
|
||||||
|
),
|
||||||
|
namesOf(idsOf(evidences, ['treatmentId']), async (ids) =>
|
||||||
|
(
|
||||||
|
await db.treatment.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true, product: true },
|
||||||
|
})
|
||||||
|
).map(({ id, product }) => ({ id, name: product })),
|
||||||
|
),
|
||||||
|
namesOf(idsOf(evidences, ['apiaryId']), (ids) =>
|
||||||
|
db.apiary.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
namesOf(idsOf(evidences, ['stockItemId']), (ids) =>
|
||||||
|
db.stockItem.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
colonyCode: (id) => colonies.get(id) ?? null,
|
||||||
|
queenCode: (id) => queens.get(id) ?? null,
|
||||||
|
treatmentProduct: (id) => treatments.get(id) ?? null,
|
||||||
|
apiaryName: (id) => apiaries.get(id) ?? null,
|
||||||
|
stockItemName: (id) => stockItems.get(id) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { planBaseDate, planOf, planSteps } from './suggestion-plan';
|
||||||
|
|
||||||
|
const PLAN = [
|
||||||
|
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
||||||
|
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
|
||||||
|
{ step: 'EGGS_CHECK', offsetDays: 28 },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('suggestion plan', () => {
|
||||||
|
it('places every step at base day + offset, with its window end', () => {
|
||||||
|
expect(planSteps(PLAN, '2026-05-01')).toEqual([
|
||||||
|
{ step: 'RAISE_CELLS_CHECK', day: '2026-05-10', windowUntil: null },
|
||||||
|
{ step: 'MATING_CHECK', day: '2026-05-22', windowUntil: '2026-05-25' },
|
||||||
|
{ step: 'EGGS_CHECK', day: '2026-05-29', windowUntil: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves the base so the first step lands on the chosen day', () => {
|
||||||
|
expect(
|
||||||
|
planSteps(PLAN, '2026-05-01', '2026-05-12').map((s) => s.day),
|
||||||
|
).toEqual(['2026-05-12', '2026-05-24', '2026-05-31']);
|
||||||
|
expect(
|
||||||
|
planSteps(PLAN, '2026-05-01', '2026-05-08').map((s) => s.day),
|
||||||
|
).toEqual(['2026-05-08', '2026-05-20', '2026-05-27']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a stored plan and rejects an unusable one', () => {
|
||||||
|
expect(planOf(PLAN)).toEqual(PLAN);
|
||||||
|
expect(planOf(null)).toBeNull();
|
||||||
|
expect(planOf([])).toBeNull();
|
||||||
|
expect(planOf([{ step: 'X' }])).toBeNull();
|
||||||
|
expect(planOf('nope')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes the base day from the evidence, else the fallback', () => {
|
||||||
|
expect(planBaseDate({ baseDate: '2026-05-01' }, '2026-06-01')).toBe(
|
||||||
|
'2026-05-01',
|
||||||
|
);
|
||||||
|
expect(planBaseDate({ baseDate: 'soon' }, '2026-06-01')).toBe('2026-06-01');
|
||||||
|
expect(planBaseDate({}, '2026-06-01')).toBe('2026-06-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { addDays, daysBetween } from '../common/dates';
|
||||||
|
import type { PlanStepConfig } from '../automation/rule-config';
|
||||||
|
|
||||||
|
/** One task a plan suggestion turns into. */
|
||||||
|
export interface PlannedStep {
|
||||||
|
step: string;
|
||||||
|
/** YYYY-MM-DD */
|
||||||
|
day: string;
|
||||||
|
/** YYYY-MM-DD, the end of the step's window. */
|
||||||
|
windowUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function isPlanStep(value: unknown): value is PlanStepConfig {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { step, offsetDays, windowDays } = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof step === 'string' &&
|
||||||
|
Number.isInteger(offsetDays) &&
|
||||||
|
(windowDays === undefined || Number.isInteger(windowDays))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored plan; null when there is none or it is unusable. */
|
||||||
|
export function planOf(json: unknown): PlanStepConfig[] | null {
|
||||||
|
if (!Array.isArray(json) || json.length === 0 || !json.every(isPlanStep)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base day of a plan: `evidence.baseDate`, else the fallback. */
|
||||||
|
export function planBaseDate(
|
||||||
|
evidence: Record<string, unknown>,
|
||||||
|
fallbackDay: string,
|
||||||
|
): string {
|
||||||
|
const { baseDate } = evidence;
|
||||||
|
return typeof baseDate === 'string' && DAY_PATTERN.test(baseDate)
|
||||||
|
? baseDate
|
||||||
|
: fallbackDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day of every step: base day + offset. With `firstStepDay` the base is
|
||||||
|
* moved so that the first step lands on that day and every other step keeps
|
||||||
|
* its distance to it.
|
||||||
|
*/
|
||||||
|
export function planSteps(
|
||||||
|
plan: readonly PlanStepConfig[],
|
||||||
|
baseDate: string,
|
||||||
|
firstStepDay?: string | null,
|
||||||
|
): PlannedStep[] {
|
||||||
|
const shift =
|
||||||
|
firstStepDay === null || firstStepDay === undefined
|
||||||
|
? 0
|
||||||
|
: daysBetween(addDays(baseDate, plan[0].offsetDays), firstStepDay);
|
||||||
|
return plan.map(({ step, offsetDays, windowDays }) => {
|
||||||
|
const day = addDays(baseDate, offsetDays + shift);
|
||||||
|
return {
|
||||||
|
step,
|
||||||
|
day,
|
||||||
|
windowUntil:
|
||||||
|
windowDays !== undefined && windowDays > 0
|
||||||
|
? addDays(day, windowDays)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { EvidenceLookup } from './evidence-lookup';
|
||||||
|
import {
|
||||||
|
formatDayDe,
|
||||||
|
formatNumberDe,
|
||||||
|
INTENT_TITLES,
|
||||||
|
PLAN_STEP_TITLES,
|
||||||
|
renderSuggestion,
|
||||||
|
SUGGESTION_TEXT_REGISTERS,
|
||||||
|
SUGGESTION_TEXTS,
|
||||||
|
} from './suggestion-texts';
|
||||||
|
|
||||||
|
const MAX_REASONS = 2;
|
||||||
|
const MAX_REASON_LENGTH = 90;
|
||||||
|
|
||||||
|
const lookup: EvidenceLookup = {
|
||||||
|
colonyCode: (id) => `V-${id}`,
|
||||||
|
queenCode: (id) => `Q-${id}`,
|
||||||
|
treatmentProduct: () => 'Oxalsäure',
|
||||||
|
apiaryName: () => 'Hausstand',
|
||||||
|
stockItemName: () => 'Gläser',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('suggestion texts', () => {
|
||||||
|
it('every registered template renders its example in at most 2 reasons of at most 90 characters', () => {
|
||||||
|
for (const [reasonCode, template] of Object.entries(SUGGESTION_TEXTS)) {
|
||||||
|
const { title, reasons } = template.render(
|
||||||
|
template.example,
|
||||||
|
lookup,
|
||||||
|
'2026-05-05',
|
||||||
|
);
|
||||||
|
expect({ reasonCode, empty: title.trim() === '' }).toEqual({
|
||||||
|
reasonCode,
|
||||||
|
empty: false,
|
||||||
|
});
|
||||||
|
expect(reasons.length).toBeLessThanOrEqual(MAX_REASONS);
|
||||||
|
for (const reason of reasons) {
|
||||||
|
expect(reason.length).toBeLessThanOrEqual(MAX_REASON_LENGTH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no reason code is registered twice', () => {
|
||||||
|
const codes = SUGGESTION_TEXT_REGISTERS.flatMap((register) =>
|
||||||
|
Object.keys(register),
|
||||||
|
);
|
||||||
|
expect(new Set(codes).size).toBe(codes.length);
|
||||||
|
expect(Object.keys(SUGGESTION_TEXTS)).toHaveLength(codes.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats numbers and days in German', () => {
|
||||||
|
expect(formatNumberDe(12.4)).toBe('12,4');
|
||||||
|
expect(formatNumberDe(12)).toBe('12');
|
||||||
|
expect(formatNumberDe(0.456, 2)).toBe('0,46');
|
||||||
|
expect(formatDayDe('2026-05-05')).toBe('05.05.');
|
||||||
|
expect(formatDayDe('2026-05-05', true)).toBe('05.05.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to INTENT_TITLES for an unknown reason code', () => {
|
||||||
|
expect(
|
||||||
|
renderSuggestion(
|
||||||
|
{ reasonCode: 'NO_SUCH_CODE', evidence: {}, intent: 'FEED' },
|
||||||
|
lookup,
|
||||||
|
'2026-05-05',
|
||||||
|
),
|
||||||
|
).toEqual({ title: INTENT_TITLES.FEED, reasons: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the title with a non-empty override only', () => {
|
||||||
|
const suggestion = {
|
||||||
|
reasonCode: 'NO_SUCH_CODE',
|
||||||
|
evidence: {},
|
||||||
|
intent: 'INSPECT' as const,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
renderSuggestion(suggestion, lookup, '2026-05-05', 'Eigener Titel').title,
|
||||||
|
).toBe('Eigener Titel');
|
||||||
|
expect(renderSuggestion(suggestion, lookup, '2026-05-05', '').title).toBe(
|
||||||
|
INTENT_TITLES.INSPECT,
|
||||||
|
);
|
||||||
|
expect(renderSuggestion(suggestion, lookup, '2026-05-05', null).title).toBe(
|
||||||
|
INTENT_TITLES.INSPECT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a title for every intent and every split step', () => {
|
||||||
|
expect(Object.keys(INTENT_TITLES)).toHaveLength(17);
|
||||||
|
for (const step of [
|
||||||
|
'RAISE_CELLS_CHECK',
|
||||||
|
'MATING_CHECK',
|
||||||
|
'EGGS_CHECK',
|
||||||
|
'ACCEPTANCE_CHECK',
|
||||||
|
]) {
|
||||||
|
expect(PLAN_STEP_TITLES[step]).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type {
|
||||||
|
AutomationTriggerType,
|
||||||
|
Suggestion,
|
||||||
|
SuggestionIntent,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import type { EvidenceLookup } from './evidence-lookup';
|
||||||
|
import type { SuggestionEvidence } from './suggestion.types';
|
||||||
|
import { AUTOPILOT_TEXTS } from './texts/autopilot-texts';
|
||||||
|
import { CHAIN_PLAN_STEP_TITLES, CHAIN_TEXTS } from './texts/chain-texts';
|
||||||
|
import { CLEANUP_TEXTS } from './texts/cleanup-texts';
|
||||||
|
import { RULE_TEXTS } from './texts/rule-texts';
|
||||||
|
|
||||||
|
export type { EvidenceLookup };
|
||||||
|
|
||||||
|
export interface RenderedSuggestionText {
|
||||||
|
title: string;
|
||||||
|
/** At most 2, each at most 90 characters. */
|
||||||
|
reasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionTextTemplate {
|
||||||
|
ruleKey: AutomationTriggerType;
|
||||||
|
/** Rendered by the text spec: the limits hold for it. */
|
||||||
|
example: SuggestionEvidence;
|
||||||
|
render(
|
||||||
|
evidence: SuggestionEvidence,
|
||||||
|
lookup: EvidenceLookup,
|
||||||
|
today: string,
|
||||||
|
): RenderedSuggestionText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Templates by reason code. */
|
||||||
|
export type SuggestionTextRegister = Readonly<
|
||||||
|
Record<string, SuggestionTextTemplate>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const SUGGESTION_TEXT_REGISTERS: readonly SuggestionTextRegister[] = [
|
||||||
|
RULE_TEXTS,
|
||||||
|
CHAIN_TEXTS,
|
||||||
|
AUTOPILOT_TEXTS,
|
||||||
|
CLEANUP_TEXTS,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** All templates; a reason code in two registers is a programming error. */
|
||||||
|
function mergeRegisters(
|
||||||
|
registers: readonly SuggestionTextRegister[],
|
||||||
|
): SuggestionTextRegister {
|
||||||
|
const merged: Record<string, SuggestionTextTemplate> = {};
|
||||||
|
for (const register of registers) {
|
||||||
|
for (const [reasonCode, template] of Object.entries(register)) {
|
||||||
|
if (Object.hasOwn(merged, reasonCode)) {
|
||||||
|
throw new Error(`Reason code registered twice: ${reasonCode}`);
|
||||||
|
}
|
||||||
|
merged[reasonCode] = template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUGGESTION_TEXTS: SuggestionTextRegister = mergeRegisters(
|
||||||
|
SUGGESTION_TEXT_REGISTERS,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PLAN_STEP_TITLES: Readonly<Record<string, string>> =
|
||||||
|
CHAIN_PLAN_STEP_TITLES;
|
||||||
|
|
||||||
|
/** Title of a suggestion whose reason code has no template. */
|
||||||
|
export const INTENT_TITLES: Readonly<Record<SuggestionIntent, string>> = {
|
||||||
|
INSPECT: 'Durchsicht fällig',
|
||||||
|
SWARM_CHECK: 'Schwarmkontrolle',
|
||||||
|
QUEEN_CHECK: 'Königin prüfen',
|
||||||
|
QUEEN_ACCEPTANCE: 'Annahme der Königin prüfen',
|
||||||
|
QUEEN_REPLACE: 'Königin ersetzen',
|
||||||
|
SPLIT_PLAN: 'Ableger kontrollieren',
|
||||||
|
VARROA_MEASURE: 'Varroa messen',
|
||||||
|
VARROA_RECHECK: 'Varroa nachkontrollieren',
|
||||||
|
VARROA_TREAT: 'Varroa behandeln',
|
||||||
|
SUMMER_TREATMENT: 'Sommerbehandlung planen',
|
||||||
|
FEED: 'Füttern',
|
||||||
|
HONEY_SUPER: 'Honigraum aufsetzen',
|
||||||
|
WINTER_CHECK: 'Winterkontrolle',
|
||||||
|
HEALTH_CHECK: 'Gesundheit prüfen',
|
||||||
|
FOLLOW_UP: 'Nachkontrolle',
|
||||||
|
STOCK_REORDER: 'Nachbestellen',
|
||||||
|
CLEANUP: 'Aufräumen',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title and reasons of a suggestion, rendered when it is read from its
|
||||||
|
* reason code and evidence. `titleOverride` (the rule's actionTemplate)
|
||||||
|
* replaces the title when it is not empty.
|
||||||
|
*/
|
||||||
|
export function renderSuggestion(
|
||||||
|
s: Pick<Suggestion, 'reasonCode' | 'evidence' | 'intent'>,
|
||||||
|
lookup: EvidenceLookup,
|
||||||
|
today: string,
|
||||||
|
titleOverride?: string | null,
|
||||||
|
): RenderedSuggestionText {
|
||||||
|
const template = Object.hasOwn(SUGGESTION_TEXTS, s.reasonCode)
|
||||||
|
? SUGGESTION_TEXTS[s.reasonCode]
|
||||||
|
: undefined;
|
||||||
|
const rendered: RenderedSuggestionText = template
|
||||||
|
? template.render(s.evidence as SuggestionEvidence, lookup, today)
|
||||||
|
: { title: INTENT_TITLES[s.intent], reasons: [] };
|
||||||
|
const override = titleOverride?.trim();
|
||||||
|
return override ? { ...rendered, title: override } : rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
|
||||||
|
export function formatDayDe(day: string, withYear = false): string {
|
||||||
|
const [year, month, date] = day.split('-');
|
||||||
|
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberFormats = new Map<number, Intl.NumberFormat>();
|
||||||
|
|
||||||
|
/** A number with a decimal comma: 12.4 as '12,4'. */
|
||||||
|
export function formatNumberDe(value: number, maxFractionDigits = 1): string {
|
||||||
|
let format = numberFormats.get(maxFractionDigits);
|
||||||
|
if (!format) {
|
||||||
|
format = new Intl.NumberFormat('de-DE', {
|
||||||
|
maximumFractionDigits: maxFractionDigits,
|
||||||
|
});
|
||||||
|
numberFormats.set(maxFractionDigits, format);
|
||||||
|
}
|
||||||
|
return format.format(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
AutomationTriggerType,
|
||||||
|
Suggestion,
|
||||||
|
SuggestionIntent,
|
||||||
|
SuggestionMode,
|
||||||
|
SuggestionSource,
|
||||||
|
Task,
|
||||||
|
TaskPriority,
|
||||||
|
TaskRelatedType,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import type { PlanStepConfig } from '../automation/rule-config';
|
||||||
|
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||||
|
|
||||||
|
export type EvidenceValue =
|
||||||
|
string | number | boolean | null | readonly string[];
|
||||||
|
|
||||||
|
/** Only IDs, numbers and YYYY-MM-DD; a plan's base day is `baseDate`. */
|
||||||
|
export type SuggestionEvidence = Readonly<Record<string, EvidenceValue>>;
|
||||||
|
|
||||||
|
export interface ProposeInput {
|
||||||
|
ruleKey: AutomationTriggerType;
|
||||||
|
source: SuggestionSource;
|
||||||
|
mode: SuggestionMode;
|
||||||
|
intent: SuggestionIntent;
|
||||||
|
priority?: TaskPriority;
|
||||||
|
relatedType: TaskRelatedType;
|
||||||
|
relatedId: string | null;
|
||||||
|
colonyId: string | null;
|
||||||
|
reasonCode: string;
|
||||||
|
evidence: SuggestionEvidence;
|
||||||
|
requiredPermission: PermissionKey | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
expiresAt?: string | null;
|
||||||
|
plan?: readonly PlanStepConfig[] | null;
|
||||||
|
anchor: string;
|
||||||
|
sourceEventId?: string | null;
|
||||||
|
/**
|
||||||
|
* Default false. Rule and chain suggestions pass `config.notify`, the
|
||||||
|
* autopilot never; the notification itself is wired by Task 7.
|
||||||
|
*/
|
||||||
|
notify?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The intent a task created by a legacy rule stands for. */
|
||||||
|
export const INTENT_OF_LEGACY_TRIGGER: Readonly<
|
||||||
|
Partial<Record<AutomationTriggerType, SuggestionIntent>>
|
||||||
|
> = {
|
||||||
|
QUEENLESS_NO_EGGS: 'QUEEN_CHECK',
|
||||||
|
VARROA_THRESHOLD: 'VARROA_TREAT',
|
||||||
|
FEED_STATUS_CRITICAL: 'FEED',
|
||||||
|
SWARM_MOOD_HIGH: 'SWARM_CHECK',
|
||||||
|
COLONY_HEALTH_CRITICAL: 'HEALTH_CHECK',
|
||||||
|
STOCK_ITEM_LOW: 'STOCK_REORDER',
|
||||||
|
RAW_MATERIAL_LOW: 'STOCK_REORDER',
|
||||||
|
TREATMENT_FOLLOW_UP: 'FOLLOW_UP',
|
||||||
|
FEEDING_FOLLOW_UP: 'FOLLOW_UP',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `<ruleKey>:<relatedId|none>:<anchor>`, e.g. `INSPECTION_INTERVAL:col_1:insp_9`. */
|
||||||
|
export function suggestionDedupeKey(
|
||||||
|
ruleKey: AutomationTriggerType,
|
||||||
|
relatedId: string | null,
|
||||||
|
anchor: string,
|
||||||
|
): string {
|
||||||
|
return `${ruleKey}:${relatedId ?? 'none'}:${anchor}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptSuggestionInput {
|
||||||
|
/** YYYY-MM-DD; for a plan the day of its first step. */
|
||||||
|
dueDate?: string | null;
|
||||||
|
assignedUserId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptSuggestionResult {
|
||||||
|
suggestionId: string;
|
||||||
|
status: 'ACCEPTED';
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SuggestionResolveNote =
|
||||||
|
| 'Bedingung entfällt'
|
||||||
|
| 'Autopilot aus'
|
||||||
|
| 'Aufgeräumt'
|
||||||
|
| 'Mit Durchsicht erledigt';
|
||||||
|
|
||||||
|
export type { Suggestion };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { TasksModule } from '../tasks/tasks.module';
|
||||||
|
import { SuggestionsService } from './suggestions.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggestions of the rules, chains and the season autopilot. The permission resolver
|
||||||
|
* the views need comes from the global PermissionsModule (API) or JobPermissionsModule (worker and jobs).
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [TasksModule, AuditModule],
|
||||||
|
providers: [SuggestionsService],
|
||||||
|
exports: [SuggestionsService],
|
||||||
|
})
|
||||||
|
export class SuggestionsModule {}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,700 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Prisma,
|
||||||
|
type AutomationTriggerType,
|
||||||
|
type Suggestion,
|
||||||
|
type SuggestionIntent,
|
||||||
|
type SuggestionStatus,
|
||||||
|
type Task,
|
||||||
|
type TaskPriority,
|
||||||
|
type TaskRelatedType,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
|
import { lockRuleIn, readRule } from '../automation/rule-config';
|
||||||
|
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
|
||||||
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
|
import { berlinToday, dayToDate } from '../common/dates';
|
||||||
|
import { utcDay } from '../domain-events/domain-event-types';
|
||||||
|
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
lockRowForUpdate,
|
||||||
|
lockRowsForShare,
|
||||||
|
lockRowsForUpdate,
|
||||||
|
} from '../prisma/row-lock';
|
||||||
|
import type { NewTaskInput } from '../tasks/tasks.service';
|
||||||
|
import { TasksService } from '../tasks/tasks.service';
|
||||||
|
import { buildEvidenceLookup } from './evidence-lookup';
|
||||||
|
import { planBaseDate, planOf, planSteps } from './suggestion-plan';
|
||||||
|
import {
|
||||||
|
formatDayDe,
|
||||||
|
INTENT_TITLES,
|
||||||
|
PLAN_STEP_TITLES,
|
||||||
|
renderSuggestion,
|
||||||
|
} from './suggestion-texts';
|
||||||
|
import {
|
||||||
|
INTENT_OF_LEGACY_TRIGGER,
|
||||||
|
suggestionDedupeKey,
|
||||||
|
type AcceptSuggestionInput,
|
||||||
|
type AcceptSuggestionResult,
|
||||||
|
type ProposeInput,
|
||||||
|
type SuggestionEvidence,
|
||||||
|
type SuggestionResolveNote,
|
||||||
|
} from './suggestion.types';
|
||||||
|
|
||||||
|
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
|
||||||
|
const BULK_MIN = 1;
|
||||||
|
const BULK_MAX = 50;
|
||||||
|
const MAX_NOTE_LENGTH = 500;
|
||||||
|
const NOT_OPEN_MESSAGE = 'Der Vorschlag ist nicht mehr offen.';
|
||||||
|
const COLONY_ENDED_MESSAGE = 'Das Volk ist nicht mehr aktiv.';
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
/** Scopes the check of an object-less target to one rule. */
|
||||||
|
ruleKey?: AutomationTriggerType;
|
||||||
|
colonyId: string | null;
|
||||||
|
relatedType: TaskRelatedType;
|
||||||
|
relatedId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The task fields every task of one accepted suggestion shares. */
|
||||||
|
type SharedTaskFields = Omit<NewTaskInput, 'title' | 'description' | 'dueDate'>;
|
||||||
|
|
||||||
|
function notOpen(status: SuggestionStatus): ConflictException {
|
||||||
|
return new ConflictException({ message: NOT_OPEN_MESSAGE, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDay(day: string | null | undefined): string | null {
|
||||||
|
if (!day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
dayToDate(day);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Bitte ein gültiges Datum wählen.');
|
||||||
|
}
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBulkIds(ids: readonly string[]): void {
|
||||||
|
if (
|
||||||
|
ids.length < BULK_MIN ||
|
||||||
|
ids.length > BULK_MAX ||
|
||||||
|
new Set(ids).size !== ids.length
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Bitte ${BULK_MIN} bis ${BULK_MAX} verschiedene Vorschläge wählen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskIntent(task: {
|
||||||
|
sourceSuggestion: { intent: SuggestionIntent } | null;
|
||||||
|
sourceAutomationTrigger: keyof typeof INTENT_OF_LEGACY_TRIGGER | null;
|
||||||
|
}): SuggestionIntent {
|
||||||
|
return (
|
||||||
|
task.sourceSuggestion?.intent ??
|
||||||
|
(task.sourceAutomationTrigger === null
|
||||||
|
? undefined
|
||||||
|
: INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger]) ??
|
||||||
|
'FOLLOW_UP'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place suggestions are proposed, decided and cleaned up: rule,
|
||||||
|
* chain and autopilot suggestions share one model and one set of rules (at
|
||||||
|
* most one open thing per intent and object, an anchor is proposed once).
|
||||||
|
* Every decision is a compare-and-set under the suggestion's row lock, with
|
||||||
|
* its audit entry in the same transaction.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SuggestionsService {
|
||||||
|
private readonly logger = new Logger(SuggestionsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly auditLog: AuditLogService,
|
||||||
|
private readonly tasks: TasksService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposes a suggestion unless its anchor was proposed before (the dedupe
|
||||||
|
* key) or something of the same intent is already open for the object.
|
||||||
|
* Locks the colony first, then the rule, so rule, chain and autopilot
|
||||||
|
* proposals for one colony are serialised and the intent check holds
|
||||||
|
* across rules. Resolves to the new suggestion, or null.
|
||||||
|
*/
|
||||||
|
async proposeIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
input: ProposeInput,
|
||||||
|
): Promise<Suggestion | null> {
|
||||||
|
assertTransactionClient(tx, 'SuggestionsService.proposeIn');
|
||||||
|
if (
|
||||||
|
input.colonyId !== null &&
|
||||||
|
!(await lockRowForUpdate(tx, 'Colony', input.colonyId))
|
||||||
|
) {
|
||||||
|
// The colony is gone: nothing to propose (and no FK error).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await lockRuleIn(tx, input.ruleKey);
|
||||||
|
const open = await this.openIntentsIn(tx, input);
|
||||||
|
if (open.has(input.intent)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const created = await tx.suggestion.createManyAndReturn({
|
||||||
|
data: [this.rowOf(input)],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
if (created.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [suggestion] = created;
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: 'Suggestion',
|
||||||
|
entityId: suggestion.id,
|
||||||
|
action: 'CREATE',
|
||||||
|
afterData: suggestion,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The intents with an open suggestion or an open task for the object. */
|
||||||
|
async openIntentsIn(
|
||||||
|
db: DbClient,
|
||||||
|
target: Target,
|
||||||
|
): Promise<Set<SuggestionIntent>> {
|
||||||
|
const suggestionWhere: Prisma.SuggestionWhereInput =
|
||||||
|
target.colonyId !== null
|
||||||
|
? { status: 'OPEN', colonyId: target.colonyId }
|
||||||
|
: {
|
||||||
|
status: 'OPEN',
|
||||||
|
relatedType: target.relatedType,
|
||||||
|
relatedId: target.relatedId,
|
||||||
|
...(this.isObjectLess(target) ? { ruleKey: target.ruleKey } : {}),
|
||||||
|
};
|
||||||
|
// One after the other, not Promise.all: on a transaction client both
|
||||||
|
// queries share one connection and are queued anyway, and the order
|
||||||
|
// stays the same in every transaction.
|
||||||
|
const suggestions = await db.suggestion.findMany({
|
||||||
|
where: suggestionWhere,
|
||||||
|
select: { intent: true },
|
||||||
|
});
|
||||||
|
const tasks = await db.task.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: [...OPEN_TASK_STATUSES] },
|
||||||
|
OR: this.taskObjectsOf(target),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
sourceAutomationTrigger: true,
|
||||||
|
sourceSuggestion: { select: { intent: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Set([
|
||||||
|
...suggestions.map((suggestion) => suggestion.intent),
|
||||||
|
...tasks.map(taskIntent),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings an open suggestion up to date with what the rule sees now. */
|
||||||
|
async refreshIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
id: string,
|
||||||
|
patch: {
|
||||||
|
evidence: SuggestionEvidence;
|
||||||
|
dueDate: string | null;
|
||||||
|
priority: TaskPriority;
|
||||||
|
expiresAt: string | null;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
assertTransactionClient(tx, 'SuggestionsService.refreshIn');
|
||||||
|
await tx.suggestion.updateMany({
|
||||||
|
where: { id, status: 'OPEN' },
|
||||||
|
data: {
|
||||||
|
evidence: patch.evidence,
|
||||||
|
dueDate: patch.dueDate === null ? null : dayToDate(patch.dueDate),
|
||||||
|
priority: patch.priority,
|
||||||
|
expiresAt: patch.expiresAt === null ? null : dayToDate(patch.expiresAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPEN to RESOLVED because the condition is gone; frees the dedupe key in
|
||||||
|
* the same statement, so the same anchor may be proposed again.
|
||||||
|
*/
|
||||||
|
async resolveIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
ids: readonly string[],
|
||||||
|
note: SuggestionResolveNote,
|
||||||
|
): Promise<number> {
|
||||||
|
assertTransactionClient(tx, 'SuggestionsService.resolveIn');
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return tx.$executeRaw`UPDATE "Suggestion"
|
||||||
|
SET status = 'RESOLVED', "dedupeKey" = NULL, "decidedAt" = now(),
|
||||||
|
"decisionNote" = ${note}, "updatedAt" = now()
|
||||||
|
WHERE id = ANY(${[...ids]}) AND status = 'OPEN'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OPEN to OBSOLETE for a colony that ended; the dedupe key stays. */
|
||||||
|
async obsoleteForColonyIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
colonyId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
assertTransactionClient(tx, 'SuggestionsService.obsoleteForColonyIn');
|
||||||
|
return tx.$executeRaw`UPDATE "Suggestion"
|
||||||
|
SET status = 'OBSOLETE', "decidedAt" = now(), "updatedAt" = now()
|
||||||
|
WHERE "colonyId" = ${colonyId} AND status = 'OPEN'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPEN past its expiry day to EXPIRED, in one statement that also frees
|
||||||
|
* the dedupe key.
|
||||||
|
*/
|
||||||
|
async expireDue(today: string): Promise<number> {
|
||||||
|
return this.prisma.$executeRaw`UPDATE "Suggestion"
|
||||||
|
SET status = 'EXPIRED', "dedupeKey" = NULL, "decidedAt" = now(),
|
||||||
|
"updatedAt" = now()
|
||||||
|
WHERE status = 'OPEN' AND "expiresAt" < ${dayToDate(today)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async accept(
|
||||||
|
id: string,
|
||||||
|
input: AcceptSuggestionInput,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<AcceptSuggestionResult> {
|
||||||
|
return this.prisma.$transaction((tx) =>
|
||||||
|
this.acceptIn(tx, id, input, actingUserId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All or nothing: one failing suggestion rolls back every task. */
|
||||||
|
async bulkAccept(
|
||||||
|
ids: readonly string[],
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<AcceptSuggestionResult[]> {
|
||||||
|
assertBulkIds(ids);
|
||||||
|
return this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await lockRowsForUpdate(tx, 'Suggestion', ids);
|
||||||
|
await this.assertDatedIn(tx, ids);
|
||||||
|
const results: AcceptSuggestionResult[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
results.push(await this.acceptIn(tx, id, {}, actingUserId));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismiss(
|
||||||
|
id: string,
|
||||||
|
note: string | null,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<Suggestion> {
|
||||||
|
this.assertNote(note);
|
||||||
|
return this.prisma.$transaction((tx) =>
|
||||||
|
this.dismissIn(tx, id, note, actingUserId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All or nothing; resolves to how many suggestions were dismissed. */
|
||||||
|
async bulkDismiss(
|
||||||
|
ids: readonly string[],
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<number> {
|
||||||
|
assertBulkIds(ids);
|
||||||
|
return this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await lockRowsForUpdate(tx, 'Suggestion', ids);
|
||||||
|
for (const id of ids) {
|
||||||
|
await this.dismissIn(tx, id, null, actingUserId);
|
||||||
|
}
|
||||||
|
return ids.length;
|
||||||
|
},
|
||||||
|
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DISMISSED back to OPEN, up to 30 days after the decision. */
|
||||||
|
async reopen(id: string, actingUserId: string): Promise<Suggestion> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const before = await this.readLockedIn(tx, id);
|
||||||
|
if (before.status !== 'DISMISSED') {
|
||||||
|
throw notOpen(before.status);
|
||||||
|
}
|
||||||
|
await this.assertColonyAliveIn(tx, before);
|
||||||
|
const open = await this.openIntentsIn(tx, before);
|
||||||
|
if (open.has(before.intent)) {
|
||||||
|
const subject =
|
||||||
|
before.relatedType === 'COLONY' ? 'dieses Volk' : 'diesen Eintrag';
|
||||||
|
throw new ConflictException({
|
||||||
|
message: `Für ${subject} ist schon ein Vorschlag „${INTENT_TITLES[before.intent]}“ offen.`,
|
||||||
|
status: before.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const moved = await tx.$executeRaw`UPDATE "Suggestion"
|
||||||
|
SET status = 'OPEN', "decidedAt" = NULL, "decidedByUserId" = NULL,
|
||||||
|
"decisionNote" = NULL, "updatedAt" = now()
|
||||||
|
WHERE id = ${id} AND status = 'DISMISSED'
|
||||||
|
AND "decidedAt" > now() - interval '30 days'`;
|
||||||
|
if (moved === 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message:
|
||||||
|
'Der Vorschlag kann nach 30 Tagen nicht mehr geöffnet werden.',
|
||||||
|
status: before.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.auditedUpdateIn(tx, before, actingUserId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async acceptIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
id: string,
|
||||||
|
input: AcceptSuggestionInput,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<AcceptSuggestionResult> {
|
||||||
|
const before = await this.readLockedIn(tx, id);
|
||||||
|
if (before.status !== 'OPEN') {
|
||||||
|
throw notOpen(before.status);
|
||||||
|
}
|
||||||
|
await this.assertColonyAliveIn(tx, before);
|
||||||
|
// Everything that can answer 400 comes before the first write.
|
||||||
|
await this.assertAssigneeIn(tx, input.assignedUserId);
|
||||||
|
const newTasks = await this.newTasksOf(tx, before, input);
|
||||||
|
const moved = await tx.$executeRaw`UPDATE "Suggestion"
|
||||||
|
SET status = 'ACCEPTED', "decidedAt" = now(),
|
||||||
|
"decidedByUserId" = ${actingUserId}, "updatedAt" = now()
|
||||||
|
WHERE id = ${id} AND status = 'OPEN'`;
|
||||||
|
if (moved === 0) {
|
||||||
|
throw notOpen(await this.currentStatusIn(tx, id));
|
||||||
|
}
|
||||||
|
const tasks: Task[] = [];
|
||||||
|
for (const newTask of newTasks) {
|
||||||
|
tasks.push(await this.tasks.createInTx(tx, newTask, actingUserId));
|
||||||
|
}
|
||||||
|
await this.auditedUpdateIn(tx, before, actingUserId);
|
||||||
|
return { suggestionId: id, status: 'ACCEPTED', tasks };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dismissIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
id: string,
|
||||||
|
note: string | null,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<Suggestion> {
|
||||||
|
const before = await this.readLockedIn(tx, id);
|
||||||
|
if (before.status !== 'OPEN') {
|
||||||
|
throw notOpen(before.status);
|
||||||
|
}
|
||||||
|
const moved = await tx.$executeRaw`UPDATE "Suggestion"
|
||||||
|
SET status = 'DISMISSED', "decidedAt" = now(),
|
||||||
|
"decidedByUserId" = ${actingUserId}, "decisionNote" = ${note},
|
||||||
|
"updatedAt" = now()
|
||||||
|
WHERE id = ${id} AND status = 'OPEN'`;
|
||||||
|
if (moved === 0) {
|
||||||
|
throw notOpen(await this.currentStatusIn(tx, id));
|
||||||
|
}
|
||||||
|
return this.auditedUpdateIn(tx, before, actingUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locks the suggestion and reads it afterwards: the copy to decide on. */
|
||||||
|
private async readLockedIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<Suggestion> {
|
||||||
|
assertTransactionClient(tx, 'SuggestionsService.readLockedIn');
|
||||||
|
await lockRowForUpdate(tx, 'Suggestion', id);
|
||||||
|
const suggestion = await tx.suggestion.findUnique({ where: { id } });
|
||||||
|
if (!suggestion) {
|
||||||
|
throw new NotFoundException('Der Vorschlag wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
return suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async currentStatusIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<SuggestionStatus> {
|
||||||
|
const current = await tx.suggestion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
throw new NotFoundException('Der Vorschlag wurde nicht gefunden.');
|
||||||
|
}
|
||||||
|
return current.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the decided suggestion and writes the audit entry for the change. */
|
||||||
|
private async auditedUpdateIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
before: Suggestion,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<Suggestion> {
|
||||||
|
const after = await tx.suggestion.findUniqueOrThrow({
|
||||||
|
where: { id: before.id },
|
||||||
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: 'Suggestion',
|
||||||
|
entityId: before.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: after,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tasks accepting `suggestion` creates: one per plan step, else one.
|
||||||
|
* A plan step is due base day + offset (moved as a whole by a chosen
|
||||||
|
* date); a DO suggestion needs a day, a PLAN suggestion without one is
|
||||||
|
* due today.
|
||||||
|
*/
|
||||||
|
private async newTasksOf(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
suggestion: Suggestion,
|
||||||
|
input: AcceptSuggestionInput,
|
||||||
|
): Promise<NewTaskInput[]> {
|
||||||
|
const today = berlinToday();
|
||||||
|
const evidence = suggestion.evidence as SuggestionEvidence;
|
||||||
|
const chosenDay = parseDay(input.dueDate);
|
||||||
|
const [rule, lookup, sourceInspectionId] = await Promise.all([
|
||||||
|
readRule(tx, suggestion.ruleKey),
|
||||||
|
buildEvidenceLookup(tx, [evidence]),
|
||||||
|
this.existingInspectionIdIn(tx, evidence),
|
||||||
|
]);
|
||||||
|
const text = renderSuggestion(
|
||||||
|
suggestion,
|
||||||
|
lookup,
|
||||||
|
today,
|
||||||
|
rule.actionTemplate,
|
||||||
|
);
|
||||||
|
const shared: SharedTaskFields = {
|
||||||
|
priority: suggestion.priority,
|
||||||
|
assignedUserId: input.assignedUserId ?? null,
|
||||||
|
relatedType: suggestion.relatedType,
|
||||||
|
relatedId: suggestion.relatedId,
|
||||||
|
sourceInspectionId,
|
||||||
|
sourceAutomationTrigger: suggestion.ruleKey,
|
||||||
|
sourceSuggestionId: suggestion.id,
|
||||||
|
};
|
||||||
|
const plan = planOf(suggestion.plan);
|
||||||
|
if (plan === null && suggestion.plan !== null) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Suggestion ${suggestion.id} has a stored plan that cannot be used; creating one task`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (plan) {
|
||||||
|
const baseDate = planBaseDate(evidence, today);
|
||||||
|
return planSteps(plan, baseDate, chosenDay).map((step) => ({
|
||||||
|
...shared,
|
||||||
|
title: Object.hasOwn(PLAN_STEP_TITLES, step.step)
|
||||||
|
? PLAN_STEP_TITLES[step.step]
|
||||||
|
: text.title,
|
||||||
|
description:
|
||||||
|
[
|
||||||
|
...text.reasons,
|
||||||
|
...(step.windowUntil === null
|
||||||
|
? []
|
||||||
|
: [`Zeitfenster bis ${formatDayDe(step.windowUntil)}`]),
|
||||||
|
].join('\n') || null,
|
||||||
|
dueDate: dayToDate(step.day),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const ownDay =
|
||||||
|
suggestion.dueDate === null ? null : utcDay(suggestion.dueDate);
|
||||||
|
const day =
|
||||||
|
chosenDay ?? ownDay ?? (suggestion.mode === 'PLAN' ? today : null);
|
||||||
|
if (day === null) {
|
||||||
|
throw new BadRequestException('Bitte ein Datum wählen.');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...shared,
|
||||||
|
title: text.title,
|
||||||
|
description: text.reasons.join('\n') || null,
|
||||||
|
dueDate: dayToDate(day),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A colony that ended takes no new work: its suggestions are dead. */
|
||||||
|
private async assertColonyAliveIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
suggestion: Suggestion,
|
||||||
|
): Promise<void> {
|
||||||
|
if (suggestion.colonyId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const colony = await tx.colony.findUnique({
|
||||||
|
where: { id: suggestion.colonyId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
if (!colony || !isLivingColonyStatus(colony.status)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
message: COLONY_ENDED_MESSAGE,
|
||||||
|
status: suggestion.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk accept has no date to give: a DO suggestion without its own date
|
||||||
|
* would fail one by one, so all of them are named at once.
|
||||||
|
*/
|
||||||
|
private async assertDatedIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
ids: readonly string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const rows = await tx.suggestion.findMany({
|
||||||
|
where: { id: { in: [...ids] } },
|
||||||
|
select: { id: true, mode: true, dueDate: true, plan: true },
|
||||||
|
});
|
||||||
|
const undated = rows
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.mode === 'DO' &&
|
||||||
|
row.dueDate === null &&
|
||||||
|
planOf(row.plan) === null,
|
||||||
|
)
|
||||||
|
.map((row) => row.id);
|
||||||
|
if (undated.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'Einige Vorschläge brauchen ein Datum.',
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
field: 'ids',
|
||||||
|
message: 'Bitte diese Vorschläge einzeln mit Datum übernehmen.',
|
||||||
|
ids: undated,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The assignee must be an existing, active user (a task links it by FK). */
|
||||||
|
private async assertAssigneeIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
assignedUserId: string | null | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!assignedUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const user = await tx.user.findUnique({
|
||||||
|
where: { id: assignedUserId },
|
||||||
|
select: { isActive: true },
|
||||||
|
});
|
||||||
|
if (!user?.isActive) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'Die Einstellung ist ungültig.',
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
field: 'assignedUserId',
|
||||||
|
message: 'Bitte einen aktiven Benutzer wählen.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The evidence's inspection, if it still exists (a task links it by FK). */
|
||||||
|
private async existingInspectionIdIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
evidence: SuggestionEvidence,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { inspectionId } = evidence;
|
||||||
|
if (typeof inspectionId !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Shared lock: the inspection cannot be deleted before the task links it.
|
||||||
|
await lockRowsForShare(tx, 'Inspection', [inspectionId]);
|
||||||
|
const found = await tx.inspection.count({ where: { id: inspectionId } });
|
||||||
|
return found > 0 ? inspectionId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No colony and no object id: only the rule tells proposals apart. */
|
||||||
|
private isObjectLess(
|
||||||
|
target: Target,
|
||||||
|
): target is Target & { ruleKey: AutomationTriggerType } {
|
||||||
|
return (
|
||||||
|
target.colonyId === null &&
|
||||||
|
target.relatedId === null &&
|
||||||
|
target.ruleKey !== undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private taskObjectsOf(target: Target): Prisma.TaskWhereInput[] {
|
||||||
|
const objects: Prisma.TaskWhereInput[] = [];
|
||||||
|
if (target.colonyId !== null) {
|
||||||
|
objects.push({ relatedType: 'COLONY', relatedId: target.colonyId });
|
||||||
|
}
|
||||||
|
const ownIsColony =
|
||||||
|
target.relatedType === 'COLONY' && target.relatedId === target.colonyId;
|
||||||
|
if (
|
||||||
|
(target.relatedId !== null || target.colonyId === null) &&
|
||||||
|
!ownIsColony
|
||||||
|
) {
|
||||||
|
objects.push({
|
||||||
|
relatedType: target.relatedType,
|
||||||
|
relatedId: target.relatedId,
|
||||||
|
...(this.isObjectLess(target)
|
||||||
|
? { sourceAutomationTrigger: target.ruleKey }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowOf(input: ProposeInput): Prisma.SuggestionCreateManyInput {
|
||||||
|
return {
|
||||||
|
ruleKey: input.ruleKey,
|
||||||
|
source: input.source,
|
||||||
|
mode: input.mode,
|
||||||
|
intent: input.intent,
|
||||||
|
priority: input.priority ?? 'NORMAL',
|
||||||
|
relatedType: input.relatedType,
|
||||||
|
relatedId: input.relatedId,
|
||||||
|
colonyId: input.colonyId,
|
||||||
|
reasonCode: input.reasonCode,
|
||||||
|
evidence: input.evidence,
|
||||||
|
requiredPermission: input.requiredPermission,
|
||||||
|
dueDate: input.dueDate ? dayToDate(input.dueDate) : null,
|
||||||
|
expiresAt: input.expiresAt ? dayToDate(input.expiresAt) : null,
|
||||||
|
plan: input.plan
|
||||||
|
? (input.plan as unknown as Prisma.InputJsonArray)
|
||||||
|
: undefined,
|
||||||
|
dedupeKey: suggestionDedupeKey(
|
||||||
|
input.ruleKey,
|
||||||
|
input.relatedId,
|
||||||
|
input.anchor,
|
||||||
|
),
|
||||||
|
sourceEventId: input.sourceEventId ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNote(note: string | null): void {
|
||||||
|
if (note !== null && note.length > MAX_NOTE_LENGTH) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Die Notiz darf höchstens ${MAX_NOTE_LENGTH} Zeichen lang sein.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { SuggestionTextRegister } from '../suggestion-texts';
|
||||||
|
|
||||||
|
/** Texts of the season autopilot suggestions, by reason code. */
|
||||||
|
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { SuggestionTextRegister } from '../suggestion-texts';
|
||||||
|
|
||||||
|
/** Texts of the chain suggestions, by reason code. */
|
||||||
|
export const CHAIN_TEXTS: SuggestionTextRegister = {};
|
||||||
|
|
||||||
|
/** Task title of each step of a plan suggestion (SPLIT_SCHEDULE). */
|
||||||
|
export const CHAIN_PLAN_STEP_TITLES: Readonly<Record<string, string>> = {
|
||||||
|
RAISE_CELLS_CHECK: 'Weiselzellen kontrollieren',
|
||||||
|
MATING_CHECK: 'Begattung kontrollieren',
|
||||||
|
EGGS_CHECK: 'Eier und Stifte kontrollieren',
|
||||||
|
ACCEPTANCE_CHECK: 'Annahme der Königin kontrollieren',
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { SuggestionTextRegister } from '../suggestion-texts';
|
||||||
|
|
||||||
|
/** Texts of the clean-up suggestions, by reason code. */
|
||||||
|
export const CLEANUP_TEXTS: SuggestionTextRegister = {};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { SuggestionTextRegister } from '../suggestion-texts';
|
||||||
|
|
||||||
|
/** Texts of the rule suggestions, by reason code (filled by the rule tasks). */
|
||||||
|
export const RULE_TEXTS: SuggestionTextRegister = {};
|
||||||
@@ -3,7 +3,11 @@ import { JobRun, Prisma } from '../../generated/prisma';
|
|||||||
import { JOB_NAMES } from '../jobs/job-names';
|
import { JOB_NAMES } from '../jobs/job-names';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { JOB_ABORTED_ERROR, JobRunService } from './job-run.service';
|
import {
|
||||||
|
JOB_ABORTED_ERROR,
|
||||||
|
JobPartialFailure,
|
||||||
|
JobRunService,
|
||||||
|
} from './job-run.service';
|
||||||
|
|
||||||
type RawCall = [readonly string[], ...unknown[]];
|
type RawCall = [readonly string[], ...unknown[]];
|
||||||
|
|
||||||
@@ -125,6 +129,26 @@ describe('JobRunService', () => {
|
|||||||
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Mustermann');
|
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Mustermann');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores the counters of a partial failure with the FAILED run', async () => {
|
||||||
|
const run = await service.run('daily-evaluation', () =>
|
||||||
|
Promise.reject(
|
||||||
|
new JobPartialFailure({ WithdrawalEnded: 2, TaskDue: 1 }, [
|
||||||
|
'time-events',
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(run.status).toBe('FAILED');
|
||||||
|
const [finish] = prisma.$queryRaw.mock.calls;
|
||||||
|
expect(finish.slice(1)).toEqual([
|
||||||
|
'FAILED',
|
||||||
|
'{"WithdrawalEnded":2,"TaskDue":1}',
|
||||||
|
'Daily evaluation failed for: time-events',
|
||||||
|
'run-1',
|
||||||
|
]);
|
||||||
|
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
// finishedAt of every run - finished, failed or aborted - comes from
|
// finishedAt of every run - finished, failed or aborted - comes from
|
||||||
// the database clock, like the comparisons of cleanup.
|
// the database clock, like the comparisons of cleanup.
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -20,9 +20,26 @@ const JOB_RUN_RELATED_TYPE = 'JobRun';
|
|||||||
|
|
||||||
const NEWEST_FIRST = [{ startedAt: 'desc' as const }, { id: 'desc' as const }];
|
const NEWEST_FIRST = [{ startedAt: 'desc' as const }, { id: 'desc' as const }];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A run of several steps in which some failed: `result` holds the counters
|
||||||
|
* of all steps, the finished ones included. JobRunService stores it with the
|
||||||
|
* FAILED run, so the System page shows what was done.
|
||||||
|
*/
|
||||||
|
export class JobPartialFailure extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly result: JobResult,
|
||||||
|
readonly failedSteps: readonly string[],
|
||||||
|
/** What the stored error names, e.g. "time-events: QueenAged". */
|
||||||
|
details: readonly string[] = failedSteps,
|
||||||
|
) {
|
||||||
|
super(`Daily evaluation failed for: ${details.join(', ')}`);
|
||||||
|
this.name = 'JobPartialFailure';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type RunOutcome =
|
type RunOutcome =
|
||||||
| { status: 'SUCCEEDED'; result: JobResult }
|
| { status: 'SUCCEEDED'; result: JobResult }
|
||||||
| { status: 'FAILED'; error: string };
|
| { status: 'FAILED'; error: string; result?: JobResult };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookkeeping of the CronJob runs: every run is one JobRun row (RUNNING,
|
* Bookkeeping of the CronJob runs: every run is one JobRun row (RUNNING,
|
||||||
@@ -116,6 +133,7 @@ export class JobRunService {
|
|||||||
const failed = await this.finish(runId, {
|
const failed = await this.finish(runId, {
|
||||||
status: 'FAILED',
|
status: 'FAILED',
|
||||||
error: message,
|
error: message,
|
||||||
|
...(error instanceof JobPartialFailure ? { result: error.result } : {}),
|
||||||
});
|
});
|
||||||
await this.notifyFailure(job, runId);
|
await this.notifyFailure(job, runId);
|
||||||
return failed;
|
return failed;
|
||||||
@@ -127,7 +145,7 @@ export class JobRunService {
|
|||||||
*/
|
*/
|
||||||
private async finish(runId: string, outcome: RunOutcome): Promise<JobRun> {
|
private async finish(runId: string, outcome: RunOutcome): Promise<JobRun> {
|
||||||
const result =
|
const result =
|
||||||
outcome.status === 'SUCCEEDED' ? JSON.stringify(outcome.result) : null;
|
outcome.result === undefined ? null : JSON.stringify(outcome.result);
|
||||||
const error = outcome.status === 'FAILED' ? outcome.error : null;
|
const error = outcome.status === 'FAILED' ? outcome.error : null;
|
||||||
const [finished] = await this.prisma.$queryRaw<JobRun[]>`
|
const [finished] = await this.prisma.$queryRaw<JobRun[]>`
|
||||||
UPDATE "JobRun"
|
UPDATE "JobRun"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface NewTaskInput {
|
|||||||
sourceInspectionId?: string | null;
|
sourceInspectionId?: string | null;
|
||||||
sourceTemplateId?: string | null;
|
sourceTemplateId?: string | null;
|
||||||
sourceAutomationTrigger?: AutomationTriggerType | null;
|
sourceAutomationTrigger?: AutomationTriggerType | null;
|
||||||
|
sourceSuggestionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskFilter {
|
export interface TaskFilter {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
|
||||||
import { AutomationModule } from '../automation/automation.module';
|
import { AutomationModule } from '../automation/automation.module';
|
||||||
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
||||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||||
import { TasksModule } from '../tasks/tasks.module';
|
import { TasksModule } from '../tasks/tasks.module';
|
||||||
import { WebhooksModule } from '../webhooks/webhooks.module';
|
import { WebhooksModule } from '../webhooks/webhooks.module';
|
||||||
import { WorkerHealthServer } from './worker-health-server.service';
|
import { WorkerHealthServer } from './worker-health-server.service';
|
||||||
@@ -23,12 +25,14 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
|||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
JobPermissionsModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
DomainEventsModule,
|
DomainEventsModule,
|
||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
CalendarSyncModule,
|
CalendarSyncModule,
|
||||||
TasksModule,
|
TasksModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
|
SuggestionsModule,
|
||||||
WorkerRuntimeModule,
|
WorkerRuntimeModule,
|
||||||
],
|
],
|
||||||
providers: [WorkerHealthServer],
|
providers: [WorkerHealthServer],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AutomationTriggerType, Prisma } from '../generated/prisma';
|
|||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
import { login } from './utils/api';
|
import { login } from './utils/api';
|
||||||
import { drainDomainEvents } from './utils/domain-events';
|
import { drainDomainEvents } from './utils/domain-events';
|
||||||
|
import { DEFAULT_RULES } from '../src/automation/rule-config';
|
||||||
|
|
||||||
interface AutomationRuleBody {
|
interface AutomationRuleBody {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -165,6 +166,94 @@ describe('Automation rules (e2e)', () => {
|
|||||||
)!.id;
|
)!.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const NEW_TRIGGERS = [
|
||||||
|
'INSPECTION_INTERVAL',
|
||||||
|
'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
'SPLIT_SCHEDULE',
|
||||||
|
'COLONY_ENDED_CLEANUP',
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
'HONEY_WATER_CONTENT',
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
'PHENOLOGY',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
it('lists the nine new rules with their defaults', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/automation-rules')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200);
|
||||||
|
const rules = response.body as AutomationRuleBody[];
|
||||||
|
|
||||||
|
for (const trigger of NEW_TRIGGERS) {
|
||||||
|
const rule = rules.find((entry) => entry.triggerType === trigger);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
expect(rule!.active).toBe(DEFAULT_RULES[trigger].active ?? true);
|
||||||
|
expect(rule!.config).toEqual(DEFAULT_RULES[trigger].config);
|
||||||
|
expect(rule!.actionTemplate).toBe('');
|
||||||
|
}
|
||||||
|
// Existing rules keep their stored config; a fresh database has the new defaults.
|
||||||
|
const varroa = rules.find(
|
||||||
|
(rule) => rule.triggerType === 'VARROA_THRESHOLD',
|
||||||
|
);
|
||||||
|
expect(varroa?.config).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH a rule with an invalid known value answers 400 with the field', async () => {
|
||||||
|
const rules = (
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/automation-rules')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.expect(200)
|
||||||
|
).body as AutomationRuleBody[];
|
||||||
|
const autopilot = rules.find(
|
||||||
|
(rule) => rule.triggerType === 'SEASON_AUTOPILOT',
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/automation-rules/${autopilot.id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ config: { queenMaxAgeYears: 0 } })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
const body = res.body as {
|
||||||
|
message: string;
|
||||||
|
issues: { field: string; message: string }[];
|
||||||
|
};
|
||||||
|
expect(body.message).toBe('Die Einstellung ist ungültig.');
|
||||||
|
expect(body.issues).toEqual([
|
||||||
|
expect.objectContaining({ field: 'queenMaxAgeYears' }),
|
||||||
|
]);
|
||||||
|
// Nothing was written.
|
||||||
|
const stored = await prisma.automationRule.findUniqueOrThrow({
|
||||||
|
where: { id: autopilot.id },
|
||||||
|
});
|
||||||
|
expect(stored.config).toEqual(autopilot.config);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH keeps unknown keys and saves a valid config', async () => {
|
||||||
|
const rule = await prisma.automationRule.findUniqueOrThrow({
|
||||||
|
where: { triggerType: 'SEASON_AUTOPILOT' },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/automation-rules/${rule.id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ config: { queenMaxAgeYears: 3, futureKey: 'x' } })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const stored = await prisma.automationRule.findUniqueOrThrow({
|
||||||
|
where: { id: rule.id },
|
||||||
|
});
|
||||||
|
expect(stored.config).toEqual({ queenMaxAgeYears: 3, futureKey: 'x' });
|
||||||
|
} finally {
|
||||||
|
await prisma.automationRule.update({
|
||||||
|
where: { id: rule.id },
|
||||||
|
data: { config: rule.config as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('creates a task and notification when queen not seen and no eggs present', async () => {
|
it('creates a task and notification when queen not seen and no eggs present', async () => {
|
||||||
await record('inspections', { queenSeen: false, eggsPresent: false });
|
await record('inspections', { queenSeen: false, eggsPresent: false });
|
||||||
|
|
||||||
@@ -190,7 +279,7 @@ describe('Automation rules (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('triggers a feeding task when feed status is kritisch', async () => {
|
it('triggers a feeding task when feed status is kritisch', async () => {
|
||||||
await record('inspections', { feedStatus: 'kritisch' });
|
await record('inspections', { feedStatus: 'CRITICAL' });
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const tasks = await prisma.task.findMany({
|
||||||
where: { relatedId: colonyId },
|
where: { relatedId: colonyId },
|
||||||
@@ -212,7 +301,7 @@ describe('Automation rules (e2e)', () => {
|
|||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.patch(`/api/v1/inspections/${id}`)
|
.patch(`/api/v1/inspections/${id}`)
|
||||||
.set('Authorization', `Bearer ${staffToken}`)
|
.set('Authorization', `Bearer ${staffToken}`)
|
||||||
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'kritisch' })
|
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'CRITICAL' })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
await drainDomainEvents(app, [id]);
|
await drainDomainEvents(app, [id]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { INestApplicationContext } from '@nestjs/common';
|
import { INestApplicationContext } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '../generated/prisma';
|
import { Prisma } from '../generated/prisma';
|
||||||
import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job';
|
|
||||||
import { JobsModule } from '../src/jobs/jobs.module';
|
import { JobsModule } from '../src/jobs/jobs.module';
|
||||||
|
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
|
||||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
|
|
||||||
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
|
|
||||||
|
|
||||||
const NOTHING_NEW = {
|
const NOTHING_NEW = {
|
||||||
WithdrawalEnded: 0,
|
WithdrawalEnded: 0,
|
||||||
@@ -21,14 +19,14 @@ const NOTHING_NEW = {
|
|||||||
* The job evaluates the whole database. The e2e files share it and run in
|
* The job evaluates the whole database. The e2e files share it and run in
|
||||||
* parallel, so the evaluation runs on days in 2001: no other file has
|
* parallel, so the evaluation runs on days in 2001: no other file has
|
||||||
* records there, and the events of this file stay its own. Called through
|
* records there, and the events of this file stay its own. Called through
|
||||||
* evaluate(day) instead of runCli, which would also write JobRun rows that
|
* TimeEventsStep.run(day) instead of runCli, which would also write JobRun
|
||||||
* test/jobs.e2e-spec.ts counts.
|
* rows that test/jobs.e2e-spec.ts counts.
|
||||||
*/
|
*/
|
||||||
describe('daily-evaluation (e2e)', () => {
|
describe('daily-evaluation (e2e)', () => {
|
||||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let job: DailyEvaluationJob;
|
let step: TimeEventsStep;
|
||||||
const ids: Record<string, string> = {};
|
const ids: Record<string, string> = {};
|
||||||
|
|
||||||
const eventsOf = (aggregateId: string) =>
|
const eventsOf = (aggregateId: string) =>
|
||||||
@@ -50,7 +48,7 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
app = await moduleRef.init();
|
app = await moduleRef.init();
|
||||||
prisma = app.get(PrismaService);
|
prisma = app.get(PrismaService);
|
||||||
job = app.get(DailyEvaluationJob);
|
step = app.get(TimeEventsStep);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -99,7 +97,8 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
ids.dead = dead.id;
|
ids.dead = dead.id;
|
||||||
// Inspected 2001-04-28: the next inspection is overdue on 2001-05-19.
|
// Inspected 2001-04-28, in the swarm season (every 7 days): the next
|
||||||
|
// inspection is due 2001-05-05 and overdue on 2001-05-06.
|
||||||
for (const colonyId of [living.id, dead.id]) {
|
for (const colonyId of [living.id, dead.id]) {
|
||||||
await prisma.inspection.create({
|
await prisma.inspection.create({
|
||||||
data: { colonyId, userId: user.id, date: d('2001-04-28') },
|
data: { colonyId, userId: user.id, date: d('2001-04-28') },
|
||||||
@@ -180,10 +179,10 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
|
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
|
||||||
const first = await job.evaluate(at('2001-05-20'));
|
const first = await step.run('2001-05-20');
|
||||||
const second = await job.evaluate(at('2001-05-20'));
|
const second = await step.run('2001-05-20');
|
||||||
// The next day the occurrence is still in the window - and still known.
|
// The next day the occurrence is still in the window - and still known.
|
||||||
const nextDay = await job.evaluate(at('2001-05-21'));
|
const nextDay = await step.run('2001-05-21');
|
||||||
|
|
||||||
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
|
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
|
||||||
expect(second).toEqual(NOTHING_NEW);
|
expect(second).toEqual(NOTHING_NEW);
|
||||||
@@ -201,8 +200,8 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
|
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records due tasks and overdue checks only for open tasks and living colonies', async () => {
|
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
|
||||||
await job.evaluate(at('2001-05-20'));
|
await step.run('2001-05-20');
|
||||||
|
|
||||||
expect(await eventsOf(ids['task:open'])).toEqual([
|
expect(await eventsOf(ids['task:open'])).toEqual([
|
||||||
{
|
{
|
||||||
@@ -215,17 +214,15 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
]);
|
]);
|
||||||
expect(await eventsOf(ids['task:done'])).toEqual([]);
|
expect(await eventsOf(ids['task:done'])).toEqual([]);
|
||||||
expect(await eventsOf(ids.living)).toEqual([
|
expect(await eventsOf(ids.living)).toEqual([
|
||||||
{
|
|
||||||
type: 'InspectionOverdue',
|
|
||||||
aggregateType: 'Colony',
|
|
||||||
payload: { dueOn: '2001-05-19', lastInspectedOn: '2001-04-28' },
|
|
||||||
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-19`,
|
|
||||||
actorUserId: null,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'VarroaMeasurementDue',
|
type: 'VarroaMeasurementDue',
|
||||||
aggregateType: 'Colony',
|
aggregateType: 'Colony',
|
||||||
payload: { dueOn: '2001-05-18', lastMeasuredOn: null },
|
payload: {
|
||||||
|
dueOn: '2001-05-18',
|
||||||
|
lastMeasuredOn: null,
|
||||||
|
kind: 'INTERVAL',
|
||||||
|
anchor: 'none',
|
||||||
|
},
|
||||||
dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`,
|
dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`,
|
||||||
actorUserId: null,
|
actorUserId: null,
|
||||||
},
|
},
|
||||||
@@ -233,11 +230,70 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
expect(await eventsOf(ids.dead)).toEqual([]);
|
expect(await eventsOf(ids.dead)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports active queens from their second year in the first days of January', async () => {
|
it('InspectionOverdue follows the segment interval', async () => {
|
||||||
await job.evaluate(at('2001-01-05'));
|
await step.run('2001-05-12');
|
||||||
expect(await job.evaluate(at('2001-01-08'))).toEqual(NOTHING_NEW);
|
|
||||||
|
|
||||||
expect(await eventsOf(ids['queen:aged'])).toEqual([
|
// SCHWARMZEIT since 2001-04-15: 2001-04-28 + 7 = 2001-05-05, not + 21.
|
||||||
|
expect(
|
||||||
|
(await eventsOf(ids.living)).filter(
|
||||||
|
(event) => event.type === 'InspectionOverdue',
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: 'InspectionOverdue',
|
||||||
|
aggregateType: 'Colony',
|
||||||
|
payload: {
|
||||||
|
dueOn: '2001-05-05',
|
||||||
|
lastInspectedOn: '2001-04-28',
|
||||||
|
firstOfYear: false,
|
||||||
|
},
|
||||||
|
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-06`,
|
||||||
|
actorUserId: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(await eventsOf(ids.dead)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records no inspection in WINTERRUHE and the first one the day after FRUEHJAHR begins', async () => {
|
||||||
|
await step.run('2002-03-10');
|
||||||
|
expect(
|
||||||
|
(await eventsOf(ids.living)).filter((event) =>
|
||||||
|
event.dedupeKey?.endsWith(':2002-03-21'),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
// FRUEHJAHR begins 2002-03-20; the colony was last inspected in 2001.
|
||||||
|
await step.run('2002-03-21');
|
||||||
|
expect(
|
||||||
|
(await eventsOf(ids.living)).filter((event) =>
|
||||||
|
event.dedupeKey?.endsWith(':2002-03-21'),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: 'InspectionOverdue',
|
||||||
|
aggregateType: 'Colony',
|
||||||
|
payload: {
|
||||||
|
dueOn: '2002-03-20',
|
||||||
|
lastInspectedOn: '2001-04-28',
|
||||||
|
firstOfYear: true,
|
||||||
|
},
|
||||||
|
dedupeKey: `InspectionOverdue:${ids.living}:2002-03-21`,
|
||||||
|
actorUserId: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the current queen of a living colony once she is aged, on any job day of the year', async () => {
|
||||||
|
await step.run('2001-09-09');
|
||||||
|
await step.run('2001-09-10');
|
||||||
|
|
||||||
|
// One per queen and year: the 2002 run of the test above added its own.
|
||||||
|
const events = await eventsOf(ids['queen:aged']);
|
||||||
|
expect(events.map((event) => event.dedupeKey)).toEqual([
|
||||||
|
`QueenAged:${ids['queen:aged']}:2001-01-01`,
|
||||||
|
`QueenAged:${ids['queen:aged']}:2002-01-01`,
|
||||||
|
]);
|
||||||
|
expect(events.slice(0, 1)).toEqual([
|
||||||
{
|
{
|
||||||
type: 'QueenAged',
|
type: 'QueenAged',
|
||||||
aggregateType: 'Queen',
|
aggregateType: 'Queen',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface InspectionBody {
|
|||||||
id: string;
|
id: string;
|
||||||
colonyId: string;
|
colonyId: string;
|
||||||
broodFrames?: number | null;
|
broodFrames?: number | null;
|
||||||
|
feedStatus?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskBody {
|
interface TaskBody {
|
||||||
@@ -268,4 +269,37 @@ describe('Inspections (e2e)', () => {
|
|||||||
.send({ broodFrames: 'x' })
|
.send({ broodFrames: 'x' })
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps legacy feed words to the enum, accepts enum names and rejects other text', async () => {
|
||||||
|
const post = (feedStatus: unknown) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post('/api/v1/inspections')
|
||||||
|
.set('Authorization', `Bearer ${staffToken}`)
|
||||||
|
.send({ colonyId, date: new Date().toISOString(), feedStatus });
|
||||||
|
|
||||||
|
await post('halb voll').expect(400);
|
||||||
|
await post(5).expect(400);
|
||||||
|
|
||||||
|
const blank = await post(' ').expect(201);
|
||||||
|
expect((blank.body as InspectionBody).feedStatus).toBeNull();
|
||||||
|
const legacy = await post(' Niedrig ').expect(201);
|
||||||
|
expect((legacy.body as InspectionBody).feedStatus).toBe('WEAK');
|
||||||
|
const created = await post('WEAK').expect(201);
|
||||||
|
const body = created.body as InspectionBody;
|
||||||
|
expect(body.feedStatus).toBe('WEAK');
|
||||||
|
|
||||||
|
const patch = (feedStatus: unknown) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/inspections/${body.id}`)
|
||||||
|
.set('Authorization', `Bearer ${staffToken}`)
|
||||||
|
.send({ feedStatus });
|
||||||
|
await patch('irgendwas').expect(400);
|
||||||
|
const mapped = await patch('Kritisch').expect(200);
|
||||||
|
expect((mapped.body as InspectionBody).feedStatus).toBe('CRITICAL');
|
||||||
|
const blankPatch = await patch(' ').expect(200);
|
||||||
|
expect((blankPatch.body as InspectionBody).feedStatus).toBeNull();
|
||||||
|
await patch('WEAK').expect(200);
|
||||||
|
const cleared = await patch(null).expect(200);
|
||||||
|
expect((cleared.body as InspectionBody).feedStatus).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { SuggestionsService } from '../src/suggestions/suggestions.service';
|
||||||
|
import type { ProposeInput } from '../src/suggestions/suggestion.types';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
|
||||||
|
* status changes that free it, the plan tasks and the 30-day reopen window.
|
||||||
|
*/
|
||||||
|
describe('Suggestions (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let service: SuggestionsService;
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
let apiaryId: string;
|
||||||
|
let colonyId: string;
|
||||||
|
let userId: string;
|
||||||
|
const proposal = (anchor: string): ProposeInput => ({
|
||||||
|
ruleKey: 'SPLIT_SCHEDULE',
|
||||||
|
source: 'CHAIN',
|
||||||
|
mode: 'PLAN',
|
||||||
|
intent: 'SPLIT_PLAN',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: colonyId,
|
||||||
|
colonyId,
|
||||||
|
reasonCode: `SUGGESTIONS_E2E_${suffix}`,
|
||||||
|
evidence: { colonyId, baseDate: '2026-05-01' },
|
||||||
|
requiredPermission: 'colonies:read',
|
||||||
|
plan: [
|
||||||
|
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
||||||
|
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
|
||||||
|
],
|
||||||
|
anchor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const propose = (input: ProposeInput) =>
|
||||||
|
prisma.$transaction((tx) => service.proposeIn(tx, input));
|
||||||
|
|
||||||
|
const statusOf = async (id: string) =>
|
||||||
|
(await prisma.suggestion.findUniqueOrThrow({ where: { id } })).status;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
service = app.get(SuggestionsService);
|
||||||
|
const apiary = await prisma.apiary.create({
|
||||||
|
data: { name: `Suggestions e2e ${suffix}` },
|
||||||
|
});
|
||||||
|
apiaryId = apiary.id;
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `SUG-${suffix}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
colonyId = colony.id;
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `suggestions-e2e-${suffix}@example.com`,
|
||||||
|
passwordHash: 'x',
|
||||||
|
name: 'Suggestions e2e',
|
||||||
|
role: { connect: { key: 'STAFF' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.task.deleteMany({
|
||||||
|
where: { sourceSuggestion: { colonyId } },
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
|
await prisma.colony.delete({ where: { id: colonyId } });
|
||||||
|
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||||
|
await prisma.user.delete({ where: { id: userId } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.task.deleteMany({
|
||||||
|
where: { sourceSuggestion: { colonyId } },
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proposes once per anchor and writes an audit entry', async () => {
|
||||||
|
const created = await propose(proposal('a1'));
|
||||||
|
expect(created).not.toBeNull();
|
||||||
|
expect(created?.dedupeKey).toBe(`SPLIT_SCHEDULE:${colonyId}:a1`);
|
||||||
|
expect(
|
||||||
|
await prisma.auditLog.count({
|
||||||
|
where: { entityType: 'Suggestion', entityId: created!.id },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
// Same anchor: the key exists. A second intent check would also stop it.
|
||||||
|
expect(await propose(proposal('a1'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows one open suggestion per intent and colony', async () => {
|
||||||
|
expect(await propose(proposal('a1'))).not.toBeNull();
|
||||||
|
expect(await propose(proposal('a2'))).toBeNull();
|
||||||
|
expect(
|
||||||
|
await propose({
|
||||||
|
...proposal('a3'),
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
intent: 'QUEEN_ACCEPTANCE',
|
||||||
|
plan: null,
|
||||||
|
}),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stopped by an open task of the same intent', async () => {
|
||||||
|
const first = (await propose(proposal('a1')))!;
|
||||||
|
await service.accept(first.id, { dueDate: '2026-05-12' }, userId);
|
||||||
|
// The accepted plan left open tasks of this intent.
|
||||||
|
expect(await propose(proposal('a2'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expireDue frees the key, dismiss keeps it', async () => {
|
||||||
|
const dismissed = (await propose(proposal('d1')))!;
|
||||||
|
await service.dismiss(dismissed.id, 'nein', userId);
|
||||||
|
expect(await statusOf(dismissed.id)).toBe('DISMISSED');
|
||||||
|
expect(await propose(proposal('d1'))).toBeNull();
|
||||||
|
|
||||||
|
const expiring = (await propose({
|
||||||
|
...proposal('e1'),
|
||||||
|
expiresAt: '2026-05-01',
|
||||||
|
}))!;
|
||||||
|
expect(await service.expireDue('2026-05-02')).toBeGreaterThanOrEqual(1);
|
||||||
|
const expired = await prisma.suggestion.findUniqueOrThrow({
|
||||||
|
where: { id: expiring.id },
|
||||||
|
});
|
||||||
|
expect(expired.status).toBe('EXPIRED');
|
||||||
|
expect(expired.dedupeKey).toBeNull();
|
||||||
|
expect(await propose(proposal('e1'))).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveIn frees the key in the same statement', async () => {
|
||||||
|
const created = (await propose(proposal('r1')))!;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await prisma.$transaction((tx) =>
|
||||||
|
service.resolveIn(tx, [created.id], 'Bedingung entfällt'),
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
const resolved = await prisma.suggestion.findUniqueOrThrow({
|
||||||
|
where: { id: created.id },
|
||||||
|
});
|
||||||
|
expect(resolved).toMatchObject({
|
||||||
|
status: 'RESOLVED',
|
||||||
|
dedupeKey: null,
|
||||||
|
decisionNote: 'Bedingung entfällt',
|
||||||
|
});
|
||||||
|
expect(await propose(proposal('r1'))).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accept creates the plan tasks shifted to the chosen day', async () => {
|
||||||
|
const created = (await propose(proposal('p1')))!;
|
||||||
|
|
||||||
|
const result = await service.accept(
|
||||||
|
created.id,
|
||||||
|
{ dueDate: '2026-05-12' },
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.tasks.map((t) => t.dueDate?.toISOString().slice(0, 10)),
|
||||||
|
).toEqual(['2026-05-12', '2026-05-24']);
|
||||||
|
expect(result.tasks[1].description).toContain('bis 27.05.');
|
||||||
|
expect(result.tasks.every((t) => t.sourceSuggestionId === created.id)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(await statusOf(created.id)).toBe('ACCEPTED');
|
||||||
|
await expect(service.accept(created.id, {}, userId)).rejects.toMatchObject({
|
||||||
|
response: { status: 'ACCEPTED' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulkAccept rolls everything back when one suggestion is not open', async () => {
|
||||||
|
const open = (await propose(proposal('b1')))!;
|
||||||
|
const other = (await propose({
|
||||||
|
...proposal('b2'),
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
intent: 'QUEEN_ACCEPTANCE',
|
||||||
|
plan: null,
|
||||||
|
dueDate: '2026-05-20',
|
||||||
|
}))!;
|
||||||
|
await service.dismiss(other.id, null, userId);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.bulkAccept([open.id, other.id], userId),
|
||||||
|
).rejects.toMatchObject({ status: 409 });
|
||||||
|
|
||||||
|
expect(await statusOf(open.id)).toBe('OPEN');
|
||||||
|
expect(
|
||||||
|
await prisma.task.count({ where: { sourceSuggestionId: open.id } }),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens within 30 days and answers 409 after that', async () => {
|
||||||
|
const created = (await propose(proposal('o1')))!;
|
||||||
|
await service.dismiss(created.id, null, userId);
|
||||||
|
|
||||||
|
const reopened = await service.reopen(created.id, userId);
|
||||||
|
expect(reopened).toMatchObject({ status: 'OPEN', decidedAt: null });
|
||||||
|
|
||||||
|
await service.dismiss(created.id, null, userId);
|
||||||
|
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`;
|
||||||
|
await expect(service.reopen(created.id, userId)).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
expect(await statusOf(created.id)).toBe('DISMISSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to reopen while the same intent is open again', async () => {
|
||||||
|
const dismissed = (await propose(proposal('q1')))!;
|
||||||
|
await service.dismiss(dismissed.id, null, userId);
|
||||||
|
expect(await propose(proposal('q2'))).not.toBeNull();
|
||||||
|
|
||||||
|
await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
expect(await statusOf(dismissed.id)).toBe('DISMISSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 400 for an unknown or inactive assignee, never 500', async () => {
|
||||||
|
const created = (await propose(proposal('u1')))!;
|
||||||
|
const inactive = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `suggestions-e2e-inactive-${suffix}@example.com`,
|
||||||
|
passwordHash: 'x',
|
||||||
|
name: 'Inactive',
|
||||||
|
isActive: false,
|
||||||
|
role: { connect: { key: 'STAFF' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const assignedUserId of ['no-such-user', inactive.id]) {
|
||||||
|
await expect(
|
||||||
|
service.accept(created.id, { assignedUserId }, userId),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
}
|
||||||
|
expect(await statusOf(created.id)).toBe('OPEN');
|
||||||
|
const ok = await service.accept(
|
||||||
|
created.id,
|
||||||
|
{ assignedUserId: userId },
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
expect(ok.tasks.every((t) => t.assignedUserId === userId)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await prisma.task.deleteMany({
|
||||||
|
where: { sourceSuggestionId: created.id },
|
||||||
|
});
|
||||||
|
await prisma.user.delete({ where: { id: inactive.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('concurrency', () => {
|
||||||
|
it('accept against dismiss on one suggestion: one wins, the other gets 409 with the status', async () => {
|
||||||
|
const created = (await propose(proposal('c1')))!;
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled([
|
||||||
|
service.accept(created.id, { dueDate: '2026-05-12' }, userId),
|
||||||
|
service.dismiss(created.id, null, userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
|
||||||
|
const [failure] = settled.filter(
|
||||||
|
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
||||||
|
);
|
||||||
|
expect(failure.reason).toMatchObject({ status: 409 });
|
||||||
|
const winner = await statusOf(created.id);
|
||||||
|
expect(['ACCEPTED', 'DISMISSED']).toContain(winner);
|
||||||
|
expect(
|
||||||
|
(failure.reason as { response: { status: string } }).response.status,
|
||||||
|
).toBe(winner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two proposals for one colony and intent from different rules leave one row', async () => {
|
||||||
|
const results = await Promise.all([
|
||||||
|
propose({ ...proposal('k1'), ruleKey: 'SPLIT_SCHEDULE' }),
|
||||||
|
propose({
|
||||||
|
...proposal('k2'),
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
source: 'RULE',
|
||||||
|
}),
|
||||||
|
propose({
|
||||||
|
...proposal('k3'),
|
||||||
|
ruleKey: 'INSPECTION_INTERVAL',
|
||||||
|
source: 'AUTOPILOT',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results.filter((r) => r !== null)).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
await prisma.suggestion.count({
|
||||||
|
where: { colonyId, intent: 'SPLIT_PLAN', status: 'OPEN' },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overlapping bulkAccept and accept do not deadlock and stay consistent', async () => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const [index, intent] of (
|
||||||
|
['SPLIT_PLAN', 'QUEEN_ACCEPTANCE', 'FEED'] as const
|
||||||
|
).entries()) {
|
||||||
|
const created = await propose({
|
||||||
|
...proposal(`ov${index}`),
|
||||||
|
ruleKey:
|
||||||
|
intent === 'SPLIT_PLAN'
|
||||||
|
? 'SPLIT_SCHEDULE'
|
||||||
|
: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
intent,
|
||||||
|
plan: null,
|
||||||
|
dueDate: '2026-05-20',
|
||||||
|
});
|
||||||
|
ids.push(created!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled([
|
||||||
|
service.bulkAccept([ids[0], ids[1], ids[2]], userId),
|
||||||
|
service.accept(ids[1], {}, userId),
|
||||||
|
service.bulkAccept([ids[2], ids[1]], userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const result of settled) {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
expect(result.reason).toMatchObject({ status: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(settled.some((r) => r.status === 'fulfilled')).toBe(true);
|
||||||
|
for (const id of ids) {
|
||||||
|
// Accepted exactly once, or not at all: never a task without it.
|
||||||
|
const accepted = (await statusOf(id)) === 'ACCEPTED';
|
||||||
|
expect(
|
||||||
|
await prisma.task.count({ where: { sourceSuggestionId: id } }),
|
||||||
|
).toBe(accepted ? 1 : 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 409 for accept and reopen once the colony has ended', async () => {
|
||||||
|
const open = (await propose(proposal('end1')))!;
|
||||||
|
const dismissed = (await propose({
|
||||||
|
...proposal('end2'),
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
intent: 'QUEEN_ACCEPTANCE',
|
||||||
|
plan: null,
|
||||||
|
dueDate: '2026-05-20',
|
||||||
|
}))!;
|
||||||
|
await service.dismiss(dismissed.id, null, userId);
|
||||||
|
await prisma.colony.update({
|
||||||
|
where: { id: colonyId },
|
||||||
|
data: { status: 'SOLD' },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(service.accept(open.id, {}, userId)).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({
|
||||||
|
status: 409,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await prisma.colony.update({
|
||||||
|
where: { id: colonyId },
|
||||||
|
data: { status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proposes nothing for a colony that does not exist', async () => {
|
||||||
|
expect(
|
||||||
|
await propose({ ...proposal('nc1'), colonyId: 'no-such-colony' }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulkAccept answers 400 naming the DO suggestions without a date', async () => {
|
||||||
|
const undated = (await propose({
|
||||||
|
...proposal('bd1'),
|
||||||
|
mode: 'DO',
|
||||||
|
plan: null,
|
||||||
|
dueDate: null,
|
||||||
|
}))!;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.bulkAccept([undated.id], userId),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
response: { issues: [{ field: 'ids', ids: [undated.id] }] },
|
||||||
|
});
|
||||||
|
expect(await statusOf(undated.id)).toBe('OPEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('obsoleteForColonyIn closes open suggestions and keeps the key', async () => {
|
||||||
|
const created = (await propose(proposal('x1')))!;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await prisma.$transaction((tx) =>
|
||||||
|
service.obsoleteForColonyIn(tx, colonyId),
|
||||||
|
),
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
const obsolete = await prisma.suggestion.findUniqueOrThrow({
|
||||||
|
where: { id: created.id },
|
||||||
|
});
|
||||||
|
expect(obsolete.status).toBe('OBSOLETE');
|
||||||
|
expect(obsolete.dedupeKey).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
ScratchDatabase,
|
||||||
|
createScratchDatabase,
|
||||||
|
} from './utils/migration-harness';
|
||||||
|
|
||||||
|
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
|
||||||
|
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
|
||||||
|
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
interface InspectionSeed {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
feedStatus: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal FK targets, written as raw SQL (Prisma ids and updatedAt have no DB default). */
|
||||||
|
async function seedColony(db: ScratchDatabase): Promise<void> {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
||||||
|
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt")
|
||||||
|
VALUES ('user-1', '[email protected]', 'x', 'Mig', 'role-1', now())`,
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "Apiary" ("id", "name", "updatedAt")
|
||||||
|
VALUES ('apiary-1', 'Stand', now())`,
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
|
||||||
|
VALUES ('colony-1', 'V-1', 'apiary-1', 'PRODUCTION_COLONY', now())`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedInspections(
|
||||||
|
db: ScratchDatabase,
|
||||||
|
seeds: InspectionSeed[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const seed of seeds) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "Inspection" ("id", "colonyId", "userId", "date", "feedStatus", "notes", "createdAt")
|
||||||
|
VALUES ($1, 'colony-1', 'user-1', '2026-05-01', $2, $3, $4)`,
|
||||||
|
[seed.id, seed.feedStatus, seed.notes, seed.createdAt],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('T4 schema II migrations (e2e)', () => {
|
||||||
|
let db: ScratchDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (db) {
|
||||||
|
await db.drop();
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
'free-text feed moves into notes and known words map to the enum',
|
||||||
|
async () => {
|
||||||
|
db = await createScratchDatabase(FEED_VOCABULARY);
|
||||||
|
await seedColony(db);
|
||||||
|
await seedInspections(db, [
|
||||||
|
{
|
||||||
|
id: 'i-1',
|
||||||
|
createdAt: '2026-05-01T10:00:00Z',
|
||||||
|
feedStatus: 'niedrig',
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'i-2',
|
||||||
|
createdAt: '2026-05-01T11:00:00Z',
|
||||||
|
feedStatus: 'halb voll',
|
||||||
|
notes: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'i-3',
|
||||||
|
createdAt: '2026-05-01T12:00:00Z',
|
||||||
|
feedStatus: 'halb voll',
|
||||||
|
notes: 'x',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.migrateThrough(FEED_VOCABULARY);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.query(
|
||||||
|
'SELECT "feedStatus", notes FROM "Inspection" ORDER BY "createdAt"',
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ feedStatus: 'WEAK', notes: null },
|
||||||
|
{ feedStatus: null, notes: 'Futter (alt): halb voll' },
|
||||||
|
{ feedStatus: null, notes: 'x\nFutter (alt): halb voll' },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'leaves no inspection whose old feed text was lost',
|
||||||
|
async () => {
|
||||||
|
db = await createScratchDatabase(FEED_VOCABULARY);
|
||||||
|
await seedColony(db);
|
||||||
|
const rawSeeds: [string | null, string | null][] = [
|
||||||
|
[' Ausreichend ', null],
|
||||||
|
['KRITISCH', 'Hinweis'],
|
||||||
|
['Niedrig', ''],
|
||||||
|
['sehr viel', 'a'],
|
||||||
|
[' Wabe voll ', null],
|
||||||
|
['', 'leer'],
|
||||||
|
[' ', null],
|
||||||
|
[null, 'ohne'],
|
||||||
|
['\tniedrig', null],
|
||||||
|
['\r\n Halb\tvoll\n', null],
|
||||||
|
];
|
||||||
|
const seeds: InspectionSeed[] = rawSeeds.map(
|
||||||
|
([feedStatus, notes], index) => ({
|
||||||
|
id: `i-${index}`,
|
||||||
|
createdAt: `2026-05-01T0${index}:00:00Z`,
|
||||||
|
feedStatus,
|
||||||
|
notes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await seedInspections(db, seeds);
|
||||||
|
|
||||||
|
await db.migrateThrough(FEED_VOCABULARY);
|
||||||
|
|
||||||
|
const rows = await db.query<{
|
||||||
|
id: string;
|
||||||
|
feedStatus: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}>('SELECT id, "feedStatus", notes FROM "Inspection" ORDER BY id');
|
||||||
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||||
|
|
||||||
|
// Spec 4.12: every old text ends up as an enum value or in the notes.
|
||||||
|
const known = ['ausreichend', 'niedrig', 'kritisch'];
|
||||||
|
for (const seed of seeds) {
|
||||||
|
const text = seed.feedStatus?.trim() ?? '';
|
||||||
|
if (text === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const row = byId.get(seed.id)!;
|
||||||
|
if (known.includes(text.toLowerCase())) {
|
||||||
|
expect(row.feedStatus).not.toBeNull();
|
||||||
|
} else {
|
||||||
|
expect(row.feedStatus).toBeNull();
|
||||||
|
expect(row.notes).toContain(`Futter (alt): ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
rows.filter(
|
||||||
|
(row) => row.feedStatus === null && (row.notes ?? '') === '',
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
// Only the blank text with no notes remains empty - nothing was lost.
|
||||||
|
{ id: 'i-6', feedStatus: null, notes: null },
|
||||||
|
]);
|
||||||
|
expect(byId.get('i-1')?.notes).toBe('Hinweis');
|
||||||
|
expect(byId.get('i-3')?.notes).toBe('a\nFutter (alt): sehr viel');
|
||||||
|
expect(byId.get('i-5')?.notes).toBe('leer');
|
||||||
|
expect(byId.get('i-7')?.notes).toBe('ohne');
|
||||||
|
expect(byId.get('i-8')).toMatchObject({
|
||||||
|
feedStatus: 'WEAK',
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
expect(byId.get('i-9')).toMatchObject({
|
||||||
|
feedStatus: null,
|
||||||
|
notes: 'Futter (alt): Halb\tvoll',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'adds the optional split queen source and varroa sample size without touching old rows',
|
||||||
|
async () => {
|
||||||
|
db = await createScratchDatabase(SPLIT_QUEEN_SOURCE);
|
||||||
|
await seedColony(db);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
|
||||||
|
VALUES ('colony-2', 'V-2', 'apiary-1', 'NUCLEUS_COLONY', now())`,
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "ColonyLineage" ("id", "type", "fromColonyId", "toColonyId", "date")
|
||||||
|
VALUES ('l-1', 'SPLIT', 'colony-1', 'colony-2', '2026-05-01')`,
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "VarroaMeasurement" ("id", "colonyId", "userId", "date", "method", "miteCount")
|
||||||
|
VALUES ('v-1', 'colony-1', 'user-1', '2026-05-02', 'WASHING_METHOD', 4)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.migrateThrough(SPLIT_QUEEN_SOURCE);
|
||||||
|
await db.migrateThrough(VARROA_SAMPLE_SIZE);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.query('SELECT "queenSource" FROM "ColonyLineage"'),
|
||||||
|
).toEqual([{ queenSource: null }]);
|
||||||
|
expect(
|
||||||
|
await db.query(
|
||||||
|
'SELECT "sampleBees", "miteCount" FROM "VarroaMeasurement"',
|
||||||
|
),
|
||||||
|
).toEqual([{ sampleBees: null, miteCount: 4 }]);
|
||||||
|
await db.query(
|
||||||
|
`UPDATE "ColonyLineage" SET "queenSource" = 'RAISE_OWN' WHERE id = 'l-1'`,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
db.query(
|
||||||
|
`UPDATE "ColonyLineage" SET "queenSource" = 'BOUGHT' WHERE id = 'l-1'`,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
},
|
||||||
|
TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { Prisma } from '../generated/prisma';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
lockRowForUpdate,
|
||||||
|
lockRowsForShare,
|
||||||
|
lockRowsForUpdate,
|
||||||
|
} from '../src/prisma/row-lock';
|
||||||
|
import {
|
||||||
|
DEFAULT_RULES,
|
||||||
|
lockRuleIn,
|
||||||
|
readRule,
|
||||||
|
} from '../src/automation/rule-config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema-level guarantees of the T4 suggestion migrations that the Prisma
|
||||||
|
* schema alone cannot express (nullable unique dedupe key, enum values the
|
||||||
|
* database really accepts) and the row locks against a real PostgreSQL.
|
||||||
|
*/
|
||||||
|
describe('T4 schema I (e2e)', () => {
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
const createdSuggestionIds: string[] = [];
|
||||||
|
const createdTaskIds: string[] = [];
|
||||||
|
|
||||||
|
const suggestionData = (
|
||||||
|
overrides: Partial<Prisma.SuggestionCreateManyInput> = {},
|
||||||
|
): Prisma.SuggestionCreateManyInput => ({
|
||||||
|
ruleKey: 'INSPECTION_INTERVAL',
|
||||||
|
source: 'RULE',
|
||||||
|
mode: 'DO',
|
||||||
|
intent: 'INSPECT',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
reasonCode: 'T4_SCHEMA_TEST',
|
||||||
|
evidence: { colonies: 1 },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
prisma = new PrismaService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.task.deleteMany({ where: { id: { in: createdTaskIds } } });
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: { in: createdSuggestionIds } },
|
||||||
|
{ reasonCode: `T4_SCHEMA_TEST_${suffix}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps at most one suggestion per dedupe key', async () => {
|
||||||
|
const data = suggestionData({
|
||||||
|
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
|
||||||
|
dedupeKey: `INSPECTION_INTERVAL:none:t-${suffix}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await prisma.suggestion.createMany({
|
||||||
|
data: [data],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
const second = await prisma.suggestion.createMany({
|
||||||
|
data: [data],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.count).toBe(1);
|
||||||
|
expect(second.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets any number of suggestions share a missing dedupe key', async () => {
|
||||||
|
const reasonCode = `T4_SCHEMA_TEST_${suffix}`;
|
||||||
|
const result = await prisma.suggestion.createMany({
|
||||||
|
data: [
|
||||||
|
suggestionData({ reasonCode, dedupeKey: null }),
|
||||||
|
suggestionData({ reasonCode, dedupeKey: null }),
|
||||||
|
],
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a suggestion OPEN with normal priority', async () => {
|
||||||
|
const created = await prisma.suggestion.create({
|
||||||
|
data: suggestionData({
|
||||||
|
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
createdSuggestionIds.push(created.id);
|
||||||
|
|
||||||
|
expect(created.status).toBe('OPEN');
|
||||||
|
expect(created.priority).toBe('NORMAL');
|
||||||
|
expect(created.dedupeKey).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a task when its source suggestion is deleted', async () => {
|
||||||
|
const suggestion = await prisma.suggestion.create({
|
||||||
|
data: suggestionData({
|
||||||
|
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
data: { title: `T4 task ${suffix}`, sourceSuggestionId: suggestion.id },
|
||||||
|
});
|
||||||
|
createdTaskIds.push(task.id);
|
||||||
|
|
||||||
|
await prisma.suggestion.delete({ where: { id: suggestion.id } });
|
||||||
|
|
||||||
|
const after = await prisma.task.findUniqueOrThrow({
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
expect(after.sourceSuggestionId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the new enum values', async () => {
|
||||||
|
const rows = await prisma.$queryRaw<
|
||||||
|
{ enum: string; values: string[] }[]
|
||||||
|
>`SELECT t.typname AS enum, array_agg(e.enumlabel::text) AS values
|
||||||
|
FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid
|
||||||
|
WHERE t.typname IN ('AutomationTriggerType', 'BatchMovementType', 'CalendarSyncSourceType')
|
||||||
|
GROUP BY t.typname`;
|
||||||
|
const valuesOf = (name: string) =>
|
||||||
|
rows.find((row) => row.enum === name)?.values ?? [];
|
||||||
|
|
||||||
|
expect(valuesOf('AutomationTriggerType')).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'INSPECTION_INTERVAL',
|
||||||
|
'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
'SPLIT_SCHEDULE',
|
||||||
|
'COLONY_ENDED_CLEANUP',
|
||||||
|
'WITHDRAWAL_ENDED',
|
||||||
|
'HONEY_WATER_CONTENT',
|
||||||
|
'SEASON_AUTOPILOT',
|
||||||
|
'PHENOLOGY',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(valuesOf('BatchMovementType')).toContain('BOTTLING');
|
||||||
|
expect(valuesOf('CalendarSyncSourceType')).toContain('WITHDRAWAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('row locks on the new tables', () => {
|
||||||
|
it('locks a suggestion by id and reports a missing one', async () => {
|
||||||
|
const suggestion = await prisma.suggestion.create({
|
||||||
|
data: suggestionData({
|
||||||
|
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
createdSuggestionIds.push(suggestion.id);
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const existing = await lockRowForUpdate(
|
||||||
|
tx,
|
||||||
|
'Suggestion',
|
||||||
|
suggestion.id,
|
||||||
|
);
|
||||||
|
await lockRowsForUpdate(tx, 'Suggestion', [suggestion.id, 'nope']);
|
||||||
|
await lockRowsForShare(tx, 'Suggestion', [suggestion.id]);
|
||||||
|
const missing = await lockRowForUpdate(tx, 'Suggestion', 'nope');
|
||||||
|
return { existing, missing };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ existing: true, missing: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a missing rule from the defaults, locks it and reads it parsed', async () => {
|
||||||
|
await prisma.automationRule.deleteMany({
|
||||||
|
where: { triggerType: 'COLONY_ENDED_CLEANUP' },
|
||||||
|
});
|
||||||
|
const before = await readRule(prisma, 'COLONY_ENDED_CLEANUP');
|
||||||
|
expect(before.id).toBeNull();
|
||||||
|
expect(before.active).toBe(true);
|
||||||
|
|
||||||
|
const locked = await prisma.$transaction((tx) =>
|
||||||
|
lockRuleIn(tx, 'COLONY_ENDED_CLEANUP'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(locked.active).toBe(true);
|
||||||
|
expect(locked.config).toEqual({});
|
||||||
|
expect(locked.actionTemplate).toBe(
|
||||||
|
DEFAULT_RULES.COLONY_ENDED_CLEANUP.actionTemplate,
|
||||||
|
);
|
||||||
|
const after = await readRule(prisma, 'COLONY_ENDED_CLEANUP');
|
||||||
|
expect(after.id).toBe(locked.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an existing rule as it is', async () => {
|
||||||
|
const rule = await prisma.automationRule.upsert({
|
||||||
|
where: { triggerType: 'WITHDRAWAL_ENDED' },
|
||||||
|
create: {
|
||||||
|
triggerType: 'WITHDRAWAL_ENDED',
|
||||||
|
active: false,
|
||||||
|
config: { notify: false },
|
||||||
|
actionTemplate: '',
|
||||||
|
},
|
||||||
|
update: { active: false, config: { notify: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const locked = await prisma.$transaction((tx) =>
|
||||||
|
lockRuleIn(tx, 'WITHDRAWAL_ENDED'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(locked.id).toBe(rule.id);
|
||||||
|
expect(locked.active).toBe(false);
|
||||||
|
expect(locked.config).toEqual({ notify: false });
|
||||||
|
|
||||||
|
await prisma.automationRule.update({
|
||||||
|
where: { id: rule.id },
|
||||||
|
data: { active: true, config: { notify: true } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { Client } from 'pg';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const BACKEND_ROOT = path.resolve(__dirname, '../..');
|
||||||
|
const MIGRATIONS_DIR = path.join(BACKEND_ROOT, 'prisma', 'migrations');
|
||||||
|
const SCHEMA_FILE = path.join(BACKEND_ROOT, 'prisma', 'schema.prisma');
|
||||||
|
const SCRATCH_ROOT = path.join(BACKEND_ROOT, '.tmp-migrations');
|
||||||
|
const PRISMA_CLI = path.join(
|
||||||
|
BACKEND_ROOT,
|
||||||
|
'node_modules',
|
||||||
|
'prisma',
|
||||||
|
'build',
|
||||||
|
'index.js',
|
||||||
|
);
|
||||||
|
const DEPLOY_TIMEOUT_MS = 100_000;
|
||||||
|
const SCHEMA_NAME_PATTERN = /^mig_[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
export interface ScratchDatabase {
|
||||||
|
readonly schema: string;
|
||||||
|
query<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params?: unknown[],
|
||||||
|
): Promise<T[]>;
|
||||||
|
/** Applies every migration up to and including the named one. */
|
||||||
|
migrateThrough(migrationName: string): Promise<void>;
|
||||||
|
drop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseConnectionString(): string {
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('DATABASE_URL is not set');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSchema(connectionString: string, schema: string): string {
|
||||||
|
const url = new URL(connectionString);
|
||||||
|
url.searchParams.set('schema', schema);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutSchemaParam(connectionString: string): string {
|
||||||
|
const url = new URL(connectionString);
|
||||||
|
url.searchParams.delete('schema');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMigrationNames(): Promise<string[]> {
|
||||||
|
const entries = await fs.readdir(MIGRATIONS_DIR, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a fresh Postgres schema and migrates it up to, but not including,
|
||||||
|
* `beforeMigration`. The migration folders are copied below
|
||||||
|
* `.tmp-migrations/` inside the backend so that the prisma config resolves
|
||||||
|
* the installed `prisma` package.
|
||||||
|
*/
|
||||||
|
export async function createScratchDatabase(
|
||||||
|
beforeMigration: string,
|
||||||
|
): Promise<ScratchDatabase> {
|
||||||
|
const names = await listMigrationNames();
|
||||||
|
const target = names.indexOf(beforeMigration);
|
||||||
|
if (target < 0) {
|
||||||
|
throw new Error(`Unknown migration: ${beforeMigration}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = randomBytes(6).toString('hex');
|
||||||
|
const schema = `mig_${id}`;
|
||||||
|
if (!SCHEMA_NAME_PATTERN.test(schema)) {
|
||||||
|
throw new Error('Unexpected scratch schema name');
|
||||||
|
}
|
||||||
|
const workDir = path.join(SCRATCH_ROOT, id);
|
||||||
|
const scratchUrl = withSchema(baseConnectionString(), schema);
|
||||||
|
|
||||||
|
const admin = new Client({
|
||||||
|
connectionString: withoutSchemaParam(baseConnectionString()),
|
||||||
|
});
|
||||||
|
await admin.connect();
|
||||||
|
await admin.query(`CREATE SCHEMA "${schema}"`);
|
||||||
|
|
||||||
|
await fs.mkdir(workDir, { recursive: true });
|
||||||
|
await fs.copyFile(
|
||||||
|
path.join(MIGRATIONS_DIR, 'migration_lock.toml'),
|
||||||
|
path.join(workDir, 'migration_lock.toml'),
|
||||||
|
);
|
||||||
|
// The URL reaches the config through the environment, never through disk.
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(workDir, 'prisma.config.ts'),
|
||||||
|
[
|
||||||
|
"import { defineConfig } from 'prisma/config';",
|
||||||
|
'',
|
||||||
|
'export default defineConfig({',
|
||||||
|
` schema: ${JSON.stringify(SCHEMA_FILE)},`,
|
||||||
|
` migrations: { path: ${JSON.stringify(workDir)} },`,
|
||||||
|
" datasource: { url: process.env['MIGRATION_HARNESS_URL'] },",
|
||||||
|
'});',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const copied = new Set<string>();
|
||||||
|
const deploy = async (upToExclusive: number): Promise<void> => {
|
||||||
|
for (const name of names.slice(0, upToExclusive)) {
|
||||||
|
if (!copied.has(name)) {
|
||||||
|
await fs.cp(path.join(MIGRATIONS_DIR, name), path.join(workDir, name), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
copied.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
PRISMA_CLI,
|
||||||
|
'migrate',
|
||||||
|
'deploy',
|
||||||
|
'--config',
|
||||||
|
path.join(workDir, 'prisma.config.ts'),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: BACKEND_ROOT,
|
||||||
|
env: { ...process.env, MIGRATION_HARNESS_URL: scratchUrl },
|
||||||
|
timeout: DEPLOY_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let client: Client | null = null;
|
||||||
|
const connected = async (): Promise<Client> => {
|
||||||
|
if (!client) {
|
||||||
|
client = new Client({
|
||||||
|
connectionString: withoutSchemaParam(baseConnectionString()),
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
await client.query(`SET search_path TO "${schema}"`);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deploy(target);
|
||||||
|
} catch (error) {
|
||||||
|
await fs.rm(workDir, { recursive: true, force: true });
|
||||||
|
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
||||||
|
await admin.end();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema,
|
||||||
|
async query<T = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const active = await connected();
|
||||||
|
const result = await active.query(sql, params);
|
||||||
|
return result.rows as T[];
|
||||||
|
},
|
||||||
|
async migrateThrough(migrationName: string): Promise<void> {
|
||||||
|
const index = names.indexOf(migrationName);
|
||||||
|
if (index < 0) {
|
||||||
|
throw new Error(`Unknown migration: ${migrationName}`);
|
||||||
|
}
|
||||||
|
await deploy(index + 1);
|
||||||
|
},
|
||||||
|
async drop(): Promise<void> {
|
||||||
|
if (client) {
|
||||||
|
await client.end();
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
||||||
|
await admin.end();
|
||||||
|
await fs.rm(workDir, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { AutomationRulesTab } from "./automation-rules-tab";
|
||||||
|
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
|
||||||
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
|
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
vi.mock("@/lib/automation/api", () => ({
|
||||||
|
fetchAutomationRules: vi.fn(),
|
||||||
|
updateAutomationRule: vi.fn(),
|
||||||
|
resetAutomationRuleSequence: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function rule(
|
||||||
|
triggerType: AutomationTriggerType,
|
||||||
|
config: Record<string, unknown> | null,
|
||||||
|
active = true,
|
||||||
|
): AutomationRule {
|
||||||
|
return {
|
||||||
|
id: `rule-${triggerType}`,
|
||||||
|
triggerType,
|
||||||
|
active,
|
||||||
|
config,
|
||||||
|
actionTemplate: null,
|
||||||
|
sequenceValue: 0,
|
||||||
|
createdAt: "2026-10-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-10-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTab(rules: AutomationRule[]) {
|
||||||
|
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
|
||||||
|
vi.mocked(updateAutomationRule).mockImplementation(
|
||||||
|
async (id, input) =>
|
||||||
|
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
|
||||||
|
);
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<AutomationRulesTab />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function card(title: string) {
|
||||||
|
const heading = await screen.findByText(title);
|
||||||
|
return within(heading.closest("[data-slot='card']") as HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests read nested keys of what was sent; the shape is checked by the matchers.
|
||||||
|
type LooseConfig = ReturnType<typeof JSON.parse>;
|
||||||
|
|
||||||
|
function savedConfig(): LooseConfig {
|
||||||
|
return vi.mocked(updateAutomationRule).mock.calls[0][1].config as LooseConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARROA = rule("VARROA_THRESHOLD", { thresholdMitesPerDay: 10 });
|
||||||
|
const VARROA_TITLE = "Varroa-Schwellenwert überschritten";
|
||||||
|
const RED_LABEL = /Rot ab mehr als/;
|
||||||
|
|
||||||
|
/** The red limit of the natural mite fall, found by the group it sits in. */
|
||||||
|
function naturalFallRed(view: ReturnType<typeof within>) {
|
||||||
|
const group = view.getByRole("group", { name: /Natürlicher Milbenfall/ });
|
||||||
|
return within(group).getByLabelText(RED_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe("varroa rule form", () => {
|
||||||
|
it("the varroa form saves bands and shows „LWG Veitshöchheim“ next to the values", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([VARROA]);
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
|
||||||
|
expect(view.getAllByText(/LWG Veitshöchheim/).length).toBeGreaterThan(0);
|
||||||
|
// The old single threshold shows up as the red limit of the natural fall.
|
||||||
|
const red = naturalFallRed(view);
|
||||||
|
expect(red).toHaveValue("10");
|
||||||
|
|
||||||
|
await user.clear(red);
|
||||||
|
await user.type(red, "12,5");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(vi.mocked(updateAutomationRule).mock.calls[0][0]).toBe("rule-VARROA_THRESHOLD");
|
||||||
|
const config = savedConfig();
|
||||||
|
expect(config.naturalFall).toMatchObject({ redAbove: 12.5, yellowFrom: 5, winterRedAbove: 0.5 });
|
||||||
|
expect(config.naturalFall.seasonMonths).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
|
||||||
|
expect(config.youngColonyTypes).toEqual(["YOUNG_COLONY", "NUCLEUS_COLONY", "MATING_NUCLEUS"]);
|
||||||
|
expect(config.notify).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 400 issue appears under its field", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([VARROA]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValue(
|
||||||
|
new ApiError(400, "Die Einstellung ist ungültig.", {
|
||||||
|
message: "Die Einstellung ist ungültig.",
|
||||||
|
issues: [{ field: "naturalFall.redAbove", message: "Gelb darf nicht über Rot liegen." }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
const message = await view.findByText("Gelb darf nicht über Rot liegen.");
|
||||||
|
const field = naturalFallRed(view);
|
||||||
|
expect(field.getAttribute("aria-describedby")).toContain(message.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send a value that is not a number", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([VARROA]);
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
const red = naturalFallRed(view);
|
||||||
|
await user.clear(red);
|
||||||
|
await user.type(red, "abc");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Bitte eine Zahl angeben.")).toBeInTheDocument();
|
||||||
|
expect(updateAutomationRule).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("season autopilot form", () => {
|
||||||
|
it("autopilot rules A1–A10 switch individually", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("SEASON_AUTOPILOT", { honeySuperMinFrames: 9 })]);
|
||||||
|
const view = await card("Saison-Autopilot");
|
||||||
|
|
||||||
|
const switches = view.getAllByRole("checkbox", { name: /^A\d+ / });
|
||||||
|
expect(switches).toHaveLength(10);
|
||||||
|
switches.forEach((box) => expect(box).toBeChecked());
|
||||||
|
|
||||||
|
await user.click(view.getByRole("checkbox", { name: /^A3 / }));
|
||||||
|
expect(view.getByRole("checkbox", { name: /^A3 / })).not.toBeChecked();
|
||||||
|
expect(view.getByRole("checkbox", { name: /^A4 / })).toBeChecked();
|
||||||
|
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
const config = savedConfig();
|
||||||
|
expect(config.rules.A3).toBe(false);
|
||||||
|
expect(Object.values(config.rules).filter(Boolean)).toHaveLength(9);
|
||||||
|
expect(config.honeySuperMinFrames).toBe(9);
|
||||||
|
expect(config.winterVarroaWindow).toEqual({ from: "11-15", to: "12-20" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows server issues of the MM-DD fields under them", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("SEASON_AUTOPILOT", null)]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValue(
|
||||||
|
new ApiError(400, "x", {
|
||||||
|
issues: [{ field: "winterVarroaWindow.from", message: "Bitte Monat und Tag als MM-TT angeben." }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = await card("Saison-Autopilot");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Bitte Monat und Tag als MM-TT angeben.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("other rule forms", () => {
|
||||||
|
it("phenology saves calendar fallbacks as MM-DD", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("PHENOLOGY", null)]);
|
||||||
|
const view = await card("Pflanzenentwicklung");
|
||||||
|
const salweide = view.getByLabelText(/^Salweide blüht/);
|
||||||
|
expect(salweide).toHaveValue("03-20");
|
||||||
|
await user.clear(salweide);
|
||||||
|
await user.type(salweide, "03-25");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
const config = savedConfig();
|
||||||
|
expect(config.calendarFallback.SALWEIDE).toBe("03-25");
|
||||||
|
expect(config.calendarFallback.WINTER).toBe("11-15");
|
||||||
|
expect(config.gtsThresholds).toEqual({ SALWEIDE: 200 });
|
||||||
|
expect(config.swarmSeason).toEqual({ from: "KIRSCHE", until: "LINDE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inspection intervals save per section", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("INSPECTION_INTERVAL", null)]);
|
||||||
|
const view = await card("Durchsicht-Rhythmus");
|
||||||
|
await user.click(view.getByRole("button", { name: /\+.*Schwarmzeit/ }));
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(savedConfig().intervalDays).toEqual({
|
||||||
|
FRUEHJAHR: 14,
|
||||||
|
SCHWARMZEIT: 8,
|
||||||
|
SOMMER: 14,
|
||||||
|
SPAETSOMMER: 21,
|
||||||
|
HERBST: 21,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a chain rule saves the due days and the notify switch", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("QUEEN_CELLS_SWARM_CHECK", { dueInDays: 7, notify: false })]);
|
||||||
|
const view = await card("Weiselzellen: Schwarmkontrolle");
|
||||||
|
await user.click(view.getByRole("checkbox", { name: "Zusätzlich melden" }));
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(savedConfig()).toEqual({ dueInDays: 7, notify: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the split schedule keeps its steps and saves the changed day", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("SPLIT_SCHEDULE", null)]);
|
||||||
|
const view = await card("Ableger-Fahrplan");
|
||||||
|
const day = view.getByLabelText(/^Begattung prüfen(?!:)/);
|
||||||
|
expect(day).toHaveValue("21");
|
||||||
|
await user.clear(day);
|
||||||
|
await user.type(day, "22");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
const config = savedConfig();
|
||||||
|
expect(config.RAISE_OWN[1]).toEqual({ step: "MATING_CHECK", offsetDays: 22, windowDays: 3 });
|
||||||
|
expect(config.QUEEN).toEqual([{ step: "ACCEPTANCE_CHECK", offsetDays: 7 }]);
|
||||||
|
expect(config.notify).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the water content form shows both sources and keeps the honey types", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([
|
||||||
|
rule("HONEY_WATER_CONTENT", {
|
||||||
|
warnAbovePercent: 18,
|
||||||
|
confirmAbovePercent: 20,
|
||||||
|
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const view = await card("Wassergehalt Honig");
|
||||||
|
expect(view.getAllByText(/D\.I\.B\./).length).toBeGreaterThan(0);
|
||||||
|
expect(view.getAllByText(/HonigV Anlage 2/).length).toBeGreaterThan(0);
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(savedConfig()).toEqual({
|
||||||
|
warnAbovePercent: 18,
|
||||||
|
confirmAbovePercent: 20,
|
||||||
|
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withdrawal ended offers only the notify switch", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("WITHDRAWAL_ENDED", { notify: true })]);
|
||||||
|
const view = await card("Wartezeit zu Ende");
|
||||||
|
await user.click(view.getByRole("checkbox", { name: "Zusätzlich melden" }));
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(vi.mocked(updateAutomationRule).mock.calls[0][1]).toEqual({ config: { notify: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an older rule keeps its notify setting when saved", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("QUEENLESS_NO_EGGS", { notify: true, dueInDays: 2 })]);
|
||||||
|
const view = await card("Keine Königin gesehen und keine Stifte");
|
||||||
|
expect(view.getByRole("checkbox", { name: "Zusätzlich melden" })).toBeChecked();
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(savedConfig()).toEqual({ notify: true, dueInDays: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,64 +8,28 @@ import {
|
|||||||
resetAutomationRuleSequence,
|
resetAutomationRuleSequence,
|
||||||
updateAutomationRule,
|
updateAutomationRule,
|
||||||
} from "@/lib/automation/api";
|
} from "@/lib/automation/api";
|
||||||
|
import {
|
||||||
|
RULE_DESCRIPTIONS,
|
||||||
|
RULE_LABELS,
|
||||||
|
SIMPLE_RULE_NOTIFY_DEFAULTS,
|
||||||
|
issuesByField,
|
||||||
|
} from "@/lib/automation/rule-configs";
|
||||||
import {
|
import {
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
AutomationTriggerType,
|
AutomationTriggerType,
|
||||||
UpdateAutomationRuleInput,
|
UpdateAutomationRuleInput,
|
||||||
} from "@/lib/automation/types";
|
} from "@/lib/automation/types";
|
||||||
|
import { errorMessage } from "@/lib/forms/error-message";
|
||||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||||
import { formatGeneratedNumber } from "@/lib/automation/number-format";
|
import { formatGeneratedNumber } from "@/lib/automation/number-format";
|
||||||
|
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { hasRuleForm, SpecialRuleCard } from "./special-rule-card";
|
||||||
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
|
|
||||||
QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte",
|
|
||||||
VARROA_THRESHOLD: "Varroa-Schwellenwert überschritten",
|
|
||||||
FEED_STATUS_CRITICAL: "Futterstatus kritisch",
|
|
||||||
SWARM_MOOD_HIGH: "Hohe Schwarmstimmung",
|
|
||||||
COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch",
|
|
||||||
STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand",
|
|
||||||
HONEY_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Honigchargen)",
|
|
||||||
BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)",
|
|
||||||
TREATMENT_FOLLOW_UP: "Kontrolle nach Behandlung",
|
|
||||||
FEEDING_FOLLOW_UP: "Kontrolle nach Fütterung",
|
|
||||||
RAW_MATERIAL_LOT_NUMBER_FORMAT: "Chargennummer-Format (Rohstoffe)",
|
|
||||||
PRODUCTION_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Herstellung)",
|
|
||||||
RAW_MATERIAL_LOW: "Rohstoff unter Mindestmenge",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
|
|
||||||
QUEENLESS_NO_EGGS:
|
|
||||||
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden, dann Kontrollaufgabe.",
|
|
||||||
VARROA_THRESHOLD:
|
|
||||||
"Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert, dann Behandlung prüfen.",
|
|
||||||
FEED_STATUS_CRITICAL:
|
|
||||||
"Futterstatus bei einer Durchsicht auf kritisch gesetzt, dann Fütterungsaufgabe.",
|
|
||||||
SWARM_MOOD_HIGH:
|
|
||||||
"Bei einer Schwarmkontrolle Schwarmstimmung hoch erfasst, dann Kontrollaufgabe.",
|
|
||||||
COLONY_HEALTH_CRITICAL:
|
|
||||||
"Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.",
|
|
||||||
STOCK_ITEM_LOW:
|
|
||||||
"Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.",
|
|
||||||
HONEY_BATCH_NUMBER_FORMAT:
|
|
||||||
"Wenn aktiv, wird die Chargennummer neuer Honigchargen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
|
|
||||||
BOTTLING_LOT_NUMBER_FORMAT:
|
|
||||||
"Wenn aktiv, wird die Los-/Chargennummer neuer Abfüllungen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
|
|
||||||
TREATMENT_FOLLOW_UP:
|
|
||||||
"Nach jeder erfassten Behandlung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach Behandlungsende. Die passende Frist hängt vom Mittel ab (Packungsbeilage) – deshalb ab Werk aus und ohne voreingestellte Frist.",
|
|
||||||
FEEDING_FOLLOW_UP:
|
|
||||||
"Nach jeder erfassten Fütterung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach der Fütterung. Ab Werk aus und ohne voreingestellte Frist.",
|
|
||||||
RAW_MATERIAL_LOT_NUMBER_FORMAT:
|
|
||||||
"Wenn aktiv, wird die Chargennummer neuer Rohstoff-Chargen (Wachs, Propolis) automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
|
|
||||||
PRODUCTION_BATCH_NUMBER_FORMAT:
|
|
||||||
"Wenn aktiv, wird die Chargennummer neuer Herstellungschargen (Kerzen, Tinkturen, …) automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
|
|
||||||
RAW_MATERIAL_LOW:
|
|
||||||
"Fällt der Gesamtbestand einer Rohstoff-Art nach Verbrauch, Entnahme oder Inventur unter die Mindestmenge, dann Aufgabe zum Nachschub. Leere Mindestmenge = diese Art wird nicht überwacht.",
|
|
||||||
};
|
|
||||||
|
|
||||||
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
|
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
|
||||||
"HONEY_BATCH_NUMBER_FORMAT",
|
"HONEY_BATCH_NUMBER_FORMAT",
|
||||||
@@ -124,15 +88,23 @@ function ruleDueInDays(rule: AutomationRule): string {
|
|||||||
return config?.dueInDays === undefined ? "" : String(config.dueInDays);
|
return config?.dueInDays === undefined ? "" : String(config.dueInDays);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ruleThreshold(rule: AutomationRule): string {
|
function ruleNotify(rule: AutomationRule): boolean {
|
||||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
const config = rule.config as { notify?: unknown } | null;
|
||||||
return String(config?.thresholdMitesPerDay ?? "");
|
return typeof config?.notify === "boolean"
|
||||||
|
? config.notify
|
||||||
|
: (SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType] ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleCard({ rule }: { rule: AutomationRule }) {
|
function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
|
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
|
||||||
const [threshold, setThreshold] = useState(ruleThreshold(rule));
|
const [notify, setNotify] = useState(ruleNotify(rule));
|
||||||
|
const [issues, setIssues] = useState<Record<string, string>>({});
|
||||||
|
// A server message goes away as soon as its field is edited.
|
||||||
|
const clearIssues = (prefix: string) =>
|
||||||
|
setIssues((current) =>
|
||||||
|
Object.fromEntries(Object.entries(current).filter(([field]) => !field.startsWith(prefix))),
|
||||||
|
);
|
||||||
const [format, setFormat] = useState(ruleFormat(rule));
|
const [format, setFormat] = useState(ruleFormat(rule));
|
||||||
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
|
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
|
||||||
const [minGrams, setMinGrams] = useState<MinGrams>(ruleMinGrams(rule));
|
const [minGrams, setMinGrams] = useState<MinGrams>(ruleMinGrams(rule));
|
||||||
@@ -147,10 +119,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
mutationFn: (input: UpdateAutomationRuleInput) =>
|
mutationFn: (input: UpdateAutomationRuleInput) =>
|
||||||
updateAutomationRule(rule.id, input),
|
updateAutomationRule(rule.id, input),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setIssues({});
|
||||||
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
|
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
|
||||||
toast.success("Regel gespeichert");
|
toast.success("Regel gespeichert");
|
||||||
},
|
},
|
||||||
onError: () => toast.error("Regel konnte nicht gespeichert werden"),
|
onError: (error) => {
|
||||||
|
setIssues(issuesByField(error));
|
||||||
|
toast.error(errorMessage(error, "Regel konnte nicht gespeichert werden"));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
@@ -173,10 +149,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
toast.error("Frist bitte als ganze Tage zwischen 0 und 365 angeben – oder leer lassen.");
|
toast.error("Frist bitte als ganze Tage zwischen 0 und 365 angeben – oder leer lassen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const config: Record<string, unknown> = { dueInDays: days };
|
const config: Record<string, unknown> = { notify, dueInDays: days };
|
||||||
if (rule.triggerType === "VARROA_THRESHOLD") {
|
|
||||||
config.thresholdMitesPerDay = Number(threshold) || 10;
|
|
||||||
}
|
|
||||||
if (rule.triggerType === "RAW_MATERIAL_LOW") {
|
if (rule.triggerType === "RAW_MATERIAL_LOW") {
|
||||||
const parsed = parseMinGrams(minGrams);
|
const parsed = parseMinGrams(minGrams);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
@@ -191,13 +164,13 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle>{TRIGGER_LABELS[rule.triggerType]}</CardTitle>
|
<CardTitle>{RULE_LABELS[rule.triggerType]}</CardTitle>
|
||||||
<Badge variant={rule.active ? "default" : "secondary"}>
|
<Badge variant={rule.active ? "default" : "secondary"}>
|
||||||
{rule.active ? "Aktiv" : "Inaktiv"}
|
{rule.active ? "Aktiv" : "Inaktiv"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4 text-sm">
|
<CardContent className="flex flex-col gap-4 text-sm">
|
||||||
<p className="text-muted-foreground">{TRIGGER_DESCRIPTIONS[rule.triggerType]}</p>
|
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
|
||||||
{numberFormatRule ? (
|
{numberFormatRule ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor={`format-${rule.id}`}>Format</Label>
|
<Label htmlFor={`format-${rule.id}`}>Format</Label>
|
||||||
@@ -205,7 +178,10 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
id={`format-${rule.id}`}
|
id={`format-${rule.id}`}
|
||||||
value={format}
|
value={format}
|
||||||
placeholder="WH-{YYYY}-{SEQ:4}"
|
placeholder="WH-{YYYY}-{SEQ:4}"
|
||||||
onChange={(event) => setFormat(event.target.value)}
|
onChange={(event) => {
|
||||||
|
clearIssues("format");
|
||||||
|
setFormat(event.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
|
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
|
||||||
@@ -269,19 +245,10 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
max={365}
|
max={365}
|
||||||
placeholder="leer = ohne Termin"
|
placeholder="leer = ohne Termin"
|
||||||
value={dueInDays}
|
value={dueInDays}
|
||||||
onChange={(event) => setDueInDays(event.target.value)}
|
onChange={(event) => {
|
||||||
/>
|
clearIssues("dueInDays");
|
||||||
</div>
|
setDueInDays(event.target.value);
|
||||||
) : null}
|
}}
|
||||||
{rule.triggerType === "VARROA_THRESHOLD" ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor={`threshold-${rule.id}`}>Schwellenwert (Milben/Tag)</Label>
|
|
||||||
<Input
|
|
||||||
id={`threshold-${rule.id}`}
|
|
||||||
type="number"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={threshold}
|
|
||||||
onChange={(event) => setThreshold(event.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -297,13 +264,27 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
step={1}
|
step={1}
|
||||||
placeholder="leer = nicht überwachen"
|
placeholder="leer = nicht überwachen"
|
||||||
value={minGrams[kind] ?? ""}
|
value={minGrams[kind] ?? ""}
|
||||||
onChange={(event) =>
|
onChange={(event) => {
|
||||||
setMinGrams((current) => ({ ...current, [kind]: event.target.value }))
|
clearIssues("minGrams");
|
||||||
}
|
setMinGrams((current) => ({ ...current, [kind]: event.target.value }));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
|
{Object.entries(issues).map(([field, message]) => (
|
||||||
|
<p key={field} role="alert" className="text-sm font-medium text-destructive">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{!numberFormatRule ? (
|
||||||
|
<ToggleChip label="Zusätzlich melden" checked={notify}
|
||||||
|
onChange={(checked) => {
|
||||||
|
clearIssues("notify");
|
||||||
|
setNotify(checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -350,9 +331,13 @@ export function AutomationRulesTab() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
{rules?.map((rule) => (
|
{rules?.map((rule) =>
|
||||||
|
hasRuleForm(rule.triggerType) ? (
|
||||||
|
<SpecialRuleCard key={rule.id} rule={rule} />
|
||||||
|
) : (
|
||||||
<RuleCard key={rule.id} rule={rule} />
|
<RuleCard key={rule.id} rule={rule} />
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { AutomationRulesTab } from "./automation-rules-tab";
|
||||||
|
import { SpecialRuleCard } from "./special-rule-card";
|
||||||
|
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
|
||||||
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
|
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
vi.mock("@/lib/automation/api", () => ({
|
||||||
|
fetchAutomationRules: vi.fn(),
|
||||||
|
updateAutomationRule: vi.fn(),
|
||||||
|
resetAutomationRuleSequence: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function rule(
|
||||||
|
triggerType: AutomationTriggerType,
|
||||||
|
config: Record<string, unknown> | null,
|
||||||
|
active = true,
|
||||||
|
): AutomationRule {
|
||||||
|
return {
|
||||||
|
id: `rule-${triggerType}`,
|
||||||
|
triggerType,
|
||||||
|
active,
|
||||||
|
config,
|
||||||
|
actionTemplate: null,
|
||||||
|
sequenceValue: 0,
|
||||||
|
createdAt: "2026-10-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-10-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTab(rules: AutomationRule[]) {
|
||||||
|
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
|
||||||
|
vi.mocked(updateAutomationRule).mockImplementation(
|
||||||
|
async (id, input) =>
|
||||||
|
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
|
||||||
|
);
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<AutomationRulesTab />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function card(title: string) {
|
||||||
|
const heading = await screen.findByText(title);
|
||||||
|
return within(heading.closest("[data-slot='card']") as HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests read nested keys of what was sent; the shape is checked by the matchers.
|
||||||
|
type LooseConfig = ReturnType<typeof JSON.parse>;
|
||||||
|
|
||||||
|
function savedConfig(): LooseConfig {
|
||||||
|
return vi.mocked(updateAutomationRule).mock.calls[0][1].config as LooseConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARROA = rule("VARROA_THRESHOLD", { thresholdMitesPerDay: 10 });
|
||||||
|
const VARROA_TITLE = "Varroa-Schwellenwert überschritten";
|
||||||
|
|
||||||
|
function naturalFallRed(view: ReturnType<typeof within>) {
|
||||||
|
const group = view.getByRole("group", { name: /Natürlicher Milbenfall/ });
|
||||||
|
return within(group).getByLabelText(/Rot ab mehr als/);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe("what a save keeps", () => {
|
||||||
|
it("keeps non-default month lists and colony types and drops the legacy threshold", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([
|
||||||
|
rule("VARROA_THRESHOLD", {
|
||||||
|
thresholdMitesPerDay: 8,
|
||||||
|
naturalFall: { seasonMonths: [4, 5, 6], yellowFrom: 3, redAbove: 8, winterRedAbove: 0.4 },
|
||||||
|
infestationPercent: { summerMonths: [6], autumnMonths: [11, 12, 1] },
|
||||||
|
youngColonyTypes: ["YOUNG_COLONY"],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
// The texts follow the stored months, not fixed words.
|
||||||
|
expect(view.getByText("Milben pro Tag. April bis Juni.")).toBeInTheDocument();
|
||||||
|
expect(view.getByText(/^Juli bis März\./)).toBeInTheDocument();
|
||||||
|
expect(view.getByText("Puderzucker und Auswaschung, November bis Januar")).toBeInTheDocument();
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
const config = savedConfig();
|
||||||
|
expect(config).not.toHaveProperty("thresholdMitesPerDay");
|
||||||
|
expect(config.naturalFall).toEqual({
|
||||||
|
seasonMonths: [4, 5, 6],
|
||||||
|
yellowFrom: 3,
|
||||||
|
redAbove: 8,
|
||||||
|
winterRedAbove: 0.4,
|
||||||
|
});
|
||||||
|
expect(config.infestationPercent.summerMonths).toEqual([6]);
|
||||||
|
expect(config.infestationPercent.autumnMonths).toEqual([11, 12, 1]);
|
||||||
|
expect(config.youngColonyTypes).toEqual(["YOUNG_COLONY"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps calibrated phases, other thresholds and unknown keys of the phenology rule", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([
|
||||||
|
rule("PHENOLOGY", {
|
||||||
|
gtsThresholds: { SALWEIDE: 150, KIRSCHE: 300 },
|
||||||
|
calibratePhases: ["KIRSCHE"],
|
||||||
|
futureKey: { a: 1 },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const view = await card("Pflanzenentwicklung");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
const config = savedConfig();
|
||||||
|
expect(config.gtsThresholds).toEqual({ SALWEIDE: 150, KIRSCHE: 300 });
|
||||||
|
expect(config.calibratePhases).toEqual(["KIRSCHE"]);
|
||||||
|
expect(config.futureKey).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearing the Salweide threshold sends an empty threshold group", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("PHENOLOGY", null)]);
|
||||||
|
const view = await card("Pflanzenentwicklung");
|
||||||
|
await user.clear(view.getByLabelText(/^Salweide: Wärmesumme ab/));
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(savedConfig().gtsThresholds).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches a rule on without a config", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("PHENOLOGY", null, false)]);
|
||||||
|
const view = await card("Pflanzenentwicklung");
|
||||||
|
await user.click(view.getByRole("button", { name: "Aktivieren" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(vi.mocked(updateAutomationRule).mock.calls[0]).toEqual([
|
||||||
|
"rule-PHENOLOGY",
|
||||||
|
{ active: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches a rule off without a config", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("PHENOLOGY", null, true)]);
|
||||||
|
const view = await card("Pflanzenentwicklung");
|
||||||
|
await user.click(view.getByRole("button", { name: "Deaktivieren" }));
|
||||||
|
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
|
||||||
|
expect(vi.mocked(updateAutomationRule).mock.calls[0][1]).toEqual({ active: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("server issues", () => {
|
||||||
|
it("a server issue disappears once its field is edited", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([VARROA]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValue(
|
||||||
|
new ApiError(400, "x", {
|
||||||
|
issues: [{ field: "naturalFall.redAbove", message: "Gelb darf nicht über Rot liegen." }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Gelb darf nicht über Rot liegen.")).toBeInTheDocument();
|
||||||
|
await user.type(naturalFallRed(view), "1");
|
||||||
|
expect(view.queryByText("Gelb darf nicht über Rot liegen.")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows issues of values without a field in an alert list", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([VARROA]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValue(
|
||||||
|
new ApiError(400, "x", {
|
||||||
|
issues: [
|
||||||
|
{ field: "naturalFall.seasonMonths", message: "Monate nur einmal angeben." },
|
||||||
|
{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Monate nur einmal angeben.")).toHaveAttribute("role", "alert");
|
||||||
|
expect(view.getByText("Unbekannter Volkstyp.")).toHaveAttribute("role", "alert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a calendar order issue of the phenology rule", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("PHENOLOGY", null)]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValue(
|
||||||
|
new ApiError(400, "x", {
|
||||||
|
issues: [{ field: "calendarFallback.APFEL", message: "Das Datum darf nicht vor dem der vorherigen Phase liegen." }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = await card("Pflanzenentwicklung");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(
|
||||||
|
await view.findByText("Das Datum darf nicht vor dem der vorherigen Phase liegen."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the messages when the rule is switched", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([VARROA]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValueOnce(
|
||||||
|
new ApiError(400, "x", {
|
||||||
|
issues: [{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
|
||||||
|
await user.click(view.getByRole("button", { name: "Deaktivieren" }));
|
||||||
|
expect(view.queryByText("Unbekannter Volkstyp.")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ties the hint and the message of a switch to it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("SEASON_AUTOPILOT", null)]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValue(
|
||||||
|
new ApiError(400, "x", { issues: [{ field: "rules.A9", message: "Ungültig." }] }),
|
||||||
|
);
|
||||||
|
const view = await card("Saison-Autopilot");
|
||||||
|
const described = view.getByRole("checkbox", { name: /^A9 / }).getAttribute("aria-describedby");
|
||||||
|
expect(document.getElementById(String(described))).toHaveTextContent("Annahme");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
const message = await view.findByText("Ungültig.");
|
||||||
|
expect(view.getByRole("checkbox", { name: /^A9 / }).getAttribute("aria-describedby")).toContain(
|
||||||
|
message.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("number checks", () => {
|
||||||
|
it("asks for whole days where days are meant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("QUEEN_CELLS_SWARM_CHECK", { dueInDays: 7, notify: false })]);
|
||||||
|
const view = await card("Weiselzellen: Schwarmkontrolle");
|
||||||
|
const days = view.getByLabelText(/^Fällig nach/);
|
||||||
|
for (const text of ["1,5", "-2", "x"]) {
|
||||||
|
await user.clear(days);
|
||||||
|
await user.type(days, text);
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Bitte ganze Tage angeben.")).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
expect(updateAutomationRule).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a whole number for the frames", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("SEASON_AUTOPILOT", null)]);
|
||||||
|
const view = await card("Saison-Autopilot");
|
||||||
|
const frames = view.getByLabelText(/^Honigraum ab besetzten Waben/);
|
||||||
|
await user.clear(frames);
|
||||||
|
await user.type(frames, "8,5");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Bitte eine ganze Zahl angeben.")).toBeInTheDocument();
|
||||||
|
expect(updateAutomationRule).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clamp an inspection interval outside its bounds", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("INSPECTION_INTERVAL", null)]);
|
||||||
|
const view = await card("Durchsicht-Rhythmus");
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
await user.click(view.getByRole("button", { name: /\+.*Schwarmzeit/ }));
|
||||||
|
}
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Bitte 7 bis 9 Tage wählen.")).toBeInTheDocument();
|
||||||
|
expect(updateAutomationRule).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a refreshed rule", () => {
|
||||||
|
it("starts the form again when the rule was updated with a new shape", () => {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
const wrap = (current: AutomationRule) => (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<SpecialRuleCard rule={current} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const first = rule("PHENOLOGY", null);
|
||||||
|
const view = render(wrap(first));
|
||||||
|
expect(screen.getByLabelText(/^Salweide: Wärmesumme ab/)).toHaveValue("200");
|
||||||
|
view.rerender(
|
||||||
|
wrap({
|
||||||
|
...first,
|
||||||
|
updatedAt: "2026-10-02T00:00:00Z",
|
||||||
|
config: { gtsThresholds: { SALWEIDE: 150 } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText(/^Salweide: Wärmesumme ab/)).toHaveValue("150");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fix round 2", () => {
|
||||||
|
it("keeps unsaved edits when the rule is switched", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const phenology = rule("PHENOLOGY", null);
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
vi.mocked(updateAutomationRule).mockResolvedValue(phenology);
|
||||||
|
const wrap = (current: AutomationRule) => (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<SpecialRuleCard rule={current} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const view = render(wrap(phenology));
|
||||||
|
const field = screen.getByLabelText(/^Salweide blüht/);
|
||||||
|
await user.clear(field);
|
||||||
|
await user.type(field, "03-25");
|
||||||
|
view.rerender(wrap({ ...phenology, active: false, updatedAt: "2026-10-03T00:00:00Z" }));
|
||||||
|
expect(screen.getByLabelText(/^Salweide blüht/)).toHaveValue("03-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides issues without a field as soon as the user edits anything", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([VARROA]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValue(
|
||||||
|
new ApiError(400, "x", {
|
||||||
|
issues: [{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
|
||||||
|
await user.type(naturalFallRed(view), "1");
|
||||||
|
expect(view.queryByText("Unbekannter Volkstyp.")).not.toBeInTheDocument();
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the message of an older rule when its field changes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([rule("QUEENLESS_NO_EGGS", { notify: true, dueInDays: 2 })]);
|
||||||
|
vi.mocked(updateAutomationRule).mockRejectedValue(
|
||||||
|
new ApiError(400, "x", { issues: [{ field: "dueInDays", message: "Zu viele Tage." }] }),
|
||||||
|
);
|
||||||
|
const view = await card("Keine Königin gesehen und keine Stifte");
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(await view.findByText("Zu viele Tage.")).toBeInTheDocument();
|
||||||
|
await user.type(view.getByLabelText(/Fällig nach/), "0");
|
||||||
|
expect(view.queryByText("Zu viele Tage.")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user