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
+11239
-801
No files matched your search
@@ -18,3 +18,6 @@ prisma/seed.d.ts
|
||||
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
||||
/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[]
|
||||
assignedTasks Task[] @relation("TaskAssignee")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
|
||||
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
|
||||
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
|
||||
colonyMoves ColonyApiaryHistory[]
|
||||
@@ -312,6 +313,7 @@ model Colony {
|
||||
feedings Feeding[]
|
||||
harvests HarvestColony[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
suggestions Suggestion[]
|
||||
|
||||
@@index([apiaryId])
|
||||
@@index([status])
|
||||
@@ -340,16 +342,23 @@ enum LineageType {
|
||||
|
||||
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
|
||||
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
|
||||
enum SplitQueenSource {
|
||||
RAISE_OWN
|
||||
QUEEN_CELL
|
||||
QUEEN
|
||||
}
|
||||
|
||||
model ColonyLineage {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
type LineageType
|
||||
fromColonyId String
|
||||
fromColony Colony @relation("LineageFrom", fields: [fromColonyId], references: [id])
|
||||
fromColony Colony @relation("LineageFrom", fields: [fromColonyId], references: [id])
|
||||
toColonyId String
|
||||
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
|
||||
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
|
||||
date DateTime
|
||||
queenSource SplitQueenSource?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([fromColonyId])
|
||||
@@index([toColonyId])
|
||||
@@ -447,11 +456,11 @@ model QueenColonyHistory {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Inspection {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
colonyId String
|
||||
colony Colony @relation(fields: [colonyId], references: [id])
|
||||
colony Colony @relation(fields: [colonyId], references: [id])
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
date DateTime
|
||||
weather String?
|
||||
temperature Float?
|
||||
@@ -467,13 +476,13 @@ model Inspection {
|
||||
swarmMood Int?
|
||||
gentleness Int?
|
||||
combBehavior Int?
|
||||
feedStatus String?
|
||||
feedStatus ColonyCondition?
|
||||
honeySuperStatus String?
|
||||
healthIssues String?
|
||||
varroaIssues String?
|
||||
measures String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tasks Task[]
|
||||
|
||||
@@ -529,12 +538,99 @@ model Task {
|
||||
// rule see that it already left an open task for the same object instead
|
||||
// of piling up a duplicate on every further trigger.
|
||||
sourceAutomationTrigger AutomationTriggerType?
|
||||
// Set when the task was created by accepting a suggestion.
|
||||
sourceSuggestionId String?
|
||||
sourceSuggestion Suggestion? @relation(fields: [sourceSuggestionId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
@@index([status, dueDate])
|
||||
@@index([relatedType, relatedId])
|
||||
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
|
||||
@@index([sourceSuggestionId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vorschlaege (T4): Regeln, Ketten und der Saison-Autopilot schlagen vor, der
|
||||
// Imker entscheidet. Uebernehmen legt eine Aufgabe an (Task.sourceSuggestionId).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum SuggestionStatus {
|
||||
OPEN
|
||||
ACCEPTED
|
||||
DISMISSED
|
||||
RESOLVED
|
||||
EXPIRED
|
||||
OBSOLETE
|
||||
}
|
||||
|
||||
enum SuggestionMode {
|
||||
PLAN
|
||||
DO
|
||||
}
|
||||
|
||||
enum SuggestionSource {
|
||||
RULE
|
||||
CHAIN
|
||||
AUTOPILOT
|
||||
}
|
||||
|
||||
enum SuggestionIntent {
|
||||
INSPECT
|
||||
SWARM_CHECK
|
||||
QUEEN_CHECK
|
||||
QUEEN_ACCEPTANCE
|
||||
QUEEN_REPLACE
|
||||
SPLIT_PLAN
|
||||
VARROA_MEASURE
|
||||
VARROA_RECHECK
|
||||
VARROA_TREAT
|
||||
SUMMER_TREATMENT
|
||||
FEED
|
||||
HONEY_SUPER
|
||||
WINTER_CHECK
|
||||
HEALTH_CHECK
|
||||
FOLLOW_UP
|
||||
STOCK_REORDER
|
||||
CLEANUP
|
||||
}
|
||||
|
||||
model Suggestion {
|
||||
id String @id @default(cuid())
|
||||
ruleKey AutomationTriggerType
|
||||
source SuggestionSource
|
||||
mode SuggestionMode
|
||||
intent SuggestionIntent
|
||||
status SuggestionStatus @default(OPEN)
|
||||
priority TaskPriority @default(NORMAL)
|
||||
relatedType TaskRelatedType
|
||||
relatedId String?
|
||||
colonyId String?
|
||||
colony Colony? @relation(fields: [colonyId], references: [id])
|
||||
reasonCode String
|
||||
// Only IDs, numbers and YYYY-MM-DD.
|
||||
evidence Json
|
||||
// e.g. 'varroa:read'; without that permission the suggestion is invisible.
|
||||
requiredPermission String?
|
||||
dueDate DateTime?
|
||||
expiresAt DateTime?
|
||||
// PLAN with several dates: [{ step, offsetDays, windowDays? }]
|
||||
plan Json?
|
||||
// '<ruleKey>:<relatedId|none>:<anchor>'; EXPIRED/RESOLVED clear it.
|
||||
dedupeKey String? @unique
|
||||
sourceEventId String?
|
||||
decidedAt DateTime?
|
||||
decidedByUserId String?
|
||||
decidedByUser User? @relation("SuggestionDecider", fields: [decidedByUserId], references: [id])
|
||||
decisionNote String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
tasks Task[]
|
||||
|
||||
@@index([status, colonyId])
|
||||
@@index([status, dueDate])
|
||||
@@index([ruleKey, relatedType, relatedId, status])
|
||||
}
|
||||
|
||||
enum TaskTemplateRecurrenceType {
|
||||
@@ -589,6 +685,7 @@ model VarroaMeasurement {
|
||||
method VarroaMethod
|
||||
durationMinutes Int?
|
||||
miteCount Int?
|
||||
sampleBees Int?
|
||||
calculatedValue Float?
|
||||
assessment String?
|
||||
notes String?
|
||||
@@ -1172,6 +1269,8 @@ enum BatchMovementType {
|
||||
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
|
||||
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
|
||||
CONSUMPTION
|
||||
// Abfuellung entnimmt Honig aus der Charge (Task 23: Bewegungsbuch)
|
||||
BOTTLING
|
||||
}
|
||||
|
||||
enum WithdrawalReason {
|
||||
@@ -1358,6 +1457,15 @@ enum AutomationTriggerType {
|
||||
RAW_MATERIAL_LOT_NUMBER_FORMAT
|
||||
PRODUCTION_BATCH_NUMBER_FORMAT
|
||||
RAW_MATERIAL_LOW
|
||||
INSPECTION_INTERVAL
|
||||
QUEEN_CELLS_SWARM_CHECK
|
||||
QUEEN_ACCEPTANCE_CHECK
|
||||
SPLIT_SCHEDULE
|
||||
COLONY_ENDED_CLEANUP
|
||||
WITHDRAWAL_ENDED
|
||||
HONEY_WATER_CONTENT
|
||||
SEASON_AUTOPILOT
|
||||
PHENOLOGY
|
||||
}
|
||||
|
||||
model AutomationRule {
|
||||
@@ -1446,6 +1554,7 @@ enum CalendarSyncSourceType {
|
||||
FEEDING
|
||||
HARVEST
|
||||
QUEEN_ASSIGNMENT
|
||||
WITHDRAWAL
|
||||
}
|
||||
|
||||
enum CalendarSyncStatus {
|
||||
|
||||
@@ -53,6 +53,7 @@ import { BackupModule } from './backup/backup.module';
|
||||
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
@@ -106,6 +107,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
WebhooksModule,
|
||||
DocumentsModule,
|
||||
AutomationModule,
|
||||
SuggestionsModule,
|
||||
LabelTemplatesModule,
|
||||
ImportModule,
|
||||
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 { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -26,6 +26,7 @@ describe('AutomationService', () => {
|
||||
createMany: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
task: { findFirst: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
@@ -84,6 +85,7 @@ describe('AutomationService', () => {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
},
|
||||
task: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
@@ -438,7 +440,7 @@ describe('AutomationService', () => {
|
||||
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]);
|
||||
|
||||
await service.evaluateInspectionIn(asTx(), {
|
||||
@@ -446,7 +448,7 @@ describe('AutomationService', () => {
|
||||
colonyId: 'colony-1',
|
||||
queenSeen: true,
|
||||
eggsPresent: true,
|
||||
feedStatus: 'Kritisch',
|
||||
feedStatus: 'CRITICAL',
|
||||
} as never);
|
||||
|
||||
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
|
||||
@@ -461,7 +463,7 @@ describe('AutomationService', () => {
|
||||
|
||||
await service.evaluateInspectionIn(asTx(), {
|
||||
...queenlessInspection,
|
||||
feedStatus: 'kritisch',
|
||||
feedStatus: 'CRITICAL',
|
||||
} as never);
|
||||
|
||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||
@@ -917,9 +919,55 @@ describe('AutomationService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('records an audit log entry with before/after data', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
// The rule row is locked first, then read, so "before" is the committed
|
||||
// state at the moment of the write.
|
||||
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,
|
||||
active: false,
|
||||
});
|
||||
@@ -931,20 +979,22 @@ describe('AutomationService', () => {
|
||||
entityType: 'AutomationRule',
|
||||
entityId: 'rule-3',
|
||||
action: 'UPDATE',
|
||||
beforeData: varroaRule,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the config on null so the rule falls back to its defaults', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.update.mockResolvedValue({
|
||||
...varroaRule,
|
||||
config: null,
|
||||
});
|
||||
|
||||
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 } }],
|
||||
];
|
||||
// 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 () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.update.mockResolvedValue(varroaRule);
|
||||
|
||||
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 } }],
|
||||
];
|
||||
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', () => {
|
||||
|
||||
@@ -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 { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
||||
import { runAfterCommit } from '../common/after-commit';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
} from '../../generated/prisma';
|
||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||
import { formatGeneratedNumber } from './number-format';
|
||||
import { DEFAULT_RULES, validateRuleConfig } from './rule-config';
|
||||
import { varroaThresholdOf } from './varroa-threshold';
|
||||
|
||||
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
|
||||
* built-in default: how soon a queenless colony or a high mite fall needs a
|
||||
@@ -223,33 +152,58 @@ export class AutomationService {
|
||||
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(
|
||||
id: string,
|
||||
dto: UpdateAutomationRuleDto,
|
||||
actingUserId: string,
|
||||
): Promise<AutomationRule> {
|
||||
const before = await this.findById(id);
|
||||
const rule = await this.prisma.automationRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
// null resets the rule to its defaults; a Json column is cleared
|
||||
// with DbNull, a plain null would fail the write.
|
||||
config:
|
||||
dto.config === null
|
||||
? Prisma.DbNull
|
||||
: (dto.config as Prisma.InputJsonValue | undefined),
|
||||
},
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
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 },
|
||||
data: {
|
||||
...dto,
|
||||
// null resets the rule to its defaults; a Json column is cleared
|
||||
// with DbNull, a plain null would fail the write.
|
||||
config:
|
||||
dto.config === null
|
||||
? Prisma.DbNull
|
||||
: (dto.config as Prisma.InputJsonValue | undefined),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'AutomationRule',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: rule,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return rule;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'AutomationRule',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: rule,
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -287,7 +241,7 @@ export class AutomationService {
|
||||
}
|
||||
|
||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
||||
if (feedRule && inspection.feedStatus === 'CRITICAL') {
|
||||
await this.triggerActionIn(
|
||||
tx,
|
||||
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);
|
||||
});
|
||||
|
||||
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([
|
||||
['a string', { thresholdMitesPerDay: 'x' }],
|
||||
['zero', { thresholdMitesPerDay: 0 }],
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
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. */
|
||||
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)
|
||||
* - inactive rule: null (no threshold in force)
|
||||
* - active rule: the configured value, or the default if it is not a
|
||||
* positive finite number (a hand-edited config must never disable the check).
|
||||
* - active rule: the configured value, or the default if it is not usable
|
||||
* (a hand-edited config must never disable the check).
|
||||
*/
|
||||
export function varroaThresholdOf(
|
||||
rule: Pick<AutomationRule, 'active' | 'config'> | null,
|
||||
@@ -19,12 +24,5 @@ export function varroaThresholdOf(
|
||||
if (!rule.active) {
|
||||
return null;
|
||||
}
|
||||
const config = rule.config;
|
||||
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;
|
||||
return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'queenColonyHistory',
|
||||
'inspection',
|
||||
'taskTemplate',
|
||||
'suggestion',
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
@@ -237,6 +238,157 @@ describe('BackupService', () => {
|
||||
];
|
||||
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', () => {
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
isAllowedDocumentMimeType,
|
||||
} from '../documents/document-mime-types';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import {
|
||||
appendLegacyFeedNote,
|
||||
feedConditionFromInput,
|
||||
} from '../common/feed-vocabulary';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { DocumentEntityType, Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
@@ -162,6 +166,28 @@ function asString(row: Row, key: string): string | 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 {
|
||||
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
||||
}
|
||||
@@ -233,6 +259,7 @@ export class BackupService {
|
||||
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
|
||||
data.inspection = await this.prisma.inspection.findMany();
|
||||
data.taskTemplate = await this.prisma.taskTemplate.findMany();
|
||||
data.suggestion = await this.prisma.suggestion.findMany();
|
||||
data.task = await this.prisma.task.findMany();
|
||||
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||
@@ -538,16 +565,14 @@ export class BackupService {
|
||||
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
|
||||
|
||||
for (const row of rowsOf('inspection')) {
|
||||
const inspection = {
|
||||
...withCurrentFeedVocabulary(row),
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
};
|
||||
await tx.inspection.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
}) as never,
|
||||
create: inspection as never,
|
||||
update: updateWith(inspection) as never,
|
||||
});
|
||||
}
|
||||
imported.inspection = rowsOf('inspection').length;
|
||||
@@ -577,6 +602,23 @@ export class BackupService {
|
||||
}
|
||||
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')) {
|
||||
await tx.task.upsert({
|
||||
where: { id: row.id as string },
|
||||
|
||||
@@ -122,6 +122,14 @@ describe('CalendarSyncService', () => {
|
||||
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 () => {
|
||||
settingsService.get.mockResolvedValue({
|
||||
...enabledSettings,
|
||||
@@ -640,6 +648,16 @@ describe('CalendarSyncService', () => {
|
||||
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 () => {
|
||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
|
||||
|
||||
@@ -103,6 +103,8 @@ export class CalendarSyncService {
|
||||
FEEDING: settings.syncFeeding,
|
||||
HARVEST: settings.syncHarvest,
|
||||
QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
|
||||
// Switch in the settings arrives with Task 3.
|
||||
WITHDRAWAL: false,
|
||||
};
|
||||
return flagBySourceType[sourceType];
|
||||
}
|
||||
@@ -499,6 +501,9 @@ export class CalendarSyncService {
|
||||
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 { berlinToday } from '../common/dates';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('DashboardService', () => {
|
||||
@@ -11,6 +12,7 @@ describe('DashboardService', () => {
|
||||
honeyBatch: { count: jest.Mock };
|
||||
stockItem: { findMany: jest.Mock };
|
||||
swarmControlCheck: { findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -28,6 +30,8 @@ describe('DashboardService', () => {
|
||||
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
|
||||
stockItem: { 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);
|
||||
});
|
||||
@@ -137,33 +141,44 @@ describe('DashboardService', () => {
|
||||
});
|
||||
|
||||
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
|
||||
.mockResolvedValueOnce([]) // attentionColonies
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear: 2020 } },
|
||||
]);
|
||||
.mockResolvedValueOnce(queenedColony(currentYear - 2));
|
||||
|
||||
const alerts = await service.alerts();
|
||||
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(alerts[0].type).toBe('OLD_QUEEN');
|
||||
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 () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
prisma.colony.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'colony-1',
|
||||
code: 'V-001',
|
||||
currentQueen: { hatchYear: currentYear },
|
||||
},
|
||||
]);
|
||||
prisma.colony.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(queenedColony(currentYear - 1));
|
||||
|
||||
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 { readRule } from '../automation/rule-config';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { isQueenAged } from '../season/queen-age';
|
||||
|
||||
export interface DashboardSummary {
|
||||
apiariesActive: number;
|
||||
@@ -24,10 +27,6 @@ export interface DashboardAlert {
|
||||
}
|
||||
|
||||
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()
|
||||
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) {
|
||||
// The where clause above already requires currentQueen and its
|
||||
// hatchYear to exist, but the relation is still nullable in the type.
|
||||
if (!colony.currentQueen?.hatchYear) {
|
||||
continue;
|
||||
}
|
||||
const ageYears = currentYear - colony.currentQueen.hatchYear;
|
||||
if (ageYears >= OLD_QUEEN_AGE_YEARS) {
|
||||
const { hatchYear } = colony.currentQueen;
|
||||
if (isQueenAged(hatchYear, today, autopilot.queenMaxAgeYears)) {
|
||||
const ageYears = currentYear - hatchYear;
|
||||
alerts.push({
|
||||
type: 'OLD_QUEEN',
|
||||
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 };
|
||||
TaskDue: { dueOn: string };
|
||||
QueenAged: { hatchYear: number; colonyId: string | null };
|
||||
VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null };
|
||||
InspectionOverdue: { dueOn: string; lastInspectedOn: string | null };
|
||||
// kind/anchor/firstOfYear came with T4a: absent on older events.
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ColonyCondition } from '../../generated/prisma';
|
||||
import { FEED_CONDITION_LABELS_DE } from '../common/feed-vocabulary';
|
||||
import {
|
||||
PDF_MARGIN,
|
||||
drawSection,
|
||||
@@ -83,7 +85,7 @@ export interface ColonyRecordCard {
|
||||
queenCellsPresent: boolean | null;
|
||||
gentleness: number | null;
|
||||
swarmMood: number | null;
|
||||
feedStatus: string | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
measures: string | null;
|
||||
notes: string | null;
|
||||
}[];
|
||||
@@ -348,7 +350,7 @@ export function renderColonyRecordCardPdf(
|
||||
yesNo(i.queenCellsPresent),
|
||||
orDash(i.gentleness),
|
||||
orDash(i.swarmMood),
|
||||
orDash(i.feedStatus),
|
||||
orDash(i.feedStatus ? FEED_CONDITION_LABELS_DE[i.feedStatus] : null),
|
||||
orDash(i.measures),
|
||||
orDash(i.notes),
|
||||
]),
|
||||
|
||||
@@ -133,6 +133,42 @@ describe('ImportService', () => {
|
||||
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 () => {
|
||||
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 { CreateColonyDto } from '../colonies/dto/create-colony.dto';
|
||||
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
|
||||
import {
|
||||
appendLegacyFeedNote,
|
||||
feedConditionFromInput,
|
||||
} from '../common/feed-vocabulary';
|
||||
import { parseCsv } from './csv';
|
||||
|
||||
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 = {
|
||||
code: emptyToUndefined(record.code),
|
||||
apiaryId: apiary.id,
|
||||
@@ -174,9 +180,13 @@ export class ImportService {
|
||||
originText: emptyToUndefined(record.originText),
|
||||
strength: parseNumberField(record.strength),
|
||||
broodStatus: emptyToUndefined(record.broodStatus),
|
||||
feedStatus: emptyToUndefined(record.feedStatus),
|
||||
feedStatus: feed.feedStatus ?? undefined,
|
||||
healthStatus: emptyToUndefined(record.healthStatus),
|
||||
notes: emptyToUndefined(record.notes),
|
||||
notes:
|
||||
appendLegacyFeedNote(
|
||||
emptyToUndefined(record.notes) ?? null,
|
||||
feed.notesSuffix,
|
||||
) ?? undefined,
|
||||
};
|
||||
const dto = plainToInstance(CreateColonyDto, plain);
|
||||
const errors = await validate(dto);
|
||||
@@ -199,7 +209,16 @@ export class ImportService {
|
||||
};
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -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 {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -9,6 +10,9 @@ import {
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ColonyCondition } from '../../../generated/prisma';
|
||||
import { feedConditionFromInput } from '../../common/feed-vocabulary';
|
||||
|
||||
export class CreateInspectionDto {
|
||||
@ApiProperty()
|
||||
@@ -99,10 +103,24 @@ export class CreateInspectionDto {
|
||||
@Max(5)
|
||||
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()
|
||||
@IsString()
|
||||
feedStatus?: string;
|
||||
@Transform(({ value }: { value: unknown }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
if (value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
return feedConditionFromInput(value).feedStatus ?? value;
|
||||
})
|
||||
@IsEnum(ColonyCondition)
|
||||
feedStatus?: ColonyCondition;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@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 { Prisma } from '../../generated/prisma';
|
||||
import { AnyNewDomainEvent } from '../domain-events/domain-event-types';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { DailyEvaluationJob } from './daily-evaluation.job';
|
||||
|
||||
type RawCall = [readonly string[], ...unknown[]];
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
import type { TimeEventsStep } from './steps/time-events.step';
|
||||
|
||||
describe('DailyEvaluationJob', () => {
|
||||
const tx = { tx: true } as unknown as Prisma.TransactionClient;
|
||||
/** 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[]]
|
||||
>;
|
||||
const COUNTS = {
|
||||
WithdrawalEnded: 1,
|
||||
TaskDue: 1,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
};
|
||||
let timeEvents: { name: 'time-events'; run: jest.Mock };
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
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,
|
||||
TaskDue: 1,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
});
|
||||
expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()');
|
||||
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('writes each type in its own transaction, with its daily dedupe keys', async () => {
|
||||
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();
|
||||
|
||||
// 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',
|
||||
}),
|
||||
],
|
||||
],
|
||||
]);
|
||||
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||
});
|
||||
|
||||
it('a second run on the same day records nothing new', async () => {
|
||||
await job.run();
|
||||
it('a failing step fails the run with the counters of all steps', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
timeEvents.run.mockRejectedValue(
|
||||
new JobPartialFailure({ WithdrawalEnded: 1 }, ['TaskDue']),
|
||||
);
|
||||
|
||||
expect(await job.run()).toEqual({
|
||||
WithdrawalEnded: 0,
|
||||
TaskDue: 0,
|
||||
QueenAged: 0,
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
const failure = await job.run().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(failure).toBeInstanceOf(JobPartialFailure);
|
||||
expect(failure).toMatchObject({
|
||||
failedSteps: ['time-events'],
|
||||
result: { WithdrawalEnded: 1 },
|
||||
message: 'Daily evaluation failed for: time-events: TaskDue',
|
||||
});
|
||||
});
|
||||
|
||||
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, [
|
||||
expect.objectContaining({ type: 'WithdrawalEnded' }),
|
||||
]);
|
||||
expect(logged).toHaveBeenCalledWith(
|
||||
'Rule "TaskDue" failed: connection reset',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +1,39 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import {
|
||||
catchUpEvents,
|
||||
DAILY_EVALUATION_RULES,
|
||||
DailyRule,
|
||||
} from './daily-evaluation.rules';
|
||||
DailyEvaluationStep,
|
||||
runDailyEvaluationSteps,
|
||||
} from './daily-evaluation-steps';
|
||||
import { Job, JobResult } from './job';
|
||||
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,
|
||||
* 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.
|
||||
* The daily evaluation (CronJob 04:30 Europe/Berlin): its steps run in the
|
||||
* fixed order of DAILY_EVALUATION_STEP_ORDER, each in transactions of its
|
||||
* own, and a failing step does not stop the others. The result holds the
|
||||
* counters of all steps; if a step failed, the run fails with that result
|
||||
* (JobPartialFailure), so it shows on the System page.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DailyEvaluationJob implements Job {
|
||||
readonly name: JobName = 'daily-evaluation';
|
||||
private readonly logger = new Logger(DailyEvaluationJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
constructor(private readonly timeEvents: TimeEventsStep) {}
|
||||
|
||||
/** Evaluates the current day of the database clock. */
|
||||
/** Evaluates the current Berlin day. */
|
||||
async run(): Promise<JobResult> {
|
||||
const [{ now }] = await this.prisma.$queryRaw<
|
||||
{ now: Date }[]
|
||||
>`SELECT now() AS "now"`;
|
||||
return this.evaluate(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
const steps: DailyEvaluationStep[] = [this.timeEvents];
|
||||
const { result, failed, failures } = await runDailyEvaluationSteps(
|
||||
steps,
|
||||
berlinToday(),
|
||||
this.logger,
|
||||
);
|
||||
if (events.length === 0) {
|
||||
return 0;
|
||||
if (failed.length > 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 { SeasonService } from '../season/season.service';
|
||||
import {
|
||||
ACTIVE_SEASON_MONTHS,
|
||||
CATCH_UP_DAYS,
|
||||
catchUpEvents,
|
||||
DAILY_EVALUATION_RULES,
|
||||
DailyEvent,
|
||||
inCatchUpWindow,
|
||||
INSPECTION_INTERVAL_DAYS,
|
||||
latestCycleOccurrence,
|
||||
QUEEN_AGED_MIN_YEARS,
|
||||
VARROA_MEASUREMENT_INTERVAL_DAYS,
|
||||
withdrawalEndDay,
|
||||
} from './daily-evaluation.rules';
|
||||
|
||||
@@ -17,8 +13,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Midnight UTC of a 'YYYY-MM-DD' day. */
|
||||
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. */
|
||||
function daysBetween(from: string, to: string): string[] {
|
||||
@@ -38,23 +32,25 @@ interface FakeRows {
|
||||
withdrawalPeriodDays: number | null;
|
||||
}[];
|
||||
tasks?: { id: string; dueDate: Date | null }[];
|
||||
queens?: {
|
||||
id: string;
|
||||
hatchYear: number | null;
|
||||
colony: { id: string } | null;
|
||||
}[];
|
||||
/** Living colonies, all in one apiary. */
|
||||
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. */
|
||||
varroa?: { colonyId: string; date: Date }[];
|
||||
varroa?: { id: string; colonyId: string; date: Date }[];
|
||||
/** The latest inspection per colony. */
|
||||
inspections?: { colonyId: string; date: Date }[];
|
||||
/** Stored rule configs by trigger type; others read as the built-in setting. */
|
||||
rules?: Record<string, object>;
|
||||
}
|
||||
|
||||
interface FakeDb {
|
||||
db: PrismaService;
|
||||
queryRaw: jest.Mock;
|
||||
taskFindMany: jest.Mock;
|
||||
queenFindMany: jest.Mock;
|
||||
colonyFindMany: jest.Mock;
|
||||
}
|
||||
|
||||
@@ -63,32 +59,60 @@ interface FakeDb {
|
||||
* day themselves, the database filters only narrow the candidates.
|
||||
*/
|
||||
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 taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
|
||||
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
|
||||
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
|
||||
const colonyFindMany = jest.fn(
|
||||
({ select }: { select: Record<string, unknown> }) =>
|
||||
Promise.resolve(
|
||||
'currentQueen' in select
|
||||
? (rows.queenedColonies ?? [])
|
||||
: (rows.colonies ?? []).map((colony) => ({
|
||||
...colony,
|
||||
apiaryId: 'apiary-1',
|
||||
})),
|
||||
),
|
||||
);
|
||||
const db = {
|
||||
$queryRaw: queryRaw,
|
||||
task: { findMany: taskFindMany },
|
||||
queen: { findMany: queenFindMany },
|
||||
colony: { findMany: colonyFindMany },
|
||||
varroaMeasurement: {
|
||||
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
|
||||
findMany: jest.fn().mockResolvedValue(rows.varroa ?? []),
|
||||
},
|
||||
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;
|
||||
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. */
|
||||
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
|
||||
const events: DailyEvent[] = [];
|
||||
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;
|
||||
}
|
||||
@@ -113,13 +137,12 @@ async function runDaily(
|
||||
return store;
|
||||
}
|
||||
|
||||
const keysOf = (store: Map<string, DailyEvent>, type: string): string[] =>
|
||||
[...store.keys()].filter((key) => key.startsWith(`${type}:`));
|
||||
|
||||
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(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', () => {
|
||||
@@ -170,50 +193,9 @@ describe('daily evaluation rules', () => {
|
||||
).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', () => {
|
||||
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-07', 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', () => {
|
||||
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
|
||||
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
|
||||
expect(inCatchUpWindow('2026-12-29', d('2027-01-04'))).toBe(true);
|
||||
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.
|
||||
{ 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') }],
|
||||
});
|
||||
|
||||
// 2026-05-29 is SCHWARMZEIT (inspection every 7 days).
|
||||
expect(await eventsOn(db, '2026-05-29')).toEqual([
|
||||
{
|
||||
type: 'WithdrawalEnded',
|
||||
@@ -269,16 +252,26 @@ describe('daily evaluation rules', () => {
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
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',
|
||||
},
|
||||
// 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',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-2',
|
||||
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
|
||||
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
|
||||
aggregateId: 'colony-1',
|
||||
payload: {
|
||||
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([]);
|
||||
});
|
||||
|
||||
it('emits no inspection or varroa event outside March–October', async () => {
|
||||
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
|
||||
const { db } = fakeDb({
|
||||
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
|
||||
describe('QueenAged', () => {
|
||||
const queen = (hatchYear: number | null) => ({
|
||||
id: 'queen-1',
|
||||
hatchYear,
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
|
||||
const dueDays = (type: string) =>
|
||||
[...store.values()]
|
||||
.filter((event) => event.type === type)
|
||||
.map((event) => event.dedupeKey.split(':')[2]);
|
||||
it('fires on the job day, once per queen and year under the January 1 key', async () => {
|
||||
const { db } = fakeDb({
|
||||
queenedColonies: [
|
||||
{ id: 'colony-1', currentQueen: queen(2024) },
|
||||
{ id: 'colony-2', currentQueen: { id: 'queen-2', hatchYear: 2025 } },
|
||||
{ id: 'colony-3', currentQueen: { id: 'queen-3', hatchYear: null } },
|
||||
],
|
||||
});
|
||||
|
||||
// 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',
|
||||
]);
|
||||
expect(await eventsOn(db, '2026-09-09')).toEqual([
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: 'queen-1',
|
||||
payload: { hatchYear: 2024, colonyId: 'colony-1' },
|
||||
dedupeKey: 'QueenAged:queen-1:2026-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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
|
||||
const { db } = fakeDb({
|
||||
queens: [
|
||||
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
|
||||
{ id: 'queen-2027', hatchYear: 2027, colony: null },
|
||||
{ id: 'queen-unknown', hatchYear: null, colony: null },
|
||||
],
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
|
||||
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') }],
|
||||
});
|
||||
|
||||
expect([...store.values()]).toEqual([
|
||||
{
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: 'queen-2026',
|
||||
payload: { hatchYear: 2026, colonyId: 'colony-1' },
|
||||
dedupeKey: 'QueenAged:queen-2026:2028-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',
|
||||
},
|
||||
]);
|
||||
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 () => {
|
||||
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
|
||||
fakeDb({});
|
||||
const { db, queryRaw, taskFindMany, colonyFindMany } = fakeDb({});
|
||||
|
||||
await eventsOn(db, '2026-06-10');
|
||||
|
||||
@@ -434,14 +645,5 @@ describe('daily evaluation rules', () => {
|
||||
];
|
||||
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
|
||||
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 { readRule } from '../automation/rule-config';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { addDays, berlinDayOf } from '../common/dates';
|
||||
import {
|
||||
AnyNewDomainEvent,
|
||||
dailyDedupeKey,
|
||||
utcDay,
|
||||
} from '../domain-events/domain-event-types';
|
||||
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. */
|
||||
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 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> {
|
||||
day: string;
|
||||
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
|
||||
* 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
|
||||
* 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> {
|
||||
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`. */
|
||||
@@ -73,39 +88,11 @@ export function withdrawalEndDay(
|
||||
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. */
|
||||
export function inCatchUpWindow(day: string, today: Date): boolean {
|
||||
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
|
||||
* key: every run within the window repeats the same key, so the unique
|
||||
@@ -117,9 +104,13 @@ export function catchUpEvents(
|
||||
): DailyEvent[] {
|
||||
return occurrences
|
||||
.filter(({ day }) => inCatchUpWindow(day, today))
|
||||
.map(({ day, event }) => ({
|
||||
.map(({ day, anchorDay, 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'> = {
|
||||
type: 'QueenAged',
|
||||
async occurrences(db, today) {
|
||||
const year = today.getUTCFullYear();
|
||||
const day = `${year}-01-01`;
|
||||
if (!inCatchUpWindow(day, today)) {
|
||||
return [];
|
||||
}
|
||||
const queens = await db.queen.findMany({
|
||||
async occurrences(db, _today, { day }) {
|
||||
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
|
||||
const year = Number(day.slice(0, 4));
|
||||
const colonies = await db.colony.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS },
|
||||
...LIVING_COLONY_WHERE,
|
||||
currentQueen: {
|
||||
status: 'ACTIVE',
|
||||
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 }) => {
|
||||
if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) {
|
||||
return colonies.flatMap(({ id, currentQueen }) => {
|
||||
if (
|
||||
currentQueen === null ||
|
||||
currentQueen.hatchYear === null ||
|
||||
!isQueenAged(currentQueen.hatchYear, day, config.queenMaxAgeYears)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
day,
|
||||
anchorDay: `${year}-01-01`,
|
||||
event: {
|
||||
type: 'QueenAged',
|
||||
aggregateType: 'Queen',
|
||||
aggregateId: id,
|
||||
payload: { hatchYear, colonyId: colony?.id ?? null },
|
||||
aggregateId: currentQueen.id,
|
||||
payload: { hatchYear: currentQueen.hatchYear, colonyId: id },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -232,98 +236,146 @@ const queenAged: DailyRule<'QueenAged'> = {
|
||||
},
|
||||
};
|
||||
|
||||
interface CycleDue {
|
||||
colonyId: string;
|
||||
day: string;
|
||||
/** The UTC day of the last record, null if the colony has none. */
|
||||
lastOn: string | null;
|
||||
interface SeasonalColony {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
/** The Berlin day the colony was created on. */
|
||||
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
|
||||
* the last record, or from the colony's creation if it has none.
|
||||
* Living colony: varroa measurement due, by the season of its apiary
|
||||
* (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(
|
||||
db: PrismaService,
|
||||
lastRecordOn: ReadonlyMap<string, Date>,
|
||||
intervalDays: number,
|
||||
today: Date,
|
||||
): Promise<CycleDue[]> {
|
||||
const colonies = await db.colony.findMany({
|
||||
where: LIVING_COLONY_WHERE,
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
return colonies.flatMap(({ id, createdAt }) => {
|
||||
const last = lastRecordOn.get(id) ?? null;
|
||||
const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today);
|
||||
if (day === null || !inActiveSeason(day)) {
|
||||
return [];
|
||||
}
|
||||
return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }];
|
||||
});
|
||||
}
|
||||
|
||||
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'> = {
|
||||
const varroaDue: DailyRule<'VarroaMeasurementDue'> = {
|
||||
type: 'VarroaMeasurementDue',
|
||||
async occurrences(db, today) {
|
||||
const latest = await db.varroaMeasurement.groupBy({
|
||||
by: ['colonyId'],
|
||||
async occurrences(db, _today, { seasons, day }) {
|
||||
const colonies = await livingColonies(db);
|
||||
const latest = await db.varroaMeasurement.findMany({
|
||||
where: { colony: LIVING_COLONY_WHERE },
|
||||
_max: { date: true },
|
||||
distinct: ['colonyId'],
|
||||
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }, { id: 'desc' }],
|
||||
select: { id: true, colonyId: true, date: true },
|
||||
});
|
||||
const due = await colonyCyclesDue(
|
||||
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
|
||||
const states = await seasons.statesFor(
|
||||
db,
|
||||
latestByColony(latest),
|
||||
VARROA_MEASUREMENT_INTERVAL_DAYS,
|
||||
today,
|
||||
);
|
||||
return due.map(({ colonyId, day, lastOn }) => ({
|
||||
colonies.map((colony) => colony.apiaryId),
|
||||
day,
|
||||
event: {
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colonyId,
|
||||
payload: { dueOn: day, lastMeasuredOn: lastOn },
|
||||
},
|
||||
}));
|
||||
);
|
||||
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 [
|
||||
{
|
||||
day: due.dueOn,
|
||||
event: {
|
||||
type: 'VarroaMeasurementDue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colony.id,
|
||||
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'> = {
|
||||
type: 'InspectionOverdue',
|
||||
async occurrences(db, today) {
|
||||
async occurrences(db, _today, { seasons, day }) {
|
||||
const colonies = await livingColonies(db);
|
||||
const latest = await db.inspection.groupBy({
|
||||
by: ['colonyId'],
|
||||
where: { colony: LIVING_COLONY_WHERE },
|
||||
_max: { date: true },
|
||||
});
|
||||
const due = await colonyCyclesDue(
|
||||
db,
|
||||
latestByColony(latest),
|
||||
INSPECTION_INTERVAL_DAYS,
|
||||
today,
|
||||
const lastInspected = new Map(
|
||||
latest.flatMap(({ colonyId, _max }) =>
|
||||
_max.date === null ? [] : [[colonyId, utcDay(_max.date)] as const],
|
||||
),
|
||||
);
|
||||
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,
|
||||
event: {
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colonyId,
|
||||
payload: { dueOn: day, lastInspectedOn: lastOn },
|
||||
},
|
||||
}));
|
||||
);
|
||||
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: {
|
||||
type: 'InspectionOverdue',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: colony.id,
|
||||
payload: {
|
||||
dueOn: due.dueOn,
|
||||
lastInspectedOn,
|
||||
firstOfYear: due.firstOfYear,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -332,6 +384,6 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
|
||||
withdrawalEnded,
|
||||
taskDue,
|
||||
queenAged,
|
||||
varroaMeasurementDue,
|
||||
varroaDue,
|
||||
inspectionOverdue,
|
||||
];
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
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 { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
import { SystemModule } from '../system/system.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { AppBackupJob } from './app-backup.job';
|
||||
@@ -15,22 +16,9 @@ import { DailyEvaluationJob } from './daily-evaluation.job';
|
||||
import { Job } from './job';
|
||||
import { JobRegistry, JOBS } from './job-registry';
|
||||
import { RekeyJob } from './rekey.job';
|
||||
import { TimeEventsStep } from './steps/time-events.step';
|
||||
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 = [
|
||||
DailyEvaluationJob,
|
||||
TaskTemplatesJob,
|
||||
@@ -58,6 +46,7 @@ const JOB_CLASSES = [
|
||||
JobPermissionsModule,
|
||||
BackupModule,
|
||||
SystemModule,
|
||||
SeasonModule,
|
||||
],
|
||||
providers: [
|
||||
...JOB_CLASSES,
|
||||
@@ -66,6 +55,7 @@ const JOB_CLASSES = [
|
||||
useFactory: (...jobs: Job[]): Job[] => jobs,
|
||||
inject: JOB_CLASSES,
|
||||
},
|
||||
TimeEventsStep,
|
||||
JobRegistry,
|
||||
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 {
|
||||
LOCK_ORDER,
|
||||
lockRowForUpdate,
|
||||
lockRowsForShare,
|
||||
lockRowsForUpdate,
|
||||
@@ -143,3 +144,98 @@ describe('lockRowsForUpdate', () => {
|
||||
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 { 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 = {
|
||||
Colony: Prisma.raw('"Colony"'),
|
||||
Task: Prisma.raw('"Task"'),
|
||||
BottlingBatch: Prisma.raw('"BottlingBatch"'),
|
||||
Feeding: Prisma.raw('"Feeding"'),
|
||||
Harvest: Prisma.raw('"Harvest"'),
|
||||
Inspection: Prisma.raw('"Inspection"'),
|
||||
Product: Prisma.raw('"Product"'),
|
||||
ProductionBatch: Prisma.raw('"ProductionBatch"'),
|
||||
RawMaterialLot: Prisma.raw('"RawMaterialLot"'),
|
||||
StockItem: Prisma.raw('"StockItem"'),
|
||||
Treatment: Prisma.raw('"Treatment"'),
|
||||
VarroaMeasurement: Prisma.raw('"VarroaMeasurement"'),
|
||||
Colony: lockable('Colony'),
|
||||
Suggestion: lockable('Suggestion'),
|
||||
Task: lockable('Task'),
|
||||
Queen: lockable('Queen'),
|
||||
AutomationRule: lockable('AutomationRule'),
|
||||
BottlingBatch: lockable('BottlingBatch'),
|
||||
Feeding: lockable('Feeding'),
|
||||
Harvest: lockable('Harvest'),
|
||||
Inspection: lockable('Inspection'),
|
||||
Product: lockable('Product'),
|
||||
ProductionBatch: lockable('ProductionBatch'),
|
||||
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;
|
||||
|
||||
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
|
||||
* 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 exists.
|
||||
* row exists. `id` is the value of the table's key column ("id", for
|
||||
* NotificationPreference "userId").
|
||||
*/
|
||||
export async function lockRowForUpdate(
|
||||
tx: Prisma.TransactionClient,
|
||||
@@ -31,20 +59,18 @@ export async function lockRowForUpdate(
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
assertTransactionClient(tx, 'lockRowForUpdate');
|
||||
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
|
||||
throw new Error('lockRowForUpdate: table not lockable');
|
||||
}
|
||||
const locked = await tx.$queryRaw<{ id: string }[]>(
|
||||
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ${id} FOR UPDATE`,
|
||||
const entry = entryOf(table, 'lockRowForUpdate');
|
||||
const locked = await tx.$queryRaw<unknown[]>(
|
||||
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ${id} FOR UPDATE`,
|
||||
);
|
||||
return locked.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* ids lock nothing; re-read the rows after locking.
|
||||
* keys lock nothing; re-read the rows after locking.
|
||||
*/
|
||||
export async function lockRowsForUpdate(
|
||||
tx: Prisma.TransactionClient,
|
||||
@@ -52,24 +78,22 @@ export async function lockRowsForUpdate(
|
||||
ids: readonly string[],
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'lockRowsForUpdate');
|
||||
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
|
||||
throw new Error('lockRowsForUpdate: table not lockable');
|
||||
}
|
||||
const entry = entryOf(table, 'lockRowsForUpdate');
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sortedIds = [...ids].sort();
|
||||
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
|
||||
* cannot be changed or deleted meanwhile, other readers are not blocked. The
|
||||
* rows are locked in ascending id order, so two transactions locking
|
||||
* overlapping sets cannot deadlock. Unknown ids lock nothing; re-read the rows
|
||||
* after locking.
|
||||
* rows are locked in ascending key order, so two transactions locking
|
||||
* overlapping sets cannot deadlock. Unknown keys lock nothing; re-read the
|
||||
* rows after locking.
|
||||
*/
|
||||
export async function lockRowsForShare(
|
||||
tx: Prisma.TransactionClient,
|
||||
@@ -77,14 +101,12 @@ export async function lockRowsForShare(
|
||||
ids: readonly string[],
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'lockRowsForShare');
|
||||
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
|
||||
throw new Error('lockRowsForShare: table not lockable');
|
||||
}
|
||||
const entry = entryOf(table, 'lockRowsForShare');
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sortedIds = [...ids].sort();
|
||||
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', () => {
|
||||
const summary = summarizeStock(4200, [
|
||||
{ type: 'CONSUMPTION', quantity: -800 },
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* always derived, never stored, so it cannot drift from the ledger.
|
||||
*/
|
||||
|
||||
export type MovementType =
|
||||
'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION' | 'CONSUMPTION';
|
||||
import type { BatchMovementType } from '../../generated/prisma';
|
||||
|
||||
export type MovementType = BatchMovementType;
|
||||
|
||||
export interface LedgerEntry {
|
||||
type: MovementType;
|
||||
@@ -45,6 +46,10 @@ export function summarizeStock(
|
||||
withdrawn -= entry.quantity;
|
||||
} else if (entry.type === 'CONSUMPTION') {
|
||||
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 {
|
||||
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 { NotificationsService } from '../notifications/notifications.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[]];
|
||||
|
||||
@@ -125,6 +129,26 @@ describe('JobRunService', () => {
|
||||
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
|
||||
// the database clock, like the comparisons of cleanup.
|
||||
it.each([
|
||||
|
||||
@@ -20,9 +20,26 @@ const JOB_RUN_RELATED_TYPE = 'JobRun';
|
||||
|
||||
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 =
|
||||
| { 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,
|
||||
@@ -116,6 +133,7 @@ export class JobRunService {
|
||||
const failed = await this.finish(runId, {
|
||||
status: 'FAILED',
|
||||
error: message,
|
||||
...(error instanceof JobPartialFailure ? { result: error.result } : {}),
|
||||
});
|
||||
await this.notifyFailure(job, runId);
|
||||
return failed;
|
||||
@@ -127,7 +145,7 @@ export class JobRunService {
|
||||
*/
|
||||
private async finish(runId: string, outcome: RunOutcome): Promise<JobRun> {
|
||||
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 [finished] = await this.prisma.$queryRaw<JobRun[]>`
|
||||
UPDATE "JobRun"
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface NewTaskInput {
|
||||
sourceInspectionId?: string | null;
|
||||
sourceTemplateId?: string | null;
|
||||
sourceAutomationTrigger?: AutomationTriggerType | null;
|
||||
sourceSuggestionId?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskFilter {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { WebhooksModule } from '../webhooks/webhooks.module';
|
||||
import { WorkerHealthServer } from './worker-health-server.service';
|
||||
@@ -23,12 +25,14 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
AuditModule,
|
||||
JobPermissionsModule,
|
||||
NotificationsModule,
|
||||
DomainEventsModule,
|
||||
WebhooksModule,
|
||||
CalendarSyncModule,
|
||||
TasksModule,
|
||||
AutomationModule,
|
||||
SuggestionsModule,
|
||||
WorkerRuntimeModule,
|
||||
],
|
||||
providers: [WorkerHealthServer],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AutomationTriggerType, Prisma } from '../generated/prisma';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
import { drainDomainEvents } from './utils/domain-events';
|
||||
import { DEFAULT_RULES } from '../src/automation/rule-config';
|
||||
|
||||
interface AutomationRuleBody {
|
||||
id: string;
|
||||
@@ -165,6 +166,94 @@ describe('Automation rules (e2e)', () => {
|
||||
)!.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 () => {
|
||||
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 () => {
|
||||
await record('inspections', { feedStatus: 'kritisch' });
|
||||
await record('inspections', { feedStatus: 'CRITICAL' });
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { relatedId: colonyId },
|
||||
@@ -212,7 +301,7 @@ describe('Automation rules (e2e)', () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch(`/api/v1/inspections/${id}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'kritisch' })
|
||||
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'CRITICAL' })
|
||||
.expect(200);
|
||||
await drainDomainEvents(app, [id]);
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { INestApplicationContext } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job';
|
||||
import { JobsModule } from '../src/jobs/jobs.module';
|
||||
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
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 = {
|
||||
WithdrawalEnded: 0,
|
||||
@@ -21,14 +19,14 @@ const NOTHING_NEW = {
|
||||
* 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
|
||||
* 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
|
||||
* test/jobs.e2e-spec.ts counts.
|
||||
* TimeEventsStep.run(day) instead of runCli, which would also write JobRun
|
||||
* rows that test/jobs.e2e-spec.ts counts.
|
||||
*/
|
||||
describe('daily-evaluation (e2e)', () => {
|
||||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
let app: INestApplicationContext;
|
||||
let prisma: PrismaService;
|
||||
let job: DailyEvaluationJob;
|
||||
let step: TimeEventsStep;
|
||||
const ids: Record<string, string> = {};
|
||||
|
||||
const eventsOf = (aggregateId: string) =>
|
||||
@@ -50,7 +48,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
}).compile();
|
||||
app = await moduleRef.init();
|
||||
prisma = app.get(PrismaService);
|
||||
job = app.get(DailyEvaluationJob);
|
||||
step = app.get(TimeEventsStep);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -99,7 +97,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
},
|
||||
});
|
||||
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]) {
|
||||
await prisma.inspection.create({
|
||||
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 () => {
|
||||
const first = await job.evaluate(at('2001-05-20'));
|
||||
const second = await job.evaluate(at('2001-05-20'));
|
||||
const first = await step.run('2001-05-20');
|
||||
const second = await step.run('2001-05-20');
|
||||
// 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(second).toEqual(NOTHING_NEW);
|
||||
@@ -201,8 +200,8 @@ describe('daily-evaluation (e2e)', () => {
|
||||
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('records due tasks and overdue checks only for open tasks and living colonies', async () => {
|
||||
await job.evaluate(at('2001-05-20'));
|
||||
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
|
||||
await step.run('2001-05-20');
|
||||
|
||||
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.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',
|
||||
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`,
|
||||
actorUserId: null,
|
||||
},
|
||||
@@ -233,11 +230,70 @@ describe('daily-evaluation (e2e)', () => {
|
||||
expect(await eventsOf(ids.dead)).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports active queens from their second year in the first days of January', async () => {
|
||||
await job.evaluate(at('2001-01-05'));
|
||||
expect(await job.evaluate(at('2001-01-08'))).toEqual(NOTHING_NEW);
|
||||
it('InspectionOverdue follows the segment interval', async () => {
|
||||
await step.run('2001-05-12');
|
||||
|
||||
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',
|
||||
aggregateType: 'Queen',
|
||||
|
||||
@@ -11,6 +11,7 @@ interface InspectionBody {
|
||||
id: string;
|
||||
colonyId: string;
|
||||
broodFrames?: number | null;
|
||||
feedStatus?: string | null;
|
||||
}
|
||||
|
||||
interface TaskBody {
|
||||
@@ -268,4 +269,37 @@ describe('Inspections (e2e)', () => {
|
||||
.send({ broodFrames: 'x' })
|
||||
.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,
|
||||
updateAutomationRule,
|
||||
} from "@/lib/automation/api";
|
||||
import {
|
||||
RULE_DESCRIPTIONS,
|
||||
RULE_LABELS,
|
||||
SIMPLE_RULE_NOTIFY_DEFAULTS,
|
||||
issuesByField,
|
||||
} from "@/lib/automation/rule-configs";
|
||||
import {
|
||||
AutomationRule,
|
||||
AutomationTriggerType,
|
||||
UpdateAutomationRuleInput,
|
||||
} from "@/lib/automation/types";
|
||||
import { errorMessage } from "@/lib/forms/error-message";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { formatGeneratedNumber } from "@/lib/automation/number-format";
|
||||
import { ToggleChip } from "@/components/form/toggle-chip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/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.",
|
||||
};
|
||||
import { hasRuleForm, SpecialRuleCard } from "./special-rule-card";
|
||||
|
||||
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
|
||||
"HONEY_BATCH_NUMBER_FORMAT",
|
||||
@@ -124,15 +88,23 @@ function ruleDueInDays(rule: AutomationRule): string {
|
||||
return config?.dueInDays === undefined ? "" : String(config.dueInDays);
|
||||
}
|
||||
|
||||
function ruleThreshold(rule: AutomationRule): string {
|
||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
||||
return String(config?.thresholdMitesPerDay ?? "");
|
||||
function ruleNotify(rule: AutomationRule): boolean {
|
||||
const config = rule.config as { notify?: unknown } | null;
|
||||
return typeof config?.notify === "boolean"
|
||||
? config.notify
|
||||
: (SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType] ?? false);
|
||||
}
|
||||
|
||||
function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
const queryClient = useQueryClient();
|
||||
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 [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
|
||||
const [minGrams, setMinGrams] = useState<MinGrams>(ruleMinGrams(rule));
|
||||
@@ -147,10 +119,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
mutationFn: (input: UpdateAutomationRuleInput) =>
|
||||
updateAutomationRule(rule.id, input),
|
||||
onSuccess: () => {
|
||||
setIssues({});
|
||||
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
|
||||
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({
|
||||
@@ -173,10 +149,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
toast.error("Frist bitte als ganze Tage zwischen 0 und 365 angeben – oder leer lassen.");
|
||||
return;
|
||||
}
|
||||
const config: Record<string, unknown> = { dueInDays: days };
|
||||
if (rule.triggerType === "VARROA_THRESHOLD") {
|
||||
config.thresholdMitesPerDay = Number(threshold) || 10;
|
||||
}
|
||||
const config: Record<string, unknown> = { notify, dueInDays: days };
|
||||
if (rule.triggerType === "RAW_MATERIAL_LOW") {
|
||||
const parsed = parseMinGrams(minGrams);
|
||||
if (!parsed) {
|
||||
@@ -191,13 +164,13 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
return (
|
||||
<Card>
|
||||
<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"}>
|
||||
{rule.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<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 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`format-${rule.id}`}>Format</Label>
|
||||
@@ -205,7 +178,10 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
id={`format-${rule.id}`}
|
||||
value={format}
|
||||
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">
|
||||
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
|
||||
@@ -269,19 +245,10 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
max={365}
|
||||
placeholder="leer = ohne Termin"
|
||||
value={dueInDays}
|
||||
onChange={(event) => setDueInDays(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : 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)}
|
||||
onChange={(event) => {
|
||||
clearIssues("dueInDays");
|
||||
setDueInDays(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -297,13 +264,27 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
|
||||
step={1}
|
||||
placeholder="leer = nicht überwachen"
|
||||
value={minGrams[kind] ?? ""}
|
||||
onChange={(event) =>
|
||||
setMinGrams((current) => ({ ...current, [kind]: event.target.value }))
|
||||
}
|
||||
onChange={(event) => {
|
||||
clearIssues("minGrams");
|
||||
setMinGrams((current) => ({ ...current, [kind]: event.target.value }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: 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">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -350,9 +331,13 @@ export function AutomationRulesTab() {
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{rules?.map((rule) => (
|
||||
<RuleCard key={rule.id} rule={rule} />
|
||||
))}
|
||||
{rules?.map((rule) =>
|
||||
hasRuleForm(rule.triggerType) ? (
|
||||
<SpecialRuleCard key={rule.id} rule={rule} />
|
||||
) : (
|
||||
<RuleCard key={rule.id} rule={rule} />
|
||||
),
|
||||
)}
|
||||
</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