feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -9,7 +9,7 @@ describe('ApiaryFieldService', () => {
|
||||
apiary: { findUnique: jest.Mock };
|
||||
colony: { findMany: jest.Mock };
|
||||
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
|
||||
notification: { groupBy: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
queen: { findMany: jest.Mock };
|
||||
task: { findMany: jest.Mock };
|
||||
};
|
||||
@@ -23,6 +23,7 @@ describe('ApiaryFieldService', () => {
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
hiveType: null,
|
||||
strength: 5,
|
||||
occupiedFrames: 12,
|
||||
broodStatus: null,
|
||||
feedStatus: null,
|
||||
healthStatus: null,
|
||||
@@ -55,7 +56,7 @@ describe('ApiaryFieldService', () => {
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
notification: { groupBy: jest.fn().mockResolvedValue([]) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
@@ -172,27 +173,23 @@ describe('ApiaryFieldService', () => {
|
||||
expect(inspectionArgs.omit).toEqual({ userId: true });
|
||||
});
|
||||
|
||||
const lastQuery = () =>
|
||||
(prisma.$queryRaw.mock.calls[0] as [{ sql: string; values: unknown[] }])[0];
|
||||
|
||||
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
|
||||
grant('colonies:read');
|
||||
prisma.notification.groupBy.mockResolvedValue([
|
||||
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } },
|
||||
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } },
|
||||
prisma.$queryRaw.mockResolvedValue([
|
||||
{ relatedId: 'c2', priority: 'WARNUNG', count: 2 },
|
||||
{ relatedId: 'c2', priority: 'KRITISCH', count: 1 },
|
||||
]);
|
||||
|
||||
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||
|
||||
expect(prisma.notification.groupBy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
by: ['relatedId', 'priority'],
|
||||
where: {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: { in: ['c2', 'c10'] },
|
||||
read: false,
|
||||
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
||||
userId: 'u1',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const query = lastQuery();
|
||||
expect(query.sql).toContain('"userId" =');
|
||||
expect(query.sql).toContain('"read" = false');
|
||||
expect(query.values).toContain('u1');
|
||||
expect(query.values).toEqual(expect.arrayContaining(['c2', 'c10']));
|
||||
expect(overview.rows[0]).toMatchObject({
|
||||
code: 'V-2',
|
||||
openWarnings: 2,
|
||||
@@ -203,4 +200,16 @@ describe('ApiaryFieldService', () => {
|
||||
openCritical: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires the notification permissions to be a subset of the held ones, stale keys included', async () => {
|
||||
grant('colonies:read', 'varroa:read');
|
||||
|
||||
await service.colonyOverview('apiary-1', 'u1');
|
||||
|
||||
const query = lastQuery();
|
||||
expect(query.sql).toContain('"requiredPermissions" <@');
|
||||
// The held set is passed as is: a key outside the catalog is never
|
||||
// contained in it, so such a row stays hidden (parity with findOwn).
|
||||
expect(query.values).toContainEqual(['colonies:read', 'varroa:read']);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
ColonyOverview,
|
||||
ColonyOverviewRow,
|
||||
@@ -83,6 +84,7 @@ export class ApiaryFieldService {
|
||||
colonyType: true,
|
||||
hiveType: true,
|
||||
strength: true,
|
||||
occupiedFrames: true,
|
||||
broodStatus: true,
|
||||
feedStatus: true,
|
||||
healthStatus: true,
|
||||
@@ -96,13 +98,14 @@ export class ApiaryFieldService {
|
||||
const inspectionsVisible = permissions.has('inspections:read');
|
||||
const [lastInspections, warnings] = await Promise.all([
|
||||
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
||||
this.warningCounts(colonies, userId),
|
||||
this.warningCounts(colonies, userId, permissions),
|
||||
]);
|
||||
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
||||
id: colony.id,
|
||||
code: colony.code,
|
||||
status: colony.status,
|
||||
strength: colony.strength,
|
||||
occupiedFrames: colony.occupiedFrames,
|
||||
broodStatus: colony.broodStatus,
|
||||
feedStatus: colony.feedStatus,
|
||||
healthStatus: colony.healthStatus,
|
||||
@@ -138,27 +141,33 @@ export class ApiaryFieldService {
|
||||
private async warningCounts(
|
||||
colonies: PackColony[],
|
||||
userId: string,
|
||||
held: ReadonlySet<string>,
|
||||
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
|
||||
const groups = await this.prisma.notification.groupBy({
|
||||
by: ['relatedId', 'priority'],
|
||||
where: {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: { in: colonies.map((colony) => colony.id) },
|
||||
read: false,
|
||||
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
||||
userId,
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
|
||||
if (colonies.length === 0) {
|
||||
return counts;
|
||||
}
|
||||
// Exactly the rule of NotificationsService.findOwn, decided in SQL: a
|
||||
// row counts only when every key in requiredPermissions is held (subset
|
||||
// test, so a key outside the catalog hides the row too).
|
||||
const groups = await this.prisma.$queryRaw<
|
||||
{ relatedId: string; priority: string; count: number }[]
|
||||
>(Prisma.sql`
|
||||
SELECT "relatedId", "priority"::text AS "priority", count(*)::int AS "count"
|
||||
FROM "Notification"
|
||||
WHERE "relatedType" = 'COLONY'
|
||||
AND "relatedId" IN (${Prisma.join(colonies.map((colony) => colony.id))})
|
||||
AND "read" = false
|
||||
AND "priority"::text IN ('WARNUNG', 'KRITISCH')
|
||||
AND "userId" = ${userId}
|
||||
AND "requiredPermissions" <@ ${[...held]}::text[]
|
||||
GROUP BY "relatedId", "priority"
|
||||
`);
|
||||
for (const group of groups) {
|
||||
if (
|
||||
group.relatedId &&
|
||||
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
|
||||
) {
|
||||
if (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') {
|
||||
counts.set(group.relatedId, {
|
||||
...counts.get(group.relatedId),
|
||||
[group.priority]: group._count._all,
|
||||
[group.priority]: group.count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ColonyOverviewRow {
|
||||
code: string;
|
||||
status: ColonyStatus;
|
||||
strength: number | null;
|
||||
occupiedFrames: number | null;
|
||||
broodStatus: ColonyCondition | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
healthStatus: ColonyCondition | null;
|
||||
@@ -56,6 +57,7 @@ export interface OfflinePackV1 {
|
||||
| 'colonyType'
|
||||
| 'hiveType'
|
||||
| 'strength'
|
||||
| 'occupiedFrames'
|
||||
| 'broodStatus'
|
||||
| 'feedStatus'
|
||||
| 'healthStatus'
|
||||
|
||||
@@ -54,6 +54,7 @@ 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 { AutopilotModule } from './autopilot/autopilot.module';
|
||||
import { SystemModule } from './system/system.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
@@ -108,6 +109,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
DocumentsModule,
|
||||
AutomationModule,
|
||||
SuggestionsModule,
|
||||
AutopilotModule,
|
||||
LabelTemplatesModule,
|
||||
ImportModule,
|
||||
PublicModule,
|
||||
|
||||
@@ -154,12 +154,17 @@ describe('AutomationEventHandlers', () => {
|
||||
async (method, type, model, core) => {
|
||||
const record = { id: 'record-1', colonyId: 'colony-1' };
|
||||
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||
|
||||
await run(method, type);
|
||||
|
||||
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
});
|
||||
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(automation[core]).toHaveBeenCalledWith(tx, record);
|
||||
},
|
||||
);
|
||||
@@ -174,8 +179,41 @@ describe('AutomationEventHandlers', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'rule handlers write nothing for an ended colony: %s on %s',
|
||||
async (method, type, model, core) => {
|
||||
tx[model].findUnique.mockResolvedValueOnce({
|
||||
id: 'record-1',
|
||||
colonyId: 'colony-1',
|
||||
});
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'DISSOLVED' });
|
||||
|
||||
await run(method, type);
|
||||
|
||||
expect(automation[core]).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'rule handlers write nothing for a colony that is gone: %s on %s',
|
||||
async (method, type, model, core) => {
|
||||
tx[model].findUnique.mockResolvedValueOnce({
|
||||
id: 'record-1',
|
||||
colonyId: 'colony-1',
|
||||
});
|
||||
|
||||
await run(method, type);
|
||||
|
||||
expect(automation[core]).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' });
|
||||
tx.inspection.findUnique.mockResolvedValueOnce({
|
||||
id: 'record-1',
|
||||
colonyId: 'colony-1',
|
||||
});
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||
automation.evaluateInspectionIn.mockRejectedValue(
|
||||
new Error('notifications down'),
|
||||
);
|
||||
@@ -192,22 +230,24 @@ describe('AutomationEventHandlers', () => {
|
||||
const event = () =>
|
||||
envelope('ColonyStatusChanged', 'colony-1', { from, to });
|
||||
|
||||
it('hands the transition of the event to evaluateColonyHealthIn', async () => {
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
|
||||
it('hands the transition and the event to evaluateColonyHealthIn', async () => {
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||
|
||||
await handlers.colonyHealth(event(), asTx());
|
||||
|
||||
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
select: { id: true },
|
||||
select: { status: true },
|
||||
});
|
||||
// The rule decides on the snapshots of the event, not on the colony
|
||||
// as it is now: a later change must not hide the transition.
|
||||
// as it is now: a later change must not hide the transition. The
|
||||
// event is the anchor of the suggestion.
|
||||
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'colony-1',
|
||||
from,
|
||||
to,
|
||||
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -216,6 +256,14 @@ describe('AutomationEventHandlers', () => {
|
||||
|
||||
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a colony that ended', async () => {
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'SOLD' });
|
||||
|
||||
await handlers.colonyHealth(event(), asTx());
|
||||
|
||||
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw-material-low', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import { AutomationService } from './automation.service';
|
||||
@@ -10,9 +11,10 @@ type Tx = Prisma.TransactionClient;
|
||||
/**
|
||||
* Evaluates the automation rules on domain events. Pure DB writer: it reads
|
||||
* the record the event is about through the delivery transaction and lets
|
||||
* AutomationService write task, warning and audit entries in it. A record
|
||||
* deleted before the event was dispatched is skipped. Errors propagate, so
|
||||
* the delivery is rolled back as a whole and retried.
|
||||
* AutomationService write suggestion, notification and audit entries in it.
|
||||
* A record deleted before the event was dispatched, or of a colony that
|
||||
* ended, is skipped. Errors propagate, so the delivery is rolled back as a
|
||||
* whole and retried.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AutomationEventHandlers {
|
||||
@@ -21,6 +23,18 @@ export class AutomationEventHandlers {
|
||||
private readonly stock: StockService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fast path only: proposeForRuleIn re-checks the colony under its lock.
|
||||
* Whether the colony still lives (a colony that ended takes no proposals).
|
||||
*/
|
||||
private async isLivingColonyIn(tx: Tx, colonyId: string): Promise<boolean> {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true },
|
||||
});
|
||||
return colony !== null && isLivingColonyStatus(colony.status);
|
||||
}
|
||||
|
||||
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
|
||||
async inspectionRules(
|
||||
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
|
||||
@@ -29,14 +43,17 @@ export class AutomationEventHandlers {
|
||||
const inspection = await tx.inspection.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (inspection === null) {
|
||||
if (
|
||||
inspection === null ||
|
||||
!(await this.isLivingColonyIn(tx, inspection.colonyId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateInspectionIn(tx, inspection);
|
||||
}
|
||||
|
||||
// A correction can lift a count over the threshold, so it is evaluated
|
||||
// like a new measurement; the open-task check keeps it to one task.
|
||||
// like a new measurement; the open-suggestion check keeps it to one.
|
||||
@OnDomainEvent(
|
||||
['VarroaMeasured', 'VarroaCorrected'],
|
||||
'automation.varroa-threshold',
|
||||
@@ -48,7 +65,10 @@ export class AutomationEventHandlers {
|
||||
const measurement = await tx.varroaMeasurement.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (measurement === null) {
|
||||
if (
|
||||
measurement === null ||
|
||||
!(await this.isLivingColonyIn(tx, measurement.colonyId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
||||
@@ -62,7 +82,10 @@ export class AutomationEventHandlers {
|
||||
const treatment = await tx.treatment.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (treatment === null) {
|
||||
if (
|
||||
treatment === null ||
|
||||
!(await this.isLivingColonyIn(tx, treatment.colonyId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateTreatmentIn(tx, treatment);
|
||||
@@ -76,7 +99,10 @@ export class AutomationEventHandlers {
|
||||
const feeding = await tx.feeding.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (feeding === null) {
|
||||
if (
|
||||
feeding === null ||
|
||||
!(await this.isLivingColonyIn(tx, feeding.colonyId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateFeedingIn(tx, feeding);
|
||||
@@ -89,18 +115,15 @@ export class AutomationEventHandlers {
|
||||
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (colony === null) {
|
||||
if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateColonyHealthIn(
|
||||
tx,
|
||||
colony.id,
|
||||
event.aggregateId,
|
||||
event.payload.from,
|
||||
event.payload.to,
|
||||
{ id: event.id, occurredAt: event.occurredAt },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { StockModule } from '../stock/stock.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { AutomationController } from './automation.controller';
|
||||
import { AutomationEventHandlers } from './automation.handlers';
|
||||
import { AutomationService } from './automation.service';
|
||||
|
||||
/**
|
||||
* Automation rules and the handlers that evaluate them on domain events.
|
||||
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
|
||||
* it calendar entry and task.created webhook), so no WebhooksService here.
|
||||
* The rules propose through SuggestionsService; the task follows when the
|
||||
* beekeeper accepts (TasksService, with calendar entry and task.created
|
||||
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TasksModule, NotificationsModule, StockModule],
|
||||
imports: [SuggestionsModule, StockModule],
|
||||
controllers: [AutomationController],
|
||||
providers: [AutomationService, AutomationEventHandlers],
|
||||
exports: [AutomationService],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,9 +9,16 @@ 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';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { addDays, berlinDayOf, berlinToday } from '../common/dates';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
|
||||
import { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
import {
|
||||
suggestionDedupeKey,
|
||||
type SuggestionEvidence,
|
||||
} from '../suggestions/suggestion.types';
|
||||
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
||||
import {
|
||||
AutomationRule,
|
||||
AutomationTriggerType,
|
||||
@@ -20,6 +27,8 @@ import {
|
||||
Prisma,
|
||||
RawMaterialKind,
|
||||
StockItem,
|
||||
Suggestion,
|
||||
SuggestionIntent,
|
||||
SwarmControlCheck,
|
||||
TaskRelatedType,
|
||||
Treatment,
|
||||
@@ -27,8 +36,13 @@ 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';
|
||||
import {
|
||||
DEFAULT_RULES,
|
||||
lockRuleIn,
|
||||
readRule,
|
||||
validateRuleConfig,
|
||||
} from './rule-config';
|
||||
import { rateMeasurement } from './varroa-bands';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
@@ -39,11 +53,6 @@ export type NumberFormatTrigger =
|
||||
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
|
||||
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
|
||||
|
||||
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
|
||||
BEESWAX: 'Bienenwachs',
|
||||
PROPOLIS: 'Propolis',
|
||||
};
|
||||
|
||||
/** What {ART} turns into in the raw material number format. */
|
||||
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
|
||||
BEESWAX: 'WA',
|
||||
@@ -63,37 +72,44 @@ function isUniqueViolation(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* look depends on season and practice, so the rule stays date-less unless the
|
||||
* beekeeper configures one.
|
||||
*/
|
||||
function formatDay(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
/** The rules that propose instead of creating anything themselves. */
|
||||
export type RuleSuggestionTrigger =
|
||||
| 'QUEENLESS_NO_EGGS'
|
||||
| 'FEED_STATUS_CRITICAL'
|
||||
| 'VARROA_THRESHOLD'
|
||||
| 'SWARM_MOOD_HIGH'
|
||||
| 'COLONY_HEALTH_CRITICAL'
|
||||
| 'STOCK_ITEM_LOW'
|
||||
| 'RAW_MATERIAL_LOW'
|
||||
| 'TREATMENT_FOLLOW_UP'
|
||||
| 'FEEDING_FOLLOW_UP';
|
||||
|
||||
export interface RuleSuggestionTarget {
|
||||
relatedType: TaskRelatedType;
|
||||
relatedId: string;
|
||||
colonyId: string | null;
|
||||
intent: SuggestionIntent;
|
||||
reasonCode: string;
|
||||
evidence: SuggestionEvidence;
|
||||
anchor: string;
|
||||
requiredPermission: PermissionKey;
|
||||
dueDate?: string | null;
|
||||
sourceEventId?: string | null;
|
||||
}
|
||||
|
||||
function dueDateFromConfig(
|
||||
dueInDays: unknown,
|
||||
baseDate: Date = new Date(),
|
||||
): Date | undefined {
|
||||
if (
|
||||
typeof dueInDays !== 'number' ||
|
||||
!Number.isInteger(dueInDays) ||
|
||||
dueInDays < 0 ||
|
||||
dueInDays > 365
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const due = new Date(baseDate);
|
||||
due.setUTCHours(0, 0, 0, 0);
|
||||
due.setUTCDate(due.getUTCDate() + dueInDays);
|
||||
return due;
|
||||
/** The anchor of a finding that has no record to hang on. */
|
||||
const NO_ANCHOR = 'none';
|
||||
|
||||
/**
|
||||
* Optional per-rule follow-up interval (config.dueInDays) counted from the
|
||||
* business day. There is no built-in default: how soon a queenless colony or
|
||||
* a high mite fall needs a look depends on season and practice, so the
|
||||
* suggestion stays date-less unless the beekeeper configures one (accepting
|
||||
* it then creates a task due today).
|
||||
*/
|
||||
function dueDayOf(config: object, baseDay: string): string | null {
|
||||
const { dueInDays } = config as { dueInDays?: unknown };
|
||||
return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -103,8 +119,7 @@ export class AutomationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly tasks: TasksService,
|
||||
private readonly suggestions: SuggestionsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -216,42 +231,58 @@ export class AutomationService {
|
||||
|
||||
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
|
||||
await this.ensureDefaults(tx);
|
||||
const rules = await tx.automationRule.findMany({
|
||||
where: {
|
||||
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
|
||||
|
||||
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
|
||||
if (
|
||||
queenlessRule &&
|
||||
inspection.queenSeen === false &&
|
||||
inspection.eggsPresent === false
|
||||
) {
|
||||
await this.triggerActionIn(
|
||||
if (inspection.queenSeen === false && inspection.eggsPresent === false) {
|
||||
await this.proposeOnInspectionIn(
|
||||
tx,
|
||||
queenlessRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
'QUEENLESS_NO_EGGS',
|
||||
'QUEEN_CHECK',
|
||||
inspection,
|
||||
);
|
||||
}
|
||||
|
||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||
if (feedRule && inspection.feedStatus === 'CRITICAL') {
|
||||
await this.triggerActionIn(
|
||||
if (inspection.feedStatus === 'CRITICAL') {
|
||||
await this.proposeOnInspectionIn(
|
||||
tx,
|
||||
feedRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
'FEED_STATUS_CRITICAL',
|
||||
'FEED',
|
||||
inspection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The reason code of an inspection rule is the rule's own name. */
|
||||
private async proposeOnInspectionIn(
|
||||
tx: Tx,
|
||||
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
|
||||
intent: SuggestionIntent,
|
||||
inspection: Inspection,
|
||||
): Promise<void> {
|
||||
const rule = await readRule(tx, trigger);
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const inspectedOn = berlinDayOf(inspection.date);
|
||||
await this.proposeForRuleIn(tx, trigger, {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: inspection.colonyId,
|
||||
colonyId: inspection.colonyId,
|
||||
intent,
|
||||
reasonCode: trigger,
|
||||
evidence: {
|
||||
colonyId: inspection.colonyId,
|
||||
inspectionId: inspection.id,
|
||||
inspectedOn,
|
||||
},
|
||||
anchor: inspection.id,
|
||||
requiredPermission: 'inspections:read',
|
||||
dueDate: dueDayOf(rule.config, inspectedOn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A red measurement outside the effect of a treatment proposes a
|
||||
* treatment check. The light comes from the same bands as the colony view
|
||||
* (season, young colony, method), so rule and screen never disagree.
|
||||
*/
|
||||
async evaluateVarroaMeasurementIn(
|
||||
tx: Tx,
|
||||
measurement: VarroaMeasurement,
|
||||
@@ -260,26 +291,78 @@ export class AutomationService {
|
||||
tx,
|
||||
'AutomationService.evaluateVarroaMeasurementIn',
|
||||
);
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||
const rule = await readRule(tx, 'VARROA_THRESHOLD');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: measurement.colonyId },
|
||||
select: { colonyType: true },
|
||||
});
|
||||
// null for an inactive rule; the one source of the default value.
|
||||
const threshold = varroaThresholdOf(rule);
|
||||
if (!rule || threshold === null) {
|
||||
if (colony === null) {
|
||||
return;
|
||||
}
|
||||
// The threshold is a natural mite fall rate (mites per day). Only that
|
||||
// method yields a value in this unit - a mite count from a sugar roll or
|
||||
// wash is per bee sample and would be compared against the wrong scale,
|
||||
// as would a raw board count without the number of days it covered.
|
||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
||||
const rating = rateMeasurement(
|
||||
measurement,
|
||||
colony.colonyType,
|
||||
rule.config,
|
||||
await treatmentWindowsIn(tx, measurement.colonyId),
|
||||
);
|
||||
// A correction can move a measurement out of red (or into it): an open
|
||||
// suggestion of this measurement follows the corrected value.
|
||||
const open = await tx.suggestion.findFirst({
|
||||
where: {
|
||||
ruleKey: 'VARROA_THRESHOLD',
|
||||
status: 'OPEN',
|
||||
dedupeKey: suggestionDedupeKey(
|
||||
'VARROA_THRESHOLD',
|
||||
measurement.colonyId,
|
||||
measurement.id,
|
||||
),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (
|
||||
rating.light !== 'red' ||
|
||||
rating.band === null ||
|
||||
measurement.calculatedValue === null
|
||||
) {
|
||||
if (open !== null) {
|
||||
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
|
||||
const measuredOn = berlinDayOf(measurement.date);
|
||||
const evidence = {
|
||||
colonyId: measurement.colonyId,
|
||||
measurementId: measurement.id,
|
||||
measuredOn,
|
||||
value: measurement.calculatedValue,
|
||||
redAbove: rating.band.redAbove,
|
||||
unit: rating.band.unit,
|
||||
};
|
||||
const dueDate = dueDayOf(rule.config, measuredOn);
|
||||
if (open !== null) {
|
||||
await this.suggestions.refreshIn(tx, open.id, {
|
||||
reasonCode: 'VARROA_RED',
|
||||
evidence,
|
||||
dueDate,
|
||||
priority: 'HIGH',
|
||||
expiresAt: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.proposeForRuleIn(tx, 'VARROA_THRESHOLD', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: measurement.colonyId,
|
||||
colonyId: measurement.colonyId,
|
||||
intent: 'VARROA_TREAT',
|
||||
reasonCode: 'VARROA_RED',
|
||||
evidence,
|
||||
anchor: measurement.id,
|
||||
requiredPermission: 'varroa:read',
|
||||
dueDate,
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateSwarmControlCheckIn(
|
||||
@@ -290,24 +373,38 @@ export class AutomationService {
|
||||
tx,
|
||||
'AutomationService.evaluateSwarmControlCheckIn',
|
||||
);
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
if (check.swarmMood !== 'HIGH') {
|
||||
return;
|
||||
}
|
||||
if (check.swarmMood === 'HIGH') {
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
|
||||
const rule = await readRule(tx, 'SWARM_MOOD_HIGH');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const checkedOn = berlinDayOf(check.date);
|
||||
await this.proposeForRuleIn(tx, 'SWARM_MOOD_HIGH', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: check.colonyId,
|
||||
colonyId: check.colonyId,
|
||||
intent: 'SWARM_CHECK',
|
||||
reasonCode: 'SWARM_MOOD_HIGH',
|
||||
evidence: { colonyId: check.colonyId, checkId: check.id, checkedOn },
|
||||
anchor: check.id,
|
||||
requiredPermission: 'swarm-control:read',
|
||||
dueDate: dueDayOf(rule.config, checkedOn),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
||||
/**
|
||||
* Fires only on the transition into CRITICAL, not on every edit while
|
||||
* already critical. The event is the anchor: a later transition into
|
||||
* CRITICAL is a new finding.
|
||||
*/
|
||||
async evaluateColonyHealthIn(
|
||||
tx: Tx,
|
||||
colonyId: string,
|
||||
from: ColonyStateSnapshot,
|
||||
to: ColonyStateSnapshot,
|
||||
event: { id: string; occurredAt: Date },
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
|
||||
// ColonyStatusChanged comes with every status or health edit: decide on
|
||||
@@ -315,16 +412,29 @@ export class AutomationService {
|
||||
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
|
||||
return;
|
||||
}
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
const rule = await readRule(tx, 'COLONY_HEALTH_CRITICAL');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
|
||||
const changedOn = berlinDayOf(event.occurredAt);
|
||||
await this.proposeForRuleIn(tx, 'COLONY_HEALTH_CRITICAL', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
colonyId,
|
||||
intent: 'HEALTH_CHECK',
|
||||
reasonCode: 'COLONY_HEALTH_CRITICAL',
|
||||
evidence: { colonyId, changedOn },
|
||||
anchor: event.id,
|
||||
requiredPermission: 'colonies:read',
|
||||
dueDate: dueDayOf(rule.config, changedOn),
|
||||
sourceEventId: event.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchored on the newest incoming movement: a dismissed reorder stays
|
||||
* dismissed until the item was restocked and ran low again.
|
||||
*/
|
||||
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
|
||||
// Every movement of the item comes here: only one below its minimum
|
||||
@@ -332,22 +442,39 @@ export class AutomationService {
|
||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||
return;
|
||||
}
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'STOCK_ITEM_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
const rule = await readRule(tx, 'STOCK_ITEM_LOW');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
|
||||
const restock = await tx.stockMovement.findFirst({
|
||||
where: { stockItemId: item.id, type: 'IN' },
|
||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: { id: true },
|
||||
});
|
||||
await this.proposeForRuleIn(tx, 'STOCK_ITEM_LOW', {
|
||||
relatedType: 'STORAGE',
|
||||
relatedId: item.id,
|
||||
colonyId: null,
|
||||
intent: 'STOCK_REORDER',
|
||||
reasonCode: 'STOCK_ITEM_LOW',
|
||||
evidence: {
|
||||
stockItemId: item.id,
|
||||
quantity: item.quantity,
|
||||
minThreshold: item.minThreshold,
|
||||
},
|
||||
anchor: restock?.id ?? NO_ANCHOR,
|
||||
requiredPermission: 'storage:read',
|
||||
dueDate: dueDayOf(rule.config, berlinToday()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After raw material left its lots (consumption, withdrawal, inventory):
|
||||
* compares what is left of the kind across all lots with config.minGrams.
|
||||
* The task is about the kind, not one lot - a new lot of the same kind is
|
||||
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
|
||||
* keeps one open task per kind.
|
||||
* The suggestion is about the kind, not one lot - a new lot of the same
|
||||
* kind is the remedy - so it hangs on GENERAL with the kind as relatedId,
|
||||
* which keeps one open suggestion per kind, and is anchored on the newest
|
||||
* lot of the kind.
|
||||
*/
|
||||
async evaluateRawMaterialLowIn(
|
||||
tx: Tx,
|
||||
@@ -355,69 +482,73 @@ export class AutomationService {
|
||||
remainingGrams: number,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
const rule = await readRule(tx, 'RAW_MATERIAL_LOW');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as {
|
||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
||||
} | null;
|
||||
const minGrams = config?.minGrams?.[kind];
|
||||
const minGrams = rule.config.minGrams?.[kind];
|
||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
|
||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
||||
const newestLot = await tx.rawMaterialLot.findFirst({
|
||||
where: { kind },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: { id: true },
|
||||
});
|
||||
await this.proposeForRuleIn(tx, 'RAW_MATERIAL_LOW', {
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: kind,
|
||||
colonyId: null,
|
||||
intent: 'STOCK_REORDER',
|
||||
reasonCode: 'RAW_MATERIAL_LOW',
|
||||
evidence: { kind, remainingGrams, minGrams },
|
||||
anchor: newestLot?.id ?? NO_ANCHOR,
|
||||
requiredPermission: 'raw-material:read',
|
||||
dueDate: dueDayOf(rule.config, berlinToday()),
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
|
||||
const measureDate = treatment.endDate ?? treatment.date;
|
||||
await this.evaluateFollowUpIn(
|
||||
tx,
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
treatment.colonyId,
|
||||
measureDate,
|
||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
|
||||
);
|
||||
const rule = await readRule(tx, 'TREATMENT_FOLLOW_UP');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const endedOn = berlinDayOf(treatment.endDate ?? treatment.date);
|
||||
await this.proposeForRuleIn(tx, 'TREATMENT_FOLLOW_UP', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: treatment.colonyId,
|
||||
colonyId: treatment.colonyId,
|
||||
intent: 'FOLLOW_UP',
|
||||
reasonCode: 'TREATMENT_FOLLOW_UP',
|
||||
evidence: {
|
||||
colonyId: treatment.colonyId,
|
||||
treatmentId: treatment.id,
|
||||
endedOn,
|
||||
},
|
||||
anchor: treatment.id,
|
||||
requiredPermission: 'treatments:read',
|
||||
dueDate: dueDayOf(rule.config, endedOn),
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
|
||||
await this.evaluateFollowUpIn(
|
||||
tx,
|
||||
'FEEDING_FOLLOW_UP',
|
||||
feeding.colonyId,
|
||||
feeding.date,
|
||||
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A check counted from the measure's own date, not from today. Its public
|
||||
* callers check the transaction client, so an error names them.
|
||||
*/
|
||||
private async evaluateFollowUpIn(
|
||||
tx: Tx,
|
||||
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
|
||||
colonyId: string,
|
||||
measureDate: Date,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
const rule = await readRule(tx, 'FEEDING_FOLLOW_UP');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
|
||||
baseDate: measureDate,
|
||||
description,
|
||||
const fedOn = berlinDayOf(feeding.date);
|
||||
await this.proposeForRuleIn(tx, 'FEEDING_FOLLOW_UP', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: feeding.colonyId,
|
||||
colonyId: feeding.colonyId,
|
||||
intent: 'FOLLOW_UP',
|
||||
reasonCode: 'FEEDING_FOLLOW_UP',
|
||||
evidence: { colonyId: feeding.colonyId, feedingId: feeding.id, fedOn },
|
||||
anchor: feeding.id,
|
||||
requiredPermission: 'feedings:read',
|
||||
dueDate: dueDayOf(rule.config, fedOn),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -547,84 +678,80 @@ export class AutomationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves a suggestion task (never a record of work done) plus a warning,
|
||||
* all on the caller's transaction: task, its audit entry and TaskCreated
|
||||
* (TasksService.createInTx - the task.created webhook and the calendar
|
||||
* entry follow from that event), the warning and the rule's audit entry.
|
||||
* A failing write rolls back all of it; a handler delivery is retried.
|
||||
* Proposes what a rule found, on the caller's transaction: the suggestion
|
||||
* (SuggestionsService.proposeIn: colony then rule lock, one open thing per
|
||||
* intent and object, an anchor is proposed once, the notification when the
|
||||
* rule says `notify`) and the rule's audit entry. A failing write rolls
|
||||
* back all of it; a handler delivery is retried. Resolves to null when
|
||||
* nothing was proposed (rule off, colony ended, anchor or intent taken).
|
||||
*
|
||||
* At most one open task per rule and object: while the reorder task for a
|
||||
* stock item or the check task for a colony is still open, further triggers
|
||||
* (the next removal below minimum, a second high varroa count) add nothing.
|
||||
* Repeating the same alarm would only bury the one task that matters.
|
||||
*
|
||||
* The check runs under a lock on the rule's row, held until the caller
|
||||
* commits: two evaluations at the same moment (parallel requests, a
|
||||
* handler and a legacy path, several pods) would otherwise both see
|
||||
* "no open task" and both create one.
|
||||
* The colony is locked and read before the rule is locked - the order
|
||||
* every proposal uses - so a colony that ends in between is not missed.
|
||||
*/
|
||||
private async triggerActionIn(
|
||||
async proposeForRuleIn(
|
||||
tx: Tx,
|
||||
rule: AutomationRule,
|
||||
relatedType: TaskRelatedType,
|
||||
relatedId: string,
|
||||
sourceInspectionId?: string,
|
||||
options: { baseDate?: Date; description?: string } = {},
|
||||
): Promise<void> {
|
||||
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
|
||||
const alreadyOpen = await tx.task.findFirst({
|
||||
where: {
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
relatedType,
|
||||
relatedId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
trigger: RuleSuggestionTrigger,
|
||||
target: RuleSuggestionTarget,
|
||||
): Promise<Suggestion | null> {
|
||||
assertTransactionClient(tx, 'AutomationService.proposeForRuleIn');
|
||||
if (
|
||||
target.colonyId !== null &&
|
||||
!(await this.isLivingColonyLockedIn(tx, target.colonyId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const rule = await lockRuleIn(tx, trigger);
|
||||
if (!rule.active) {
|
||||
return null;
|
||||
}
|
||||
const suggestion = await this.suggestions.proposeIn(tx, {
|
||||
ruleKey: trigger,
|
||||
source: 'RULE',
|
||||
mode: 'PLAN',
|
||||
intent: target.intent,
|
||||
priority: 'HIGH',
|
||||
relatedType: target.relatedType,
|
||||
relatedId: target.relatedId,
|
||||
colonyId: target.colonyId,
|
||||
reasonCode: target.reasonCode,
|
||||
evidence: target.evidence,
|
||||
requiredPermission: target.requiredPermission,
|
||||
dueDate: target.dueDate ?? null,
|
||||
anchor: target.anchor,
|
||||
sourceEventId: target.sourceEventId ?? null,
|
||||
notify: rule.config.notify,
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return;
|
||||
if (suggestion === null) {
|
||||
return null;
|
||||
}
|
||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||
const config = rule.config as { dueInDays?: unknown } | null;
|
||||
const task = await this.tasks.createInTx(
|
||||
tx,
|
||||
{
|
||||
title,
|
||||
priority: 'HIGH',
|
||||
relatedType,
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
description: options.description,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||
},
|
||||
null,
|
||||
);
|
||||
// Task 7 replaces this path by suggestions.
|
||||
await this.notifications.recordForPermission(
|
||||
'tasks:read',
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'SUGGESTION',
|
||||
message: title,
|
||||
relatedType,
|
||||
relatedId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'AutomationRule',
|
||||
entityId: rule.id,
|
||||
action: 'CREATE',
|
||||
afterData: {
|
||||
taskId: task.id,
|
||||
relatedType,
|
||||
relatedId,
|
||||
triggerType: rule.triggerType,
|
||||
suggestionId: suggestion.id,
|
||||
relatedType: target.relatedType,
|
||||
relatedId: target.relatedId,
|
||||
triggerType: trigger,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
private async isLivingColonyLockedIn(
|
||||
tx: Tx,
|
||||
colonyId: string,
|
||||
): Promise<boolean> {
|
||||
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
||||
return false;
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true },
|
||||
});
|
||||
return colony !== null && isLivingColonyStatus(colony.status);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export interface SimpleRuleOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules that create one task: notify, an optional follow-up interval
|
||||
* Rules that propose one suggestion: notify, an optional follow-up interval
|
||||
* (dueInDays) and, for the raw material rule, minGrams per kind.
|
||||
*/
|
||||
export function simpleParser(
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
||||
varroaThresholdOf,
|
||||
} from './varroa-threshold';
|
||||
|
||||
describe('varroaThresholdOf', () => {
|
||||
it('uses the default when no rule exists yet (default rule is active)', () => {
|
||||
expect(varroaThresholdOf(null)).toBe(
|
||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
||||
);
|
||||
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
|
||||
});
|
||||
|
||||
it('returns null for an inactive rule', () => {
|
||||
expect(
|
||||
varroaThresholdOf({
|
||||
active: false,
|
||||
config: { thresholdMitesPerDay: 7 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the configured threshold of an active rule', () => {
|
||||
expect(
|
||||
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('reads naturalFall.redAbove from the band format', () => {
|
||||
expect(
|
||||
varroaThresholdOf({
|
||||
active: true,
|
||||
config: { naturalFall: { redAbove: 8 } },
|
||||
}),
|
||||
).toBe(8);
|
||||
});
|
||||
|
||||
it('prefers the band format over the legacy key', () => {
|
||||
expect(
|
||||
varroaThresholdOf({
|
||||
active: true,
|
||||
config: { thresholdMitesPerDay: 7, naturalFall: { redAbove: 8 } },
|
||||
}),
|
||||
).toBe(8);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a string', { thresholdMitesPerDay: 'x' }],
|
||||
['zero', { thresholdMitesPerDay: 0 }],
|
||||
['a negative number', { thresholdMitesPerDay: -1 }],
|
||||
['NaN', { thresholdMitesPerDay: Number.NaN }],
|
||||
['a missing key', {}],
|
||||
['a null config', null],
|
||||
])('falls back to the default for %s', (_label, config) => {
|
||||
expect(varroaThresholdOf({ active: true, config })).toBe(
|
||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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 =
|
||||
DEFAULT_VARROA_THRESHOLD_CONFIG.naturalFall.redAbove;
|
||||
|
||||
/**
|
||||
* 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 usable
|
||||
* (a hand-edited config must never disable the check).
|
||||
*/
|
||||
export function varroaThresholdOf(
|
||||
rule: Pick<AutomationRule, 'active' | 'config'> | null,
|
||||
): number | null {
|
||||
if (rule === null) {
|
||||
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
||||
}
|
||||
if (!rule.active) {
|
||||
return null;
|
||||
}
|
||||
return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { loadAutopilotInputIn } from './autopilot-input';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
const season = calendarSeasonState(
|
||||
'api-1',
|
||||
'2026-08-20',
|
||||
ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
|
||||
);
|
||||
|
||||
function tx(treatments: object[], varroaCount = 0) {
|
||||
return {
|
||||
colony: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'c1',
|
||||
code: 'B3',
|
||||
status: 'ACTIVE',
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
apiaryId: 'api-1',
|
||||
createdAt: d('2026-04-01'),
|
||||
feedStatus: null,
|
||||
currentQueen: null,
|
||||
}),
|
||||
},
|
||||
inspection: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
varroaMeasurement: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
count: jest.fn().mockResolvedValue(varroaCount),
|
||||
},
|
||||
treatment: {
|
||||
findMany: jest.fn().mockResolvedValue(treatments),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
feeding: { count: jest.fn().mockResolvedValue(0) },
|
||||
winterCheck: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
}
|
||||
|
||||
describe('loadAutopilotInputIn', () => {
|
||||
it('takes the treatment with the latest end day, not the latest date', async () => {
|
||||
const db = tx([
|
||||
{
|
||||
id: 'old',
|
||||
date: d('2026-07-01'),
|
||||
startDate: null,
|
||||
endDate: d('2026-08-10'),
|
||||
},
|
||||
{
|
||||
id: 'backdated',
|
||||
date: d('2026-07-20'),
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
},
|
||||
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
|
||||
]);
|
||||
|
||||
const input = await loadAutopilotInputIn(db as never, 'c1', season);
|
||||
|
||||
expect(input?.lastTreatment).toEqual({
|
||||
id: 'old',
|
||||
startDay: '2026-07-01',
|
||||
endDay: '2026-08-10',
|
||||
});
|
||||
});
|
||||
|
||||
it('counts only measurements after the end day', async () => {
|
||||
const db = tx([
|
||||
{
|
||||
id: 't',
|
||||
date: d('2026-08-01'),
|
||||
startDate: null,
|
||||
endDate: d('2026-08-10'),
|
||||
},
|
||||
]);
|
||||
|
||||
await loadAutopilotInputIn(db as never, 'c1', season);
|
||||
|
||||
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
|
||||
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
|
||||
});
|
||||
});
|
||||
|
||||
it('has no last treatment without treatments', async () => {
|
||||
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season);
|
||||
|
||||
expect(input?.lastTreatment).toBeNull();
|
||||
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { berlinDayOf, dayToDate } from '../common/dates';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import type { AutopilotInput } from './autopilot.types';
|
||||
|
||||
/**
|
||||
* Until Task 27 passes the real start of the late summer, the feedings and
|
||||
* treatments of A4-A6 are counted from this day of the year (the built-in
|
||||
* calendar day of TRACHTENDE).
|
||||
*/
|
||||
const LATE_SUMMER_FALLBACK_START = '08-01';
|
||||
|
||||
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
|
||||
|
||||
function lateSummerStart(season: SeasonState): string | null {
|
||||
return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST'
|
||||
? `${season.year}-${LATE_SUMMER_FALLBACK_START}`
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the autopilot rules need of one colony, in a fixed sequence of
|
||||
* queries (no query per rule, none per record). Resolves to null for a
|
||||
* colony that does not exist. The caller holds the colony row lock, so the
|
||||
* picture stays consistent while the rules run.
|
||||
*/
|
||||
export async function loadAutopilotInputIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
season: SeasonState,
|
||||
): Promise<AutopilotInput | null> {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
status: true,
|
||||
colonyType: true,
|
||||
apiaryId: true,
|
||||
createdAt: true,
|
||||
feedStatus: true,
|
||||
currentQueen: { select: { id: true, hatchYear: true } },
|
||||
},
|
||||
});
|
||||
if (colony === null) {
|
||||
return null;
|
||||
}
|
||||
const inspection = await tx.inspection.findFirst({
|
||||
where: { colonyId },
|
||||
orderBy: [...LATEST_FIRST],
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
colonyStrength: true,
|
||||
feedStatus: true,
|
||||
queenCellsPresent: true,
|
||||
},
|
||||
});
|
||||
const varroa = await tx.varroaMeasurement.findFirst({
|
||||
where: { colonyId },
|
||||
orderBy: [...LATEST_FIRST],
|
||||
select: { id: true, date: true },
|
||||
});
|
||||
// The latest END day wins, not the latest entry: a backdated or
|
||||
// overlapping treatment must not hide one that ends later.
|
||||
const treatments = await tx.treatment.findMany({
|
||||
where: { colonyId },
|
||||
orderBy: [{ date: 'desc' }, { id: 'asc' }],
|
||||
select: { id: true, date: true, startDate: true, endDate: true },
|
||||
});
|
||||
const lastTreatment = treatments
|
||||
.map((treatment) => ({
|
||||
id: treatment.id,
|
||||
startDay: utcDay(treatment.startDate ?? treatment.date),
|
||||
endDay: utcDay(treatment.endDate ?? treatment.date),
|
||||
}))
|
||||
.reduce<{ id: string; startDay: string; endDay: string } | null>(
|
||||
(latest, candidate) =>
|
||||
latest === null || candidate.endDay > latest.endDay
|
||||
? candidate
|
||||
: latest,
|
||||
null,
|
||||
);
|
||||
const varroaSinceLastTreatmentEnd =
|
||||
lastTreatment !== null &&
|
||||
(await tx.varroaMeasurement.count({
|
||||
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
|
||||
})) > 0;
|
||||
const feedingsSinceInspection = await tx.feeding.count({
|
||||
where: {
|
||||
colonyId,
|
||||
...(inspection === null ? {} : { date: { gte: inspection.date } }),
|
||||
},
|
||||
});
|
||||
const lateSummer = lateSummerStart(season);
|
||||
const sinceLateSummer =
|
||||
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
|
||||
const feedingsSinceLateSummer =
|
||||
sinceLateSummer === null
|
||||
? 0
|
||||
: await tx.feeding.count({ where: { colonyId, date: sinceLateSummer } });
|
||||
const treatmentsSinceLateSummer =
|
||||
sinceLateSummer === null
|
||||
? 0
|
||||
: await tx.treatment.count({
|
||||
where: { colonyId, date: sinceLateSummer },
|
||||
});
|
||||
const winterChecks = await tx.winterCheck.findMany({
|
||||
where: { colonyId, winterYear: { gte: season.year - 1 } },
|
||||
select: { winterYear: true, season: true },
|
||||
orderBy: [{ winterYear: 'asc' }, { season: 'asc' }],
|
||||
});
|
||||
return {
|
||||
colony: {
|
||||
id: colony.id,
|
||||
code: colony.code,
|
||||
status: colony.status,
|
||||
colonyType: colony.colonyType,
|
||||
apiaryId: colony.apiaryId,
|
||||
createdOn: berlinDayOf(colony.createdAt),
|
||||
feedStatus: colony.feedStatus,
|
||||
},
|
||||
lastInspection:
|
||||
inspection === null
|
||||
? null
|
||||
: { ...inspection, date: utcDay(inspection.date) },
|
||||
lastVarroa:
|
||||
varroa === null ? null : { id: varroa.id, date: utcDay(varroa.date) },
|
||||
lastTreatment,
|
||||
varroaSinceLastTreatmentEnd,
|
||||
feedingsSinceInspection,
|
||||
feedingsSinceLateSummer,
|
||||
treatmentsSinceLateSummer,
|
||||
queen: colony.currentQueen,
|
||||
winterChecks,
|
||||
season,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { evaluateAutopilot } from './autopilot-rules';
|
||||
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
|
||||
|
||||
type RuleName = 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL' | 'VARROA_THRESHOLD';
|
||||
|
||||
function activeRule(trigger: RuleName, override?: object) {
|
||||
return {
|
||||
id: null,
|
||||
active: true,
|
||||
config: ruleConfigOf(trigger, {
|
||||
...DEFAULT_RULES[trigger].config,
|
||||
...override,
|
||||
}),
|
||||
actionTemplate: null,
|
||||
};
|
||||
}
|
||||
|
||||
function configs(autopilotOverride?: object): AutopilotConfigs {
|
||||
return {
|
||||
autopilot: activeRule('SEASON_AUTOPILOT', autopilotOverride),
|
||||
interval: activeRule('INSPECTION_INTERVAL'),
|
||||
varroa: activeRule('VARROA_THRESHOLD'),
|
||||
phenology: ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
|
||||
} as AutopilotConfigs;
|
||||
}
|
||||
|
||||
function input(
|
||||
today: string,
|
||||
patch: Partial<AutopilotInput> = {},
|
||||
): AutopilotInput {
|
||||
return {
|
||||
colony: {
|
||||
id: 'col_1',
|
||||
code: 'B3',
|
||||
status: 'ACTIVE',
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
apiaryId: 'api_1',
|
||||
createdOn: '2025-04-01',
|
||||
feedStatus: null,
|
||||
},
|
||||
lastInspection: null,
|
||||
lastVarroa: { id: 'var_1', date: today },
|
||||
lastTreatment: null,
|
||||
varroaSinceLastTreatmentEnd: false,
|
||||
feedingsSinceInspection: 0,
|
||||
feedingsSinceLateSummer: 0,
|
||||
treatmentsSinceLateSummer: 0,
|
||||
queen: null,
|
||||
winterChecks: [],
|
||||
season: calendarSeasonState('api_1', today, configs().phenology),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
const inspectionOn = (date: string) => ({
|
||||
id: 'insp_9',
|
||||
date,
|
||||
colonyStrength: null,
|
||||
feedStatus: null,
|
||||
queenCellsPresent: null,
|
||||
});
|
||||
|
||||
const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
|
||||
|
||||
describe('autopilot rules', () => {
|
||||
it('A1: last inspection 8 days ago in SCHWARMZEIT -> INSPECT overdue, HIGH, anchor = inspection id', () => {
|
||||
const today = '2026-05-12';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, { lastInspection: inspectionOn('2026-05-04') }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
rule: 'A1',
|
||||
ruleKey: 'INSPECTION_INTERVAL',
|
||||
intent: 'INSPECT',
|
||||
mode: 'DO',
|
||||
reasonCode: 'INSPECTION_DUE',
|
||||
dueDate: '2026-05-11',
|
||||
priority: 'HIGH',
|
||||
anchor: 'insp_9',
|
||||
requiredPermission: 'inspections:read',
|
||||
});
|
||||
expect(result[0].evidence).toMatchObject({
|
||||
colonyId: 'col_1',
|
||||
inspectionId: 'insp_9',
|
||||
lastInspectedOn: '2026-05-04',
|
||||
intervalDays: 7,
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
});
|
||||
|
||||
it('A1: due in 2 days -> NORMAL; due in 3 days -> nothing; WINTERRUHE -> nothing', () => {
|
||||
const today = '2026-05-12';
|
||||
const inTwo = evaluateAutopilot(
|
||||
input(today, { lastInspection: inspectionOn('2026-05-07') }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
expect(inTwo[0]).toMatchObject({
|
||||
dueDate: '2026-05-14',
|
||||
priority: 'NORMAL',
|
||||
});
|
||||
|
||||
const inThree = evaluateAutopilot(
|
||||
input(today, { lastInspection: inspectionOn('2026-05-08') }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
expect(inThree).toEqual([]);
|
||||
|
||||
const winter = '2026-12-05';
|
||||
expect(
|
||||
evaluateAutopilot(
|
||||
input(winter, {
|
||||
lastInspection: inspectionOn('2026-09-01'),
|
||||
lastVarroa: { id: 'var_1', date: '2026-12-01' },
|
||||
}),
|
||||
configs(),
|
||||
winter,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('A1: due today is not overdue; no inspection ever uses the anchor none', () => {
|
||||
const today = '2026-05-12';
|
||||
const dueToday = evaluateAutopilot(
|
||||
input(today, { lastInspection: inspectionOn('2026-05-05') }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
expect(dueToday[0].priority).toBe('NORMAL');
|
||||
|
||||
const base = input(today);
|
||||
const never = evaluateAutopilot(
|
||||
input(today, { colony: { ...base.colony, createdOn: '2026-05-01' } }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
expect(never[0]).toMatchObject({ anchor: 'none', dueDate: '2026-05-08' });
|
||||
expect(never[0].evidence).toMatchObject({
|
||||
inspectionId: null,
|
||||
lastInspectedOn: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('A1: last inspection before spring start -> INSPECTION_FIRST_OF_YEAR', () => {
|
||||
const today = '2026-04-01';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, { lastInspection: inspectionOn('2025-09-20') }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
|
||||
dueDate: '2026-03-20',
|
||||
priority: 'HIGH',
|
||||
anchor: 'insp_9',
|
||||
});
|
||||
});
|
||||
|
||||
it('A2: last measurement 35 days ago -> VARROA_MEASURE due day 28', () => {
|
||||
const today = '2026-06-10';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, {
|
||||
lastInspection: inspectionOn('2026-06-08'),
|
||||
lastVarroa: { id: 'var_7', date: '2026-05-06' },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
rule: 'A2',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'VARROA_MEASURE',
|
||||
mode: 'DO',
|
||||
reasonCode: 'VARROA_MEASURE_DUE',
|
||||
dueDate: '2026-06-03',
|
||||
priority: 'NORMAL',
|
||||
anchor: 'var_7',
|
||||
requiredPermission: 'varroa:read',
|
||||
});
|
||||
});
|
||||
|
||||
it('A2: a measurement 27 days ago yields nothing', () => {
|
||||
const today = '2026-06-10';
|
||||
expect(
|
||||
evaluateAutopilot(
|
||||
input(today, {
|
||||
lastInspection: inspectionOn('2026-06-08'),
|
||||
lastVarroa: { id: 'var_7', date: '2026-05-14' },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('A2: winter window without a measurement since 11-01 -> anchor winter-2026', () => {
|
||||
const today = '2026-11-20';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, { lastVarroa: { id: 'var_2', date: '2026-09-01' } }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
intent: 'VARROA_MEASURE',
|
||||
reasonCode: 'VARROA_WINTER_MEASURE',
|
||||
dueDate: '2026-11-15',
|
||||
anchor: 'winter-2026',
|
||||
});
|
||||
});
|
||||
|
||||
it('A3: treatment ended 14 days ago without a measurement since -> VARROA_RECHECK, anchor treatment id', () => {
|
||||
const today = '2026-08-16';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, {
|
||||
lastInspection: inspectionOn('2026-08-10'),
|
||||
lastVarroa: { id: 'var_3', date: '2026-08-01' },
|
||||
lastTreatment: TREATMENT,
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
rule: 'A3',
|
||||
intent: 'VARROA_RECHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'VARROA_RECHECK_DUE',
|
||||
dueDate: '2026-08-16',
|
||||
priority: 'NORMAL',
|
||||
anchor: 'tre_1',
|
||||
requiredPermission: 'varroa:read',
|
||||
});
|
||||
expect(result[0].evidence).toMatchObject({
|
||||
treatmentId: 'tre_1',
|
||||
endedOn: '2026-08-02',
|
||||
});
|
||||
});
|
||||
|
||||
it('A3: nothing 13 days after the end, nothing once a measurement exists since', () => {
|
||||
const early = '2026-08-15';
|
||||
expect(
|
||||
evaluateAutopilot(
|
||||
input(early, {
|
||||
lastInspection: inspectionOn('2026-08-10'),
|
||||
lastTreatment: TREATMENT,
|
||||
}),
|
||||
configs(),
|
||||
early,
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
const late = '2026-08-20';
|
||||
expect(
|
||||
evaluateAutopilot(
|
||||
input(late, {
|
||||
lastInspection: inspectionOn('2026-08-15'),
|
||||
lastTreatment: TREATMENT,
|
||||
varroaSinceLastTreatmentEnd: true,
|
||||
}),
|
||||
configs(),
|
||||
late,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('A3: honours recheckAfterDays of the varroa rule', () => {
|
||||
const today = '2026-08-09';
|
||||
const cfg = configs();
|
||||
cfg.varroa = activeRule('VARROA_THRESHOLD', {
|
||||
recheckAfterDays: 7,
|
||||
}) as AutopilotConfigs['varroa'];
|
||||
const result = evaluateAutopilot(
|
||||
input(today, {
|
||||
lastInspection: inspectionOn('2026-08-05'),
|
||||
lastTreatment: TREATMENT,
|
||||
}),
|
||||
cfg,
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result.map((c) => c.rule)).toEqual(['A3']);
|
||||
expect(result[0].dueDate).toBe('2026-08-09');
|
||||
});
|
||||
|
||||
it('returns the candidates in the order A1, A2', () => {
|
||||
const today = '2026-06-10';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, {
|
||||
lastInspection: inspectionOn('2026-05-20'),
|
||||
lastVarroa: { id: 'var_7', date: '2026-04-20' },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2']);
|
||||
});
|
||||
|
||||
it('A2 is not proposed while A3 applies: the recheck is the measurement', () => {
|
||||
const today = '2026-06-10';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, {
|
||||
lastInspection: inspectionOn('2026-05-20'),
|
||||
lastVarroa: { id: 'var_7', date: '2026-04-20' },
|
||||
lastTreatment: {
|
||||
id: 'tre_1',
|
||||
startDay: '2026-05-01',
|
||||
endDay: '2026-05-10',
|
||||
},
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(result.map((c) => c.rule)).toEqual(['A1', 'A3']);
|
||||
});
|
||||
|
||||
it('A3 only looks at treatments that ended at most 60 days ago', () => {
|
||||
const today = '2026-08-16';
|
||||
const base = { lastInspection: inspectionOn('2026-08-10') };
|
||||
const at = (endDay: string) =>
|
||||
evaluateAutopilot(
|
||||
input(today, {
|
||||
...base,
|
||||
lastTreatment: { id: 'tre_1', startDay: '2026-05-01', endDay },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
).filter((c) => c.rule === 'A3');
|
||||
|
||||
expect(at('2026-06-17')).toHaveLength(1); // 60 days ago
|
||||
expect(at('2026-06-16')).toHaveLength(0); // 61 days ago
|
||||
});
|
||||
|
||||
it('a rule switched off in config.rules yields nothing', () => {
|
||||
const today = '2026-05-12';
|
||||
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
|
||||
expect(
|
||||
evaluateAutopilot(base, configs(), today).map((c) => c.rule),
|
||||
).toEqual(['A1']);
|
||||
|
||||
const a1Off = configs({
|
||||
rules: { ...configs().autopilot.config.rules, A1: false },
|
||||
});
|
||||
expect(evaluateAutopilot(base, a1Off, today)).toEqual([]);
|
||||
});
|
||||
|
||||
it('SEASON_AUTOPILOT switched off yields nothing; INSPECTION_INTERVAL off only silences A1', () => {
|
||||
const today = '2026-05-12';
|
||||
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
|
||||
const off = configs();
|
||||
off.autopilot = { ...off.autopilot, active: false };
|
||||
expect(evaluateAutopilot(base, off, today)).toEqual([]);
|
||||
|
||||
const noInterval = configs();
|
||||
noInterval.interval = { ...noInterval.interval, active: false };
|
||||
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { AutopilotRuleId } from '../automation/rule-config';
|
||||
import { addDays, daysBetween } from '../common/dates';
|
||||
import { inspectionDueOn } from '../season/inspection-due';
|
||||
import { varroaMeasurementDue } from '../season/varroa-due';
|
||||
import type {
|
||||
AutopilotCandidate,
|
||||
AutopilotConfigs,
|
||||
AutopilotInput,
|
||||
AutopilotRule,
|
||||
} from './autopilot.types';
|
||||
|
||||
/**
|
||||
* A3 only follows treatments that ended at most this many days ago, so old
|
||||
* history without a later measurement does not produce suggestions.
|
||||
*/
|
||||
const RECHECK_MAX_AGE_DAYS = 60;
|
||||
|
||||
/** A1: an inspection is due (or nearly) and the colony is not in winter rest. */
|
||||
const INSPECTION_LEAD_DAYS = 2;
|
||||
|
||||
const a1Inspection: AutopilotRule = (input, configs, today) => {
|
||||
if (!configs.interval.active) {
|
||||
return null;
|
||||
}
|
||||
const due = inspectionDueOn(
|
||||
{
|
||||
createdOn: input.colony.createdOn,
|
||||
lastInspectedOn: input.lastInspection?.date ?? null,
|
||||
},
|
||||
input.season,
|
||||
configs.interval.config,
|
||||
);
|
||||
if (due === null || due.dueOn > addDays(today, INSPECTION_LEAD_DAYS)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A1',
|
||||
ruleKey: 'INSPECTION_INTERVAL',
|
||||
intent: 'INSPECT',
|
||||
mode: 'DO',
|
||||
reasonCode: due.firstOfYear ? 'INSPECTION_FIRST_OF_YEAR' : 'INSPECTION_DUE',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
inspectionId: input.lastInspection?.id ?? null,
|
||||
lastInspectedOn: input.lastInspection?.date ?? null,
|
||||
dueOn: due.dueOn,
|
||||
intervalDays: due.intervalDays,
|
||||
segment: input.season.segment,
|
||||
},
|
||||
dueDate: due.dueOn,
|
||||
priority: due.dueOn < today ? 'HIGH' : 'NORMAL',
|
||||
anchor: input.lastInspection?.id ?? 'none',
|
||||
requiredPermission: 'inspections:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A2: the varroa measurement interval ran out, or the winter window is open. */
|
||||
const a2VarroaMeasure: AutopilotRule = (input, configs, today) => {
|
||||
const due = varroaMeasurementDue(
|
||||
{
|
||||
createdOn: input.colony.createdOn,
|
||||
lastMeasuredOn: input.lastVarroa?.date ?? null,
|
||||
lastMeasurementId: input.lastVarroa?.id ?? null,
|
||||
},
|
||||
input.season,
|
||||
configs.autopilot.config,
|
||||
today,
|
||||
);
|
||||
if (due === null || due.dueOn > today) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A2',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'VARROA_MEASURE',
|
||||
mode: 'DO',
|
||||
reasonCode:
|
||||
due.kind === 'WINTER_WINDOW'
|
||||
? 'VARROA_WINTER_MEASURE'
|
||||
: 'VARROA_MEASURE_DUE',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
measurementId: input.lastVarroa?.id ?? null,
|
||||
lastMeasuredOn: input.lastVarroa?.date ?? null,
|
||||
dueOn: due.dueOn,
|
||||
intervalDays: configs.autopilot.config.varroaMeasureIntervalDays,
|
||||
},
|
||||
dueDate: due.dueOn,
|
||||
priority: 'NORMAL',
|
||||
anchor: due.anchor,
|
||||
requiredPermission: 'varroa:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A3: a treatment ended long enough ago and nothing was measured since. */
|
||||
const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
|
||||
const treatment = input.lastTreatment;
|
||||
if (
|
||||
treatment === null ||
|
||||
input.varroaSinceLastTreatmentEnd ||
|
||||
daysBetween(treatment.endDay, today) > RECHECK_MAX_AGE_DAYS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const recheckAfterDays = configs.varroa.config.recheckAfterDays;
|
||||
const dueOn = addDays(treatment.endDay, recheckAfterDays);
|
||||
if (dueOn > today) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A3',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'VARROA_RECHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'VARROA_RECHECK_DUE',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
treatmentId: treatment.id,
|
||||
endedOn: treatment.endDay,
|
||||
recheckOn: dueOn,
|
||||
},
|
||||
dueDate: dueOn,
|
||||
priority: 'NORMAL',
|
||||
anchor: treatment.id,
|
||||
// The action is a varroa measurement.
|
||||
requiredPermission: 'varroa:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A4-A10 follow with Task 27. */
|
||||
export const AUTOPILOT_RULES: Readonly<
|
||||
Partial<Record<AutopilotRuleId, AutopilotRule>>
|
||||
> = {
|
||||
A1: a1Inspection,
|
||||
A2: a2VarroaMeasure,
|
||||
A3: a3VarroaRecheck,
|
||||
};
|
||||
|
||||
const RULE_ORDER: readonly AutopilotRuleId[] = [
|
||||
'A1',
|
||||
'A2',
|
||||
'A3',
|
||||
'A4',
|
||||
'A5',
|
||||
'A6',
|
||||
'A7',
|
||||
'A8',
|
||||
'A9',
|
||||
'A10',
|
||||
];
|
||||
|
||||
/**
|
||||
* The candidates for one colony, always in the order A1...A10 so concurrent
|
||||
* syncs take the proposal locks in the same order. A switched-off autopilot
|
||||
* or rule yields nothing.
|
||||
*/
|
||||
export function evaluateAutopilot(
|
||||
input: AutopilotInput,
|
||||
configs: AutopilotConfigs,
|
||||
today: string,
|
||||
): AutopilotCandidate[] {
|
||||
if (!configs.autopilot.active) {
|
||||
return [];
|
||||
}
|
||||
const candidates: AutopilotCandidate[] = [];
|
||||
for (const id of RULE_ORDER) {
|
||||
const rule = AUTOPILOT_RULES[id];
|
||||
if (rule === undefined || !configs.autopilot.config.rules[id]) {
|
||||
continue;
|
||||
}
|
||||
const candidate = rule(input, configs, today);
|
||||
if (candidate !== null) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
// The recheck after a treatment is the measurement: A2 would ask twice.
|
||||
return candidates.some((candidate) => candidate.rule === 'A3')
|
||||
? candidates.filter((candidate) => candidate.rule !== 'A2')
|
||||
: candidates;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import { AutopilotEventHandlers } from './autopilot.handlers';
|
||||
import type { AutopilotService } from './autopilot.service';
|
||||
|
||||
const EXPECTED_TYPES: DomainEventType[] = [
|
||||
'InspectionRecorded',
|
||||
'InspectionCorrected',
|
||||
'VarroaMeasured',
|
||||
'VarroaCorrected',
|
||||
'TreatmentRecorded',
|
||||
'TreatmentCorrected',
|
||||
'FeedingRecorded',
|
||||
'FeedingCorrected',
|
||||
'HarvestRecorded',
|
||||
'HarvestCorrected',
|
||||
'ColonyStatusChanged',
|
||||
'ColonySplit',
|
||||
'QueenAssigned',
|
||||
'TaskCompleted',
|
||||
'TaskUpdated',
|
||||
'WithdrawalEnded',
|
||||
'QueenAged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
];
|
||||
|
||||
function event(
|
||||
type: DomainEventType,
|
||||
aggregateId: string,
|
||||
payload: object,
|
||||
): AnyDomainEventEnvelope {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
type,
|
||||
aggregateType: 'Colony',
|
||||
aggregateId,
|
||||
payload,
|
||||
actorUserId: null,
|
||||
correlationId: 'cor-1',
|
||||
occurredAt: new Date(),
|
||||
attempts: 0,
|
||||
} as AnyDomainEventEnvelope;
|
||||
}
|
||||
|
||||
describe('AutopilotEventHandlers', () => {
|
||||
let autopilot: { syncColonyIn: jest.Mock };
|
||||
let tx: { task: { findUnique: jest.Mock } };
|
||||
let handlers: AutopilotEventHandlers;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
|
||||
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
|
||||
tx = { task: { findUnique: jest.fn() } };
|
||||
handlers = new AutopilotEventHandlers(
|
||||
autopilot as unknown as AutopilotService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const run = (e: AnyDomainEventEnvelope) =>
|
||||
handlers.colonySync(
|
||||
e as Parameters<AutopilotEventHandlers['colonySync']>[0],
|
||||
tx as unknown as Prisma.TransactionClient,
|
||||
);
|
||||
const synced = () =>
|
||||
autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]);
|
||||
|
||||
it('is registered as autopilot.colony-sync for the listed events', () => {
|
||||
const entries = domainEventHandlerMetadata(
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
AutopilotEventHandlers.prototype.colonySync,
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].handlerId).toBe('autopilot.colony-sync');
|
||||
expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort());
|
||||
});
|
||||
|
||||
it.each([
|
||||
['InspectionRecorded', { colonyId: 'c1' }],
|
||||
['InspectionCorrected', { colonyId: 'c1' }],
|
||||
['VarroaMeasured', { colonyId: 'c1' }],
|
||||
['VarroaCorrected', { colonyId: 'c1' }],
|
||||
['TreatmentRecorded', { colonyId: 'c1' }],
|
||||
['TreatmentCorrected', { colonyId: 'c1' }],
|
||||
['FeedingRecorded', { colonyId: 'c1' }],
|
||||
['FeedingCorrected', { colonyId: 'c1' }],
|
||||
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
|
||||
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
|
||||
] as [DomainEventType, object][])(
|
||||
'%s syncs the colony of its payload for the Berlin day',
|
||||
async (type, payload) => {
|
||||
await run(event(type, 'aggregate-1', payload));
|
||||
|
||||
expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1);
|
||||
expect(autopilot.syncColonyIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'c1',
|
||||
'2026-05-12',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'ColonyStatusChanged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
] as const)('%s syncs the aggregate colony', async (type) => {
|
||||
await run(event(type, 'c9', {}));
|
||||
|
||||
expect(synced()).toEqual(['c9']);
|
||||
});
|
||||
|
||||
it('syncs every colony of a harvest, in id order', async () => {
|
||||
await run(
|
||||
event('HarvestRecorded', 'h1', {
|
||||
apiaryId: 'a',
|
||||
colonyIds: ['c3', 'c1', 'c2', 'c1'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(synced()).toEqual(['c1', 'c2', 'c3']);
|
||||
});
|
||||
|
||||
it('syncs the new and the parent colony of a split', async () => {
|
||||
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
|
||||
|
||||
expect(synced()).toEqual(['mother', 'new']);
|
||||
});
|
||||
|
||||
it('syncs the colony and the previous colony of a queen assignment', async () => {
|
||||
await run(
|
||||
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }),
|
||||
);
|
||||
await run(
|
||||
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }),
|
||||
);
|
||||
|
||||
expect(synced()).toEqual(['c1', 'c2', 'c2']);
|
||||
});
|
||||
|
||||
it('syncs a colony task on completion and update, nothing for other tasks', async () => {
|
||||
await run(
|
||||
event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }),
|
||||
);
|
||||
await run(
|
||||
event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }),
|
||||
);
|
||||
tx.task.findUnique
|
||||
.mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' })
|
||||
.mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' })
|
||||
.mockResolvedValueOnce(null);
|
||||
await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null }));
|
||||
await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null }));
|
||||
await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null }));
|
||||
|
||||
expect(synced()).toEqual(['c1', 'c4']);
|
||||
});
|
||||
|
||||
it('does nothing for a queen event without a colony', async () => {
|
||||
await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 }));
|
||||
|
||||
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets errors propagate so the delivery is retried', async () => {
|
||||
autopilot.syncColonyIn.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { AutopilotService } from './autopilot.service';
|
||||
|
||||
const COLONY_SYNC_EVENTS = [
|
||||
'InspectionRecorded',
|
||||
'InspectionCorrected',
|
||||
'VarroaMeasured',
|
||||
'VarroaCorrected',
|
||||
'TreatmentRecorded',
|
||||
'TreatmentCorrected',
|
||||
'FeedingRecorded',
|
||||
'FeedingCorrected',
|
||||
'HarvestRecorded',
|
||||
'HarvestCorrected',
|
||||
'ColonyStatusChanged',
|
||||
'ColonySplit',
|
||||
'QueenAssigned',
|
||||
'TaskCompleted',
|
||||
'TaskUpdated',
|
||||
'WithdrawalEnded',
|
||||
'QueenAged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
] as const satisfies readonly DomainEventType[];
|
||||
|
||||
type ColonySyncEvent = AnyDomainEventEnvelope<
|
||||
(typeof COLONY_SYNC_EVENTS)[number]
|
||||
>;
|
||||
|
||||
/** The colony of a task, if it is about one. */
|
||||
async function taskColonyIdIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
taskId: string,
|
||||
): Promise<string | null> {
|
||||
const task = await tx.task.findUnique({
|
||||
where: { id: taskId },
|
||||
select: { relatedType: true, relatedId: true },
|
||||
});
|
||||
return task?.relatedType === 'COLONY' ? task.relatedId : null;
|
||||
}
|
||||
|
||||
/** The colonies an event is about; none if it is about no colony. */
|
||||
async function colonyIdsOf(
|
||||
event: ColonySyncEvent,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<(string | null)[]> {
|
||||
switch (event.type) {
|
||||
case 'InspectionRecorded':
|
||||
case 'InspectionCorrected':
|
||||
case 'VarroaMeasured':
|
||||
case 'VarroaCorrected':
|
||||
case 'TreatmentRecorded':
|
||||
case 'TreatmentCorrected':
|
||||
case 'FeedingRecorded':
|
||||
case 'FeedingCorrected':
|
||||
case 'WithdrawalEnded':
|
||||
case 'QueenAged':
|
||||
return [event.payload.colonyId];
|
||||
case 'HarvestRecorded':
|
||||
case 'HarvestCorrected':
|
||||
return event.payload.colonyIds;
|
||||
case 'ColonyStatusChanged':
|
||||
case 'InspectionOverdue':
|
||||
case 'VarroaMeasurementDue':
|
||||
return [event.aggregateId];
|
||||
case 'ColonySplit':
|
||||
// The aggregate is the new colony.
|
||||
return [event.aggregateId, event.payload.parentColonyId];
|
||||
case 'QueenAssigned':
|
||||
return [event.payload.colonyId, event.payload.previousColonyId];
|
||||
case 'TaskCompleted':
|
||||
return [
|
||||
event.payload.relatedType === 'COLONY' ? event.payload.relatedId : null,
|
||||
];
|
||||
case 'TaskUpdated':
|
||||
return [await taskColonyIdIn(tx, event.aggregateId)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the autopilot suggestions of the colonies an event touches.
|
||||
* Pure DB writer through the delivery transaction; the colonies are synced
|
||||
* in id order, so two deliveries never lock them the other way round.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AutopilotEventHandlers {
|
||||
constructor(private readonly autopilot: AutopilotService) {}
|
||||
|
||||
@OnDomainEvent([...COLONY_SYNC_EVENTS], 'autopilot.colony-sync')
|
||||
async colonySync(
|
||||
event: ColonySyncEvent,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
const colonyIds = [
|
||||
...new Set((await colonyIdsOf(event, tx)).filter((id) => id !== null)),
|
||||
].sort();
|
||||
if (colonyIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const today = berlinToday();
|
||||
for (const colonyId of colonyIds) {
|
||||
await this.autopilot.syncColonyIn(tx, colonyId, today);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeasonModule } from '../season/season.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { AutopilotEventHandlers } from './autopilot.handlers';
|
||||
import { AutopilotService } from './autopilot.service';
|
||||
import { AutopilotStep } from './autopilot.step';
|
||||
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
|
||||
|
||||
/**
|
||||
* The season autopilot: the event handler and the daily steps that keep the
|
||||
* autopilot suggestions of each colony in line with the rules. Imported by
|
||||
* AppModule and WorkerModule (the handler) and by JobsModule (the steps).
|
||||
*/
|
||||
@Module({
|
||||
imports: [SeasonModule, SuggestionsModule],
|
||||
providers: [
|
||||
AutopilotService,
|
||||
AutopilotEventHandlers,
|
||||
AutopilotStep,
|
||||
SuggestionsExpiryStep,
|
||||
],
|
||||
exports: [AutopilotService, AutopilotStep, SuggestionsExpiryStep],
|
||||
})
|
||||
export class AutopilotModule {}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
DEFAULT_RULES,
|
||||
readRule,
|
||||
ruleConfigOf,
|
||||
} from '../automation/rule-config';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import type { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
import { loadAutopilotInputIn } from './autopilot-input';
|
||||
import { AutopilotService } from './autopilot.service';
|
||||
import type { AutopilotInput } from './autopilot.types';
|
||||
|
||||
jest.mock('../prisma/row-lock');
|
||||
jest.mock('../automation/rule-config', () => ({
|
||||
...jest.requireActual<object>('../automation/rule-config'),
|
||||
readRule: jest.fn(),
|
||||
}));
|
||||
jest.mock('./autopilot-input');
|
||||
|
||||
const lockRow = jest.mocked(lockRowForUpdate);
|
||||
const readRuleMock = jest.mocked(readRule);
|
||||
const loadInput = jest.mocked(loadAutopilotInputIn);
|
||||
|
||||
const TODAY = '2026-05-12';
|
||||
const PHENOLOGY = ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config);
|
||||
|
||||
function rule(trigger: string, override: object = {}, active = true) {
|
||||
return {
|
||||
id: null,
|
||||
active,
|
||||
config: ruleConfigOf(trigger as never, {
|
||||
...DEFAULT_RULES[trigger as 'SEASON_AUTOPILOT'].config,
|
||||
...override,
|
||||
}),
|
||||
actionTemplate: null,
|
||||
};
|
||||
}
|
||||
|
||||
function input(lastInspectedOn: string | null): AutopilotInput {
|
||||
return {
|
||||
colony: {
|
||||
id: 'col-1',
|
||||
code: 'B3',
|
||||
status: 'ACTIVE',
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
apiaryId: 'api-1',
|
||||
createdOn: '2026-04-20',
|
||||
feedStatus: null,
|
||||
},
|
||||
lastInspection:
|
||||
lastInspectedOn === null
|
||||
? null
|
||||
: {
|
||||
id: 'insp-9',
|
||||
date: lastInspectedOn,
|
||||
colonyStrength: null,
|
||||
feedStatus: null,
|
||||
queenCellsPresent: null,
|
||||
},
|
||||
lastVarroa: { id: 'var-1', date: '2026-05-10' },
|
||||
lastTreatment: null,
|
||||
varroaSinceLastTreatmentEnd: false,
|
||||
feedingsSinceInspection: 0,
|
||||
feedingsSinceLateSummer: 0,
|
||||
treatmentsSinceLateSummer: 0,
|
||||
queen: null,
|
||||
winterChecks: [],
|
||||
season: calendarSeasonState('api-1', TODAY, PHENOLOGY),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AutopilotService', () => {
|
||||
let tx: {
|
||||
$connect?: undefined;
|
||||
colony: { findUnique: jest.Mock };
|
||||
suggestion: { findMany: jest.Mock };
|
||||
};
|
||||
let suggestions: {
|
||||
proposeIn: jest.Mock;
|
||||
refreshIn: jest.Mock;
|
||||
resolveIn: jest.Mock;
|
||||
};
|
||||
let seasons: { statesFor: jest.Mock };
|
||||
let service: AutopilotService;
|
||||
let rules: Record<string, ReturnType<typeof rule>>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
tx = {
|
||||
colony: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
|
||||
},
|
||||
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
suggestions = {
|
||||
proposeIn: jest.fn().mockResolvedValue({ id: 'new' }),
|
||||
refreshIn: jest.fn().mockResolvedValue(undefined),
|
||||
resolveIn: jest
|
||||
.fn()
|
||||
.mockImplementation((_tx, ids: string[]) => ids.length),
|
||||
};
|
||||
seasons = {
|
||||
statesFor: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
|
||||
),
|
||||
};
|
||||
rules = {
|
||||
SEASON_AUTOPILOT: rule('SEASON_AUTOPILOT'),
|
||||
INSPECTION_INTERVAL: rule('INSPECTION_INTERVAL'),
|
||||
VARROA_THRESHOLD: rule('VARROA_THRESHOLD'),
|
||||
PHENOLOGY: rule('PHENOLOGY'),
|
||||
};
|
||||
readRuleMock.mockImplementation((_db, trigger) =>
|
||||
Promise.resolve(rules[trigger] as never),
|
||||
);
|
||||
lockRow.mockResolvedValue(true);
|
||||
loadInput.mockResolvedValue(input('2026-05-03'));
|
||||
service = new AutopilotService(
|
||||
suggestions as unknown as SuggestionsService,
|
||||
seasons,
|
||||
);
|
||||
});
|
||||
|
||||
const sync = () => service.syncColonyIn(tx as never, 'col-1', TODAY);
|
||||
|
||||
it('locks the colony row first and does nothing for an ended colony', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
status: 'DECEASED',
|
||||
apiaryId: 'api-1',
|
||||
});
|
||||
|
||||
const result = await sync();
|
||||
|
||||
expect(lockRow).toHaveBeenCalledWith(tx, 'Colony', 'col-1');
|
||||
expect(lockRow.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.colony.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
|
||||
expect(suggestions.proposeIn).not.toHaveBeenCalled();
|
||||
expect(loadInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the colony row is gone', async () => {
|
||||
lockRow.mockResolvedValue(false);
|
||||
|
||||
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
|
||||
expect(tx.colony.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proposes a new candidate with the autopilot source, dedupe inputs and expiry', async () => {
|
||||
const result = await sync();
|
||||
|
||||
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
|
||||
expect(suggestions.proposeIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
ruleKey: 'INSPECTION_INTERVAL',
|
||||
source: 'AUTOPILOT',
|
||||
mode: 'DO',
|
||||
intent: 'INSPECT',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: 'col-1',
|
||||
colonyId: 'col-1',
|
||||
reasonCode: 'INSPECTION_DUE',
|
||||
dueDate: '2026-05-10',
|
||||
anchor: 'insp-9',
|
||||
requiredPermission: 'inspections:read',
|
||||
expiresAt: '2026-06-11',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an overdue suggestion alive for the full ttl from today', async () => {
|
||||
loadInput.mockResolvedValue(input('2026-03-25'));
|
||||
|
||||
await sync();
|
||||
|
||||
expect(suggestions.proposeIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
dueDate: '2026-04-01',
|
||||
expiresAt: '2026-06-11',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes an open suggestion with the same key instead of creating one', async () => {
|
||||
tx.suggestion.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'sug-1',
|
||||
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-9',
|
||||
expiresAt: new Date('2026-06-01T00:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sync();
|
||||
|
||||
expect(result).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
|
||||
expect(suggestions.proposeIn).not.toHaveBeenCalled();
|
||||
expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'sug-1', {
|
||||
reasonCode: 'INSPECTION_DUE',
|
||||
evidence: expect.objectContaining({ inspectionId: 'insp-9' }) as object,
|
||||
dueDate: '2026-05-10',
|
||||
priority: 'HIGH',
|
||||
expiresAt: '2026-06-01',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a candidate when proposeIn finds an open task of the same intent', async () => {
|
||||
suggestions.proposeIn.mockResolvedValue(null);
|
||||
|
||||
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
|
||||
});
|
||||
|
||||
it('resolves open autopilot suggestions whose key vanished, before proposing, never rule or chain ones', async () => {
|
||||
tx.suggestion.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'sug-old',
|
||||
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-1',
|
||||
expiresAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await sync();
|
||||
|
||||
expect(tx.suggestion.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { colonyId: 'col-1', source: 'AUTOPILOT', status: 'OPEN' },
|
||||
}),
|
||||
);
|
||||
expect(suggestions.resolveIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
['sug-old'],
|
||||
'Bedingung entfällt',
|
||||
);
|
||||
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 1 });
|
||||
expect(suggestions.resolveIn.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
suggestions.proposeIn.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves every open autopilot suggestion when SEASON_AUTOPILOT is off', async () => {
|
||||
rules.SEASON_AUTOPILOT = rule('SEASON_AUTOPILOT', {}, false);
|
||||
tx.suggestion.findMany.mockResolvedValue([
|
||||
{ id: 'a', dedupeKey: 'k1', expiresAt: null },
|
||||
{ id: 'b', dedupeKey: 'k2', expiresAt: null },
|
||||
]);
|
||||
|
||||
const result = await sync();
|
||||
|
||||
expect(suggestions.resolveIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
['a', 'b'],
|
||||
'Autopilot aus',
|
||||
);
|
||||
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 2 });
|
||||
expect(suggestions.proposeIn).not.toHaveBeenCalled();
|
||||
expect(loadInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proposes the candidates in the order A1, A2', async () => {
|
||||
loadInput.mockResolvedValue({
|
||||
...input('2026-04-20'),
|
||||
lastVarroa: { id: 'var-1', date: '2026-04-01' },
|
||||
});
|
||||
|
||||
await sync();
|
||||
|
||||
expect(
|
||||
suggestions.proposeIn.mock.calls.map(
|
||||
(call: [unknown, { intent: string }]) => call[1].intent,
|
||||
),
|
||||
).toEqual(['INSPECT', 'VARROA_MEASURE']);
|
||||
});
|
||||
|
||||
it('uses preloaded configs and seasons without reading rules or seasons again', async () => {
|
||||
const season = calendarSeasonState('api-1', TODAY, PHENOLOGY);
|
||||
const preload = {
|
||||
configs: {
|
||||
autopilot: rules.SEASON_AUTOPILOT,
|
||||
interval: rules.INSPECTION_INTERVAL,
|
||||
varroa: rules.VARROA_THRESHOLD,
|
||||
phenology: PHENOLOGY,
|
||||
},
|
||||
seasons: new Map([['api-1', season]]),
|
||||
};
|
||||
readRuleMock.mockClear();
|
||||
|
||||
const result = await service.syncColonyIn(
|
||||
tx as never,
|
||||
'col-1',
|
||||
TODAY,
|
||||
preload,
|
||||
);
|
||||
|
||||
expect(result.opened).toBe(1);
|
||||
expect(readRuleMock).not.toHaveBeenCalled();
|
||||
expect(seasons.statesFor).not.toHaveBeenCalled();
|
||||
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season);
|
||||
});
|
||||
|
||||
it('preloadFor reads the four rules and the seasons once', async () => {
|
||||
const preload = await service.preloadFor(tx as never, ['api-1'], TODAY);
|
||||
|
||||
expect(readRuleMock).toHaveBeenCalledTimes(4);
|
||||
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
|
||||
expect(preload.seasons.has('api-1')).toBe(true);
|
||||
expect(preload.configs.phenology).toEqual(PHENOLOGY);
|
||||
});
|
||||
|
||||
it('warns and does nothing when the apiary has no season state or the input is gone', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
seasons.statesFor.mockResolvedValue(new Map());
|
||||
|
||||
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('season'));
|
||||
|
||||
seasons.statesFor.mockResolvedValue(
|
||||
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
|
||||
);
|
||||
loadInput.mockResolvedValue(null);
|
||||
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { addDays } from '../common/dates';
|
||||
import { utcDay } from '../domain-events/domain-event-types';
|
||||
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
import { suggestionDedupeKey } from '../suggestions/suggestion.types';
|
||||
import { loadAutopilotInputIn } from './autopilot-input';
|
||||
import { evaluateAutopilot } from './autopilot-rules';
|
||||
import type {
|
||||
AutopilotCandidate,
|
||||
AutopilotConfigs,
|
||||
AutopilotSyncResult,
|
||||
} from './autopilot.types';
|
||||
|
||||
/** Rules and seasons a daily step reads once for all its colonies. */
|
||||
export interface AutopilotPreload {
|
||||
configs: AutopilotConfigs;
|
||||
seasons: ReadonlyMap<string, SeasonState>;
|
||||
}
|
||||
|
||||
const NOTHING: AutopilotSyncResult = { opened: 0, refreshed: 0, resolved: 0 };
|
||||
|
||||
/**
|
||||
* A suggestion lives suggestionTtlDays from its due day, from today if it is
|
||||
* already overdue: a condition that has lasted longer than the ttl would
|
||||
* otherwise expire the day it is proposed and never be seen.
|
||||
*/
|
||||
function expiresOn(dueDate: string, today: string, ttlDays: number): string {
|
||||
return addDays(dueDate > today ? dueDate : today, ttlDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the open autopilot suggestions of one colony with what the
|
||||
* rules say today. It writes suggestions only, never tasks or colony data,
|
||||
* and leaves rule and chain suggestions alone.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AutopilotService {
|
||||
private readonly logger = new Logger(AutopilotService.name);
|
||||
|
||||
constructor(
|
||||
private readonly suggestions: SuggestionsService,
|
||||
private readonly seasons: SeasonService,
|
||||
) {}
|
||||
|
||||
/** The rules and the season of the given apiaries, read once for many colonies. */
|
||||
async preloadFor(
|
||||
db: DbClient,
|
||||
apiaryIds: readonly string[],
|
||||
today: string,
|
||||
): Promise<AutopilotPreload> {
|
||||
return {
|
||||
configs: await this.readConfigs(db),
|
||||
seasons: await this.seasons.statesFor(db, apiaryIds, today),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the colony row, then: ended colony - nothing; autopilot off - all
|
||||
* open autopilot suggestions RESOLVED; otherwise suggestions whose key is
|
||||
* gone are RESOLVED, those with the same key refreshed, new candidates
|
||||
* proposed (in the order A1...A10, so concurrent syncs take the proposal
|
||||
* locks in the same order). A candidate is dropped by `proposeIn` when an
|
||||
* open suggestion or task of the same intent exists. A colony that has
|
||||
* ended is skipped here; its open suggestions become OBSOLETE through the
|
||||
* handler `suggestions.colony-ended`. `maxStepsPerColony` is not applied:
|
||||
* it limits the next-steps view, not what is proposed. `preload` spares a
|
||||
* daily step the rule and season reads per colony.
|
||||
*/
|
||||
async syncColonyIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
today: string,
|
||||
preload?: AutopilotPreload,
|
||||
): Promise<AutopilotSyncResult> {
|
||||
assertTransactionClient(tx, 'AutopilotService.syncColonyIn');
|
||||
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
||||
return NOTHING;
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true, apiaryId: true },
|
||||
});
|
||||
if (colony === null || !isLivingColonyStatus(colony.status)) {
|
||||
return NOTHING;
|
||||
}
|
||||
const configs = preload?.configs ?? (await this.readConfigs(tx));
|
||||
const autopilot = configs.autopilot;
|
||||
const open = await tx.suggestion.findMany({
|
||||
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
|
||||
select: { id: true, dedupeKey: true, expiresAt: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!autopilot.active) {
|
||||
const resolved = await this.suggestions.resolveIn(
|
||||
tx,
|
||||
open.map((suggestion) => suggestion.id),
|
||||
'Autopilot aus',
|
||||
);
|
||||
return { ...NOTHING, resolved };
|
||||
}
|
||||
const season = (
|
||||
preload?.seasons ??
|
||||
(await this.seasons.statesFor(tx, [colony.apiaryId], today))
|
||||
).get(colony.apiaryId);
|
||||
if (season === undefined) {
|
||||
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
|
||||
return NOTHING;
|
||||
}
|
||||
const input = await loadAutopilotInputIn(tx, colonyId, season);
|
||||
if (input === null) {
|
||||
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
|
||||
return NOTHING;
|
||||
}
|
||||
const candidates = evaluateAutopilot(input, configs, today);
|
||||
const keyOf = (candidate: AutopilotCandidate): string =>
|
||||
suggestionDedupeKey(candidate.ruleKey, colonyId, candidate.anchor);
|
||||
const currentKeys = new Set(candidates.map(keyOf));
|
||||
const resolved = await this.suggestions.resolveIn(
|
||||
tx,
|
||||
open
|
||||
.filter((s) => s.dedupeKey === null || !currentKeys.has(s.dedupeKey))
|
||||
.map((s) => s.id),
|
||||
'Bedingung entfällt',
|
||||
);
|
||||
const openByKey = new Map(
|
||||
open.flatMap((s) => (s.dedupeKey === null ? [] : [[s.dedupeKey, s]])),
|
||||
);
|
||||
const ttlDays = autopilot.config.suggestionTtlDays;
|
||||
let opened = 0;
|
||||
let refreshed = 0;
|
||||
for (const candidate of candidates) {
|
||||
const existing = openByKey.get(keyOf(candidate));
|
||||
if (existing !== undefined) {
|
||||
await this.suggestions.refreshIn(tx, existing.id, {
|
||||
reasonCode: candidate.reasonCode,
|
||||
evidence: candidate.evidence,
|
||||
dueDate: candidate.dueDate,
|
||||
priority: candidate.priority,
|
||||
expiresAt:
|
||||
existing.expiresAt === null ? null : utcDay(existing.expiresAt),
|
||||
});
|
||||
refreshed += 1;
|
||||
continue;
|
||||
}
|
||||
const created = await this.suggestions.proposeIn(tx, {
|
||||
ruleKey: candidate.ruleKey,
|
||||
source: 'AUTOPILOT',
|
||||
mode: candidate.mode,
|
||||
intent: candidate.intent,
|
||||
priority: candidate.priority,
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
colonyId,
|
||||
reasonCode: candidate.reasonCode,
|
||||
evidence: candidate.evidence,
|
||||
requiredPermission: candidate.requiredPermission,
|
||||
dueDate: candidate.dueDate,
|
||||
expiresAt: expiresOn(candidate.dueDate, today, ttlDays),
|
||||
anchor: candidate.anchor,
|
||||
notify: false,
|
||||
});
|
||||
if (created !== null) {
|
||||
opened += 1;
|
||||
}
|
||||
}
|
||||
return { opened, refreshed, resolved };
|
||||
}
|
||||
|
||||
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
|
||||
return {
|
||||
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
|
||||
interval: await readRule(db, 'INSPECTION_INTERVAL'),
|
||||
varroa: await readRule(db, 'VARROA_THRESHOLD'),
|
||||
phenology: (await readRule(db, 'PHENOLOGY')).config,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { AutopilotStep } from './autopilot.step';
|
||||
import type { AutopilotService } from './autopilot.service';
|
||||
|
||||
describe('AutopilotStep', () => {
|
||||
const tx = { tx: true };
|
||||
let findMany: jest.Mock;
|
||||
let transaction: jest.Mock;
|
||||
let syncColonyIn: jest.Mock;
|
||||
let preloadFor: jest.Mock;
|
||||
const PRELOAD = { configs: {}, seasons: new Map() };
|
||||
let step: AutopilotStep;
|
||||
|
||||
beforeEach(() => {
|
||||
findMany = jest.fn().mockResolvedValue([
|
||||
{ id: 'c1', apiaryId: 'a1' },
|
||||
{ id: 'c2', apiaryId: 'a1' },
|
||||
{ id: 'c3', apiaryId: 'a2' },
|
||||
]);
|
||||
transaction = jest.fn((work: (client: unknown) => unknown) => work(tx));
|
||||
syncColonyIn = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ opened: 1, refreshed: 0, resolved: 2 });
|
||||
preloadFor = jest.fn().mockResolvedValue(PRELOAD);
|
||||
step = new AutopilotStep(
|
||||
{
|
||||
colony: { findMany },
|
||||
$transaction: transaction,
|
||||
} as unknown as PrismaService,
|
||||
{ syncColonyIn, preloadFor } as unknown as AutopilotService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('is the autopilot step', () => {
|
||||
expect(step.name).toBe('autopilot');
|
||||
});
|
||||
|
||||
it('syncs every living colony in a transaction of its own and sums up', async () => {
|
||||
const result = await step.run('2026-05-12');
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||
},
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
);
|
||||
expect(transaction).toHaveBeenCalledTimes(3);
|
||||
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c1', '2026-05-12', PRELOAD);
|
||||
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c3', '2026-05-12', PRELOAD);
|
||||
expect(result).toEqual({
|
||||
autopilotColonies: 3,
|
||||
suggestionsOpened: 3,
|
||||
suggestionsResolved: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads rules and seasons once for all colonies', async () => {
|
||||
await step.run('2026-05-12');
|
||||
|
||||
expect(preloadFor).toHaveBeenCalledTimes(1);
|
||||
expect(preloadFor).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
['a1', 'a2'],
|
||||
'2026-05-12',
|
||||
);
|
||||
});
|
||||
|
||||
it('a failing colony does not stop the others; the step fails with the counters', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
syncColonyIn
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce({ opened: 1, refreshed: 0, resolved: 0 });
|
||||
|
||||
const failure = await step.run('2026-05-12').then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(failure).toBeInstanceOf(JobPartialFailure);
|
||||
expect(failure).toMatchObject({
|
||||
result: {
|
||||
autopilotColonies: 2,
|
||||
suggestionsOpened: 2,
|
||||
suggestionsResolved: 2,
|
||||
},
|
||||
failedSteps: ['1 colony'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { loggableError } from '../common/loggable-error';
|
||||
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
|
||||
import type { JobResult } from '../jobs/job';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { AutopilotService } from './autopilot.service';
|
||||
|
||||
/**
|
||||
* Step "autopilot" of the daily evaluation: reconciles the suggestions of
|
||||
* every living colony, one transaction (and one colony row lock) per colony.
|
||||
* A failing colony is logged and does not stop the others; the step then
|
||||
* fails with the counters of the colonies that went through, and the next
|
||||
* run tries again.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AutopilotStep implements DailyEvaluationStep {
|
||||
readonly name = 'autopilot';
|
||||
private readonly logger = new Logger(AutopilotStep.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly autopilot: AutopilotService,
|
||||
) {}
|
||||
|
||||
async run(today: string): Promise<JobResult> {
|
||||
const colonies = await this.prisma.colony.findMany({
|
||||
where: LIVING_COLONY_WHERE,
|
||||
select: { id: true, apiaryId: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
// Rules and seasons are the same for every colony: read them once.
|
||||
const preload = await this.autopilot.preloadFor(
|
||||
this.prisma,
|
||||
[...new Set(colonies.map((colony) => colony.apiaryId))],
|
||||
today,
|
||||
);
|
||||
let synced = 0;
|
||||
let failed = 0;
|
||||
let opened = 0;
|
||||
let resolved = 0;
|
||||
for (const { id } of colonies) {
|
||||
try {
|
||||
const result = await this.prisma.$transaction((tx) =>
|
||||
this.autopilot.syncColonyIn(tx, id, today, preload),
|
||||
);
|
||||
synced += 1;
|
||||
opened += result.opened;
|
||||
resolved += result.resolved;
|
||||
} catch (error: unknown) {
|
||||
const { message, stack } = loggableError(error);
|
||||
this.logger.error(
|
||||
`Autopilot of colony ${id} failed: ${message}`,
|
||||
stack,
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
const counts: JobResult = {
|
||||
autopilotColonies: synced,
|
||||
suggestionsOpened: opened,
|
||||
suggestionsResolved: resolved,
|
||||
};
|
||||
if (failed > 0) {
|
||||
throw new JobPartialFailure(counts, [
|
||||
`${failed} ${failed === 1 ? 'colony' : 'colonies'}`,
|
||||
]);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
ColonyCondition,
|
||||
ColonyStatus,
|
||||
ColonyType,
|
||||
SuggestionIntent,
|
||||
SuggestionMode,
|
||||
TaskPriority,
|
||||
WinterCheckSeason,
|
||||
} from '../../generated/prisma';
|
||||
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
import type {
|
||||
ActiveRule,
|
||||
AutopilotRuleId,
|
||||
PhenologyConfig,
|
||||
} from '../automation/rule-config';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import type { SuggestionEvidence } from '../suggestions/suggestion.types';
|
||||
|
||||
/** What the autopilot rules see of one living colony. Days are YYYY-MM-DD. */
|
||||
export interface AutopilotInput {
|
||||
colony: {
|
||||
id: string;
|
||||
code: string;
|
||||
status: ColonyStatus;
|
||||
colonyType: ColonyType;
|
||||
apiaryId: string;
|
||||
createdOn: string;
|
||||
feedStatus: ColonyCondition | null;
|
||||
};
|
||||
lastInspection: {
|
||||
id: string;
|
||||
date: string;
|
||||
colonyStrength: number | null;
|
||||
feedStatus: ColonyCondition | null;
|
||||
queenCellsPresent: boolean | null;
|
||||
} | null;
|
||||
lastVarroa: { id: string; date: string } | null;
|
||||
lastTreatment: { id: string; startDay: string; endDay: string } | null;
|
||||
/** A measurement after the end of the last treatment exists. */
|
||||
varroaSinceLastTreatmentEnd: boolean;
|
||||
// A4-A6 (Task 27).
|
||||
feedingsSinceInspection: number;
|
||||
feedingsSinceLateSummer: number;
|
||||
treatmentsSinceLateSummer: number;
|
||||
queen: { id: string; hatchYear: number | null } | null;
|
||||
winterChecks: { winterYear: number; season: WinterCheckSeason }[];
|
||||
season: SeasonState;
|
||||
}
|
||||
|
||||
export interface AutopilotConfigs {
|
||||
autopilot: ActiveRule<'SEASON_AUTOPILOT'>;
|
||||
interval: ActiveRule<'INSPECTION_INTERVAL'>;
|
||||
/** Only `recheckAfterDays` is used here (A3). */
|
||||
varroa: ActiveRule<'VARROA_THRESHOLD'>;
|
||||
phenology: PhenologyConfig;
|
||||
}
|
||||
|
||||
export interface AutopilotCandidate {
|
||||
rule: AutopilotRuleId;
|
||||
ruleKey: 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL';
|
||||
intent: SuggestionIntent;
|
||||
mode: SuggestionMode;
|
||||
reasonCode: string;
|
||||
evidence: SuggestionEvidence;
|
||||
dueDate: string;
|
||||
priority: TaskPriority;
|
||||
anchor: string;
|
||||
requiredPermission: PermissionKey;
|
||||
}
|
||||
|
||||
export type AutopilotRule = (
|
||||
input: AutopilotInput,
|
||||
configs: AutopilotConfigs,
|
||||
today: string,
|
||||
) => AutopilotCandidate | null;
|
||||
|
||||
export interface AutopilotSyncResult {
|
||||
opened: number;
|
||||
refreshed: number;
|
||||
resolved: number;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
|
||||
|
||||
describe('SuggestionsExpiryStep', () => {
|
||||
it('expires the open suggestions past their day and reports the count', async () => {
|
||||
const expireDue = jest.fn().mockResolvedValue(3);
|
||||
const step = new SuggestionsExpiryStep({
|
||||
expireDue,
|
||||
} as unknown as SuggestionsService);
|
||||
|
||||
expect(step.name).toBe('suggestions-expiry');
|
||||
expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 3 });
|
||||
expect(expireDue).toHaveBeenCalledWith('2026-05-12');
|
||||
});
|
||||
|
||||
it('is idempotent: a second run expires nothing more', async () => {
|
||||
const expireDue = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(0);
|
||||
const step = new SuggestionsExpiryStep({
|
||||
expireDue,
|
||||
} as unknown as SuggestionsService);
|
||||
|
||||
await step.run('2026-05-12');
|
||||
|
||||
expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
|
||||
import type { JobResult } from '../jobs/job';
|
||||
import { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
|
||||
/**
|
||||
* Step "suggestions-expiry": OPEN suggestions past their expiry day become
|
||||
* EXPIRED (one statement, which also frees their dedupe key). Running it
|
||||
* twice for one day changes nothing the second time.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SuggestionsExpiryStep implements DailyEvaluationStep {
|
||||
readonly name = 'suggestions-expiry';
|
||||
|
||||
constructor(private readonly suggestions: SuggestionsService) {}
|
||||
|
||||
async run(today: string): Promise<JobResult> {
|
||||
return { suggestionsExpired: await this.suggestions.expireDue(today) };
|
||||
}
|
||||
}
|
||||
@@ -518,6 +518,44 @@ describe('ColoniesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('writing occupiedFrames leaves strength untouched', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
strength: 7,
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
strength: 7,
|
||||
occupiedFrames: 14,
|
||||
});
|
||||
|
||||
await service.update('colony-1', { occupiedFrames: 14 }, 'user-1');
|
||||
|
||||
expect(tx.colony.update).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
data: { occupiedFrames: 14 },
|
||||
});
|
||||
});
|
||||
|
||||
it('updateIn locks the colony row before reading before', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
const result = await service.updateIn(
|
||||
tx as never,
|
||||
'colony-1',
|
||||
{ notes: 'x' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.colony.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(result.before.id).toBe('colony-1');
|
||||
expect(result.after).toBe(parentColony);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update records no event when status and health stay the same', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { Colony, ColonyStatus } from '../../generated/prisma';
|
||||
import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
|
||||
import { CreateColonyDto } from './dto/create-colony.dto';
|
||||
import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||
import { MoveColonyDto } from './dto/move-colony.dto';
|
||||
@@ -105,51 +105,63 @@ export class ColoniesService {
|
||||
dto: UpdateColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Colony> {
|
||||
// Fail fast with 404; everything else is decided on the locked row.
|
||||
await this.findById(id);
|
||||
const { after } = await this.prisma.$transaction((tx) =>
|
||||
this.updateIn(tx, id, dto, actingUserId),
|
||||
);
|
||||
return after;
|
||||
}
|
||||
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const before = await lockColony(tx, id);
|
||||
const movesApiary =
|
||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||
if (movesApiary) {
|
||||
await this.assertApiaryActive(dto.apiaryId!);
|
||||
}
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
// Correcting the apiary has to leave the same trace a deliberate move
|
||||
// does - otherwise the colony's location history would silently skip a
|
||||
// stand it was recorded on.
|
||||
if (movesApiary) {
|
||||
await tx.colonyApiaryHistory.create({
|
||||
data: {
|
||||
colonyId: id,
|
||||
apiaryId: updated.apiaryId,
|
||||
movedByUserId: actingUserId,
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(before, updated, actingUserId),
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
/**
|
||||
* The core of update, for callers that already hold a transaction (an
|
||||
* inspection handing values over to the colony card). Locks the colony row
|
||||
* and reads "before" only afterwards (404 when missing); writes the apiary
|
||||
* history, the status events and the audit entry with the change.
|
||||
*/
|
||||
async updateIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
dto: UpdateColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<{ before: Colony; after: Colony }> {
|
||||
const before = await lockColony(tx, id);
|
||||
const movesApiary =
|
||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||
if (movesApiary) {
|
||||
await this.assertApiaryActive(dto.apiaryId!);
|
||||
}
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
return colony;
|
||||
// Correcting the apiary has to leave the same trace a deliberate move
|
||||
// does - otherwise the colony's location history would silently skip a
|
||||
// stand it was recorded on.
|
||||
if (movesApiary) {
|
||||
await tx.colonyApiaryHistory.create({
|
||||
data: {
|
||||
colonyId: id,
|
||||
apiaryId: updated.apiaryId,
|
||||
movedByUserId: actingUserId,
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(before, updated, actingUserId),
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return { before, after: updated };
|
||||
}
|
||||
|
||||
async move(
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
broodConditionFromPattern,
|
||||
planColonyTakeover,
|
||||
} from './colony-takeover';
|
||||
|
||||
const inspection = {
|
||||
colonyStrength: 12 as number | null,
|
||||
broodPattern: 4 as number | null,
|
||||
feedStatus: 'GOOD' as 'GOOD' | null,
|
||||
};
|
||||
const allowed = {
|
||||
canUpdateColony: true,
|
||||
colonyEnded: false,
|
||||
hasNewerInspection: false,
|
||||
};
|
||||
|
||||
describe('colony takeover', () => {
|
||||
it.each([
|
||||
[5, 'EXCELLENT'],
|
||||
[4, 'GOOD'],
|
||||
[3, 'AVERAGE'],
|
||||
[2, 'WEAK'],
|
||||
[1, 'CRITICAL'],
|
||||
[null, null],
|
||||
])('brood pattern %p -> %p', (pattern, expected) => {
|
||||
expect(broodConditionFromPattern(pattern)).toBe(expected);
|
||||
});
|
||||
|
||||
it('takes over only requested fields that have a value', () => {
|
||||
const result = planColonyTakeover(
|
||||
{ ...inspection, feedStatus: null },
|
||||
['occupiedFrames', 'feedStatus'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.patch).toEqual({ occupiedFrames: 12 });
|
||||
expect(result.takeover).toEqual({
|
||||
applied: ['occupiedFrames'],
|
||||
skipped: [{ field: 'feedStatus', reason: 'NO_VALUE' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps the brood pattern to the brood status', () => {
|
||||
const result = planColonyTakeover(inspection, ['broodStatus'], allowed);
|
||||
expect(result.patch).toEqual({ broodStatus: 'GOOD' });
|
||||
});
|
||||
|
||||
it('takes nothing over when nothing is requested', () => {
|
||||
const result = planColonyTakeover(inspection, [], allowed);
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover).toEqual({ applied: [], skipped: [] });
|
||||
});
|
||||
|
||||
it('skips everything with NO_PERMISSION when the caller may not update colonies', () => {
|
||||
const result = planColonyTakeover(
|
||||
inspection,
|
||||
['occupiedFrames', 'broodStatus', 'feedStatus'],
|
||||
{ ...allowed, canUpdateColony: false, hasNewerInspection: true },
|
||||
);
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover.applied).toEqual([]);
|
||||
expect(result.takeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'NO_PERMISSION' },
|
||||
{ field: 'broodStatus', reason: 'NO_PERMISSION' },
|
||||
{ field: 'feedStatus', reason: 'NO_PERMISSION' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips everything with NEWER_INSPECTION when a later inspection exists', () => {
|
||||
const result = planColonyTakeover(inspection, ['occupiedFrames'], {
|
||||
...allowed,
|
||||
hasNewerInspection: true,
|
||||
});
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips every field with COLONY_ENDED when the colony no longer exists', () => {
|
||||
const result = planColonyTakeover(
|
||||
inspection,
|
||||
['occupiedFrames', 'feedStatus'],
|
||||
{ ...allowed, colonyEnded: true },
|
||||
);
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
|
||||
{ field: 'feedStatus', reason: 'COLONY_ENDED' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([45, -1])(
|
||||
'skips OUT_OF_RANGE for the value %p outside the colony card range',
|
||||
(value) => {
|
||||
const result = planColonyTakeover(
|
||||
{ ...inspection, colonyStrength: value },
|
||||
['occupiedFrames'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'OUT_OF_RANGE' },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts the bounds 0 and 40', () => {
|
||||
for (const value of [0, 40]) {
|
||||
const result = planColonyTakeover(
|
||||
{ ...inspection, colonyStrength: value },
|
||||
['occupiedFrames'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.patch).toEqual({ occupiedFrames: value });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
Colony,
|
||||
ColonyCondition,
|
||||
Inspection,
|
||||
} from '../../generated/prisma';
|
||||
|
||||
export const COLONY_TAKEOVER_FIELDS = [
|
||||
'occupiedFrames',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
] as const;
|
||||
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
|
||||
|
||||
export type TakeoverSkipReason =
|
||||
| 'NO_VALUE'
|
||||
| 'NO_PERMISSION'
|
||||
| 'NEWER_INSPECTION'
|
||||
| 'COLONY_ENDED'
|
||||
| 'OUT_OF_RANGE';
|
||||
|
||||
export interface ColonyTakeover {
|
||||
applied: ColonyTakeoverField[];
|
||||
skipped: { field: ColonyTakeoverField; reason: TakeoverSkipReason }[];
|
||||
}
|
||||
|
||||
export const MAX_OCCUPIED_FRAMES = 40;
|
||||
|
||||
const CONDITION_BY_BROOD_PATTERN: Record<number, ColonyCondition> = {
|
||||
5: 'EXCELLENT',
|
||||
4: 'GOOD',
|
||||
3: 'AVERAGE',
|
||||
2: 'WEAK',
|
||||
1: 'CRITICAL',
|
||||
};
|
||||
|
||||
/** Brood pattern 1-5 as the colony card's brood status; the mapping is an assumption. */
|
||||
export function broodConditionFromPattern(
|
||||
pattern: number | null,
|
||||
): ColonyCondition | null {
|
||||
if (pattern === null) {
|
||||
return null;
|
||||
}
|
||||
return CONDITION_BY_BROOD_PATTERN[pattern] ?? null;
|
||||
}
|
||||
|
||||
/** Only values that exist: a takeover never writes null. */
|
||||
type TakeoverPatch = {
|
||||
[K in ColonyTakeoverField]?: NonNullable<Colony[K]>;
|
||||
};
|
||||
type TakeoverSource = Pick<
|
||||
Inspection,
|
||||
'colonyStrength' | 'broodPattern' | 'feedStatus'
|
||||
>;
|
||||
|
||||
interface FieldPlan {
|
||||
patch: TakeoverPatch | null;
|
||||
skip: TakeoverSkipReason | null;
|
||||
}
|
||||
|
||||
function planField(
|
||||
field: ColonyTakeoverField,
|
||||
inspection: TakeoverSource,
|
||||
): FieldPlan {
|
||||
switch (field) {
|
||||
case 'occupiedFrames': {
|
||||
const frames = inspection.colonyStrength;
|
||||
if (frames === null) {
|
||||
return { patch: null, skip: 'NO_VALUE' };
|
||||
}
|
||||
if (frames < 0 || frames > MAX_OCCUPIED_FRAMES) {
|
||||
return { patch: null, skip: 'OUT_OF_RANGE' };
|
||||
}
|
||||
return { patch: { occupiedFrames: frames }, skip: null };
|
||||
}
|
||||
case 'broodStatus': {
|
||||
const status = broodConditionFromPattern(inspection.broodPattern);
|
||||
return status === null
|
||||
? { patch: null, skip: 'NO_VALUE' }
|
||||
: { patch: { broodStatus: status }, skip: null };
|
||||
}
|
||||
case 'feedStatus':
|
||||
return inspection.feedStatus === null
|
||||
? { patch: null, skip: 'NO_VALUE' }
|
||||
: { patch: { feedStatus: inspection.feedStatus }, skip: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a saved inspection hands over to the colony card. Values come from the
|
||||
* stored inspection only. A missing permission, an ended colony and a newer
|
||||
* inspection each skip every requested field: a newer inspection blocks all
|
||||
* fields, not only the one it would overwrite (spec 7.9).
|
||||
*/
|
||||
export function planColonyTakeover(
|
||||
inspection: TakeoverSource,
|
||||
requested: readonly ColonyTakeoverField[],
|
||||
ctx: {
|
||||
canUpdateColony: boolean;
|
||||
colonyEnded: boolean;
|
||||
hasNewerInspection: boolean;
|
||||
},
|
||||
): { patch: TakeoverPatch; takeover: ColonyTakeover } {
|
||||
const patch: TakeoverPatch = {};
|
||||
const takeover: ColonyTakeover = { applied: [], skipped: [] };
|
||||
for (const field of requested) {
|
||||
const plan: FieldPlan = !ctx.canUpdateColony
|
||||
? { patch: null, skip: 'NO_PERMISSION' }
|
||||
: ctx.colonyEnded
|
||||
? { patch: null, skip: 'COLONY_ENDED' }
|
||||
: ctx.hasNewerInspection
|
||||
? { patch: null, skip: 'NEWER_INSPECTION' }
|
||||
: planField(field, inspection);
|
||||
if (plan.skip !== null) {
|
||||
takeover.skipped.push({ field, reason: plan.skip });
|
||||
continue;
|
||||
}
|
||||
Object.assign(patch, plan.patch);
|
||||
takeover.applied.push(field);
|
||||
}
|
||||
return { patch, takeover };
|
||||
}
|
||||
@@ -25,13 +25,29 @@ export class CreateColonyDto {
|
||||
@IsString()
|
||||
originText?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 10 })
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
deprecated: true,
|
||||
description: 'Volksstärke (alt, Skala 1–10)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
strength?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
maximum: 40,
|
||||
description: 'Volksstärke in besetzten Waben',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(40, { message: 'Höchstens 40 besetzte Waben.' })
|
||||
occupiedFrames?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: ColonyCondition })
|
||||
@IsOptional()
|
||||
@IsEnum(ColonyCondition)
|
||||
|
||||
@@ -18,6 +18,7 @@ class UpdatableColonyFieldsDto extends PartialType(
|
||||
'originText',
|
||||
'colonyType',
|
||||
'strength',
|
||||
'occupiedFrames',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
'healthStatus',
|
||||
|
||||
@@ -115,6 +115,7 @@ const contracts: Record<string, NullContract> = {
|
||||
'hiveType',
|
||||
'originText',
|
||||
'strength',
|
||||
'occupiedFrames',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
'healthStatus',
|
||||
@@ -334,6 +335,7 @@ const contracts: Record<string, NullContract> = {
|
||||
clearable: [
|
||||
'durationMinutes',
|
||||
'miteCount',
|
||||
'sampleBees',
|
||||
'calculatedValue',
|
||||
'assessment',
|
||||
'notes',
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('ExportService', () => {
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
status: 'ACTIVE',
|
||||
strength: 8,
|
||||
occupiedFrames: 14,
|
||||
healthStatus: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
@@ -67,9 +68,10 @@ describe('ExportService', () => {
|
||||
const csv = await service.exportColoniesCsv();
|
||||
const lines = csv.split('\n');
|
||||
expect(lines[0]).toBe(
|
||||
'id,code,apiaryId,colonyType,status,strength,healthStatus,createdAt',
|
||||
'id,code,apiaryId,colonyType,status,strength,healthStatus,createdAt,occupiedFrames',
|
||||
);
|
||||
expect(lines[1]).toContain('"V-1, Ost"');
|
||||
expect(lines[1]).toMatch(/,8,,2026-01-01T00:00:00.000Z,14$/);
|
||||
});
|
||||
|
||||
it('exports honey batches as CSV', async () => {
|
||||
|
||||
@@ -122,6 +122,8 @@ export class ExportService {
|
||||
'strength',
|
||||
'healthStatus',
|
||||
'createdAt',
|
||||
// Last: consumers of the CSV read columns by position.
|
||||
'occupiedFrames',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,32 @@ describe('ImportService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries strength and occupiedFrames from the CSV', 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,strength,occupiedFrames',
|
||||
'V-1,Teststand,PRODUCTION_COLONY,6,14',
|
||||
'V-2,Teststand,PRODUCTION_COLONY,,41',
|
||||
].join('\n'),
|
||||
),
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(report.created).toBe(1);
|
||||
expect(report.errors).toBe(1);
|
||||
expect(report.rows[1].message).toContain('Höchstens 40 besetzte Waben.');
|
||||
const [[createArg]] = prisma.colony.create.mock.calls as [
|
||||
[{ data: object }],
|
||||
];
|
||||
expect(createArg.data).toMatchObject({ strength: 6, occupiedFrames: 14 });
|
||||
});
|
||||
|
||||
it('reports a validation error for an invalid colonyType', async () => {
|
||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ export class ImportService {
|
||||
hiveType: emptyToUndefined(record.hiveType),
|
||||
originText: emptyToUndefined(record.originText),
|
||||
strength: parseNumberField(record.strength),
|
||||
occupiedFrames: parseNumberField(record.occupiedFrames),
|
||||
broodStatus: emptyToUndefined(record.broodStatus),
|
||||
feedStatus: feed.feedStatus ?? undefined,
|
||||
healthStatus: emptyToUndefined(record.healthStatus),
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -10,6 +14,10 @@ import {
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
COLONY_TAKEOVER_FIELDS,
|
||||
type ColonyTakeoverField,
|
||||
} from '../../colonies/colony-takeover';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ColonyCondition } from '../../../generated/prisma';
|
||||
import { feedConditionFromInput } from '../../common/feed-vocabulary';
|
||||
@@ -33,10 +41,15 @@ export class CreateInspectionDto {
|
||||
@IsNumber()
|
||||
temperature?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
maximum: 40,
|
||||
description: 'Volksstärke in besetzten Waben',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(40, { message: 'Höchstens 40 besetzte Waben.' })
|
||||
colonyStrength?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Anzahl Brutwaben' })
|
||||
@@ -146,4 +159,17 @@ export class CreateInspectionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: COLONY_TAKEOVER_FIELDS,
|
||||
isArray: true,
|
||||
description:
|
||||
'Colony card fields to fill from this inspection, in the same request. The server takes the values from the saved inspection.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(3)
|
||||
@IsIn(COLONY_TAKEOVER_FIELDS, { each: true })
|
||||
applyToColony?: ColonyTakeoverField[];
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { CreateInspectionDto } from './create-inspection.dto';
|
||||
|
||||
/**
|
||||
* Every field stays correctable, `colonyId` included: a record filed against
|
||||
* the wrong colony is a data-entry error, and re-filing it is the only way to
|
||||
* repair the colony's history without deleting the record.
|
||||
* repair the colony's history without deleting the record. `applyToColony` is
|
||||
* not part of a correction: a correction takes nothing over to the colony.
|
||||
*/
|
||||
export class UpdateInspectionDto extends PartialType(CreateInspectionDto, {
|
||||
skipNullProperties: false,
|
||||
}) {}
|
||||
export class UpdateInspectionDto extends PartialType(
|
||||
OmitType(CreateInspectionDto, ['applyToColony'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { ColoniesModule } from '../colonies/colonies.module';
|
||||
import { InspectionsController } from './inspections.controller';
|
||||
import { ColonyInspectionsController } from './colony-inspections.controller';
|
||||
import { InspectionsService } from './inspections.service';
|
||||
|
||||
/**
|
||||
* Rules, calendar and webhooks follow from the inspection's domain events
|
||||
* (DomainEventsModule is global), so no AutomationModule here.
|
||||
* (DomainEventsModule is global), so no AutomationModule here. The
|
||||
* PermissionResolverService comes from the global resolver module.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TasksModule],
|
||||
imports: [TasksModule, ColoniesModule],
|
||||
controllers: [InspectionsController, ColonyInspectionsController],
|
||||
providers: [InspectionsService],
|
||||
exports: [InspectionsService],
|
||||
|
||||
@@ -4,13 +4,21 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { ColoniesService } from '../colonies/colonies.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
|
||||
describe('InspectionsService', () => {
|
||||
let service: InspectionsService;
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
inspection: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
|
||||
colony: { findUniqueOrThrow: jest.Mock };
|
||||
inspection: {
|
||||
findUnique: jest.Mock;
|
||||
findFirst: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: {
|
||||
$connect: jest.Mock;
|
||||
@@ -25,6 +33,8 @@ describe('InspectionsService', () => {
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock };
|
||||
let tasks: { createInTx: jest.Mock };
|
||||
let colonies: { updateIn: jest.Mock };
|
||||
let resolver: { getPermissions: jest.Mock };
|
||||
|
||||
const inspection = {
|
||||
id: 'inspection-1',
|
||||
@@ -35,8 +45,12 @@ describe('InspectionsService', () => {
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
colony: {
|
||||
findUniqueOrThrow: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
|
||||
},
|
||||
inspection: {
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
@@ -56,11 +70,17 @@ describe('InspectionsService', () => {
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
|
||||
colonies = { updateIn: jest.fn().mockResolvedValue({}) };
|
||||
resolver = {
|
||||
getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])),
|
||||
};
|
||||
service = new InspectionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
tasks as unknown as TasksService,
|
||||
colonies as unknown as ColoniesService,
|
||||
resolver as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,6 +92,8 @@ describe('InspectionsService', () => {
|
||||
AuditLogService,
|
||||
DomainEventsService,
|
||||
TasksService,
|
||||
ColoniesService,
|
||||
PermissionResolverService,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -90,7 +112,10 @@ describe('InspectionsService', () => {
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(result).toBe(inspection);
|
||||
expect(result).toEqual({
|
||||
...inspection,
|
||||
colonyTakeover: { applied: [], skipped: [] },
|
||||
});
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(tx.inspection.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
@@ -122,6 +147,164 @@ describe('InspectionsService', () => {
|
||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('applyToColony', () => {
|
||||
const saved = {
|
||||
...inspection,
|
||||
colonyStrength: 14,
|
||||
broodPattern: 4,
|
||||
feedStatus: 'GOOD',
|
||||
};
|
||||
|
||||
it('locks the colony, then takes the stored values over via updateIn in the same transaction', async () => {
|
||||
tx.inspection.create.mockResolvedValue(saved);
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
colonyStrength: 14,
|
||||
applyToColony: ['occupiedFrames', 'broodStatus', 'feedStatus'],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const lock = tx.$queryRaw.mock.invocationCallOrder[0];
|
||||
expect(lock).toBeLessThan(
|
||||
tx.inspection.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(lock).toBeLessThan(
|
||||
tx.inspection.findFirst.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(lock).toBeLessThan(colonies.updateIn.mock.invocationCallOrder[0]);
|
||||
// Permissions are resolved before the transaction opens.
|
||||
expect(resolver.getPermissions.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
prisma.$transaction.mock.invocationCallOrder[0],
|
||||
);
|
||||
const createArg = (
|
||||
tx.inspection.create.mock.calls[0] as [{ data: object }]
|
||||
)[0];
|
||||
expect(createArg.data).not.toHaveProperty('applyToColony');
|
||||
expect(colonies.updateIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'colony-1',
|
||||
{ occupiedFrames: 14, broodStatus: 'GOOD', feedStatus: 'GOOD' },
|
||||
'user-1',
|
||||
);
|
||||
expect(result.colonyTakeover.applied).toEqual([
|
||||
'occupiedFrames',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes no lock and resolves no permission for an empty applyToColony', async () => {
|
||||
tx.inspection.create.mockResolvedValue(saved);
|
||||
|
||||
const result = await service.create(
|
||||
{ colonyId: 'colony-1', date: '2026-01-01', applyToColony: [] },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(resolver.getPermissions).not.toHaveBeenCalled();
|
||||
expect(tx.inspection.findFirst).not.toHaveBeenCalled();
|
||||
expect(result.colonyTakeover).toEqual({ applied: [], skipped: [] });
|
||||
});
|
||||
|
||||
it('orders two inspections by date, then createdAt, then id', async () => {
|
||||
tx.inspection.create.mockResolvedValue({
|
||||
...saved,
|
||||
date: new Date('2026-01-01'),
|
||||
createdAt: new Date('2026-01-02T10:00:00Z'),
|
||||
});
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
applyToColony: ['feedStatus'],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[arg]] = tx.inspection.findFirst.mock.calls as [
|
||||
[{ where: { colonyId: string; OR: object[] } }],
|
||||
];
|
||||
expect(arg.where.OR).toEqual([
|
||||
{ date: { gt: new Date('2026-01-01') } },
|
||||
{
|
||||
date: new Date('2026-01-01'),
|
||||
createdAt: { gt: new Date('2026-01-02T10:00:00Z') },
|
||||
},
|
||||
{
|
||||
date: new Date('2026-01-01'),
|
||||
createdAt: new Date('2026-01-02T10:00:00Z'),
|
||||
id: { gt: 'inspection-1' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips with COLONY_ENDED for an ended colony but still saves the inspection', async () => {
|
||||
tx.inspection.create.mockResolvedValue(saved);
|
||||
tx.colony.findUniqueOrThrow.mockResolvedValue({ status: 'DISSOLVED' });
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
applyToColony: ['occupiedFrames'],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(colonies.updateIn).not.toHaveBeenCalled();
|
||||
expect(result.id).toBe('inspection-1');
|
||||
expect(result.colonyTakeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips with NEWER_INSPECTION and writes no colony change', async () => {
|
||||
tx.inspection.create.mockResolvedValue(saved);
|
||||
tx.inspection.findFirst.mockResolvedValue({ id: 'later' });
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
applyToColony: ['occupiedFrames'],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(colonies.updateIn).not.toHaveBeenCalled();
|
||||
expect(result.colonyTakeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips with NO_PERMISSION without colonies:update', async () => {
|
||||
tx.inspection.create.mockResolvedValue(saved);
|
||||
resolver.getPermissions.mockResolvedValue(
|
||||
new Set(['inspections:create']),
|
||||
);
|
||||
|
||||
const result = await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
applyToColony: ['feedStatus'],
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(colonies.updateIn).not.toHaveBeenCalled();
|
||||
expect(result.colonyTakeover.skipped).toEqual([
|
||||
{ field: 'feedStatus', reason: 'NO_PERMISSION' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('create fails as a whole when its event cannot be written', async () => {
|
||||
tx.inspection.create.mockResolvedValue(inspection);
|
||||
events.record.mockRejectedValue(new Error('outbox down'));
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DbClient } from '../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { Inspection, Task } from '../../generated/prisma';
|
||||
import { ColoniesService } from '../colonies/colonies.service';
|
||||
import {
|
||||
ColonyTakeover,
|
||||
ColonyTakeoverField,
|
||||
planColonyTakeover,
|
||||
} from '../colonies/colony-takeover';
|
||||
import { Inspection, Prisma, Task } from '../../generated/prisma';
|
||||
import { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||
import { UpdateInspectionDto } from './dto/update-inspection.dto';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
|
||||
export type InspectionWithTakeover = Inspection & {
|
||||
colonyTakeover: ColonyTakeover;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inspections are written together with their audit entry and domain event
|
||||
* in one transaction: InspectionRecorded, InspectionCorrected. Automation
|
||||
@@ -26,6 +38,8 @@ export class InspectionsService {
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
private readonly tasks: TasksService,
|
||||
private readonly colonies: ColoniesService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Inspection[]> {
|
||||
@@ -46,10 +60,24 @@ export class InspectionsService {
|
||||
async create(
|
||||
dto: CreateInspectionDto,
|
||||
actingUserId: string,
|
||||
): Promise<Inspection> {
|
||||
): Promise<InspectionWithTakeover> {
|
||||
const { applyToColony = [], ...data } = dto;
|
||||
// Resolved before the transaction (it may query the database): the
|
||||
// permission is only needed when a takeover was requested.
|
||||
const canUpdateColony =
|
||||
applyToColony.length > 0 &&
|
||||
(await this.permissionResolver.getPermissions(actingUserId)).has(
|
||||
'colonies:update',
|
||||
);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (applyToColony.length > 0) {
|
||||
// Colony first (before the inspection row takes a key-share lock on
|
||||
// it): serializes concurrent inspections of one colony, so "is there
|
||||
// a newer inspection" below is decided on a stable state.
|
||||
await lockRowForUpdate(tx, 'Colony', dto.colonyId);
|
||||
}
|
||||
const inspection = await tx.inspection.create({
|
||||
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
|
||||
data: { ...data, date: new Date(dto.date), userId: actingUserId },
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
@@ -68,10 +96,69 @@ export class InspectionsService {
|
||||
payload: { colonyId: inspection.colonyId },
|
||||
actorUserId: actingUserId,
|
||||
});
|
||||
return inspection;
|
||||
const colonyTakeover = await this.takeOver(
|
||||
tx,
|
||||
inspection,
|
||||
applyToColony,
|
||||
canUpdateColony,
|
||||
actingUserId,
|
||||
);
|
||||
return { ...inspection, colonyTakeover };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands values of the just-saved inspection over to the colony card. The
|
||||
* values come from the stored row, never from the request.
|
||||
*/
|
||||
private async takeOver(
|
||||
tx: Prisma.TransactionClient,
|
||||
inspection: Inspection,
|
||||
requested: readonly ColonyTakeoverField[],
|
||||
canUpdateColony: boolean,
|
||||
actingUserId: string,
|
||||
): Promise<ColonyTakeover> {
|
||||
if (requested.length === 0) {
|
||||
return { applied: [], skipped: [] };
|
||||
}
|
||||
// The colony row is locked (see create), so both reads below see a state
|
||||
// no concurrent inspection or colony change can alter.
|
||||
const colony = await tx.colony.findUniqueOrThrow({
|
||||
where: { id: inspection.colonyId },
|
||||
select: { status: true },
|
||||
});
|
||||
// Deterministic order of two inspections: date, then createdAt, then id.
|
||||
const newer = await tx.inspection.findFirst({
|
||||
where: {
|
||||
colonyId: inspection.colonyId,
|
||||
OR: [
|
||||
{ date: { gt: inspection.date } },
|
||||
{ date: inspection.date, createdAt: { gt: inspection.createdAt } },
|
||||
{
|
||||
date: inspection.date,
|
||||
createdAt: inspection.createdAt,
|
||||
id: { gt: inspection.id },
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const { patch, takeover } = planColonyTakeover(inspection, requested, {
|
||||
canUpdateColony,
|
||||
colonyEnded: !isLivingColonyStatus(colony.status),
|
||||
hasNewerInspection: newer !== null,
|
||||
});
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await this.colonies.updateIn(
|
||||
tx,
|
||||
inspection.colonyId,
|
||||
patch,
|
||||
actingUserId,
|
||||
);
|
||||
}
|
||||
return takeover;
|
||||
}
|
||||
|
||||
/**
|
||||
* A correction evaluates no automation rule, as before the event
|
||||
* backbone. InspectionCorrected moves the calendar entry when the date or
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import { DailyEvaluationJob } from './daily-evaluation.job';
|
||||
import type { AutopilotStep } from '../autopilot/autopilot.step';
|
||||
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
|
||||
import type { TimeEventsStep } from './steps/time-events.step';
|
||||
|
||||
describe('DailyEvaluationJob', () => {
|
||||
@@ -11,14 +13,29 @@ describe('DailyEvaluationJob', () => {
|
||||
VarroaMeasurementDue: 0,
|
||||
InspectionOverdue: 0,
|
||||
};
|
||||
const AUTOPILOT_COUNTS = {
|
||||
autopilotColonies: 4,
|
||||
suggestionsOpened: 2,
|
||||
suggestionsResolved: 1,
|
||||
};
|
||||
let timeEvents: { name: 'time-events'; run: jest.Mock };
|
||||
let autopilot: { name: 'autopilot'; run: jest.Mock };
|
||||
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
|
||||
let job: DailyEvaluationJob;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
|
||||
timeEvents = { name: 'time-events', run: jest.fn() };
|
||||
timeEvents.run.mockResolvedValue(COUNTS);
|
||||
job = new DailyEvaluationJob(timeEvents as unknown as TimeEventsStep);
|
||||
autopilot = { name: 'autopilot', run: jest.fn() };
|
||||
autopilot.run.mockResolvedValue(AUTOPILOT_COUNTS);
|
||||
expiry = { name: 'suggestions-expiry', run: jest.fn() };
|
||||
expiry.run.mockResolvedValue({ suggestionsExpired: 3 });
|
||||
job = new DailyEvaluationJob(
|
||||
timeEvents as unknown as TimeEventsStep,
|
||||
autopilot as unknown as AutopilotStep,
|
||||
expiry as unknown as SuggestionsExpiryStep,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -31,8 +48,44 @@ describe('DailyEvaluationJob', () => {
|
||||
});
|
||||
|
||||
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
|
||||
expect(await job.run()).toEqual(COUNTS);
|
||||
expect(await job.run()).toEqual({
|
||||
...COUNTS,
|
||||
...AUTOPILOT_COUNTS,
|
||||
suggestionsExpired: 3,
|
||||
});
|
||||
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
|
||||
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
|
||||
});
|
||||
|
||||
it('runs time-events, autopilot and suggestions-expiry in that order', async () => {
|
||||
await job.run();
|
||||
|
||||
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
autopilot.run.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(autopilot.run.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
expiry.run.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('a failing autopilot step does not stop the expiry step', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
autopilot.run.mockRejectedValue(
|
||||
new JobPartialFailure({ autopilotColonies: 1 }, ['1 colony']),
|
||||
);
|
||||
|
||||
const failure = await job.run().then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
expect(expiry.run).toHaveBeenCalled();
|
||||
expect(failure).toMatchObject({
|
||||
failedSteps: ['autopilot'],
|
||||
result: { autopilotColonies: 1, suggestionsExpired: 3 },
|
||||
message: 'Daily evaluation failed for: autopilot: 1 colony',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the Berlin day, not the UTC day, around midnight', async () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AutopilotStep } from '../autopilot/autopilot.step';
|
||||
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
|
||||
import { berlinToday } from '../common/dates';
|
||||
import { JobPartialFailure } from '../system/job-run.service';
|
||||
import {
|
||||
@@ -21,11 +23,19 @@ export class DailyEvaluationJob implements Job {
|
||||
readonly name: JobName = 'daily-evaluation';
|
||||
private readonly logger = new Logger(DailyEvaluationJob.name);
|
||||
|
||||
constructor(private readonly timeEvents: TimeEventsStep) {}
|
||||
constructor(
|
||||
private readonly timeEvents: TimeEventsStep,
|
||||
private readonly autopilot: AutopilotStep,
|
||||
private readonly suggestionsExpiry: SuggestionsExpiryStep,
|
||||
) {}
|
||||
|
||||
/** Evaluates the current Berlin day. */
|
||||
async run(): Promise<JobResult> {
|
||||
const steps: DailyEvaluationStep[] = [this.timeEvents];
|
||||
const steps: DailyEvaluationStep[] = [
|
||||
this.timeEvents,
|
||||
this.autopilot,
|
||||
this.suggestionsExpiry,
|
||||
];
|
||||
const { result, failed, failures } = await runDailyEvaluationSteps(
|
||||
steps,
|
||||
berlinToday(),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { AutopilotModule } from '../autopilot/autopilot.module';
|
||||
import { BackupModule } from '../backup/backup.module';
|
||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
@@ -47,6 +48,7 @@ const JOB_CLASSES = [
|
||||
BackupModule,
|
||||
SystemModule,
|
||||
SeasonModule,
|
||||
AutopilotModule,
|
||||
],
|
||||
providers: [
|
||||
...JOB_CLASSES,
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('NotificationsService', () => {
|
||||
createManyAndReturn: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
findFirstOrThrow: jest.Mock;
|
||||
findFirst: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
};
|
||||
user: { findMany: jest.Mock };
|
||||
@@ -39,6 +40,7 @@ describe('NotificationsService', () => {
|
||||
),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findFirstOrThrow: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
user: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
@@ -351,17 +353,33 @@ describe('NotificationsService', () => {
|
||||
});
|
||||
|
||||
it('answers 404 when the notification is foreign or unknown', async () => {
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 0 });
|
||||
prisma.notification.findFirst.mockResolvedValue(null);
|
||||
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||
expect(prisma.notification.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: 'n1', userId: 'user-1' },
|
||||
data: { read: true },
|
||||
select: { requiredPermissions: true },
|
||||
});
|
||||
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the own notification as read', async () => {
|
||||
it('answers 404 and changes nothing for a row whose permissions are no longer held', async () => {
|
||||
prisma.notification.findFirst.mockResolvedValue({
|
||||
requiredPermissions: ['colonies:read'],
|
||||
});
|
||||
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.notification.findFirstOrThrow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the own visible notification as read', async () => {
|
||||
held.add('colonies:read');
|
||||
prisma.notification.findFirst.mockResolvedValue({
|
||||
requiredPermissions: ['colonies:read'],
|
||||
});
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.notification.findFirstOrThrow.mockResolvedValue({
|
||||
id: 'n1',
|
||||
@@ -371,16 +389,30 @@ describe('NotificationsService', () => {
|
||||
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
|
||||
read: true,
|
||||
});
|
||||
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'n1', userId: 'user-1' },
|
||||
data: { read: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only the own unread notifications as read', async () => {
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
|
||||
it('marks only the own unread notifications the user may see', async () => {
|
||||
held.add('colonies:read');
|
||||
prisma.notification.findMany.mockResolvedValue([
|
||||
{ id: 'a', requiredPermissions: [] },
|
||||
{ id: 'b', requiredPermissions: ['colonies:read'] },
|
||||
{ id: 'c', requiredPermissions: ['varroa:read'] },
|
||||
]);
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
await expect(service.markAllRead('user-1')).resolves.toEqual({
|
||||
updated: 3,
|
||||
updated: 2,
|
||||
});
|
||||
expect(prisma.notification.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', read: false },
|
||||
select: { id: true, requiredPermissions: true },
|
||||
});
|
||||
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', read: false },
|
||||
where: { id: { in: ['a', 'b'] }, userId: 'user-1', read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,8 +168,19 @@ export class NotificationsService {
|
||||
.map(toView);
|
||||
}
|
||||
|
||||
/** One statement; a foreign or unknown id matches nothing and answers 404. */
|
||||
/**
|
||||
* A foreign, unknown or no longer visible row (same rule as findOwn)
|
||||
* answers 404 and is left untouched.
|
||||
*/
|
||||
async markRead(id: string, userId: string): Promise<NotificationView> {
|
||||
const row = await this.prisma.notification.findFirst({
|
||||
where: { id, userId },
|
||||
select: { requiredPermissions: true },
|
||||
});
|
||||
const held = await this.permissionResolver.getPermissions(userId);
|
||||
if (!row || !row.requiredPermissions.every((key) => held.has(key))) {
|
||||
throw new NotFoundException('Notification not found');
|
||||
}
|
||||
const { count } = await this.prisma.notification.updateMany({
|
||||
where: { id, userId },
|
||||
data: { read: true },
|
||||
@@ -183,9 +194,21 @@ export class NotificationsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Marks only the unread rows the user may see. */
|
||||
async markAllRead(userId: string): Promise<{ updated: number }> {
|
||||
const { count } = await this.prisma.notification.updateMany({
|
||||
const unread = await this.prisma.notification.findMany({
|
||||
where: { userId, read: false },
|
||||
select: { id: true, requiredPermissions: true },
|
||||
});
|
||||
const held = await this.permissionResolver.getPermissions(userId);
|
||||
const visible = unread
|
||||
.filter((row) => row.requiredPermissions.every((key) => held.has(key)))
|
||||
.map((row) => row.id);
|
||||
if (visible.length === 0) {
|
||||
return { updated: 0 };
|
||||
}
|
||||
const { count } = await this.prisma.notification.updateMany({
|
||||
where: { id: { in: visible }, userId, read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
return { updated: count };
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { DEFAULT_SPLIT_SCHEDULE_CONFIG } from '../../automation/rule-config/split-schedule.config';
|
||||
import { DEFAULT_CHAIN_CONFIG } from '../../automation/rule-config/chain.config';
|
||||
import {
|
||||
queenAcceptanceCandidate,
|
||||
queenCellsCandidate,
|
||||
splitScheduleCandidate,
|
||||
} from './chain-planner';
|
||||
|
||||
const inspection = (queenCellsPresent: boolean | null) => ({
|
||||
id: 'insp-1',
|
||||
colonyId: 'col-1',
|
||||
// 10:30 Berlin time on 2026-05-05
|
||||
date: new Date('2026-05-05T08:30:00.000Z'),
|
||||
queenCellsPresent,
|
||||
});
|
||||
|
||||
const split = (queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null) => ({
|
||||
parentColonyId: 'col-0',
|
||||
lineageId: 'lin-1',
|
||||
queenSource,
|
||||
splitOn: '2026-05-01',
|
||||
});
|
||||
|
||||
describe('chain planner', () => {
|
||||
it('queen cells on 2026-05-05 propose a swarm check due 2026-05-12', () => {
|
||||
const candidate = queenCellsCandidate(
|
||||
inspection(true),
|
||||
DEFAULT_CHAIN_CONFIG,
|
||||
);
|
||||
|
||||
expect(candidate).toMatchObject({
|
||||
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
|
||||
intent: 'SWARM_CHECK',
|
||||
anchor: 'insp-1',
|
||||
baseDate: '2026-05-05',
|
||||
dueDate: '2026-05-12',
|
||||
plan: null,
|
||||
requiredPermission: 'inspections:read',
|
||||
});
|
||||
expect(candidate?.evidence).toEqual({
|
||||
colonyId: 'col-1',
|
||||
inspectionId: 'insp-1',
|
||||
inspectedOn: '2026-05-05',
|
||||
baseDate: '2026-05-05',
|
||||
});
|
||||
});
|
||||
|
||||
it('no queen cells (false or unknown) yield nothing', () => {
|
||||
expect(
|
||||
queenCellsCandidate(inspection(false), DEFAULT_CHAIN_CONFIG),
|
||||
).toBeNull();
|
||||
expect(
|
||||
queenCellsCandidate(inspection(null), DEFAULT_CHAIN_CONFIG),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the configured dueInDays', () => {
|
||||
const candidate = queenCellsCandidate(inspection(true), {
|
||||
...DEFAULT_CHAIN_CONFIG,
|
||||
dueInDays: 3,
|
||||
});
|
||||
expect(candidate?.dueDate).toBe('2026-05-08');
|
||||
});
|
||||
|
||||
it('RAISE_OWN yields one SPLIT_PLAN with offsets 9, 21 (window 3) and 28 from the split date', () => {
|
||||
const candidate = splitScheduleCandidate(
|
||||
'col-new',
|
||||
split('RAISE_OWN'),
|
||||
DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||
);
|
||||
|
||||
expect(candidate).toMatchObject({
|
||||
ruleKey: 'SPLIT_SCHEDULE',
|
||||
intent: 'SPLIT_PLAN',
|
||||
anchor: 'lin-1',
|
||||
baseDate: '2026-05-01',
|
||||
dueDate: '2026-05-10',
|
||||
requiredPermission: 'colonies:read',
|
||||
});
|
||||
expect(candidate?.plan).toEqual([
|
||||
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
||||
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
|
||||
{ step: 'EGGS_CHECK', offsetDays: 28 },
|
||||
]);
|
||||
expect(candidate?.evidence).toMatchObject({
|
||||
colonyId: 'col-new',
|
||||
parentColonyId: 'col-0',
|
||||
lineageId: 'lin-1',
|
||||
baseDate: '2026-05-01',
|
||||
});
|
||||
});
|
||||
|
||||
it('QUEEN_CELL yields one step at +21; QUEEN yields an acceptance check at +7', () => {
|
||||
const cell = splitScheduleCandidate(
|
||||
'col-new',
|
||||
split('QUEEN_CELL'),
|
||||
DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||
);
|
||||
expect(cell?.intent).toBe('SPLIT_PLAN');
|
||||
expect(cell?.plan).toEqual([{ step: 'EGGS_CHECK', offsetDays: 21 }]);
|
||||
expect(cell?.dueDate).toBe('2026-05-22');
|
||||
|
||||
const queen = splitScheduleCandidate(
|
||||
'col-new',
|
||||
split('QUEEN'),
|
||||
DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||
);
|
||||
expect(queen).toMatchObject({
|
||||
intent: 'QUEEN_ACCEPTANCE',
|
||||
requiredPermission: 'queens:read',
|
||||
anchor: 'lin-1',
|
||||
dueDate: '2026-05-08',
|
||||
});
|
||||
expect(queen?.plan).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
|
||||
});
|
||||
|
||||
it('a split without queenSource yields nothing', () => {
|
||||
expect(
|
||||
splitScheduleCandidate(
|
||||
'col-new',
|
||||
split(null),
|
||||
DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('the due date is the earliest step, whatever the order of the steps', () => {
|
||||
const candidate = splitScheduleCandidate('col-new', split('RAISE_OWN'), {
|
||||
...DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||
RAISE_OWN: [
|
||||
{ step: 'EGGS_CHECK', offsetDays: 28 },
|
||||
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
||||
],
|
||||
});
|
||||
expect(candidate?.dueDate).toBe('2026-05-10');
|
||||
});
|
||||
|
||||
it('a variant without steps yields nothing', () => {
|
||||
expect(
|
||||
splitScheduleCandidate('col-new', split('QUEEN'), {
|
||||
...DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||
QUEEN: [],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('a queen assignment proposes an acceptance check +7, anchored on the history row', () => {
|
||||
const candidate = queenAcceptanceCandidate(
|
||||
{
|
||||
colonyId: 'col-1',
|
||||
historyId: 'hist-1',
|
||||
assignedOn: '2026-05-05',
|
||||
previousColonyId: null,
|
||||
},
|
||||
'queen-1',
|
||||
DEFAULT_CHAIN_CONFIG,
|
||||
);
|
||||
|
||||
expect(candidate).toMatchObject({
|
||||
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||
intent: 'QUEEN_ACCEPTANCE',
|
||||
anchor: 'hist-1',
|
||||
baseDate: '2026-05-05',
|
||||
dueDate: '2026-05-12',
|
||||
plan: null,
|
||||
requiredPermission: 'queens:read',
|
||||
});
|
||||
expect(candidate.evidence).toMatchObject({
|
||||
colonyId: 'col-1',
|
||||
queenId: 'queen-1',
|
||||
baseDate: '2026-05-05',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Inspection } from '../../../generated/prisma';
|
||||
import type {
|
||||
ChainConfig,
|
||||
PlanStepConfig,
|
||||
SplitScheduleConfig,
|
||||
} from '../../automation/rule-config';
|
||||
import type { PermissionKey } from '../../auth/permissions/permission-catalog';
|
||||
import { addDays, berlinDayOf } from '../../common/dates';
|
||||
import type { DomainEventPayloads } from '../../domain-events/domain-event-types';
|
||||
import type { SuggestionEvidence } from '../suggestion.types';
|
||||
|
||||
export interface ChainCandidate {
|
||||
ruleKey:
|
||||
'QUEEN_CELLS_SWARM_CHECK' | 'QUEEN_ACCEPTANCE_CHECK' | 'SPLIT_SCHEDULE';
|
||||
intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN';
|
||||
reasonCode: string;
|
||||
anchor: string;
|
||||
/** YYYY-MM-DD, the day the offsets count from. */
|
||||
baseDate: string;
|
||||
/** YYYY-MM-DD, the day of the first (or only) check. */
|
||||
dueDate: string;
|
||||
plan: PlanStepConfig[] | null;
|
||||
requiredPermission: PermissionKey;
|
||||
evidence: SuggestionEvidence;
|
||||
}
|
||||
|
||||
/** A queen-cell finding on an inspection: check for a swarm a week later. */
|
||||
export function queenCellsCandidate(
|
||||
inspection: Pick<
|
||||
Inspection,
|
||||
'id' | 'colonyId' | 'date' | 'queenCellsPresent'
|
||||
>,
|
||||
config: ChainConfig,
|
||||
): ChainCandidate | null {
|
||||
if (inspection.queenCellsPresent !== true) {
|
||||
return null;
|
||||
}
|
||||
const baseDate = berlinDayOf(inspection.date);
|
||||
return {
|
||||
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
|
||||
intent: 'SWARM_CHECK',
|
||||
reasonCode: 'QUEEN_CELLS_SWARM_CHECK',
|
||||
anchor: inspection.id,
|
||||
baseDate,
|
||||
dueDate: addDays(baseDate, config.dueInDays),
|
||||
plan: null,
|
||||
requiredPermission: 'inspections:read',
|
||||
evidence: {
|
||||
colonyId: inspection.colonyId,
|
||||
inspectionId: inspection.id,
|
||||
inspectedOn: baseDate,
|
||||
baseDate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A queen put into a colony: check that it was accepted. */
|
||||
export function queenAcceptanceCandidate(
|
||||
payload: DomainEventPayloads['QueenAssigned'],
|
||||
queenId: string,
|
||||
config: ChainConfig,
|
||||
): ChainCandidate {
|
||||
return {
|
||||
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||
intent: 'QUEEN_ACCEPTANCE',
|
||||
reasonCode: 'QUEEN_ASSIGNED_ACCEPTANCE',
|
||||
anchor: payload.historyId,
|
||||
baseDate: payload.assignedOn,
|
||||
dueDate: addDays(payload.assignedOn, config.dueInDays),
|
||||
plan: null,
|
||||
requiredPermission: 'queens:read',
|
||||
evidence: {
|
||||
colonyId: payload.colonyId,
|
||||
queenId,
|
||||
historyId: payload.historyId,
|
||||
assignedOn: payload.assignedOn,
|
||||
baseDate: payload.assignedOn,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The follow-up plan of a split, by where the new colony's queen comes
|
||||
* from. `colonyId` is the new colony. No queen source, no chain.
|
||||
*/
|
||||
export function splitScheduleCandidate(
|
||||
colonyId: string,
|
||||
payload: DomainEventPayloads['ColonySplit'],
|
||||
config: SplitScheduleConfig,
|
||||
): ChainCandidate | null {
|
||||
const { queenSource } = payload;
|
||||
if (queenSource === null) {
|
||||
return null;
|
||||
}
|
||||
const plan = config[queenSource].map((step) => ({ ...step }));
|
||||
if (plan.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const isAcceptance = queenSource === 'QUEEN';
|
||||
return {
|
||||
ruleKey: 'SPLIT_SCHEDULE',
|
||||
intent: isAcceptance ? 'QUEEN_ACCEPTANCE' : 'SPLIT_PLAN',
|
||||
reasonCode: `SPLIT_SCHEDULE_${queenSource}`,
|
||||
anchor: payload.lineageId,
|
||||
baseDate: payload.splitOn,
|
||||
dueDate: addDays(
|
||||
payload.splitOn,
|
||||
Math.min(...plan.map((step) => step.offsetDays)),
|
||||
),
|
||||
plan,
|
||||
requiredPermission: isAcceptance ? 'queens:read' : 'colonies:read',
|
||||
evidence: {
|
||||
colonyId,
|
||||
parentColonyId: payload.parentColonyId,
|
||||
lineageId: payload.lineageId,
|
||||
queenSource,
|
||||
splitOn: payload.splitOn,
|
||||
baseDate: payload.splitOn,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import type { Prisma } from '../../../generated/prisma';
|
||||
import { lockRowForUpdate } from '../../prisma/row-lock';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../../domain-events/on-domain-event.decorator';
|
||||
import type { SuggestionsService } from '../suggestions.service';
|
||||
import { ChainEventHandlers } from './chains.handlers';
|
||||
|
||||
jest.mock('../../prisma/row-lock');
|
||||
|
||||
function envelope<T extends DomainEventType>(
|
||||
type: T,
|
||||
aggregateId: string,
|
||||
payload: DomainEventEnvelope<T>['payload'],
|
||||
): AnyDomainEventEnvelope<T> {
|
||||
return {
|
||||
id: 'ev-1',
|
||||
type,
|
||||
aggregateType: 'Inspection',
|
||||
aggregateId,
|
||||
payload,
|
||||
actorUserId: null,
|
||||
correlationId: 'corr-1',
|
||||
occurredAt: new Date('2026-05-05T08:00:00.000Z'),
|
||||
attempts: 0,
|
||||
} as unknown as AnyDomainEventEnvelope<T>;
|
||||
}
|
||||
|
||||
interface RuleRow {
|
||||
id: string;
|
||||
active: boolean;
|
||||
config: unknown;
|
||||
actionTemplate: string | null;
|
||||
}
|
||||
|
||||
const assignedEvent = () =>
|
||||
envelope('QueenAssigned', 'queen-1', {
|
||||
colonyId: 'col-1',
|
||||
historyId: 'hist-1',
|
||||
assignedOn: '2026-05-05',
|
||||
previousColonyId: null,
|
||||
});
|
||||
|
||||
const splitEvent = (queenSource: 'RAISE_OWN' | 'QUEEN' | null) =>
|
||||
envelope('ColonySplit', 'col-new', {
|
||||
parentColonyId: 'col-0',
|
||||
lineageId: 'lin-1',
|
||||
queenSource,
|
||||
splitOn: '2026-05-01',
|
||||
});
|
||||
|
||||
describe('ChainEventHandlers', () => {
|
||||
let tx: {
|
||||
colony: { findUnique: jest.Mock };
|
||||
inspection: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
suggestion: { findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock };
|
||||
};
|
||||
let suggestions: { proposeIn: jest.Mock; resolveIn: jest.Mock };
|
||||
let handlers: ChainEventHandlers;
|
||||
const asTx = () => tx as unknown as Prisma.TransactionClient;
|
||||
const rule = (overrides: Partial<RuleRow> = {}): RuleRow => ({
|
||||
id: 'rule-1',
|
||||
active: true,
|
||||
config: {},
|
||||
actionTemplate: null,
|
||||
...overrides,
|
||||
});
|
||||
const inspectionEvent = () =>
|
||||
envelope('InspectionRecorded', 'insp-1', { colonyId: 'col-1' });
|
||||
const inspectionRow = (overrides: object = {}) => ({
|
||||
id: 'insp-1',
|
||||
colonyId: 'col-1',
|
||||
date: new Date('2026-05-05T08:30:00.000Z'),
|
||||
createdAt: new Date('2026-05-05T08:31:00.000Z'),
|
||||
queenCellsPresent: true,
|
||||
queenSeen: false,
|
||||
eggsPresent: false,
|
||||
...overrides,
|
||||
});
|
||||
const oldCheck = (inspectionId = 'insp-0') => ({
|
||||
id: 'old',
|
||||
evidence: { colonyId: 'col-1', inspectionId },
|
||||
});
|
||||
const firstWhere = () =>
|
||||
(tx.suggestion.findMany.mock.calls[0] as [{ where: object }])[0].where;
|
||||
|
||||
beforeEach(() => {
|
||||
(lockRowForUpdate as jest.Mock).mockReset().mockResolvedValue(true);
|
||||
tx = {
|
||||
colony: {
|
||||
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
|
||||
},
|
||||
inspection: {
|
||||
findUnique: jest.fn().mockResolvedValue(inspectionRow()),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'insp-0',
|
||||
date: new Date('2026-04-28T08:30:00.000Z'),
|
||||
createdAt: new Date('2026-04-28T08:31:00.000Z'),
|
||||
},
|
||||
]),
|
||||
},
|
||||
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(rule()) },
|
||||
};
|
||||
suggestions = {
|
||||
proposeIn: jest.fn().mockResolvedValue({ id: 'sug-1' }),
|
||||
resolveIn: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
handlers = new ChainEventHandlers(
|
||||
suggestions as unknown as SuggestionsService,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers its three handler ids', () => {
|
||||
const ids = (
|
||||
['queenCells', 'queenAcceptance', 'splitSchedule'] as const
|
||||
).flatMap((name) =>
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- only the decorator metadata is read
|
||||
domainEventHandlerMetadata(ChainEventHandlers.prototype[name]).map(
|
||||
(entry) => entry.handlerId,
|
||||
),
|
||||
);
|
||||
expect(ids).toEqual([
|
||||
'chains.queen-cells',
|
||||
'chains.queen-acceptance',
|
||||
'chains.split-schedule',
|
||||
]);
|
||||
});
|
||||
|
||||
it('the handlers pass notify from the chain config to proposeIn', async () => {
|
||||
tx.automationRule.findUnique.mockResolvedValue(
|
||||
rule({ config: { notify: true, dueInDays: 5 } }),
|
||||
);
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
expect(suggestions.proposeIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
|
||||
source: 'CHAIN',
|
||||
mode: 'PLAN',
|
||||
intent: 'SWARM_CHECK',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: 'col-1',
|
||||
colonyId: 'col-1',
|
||||
anchor: 'insp-1',
|
||||
dueDate: '2026-05-10',
|
||||
notify: true,
|
||||
sourceEventId: 'ev-1',
|
||||
}),
|
||||
);
|
||||
|
||||
suggestions.proposeIn.mockClear();
|
||||
tx.automationRule.findUnique.mockResolvedValue(
|
||||
rule({ config: { notify: true } }),
|
||||
);
|
||||
await handlers.queenAcceptance(assignedEvent(), asTx());
|
||||
expect(suggestions.proposeIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({ intent: 'QUEEN_ACCEPTANCE', notify: true }),
|
||||
);
|
||||
|
||||
suggestions.proposeIn.mockClear();
|
||||
await handlers.splitSchedule(splitEvent('RAISE_OWN'), asTx());
|
||||
expect(suggestions.proposeIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({
|
||||
ruleKey: 'SPLIT_SCHEDULE',
|
||||
intent: 'SPLIT_PLAN',
|
||||
colonyId: 'col-new',
|
||||
relatedId: 'col-new',
|
||||
notify: true,
|
||||
plan: expect.any(Array) as unknown,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('notify defaults to false', async () => {
|
||||
await handlers.queenAcceptance(assignedEvent(), asTx());
|
||||
expect(suggestions.proposeIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
expect.objectContaining({ notify: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the colony before it resolves or proposes anything', async () => {
|
||||
const order: string[] = [];
|
||||
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||
order.push('lock');
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
tx.suggestion.findMany.mockResolvedValue([oldCheck()]);
|
||||
suggestions.resolveIn.mockImplementation(() => {
|
||||
order.push('resolve');
|
||||
return Promise.resolve(1);
|
||||
});
|
||||
suggestions.proposeIn.mockImplementation(() => {
|
||||
order.push('propose');
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
|
||||
expect(lockRowForUpdate).toHaveBeenCalledWith(tx, 'Colony', 'col-1');
|
||||
expect(order[0]).toBe('lock');
|
||||
expect(order.indexOf('resolve')).toBeLessThan(order.indexOf('propose'));
|
||||
});
|
||||
|
||||
it('resolves all open swarm checks when the inspection finds queen cells', async () => {
|
||||
tx.suggestion.findMany.mockResolvedValue([oldCheck()]);
|
||||
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
|
||||
expect(firstWhere()).toEqual({
|
||||
colonyId: 'col-1',
|
||||
status: 'OPEN',
|
||||
source: 'CHAIN',
|
||||
intent: 'SWARM_CHECK',
|
||||
});
|
||||
expect(suggestions.resolveIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
['old'],
|
||||
'Mit Durchsicht erledigt',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a swarm check open whose base inspection is not older than this one', async () => {
|
||||
// A backdated inspection: the open check rests on a newer inspection.
|
||||
tx.inspection.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'insp-0',
|
||||
date: new Date('2026-05-09T08:30:00.000Z'),
|
||||
createdAt: new Date('2026-05-09T08:31:00.000Z'),
|
||||
},
|
||||
]);
|
||||
tx.suggestion.findMany.mockResolvedValue([oldCheck()]);
|
||||
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
|
||||
expect(suggestions.resolveIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
[],
|
||||
'Mit Durchsicht erledigt',
|
||||
);
|
||||
});
|
||||
|
||||
it('on the same day the later created inspection wins', async () => {
|
||||
tx.inspection.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'insp-0',
|
||||
date: new Date('2026-05-05T08:30:00.000Z'),
|
||||
createdAt: new Date('2026-05-05T08:30:00.000Z'),
|
||||
},
|
||||
]);
|
||||
tx.suggestion.findMany.mockResolvedValue([oldCheck()]);
|
||||
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
|
||||
expect(suggestions.resolveIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
['old'],
|
||||
'Mit Durchsicht erledigt',
|
||||
);
|
||||
});
|
||||
|
||||
it('re-running the event for the same inspection resolves nothing', async () => {
|
||||
tx.inspection.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'insp-1',
|
||||
date: new Date('2026-05-05T08:30:00.000Z'),
|
||||
createdAt: new Date('2026-05-05T08:31:00.000Z'),
|
||||
},
|
||||
]);
|
||||
tx.suggestion.findMany.mockResolvedValue([oldCheck('insp-1')]);
|
||||
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
|
||||
expect(suggestions.resolveIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
[],
|
||||
'Mit Durchsicht erledigt',
|
||||
);
|
||||
});
|
||||
|
||||
it('without queen cells only swarm checks due up to the inspection day are resolved, and nothing is proposed', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValue(
|
||||
inspectionRow({ queenCellsPresent: false }),
|
||||
);
|
||||
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
|
||||
expect(firstWhere()).toEqual({
|
||||
colonyId: 'col-1',
|
||||
status: 'OPEN',
|
||||
source: 'CHAIN',
|
||||
intent: 'SWARM_CHECK',
|
||||
dueDate: { lte: new Date('2026-05-05T00:00:00.000Z') },
|
||||
});
|
||||
expect(suggestions.proposeIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves acceptance checks only when the queen or eggs were seen', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValue(
|
||||
inspectionRow({ queenCellsPresent: false }),
|
||||
);
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
expect(tx.suggestion.findMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
tx.suggestion.findMany.mockClear();
|
||||
tx.inspection.findUnique.mockResolvedValue(
|
||||
inspectionRow({ queenCellsPresent: false, eggsPresent: true }),
|
||||
);
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
const intents = (
|
||||
tx.suggestion.findMany.mock.calls as [{ where: { intent: string } }][]
|
||||
).map(([arg]) => arg.where.intent);
|
||||
expect(intents).toEqual(['SWARM_CHECK', 'QUEEN_ACCEPTANCE']);
|
||||
});
|
||||
|
||||
it('nothing for an ended colony', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' });
|
||||
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
await handlers.queenAcceptance(assignedEvent(), asTx());
|
||||
await handlers.splitSchedule(splitEvent('QUEEN'), asTx());
|
||||
|
||||
expect(suggestions.proposeIn).not.toHaveBeenCalled();
|
||||
expect(suggestions.resolveIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nothing for an inactive rule', async () => {
|
||||
tx.automationRule.findUnique.mockResolvedValue(rule({ active: false }));
|
||||
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
await handlers.queenAcceptance(assignedEvent(), asTx());
|
||||
await handlers.splitSchedule(splitEvent('QUEEN'), asTx());
|
||||
|
||||
expect(suggestions.proposeIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nothing for a colony or inspection that no longer exists', async () => {
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(false);
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
expect(tx.colony.findUnique).not.toHaveBeenCalled();
|
||||
|
||||
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
|
||||
tx.inspection.findUnique.mockResolvedValue(null);
|
||||
await handlers.queenCells(inspectionEvent(), asTx());
|
||||
expect(suggestions.proposeIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a split without queenSource proposes nothing', async () => {
|
||||
await handlers.splitSchedule(splitEvent(null), asTx());
|
||||
expect(suggestions.proposeIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
Inspection,
|
||||
Prisma,
|
||||
SuggestionIntent,
|
||||
} from '../../../generated/prisma';
|
||||
import { readRule } from '../../automation/rule-config';
|
||||
import { isLivingColonyStatus } from '../../colonies/colony-status';
|
||||
import { berlinDayOf, dayToDate } from '../../common/dates';
|
||||
import type { AnyDomainEventEnvelope } from '../../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../../domain-events/on-domain-event.decorator';
|
||||
import { lockRowForUpdate } from '../../prisma/row-lock';
|
||||
import type { ProposeInput } from '../suggestion.types';
|
||||
import { SuggestionsService } from '../suggestions.service';
|
||||
import {
|
||||
queenAcceptanceCandidate,
|
||||
queenCellsCandidate,
|
||||
splitScheduleCandidate,
|
||||
type ChainCandidate,
|
||||
} from './chain-planner';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* Follow-up chains of the beekeeping craft: queen cells lead to a swarm
|
||||
* check, a new queen to an acceptance check, a split to its schedule. Each
|
||||
* handler locks the colony first (the same order as the colony sync), then
|
||||
* lets SuggestionsService do the dedupe and the notification. Writes only
|
||||
* through `tx`; an ended colony takes no new chain.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChainEventHandlers {
|
||||
constructor(private readonly suggestions: SuggestionsService) {}
|
||||
|
||||
@OnDomainEvent('InspectionRecorded', 'chains.queen-cells')
|
||||
async queenCells(
|
||||
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const { colonyId } = event.payload;
|
||||
if (!(await this.lockLivingColonyIn(tx, colonyId))) {
|
||||
return;
|
||||
}
|
||||
const inspection = await tx.inspection.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (inspection === null) {
|
||||
return;
|
||||
}
|
||||
// Clean up first: an open check of the same intent would block the new one.
|
||||
await this.resolveAnsweredChecksIn(tx, inspection);
|
||||
const rule = await readRule(tx, 'QUEEN_CELLS_SWARM_CHECK');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const candidate = queenCellsCandidate(inspection, rule.config);
|
||||
if (candidate !== null) {
|
||||
await this.proposeIn(
|
||||
tx,
|
||||
colonyId,
|
||||
candidate,
|
||||
rule.config.notify,
|
||||
event.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnDomainEvent('QueenAssigned', 'chains.queen-acceptance')
|
||||
async queenAcceptance(
|
||||
event: AnyDomainEventEnvelope<'QueenAssigned'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const { colonyId } = event.payload;
|
||||
if (!(await this.lockLivingColonyIn(tx, colonyId))) {
|
||||
return;
|
||||
}
|
||||
const rule = await readRule(tx, 'QUEEN_ACCEPTANCE_CHECK');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const candidate = queenAcceptanceCandidate(
|
||||
event.payload,
|
||||
event.aggregateId,
|
||||
rule.config,
|
||||
);
|
||||
await this.proposeIn(tx, colonyId, candidate, rule.config.notify, event.id);
|
||||
}
|
||||
|
||||
// The event's aggregate is the new colony.
|
||||
@OnDomainEvent('ColonySplit', 'chains.split-schedule')
|
||||
async splitSchedule(
|
||||
event: AnyDomainEventEnvelope<'ColonySplit'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const colonyId = event.aggregateId;
|
||||
if (!(await this.lockLivingColonyIn(tx, colonyId))) {
|
||||
return;
|
||||
}
|
||||
const rule = await readRule(tx, 'SPLIT_SCHEDULE');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const candidate = splitScheduleCandidate(
|
||||
colonyId,
|
||||
event.payload,
|
||||
rule.config,
|
||||
);
|
||||
if (candidate !== null) {
|
||||
await this.proposeIn(
|
||||
tx,
|
||||
colonyId,
|
||||
candidate,
|
||||
rule.config.notify,
|
||||
event.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locks the colony row; true when the colony exists and still lives. */
|
||||
private async lockLivingColonyIn(tx: Tx, colonyId: string): Promise<boolean> {
|
||||
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
||||
return false;
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true },
|
||||
});
|
||||
return colony !== null && isLivingColonyStatus(colony.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* An inspection answers the open chain checks of its colony: queen cells
|
||||
* found replace every open swarm check that rests on an older inspection,
|
||||
* otherwise the swarm checks due by now are done; an acceptance check is
|
||||
* done once the queen or eggs were seen. A backdated inspection never
|
||||
* closes a check that rests on a newer one.
|
||||
*/
|
||||
private async resolveAnsweredChecksIn(
|
||||
tx: Tx,
|
||||
inspection: Pick<
|
||||
Inspection,
|
||||
| 'id'
|
||||
| 'colonyId'
|
||||
| 'date'
|
||||
| 'createdAt'
|
||||
| 'queenCellsPresent'
|
||||
| 'queenSeen'
|
||||
| 'eggsPresent'
|
||||
>,
|
||||
): Promise<void> {
|
||||
const dueUpTo = { lte: dayToDate(berlinDayOf(inspection.date)) };
|
||||
const swarmChecks = await this.openChecksIn(
|
||||
tx,
|
||||
inspection.colonyId,
|
||||
'SWARM_CHECK',
|
||||
inspection.queenCellsPresent === true ? undefined : dueUpTo,
|
||||
);
|
||||
await this.suggestions.resolveIn(
|
||||
tx,
|
||||
await this.restingOnOlderIn(tx, swarmChecks, inspection),
|
||||
'Mit Durchsicht erledigt',
|
||||
);
|
||||
if (inspection.queenSeen === true || inspection.eggsPresent === true) {
|
||||
const acceptanceChecks = await this.openChecksIn(
|
||||
tx,
|
||||
inspection.colonyId,
|
||||
'QUEEN_ACCEPTANCE',
|
||||
dueUpTo,
|
||||
);
|
||||
await this.suggestions.resolveIn(
|
||||
tx,
|
||||
acceptanceChecks.map((check) => check.id),
|
||||
'Mit Durchsicht erledigt',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async openChecksIn(
|
||||
tx: Tx,
|
||||
colonyId: string,
|
||||
intent: SuggestionIntent,
|
||||
dueDate: { lte: Date } | undefined,
|
||||
): Promise<{ id: string; evidence: Prisma.JsonValue }[]> {
|
||||
return tx.suggestion.findMany({
|
||||
where: {
|
||||
colonyId,
|
||||
status: 'OPEN',
|
||||
source: 'CHAIN',
|
||||
intent,
|
||||
...(dueDate === undefined ? {} : { dueDate }),
|
||||
},
|
||||
select: { id: true, evidence: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The checks whose base inspection (evidence.inspectionId) is older than
|
||||
* `inspection`: by date, on the same date by creation time. A check whose
|
||||
* base inspection is gone counts as older.
|
||||
*/
|
||||
private async restingOnOlderIn(
|
||||
tx: Tx,
|
||||
checks: readonly { id: string; evidence: Prisma.JsonValue }[],
|
||||
inspection: Pick<Inspection, 'id' | 'date' | 'createdAt'>,
|
||||
): Promise<string[]> {
|
||||
const baseIdOf = (evidence: Prisma.JsonValue): string | null => {
|
||||
const id = (evidence as { inspectionId?: unknown } | null)?.inspectionId;
|
||||
return typeof id === 'string' ? id : null;
|
||||
};
|
||||
const baseIds = checks.flatMap((check) => baseIdOf(check.evidence) ?? []);
|
||||
if (baseIds.length === 0) {
|
||||
return checks.map((check) => check.id);
|
||||
}
|
||||
const bases = new Map(
|
||||
(
|
||||
await tx.inspection.findMany({
|
||||
where: { id: { in: baseIds } },
|
||||
select: { id: true, date: true, createdAt: true },
|
||||
})
|
||||
).map((base) => [base.id, base]),
|
||||
);
|
||||
const isOlder = (base: { id: string; date: Date; createdAt: Date }) =>
|
||||
base.id !== inspection.id &&
|
||||
(base.date.getTime() < inspection.date.getTime() ||
|
||||
(base.date.getTime() === inspection.date.getTime() &&
|
||||
base.createdAt.getTime() < inspection.createdAt.getTime()));
|
||||
return checks
|
||||
.filter((check) => {
|
||||
const baseId = baseIdOf(check.evidence);
|
||||
const base = baseId === null ? undefined : bases.get(baseId);
|
||||
return base === undefined || isOlder(base);
|
||||
})
|
||||
.map((check) => check.id);
|
||||
}
|
||||
|
||||
private async proposeIn(
|
||||
tx: Tx,
|
||||
colonyId: string,
|
||||
candidate: ChainCandidate,
|
||||
notify: boolean,
|
||||
sourceEventId: string,
|
||||
): Promise<void> {
|
||||
const input: ProposeInput = {
|
||||
ruleKey: candidate.ruleKey,
|
||||
source: 'CHAIN',
|
||||
mode: 'PLAN',
|
||||
intent: candidate.intent,
|
||||
priority: 'NORMAL',
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
colonyId,
|
||||
reasonCode: candidate.reasonCode,
|
||||
evidence: candidate.evidence,
|
||||
requiredPermission: candidate.requiredPermission,
|
||||
dueDate: candidate.dueDate,
|
||||
plan: candidate.plan,
|
||||
anchor: candidate.anchor,
|
||||
sourceEventId,
|
||||
notify,
|
||||
};
|
||||
await this.suggestions.proposeIn(tx, input);
|
||||
}
|
||||
}
|
||||
@@ -106,22 +106,5 @@ export function renderSuggestion(
|
||||
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);
|
||||
}
|
||||
export { formatDayDe } from './texts/format-day';
|
||||
export { formatNumberDe } from './texts/format-number';
|
||||
@@ -140,4 +140,31 @@ describe('SuggestionViewsService', () => {
|
||||
skip: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts only open suggestions the user may see in the summary', async () => {
|
||||
const { service, prisma } = build([]);
|
||||
prisma.suggestion.groupBy.mockResolvedValue([
|
||||
{ priority: 'NORMAL', _count: { _all: 2 } },
|
||||
{ priority: 'URGENT', _count: { _all: 1 } },
|
||||
]);
|
||||
|
||||
const summary = await service.summary('user_1');
|
||||
|
||||
const [args] = prisma.suggestion.groupBy.mock.calls[0] as [
|
||||
{ where: { AND: unknown[] } },
|
||||
];
|
||||
expect(args.where.AND).toEqual([
|
||||
{
|
||||
OR: [
|
||||
{ requiredPermission: null },
|
||||
{ requiredPermission: { in: ['inspections:read'] } },
|
||||
],
|
||||
},
|
||||
{ status: 'OPEN' },
|
||||
]);
|
||||
expect(summary).toEqual({
|
||||
open: 3,
|
||||
byPriority: { LOW: 0, NORMAL: 2, HIGH: 0, URGENT: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,8 @@ export interface ProposeInput {
|
||||
sourceEventId?: string | null;
|
||||
/**
|
||||
* Default false. Rule and chain suggestions pass `config.notify`, the
|
||||
* autopilot never; the notification itself is wired by Task 7.
|
||||
* autopilot never; a new row then notifies the holders of `tasks:read` and
|
||||
* of the required permission.
|
||||
*/
|
||||
notify?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { ChainEventHandlers } from './chains/chains.handlers';
|
||||
import { SuggestionViewsService } from './suggestion-views.service';
|
||||
import { SuggestionsController } from './suggestions.controller';
|
||||
import { SuggestionsService } from './suggestions.service';
|
||||
@@ -10,9 +12,9 @@ import { SuggestionsService } from './suggestions.service';
|
||||
* the views need comes from the global PermissionsModule (API) or JobPermissionsModule (worker and jobs).
|
||||
*/
|
||||
@Module({
|
||||
imports: [TasksModule, AuditModule],
|
||||
imports: [TasksModule, AuditModule, NotificationsModule],
|
||||
controllers: [SuggestionsController],
|
||||
providers: [SuggestionsService, SuggestionViewsService],
|
||||
providers: [SuggestionsService, SuggestionViewsService, ChainEventHandlers],
|
||||
exports: [SuggestionsService, SuggestionViewsService],
|
||||
})
|
||||
export class SuggestionsModule {}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
lockRowsForShare,
|
||||
lockRowsForUpdate,
|
||||
} from '../prisma/row-lock';
|
||||
import type { NotificationsService } from '../notifications/notifications.service';
|
||||
import type { TasksService } from '../tasks/tasks.service';
|
||||
import { SuggestionsService } from './suggestions.service';
|
||||
import type { ProposeInput } from './suggestion.types';
|
||||
@@ -91,8 +92,9 @@ describe('SuggestionsService', () => {
|
||||
task: { findMany: jest.Mock };
|
||||
inspection: { count: jest.Mock };
|
||||
user: { findUnique: jest.Mock };
|
||||
colony: { findUnique: jest.Mock };
|
||||
colony: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
};
|
||||
let notifications: { recordForPermission: jest.Mock };
|
||||
let prisma: { $connect: jest.Mock; $transaction: jest.Mock } & {
|
||||
$executeRaw: jest.Mock;
|
||||
};
|
||||
@@ -119,8 +121,12 @@ describe('SuggestionsService', () => {
|
||||
},
|
||||
colony: {
|
||||
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'col-1', code: 'LWG' }]),
|
||||
},
|
||||
};
|
||||
notifications = {
|
||||
recordForPermission: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
prisma = {
|
||||
$connect: jest.fn(),
|
||||
$executeRaw: jest.fn().mockResolvedValue(0),
|
||||
@@ -160,6 +166,7 @@ describe('SuggestionsService', () => {
|
||||
prisma as unknown as PrismaService,
|
||||
audit as unknown as AuditLogService,
|
||||
tasks as unknown as TasksService,
|
||||
notifications as unknown as NotificationsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -291,6 +298,122 @@ describe('SuggestionsService', () => {
|
||||
).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
describe('notify', () => {
|
||||
const rulePlan: ProposeInput = {
|
||||
...proposal,
|
||||
ruleKey: 'QUEENLESS_NO_EGGS',
|
||||
source: 'RULE',
|
||||
mode: 'PLAN',
|
||||
intent: 'QUEEN_CHECK',
|
||||
priority: 'HIGH',
|
||||
reasonCode: 'QUEENLESS_NO_EGGS',
|
||||
evidence: {
|
||||
colonyId: 'col-1',
|
||||
inspectionId: 'insp-9',
|
||||
inspectedOn: '2026-05-05',
|
||||
},
|
||||
requiredPermission: 'inspections:read',
|
||||
anchor: 'insp-9',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tx.suggestion.createManyAndReturn.mockResolvedValue([
|
||||
suggestionRow({
|
||||
id: 'sug-new',
|
||||
ruleKey: 'QUEENLESS_NO_EGGS',
|
||||
source: 'RULE',
|
||||
mode: 'PLAN',
|
||||
intent: 'QUEEN_CHECK',
|
||||
priority: 'HIGH',
|
||||
reasonCode: 'QUEENLESS_NO_EGGS',
|
||||
evidence: rulePlan.evidence,
|
||||
plan: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('with notify and a new row writes one SUGGESTION notification to tasks:read plus the required permission', async () => {
|
||||
await service.proposeIn(tx as never, { ...rulePlan, notify: true });
|
||||
|
||||
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
|
||||
expect(notifications.recordForPermission).toHaveBeenCalledWith(
|
||||
['tasks:read', 'inspections:read'],
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'SUGGESTION',
|
||||
message: expect.stringMatching(/^LWG: .+/) as string,
|
||||
link: '/colonies/col-1',
|
||||
relatedType: 'Suggestion',
|
||||
relatedId: 'sug-new',
|
||||
dedupeKeyPrefix: 'suggestion:sug-new',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('writes an URGENT suggestion as KRITISCH and links a colony-less one to /suggestions', async () => {
|
||||
tx.suggestion.createManyAndReturn.mockResolvedValue([
|
||||
suggestionRow({
|
||||
id: 'sug-urgent',
|
||||
priority: 'URGENT',
|
||||
colonyId: null,
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: 'BEESWAX',
|
||||
reasonCode: 'NO_SUCH_CODE',
|
||||
evidence: {},
|
||||
plan: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
await service.proposeIn(tx as never, {
|
||||
...rulePlan,
|
||||
colonyId: null,
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: 'BEESWAX',
|
||||
notify: true,
|
||||
});
|
||||
|
||||
const [, content] = notifications.recordForPermission.mock.calls[0] as [
|
||||
unknown,
|
||||
{ priority: string; link: string },
|
||||
];
|
||||
expect(content).toMatchObject({
|
||||
priority: 'KRITISCH',
|
||||
link: '/suggestions',
|
||||
});
|
||||
});
|
||||
|
||||
it('notify false or absent writes none', async () => {
|
||||
await service.proposeIn(tx as never, { ...rulePlan, notify: false });
|
||||
await service.proposeIn(tx as never, rulePlan);
|
||||
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a skipped row (existing dedupe key or open intent) writes none', async () => {
|
||||
tx.suggestion.createManyAndReturn.mockResolvedValue([]);
|
||||
await service.proposeIn(tx as never, { ...rulePlan, notify: true });
|
||||
tx.suggestion.findMany.mockResolvedValue([{ intent: 'QUEEN_CHECK' }]);
|
||||
await service.proposeIn(tx as never, { ...rulePlan, notify: true });
|
||||
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('addresses only tasks:read when the suggestion needs no permission', async () => {
|
||||
await service.proposeIn(tx as never, {
|
||||
...rulePlan,
|
||||
requiredPermission: null,
|
||||
notify: true,
|
||||
});
|
||||
|
||||
expect(notifications.recordForPermission).toHaveBeenCalledWith(
|
||||
['tasks:read'],
|
||||
expect.anything(),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the root client', async () => {
|
||||
await expect(
|
||||
service.proposeIn(prisma as never, proposal),
|
||||
@@ -338,9 +461,9 @@ describe('SuggestionsService', () => {
|
||||
expect(calls[1][1].description).toContain('bis 25.05.');
|
||||
expect(calls[0][1].description).toBeNull();
|
||||
expect(calls.map((c) => c[1].title)).toEqual([
|
||||
'Weiselzellen kontrollieren',
|
||||
'Begattung kontrollieren',
|
||||
'Eier und Stifte kontrollieren',
|
||||
'Nachschaffungszellen prüfen',
|
||||
'Begattung prüfen',
|
||||
'Stifte prüfen',
|
||||
]);
|
||||
for (const [client, input, actingUserId] of calls) {
|
||||
expect(client).toBe(tx);
|
||||
@@ -1000,6 +1123,25 @@ describe('SuggestionsService', () => {
|
||||
expect(tx.$executeRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshIn also updates the reason code when given', async () => {
|
||||
await service.refreshIn(tx as never, 'sug-1', {
|
||||
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
|
||||
evidence: { colonyId: 'col-1' },
|
||||
dueDate: null,
|
||||
priority: 'NORMAL',
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
const args = argOf<{ data: Record<string, unknown> }>(
|
||||
tx.suggestion.updateMany,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(args.data).toMatchObject({
|
||||
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshIn updates an open suggestion only', async () => {
|
||||
await service.refreshIn(tx as never, 'sug-1', {
|
||||
evidence: { colonyId: 'col-1' },
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { lockRuleIn, readRule } from '../automation/rule-config';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
@@ -122,6 +123,7 @@ export class SuggestionsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly tasks: TasksService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -143,7 +145,7 @@ export class SuggestionsService {
|
||||
// The colony is gone: nothing to propose (and no FK error).
|
||||
return null;
|
||||
}
|
||||
await lockRuleIn(tx, input.ruleKey);
|
||||
const rule = await lockRuleIn(tx, input.ruleKey);
|
||||
const open = await this.openIntentsIn(tx, input);
|
||||
if (open.has(input.intent)) {
|
||||
return null;
|
||||
@@ -165,9 +167,57 @@ export class SuggestionsService {
|
||||
},
|
||||
tx,
|
||||
);
|
||||
if (input.notify) {
|
||||
await this.notifyIn(tx, suggestion, input, rule.actionTemplate);
|
||||
}
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
/**
|
||||
* One SUGGESTION notification per user who may read tasks and the
|
||||
* suggestion's area (its title can name data of that area). Written in the
|
||||
* proposing transaction, so a failing write rolls the suggestion back and
|
||||
* the delivery is retried.
|
||||
*/
|
||||
private async notifyIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
suggestion: Suggestion,
|
||||
input: ProposeInput,
|
||||
actionTemplate: string | null,
|
||||
): Promise<void> {
|
||||
const lookup = await buildEvidenceLookup(tx, [
|
||||
suggestion.evidence as SuggestionEvidence,
|
||||
]);
|
||||
const { title } = renderSuggestion(
|
||||
suggestion,
|
||||
lookup,
|
||||
berlinToday(),
|
||||
actionTemplate,
|
||||
);
|
||||
const colonyCode =
|
||||
suggestion.colonyId === null
|
||||
? null
|
||||
: lookup.colonyCode(suggestion.colonyId);
|
||||
await this.notifications.recordForPermission(
|
||||
input.requiredPermission === null
|
||||
? ['tasks:read']
|
||||
: ['tasks:read', input.requiredPermission],
|
||||
{
|
||||
priority: suggestion.priority === 'URGENT' ? 'KRITISCH' : 'WARNUNG',
|
||||
category: 'SUGGESTION',
|
||||
message: colonyCode === null ? title : `${colonyCode}: ${title}`,
|
||||
link:
|
||||
suggestion.colonyId === null
|
||||
? '/suggestions'
|
||||
: `/colonies/${suggestion.colonyId}`,
|
||||
relatedType: 'Suggestion',
|
||||
relatedId: suggestion.id,
|
||||
dedupeKeyPrefix: `suggestion:${suggestion.id}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
/** The intents with an open suggestion or an open task for the object. */
|
||||
async openIntentsIn(
|
||||
db: DbClient,
|
||||
@@ -210,6 +260,8 @@ export class SuggestionsService {
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
patch: {
|
||||
/** Given when the same anchor can carry another text (e.g. first of the year). */
|
||||
reasonCode?: string;
|
||||
evidence: SuggestionEvidence;
|
||||
dueDate: string | null;
|
||||
priority: TaskPriority;
|
||||
@@ -220,6 +272,9 @@ export class SuggestionsService {
|
||||
await tx.suggestion.updateMany({
|
||||
where: { id, status: 'OPEN' },
|
||||
data: {
|
||||
...(patch.reasonCode === undefined
|
||||
? {}
|
||||
: { reasonCode: patch.reasonCode }),
|
||||
evidence: patch.evidence,
|
||||
dueDate: patch.dueDate === null ? null : dayToDate(patch.dueDate),
|
||||
priority: patch.priority,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { EvidenceLookup } from '../evidence-lookup';
|
||||
import { renderSuggestion } from '../suggestion-texts';
|
||||
|
||||
const lookup: EvidenceLookup = {
|
||||
colonyCode: () => 'B3',
|
||||
queenCode: () => null,
|
||||
treatmentProduct: (id) => (id === 't1' ? 'Oxuvar' : null),
|
||||
apiaryName: () => null,
|
||||
stockItemName: () => null,
|
||||
};
|
||||
|
||||
function render(
|
||||
reasonCode: string,
|
||||
evidence: Record<string, string | number | null>,
|
||||
today = '2026-05-12',
|
||||
) {
|
||||
return renderSuggestion(
|
||||
{ reasonCode, evidence, intent: 'INSPECT' },
|
||||
lookup,
|
||||
today,
|
||||
);
|
||||
}
|
||||
|
||||
describe('autopilot texts', () => {
|
||||
it('INSPECTION_DUE names the days since the last inspection and the interval of the segment', () => {
|
||||
expect(
|
||||
render('INSPECTION_DUE', {
|
||||
lastInspectedOn: '2026-05-04',
|
||||
intervalDays: 7,
|
||||
segment: 'SCHWARMZEIT',
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Durchsicht',
|
||||
reasons: [
|
||||
'Letzte Durchsicht vor 8 Tagen.',
|
||||
'In der Schwarmzeit alle 7 Tage.',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('INSPECTION_DUE copes with no inspection yet and singular days', () => {
|
||||
expect(
|
||||
render('INSPECTION_DUE', {
|
||||
lastInspectedOn: null,
|
||||
intervalDays: 14,
|
||||
segment: 'SOMMER',
|
||||
}).reasons,
|
||||
).toEqual(['Noch keine Durchsicht erfasst.', 'Im Sommer alle 14 Tage.']);
|
||||
expect(
|
||||
render('INSPECTION_DUE', {
|
||||
lastInspectedOn: '2026-05-11',
|
||||
intervalDays: 7,
|
||||
segment: 'SCHWARMZEIT',
|
||||
}).reasons[0],
|
||||
).toBe('Letzte Durchsicht vor 1 Tag.');
|
||||
});
|
||||
|
||||
it('INSPECTION_FIRST_OF_YEAR says so', () => {
|
||||
expect(
|
||||
render('INSPECTION_FIRST_OF_YEAR', { lastInspectedOn: '2025-09-20' }),
|
||||
).toEqual({
|
||||
title: 'Erste Durchsicht im Jahr',
|
||||
reasons: ['Die Saison hat begonnen.', 'Letzte Durchsicht am 20.09.2025.'],
|
||||
});
|
||||
});
|
||||
|
||||
it('VARROA_MEASURE_DUE and VARROA_WINTER_MEASURE', () => {
|
||||
expect(
|
||||
render(
|
||||
'VARROA_MEASURE_DUE',
|
||||
{ lastMeasuredOn: '2026-04-07', intervalDays: 28 },
|
||||
'2026-05-12',
|
||||
),
|
||||
).toEqual({
|
||||
title: 'Varroa messen',
|
||||
reasons: ['Letzte Messung vor 35 Tagen.', 'Alle 28 Tage messen.'],
|
||||
});
|
||||
expect(render('VARROA_WINTER_MEASURE', {}).reasons).toEqual([
|
||||
'Vor der Winterbehandlung den Milbenfall messen.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('VARROA_RECHECK_DUE names the product from the lookup', () => {
|
||||
expect(
|
||||
render('VARROA_RECHECK_DUE', {
|
||||
treatmentId: 't1',
|
||||
endedOn: '2026-08-02',
|
||||
recheckOn: '2026-08-16',
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Behandlungserfolg messen',
|
||||
reasons: [
|
||||
'Behandlung mit Oxuvar endete am 02.08.',
|
||||
'Nachkontrolle ab 16.08.',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,175 @@
|
||||
import type { SuggestionTextRegister } from '../suggestion-texts';
|
||||
import { daysBetween } from '../../common/dates';
|
||||
import type { SuggestionEvidence } from '../suggestion.types';
|
||||
import {
|
||||
formatDayDe,
|
||||
type SuggestionTextRegister,
|
||||
type SuggestionTextTemplate,
|
||||
} from '../suggestion-texts';
|
||||
|
||||
const MAX_PRODUCT_LENGTH = 40;
|
||||
|
||||
const SEGMENT_PHRASES: Readonly<Record<string, string>> = {
|
||||
FRUEHJAHR: 'Im Frühjahr',
|
||||
SCHWARMZEIT: 'In der Schwarmzeit',
|
||||
SOMMER: 'Im Sommer',
|
||||
SPAETSOMMER: 'Im Spätsommer',
|
||||
HERBST: 'Im Herbst',
|
||||
};
|
||||
|
||||
function text(evidence: SuggestionEvidence, key: string): string | null {
|
||||
const value = evidence[key];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function count(evidence: SuggestionEvidence, key: string): number | null {
|
||||
const value = evidence[key];
|
||||
return typeof value === 'number' ? value : null;
|
||||
}
|
||||
|
||||
/** 'vor 8 Tagen', 'vor 1 Tag', 'heute'. */
|
||||
function agoDe(from: string, today: string): string {
|
||||
const days = Math.max(0, daysBetween(from, today));
|
||||
if (days === 0) {
|
||||
return 'heute';
|
||||
}
|
||||
return `vor ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
|
||||
}
|
||||
|
||||
function lastInspectionReason(
|
||||
evidence: SuggestionEvidence,
|
||||
today: string,
|
||||
): string {
|
||||
const last = text(evidence, 'lastInspectedOn');
|
||||
return last === null
|
||||
? 'Noch keine Durchsicht erfasst.'
|
||||
: `Letzte Durchsicht ${agoDe(last, today)}.`;
|
||||
}
|
||||
|
||||
function intervalReason(evidence: SuggestionEvidence): string[] {
|
||||
const phrase = SEGMENT_PHRASES[text(evidence, 'segment') ?? ''];
|
||||
const days = count(evidence, 'intervalDays');
|
||||
return phrase !== undefined && days !== null
|
||||
? [`${phrase} alle ${days} Tage.`]
|
||||
: [];
|
||||
}
|
||||
|
||||
const INSPECTION_DUE: SuggestionTextTemplate = {
|
||||
ruleKey: 'INSPECTION_INTERVAL',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
inspectionId: 'i1',
|
||||
lastInspectedOn: '2026-05-04',
|
||||
dueOn: '2026-05-11',
|
||||
intervalDays: 7,
|
||||
segment: 'SCHWARMZEIT',
|
||||
},
|
||||
render: (evidence, _lookup, today) => ({
|
||||
title: 'Durchsicht',
|
||||
reasons: [
|
||||
lastInspectionReason(evidence, today),
|
||||
...intervalReason(evidence),
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const INSPECTION_FIRST_OF_YEAR: SuggestionTextTemplate = {
|
||||
ruleKey: 'INSPECTION_INTERVAL',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
inspectionId: 'i1',
|
||||
lastInspectedOn: '2025-09-20',
|
||||
dueOn: '2026-03-20',
|
||||
intervalDays: 14,
|
||||
segment: 'FRUEHJAHR',
|
||||
},
|
||||
render: (evidence) => {
|
||||
const last = text(evidence, 'lastInspectedOn');
|
||||
return {
|
||||
title: 'Erste Durchsicht im Jahr',
|
||||
reasons: [
|
||||
'Die Saison hat begonnen.',
|
||||
last === null
|
||||
? 'Noch keine Durchsicht erfasst.'
|
||||
: `Letzte Durchsicht am ${formatDayDe(last, true)}.`,
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const VARROA_MEASURE_DUE: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
measurementId: 'm1',
|
||||
lastMeasuredOn: '2026-05-06',
|
||||
dueOn: '2026-06-03',
|
||||
intervalDays: 28,
|
||||
},
|
||||
render: (evidence, _lookup, today) => {
|
||||
const last = text(evidence, 'lastMeasuredOn');
|
||||
const days = count(evidence, 'intervalDays');
|
||||
return {
|
||||
title: 'Varroa messen',
|
||||
reasons: [
|
||||
last === null
|
||||
? 'Noch keine Messung erfasst.'
|
||||
: `Letzte Messung ${agoDe(last, today)}.`,
|
||||
...(days === null ? [] : [`Alle ${days} Tage messen.`]),
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const VARROA_WINTER_MEASURE: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
measurementId: 'm1',
|
||||
lastMeasuredOn: '2026-09-01',
|
||||
dueOn: '2026-11-15',
|
||||
intervalDays: 28,
|
||||
},
|
||||
render: () => ({
|
||||
title: 'Varroa messen',
|
||||
reasons: ['Vor der Winterbehandlung den Milbenfall messen.'],
|
||||
}),
|
||||
};
|
||||
|
||||
const VARROA_RECHECK_DUE: SuggestionTextTemplate = {
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
example: {
|
||||
colonyId: 'c1',
|
||||
treatmentId: 't1',
|
||||
endedOn: '2026-08-02',
|
||||
recheckOn: '2026-08-16',
|
||||
},
|
||||
render: (evidence, lookup) => {
|
||||
const treatmentId = text(evidence, 'treatmentId');
|
||||
const product =
|
||||
(treatmentId === null ? null : lookup.treatmentProduct(treatmentId)) ??
|
||||
'Behandlung';
|
||||
const name = product.slice(0, MAX_PRODUCT_LENGTH);
|
||||
const ended = text(evidence, 'endedOn');
|
||||
const recheck = text(evidence, 'recheckOn');
|
||||
return {
|
||||
title: 'Behandlungserfolg messen',
|
||||
reasons: [
|
||||
ended === null
|
||||
? `Behandlung mit ${name} beendet.`
|
||||
: `Behandlung mit ${name} endete am ${formatDayDe(ended)}`,
|
||||
...(recheck === null
|
||||
? []
|
||||
: [`Nachkontrolle ab ${formatDayDe(recheck)}`]),
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Texts of the season autopilot suggestions, by reason code. */
|
||||
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {};
|
||||
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {
|
||||
INSPECTION_DUE,
|
||||
INSPECTION_FIRST_OF_YEAR,
|
||||
VARROA_MEASURE_DUE,
|
||||
VARROA_WINTER_MEASURE,
|
||||
VARROA_RECHECK_DUE,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
// Imported before anything else: the texts must not depend on suggestion-texts
|
||||
// having been loaded first.
|
||||
import { CHAIN_TEXTS } from './chain-texts';
|
||||
import { SUGGESTION_TEXTS } from '../suggestion-texts';
|
||||
|
||||
describe('chain texts', () => {
|
||||
it('load on their own and are part of the merged register', () => {
|
||||
expect(Object.keys(CHAIN_TEXTS).length).toBeGreaterThan(0);
|
||||
for (const code of Object.keys(CHAIN_TEXTS)) {
|
||||
expect(SUGGESTION_TEXTS[code]).toBe(CHAIN_TEXTS[code]);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the suggestions as the spec does', () => {
|
||||
const lookup = {
|
||||
colonyCode: () => 'V-1',
|
||||
queenCode: () => 'Q-1',
|
||||
treatmentProduct: () => null,
|
||||
apiaryName: () => null,
|
||||
stockItemName: () => null,
|
||||
};
|
||||
const title = (code: string) =>
|
||||
CHAIN_TEXTS[code].render(CHAIN_TEXTS[code].example, lookup, '2026-05-05')
|
||||
.title;
|
||||
expect(title('SPLIT_SCHEDULE_RAISE_OWN')).toBe('Ableger-Plan übernehmen');
|
||||
expect(title('SPLIT_SCHEDULE_QUEEN_CELL')).toBe('Stifte prüfen');
|
||||
expect(title('SPLIT_SCHEDULE_QUEEN')).toBe('Annahme der Königin prüfen');
|
||||
expect(title('QUEEN_CELLS_SWARM_CHECK')).toBe('Schwarmkontrolle');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,111 @@
|
||||
import type { SuggestionTextRegister } from '../suggestion-texts';
|
||||
import type {
|
||||
SuggestionTextRegister,
|
||||
SuggestionTextTemplate,
|
||||
} from '../suggestion-texts';
|
||||
import { formatDayDe } from './format-day';
|
||||
import type { EvidenceLookup } from '../evidence-lookup';
|
||||
import type { SuggestionEvidence } from '../suggestion.types';
|
||||
|
||||
function dayOf(evidence: SuggestionEvidence, key: string): string {
|
||||
const value = evidence[key];
|
||||
return typeof value === 'string' ? formatDayDe(value) : '';
|
||||
}
|
||||
|
||||
function colonyOf(
|
||||
evidence: SuggestionEvidence,
|
||||
key: string,
|
||||
lookup: EvidenceLookup,
|
||||
): string {
|
||||
const id = evidence[key];
|
||||
return (typeof id === 'string' ? lookup.colonyCode(id) : null) ?? 'Volk';
|
||||
}
|
||||
|
||||
/** Split suggestions differ only in the second reason. */
|
||||
function splitTemplate(
|
||||
title: string,
|
||||
queenSource: string,
|
||||
reason: string,
|
||||
): SuggestionTextTemplate {
|
||||
return {
|
||||
ruleKey: 'SPLIT_SCHEDULE',
|
||||
example: {
|
||||
colonyId: 'col-1',
|
||||
parentColonyId: 'col-0',
|
||||
lineageId: 'lin-1',
|
||||
queenSource,
|
||||
splitOn: '2026-05-01',
|
||||
baseDate: '2026-05-01',
|
||||
},
|
||||
render: (evidence, lookup) => ({
|
||||
title,
|
||||
reasons: [
|
||||
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}.`,
|
||||
reason,
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Texts of the chain suggestions, by reason code. */
|
||||
export const CHAIN_TEXTS: SuggestionTextRegister = {};
|
||||
export const CHAIN_TEXTS: SuggestionTextRegister = {
|
||||
QUEEN_CELLS_SWARM_CHECK: {
|
||||
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
|
||||
example: {
|
||||
colonyId: 'col-1',
|
||||
inspectionId: 'insp-1',
|
||||
inspectedOn: '2026-05-05',
|
||||
baseDate: '2026-05-05',
|
||||
},
|
||||
render: (evidence, lookup) => ({
|
||||
title: 'Schwarmkontrolle',
|
||||
reasons: [
|
||||
`Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden.`,
|
||||
],
|
||||
}),
|
||||
},
|
||||
QUEEN_ASSIGNED_ACCEPTANCE: {
|
||||
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||
example: {
|
||||
colonyId: 'col-1',
|
||||
queenId: 'queen-1',
|
||||
historyId: 'hist-1',
|
||||
assignedOn: '2026-05-05',
|
||||
baseDate: '2026-05-05',
|
||||
},
|
||||
render: (evidence, lookup) => {
|
||||
const queenId = evidence.queenId;
|
||||
const queen =
|
||||
(typeof queenId === 'string' ? lookup.queenCode(queenId) : null) ??
|
||||
'Königin';
|
||||
return {
|
||||
title: 'Annahme der Königin prüfen',
|
||||
reasons: [
|
||||
`${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt.`,
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
SPLIT_SCHEDULE_RAISE_OWN: splitTemplate(
|
||||
'Ableger-Plan übernehmen',
|
||||
'RAISE_OWN',
|
||||
'Die Königin wird selbst nachgezogen.',
|
||||
),
|
||||
SPLIT_SCHEDULE_QUEEN_CELL: splitTemplate(
|
||||
'Stifte prüfen',
|
||||
'QUEEN_CELL',
|
||||
'Mit Weiselzelle gebildet.',
|
||||
),
|
||||
SPLIT_SCHEDULE_QUEEN: splitTemplate(
|
||||
'Annahme der Königin prüfen',
|
||||
'QUEEN',
|
||||
'Die Königin wurde zugesetzt.',
|
||||
),
|
||||
};
|
||||
|
||||
/** 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',
|
||||
RAISE_CELLS_CHECK: 'Nachschaffungszellen prüfen',
|
||||
MATING_CHECK: 'Begattung prüfen',
|
||||
EGGS_CHECK: 'Stifte prüfen',
|
||||
ACCEPTANCE_CHECK: 'Annahme der Königin prüfen',
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
/** '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}.`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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,108 @@
|
||||
import type { EvidenceLookup } from '../evidence-lookup';
|
||||
import { renderSuggestion } from '../suggestion-texts';
|
||||
import type { SuggestionEvidence } from '../suggestion.types';
|
||||
import { RULE_TEXTS } from './rule-texts';
|
||||
|
||||
const lookup: EvidenceLookup = {
|
||||
colonyCode: (id) => (id === 'gone' ? null : 'LWG'),
|
||||
queenCode: () => null,
|
||||
treatmentProduct: (id) => (id === 'gone' ? null : 'Ameisensäure'),
|
||||
apiaryName: () => null,
|
||||
stockItemName: () => 'Gläser 500 g',
|
||||
};
|
||||
|
||||
const TODAY = '2026-10-14';
|
||||
|
||||
function render(
|
||||
reasonCode: string,
|
||||
evidence: SuggestionEvidence,
|
||||
intent: 'VARROA_TREAT' | 'FOLLOW_UP' | 'STOCK_REORDER' = 'FOLLOW_UP',
|
||||
) {
|
||||
return renderSuggestion(
|
||||
{ reasonCode, evidence: evidence as never, intent },
|
||||
lookup,
|
||||
TODAY,
|
||||
);
|
||||
}
|
||||
|
||||
describe('rule texts', () => {
|
||||
it('has a template for every reason code of the rule suggestions', () => {
|
||||
expect(Object.keys(RULE_TEXTS).sort()).toEqual(
|
||||
[
|
||||
'COLONY_HEALTH_CRITICAL',
|
||||
'FEEDING_FOLLOW_UP',
|
||||
'FEED_STATUS_CRITICAL',
|
||||
'QUEENLESS_NO_EGGS',
|
||||
'RAW_MATERIAL_LOW',
|
||||
'STOCK_ITEM_LOW',
|
||||
'SWARM_MOOD_HIGH',
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
'VARROA_RED',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('VARROA_RED names rate, day and limit with the colony code', () => {
|
||||
expect(render('VARROA_RED', RULE_TEXTS.VARROA_RED.example)).toEqual({
|
||||
title: 'Behandlung prüfen',
|
||||
reasons: ['12,4 Milben pro Tag am 10.05.', 'Grenze: 10 (LWG).'],
|
||||
});
|
||||
});
|
||||
|
||||
it('VARROA_RED words a percentage method in percent', () => {
|
||||
const { reasons } = render('VARROA_RED', {
|
||||
colonyId: 'c',
|
||||
measuredOn: '2026-07-10',
|
||||
value: 6,
|
||||
redAbove: 5,
|
||||
unit: 'PERCENT',
|
||||
});
|
||||
|
||||
expect(reasons).toEqual(['6 % Befall am 10.07.', 'Grenze: 5 % (LWG).']);
|
||||
});
|
||||
|
||||
it('QUEENLESS_NO_EGGS and the inspection rules name the day and the colony', () => {
|
||||
expect(
|
||||
render('QUEENLESS_NO_EGGS', RULE_TEXTS.QUEENLESS_NO_EGGS.example).reasons,
|
||||
).toEqual(['Königin nicht gesehen, keine Eier am 05.05.']);
|
||||
expect(
|
||||
render('FEED_STATUS_CRITICAL', RULE_TEXTS.FEED_STATUS_CRITICAL.example)
|
||||
.reasons,
|
||||
).toEqual(['Futterstatus kritisch am 05.05.']);
|
||||
});
|
||||
|
||||
it('STOCK_ITEM_LOW names the item and the minimum', () => {
|
||||
expect(
|
||||
render('STOCK_ITEM_LOW', RULE_TEXTS.STOCK_ITEM_LOW.example).reasons,
|
||||
).toEqual(['Gläser 500 g: noch 2 auf Lager, Mindestbestand 5.']);
|
||||
});
|
||||
|
||||
it('RAW_MATERIAL_LOW names the kind in German', () => {
|
||||
expect(
|
||||
render('RAW_MATERIAL_LOW', RULE_TEXTS.RAW_MATERIAL_LOW.example).reasons,
|
||||
).toEqual(['Bienenwachs: noch 400 g auf Lager, Mindestmenge 1.000 g.']);
|
||||
});
|
||||
|
||||
it('TREATMENT_FOLLOW_UP names the product, or none when the treatment is gone', () => {
|
||||
expect(
|
||||
render('TREATMENT_FOLLOW_UP', RULE_TEXTS.TREATMENT_FOLLOW_UP.example)
|
||||
.reasons,
|
||||
).toEqual(['Nach Behandlung mit Ameisensäure am 10.08.']);
|
||||
expect(
|
||||
render('TREATMENT_FOLLOW_UP', {
|
||||
colonyId: 'gone',
|
||||
treatmentId: 'gone',
|
||||
endedOn: '2026-08-10',
|
||||
}).reasons,
|
||||
).toEqual(['Nach der Behandlung am 10.08.']);
|
||||
});
|
||||
|
||||
it('survives evidence without its optional parts', () => {
|
||||
for (const [reasonCode, template] of Object.entries(RULE_TEXTS)) {
|
||||
const { title, reasons } = render(reasonCode, {});
|
||||
expect(title).not.toBe('');
|
||||
expect(reasons.length).toBeLessThanOrEqual(2);
|
||||
expect(template.ruleKey).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,214 @@
|
||||
import type { SuggestionTextRegister } from '../suggestion-texts';
|
||||
import { formatDayDe } from './format-day';
|
||||
import { formatNumberDe } from './format-number';
|
||||
import type {
|
||||
RenderedSuggestionText,
|
||||
SuggestionTextRegister,
|
||||
SuggestionTextTemplate,
|
||||
} from '../suggestion-texts';
|
||||
import type { EvidenceLookup } from '../evidence-lookup';
|
||||
import type { SuggestionEvidence } from '../suggestion.types';
|
||||
|
||||
/** Texts of the rule suggestions, by reason code (filled by the rule tasks). */
|
||||
export const RULE_TEXTS: SuggestionTextRegister = {};
|
||||
const RAW_MATERIAL_LABELS: Readonly<Record<string, string>> = {
|
||||
BEESWAX: 'Bienenwachs',
|
||||
PROPOLIS: 'Propolis',
|
||||
};
|
||||
|
||||
function text(evidence: SuggestionEvidence, key: string): string | null {
|
||||
const value = evidence[key];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function num(evidence: SuggestionEvidence, key: string): number | null {
|
||||
const value = evidence[key];
|
||||
return typeof value === 'number' ? value : null;
|
||||
}
|
||||
|
||||
/** '05.05.' for an evidence day; empty when the evidence has none. */
|
||||
function dayOf(evidence: SuggestionEvidence, key: string): string {
|
||||
const day = text(evidence, key);
|
||||
return day === null ? '' : formatDayDe(day);
|
||||
}
|
||||
|
||||
/** 'Text am 05.05.' (the day's dot ends the sentence), or 'Text.' */
|
||||
function dated(
|
||||
base: string,
|
||||
evidence: SuggestionEvidence,
|
||||
key: string,
|
||||
): string {
|
||||
const day = dayOf(evidence, key);
|
||||
return day === '' ? `${base}.` : `${base} am ${day}`;
|
||||
}
|
||||
|
||||
function colonyCodeOf(
|
||||
evidence: SuggestionEvidence,
|
||||
lookup: EvidenceLookup,
|
||||
): string | null {
|
||||
const id = text(evidence, 'colonyId');
|
||||
return id === null ? null : lookup.colonyCode(id);
|
||||
}
|
||||
|
||||
function template(
|
||||
ruleKey: SuggestionTextTemplate['ruleKey'],
|
||||
example: SuggestionEvidence,
|
||||
render: (
|
||||
evidence: SuggestionEvidence,
|
||||
lookup: EvidenceLookup,
|
||||
) => RenderedSuggestionText,
|
||||
): SuggestionTextTemplate {
|
||||
return { ruleKey, example, render };
|
||||
}
|
||||
|
||||
function varroaReasons(
|
||||
evidence: SuggestionEvidence,
|
||||
lookup: EvidenceLookup,
|
||||
): string[] {
|
||||
const value = num(evidence, 'value');
|
||||
const limit = num(evidence, 'redAbove');
|
||||
const perDay = evidence.unit === 'MITES_PER_DAY';
|
||||
const shown = value === null ? '?' : formatNumberDe(value);
|
||||
const measured = dated(
|
||||
perDay ? `${shown} Milben pro Tag` : `${shown} % Befall`,
|
||||
evidence,
|
||||
'measuredOn',
|
||||
);
|
||||
if (limit === null) {
|
||||
return [measured];
|
||||
}
|
||||
const code = colonyCodeOf(evidence, lookup);
|
||||
const unit = perDay ? '' : ' %';
|
||||
return [
|
||||
measured,
|
||||
`Grenze: ${formatNumberDe(limit)}${unit}${code === null ? '' : ` (${code})`}.`,
|
||||
];
|
||||
}
|
||||
|
||||
/** Texts of the rule suggestions, by reason code. */
|
||||
export const RULE_TEXTS: SuggestionTextRegister = {
|
||||
QUEENLESS_NO_EGGS: template(
|
||||
'QUEENLESS_NO_EGGS',
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
inspectionId: 'insp-1',
|
||||
inspectedOn: '2026-05-05',
|
||||
},
|
||||
(evidence) => ({
|
||||
title: 'Königin prüfen',
|
||||
reasons: [
|
||||
dated('Königin nicht gesehen, keine Eier', evidence, 'inspectedOn'),
|
||||
],
|
||||
}),
|
||||
),
|
||||
FEED_STATUS_CRITICAL: template(
|
||||
'FEED_STATUS_CRITICAL',
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
inspectionId: 'insp-1',
|
||||
inspectedOn: '2026-05-05',
|
||||
},
|
||||
(evidence) => ({
|
||||
title: 'Füttern',
|
||||
reasons: [dated('Futterstatus kritisch', evidence, 'inspectedOn')],
|
||||
}),
|
||||
),
|
||||
VARROA_RED: template(
|
||||
'VARROA_THRESHOLD',
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
measurementId: 'm-1',
|
||||
measuredOn: '2026-05-10',
|
||||
value: 12.4,
|
||||
redAbove: 10,
|
||||
unit: 'MITES_PER_DAY',
|
||||
},
|
||||
(evidence, lookup) => ({
|
||||
title: 'Behandlung prüfen',
|
||||
reasons: varroaReasons(evidence, lookup),
|
||||
}),
|
||||
),
|
||||
SWARM_MOOD_HIGH: template(
|
||||
'SWARM_MOOD_HIGH',
|
||||
{ colonyId: 'colony-1', checkId: 'check-1', checkedOn: '2026-05-05' },
|
||||
(evidence) => ({
|
||||
title: 'Schwarmkontrolle',
|
||||
reasons: [dated('Hohe Schwarmstimmung', evidence, 'checkedOn')],
|
||||
}),
|
||||
),
|
||||
COLONY_HEALTH_CRITICAL: template(
|
||||
'COLONY_HEALTH_CRITICAL',
|
||||
{ colonyId: 'colony-1', changedOn: '2026-05-05' },
|
||||
(evidence) => ({
|
||||
title: 'Gesundheit prüfen',
|
||||
reasons: [
|
||||
dated('Gesundheitsstatus auf kritisch gesetzt', evidence, 'changedOn'),
|
||||
],
|
||||
}),
|
||||
),
|
||||
STOCK_ITEM_LOW: template(
|
||||
'STOCK_ITEM_LOW',
|
||||
{ stockItemId: 'item-1', quantity: 2, minThreshold: 5 },
|
||||
(evidence, lookup) => {
|
||||
const itemId = text(evidence, 'stockItemId');
|
||||
const name = itemId === null ? null : lookup.stockItemName(itemId);
|
||||
const quantity = num(evidence, 'quantity');
|
||||
const minimum = num(evidence, 'minThreshold');
|
||||
return {
|
||||
title: 'Nachbestellen',
|
||||
reasons: [
|
||||
`${name ?? 'Lagerartikel'}: noch ${quantity === null ? '?' : formatNumberDe(quantity)} auf Lager, Mindestbestand ${minimum === null ? '?' : formatNumberDe(minimum)}.`,
|
||||
],
|
||||
};
|
||||
},
|
||||
),
|
||||
RAW_MATERIAL_LOW: template(
|
||||
'RAW_MATERIAL_LOW',
|
||||
{ kind: 'BEESWAX', remainingGrams: 400, minGrams: 1000 },
|
||||
(evidence) => {
|
||||
const kind = text(evidence, 'kind');
|
||||
const label =
|
||||
kind !== null && Object.hasOwn(RAW_MATERIAL_LABELS, kind)
|
||||
? RAW_MATERIAL_LABELS[kind]
|
||||
: 'Rohstoff';
|
||||
const remaining = num(evidence, 'remainingGrams');
|
||||
const minimum = num(evidence, 'minGrams');
|
||||
return {
|
||||
title: 'Rohstoff nachbestellen',
|
||||
reasons: [
|
||||
`${label}: noch ${remaining === null ? '?' : formatNumberDe(remaining)} g auf Lager, Mindestmenge ${minimum === null ? '?' : formatNumberDe(minimum)} g.`,
|
||||
],
|
||||
};
|
||||
},
|
||||
),
|
||||
TREATMENT_FOLLOW_UP: template(
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
treatmentId: 'treatment-1',
|
||||
endedOn: '2026-08-10',
|
||||
},
|
||||
(evidence, lookup) => {
|
||||
const treatmentId = text(evidence, 'treatmentId');
|
||||
const product =
|
||||
treatmentId === null ? null : lookup.treatmentProduct(treatmentId);
|
||||
return {
|
||||
title: 'Behandlungserfolg kontrollieren',
|
||||
reasons: [
|
||||
dated(
|
||||
product === null
|
||||
? 'Nach der Behandlung'
|
||||
: `Nach Behandlung mit ${product}`,
|
||||
evidence,
|
||||
'endedOn',
|
||||
),
|
||||
],
|
||||
};
|
||||
},
|
||||
),
|
||||
FEEDING_FOLLOW_UP: template(
|
||||
'FEEDING_FOLLOW_UP',
|
||||
{ colonyId: 'colony-1', feedingId: 'feeding-1', fedOn: '2026-09-01' },
|
||||
(evidence) => ({
|
||||
title: 'Futterabnahme kontrollieren',
|
||||
reasons: [dated('Nach der Fütterung', evidence, 'fedOn')],
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -26,4 +26,23 @@ export class VarroaThresholdsQuery {
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' })
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Range form: first day (JJJJ-MM-TT); needs `to`, excludes `date`',
|
||||
example: '2025-01-01',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN, { message: 'from muss ein Tag (JJJJ-MM-TT) sein' })
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Range form: last day (JJJJ-MM-TT), at most 731 days',
|
||||
example: '2026-12-31',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN, { message: 'to muss ein Tag (JJJJ-MM-TT) sein' })
|
||||
to?: string;
|
||||
}
|
||||
@@ -100,6 +100,108 @@ describe('VarroaBandsService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('thresholdSegments', () => {
|
||||
const range = (from: string, to: string, colonyId?: string) =>
|
||||
service.thresholdSegments({ colonyId, from, to });
|
||||
|
||||
it('merges days with identical bands into one segment', async () => {
|
||||
const { segments } = await range('2026-06-05', '2026-06-20');
|
||||
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0]).toMatchObject({
|
||||
from: '2026-06-05',
|
||||
to: '2026-06-20',
|
||||
active: true,
|
||||
postTreatmentUntil: null,
|
||||
});
|
||||
expect(segments[0].bands.NATURAL_MITE_FALL).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2026-02-27', '2026-03-02', '2026-02-28', '2026-03-01'],
|
||||
['2026-04-29', '2026-05-02', '2026-04-30', '2026-05-01'],
|
||||
['2026-07-30', '2026-08-02', '2026-07-31', '2026-08-01'],
|
||||
['2026-10-30', '2026-11-02', '2026-10-31', '2026-11-01'],
|
||||
])(
|
||||
'splits %s..%s between %s and %s',
|
||||
async (from, to, lastOfFirst, firstOfSecond) => {
|
||||
const { segments } = await range(from, to);
|
||||
|
||||
expect(segments.map((segment) => [segment.from, segment.to])).toEqual([
|
||||
[from, lastOfFirst],
|
||||
[firstOfSecond, to],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('splits at treatment windows and names the recheck day', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
});
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{ date: d('2026-07-12'), startDate: d('2026-07-10'), endDate: null },
|
||||
]);
|
||||
|
||||
const { segments } = await range('2026-07-08', '2026-07-30', 'c1');
|
||||
|
||||
expect(segments.map((s) => [s.from, s.to, s.postTreatmentUntil])).toEqual(
|
||||
[
|
||||
['2026-07-08', '2026-07-09', null],
|
||||
['2026-07-10', '2026-07-25', '2026-07-26'],
|
||||
['2026-07-26', '2026-07-30', null],
|
||||
],
|
||||
);
|
||||
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.colony.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns one inactive segment without bands while the rule is off', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'r',
|
||||
active: false,
|
||||
config: {},
|
||||
actionTemplate: null,
|
||||
});
|
||||
|
||||
await expect(range('2026-03-01', '2026-09-30')).resolves.toEqual({
|
||||
segments: [
|
||||
{
|
||||
from: '2026-03-01',
|
||||
to: '2026-09-30',
|
||||
bands: {},
|
||||
postTreatmentUntil: null,
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts exactly 731 days and rejects 732', async () => {
|
||||
await expect(range('2025-01-01', '2027-01-01')).resolves.toBeDefined();
|
||||
await expect(range('2025-01-01', '2027-01-02')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects from after to, invalid days and an unknown colony', async () => {
|
||||
await expect(range('2026-06-02', '2026-06-01')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(range('2026-02-30', '2026-03-05')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
range('2026-06-01', '2026-06-02', 'missing'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('accepts a single day', async () => {
|
||||
const { segments } = await range('2026-06-01', '2026-06-01');
|
||||
expect(segments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate', () => {
|
||||
const measurement = {
|
||||
id: 'm1',
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type VarroaBands,
|
||||
type VarroaRating,
|
||||
} from '../automation/varroa-bands';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { addDays, berlinToday, dayToDate, daysBetween } from '../common/dates';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
||||
|
||||
@@ -20,6 +20,22 @@ export interface VarroaThresholds extends VarroaBands {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** A run of consecutive days on which the bands are identical. */
|
||||
export interface VarroaThresholdSegment {
|
||||
from: string;
|
||||
to: string;
|
||||
bands: VarroaBands['bands'];
|
||||
postTreatmentUntil: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface VarroaThresholdSegments {
|
||||
segments: VarroaThresholdSegment[];
|
||||
}
|
||||
|
||||
/** Longest range of the range query, in days (two years plus a leap day). */
|
||||
export const MAX_THRESHOLD_RANGE_DAYS = 731;
|
||||
|
||||
type RatableMeasurement = Pick<
|
||||
VarroaMeasurement,
|
||||
'date' | 'method' | 'calculatedValue'
|
||||
@@ -72,6 +88,68 @@ export class VarroaBandsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The bands over a day range as segments of identical bands (they change by
|
||||
* month and with treatment windows). Rule, colony type and treatments are
|
||||
* read once.
|
||||
*/
|
||||
async thresholdSegments(query: {
|
||||
colonyId?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<VarroaThresholdSegments> {
|
||||
const from = validDay(query.from);
|
||||
const to = validDay(query.to);
|
||||
const days = daysBetween(from, to) + 1;
|
||||
if (days < 1 || days > MAX_THRESHOLD_RANGE_DAYS) {
|
||||
throw new BadRequestException(
|
||||
`Der Zeitraum muss zwischen 1 und ${MAX_THRESHOLD_RANGE_DAYS} Tage lang sein.`,
|
||||
);
|
||||
}
|
||||
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
|
||||
const colonyType =
|
||||
query.colonyId === undefined
|
||||
? null
|
||||
: await this.colonyTypeOf(query.colonyId);
|
||||
if (!rule.active) {
|
||||
return {
|
||||
segments: [
|
||||
{ from, to, bands: {}, postTreatmentUntil: null, active: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
const treatments =
|
||||
query.colonyId === undefined
|
||||
? []
|
||||
: await treatmentWindowsIn(this.prisma, query.colonyId);
|
||||
const segments: VarroaThresholdSegment[] = [];
|
||||
let key = '';
|
||||
for (let offset = 0; offset < days; offset++) {
|
||||
const day = addDays(from, offset);
|
||||
const { bands, postTreatmentUntil } = bandsFor(
|
||||
day,
|
||||
colonyType,
|
||||
rule.config,
|
||||
treatments,
|
||||
);
|
||||
const dayKey = JSON.stringify([bands, postTreatmentUntil]);
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && dayKey === key) {
|
||||
last.to = day;
|
||||
} else {
|
||||
segments.push({
|
||||
from: day,
|
||||
to: day,
|
||||
bands,
|
||||
postTreatmentUntil,
|
||||
active: true,
|
||||
});
|
||||
key = dayKey;
|
||||
}
|
||||
}
|
||||
return { segments };
|
||||
}
|
||||
|
||||
/** Each measurement of one colony with its rating. */
|
||||
async rate<M extends RatableMeasurement>(
|
||||
colonyId: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -34,11 +35,20 @@ export class VarroaController {
|
||||
return this.varroaService.findAll();
|
||||
}
|
||||
|
||||
/** Bands in force on a day for a colony; declared before :id so it is not shadowed. */
|
||||
/** Bands in force on a day (`date`) or as segments over a range (`from`, `to`) for a colony; declared before :id so it is not shadowed. */
|
||||
@RequirePermission('varroa:read', 'varroa:create')
|
||||
@Get('thresholds')
|
||||
getThresholds(@Query() query: VarroaThresholdsQuery) {
|
||||
return this.varroaBandsService.thresholds(query);
|
||||
const { colonyId, date, from, to } = query;
|
||||
if (from === undefined && to === undefined) {
|
||||
return this.varroaBandsService.thresholds({ colonyId, date });
|
||||
}
|
||||
if (from === undefined || to === undefined || date !== undefined) {
|
||||
throw new BadRequestException(
|
||||
'Bitte entweder date oder from und to angeben.',
|
||||
);
|
||||
}
|
||||
return this.varroaBandsService.thresholdSegments({ colonyId, from, to });
|
||||
}
|
||||
|
||||
@RequirePermission('varroa:read')
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { AutopilotModule } from '../autopilot/autopilot.module';
|
||||
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
@@ -33,6 +34,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
||||
TasksModule,
|
||||
AutomationModule,
|
||||
SuggestionsModule,
|
||||
AutopilotModule,
|
||||
WorkerRuntimeModule,
|
||||
],
|
||||
providers: [WorkerHealthServer],
|
||||
|
||||
Reference in new issue
Block a user