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 };
|
apiary: { findUnique: jest.Mock };
|
||||||
colony: { findMany: jest.Mock };
|
colony: { findMany: jest.Mock };
|
||||||
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
|
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
|
||||||
notification: { groupBy: jest.Mock };
|
$queryRaw: jest.Mock;
|
||||||
queen: { findMany: jest.Mock };
|
queen: { findMany: jest.Mock };
|
||||||
task: { findMany: jest.Mock };
|
task: { findMany: jest.Mock };
|
||||||
};
|
};
|
||||||
@@ -23,6 +23,7 @@ describe('ApiaryFieldService', () => {
|
|||||||
colonyType: 'PRODUCTION_COLONY',
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
hiveType: null,
|
hiveType: null,
|
||||||
strength: 5,
|
strength: 5,
|
||||||
|
occupiedFrames: 12,
|
||||||
broodStatus: null,
|
broodStatus: null,
|
||||||
feedStatus: null,
|
feedStatus: null,
|
||||||
healthStatus: null,
|
healthStatus: null,
|
||||||
@@ -55,7 +56,7 @@ describe('ApiaryFieldService', () => {
|
|||||||
groupBy: jest.fn().mockResolvedValue([]),
|
groupBy: jest.fn().mockResolvedValue([]),
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
notification: { groupBy: jest.fn().mockResolvedValue([]) },
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
task: { findMany: jest.fn().mockResolvedValue([]) },
|
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
@@ -172,27 +173,23 @@ describe('ApiaryFieldService', () => {
|
|||||||
expect(inspectionArgs.omit).toEqual({ userId: true });
|
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 () => {
|
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
|
||||||
grant('colonies:read');
|
grant('colonies:read');
|
||||||
prisma.notification.groupBy.mockResolvedValue([
|
prisma.$queryRaw.mockResolvedValue([
|
||||||
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } },
|
{ relatedId: 'c2', priority: 'WARNUNG', count: 2 },
|
||||||
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } },
|
{ relatedId: 'c2', priority: 'KRITISCH', count: 1 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const overview = await service.colonyOverview('apiary-1', 'u1');
|
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||||
|
|
||||||
expect(prisma.notification.groupBy).toHaveBeenCalledWith(
|
const query = lastQuery();
|
||||||
expect.objectContaining({
|
expect(query.sql).toContain('"userId" =');
|
||||||
by: ['relatedId', 'priority'],
|
expect(query.sql).toContain('"read" = false');
|
||||||
where: {
|
expect(query.values).toContain('u1');
|
||||||
relatedType: 'COLONY',
|
expect(query.values).toEqual(expect.arrayContaining(['c2', 'c10']));
|
||||||
relatedId: { in: ['c2', 'c10'] },
|
|
||||||
read: false,
|
|
||||||
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
|
||||||
userId: 'u1',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(overview.rows[0]).toMatchObject({
|
expect(overview.rows[0]).toMatchObject({
|
||||||
code: 'V-2',
|
code: 'V-2',
|
||||||
openWarnings: 2,
|
openWarnings: 2,
|
||||||
@@ -203,4 +200,16 @@ describe('ApiaryFieldService', () => {
|
|||||||
openCritical: 0,
|
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 { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Prisma } from '../../generated/prisma';
|
||||||
import type {
|
import type {
|
||||||
ColonyOverview,
|
ColonyOverview,
|
||||||
ColonyOverviewRow,
|
ColonyOverviewRow,
|
||||||
@@ -83,6 +84,7 @@ export class ApiaryFieldService {
|
|||||||
colonyType: true,
|
colonyType: true,
|
||||||
hiveType: true,
|
hiveType: true,
|
||||||
strength: true,
|
strength: true,
|
||||||
|
occupiedFrames: true,
|
||||||
broodStatus: true,
|
broodStatus: true,
|
||||||
feedStatus: true,
|
feedStatus: true,
|
||||||
healthStatus: true,
|
healthStatus: true,
|
||||||
@@ -96,13 +98,14 @@ export class ApiaryFieldService {
|
|||||||
const inspectionsVisible = permissions.has('inspections:read');
|
const inspectionsVisible = permissions.has('inspections:read');
|
||||||
const [lastInspections, warnings] = await Promise.all([
|
const [lastInspections, warnings] = await Promise.all([
|
||||||
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
||||||
this.warningCounts(colonies, userId),
|
this.warningCounts(colonies, userId, permissions),
|
||||||
]);
|
]);
|
||||||
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
||||||
id: colony.id,
|
id: colony.id,
|
||||||
code: colony.code,
|
code: colony.code,
|
||||||
status: colony.status,
|
status: colony.status,
|
||||||
strength: colony.strength,
|
strength: colony.strength,
|
||||||
|
occupiedFrames: colony.occupiedFrames,
|
||||||
broodStatus: colony.broodStatus,
|
broodStatus: colony.broodStatus,
|
||||||
feedStatus: colony.feedStatus,
|
feedStatus: colony.feedStatus,
|
||||||
healthStatus: colony.healthStatus,
|
healthStatus: colony.healthStatus,
|
||||||
@@ -138,27 +141,33 @@ export class ApiaryFieldService {
|
|||||||
private async warningCounts(
|
private async warningCounts(
|
||||||
colonies: PackColony[],
|
colonies: PackColony[],
|
||||||
userId: string,
|
userId: string,
|
||||||
|
held: ReadonlySet<string>,
|
||||||
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
|
): 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 }>();
|
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) {
|
for (const group of groups) {
|
||||||
if (
|
if (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') {
|
||||||
group.relatedId &&
|
|
||||||
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
|
|
||||||
) {
|
|
||||||
counts.set(group.relatedId, {
|
counts.set(group.relatedId, {
|
||||||
...counts.get(group.relatedId),
|
...counts.get(group.relatedId),
|
||||||
[group.priority]: group._count._all,
|
[group.priority]: group.count,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface ColonyOverviewRow {
|
|||||||
code: string;
|
code: string;
|
||||||
status: ColonyStatus;
|
status: ColonyStatus;
|
||||||
strength: number | null;
|
strength: number | null;
|
||||||
|
occupiedFrames: number | null;
|
||||||
broodStatus: ColonyCondition | null;
|
broodStatus: ColonyCondition | null;
|
||||||
feedStatus: ColonyCondition | null;
|
feedStatus: ColonyCondition | null;
|
||||||
healthStatus: ColonyCondition | null;
|
healthStatus: ColonyCondition | null;
|
||||||
@@ -56,6 +57,7 @@ export interface OfflinePackV1 {
|
|||||||
| 'colonyType'
|
| 'colonyType'
|
||||||
| 'hiveType'
|
| 'hiveType'
|
||||||
| 'strength'
|
| 'strength'
|
||||||
|
| 'occupiedFrames'
|
||||||
| 'broodStatus'
|
| 'broodStatus'
|
||||||
| 'feedStatus'
|
| 'feedStatus'
|
||||||
| 'healthStatus'
|
| 'healthStatus'
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
|||||||
import { ErpExportModule } from './erp-export/erp-export.module';
|
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||||
import { SuggestionsModule } from './suggestions/suggestions.module';
|
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||||
|
import { AutopilotModule } from './autopilot/autopilot.module';
|
||||||
import { SystemModule } from './system/system.module';
|
import { SystemModule } from './system/system.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||||
@@ -108,6 +109,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
|||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
SuggestionsModule,
|
SuggestionsModule,
|
||||||
|
AutopilotModule,
|
||||||
LabelTemplatesModule,
|
LabelTemplatesModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
PublicModule,
|
PublicModule,
|
||||||
|
|||||||
@@ -154,12 +154,17 @@ describe('AutomationEventHandlers', () => {
|
|||||||
async (method, type, model, core) => {
|
async (method, type, model, core) => {
|
||||||
const record = { id: 'record-1', colonyId: 'colony-1' };
|
const record = { id: 'record-1', colonyId: 'colony-1' };
|
||||||
tx[model].findUnique.mockResolvedValueOnce(record);
|
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||||
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
await run(method, type);
|
await run(method, type);
|
||||||
|
|
||||||
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: 'record-1' },
|
where: { id: 'record-1' },
|
||||||
});
|
});
|
||||||
|
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'colony-1' },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
expect(automation[core]).toHaveBeenCalledWith(tx, record);
|
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 () => {
|
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(
|
automation.evaluateInspectionIn.mockRejectedValue(
|
||||||
new Error('notifications down'),
|
new Error('notifications down'),
|
||||||
);
|
);
|
||||||
@@ -192,22 +230,24 @@ describe('AutomationEventHandlers', () => {
|
|||||||
const event = () =>
|
const event = () =>
|
||||||
envelope('ColonyStatusChanged', 'colony-1', { from, to });
|
envelope('ColonyStatusChanged', 'colony-1', { from, to });
|
||||||
|
|
||||||
it('hands the transition of the event to evaluateColonyHealthIn', async () => {
|
it('hands the transition and the event to evaluateColonyHealthIn', async () => {
|
||||||
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
|
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||||
|
|
||||||
await handlers.colonyHealth(event(), asTx());
|
await handlers.colonyHealth(event(), asTx());
|
||||||
|
|
||||||
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: 'colony-1' },
|
where: { id: 'colony-1' },
|
||||||
select: { id: true },
|
select: { status: true },
|
||||||
});
|
});
|
||||||
// The rule decides on the snapshots of the event, not on the colony
|
// 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(
|
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
|
||||||
tx,
|
tx,
|
||||||
'colony-1',
|
'colony-1',
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,6 +256,14 @@ describe('AutomationEventHandlers', () => {
|
|||||||
|
|
||||||
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
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', () => {
|
describe('raw-material-low', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||||
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
import { Prisma } from '../../generated/prisma';
|
import { Prisma } from '../../generated/prisma';
|
||||||
import { StockService } from '../stock/stock.service';
|
import { StockService } from '../stock/stock.service';
|
||||||
import { AutomationService } from './automation.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
|
* Evaluates the automation rules on domain events. Pure DB writer: it reads
|
||||||
* the record the event is about through the delivery transaction and lets
|
* the record the event is about through the delivery transaction and lets
|
||||||
* AutomationService write task, warning and audit entries in it. A record
|
* AutomationService write suggestion, notification and audit entries in it.
|
||||||
* deleted before the event was dispatched is skipped. Errors propagate, so
|
* A record deleted before the event was dispatched, or of a colony that
|
||||||
* the delivery is rolled back as a whole and retried.
|
* ended, is skipped. Errors propagate, so the delivery is rolled back as a
|
||||||
|
* whole and retried.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AutomationEventHandlers {
|
export class AutomationEventHandlers {
|
||||||
@@ -21,6 +23,18 @@ export class AutomationEventHandlers {
|
|||||||
private readonly stock: StockService,
|
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')
|
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
|
||||||
async inspectionRules(
|
async inspectionRules(
|
||||||
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
|
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
|
||||||
@@ -29,14 +43,17 @@ export class AutomationEventHandlers {
|
|||||||
const inspection = await tx.inspection.findUnique({
|
const inspection = await tx.inspection.findUnique({
|
||||||
where: { id: event.aggregateId },
|
where: { id: event.aggregateId },
|
||||||
});
|
});
|
||||||
if (inspection === null) {
|
if (
|
||||||
|
inspection === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, inspection.colonyId))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateInspectionIn(tx, inspection);
|
await this.automation.evaluateInspectionIn(tx, inspection);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A correction can lift a count over the threshold, so it is evaluated
|
// 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(
|
@OnDomainEvent(
|
||||||
['VarroaMeasured', 'VarroaCorrected'],
|
['VarroaMeasured', 'VarroaCorrected'],
|
||||||
'automation.varroa-threshold',
|
'automation.varroa-threshold',
|
||||||
@@ -48,7 +65,10 @@ export class AutomationEventHandlers {
|
|||||||
const measurement = await tx.varroaMeasurement.findUnique({
|
const measurement = await tx.varroaMeasurement.findUnique({
|
||||||
where: { id: event.aggregateId },
|
where: { id: event.aggregateId },
|
||||||
});
|
});
|
||||||
if (measurement === null) {
|
if (
|
||||||
|
measurement === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, measurement.colonyId))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
||||||
@@ -62,7 +82,10 @@ export class AutomationEventHandlers {
|
|||||||
const treatment = await tx.treatment.findUnique({
|
const treatment = await tx.treatment.findUnique({
|
||||||
where: { id: event.aggregateId },
|
where: { id: event.aggregateId },
|
||||||
});
|
});
|
||||||
if (treatment === null) {
|
if (
|
||||||
|
treatment === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, treatment.colonyId))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateTreatmentIn(tx, treatment);
|
await this.automation.evaluateTreatmentIn(tx, treatment);
|
||||||
@@ -76,7 +99,10 @@ export class AutomationEventHandlers {
|
|||||||
const feeding = await tx.feeding.findUnique({
|
const feeding = await tx.feeding.findUnique({
|
||||||
where: { id: event.aggregateId },
|
where: { id: event.aggregateId },
|
||||||
});
|
});
|
||||||
if (feeding === null) {
|
if (
|
||||||
|
feeding === null ||
|
||||||
|
!(await this.isLivingColonyIn(tx, feeding.colonyId))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateFeedingIn(tx, feeding);
|
await this.automation.evaluateFeedingIn(tx, feeding);
|
||||||
@@ -89,18 +115,15 @@ export class AutomationEventHandlers {
|
|||||||
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const colony = await tx.colony.findUnique({
|
if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
|
||||||
where: { id: event.aggregateId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (colony === null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.automation.evaluateColonyHealthIn(
|
await this.automation.evaluateColonyHealthIn(
|
||||||
tx,
|
tx,
|
||||||
colony.id,
|
event.aggregateId,
|
||||||
event.payload.from,
|
event.payload.from,
|
||||||
event.payload.to,
|
event.payload.to,
|
||||||
|
{ id: event.id, occurredAt: event.occurredAt },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
|
||||||
import { StockModule } from '../stock/stock.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 { AutomationController } from './automation.controller';
|
||||||
import { AutomationEventHandlers } from './automation.handlers';
|
import { AutomationEventHandlers } from './automation.handlers';
|
||||||
import { AutomationService } from './automation.service';
|
import { AutomationService } from './automation.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automation rules and the handlers that evaluate them on domain events.
|
* Automation rules and the handlers that evaluate them on domain events.
|
||||||
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
|
* The rules propose through SuggestionsService; the task follows when the
|
||||||
* it calendar entry and task.created webhook), so no WebhooksService here.
|
* beekeeper accepts (TasksService, with calendar entry and task.created
|
||||||
|
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TasksModule, NotificationsModule, StockModule],
|
imports: [SuggestionsModule, StockModule],
|
||||||
controllers: [AutomationController],
|
controllers: [AutomationController],
|
||||||
providers: [AutomationService, AutomationEventHandlers],
|
providers: [AutomationService, AutomationEventHandlers],
|
||||||
exports: [AutomationService],
|
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 { assertTransactionClient, DbClient } from '../prisma/db-client';
|
||||||
import { runAfterCommit } from '../common/after-commit';
|
import { runAfterCommit } from '../common/after-commit';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { addDays, berlinDayOf, berlinToday } from '../common/dates';
|
||||||
import { TasksService } from '../tasks/tasks.service';
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
|
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||||
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
|
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 {
|
import {
|
||||||
AutomationRule,
|
AutomationRule,
|
||||||
AutomationTriggerType,
|
AutomationTriggerType,
|
||||||
@@ -20,6 +27,8 @@ import {
|
|||||||
Prisma,
|
Prisma,
|
||||||
RawMaterialKind,
|
RawMaterialKind,
|
||||||
StockItem,
|
StockItem,
|
||||||
|
Suggestion,
|
||||||
|
SuggestionIntent,
|
||||||
SwarmControlCheck,
|
SwarmControlCheck,
|
||||||
TaskRelatedType,
|
TaskRelatedType,
|
||||||
Treatment,
|
Treatment,
|
||||||
@@ -27,8 +36,13 @@ import {
|
|||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||||
import { formatGeneratedNumber } from './number-format';
|
import { formatGeneratedNumber } from './number-format';
|
||||||
import { DEFAULT_RULES, validateRuleConfig } from './rule-config';
|
import {
|
||||||
import { varroaThresholdOf } from './varroa-threshold';
|
DEFAULT_RULES,
|
||||||
|
lockRuleIn,
|
||||||
|
readRule,
|
||||||
|
validateRuleConfig,
|
||||||
|
} from './rule-config';
|
||||||
|
import { rateMeasurement } from './varroa-bands';
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
|
|
||||||
@@ -39,11 +53,6 @@ export type NumberFormatTrigger =
|
|||||||
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
|
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
|
||||||
| 'PRODUCTION_BATCH_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. */
|
/** What {ART} turns into in the raw material number format. */
|
||||||
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
|
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
|
||||||
BEESWAX: 'WA',
|
BEESWAX: 'WA',
|
||||||
@@ -63,37 +72,44 @@ function isUniqueViolation(error: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The rules that propose instead of creating anything themselves. */
|
||||||
* Optional per-rule follow-up interval (config.dueInDays). There is no
|
export type RuleSuggestionTrigger =
|
||||||
* built-in default: how soon a queenless colony or a high mite fall needs a
|
| 'QUEENLESS_NO_EGGS'
|
||||||
* look depends on season and practice, so the rule stays date-less unless the
|
| 'FEED_STATUS_CRITICAL'
|
||||||
* beekeeper configures one.
|
| 'VARROA_THRESHOLD'
|
||||||
*/
|
| 'SWARM_MOOD_HIGH'
|
||||||
function formatDay(date: Date): string {
|
| 'COLONY_HEALTH_CRITICAL'
|
||||||
return date.toLocaleDateString('de-DE', {
|
| 'STOCK_ITEM_LOW'
|
||||||
day: '2-digit',
|
| 'RAW_MATERIAL_LOW'
|
||||||
month: '2-digit',
|
| 'TREATMENT_FOLLOW_UP'
|
||||||
year: 'numeric',
|
| 'FEEDING_FOLLOW_UP';
|
||||||
timeZone: 'UTC',
|
|
||||||
});
|
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(
|
/** The anchor of a finding that has no record to hang on. */
|
||||||
dueInDays: unknown,
|
const NO_ANCHOR = 'none';
|
||||||
baseDate: Date = new Date(),
|
|
||||||
): Date | undefined {
|
/**
|
||||||
if (
|
* Optional per-rule follow-up interval (config.dueInDays) counted from the
|
||||||
typeof dueInDays !== 'number' ||
|
* business day. There is no built-in default: how soon a queenless colony or
|
||||||
!Number.isInteger(dueInDays) ||
|
* a high mite fall needs a look depends on season and practice, so the
|
||||||
dueInDays < 0 ||
|
* suggestion stays date-less unless the beekeeper configures one (accepting
|
||||||
dueInDays > 365
|
* it then creates a task due today).
|
||||||
) {
|
*/
|
||||||
return undefined;
|
function dueDayOf(config: object, baseDay: string): string | null {
|
||||||
}
|
const { dueInDays } = config as { dueInDays?: unknown };
|
||||||
const due = new Date(baseDate);
|
return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null;
|
||||||
due.setUTCHours(0, 0, 0, 0);
|
|
||||||
due.setUTCDate(due.getUTCDate() + dueInDays);
|
|
||||||
return due;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -103,8 +119,7 @@ export class AutomationService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly suggestions: SuggestionsService,
|
||||||
private readonly tasks: TasksService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,42 +231,58 @@ export class AutomationService {
|
|||||||
|
|
||||||
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
|
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
|
||||||
await this.ensureDefaults(tx);
|
if (inspection.queenSeen === false && inspection.eggsPresent === false) {
|
||||||
const rules = await tx.automationRule.findMany({
|
await this.proposeOnInspectionIn(
|
||||||
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(
|
|
||||||
tx,
|
tx,
|
||||||
queenlessRule,
|
'QUEENLESS_NO_EGGS',
|
||||||
'COLONY',
|
'QUEEN_CHECK',
|
||||||
inspection.colonyId,
|
inspection,
|
||||||
inspection.id,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (inspection.feedStatus === 'CRITICAL') {
|
||||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
await this.proposeOnInspectionIn(
|
||||||
if (feedRule && inspection.feedStatus === 'CRITICAL') {
|
|
||||||
await this.triggerActionIn(
|
|
||||||
tx,
|
tx,
|
||||||
feedRule,
|
'FEED_STATUS_CRITICAL',
|
||||||
'COLONY',
|
'FEED',
|
||||||
inspection.colonyId,
|
inspection,
|
||||||
inspection.id,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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(
|
async evaluateVarroaMeasurementIn(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
measurement: VarroaMeasurement,
|
measurement: VarroaMeasurement,
|
||||||
@@ -260,26 +291,78 @@ export class AutomationService {
|
|||||||
tx,
|
tx,
|
||||||
'AutomationService.evaluateVarroaMeasurementIn',
|
'AutomationService.evaluateVarroaMeasurementIn',
|
||||||
);
|
);
|
||||||
await this.ensureDefaults(tx);
|
const rule = await readRule(tx, 'VARROA_THRESHOLD');
|
||||||
const rule = await tx.automationRule.findUnique({
|
if (!rule.active) {
|
||||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
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.
|
if (colony === null) {
|
||||||
const threshold = varroaThresholdOf(rule);
|
|
||||||
if (!rule || threshold === null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The threshold is a natural mite fall rate (mites per day). Only that
|
const rating = rateMeasurement(
|
||||||
// method yields a value in this unit - a mite count from a sugar roll or
|
measurement,
|
||||||
// wash is per bee sample and would be compared against the wrong scale,
|
colony.colonyType,
|
||||||
// as would a raw board count without the number of days it covered.
|
rule.config,
|
||||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const value = measurement.calculatedValue;
|
const measuredOn = berlinDayOf(measurement.date);
|
||||||
if (value !== null && value >= threshold) {
|
const evidence = {
|
||||||
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
|
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(
|
async evaluateSwarmControlCheckIn(
|
||||||
@@ -290,24 +373,38 @@ export class AutomationService {
|
|||||||
tx,
|
tx,
|
||||||
'AutomationService.evaluateSwarmControlCheckIn',
|
'AutomationService.evaluateSwarmControlCheckIn',
|
||||||
);
|
);
|
||||||
await this.ensureDefaults(tx);
|
if (check.swarmMood !== 'HIGH') {
|
||||||
const rule = await tx.automationRule.findUnique({
|
|
||||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (check.swarmMood === 'HIGH') {
|
const rule = await readRule(tx, 'SWARM_MOOD_HIGH');
|
||||||
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
|
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(
|
async evaluateColonyHealthIn(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
colonyId: string,
|
colonyId: string,
|
||||||
from: ColonyStateSnapshot,
|
from: ColonyStateSnapshot,
|
||||||
to: ColonyStateSnapshot,
|
to: ColonyStateSnapshot,
|
||||||
|
event: { id: string; occurredAt: Date },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
|
||||||
// ColonyStatusChanged comes with every status or health edit: decide on
|
// ColonyStatusChanged comes with every status or health edit: decide on
|
||||||
@@ -315,16 +412,29 @@ export class AutomationService {
|
|||||||
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
|
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.ensureDefaults(tx);
|
const rule = await readRule(tx, 'COLONY_HEALTH_CRITICAL');
|
||||||
const rule = await tx.automationRule.findUnique({
|
if (!rule.active) {
|
||||||
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
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> {
|
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
|
||||||
// Every movement of the item comes here: only one below its minimum
|
// 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) {
|
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.ensureDefaults(tx);
|
const rule = await readRule(tx, 'STOCK_ITEM_LOW');
|
||||||
const rule = await tx.automationRule.findUnique({
|
if (!rule.active) {
|
||||||
where: { triggerType: 'STOCK_ITEM_LOW' },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
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):
|
* After raw material left its lots (consumption, withdrawal, inventory):
|
||||||
* compares what is left of the kind across all lots with config.minGrams.
|
* 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 suggestion is about the kind, not one lot - a new lot of the same
|
||||||
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
|
* kind is the remedy - so it hangs on GENERAL with the kind as relatedId,
|
||||||
* keeps one open task per kind.
|
* which keeps one open suggestion per kind, and is anchored on the newest
|
||||||
|
* lot of the kind.
|
||||||
*/
|
*/
|
||||||
async evaluateRawMaterialLowIn(
|
async evaluateRawMaterialLowIn(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
@@ -355,69 +482,73 @@ export class AutomationService {
|
|||||||
remainingGrams: number,
|
remainingGrams: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
|
||||||
await this.ensureDefaults(tx);
|
const rule = await readRule(tx, 'RAW_MATERIAL_LOW');
|
||||||
const rule = await tx.automationRule.findUnique({
|
if (!rule.active) {
|
||||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
|
||||||
});
|
|
||||||
if (!rule || !rule.active) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const config = rule.config as {
|
const minGrams = rule.config.minGrams?.[kind];
|
||||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
|
||||||
} | null;
|
|
||||||
const minGrams = config?.minGrams?.[kind];
|
|
||||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
|
const newestLot = await tx.rawMaterialLot.findFirst({
|
||||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
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> {
|
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
|
||||||
const measureDate = treatment.endDate ?? treatment.date;
|
const rule = await readRule(tx, 'TREATMENT_FOLLOW_UP');
|
||||||
await this.evaluateFollowUpIn(
|
if (!rule.active) {
|
||||||
tx,
|
return;
|
||||||
'TREATMENT_FOLLOW_UP',
|
}
|
||||||
treatment.colonyId,
|
const endedOn = berlinDayOf(treatment.endDate ?? treatment.date);
|
||||||
measureDate,
|
await this.proposeForRuleIn(tx, 'TREATMENT_FOLLOW_UP', {
|
||||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
|
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> {
|
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
|
||||||
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
|
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
|
||||||
await this.evaluateFollowUpIn(
|
const rule = await readRule(tx, 'FEEDING_FOLLOW_UP');
|
||||||
tx,
|
if (!rule.active) {
|
||||||
'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) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
|
const fedOn = berlinDayOf(feeding.date);
|
||||||
baseDate: measureDate,
|
await this.proposeForRuleIn(tx, 'FEEDING_FOLLOW_UP', {
|
||||||
description,
|
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,
|
* Proposes what a rule found, on the caller's transaction: the suggestion
|
||||||
* all on the caller's transaction: task, its audit entry and TaskCreated
|
* (SuggestionsService.proposeIn: colony then rule lock, one open thing per
|
||||||
* (TasksService.createInTx - the task.created webhook and the calendar
|
* intent and object, an anchor is proposed once, the notification when the
|
||||||
* entry follow from that event), the warning and the rule's audit entry.
|
* rule says `notify`) and the rule's audit entry. A failing write rolls
|
||||||
* A failing write rolls back all of it; a handler delivery is retried.
|
* 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
|
* The colony is locked and read before the rule is locked - the order
|
||||||
* stock item or the check task for a colony is still open, further triggers
|
* every proposal uses - so a colony that ends in between is not missed.
|
||||||
* (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.
|
|
||||||
*/
|
*/
|
||||||
private async triggerActionIn(
|
async proposeForRuleIn(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
rule: AutomationRule,
|
trigger: RuleSuggestionTrigger,
|
||||||
relatedType: TaskRelatedType,
|
target: RuleSuggestionTarget,
|
||||||
relatedId: string,
|
): Promise<Suggestion | null> {
|
||||||
sourceInspectionId?: string,
|
assertTransactionClient(tx, 'AutomationService.proposeForRuleIn');
|
||||||
options: { baseDate?: Date; description?: string } = {},
|
if (
|
||||||
): Promise<void> {
|
target.colonyId !== null &&
|
||||||
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
|
!(await this.isLivingColonyLockedIn(tx, target.colonyId))
|
||||||
const alreadyOpen = await tx.task.findFirst({
|
) {
|
||||||
where: {
|
return null;
|
||||||
sourceAutomationTrigger: rule.triggerType,
|
}
|
||||||
relatedType,
|
const rule = await lockRuleIn(tx, trigger);
|
||||||
relatedId,
|
if (!rule.active) {
|
||||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
return null;
|
||||||
},
|
}
|
||||||
select: { id: true },
|
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) {
|
if (suggestion === null) {
|
||||||
return;
|
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(
|
await this.auditLog.record(
|
||||||
{
|
{
|
||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
entityId: rule.id,
|
entityId: rule.id,
|
||||||
action: 'CREATE',
|
action: 'CREATE',
|
||||||
afterData: {
|
afterData: {
|
||||||
taskId: task.id,
|
suggestionId: suggestion.id,
|
||||||
relatedType,
|
relatedType: target.relatedType,
|
||||||
relatedId,
|
relatedId: target.relatedId,
|
||||||
triggerType: rule.triggerType,
|
triggerType: trigger,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tx,
|
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.
|
* (dueInDays) and, for the raw material rule, minGrams per kind.
|
||||||
*/
|
*/
|
||||||
export function simpleParser(
|
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 () => {
|
it('update records no event when status and health stay the same', async () => {
|
||||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
tx.colony.update.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 { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { DomainEventsService } from '../domain-events/domain-events.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 { CreateColonyDto } from './dto/create-colony.dto';
|
||||||
import { UpdateColonyDto } from './dto/update-colony.dto';
|
import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||||
import { MoveColonyDto } from './dto/move-colony.dto';
|
import { MoveColonyDto } from './dto/move-colony.dto';
|
||||||
@@ -105,51 +105,63 @@ export class ColoniesService {
|
|||||||
dto: UpdateColonyDto,
|
dto: UpdateColonyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Colony> {
|
): Promise<Colony> {
|
||||||
// Fail fast with 404; everything else is decided on the locked row.
|
const { after } = await this.prisma.$transaction((tx) =>
|
||||||
await this.findById(id);
|
this.updateIn(tx, id, dto, actingUserId),
|
||||||
|
);
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
const colony = await this.prisma.$transaction(async (tx) => {
|
/**
|
||||||
const before = await lockColony(tx, id);
|
* The core of update, for callers that already hold a transaction (an
|
||||||
const movesApiary =
|
* inspection handing values over to the colony card). Locks the colony row
|
||||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
* and reads "before" only afterwards (404 when missing); writes the apiary
|
||||||
if (movesApiary) {
|
* history, the status events and the audit entry with the change.
|
||||||
await this.assertApiaryActive(dto.apiaryId!);
|
*/
|
||||||
}
|
async updateIn(
|
||||||
const updated = await tx.colony.update({
|
tx: Prisma.TransactionClient,
|
||||||
where: { id },
|
id: string,
|
||||||
data: { ...dto },
|
dto: UpdateColonyDto,
|
||||||
});
|
actingUserId: string,
|
||||||
// Correcting the apiary has to leave the same trace a deliberate move
|
): Promise<{ before: Colony; after: Colony }> {
|
||||||
// does - otherwise the colony's location history would silently skip a
|
const before = await lockColony(tx, id);
|
||||||
// stand it was recorded on.
|
const movesApiary =
|
||||||
if (movesApiary) {
|
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||||
await tx.colonyApiaryHistory.create({
|
if (movesApiary) {
|
||||||
data: {
|
await this.assertApiaryActive(dto.apiaryId!);
|
||||||
colonyId: id,
|
}
|
||||||
apiaryId: updated.apiaryId,
|
const updated = await tx.colony.update({
|
||||||
movedByUserId: actingUserId,
|
where: { id },
|
||||||
notes: 'Korrektur über Volk bearbeiten',
|
data: { ...dto },
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
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(
|
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()
|
@IsString()
|
||||||
originText?: string;
|
originText?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ minimum: 1, maximum: 10 })
|
@ApiPropertyOptional({
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 10,
|
||||||
|
deprecated: true,
|
||||||
|
description: 'Volksstärke (alt, Skala 1–10)',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(10)
|
@Max(10)
|
||||||
strength?: number;
|
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 })
|
@ApiPropertyOptional({ enum: ColonyCondition })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(ColonyCondition)
|
@IsEnum(ColonyCondition)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class UpdatableColonyFieldsDto extends PartialType(
|
|||||||
'originText',
|
'originText',
|
||||||
'colonyType',
|
'colonyType',
|
||||||
'strength',
|
'strength',
|
||||||
|
'occupiedFrames',
|
||||||
'broodStatus',
|
'broodStatus',
|
||||||
'feedStatus',
|
'feedStatus',
|
||||||
'healthStatus',
|
'healthStatus',
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const contracts: Record<string, NullContract> = {
|
|||||||
'hiveType',
|
'hiveType',
|
||||||
'originText',
|
'originText',
|
||||||
'strength',
|
'strength',
|
||||||
|
'occupiedFrames',
|
||||||
'broodStatus',
|
'broodStatus',
|
||||||
'feedStatus',
|
'feedStatus',
|
||||||
'healthStatus',
|
'healthStatus',
|
||||||
@@ -334,6 +335,7 @@ const contracts: Record<string, NullContract> = {
|
|||||||
clearable: [
|
clearable: [
|
||||||
'durationMinutes',
|
'durationMinutes',
|
||||||
'miteCount',
|
'miteCount',
|
||||||
|
'sampleBees',
|
||||||
'calculatedValue',
|
'calculatedValue',
|
||||||
'assessment',
|
'assessment',
|
||||||
'notes',
|
'notes',
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe('ExportService', () => {
|
|||||||
colonyType: 'PRODUCTION_COLONY',
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
strength: 8,
|
strength: 8,
|
||||||
|
occupiedFrames: 14,
|
||||||
healthStatus: null,
|
healthStatus: null,
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
},
|
},
|
||||||
@@ -67,9 +68,10 @@ describe('ExportService', () => {
|
|||||||
const csv = await service.exportColoniesCsv();
|
const csv = await service.exportColoniesCsv();
|
||||||
const lines = csv.split('\n');
|
const lines = csv.split('\n');
|
||||||
expect(lines[0]).toBe(
|
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]).toContain('"V-1, Ost"');
|
||||||
|
expect(lines[1]).toMatch(/,8,,2026-01-01T00:00:00.000Z,14$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exports honey batches as CSV', async () => {
|
it('exports honey batches as CSV', async () => {
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export class ExportService {
|
|||||||
'strength',
|
'strength',
|
||||||
'healthStatus',
|
'healthStatus',
|
||||||
'createdAt',
|
'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 () => {
|
it('reports a validation error for an invalid colonyType', async () => {
|
||||||
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
|
||||||
|
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export class ImportService {
|
|||||||
hiveType: emptyToUndefined(record.hiveType),
|
hiveType: emptyToUndefined(record.hiveType),
|
||||||
originText: emptyToUndefined(record.originText),
|
originText: emptyToUndefined(record.originText),
|
||||||
strength: parseNumberField(record.strength),
|
strength: parseNumberField(record.strength),
|
||||||
|
occupiedFrames: parseNumberField(record.occupiedFrames),
|
||||||
broodStatus: emptyToUndefined(record.broodStatus),
|
broodStatus: emptyToUndefined(record.broodStatus),
|
||||||
feedStatus: feed.feedStatus ?? undefined,
|
feedStatus: feed.feedStatus ?? undefined,
|
||||||
healthStatus: emptyToUndefined(record.healthStatus),
|
healthStatus: emptyToUndefined(record.healthStatus),
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -10,6 +14,10 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import {
|
||||||
|
COLONY_TAKEOVER_FIELDS,
|
||||||
|
type ColonyTakeoverField,
|
||||||
|
} from '../../colonies/colony-takeover';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { ColonyCondition } from '../../../generated/prisma';
|
import { ColonyCondition } from '../../../generated/prisma';
|
||||||
import { feedConditionFromInput } from '../../common/feed-vocabulary';
|
import { feedConditionFromInput } from '../../common/feed-vocabulary';
|
||||||
@@ -33,10 +41,15 @@ export class CreateInspectionDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional({
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 40,
|
||||||
|
description: 'Volksstärke in besetzten Waben',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
|
@Max(40, { message: 'Höchstens 40 besetzte Waben.' })
|
||||||
colonyStrength?: number;
|
colonyStrength?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Anzahl Brutwaben' })
|
@ApiPropertyOptional({ description: 'Anzahl Brutwaben' })
|
||||||
@@ -146,4 +159,17 @@ export class CreateInspectionDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
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';
|
import { CreateInspectionDto } from './create-inspection.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every field stays correctable, `colonyId` included: a record filed against
|
* 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
|
* 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, {
|
export class UpdateInspectionDto extends PartialType(
|
||||||
skipNullProperties: false,
|
OmitType(CreateInspectionDto, ['applyToColony'] as const),
|
||||||
}) {}
|
{ skipNullProperties: false },
|
||||||
|
) {}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TasksModule } from '../tasks/tasks.module';
|
import { TasksModule } from '../tasks/tasks.module';
|
||||||
|
import { ColoniesModule } from '../colonies/colonies.module';
|
||||||
import { InspectionsController } from './inspections.controller';
|
import { InspectionsController } from './inspections.controller';
|
||||||
import { ColonyInspectionsController } from './colony-inspections.controller';
|
import { ColonyInspectionsController } from './colony-inspections.controller';
|
||||||
import { InspectionsService } from './inspections.service';
|
import { InspectionsService } from './inspections.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rules, calendar and webhooks follow from the inspection's domain events
|
* 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({
|
@Module({
|
||||||
imports: [TasksModule],
|
imports: [TasksModule, ColoniesModule],
|
||||||
controllers: [InspectionsController, ColonyInspectionsController],
|
controllers: [InspectionsController, ColonyInspectionsController],
|
||||||
providers: [InspectionsService],
|
providers: [InspectionsService],
|
||||||
exports: [InspectionsService],
|
exports: [InspectionsService],
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
import { TasksService } from '../tasks/tasks.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';
|
import { Prisma } from '../../generated/prisma';
|
||||||
|
|
||||||
describe('InspectionsService', () => {
|
describe('InspectionsService', () => {
|
||||||
let service: InspectionsService;
|
let service: InspectionsService;
|
||||||
let tx: {
|
let tx: {
|
||||||
$queryRaw: jest.Mock;
|
$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: {
|
let prisma: {
|
||||||
$connect: jest.Mock;
|
$connect: jest.Mock;
|
||||||
@@ -25,6 +33,8 @@ describe('InspectionsService', () => {
|
|||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
let events: { record: jest.Mock };
|
let events: { record: jest.Mock };
|
||||||
let tasks: { createInTx: jest.Mock };
|
let tasks: { createInTx: jest.Mock };
|
||||||
|
let colonies: { updateIn: jest.Mock };
|
||||||
|
let resolver: { getPermissions: jest.Mock };
|
||||||
|
|
||||||
const inspection = {
|
const inspection = {
|
||||||
id: 'inspection-1',
|
id: 'inspection-1',
|
||||||
@@ -35,8 +45,12 @@ describe('InspectionsService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tx = {
|
tx = {
|
||||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
|
colony: {
|
||||||
|
findUniqueOrThrow: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
|
||||||
|
},
|
||||||
inspection: {
|
inspection: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -56,11 +70,17 @@ describe('InspectionsService', () => {
|
|||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
events = { record: jest.fn().mockResolvedValue(undefined) };
|
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
|
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(
|
service = new InspectionsService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
events as unknown as DomainEventsService,
|
events as unknown as DomainEventsService,
|
||||||
tasks as unknown as TasksService,
|
tasks as unknown as TasksService,
|
||||||
|
colonies as unknown as ColoniesService,
|
||||||
|
resolver as unknown as PermissionResolverService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +92,8 @@ describe('InspectionsService', () => {
|
|||||||
AuditLogService,
|
AuditLogService,
|
||||||
DomainEventsService,
|
DomainEventsService,
|
||||||
TasksService,
|
TasksService,
|
||||||
|
ColoniesService,
|
||||||
|
PermissionResolverService,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,7 +112,10 @@ describe('InspectionsService', () => {
|
|||||||
'user-1',
|
'user-1',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(inspection);
|
expect(result).toEqual({
|
||||||
|
...inspection,
|
||||||
|
colonyTakeover: { applied: [], skipped: [] },
|
||||||
|
});
|
||||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||||
expect(tx.inspection.create).toHaveBeenCalledWith({
|
expect(tx.inspection.create).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
@@ -122,6 +147,164 @@ describe('InspectionsService', () => {
|
|||||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
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 () => {
|
it('create fails as a whole when its event cannot be written', async () => {
|
||||||
tx.inspection.create.mockResolvedValue(inspection);
|
tx.inspection.create.mockResolvedValue(inspection);
|
||||||
events.record.mockRejectedValue(new Error('outbox down'));
|
events.record.mockRejectedValue(new Error('outbox down'));
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { DbClient } from '../prisma/db-client';
|
import { DbClient } from '../prisma/db-client';
|
||||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
import { TasksService } from '../tasks/tasks.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 { CreateInspectionDto } from './dto/create-inspection.dto';
|
||||||
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
|
||||||
import { UpdateInspectionDto } from './dto/update-inspection.dto';
|
import { UpdateInspectionDto } from './dto/update-inspection.dto';
|
||||||
import { optionalDate } from '../common/patch-values';
|
import { optionalDate } from '../common/patch-values';
|
||||||
|
|
||||||
|
export type InspectionWithTakeover = Inspection & {
|
||||||
|
colonyTakeover: ColonyTakeover;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspections are written together with their audit entry and domain event
|
* Inspections are written together with their audit entry and domain event
|
||||||
* in one transaction: InspectionRecorded, InspectionCorrected. Automation
|
* in one transaction: InspectionRecorded, InspectionCorrected. Automation
|
||||||
@@ -26,6 +38,8 @@ export class InspectionsService {
|
|||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly events: DomainEventsService,
|
private readonly events: DomainEventsService,
|
||||||
private readonly tasks: TasksService,
|
private readonly tasks: TasksService,
|
||||||
|
private readonly colonies: ColoniesService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<Inspection[]> {
|
async findAll(): Promise<Inspection[]> {
|
||||||
@@ -46,10 +60,24 @@ export class InspectionsService {
|
|||||||
async create(
|
async create(
|
||||||
dto: CreateInspectionDto,
|
dto: CreateInspectionDto,
|
||||||
actingUserId: string,
|
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) => {
|
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({
|
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(
|
await this.auditLog.record(
|
||||||
{
|
{
|
||||||
@@ -68,10 +96,69 @@ export class InspectionsService {
|
|||||||
payload: { colonyId: inspection.colonyId },
|
payload: { colonyId: inspection.colonyId },
|
||||||
actorUserId: actingUserId,
|
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
|
* A correction evaluates no automation rule, as before the event
|
||||||
* backbone. InspectionCorrected moves the calendar entry when the date or
|
* backbone. InspectionCorrected moves the calendar entry when the date or
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { JobPartialFailure } from '../system/job-run.service';
|
import { JobPartialFailure } from '../system/job-run.service';
|
||||||
import { DailyEvaluationJob } from './daily-evaluation.job';
|
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';
|
import type { TimeEventsStep } from './steps/time-events.step';
|
||||||
|
|
||||||
describe('DailyEvaluationJob', () => {
|
describe('DailyEvaluationJob', () => {
|
||||||
@@ -11,14 +13,29 @@ describe('DailyEvaluationJob', () => {
|
|||||||
VarroaMeasurementDue: 0,
|
VarroaMeasurementDue: 0,
|
||||||
InspectionOverdue: 0,
|
InspectionOverdue: 0,
|
||||||
};
|
};
|
||||||
|
const AUTOPILOT_COUNTS = {
|
||||||
|
autopilotColonies: 4,
|
||||||
|
suggestionsOpened: 2,
|
||||||
|
suggestionsResolved: 1,
|
||||||
|
};
|
||||||
let timeEvents: { name: 'time-events'; run: jest.Mock };
|
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;
|
let job: DailyEvaluationJob;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
|
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
|
||||||
timeEvents = { name: 'time-events', run: jest.fn() };
|
timeEvents = { name: 'time-events', run: jest.fn() };
|
||||||
timeEvents.run.mockResolvedValue(COUNTS);
|
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(() => {
|
afterEach(() => {
|
||||||
@@ -31,8 +48,44 @@ describe('DailyEvaluationJob', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
|
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(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 () => {
|
it('takes the Berlin day, not the UTC day, around midnight', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
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 { berlinToday } from '../common/dates';
|
||||||
import { JobPartialFailure } from '../system/job-run.service';
|
import { JobPartialFailure } from '../system/job-run.service';
|
||||||
import {
|
import {
|
||||||
@@ -21,11 +23,19 @@ export class DailyEvaluationJob implements Job {
|
|||||||
readonly name: JobName = 'daily-evaluation';
|
readonly name: JobName = 'daily-evaluation';
|
||||||
private readonly logger = new Logger(DailyEvaluationJob.name);
|
private readonly logger = new Logger(DailyEvaluationJob.name);
|
||||||
|
|
||||||
constructor(private readonly timeEvents: TimeEventsStep) {}
|
constructor(
|
||||||
|
private readonly timeEvents: TimeEventsStep,
|
||||||
|
private readonly autopilot: AutopilotStep,
|
||||||
|
private readonly suggestionsExpiry: SuggestionsExpiryStep,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** Evaluates the current Berlin day. */
|
/** Evaluates the current Berlin day. */
|
||||||
async run(): Promise<JobResult> {
|
async run(): Promise<JobResult> {
|
||||||
const steps: DailyEvaluationStep[] = [this.timeEvents];
|
const steps: DailyEvaluationStep[] = [
|
||||||
|
this.timeEvents,
|
||||||
|
this.autopilot,
|
||||||
|
this.suggestionsExpiry,
|
||||||
|
];
|
||||||
const { result, failed, failures } = await runDailyEvaluationSteps(
|
const { result, failed, failures } = await runDailyEvaluationSteps(
|
||||||
steps,
|
steps,
|
||||||
berlinToday(),
|
berlinToday(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
|
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
|
||||||
|
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||||
import { BackupModule } from '../backup/backup.module';
|
import { BackupModule } from '../backup/backup.module';
|
||||||
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
|
||||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||||
@@ -47,6 +48,7 @@ const JOB_CLASSES = [
|
|||||||
BackupModule,
|
BackupModule,
|
||||||
SystemModule,
|
SystemModule,
|
||||||
SeasonModule,
|
SeasonModule,
|
||||||
|
AutopilotModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
...JOB_CLASSES,
|
...JOB_CLASSES,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe('NotificationsService', () => {
|
|||||||
createManyAndReturn: jest.Mock;
|
createManyAndReturn: jest.Mock;
|
||||||
findMany: jest.Mock;
|
findMany: jest.Mock;
|
||||||
findFirstOrThrow: jest.Mock;
|
findFirstOrThrow: jest.Mock;
|
||||||
|
findFirst: jest.Mock;
|
||||||
updateMany: jest.Mock;
|
updateMany: jest.Mock;
|
||||||
};
|
};
|
||||||
user: { findMany: jest.Mock };
|
user: { findMany: jest.Mock };
|
||||||
@@ -39,6 +40,7 @@ describe('NotificationsService', () => {
|
|||||||
),
|
),
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findFirstOrThrow: jest.fn(),
|
findFirstOrThrow: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
updateMany: jest.fn(),
|
updateMany: jest.fn(),
|
||||||
},
|
},
|
||||||
user: { findMany: jest.fn().mockResolvedValue([]) },
|
user: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
@@ -351,17 +353,33 @@ describe('NotificationsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('answers 404 when the notification is foreign or unknown', async () => {
|
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(
|
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
expect(prisma.notification.findFirst).toHaveBeenCalledWith({
|
||||||
where: { id: 'n1', userId: 'user-1' },
|
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.updateMany.mockResolvedValue({ count: 1 });
|
||||||
prisma.notification.findFirstOrThrow.mockResolvedValue({
|
prisma.notification.findFirstOrThrow.mockResolvedValue({
|
||||||
id: 'n1',
|
id: 'n1',
|
||||||
@@ -371,16 +389,30 @@ describe('NotificationsService', () => {
|
|||||||
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
|
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
|
||||||
read: true,
|
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 () => {
|
it('marks only the own unread notifications the user may see', async () => {
|
||||||
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
|
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({
|
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({
|
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||||
where: { userId: 'user-1', read: false },
|
where: { id: { in: ['a', 'b'] }, userId: 'user-1', read: false },
|
||||||
data: { read: true },
|
data: { read: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -168,8 +168,19 @@ export class NotificationsService {
|
|||||||
.map(toView);
|
.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> {
|
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({
|
const { count } = await this.prisma.notification.updateMany({
|
||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
data: { read: true },
|
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 }> {
|
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 },
|
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 },
|
data: { read: true },
|
||||||
});
|
});
|
||||||
return { updated: count };
|
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;
|
return override ? { ...rendered, title: override } : rendered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
|
export { formatDayDe } from './texts/format-day';
|
||||||
export function formatDayDe(day: string, withYear = false): string {
|
export { formatNumberDe } from './texts/format-number';
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -140,4 +140,31 @@ describe('SuggestionViewsService', () => {
|
|||||||
skip: 10,
|
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;
|
sourceEventId?: string | null;
|
||||||
/**
|
/**
|
||||||
* Default false. Rule and chain suggestions pass `config.notify`, the
|
* 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;
|
notify?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { TasksModule } from '../tasks/tasks.module';
|
import { TasksModule } from '../tasks/tasks.module';
|
||||||
|
import { ChainEventHandlers } from './chains/chains.handlers';
|
||||||
import { SuggestionViewsService } from './suggestion-views.service';
|
import { SuggestionViewsService } from './suggestion-views.service';
|
||||||
import { SuggestionsController } from './suggestions.controller';
|
import { SuggestionsController } from './suggestions.controller';
|
||||||
import { SuggestionsService } from './suggestions.service';
|
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).
|
* the views need comes from the global PermissionsModule (API) or JobPermissionsModule (worker and jobs).
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TasksModule, AuditModule],
|
imports: [TasksModule, AuditModule, NotificationsModule],
|
||||||
controllers: [SuggestionsController],
|
controllers: [SuggestionsController],
|
||||||
providers: [SuggestionsService, SuggestionViewsService],
|
providers: [SuggestionsService, SuggestionViewsService, ChainEventHandlers],
|
||||||
exports: [SuggestionsService, SuggestionViewsService],
|
exports: [SuggestionsService, SuggestionViewsService],
|
||||||
})
|
})
|
||||||
export class SuggestionsModule {}
|
export class SuggestionsModule {}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
lockRowsForShare,
|
lockRowsForShare,
|
||||||
lockRowsForUpdate,
|
lockRowsForUpdate,
|
||||||
} from '../prisma/row-lock';
|
} from '../prisma/row-lock';
|
||||||
|
import type { NotificationsService } from '../notifications/notifications.service';
|
||||||
import type { TasksService } from '../tasks/tasks.service';
|
import type { TasksService } from '../tasks/tasks.service';
|
||||||
import { SuggestionsService } from './suggestions.service';
|
import { SuggestionsService } from './suggestions.service';
|
||||||
import type { ProposeInput } from './suggestion.types';
|
import type { ProposeInput } from './suggestion.types';
|
||||||
@@ -91,8 +92,9 @@ describe('SuggestionsService', () => {
|
|||||||
task: { findMany: jest.Mock };
|
task: { findMany: jest.Mock };
|
||||||
inspection: { count: jest.Mock };
|
inspection: { count: jest.Mock };
|
||||||
user: { findUnique: 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 } & {
|
let prisma: { $connect: jest.Mock; $transaction: jest.Mock } & {
|
||||||
$executeRaw: jest.Mock;
|
$executeRaw: jest.Mock;
|
||||||
};
|
};
|
||||||
@@ -119,8 +121,12 @@ describe('SuggestionsService', () => {
|
|||||||
},
|
},
|
||||||
colony: {
|
colony: {
|
||||||
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
|
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
|
||||||
|
findMany: jest.fn().mockResolvedValue([{ id: 'col-1', code: 'LWG' }]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
notifications = {
|
||||||
|
recordForPermission: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
$connect: jest.fn(),
|
$connect: jest.fn(),
|
||||||
$executeRaw: jest.fn().mockResolvedValue(0),
|
$executeRaw: jest.fn().mockResolvedValue(0),
|
||||||
@@ -160,6 +166,7 @@ describe('SuggestionsService', () => {
|
|||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
audit as unknown as AuditLogService,
|
audit as unknown as AuditLogService,
|
||||||
tasks as unknown as TasksService,
|
tasks as unknown as TasksService,
|
||||||
|
notifications as unknown as NotificationsService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,6 +298,122 @@ describe('SuggestionsService', () => {
|
|||||||
).resolves.not.toBeNull();
|
).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 () => {
|
it('rejects the root client', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.proposeIn(prisma as never, proposal),
|
service.proposeIn(prisma as never, proposal),
|
||||||
@@ -338,9 +461,9 @@ describe('SuggestionsService', () => {
|
|||||||
expect(calls[1][1].description).toContain('bis 25.05.');
|
expect(calls[1][1].description).toContain('bis 25.05.');
|
||||||
expect(calls[0][1].description).toBeNull();
|
expect(calls[0][1].description).toBeNull();
|
||||||
expect(calls.map((c) => c[1].title)).toEqual([
|
expect(calls.map((c) => c[1].title)).toEqual([
|
||||||
'Weiselzellen kontrollieren',
|
'Nachschaffungszellen prüfen',
|
||||||
'Begattung kontrollieren',
|
'Begattung prüfen',
|
||||||
'Eier und Stifte kontrollieren',
|
'Stifte prüfen',
|
||||||
]);
|
]);
|
||||||
for (const [client, input, actingUserId] of calls) {
|
for (const [client, input, actingUserId] of calls) {
|
||||||
expect(client).toBe(tx);
|
expect(client).toBe(tx);
|
||||||
@@ -1000,6 +1123,25 @@ describe('SuggestionsService', () => {
|
|||||||
expect(tx.$executeRaw).not.toHaveBeenCalled();
|
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 () => {
|
it('refreshIn updates an open suggestion only', async () => {
|
||||||
await service.refreshIn(tx as never, 'sug-1', {
|
await service.refreshIn(tx as never, 'sug-1', {
|
||||||
evidence: { colonyId: 'col-1' },
|
evidence: { colonyId: 'col-1' },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { lockRuleIn, readRule } from '../automation/rule-config';
|
import { lockRuleIn, readRule } from '../automation/rule-config';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
|
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
|
||||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||||
import { berlinToday, dayToDate } from '../common/dates';
|
import { berlinToday, dayToDate } from '../common/dates';
|
||||||
@@ -122,6 +123,7 @@ export class SuggestionsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly tasks: TasksService,
|
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).
|
// The colony is gone: nothing to propose (and no FK error).
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
await lockRuleIn(tx, input.ruleKey);
|
const rule = await lockRuleIn(tx, input.ruleKey);
|
||||||
const open = await this.openIntentsIn(tx, input);
|
const open = await this.openIntentsIn(tx, input);
|
||||||
if (open.has(input.intent)) {
|
if (open.has(input.intent)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -165,9 +167,57 @@ export class SuggestionsService {
|
|||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
if (input.notify) {
|
||||||
|
await this.notifyIn(tx, suggestion, input, rule.actionTemplate);
|
||||||
|
}
|
||||||
return suggestion;
|
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. */
|
/** The intents with an open suggestion or an open task for the object. */
|
||||||
async openIntentsIn(
|
async openIntentsIn(
|
||||||
db: DbClient,
|
db: DbClient,
|
||||||
@@ -210,6 +260,8 @@ export class SuggestionsService {
|
|||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
id: string,
|
id: string,
|
||||||
patch: {
|
patch: {
|
||||||
|
/** Given when the same anchor can carry another text (e.g. first of the year). */
|
||||||
|
reasonCode?: string;
|
||||||
evidence: SuggestionEvidence;
|
evidence: SuggestionEvidence;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
priority: TaskPriority;
|
priority: TaskPriority;
|
||||||
@@ -220,6 +272,9 @@ export class SuggestionsService {
|
|||||||
await tx.suggestion.updateMany({
|
await tx.suggestion.updateMany({
|
||||||
where: { id, status: 'OPEN' },
|
where: { id, status: 'OPEN' },
|
||||||
data: {
|
data: {
|
||||||
|
...(patch.reasonCode === undefined
|
||||||
|
? {}
|
||||||
|
: { reasonCode: patch.reasonCode }),
|
||||||
evidence: patch.evidence,
|
evidence: patch.evidence,
|
||||||
dueDate: patch.dueDate === null ? null : dayToDate(patch.dueDate),
|
dueDate: patch.dueDate === null ? null : dayToDate(patch.dueDate),
|
||||||
priority: patch.priority,
|
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. */
|
/** 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. */
|
/** 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). */
|
/** Task title of each step of a plan suggestion (SPLIT_SCHEDULE). */
|
||||||
export const CHAIN_PLAN_STEP_TITLES: Readonly<Record<string, string>> = {
|
export const CHAIN_PLAN_STEP_TITLES: Readonly<Record<string, string>> = {
|
||||||
RAISE_CELLS_CHECK: 'Weiselzellen kontrollieren',
|
RAISE_CELLS_CHECK: 'Nachschaffungszellen prüfen',
|
||||||
MATING_CHECK: 'Begattung kontrollieren',
|
MATING_CHECK: 'Begattung prüfen',
|
||||||
EGGS_CHECK: 'Eier und Stifte kontrollieren',
|
EGGS_CHECK: 'Stifte prüfen',
|
||||||
ACCEPTANCE_CHECK: 'Annahme der Königin kontrollieren',
|
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). */
|
const RAW_MATERIAL_LABELS: Readonly<Record<string, string>> = {
|
||||||
export const RULE_TEXTS: SuggestionTextRegister = {};
|
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()
|
@IsString()
|
||||||
@Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' })
|
@Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' })
|
||||||
date?: string;
|
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', () => {
|
describe('rate', () => {
|
||||||
const measurement = {
|
const measurement = {
|
||||||
id: 'm1',
|
id: 'm1',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type VarroaBands,
|
type VarroaBands,
|
||||||
type VarroaRating,
|
type VarroaRating,
|
||||||
} from '../automation/varroa-bands';
|
} 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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
||||||
|
|
||||||
@@ -20,6 +20,22 @@ export interface VarroaThresholds extends VarroaBands {
|
|||||||
active: boolean;
|
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<
|
type RatableMeasurement = Pick<
|
||||||
VarroaMeasurement,
|
VarroaMeasurement,
|
||||||
'date' | 'method' | 'calculatedValue'
|
'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. */
|
/** Each measurement of one colony with its rating. */
|
||||||
async rate<M extends RatableMeasurement>(
|
async rate<M extends RatableMeasurement>(
|
||||||
colonyId: string,
|
colonyId: string,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -34,11 +35,20 @@ export class VarroaController {
|
|||||||
return this.varroaService.findAll();
|
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')
|
@RequirePermission('varroa:read', 'varroa:create')
|
||||||
@Get('thresholds')
|
@Get('thresholds')
|
||||||
getThresholds(@Query() query: VarroaThresholdsQuery) {
|
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')
|
@RequirePermission('varroa:read')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
|
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
|
||||||
import { AutomationModule } from '../automation/automation.module';
|
import { AutomationModule } from '../automation/automation.module';
|
||||||
|
import { AutopilotModule } from '../autopilot/autopilot.module';
|
||||||
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
|
||||||
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
import { DomainEventsModule } from '../domain-events/domain-events.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
@@ -33,6 +34,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
|
|||||||
TasksModule,
|
TasksModule,
|
||||||
AutomationModule,
|
AutomationModule,
|
||||||
SuggestionsModule,
|
SuggestionsModule,
|
||||||
|
AutopilotModule,
|
||||||
WorkerRuntimeModule,
|
WorkerRuntimeModule,
|
||||||
],
|
],
|
||||||
providers: [WorkerHealthServer],
|
providers: [WorkerHealthServer],
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ describe('Automation rules (e2e)', () => {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const suggestionsOf = (ruleKey?: AutomationTriggerType) =>
|
||||||
|
prisma.suggestion.findMany({
|
||||||
|
where: { colonyId, source: 'RULE', ...(ruleKey ? { ruleKey } : {}) },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
// Other sources (the season autopilot) propose on the same events.
|
||||||
|
const ruleSuggestionCount = () =>
|
||||||
|
prisma.suggestion.count({ where: { colonyId, source: 'RULE' } });
|
||||||
|
const tasksOfColony = () =>
|
||||||
|
prisma.task.count({ where: { relatedId: colonyId } });
|
||||||
|
// Settles every open suggestion, so the one-open-thing guard cannot be
|
||||||
|
// what keeps a new one from appearing - only the missing evaluation may.
|
||||||
|
const dismissOpen = () =>
|
||||||
|
prisma.suggestion.updateMany({
|
||||||
|
where: { colonyId, status: 'OPEN' },
|
||||||
|
data: { status: 'DISMISSED' },
|
||||||
|
});
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await createTestApp();
|
app = await createTestApp();
|
||||||
prisma = app.get(PrismaService);
|
prisma = app.get(PrismaService);
|
||||||
@@ -91,15 +109,15 @@ describe('Automation rules (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Rule tasks and the rules' own entries are written without a user -
|
// Rule suggestions and the rules' own entries are written without a
|
||||||
// they are not covered by the userId cleanup below. Deliveries go with
|
// user - they are not covered by the userId cleanup below. Deliveries go
|
||||||
// their event (ON DELETE CASCADE).
|
// with their event (ON DELETE CASCADE).
|
||||||
const ruleTaskIds = (
|
const suggestionIds = (
|
||||||
await prisma.task.findMany({
|
await prisma.suggestion.findMany({
|
||||||
where: { relatedId: colonyId },
|
where: { colonyId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
).map((task) => task.id);
|
).map((suggestion) => suggestion.id);
|
||||||
const byColony = { where: { colonyId }, select: { id: true } };
|
const byColony = { where: { colonyId }, select: { id: true } };
|
||||||
const recordIds = [
|
const recordIds = [
|
||||||
...(await prisma.inspection.findMany(byColony)),
|
...(await prisma.inspection.findMany(byColony)),
|
||||||
@@ -108,7 +126,7 @@ describe('Automation rules (e2e)', () => {
|
|||||||
await prisma.auditLog.deleteMany({
|
await prisma.auditLog.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ entityType: 'Task', entityId: { in: ruleTaskIds } },
|
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
|
||||||
{
|
{
|
||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
afterData: { path: ['relatedId'], equals: colonyId },
|
afterData: { path: ['relatedId'], equals: colonyId },
|
||||||
@@ -117,13 +135,15 @@ describe('Automation rules (e2e)', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.domainEvent.deleteMany({
|
await prisma.domainEvent.deleteMany({
|
||||||
where: { aggregateId: { in: [...ruleTaskIds, ...recordIds] } },
|
where: { aggregateId: { in: recordIds } },
|
||||||
});
|
});
|
||||||
await prisma.calendarSyncOutboxEntry.deleteMany({
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||||
where: { sourceId: { in: [...ruleTaskIds, ...recordIds] } },
|
where: { sourceId: { in: recordIds } },
|
||||||
});
|
});
|
||||||
await prisma.task.deleteMany({ where: { relatedId: colonyId } });
|
await prisma.notification.deleteMany({
|
||||||
await prisma.notification.deleteMany({ where: { relatedId: colonyId } });
|
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
|
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
|
||||||
await prisma.swarmControlCheck.deleteMany({ where: { colonyId } });
|
await prisma.swarmControlCheck.deleteMany({ where: { colonyId } });
|
||||||
await prisma.inspection.deleteMany({ where: { colonyId } });
|
await prisma.inspection.deleteMany({ where: { colonyId } });
|
||||||
@@ -254,19 +274,32 @@ describe('Automation rules (e2e)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a task and notification when queen not seen and no eggs present', async () => {
|
it('proposes a queen check and notifies when queen not seen and no eggs present', async () => {
|
||||||
await record('inspections', { queenSeen: false, eggsPresent: false });
|
const inspectionId = await record('inspections', {
|
||||||
|
queenSeen: false,
|
||||||
const tasks = await prisma.task.findMany({
|
eggsPresent: false,
|
||||||
where: { relatedId: colonyId },
|
|
||||||
});
|
});
|
||||||
expect(tasks).toHaveLength(1);
|
|
||||||
expect(tasks[0].title).toContain('Kontrolle');
|
const suggestions = await suggestionsOf();
|
||||||
|
expect(suggestions).toHaveLength(1);
|
||||||
|
expect(suggestions[0]).toMatchObject({
|
||||||
|
ruleKey: 'QUEENLESS_NO_EGGS',
|
||||||
|
source: 'RULE',
|
||||||
|
mode: 'PLAN',
|
||||||
|
intent: 'QUEEN_CHECK',
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: 'HIGH',
|
||||||
|
reasonCode: 'QUEENLESS_NO_EGGS',
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`,
|
||||||
|
});
|
||||||
|
// The rule proposes, it creates no task.
|
||||||
|
expect(await tasksOfColony()).toBe(0);
|
||||||
|
|
||||||
const notifications = await prisma.notification.findMany({
|
const notifications = await prisma.notification.findMany({
|
||||||
where: { relatedId: colonyId },
|
where: { relatedType: 'Suggestion', relatedId: suggestions[0].id },
|
||||||
});
|
});
|
||||||
// One copy per user who may read tasks, never a broadcast.
|
// One copy per user who may read tasks and inspections, never a broadcast.
|
||||||
expect(notifications.length).toBeGreaterThan(0);
|
expect(notifications.length).toBeGreaterThan(0);
|
||||||
expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true);
|
expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true);
|
||||||
expect(new Set(notifications.map((n) => n.userId)).size).toBe(
|
expect(new Set(notifications.map((n) => n.userId)).size).toBe(
|
||||||
@@ -277,31 +310,28 @@ describe('Automation rules (e2e)', () => {
|
|||||||
it('does not trigger when the queen was seen', async () => {
|
it('does not trigger when the queen was seen', async () => {
|
||||||
await record('inspections', { queenSeen: true, eggsPresent: false });
|
await record('inspections', { queenSeen: true, eggsPresent: false });
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
expect(await suggestionsOf()).toHaveLength(1);
|
||||||
where: { relatedId: colonyId },
|
|
||||||
});
|
|
||||||
expect(tasks).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('triggers a feeding task when feed status is kritisch', async () => {
|
it('proposes feeding when feed status is kritisch, without a notification', async () => {
|
||||||
await record('inspections', { feedStatus: 'CRITICAL' });
|
await record('inspections', { feedStatus: 'CRITICAL' });
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const suggestions = await suggestionsOf('FEED_STATUS_CRITICAL');
|
||||||
where: { relatedId: colonyId },
|
expect(suggestions).toHaveLength(1);
|
||||||
});
|
expect(suggestions[0]).toMatchObject({ intent: 'FEED', status: 'OPEN' });
|
||||||
expect(tasks).toHaveLength(2);
|
// The rule is off for notifications by default (notify: false).
|
||||||
expect(tasks.some((task) => task.title.includes('Fütterung'))).toBe(true);
|
expect(
|
||||||
|
await prisma.notification.count({
|
||||||
|
where: { relatedType: 'Suggestion', relatedId: suggestions[0].id },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
expect(await tasksOfColony()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('evaluates no rule on a corrected inspection', async () => {
|
it('evaluates no rule on a corrected inspection', async () => {
|
||||||
const before = await prisma.task.count({ where: { relatedId: colonyId } });
|
|
||||||
const id = await record('inspections', { queenSeen: true });
|
const id = await record('inspections', { queenSeen: true });
|
||||||
// Close the open tasks so the one-open-task guard cannot be what keeps
|
await dismissOpen();
|
||||||
// a new task from appearing - only the missing evaluation may.
|
const before = await ruleSuggestionCount();
|
||||||
await prisma.task.updateMany({
|
|
||||||
where: { relatedId: colonyId },
|
|
||||||
data: { status: 'DONE' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.patch(`/api/v1/inspections/${id}`)
|
.patch(`/api/v1/inspections/${id}`)
|
||||||
@@ -310,57 +340,74 @@ describe('Automation rules (e2e)', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
await drainDomainEvents(app, [id]);
|
await drainDomainEvents(app, [id]);
|
||||||
|
|
||||||
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
|
expect(await ruleSuggestionCount()).toBe(before);
|
||||||
before,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('triggers a treatment check when the varroa threshold is exceeded', async () => {
|
it('proposes a treatment check when the mite fall is red', async () => {
|
||||||
const before = await prisma.task.count({ where: { relatedId: colonyId } });
|
const id = await record('varroa-measurements', {
|
||||||
await record('varroa-measurements', {
|
|
||||||
method: 'NATURAL_MITE_FALL',
|
method: 'NATURAL_MITE_FALL',
|
||||||
miteCount: 20,
|
miteCount: 20,
|
||||||
// One day on the board: 20 mites per day, above the default 10.
|
date: '2026-08-10T00:00:00.000Z',
|
||||||
|
// One day on the board: 20 mites per day, above the limit of 10.
|
||||||
durationMinutes: 24 * 60,
|
durationMinutes: 24 * 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const suggestions = await suggestionsOf('VARROA_THRESHOLD');
|
||||||
where: { relatedId: colonyId },
|
expect(suggestions).toHaveLength(1);
|
||||||
|
expect(suggestions[0]).toMatchObject({
|
||||||
|
intent: 'VARROA_TREAT',
|
||||||
|
reasonCode: 'VARROA_RED',
|
||||||
|
status: 'OPEN',
|
||||||
|
requiredPermission: 'varroa:read',
|
||||||
|
evidence: {
|
||||||
|
colonyId,
|
||||||
|
measurementId: id,
|
||||||
|
measuredOn: '2026-08-10',
|
||||||
|
value: 20,
|
||||||
|
redAbove: 10,
|
||||||
|
unit: 'MITES_PER_DAY',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(tasks).toHaveLength(before + 1);
|
expect(await tasksOfColony()).toBe(0);
|
||||||
expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not compare a sample-based count against the per-day threshold', async () => {
|
it('rates a sample-based count with the percentage band', async () => {
|
||||||
await prisma.task.updateMany({
|
await dismissOpen();
|
||||||
where: { relatedId: colonyId },
|
const before = await ruleSuggestionCount();
|
||||||
data: { status: 'DONE' },
|
// Summer band: red above 5 %. 5 of 500 bees is 1 %: yellow, no proposal.
|
||||||
|
await record('varroa-measurements', {
|
||||||
|
method: 'POWDERED_SUGAR',
|
||||||
|
miteCount: 5,
|
||||||
|
sampleBees: 500,
|
||||||
|
date: '2026-07-10T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
const before = await prisma.task.count({ where: { relatedId: colonyId } });
|
expect(await ruleSuggestionCount()).toBe(before);
|
||||||
|
|
||||||
|
// 50 of 500 bees is 10 %: red.
|
||||||
await record('varroa-measurements', {
|
await record('varroa-measurements', {
|
||||||
method: 'POWDERED_SUGAR',
|
method: 'POWDERED_SUGAR',
|
||||||
miteCount: 50,
|
miteCount: 50,
|
||||||
|
sampleBees: 500,
|
||||||
|
date: '2026-07-11T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
|
const red = await prisma.suggestion.findMany({
|
||||||
before,
|
where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' },
|
||||||
);
|
});
|
||||||
|
expect(red).toHaveLength(1);
|
||||||
|
expect(red[0].evidence).toMatchObject({ unit: 'PERCENT', value: 10 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('re-evaluates a corrected varroa measurement against the threshold', async () => {
|
it('re-evaluates a corrected varroa measurement against the limit', async () => {
|
||||||
await prisma.task.updateMany({
|
await dismissOpen();
|
||||||
where: { relatedId: colonyId },
|
const before = await ruleSuggestionCount();
|
||||||
data: { status: 'DONE' },
|
// Five mites per day: yellow in August, no proposal.
|
||||||
});
|
|
||||||
const before = await prisma.task.count({ where: { relatedId: colonyId } });
|
|
||||||
// Five mites per day: below the threshold.
|
|
||||||
const id = await record('varroa-measurements', {
|
const id = await record('varroa-measurements', {
|
||||||
method: 'NATURAL_MITE_FALL',
|
method: 'NATURAL_MITE_FALL',
|
||||||
miteCount: 5,
|
miteCount: 5,
|
||||||
|
date: '2026-08-20T00:00:00.000Z',
|
||||||
durationMinutes: 24 * 60,
|
durationMinutes: 24 * 60,
|
||||||
});
|
});
|
||||||
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
|
expect(await ruleSuggestionCount()).toBe(before);
|
||||||
before,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The count was miscounted: 25 mites per day.
|
// The count was miscounted: 25 mites per day.
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
@@ -370,16 +417,35 @@ describe('Automation rules (e2e)', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
await drainDomainEvents(app, [id]);
|
await drainDomainEvents(app, [id]);
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const open = await prisma.suggestion.findMany({
|
||||||
where: {
|
where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' },
|
||||||
relatedId: colonyId,
|
|
||||||
sourceAutomationTrigger: 'VARROA_THRESHOLD',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(tasks.filter((task) => task.status === 'OPEN')).toHaveLength(1);
|
expect(open).toHaveLength(1);
|
||||||
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
|
expect(open[0].dedupeKey).toBe(`VARROA_THRESHOLD:${colonyId}:${id}`);
|
||||||
before + 1,
|
expect(await ruleSuggestionCount()).toBe(before + 1);
|
||||||
);
|
|
||||||
|
// Corrected again while still red: the open suggestion follows the value.
|
||||||
|
const correct = async (miteCount: number) => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/varroa-measurements/${id}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ miteCount })
|
||||||
|
.expect(200);
|
||||||
|
await drainDomainEvents(app, [id]);
|
||||||
|
};
|
||||||
|
await correct(30);
|
||||||
|
const refreshed = await prisma.suggestion.findUniqueOrThrow({
|
||||||
|
where: { id: open[0].id },
|
||||||
|
});
|
||||||
|
expect(refreshed.status).toBe('OPEN');
|
||||||
|
expect(refreshed.evidence).toMatchObject({ value: 30 });
|
||||||
|
|
||||||
|
// Corrected below the limit: the condition is gone, the suggestion resolves.
|
||||||
|
await correct(1);
|
||||||
|
const resolved = await prisma.suggestion.findUniqueOrThrow({
|
||||||
|
where: { id: open[0].id },
|
||||||
|
});
|
||||||
|
expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets an admin clear a rule config, which falls back to its defaults', async () => {
|
it('lets an admin clear a rule config, which falls back to its defaults', async () => {
|
||||||
@@ -430,25 +496,19 @@ describe('Automation rules (e2e)', () => {
|
|||||||
swarmMood: 'HIGH',
|
swarmMood: 'HIGH',
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
const swarmTasks = () =>
|
const swarmSuggestions = () =>
|
||||||
prisma.task.count({
|
prisma.suggestion.count({
|
||||||
where: {
|
where: { colonyId, ruleKey: 'SWARM_MOOD_HIGH' },
|
||||||
relatedId: colonyId,
|
|
||||||
sourceAutomationTrigger: 'SWARM_MOOD_HIGH',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// While active, high swarm mood leaves a task - the rule really fires.
|
// While active, high swarm mood leaves a suggestion - the rule really
|
||||||
// (Swarm checks stay on the post-commit path: evaluated before the
|
// fires. (Swarm checks stay on the post-commit path: evaluated before
|
||||||
// response, no event to drain.)
|
// the response, no event to drain.)
|
||||||
await highSwarmMood();
|
await highSwarmMood();
|
||||||
expect(await swarmTasks()).toBe(1);
|
expect(await swarmSuggestions()).toBe(1);
|
||||||
// Close it so the one-open-task guard cannot be what keeps a new task
|
// Settle it so the one-open-thing guard cannot be what keeps a new one
|
||||||
// from appearing - only the deactivation may.
|
// from appearing - only the deactivation may.
|
||||||
await prisma.task.updateMany({
|
await dismissOpen();
|
||||||
where: { relatedId: colonyId },
|
|
||||||
data: { status: 'DONE' },
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
@@ -459,7 +519,7 @@ describe('Automation rules (e2e)', () => {
|
|||||||
|
|
||||||
await highSwarmMood();
|
await highSwarmMood();
|
||||||
|
|
||||||
expect(await swarmTasks()).toBe(1);
|
expect(await swarmSuggestions()).toBe(1);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.automationRule.update({
|
await prisma.automationRule.update({
|
||||||
where: { id: swarmRuleId },
|
where: { id: swarmRuleId },
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AutopilotService } from '../src/autopilot/autopilot.service';
|
||||||
|
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { login } from './utils/api';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { drainDomainEvents } from './utils/domain-events';
|
||||||
|
|
||||||
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
/** Fixed day inside the swarm season by calendar; 2001 keeps it apart from other files. */
|
||||||
|
const TODAY = '2001-05-12';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autopilot sync against a real PostgreSQL, on fixed days in 2001. The
|
||||||
|
* colonies are created 2001-04-20, so no other file's data is reached.
|
||||||
|
*/
|
||||||
|
describe('Autopilot (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let autopilot: AutopilotService;
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
let apiaryId: string;
|
||||||
|
let userId: string;
|
||||||
|
const colonyIds: string[] = [];
|
||||||
|
const roleIds: string[] = [];
|
||||||
|
const userIds: string[] = [];
|
||||||
|
|
||||||
|
/** A living colony inspected 2001-05-03 (9 days before TODAY) and measured 2001-05-10. */
|
||||||
|
async function colonyWithLateInspection(name: string): Promise<string> {
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `AP-E2E-${suffix}-${name}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
createdAt: d('2001-04-20'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
colonyIds.push(colony.id);
|
||||||
|
await prisma.inspection.create({
|
||||||
|
data: { colonyId: colony.id, userId, date: d('2001-05-03') },
|
||||||
|
});
|
||||||
|
await prisma.varroaMeasurement.create({
|
||||||
|
data: {
|
||||||
|
colonyId: colony.id,
|
||||||
|
userId,
|
||||||
|
date: d('2001-05-10'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return colony.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sync = (colonyId: string, day = TODAY) =>
|
||||||
|
prisma.$transaction((tx) => autopilot.syncColonyIn(tx, colonyId, day));
|
||||||
|
|
||||||
|
const suggestionsOf = (colonyId: string, intent = 'INSPECT') =>
|
||||||
|
prisma.suggestion.findMany({
|
||||||
|
where: { colonyId, source: 'AUTOPILOT', intent: intent as 'INSPECT' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
autopilot = app.get(AutopilotService);
|
||||||
|
apiaryId = (
|
||||||
|
await prisma.apiary.create({ data: { name: `Autopilot e2e ${suffix}` } })
|
||||||
|
).id;
|
||||||
|
userId = (
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `autopilot-e2e-${suffix}@example.com`,
|
||||||
|
passwordHash: 'x',
|
||||||
|
name: 'Autopilot e2e',
|
||||||
|
role: { connect: { key: 'STAFF' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const inspections = await prisma.inspection.findMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateId: { in: inspections.map((i) => i.id) } },
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.inspection.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.varroaMeasurement.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.treatment.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||||
|
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||||
|
await prisma.user.delete({ where: { id: userId } });
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swarm season by calendar, last inspection 9 days ago -> step INSPECT overdue', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('overdue');
|
||||||
|
const inspection = await prisma.inspection.findFirstOrThrow({
|
||||||
|
where: { colonyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sync(colonyId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
|
||||||
|
const [suggestion] = await suggestionsOf(colonyId);
|
||||||
|
expect(suggestion).toMatchObject({
|
||||||
|
status: 'OPEN',
|
||||||
|
mode: 'DO',
|
||||||
|
priority: 'HIGH',
|
||||||
|
ruleKey: 'INSPECTION_INTERVAL',
|
||||||
|
reasonCode: 'INSPECTION_DUE',
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: colonyId,
|
||||||
|
dedupeKey: `INSPECTION_INTERVAL:${colonyId}:${inspection.id}`,
|
||||||
|
});
|
||||||
|
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-10');
|
||||||
|
expect(suggestion.expiresAt?.toISOString().slice(0, 10)).toBe('2001-06-11');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second sync refreshes instead of creating another suggestion', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('refresh');
|
||||||
|
await sync(colonyId);
|
||||||
|
|
||||||
|
const again = await sync(colonyId, '2001-05-13');
|
||||||
|
|
||||||
|
expect(again).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
|
||||||
|
expect(await suggestionsOf(colonyId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after a new inspection the INSPECT suggestion is RESOLVED', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('resolved');
|
||||||
|
await sync(colonyId);
|
||||||
|
await prisma.inspection.create({
|
||||||
|
data: { colonyId, userId, date: d(TODAY) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sync(colonyId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 1 });
|
||||||
|
const [suggestion] = await suggestionsOf(colonyId);
|
||||||
|
expect(suggestion).toMatchObject({
|
||||||
|
status: 'RESOLVED',
|
||||||
|
decisionNote: 'Bedingung entfällt',
|
||||||
|
dedupeKey: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two colony syncs in parallel leave one INSPECT suggestion', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('parallel');
|
||||||
|
|
||||||
|
await Promise.all([sync(colonyId), sync(colonyId), sync(colonyId)]);
|
||||||
|
|
||||||
|
const open = (await suggestionsOf(colonyId)).filter(
|
||||||
|
(suggestion) => suggestion.status === 'OPEN',
|
||||||
|
);
|
||||||
|
expect(open).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('returns');
|
||||||
|
await sync(colonyId);
|
||||||
|
const fresh = await prisma.inspection.create({
|
||||||
|
data: { colonyId, userId, date: d(TODAY) },
|
||||||
|
});
|
||||||
|
await sync(colonyId);
|
||||||
|
await prisma.inspection.delete({ where: { id: fresh.id } });
|
||||||
|
|
||||||
|
const result = await sync(colonyId);
|
||||||
|
|
||||||
|
expect(result.opened).toBe(1);
|
||||||
|
expect(
|
||||||
|
(await suggestionsOf(colonyId)).map((suggestion) => suggestion.status),
|
||||||
|
).toEqual(['RESOLVED', 'OPEN']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a dismissed suggestion keeps its anchor: the same condition is not proposed again', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('dismissed');
|
||||||
|
await sync(colonyId);
|
||||||
|
await prisma.suggestion.updateMany({
|
||||||
|
where: { colonyId, intent: 'INSPECT' },
|
||||||
|
data: { status: 'DISMISSED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sync(colonyId);
|
||||||
|
|
||||||
|
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
|
||||||
|
expect(await suggestionsOf(colonyId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an ended colony gets nothing', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('ended');
|
||||||
|
await prisma.colony.update({
|
||||||
|
where: { id: colonyId },
|
||||||
|
data: { status: 'DECEASED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await sync(colonyId)).toEqual({
|
||||||
|
opened: 0,
|
||||||
|
refreshed: 0,
|
||||||
|
resolved: 0,
|
||||||
|
});
|
||||||
|
expect(await suggestionsOf(colonyId)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proposes VARROA_MEASURE (A2) when the last measurement is older than 28 days', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('varroa');
|
||||||
|
await prisma.varroaMeasurement.updateMany({
|
||||||
|
where: { colonyId },
|
||||||
|
data: { date: d('2001-04-07') },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync(colonyId);
|
||||||
|
|
||||||
|
const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE');
|
||||||
|
expect(measure).toMatchObject({
|
||||||
|
status: 'OPEN',
|
||||||
|
ruleKey: 'SEASON_AUTOPILOT',
|
||||||
|
reasonCode: 'VARROA_MEASURE_DUE',
|
||||||
|
requiredPermission: 'varroa:read',
|
||||||
|
});
|
||||||
|
expect(measure.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autopilot.colony-sync resolves a stale autopilot suggestion when an inspection event is delivered', async () => {
|
||||||
|
// Real today: a colony inspected and measured today has no candidate.
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `AP-E2E-${suffix}-event`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
colonyIds.push(colony.id);
|
||||||
|
const inspection = await prisma.inspection.create({
|
||||||
|
data: { colonyId: colony.id, userId, date: new Date() },
|
||||||
|
});
|
||||||
|
await prisma.varroaMeasurement.create({
|
||||||
|
data: {
|
||||||
|
colonyId: colony.id,
|
||||||
|
userId,
|
||||||
|
date: new Date(),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const stale = await prisma.suggestion.create({
|
||||||
|
data: {
|
||||||
|
ruleKey: 'INSPECTION_INTERVAL',
|
||||||
|
source: 'AUTOPILOT',
|
||||||
|
mode: 'DO',
|
||||||
|
intent: 'INSPECT',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: colony.id,
|
||||||
|
colonyId: colony.id,
|
||||||
|
reasonCode: 'INSPECTION_DUE',
|
||||||
|
evidence: {},
|
||||||
|
dedupeKey: `INSPECTION_INTERVAL:${colony.id}:stale`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.$transaction((tx) =>
|
||||||
|
app.get(DomainEventsService).record(tx, {
|
||||||
|
type: 'InspectionRecorded',
|
||||||
|
aggregateType: 'Inspection',
|
||||||
|
aggregateId: inspection.id,
|
||||||
|
payload: { colonyId: colony.id },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await drainDomainEvents(app, [inspection.id]);
|
||||||
|
|
||||||
|
const after = await prisma.suggestion.findUniqueOrThrow({
|
||||||
|
where: { id: stale.id },
|
||||||
|
});
|
||||||
|
expect(after.status).toBe('RESOLVED');
|
||||||
|
expect(after.decisionNote).toBe('Bedingung entfällt');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A colony with a treatment that ended 2001-04-28 and an older measurement. */
|
||||||
|
async function colonyAfterTreatment(
|
||||||
|
name: string,
|
||||||
|
measurements: string[],
|
||||||
|
): Promise<string> {
|
||||||
|
const colonyId = await colonyWithLateInspection(name);
|
||||||
|
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
|
||||||
|
for (const date of measurements) {
|
||||||
|
await prisma.varroaMeasurement.create({
|
||||||
|
data: { colonyId, userId, date: d(date), method: 'NATURAL_MITE_FALL' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await prisma.treatment.create({
|
||||||
|
data: {
|
||||||
|
colonyId,
|
||||||
|
userId,
|
||||||
|
date: d('2001-04-14'),
|
||||||
|
endDate: d('2001-04-28'),
|
||||||
|
product: 'Oxuvar',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return colonyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('A3: treatment ended 14 days ago -> VARROA_RECHECK for varroa readers, and no A2 beside it', async () => {
|
||||||
|
const colonyId = await colonyAfterTreatment('recheck', ['2001-04-01']);
|
||||||
|
|
||||||
|
await sync(colonyId);
|
||||||
|
|
||||||
|
const [recheck] = await suggestionsOf(colonyId, 'VARROA_RECHECK');
|
||||||
|
expect(recheck).toMatchObject({
|
||||||
|
status: 'OPEN',
|
||||||
|
mode: 'DO',
|
||||||
|
reasonCode: 'VARROA_RECHECK_DUE',
|
||||||
|
requiredPermission: 'varroa:read',
|
||||||
|
});
|
||||||
|
expect(recheck.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-12');
|
||||||
|
expect(await suggestionsOf(colonyId, 'VARROA_MEASURE')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A3: a measurement on the end day does not count, one the day after does', async () => {
|
||||||
|
const onEndDay = await colonyAfterTreatment('endday', ['2001-04-28']);
|
||||||
|
const dayAfter = await colonyAfterTreatment('dayafter', ['2001-04-29']);
|
||||||
|
|
||||||
|
await sync(onEndDay);
|
||||||
|
await sync(dayAfter);
|
||||||
|
|
||||||
|
expect(await suggestionsOf(onEndDay, 'VARROA_RECHECK')).toHaveLength(1);
|
||||||
|
expect(await suggestionsOf(dayAfter, 'VARROA_RECHECK')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('A2 winter window: no measurement since 11-01 -> VARROA_WINTER_MEASURE with anchor winter-2001', async () => {
|
||||||
|
const colonyId = await colonyWithLateInspection('winter');
|
||||||
|
await prisma.varroaMeasurement.updateMany({
|
||||||
|
where: { colonyId },
|
||||||
|
data: { date: d('2001-09-01') },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync(colonyId, '2001-11-20');
|
||||||
|
|
||||||
|
const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE');
|
||||||
|
expect(measure).toMatchObject({
|
||||||
|
status: 'OPEN',
|
||||||
|
reasonCode: 'VARROA_WINTER_MEASURE',
|
||||||
|
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:winter-2001`,
|
||||||
|
});
|
||||||
|
expect(await suggestionsOf(colonyId, 'INSPECT')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user without varroa:read sees neither the A2 nor the A3 suggestion', async () => {
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
const tokenFor = async (label: string, keys: string[]) => {
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `AP_E2E_${label.toUpperCase()}_${suffix}`,
|
||||||
|
name: `Autopilot e2e ${label}`,
|
||||||
|
permissions: {
|
||||||
|
create: keys.map((key) => ({ permission: { connect: { key } } })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
roleIds.push(role.id);
|
||||||
|
const email = `autopilot-e2e-${label}-${suffix}@example.com`;
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
name: `Autopilot e2e ${label}`,
|
||||||
|
role: { connect: { id: role.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userIds.push(user.id);
|
||||||
|
return (await login(app, email, password)).accessToken;
|
||||||
|
};
|
||||||
|
const full = await tokenFor('full', ['tasks:read', 'varroa:read']);
|
||||||
|
const noVarroa = await tokenFor('novarroa', ['tasks:read']);
|
||||||
|
const colonyId = await colonyAfterTreatment('visible', ['2001-04-01']);
|
||||||
|
await sync(colonyId);
|
||||||
|
const list = (token: string) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/suggestions?colonyId=${colonyId}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
const seen = await list(full);
|
||||||
|
const hidden = await list(noVarroa);
|
||||||
|
|
||||||
|
expect((seen.body as unknown[]).length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(hidden.body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||||
|
import { ChainEventHandlers } from '../src/suggestions/chains/chains.handlers';
|
||||||
|
import { SuggestionsService } from '../src/suggestions/suggestions.service';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { drainDomainEvents } from './utils/domain-events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The biotechnical chains against a real PostgreSQL: an inspection, a queen
|
||||||
|
* assignment or a split leaves one PLAN suggestion; later inspections clean
|
||||||
|
* up the checks they answer. The events are recorded directly (the services
|
||||||
|
* that record them have their own e2e tests) and drained like the worker.
|
||||||
|
*/
|
||||||
|
describe('Chains (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let events: DomainEventsService;
|
||||||
|
let suggestions: SuggestionsService;
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
let apiaryId: string;
|
||||||
|
let colonyId: string;
|
||||||
|
let userId: string;
|
||||||
|
let aggregateIds: string[] = [];
|
||||||
|
|
||||||
|
// Only the chains: the season autopilot writes its own suggestions for the
|
||||||
|
// same colony when the inspection event is delivered.
|
||||||
|
const chainSuggestions = () =>
|
||||||
|
prisma.suggestion.findMany({
|
||||||
|
where: { colonyId, source: 'CHAIN' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const openOf = (intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN') =>
|
||||||
|
prisma.suggestion.findMany({
|
||||||
|
where: { colonyId, source: 'CHAIN', status: 'OPEN', intent },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dayOf = (date: Date | null) => date?.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
/** Records an inspection and runs the handlers of its event. */
|
||||||
|
async function inspect(
|
||||||
|
day: string,
|
||||||
|
findings: {
|
||||||
|
queenCellsPresent?: boolean | null;
|
||||||
|
queenSeen?: boolean | null;
|
||||||
|
eggsPresent?: boolean | null;
|
||||||
|
} = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const inspection = await prisma.inspection.create({
|
||||||
|
data: {
|
||||||
|
colonyId,
|
||||||
|
userId,
|
||||||
|
date: new Date(`${day}T10:00:00.000Z`),
|
||||||
|
queenCellsPresent: findings.queenCellsPresent ?? null,
|
||||||
|
queenSeen: findings.queenSeen ?? null,
|
||||||
|
eggsPresent: findings.eggsPresent ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await recordAndDrain(inspection.id, () =>
|
||||||
|
prisma.$transaction((tx) =>
|
||||||
|
events.record(tx, {
|
||||||
|
type: 'InspectionRecorded',
|
||||||
|
aggregateType: 'Inspection',
|
||||||
|
aggregateId: inspection.id,
|
||||||
|
payload: { colonyId },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return inspection.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignQueen(assignedOn: string): Promise<string> {
|
||||||
|
const queenId = `queen-${suffix}-${aggregateIds.length}`;
|
||||||
|
const historyId = `hist-${suffix}-${aggregateIds.length}`;
|
||||||
|
await recordAndDrain(queenId, () =>
|
||||||
|
prisma.$transaction((tx) =>
|
||||||
|
events.record(tx, {
|
||||||
|
type: 'QueenAssigned',
|
||||||
|
aggregateType: 'Queen',
|
||||||
|
aggregateId: queenId,
|
||||||
|
payload: { colonyId, historyId, assignedOn, previousColonyId: null },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return historyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function split(
|
||||||
|
queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await recordAndDrain(colonyId, () =>
|
||||||
|
prisma.$transaction((tx) =>
|
||||||
|
events.record(tx, {
|
||||||
|
type: 'ColonySplit',
|
||||||
|
aggregateType: 'Colony',
|
||||||
|
aggregateId: colonyId,
|
||||||
|
payload: {
|
||||||
|
parentColonyId: `parent-${suffix}`,
|
||||||
|
lineageId: `lineage-${suffix}-${aggregateIds.length}`,
|
||||||
|
queenSource,
|
||||||
|
splitOn: '2026-05-01',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordAndDrain(
|
||||||
|
aggregateId: string,
|
||||||
|
record: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
aggregateIds = [...aggregateIds, aggregateId];
|
||||||
|
await record();
|
||||||
|
await drainDomainEvents(app, [aggregateId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
events = app.get(DomainEventsService);
|
||||||
|
suggestions = app.get(SuggestionsService);
|
||||||
|
const apiary = await prisma.apiary.create({
|
||||||
|
data: { name: `Chains e2e ${suffix}` },
|
||||||
|
});
|
||||||
|
apiaryId = apiary.id;
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `CHN-${suffix}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
colonyId = colony.id;
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `chains-e2e-${suffix}@example.com`,
|
||||||
|
passwordHash: 'x',
|
||||||
|
name: 'Chains e2e',
|
||||||
|
role: { connect: { key: 'STAFF' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cleanUp(): Promise<void> {
|
||||||
|
await prisma.task.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ relatedId: colonyId }, { sourceSuggestion: { colonyId } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateId: { in: aggregateIds } },
|
||||||
|
});
|
||||||
|
await prisma.inspection.deleteMany({ where: { colonyId } });
|
||||||
|
aggregateIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanUp();
|
||||||
|
await prisma.colony.update({
|
||||||
|
where: { id: colonyId },
|
||||||
|
data: { status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanUp();
|
||||||
|
await prisma.auditLog.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ userId },
|
||||||
|
{
|
||||||
|
entityType: 'Suggestion',
|
||||||
|
afterData: { path: ['colonyId'], equals: colonyId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.colony.delete({ where: { id: colonyId } });
|
||||||
|
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||||
|
await prisma.user.delete({ where: { id: userId } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queen cells -> one PLAN suggestion due +7', async () => {
|
||||||
|
const inspectionId = await inspect('2026-05-05', {
|
||||||
|
queenCellsPresent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = await openOf('SWARM_CHECK');
|
||||||
|
expect(open).toHaveLength(1);
|
||||||
|
expect(open[0]).toMatchObject({
|
||||||
|
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
|
||||||
|
source: 'CHAIN',
|
||||||
|
mode: 'PLAN',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: colonyId,
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
dedupeKey: `QUEEN_CELLS_SWARM_CHECK:${colonyId}:${inspectionId}`,
|
||||||
|
});
|
||||||
|
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
|
||||||
|
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an inspection without queen cells proposes nothing', async () => {
|
||||||
|
await inspect('2026-05-05', { queenCellsPresent: false });
|
||||||
|
await inspect('2026-05-06');
|
||||||
|
|
||||||
|
expect(await chainSuggestions()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RAISE_OWN -> accept creates 3 tasks at +9, +21 and +28', async () => {
|
||||||
|
await split('RAISE_OWN');
|
||||||
|
|
||||||
|
const [suggestion] = await openOf('SPLIT_PLAN');
|
||||||
|
expect(suggestion).toMatchObject({
|
||||||
|
ruleKey: 'SPLIT_SCHEDULE',
|
||||||
|
mode: 'PLAN',
|
||||||
|
requiredPermission: 'colonies:read',
|
||||||
|
});
|
||||||
|
expect(dayOf(suggestion.dueDate)).toBe('2026-05-10');
|
||||||
|
|
||||||
|
const result = await suggestions.accept(suggestion.id, {}, userId);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.tasks.map((task) => [task.title, dayOf(task.dueDate)]),
|
||||||
|
).toEqual([
|
||||||
|
['Nachschaffungszellen prüfen', '2026-05-10'],
|
||||||
|
['Begattung prüfen', '2026-05-22'],
|
||||||
|
['Stifte prüfen', '2026-05-29'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a split without queenSource leaves nothing', async () => {
|
||||||
|
await split(null);
|
||||||
|
|
||||||
|
expect(await chainSuggestions()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a queen assignment -> acceptance check +7', async () => {
|
||||||
|
const historyId = await assignQueen('2026-05-05');
|
||||||
|
|
||||||
|
const open = await openOf('QUEEN_ACCEPTANCE');
|
||||||
|
expect(open).toHaveLength(1);
|
||||||
|
expect(open[0]).toMatchObject({
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
source: 'CHAIN',
|
||||||
|
mode: 'PLAN',
|
||||||
|
requiredPermission: 'queens:read',
|
||||||
|
dedupeKey: `QUEEN_ACCEPTANCE_CHECK:${colonyId}:${historyId}`,
|
||||||
|
});
|
||||||
|
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a split with QUEEN plus a later assignment yields one acceptance suggestion', async () => {
|
||||||
|
await split('QUEEN');
|
||||||
|
await assignQueen('2026-05-01');
|
||||||
|
|
||||||
|
const open = await openOf('QUEEN_ACCEPTANCE');
|
||||||
|
expect(open).toHaveLength(1);
|
||||||
|
expect(open[0].ruleKey).toBe('SPLIT_SCHEDULE');
|
||||||
|
expect(dayOf(open[0].dueDate)).toBe('2026-05-08');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an inspection on the due day resolves the open swarm check', async () => {
|
||||||
|
await inspect('2026-05-05', { queenCellsPresent: true });
|
||||||
|
|
||||||
|
await inspect('2026-05-12', { queenCellsPresent: false });
|
||||||
|
|
||||||
|
const [resolved] = await chainSuggestions();
|
||||||
|
expect(resolved).toMatchObject({
|
||||||
|
status: 'RESOLVED',
|
||||||
|
dedupeKey: null,
|
||||||
|
decisionNote: 'Mit Durchsicht erledigt',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an inspection before the due day leaves the swarm check open', async () => {
|
||||||
|
await inspect('2026-05-05', { queenCellsPresent: true });
|
||||||
|
|
||||||
|
await inspect('2026-05-08', { queenCellsPresent: false });
|
||||||
|
|
||||||
|
expect(await openOf('SWARM_CHECK')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second inspection with queen cells replaces the open swarm check by a new one due +7', async () => {
|
||||||
|
await inspect('2026-05-05', { queenCellsPresent: true });
|
||||||
|
|
||||||
|
const secondId = await inspect('2026-05-08', { queenCellsPresent: true });
|
||||||
|
|
||||||
|
const all = await chainSuggestions();
|
||||||
|
expect(all.map((row) => row.status)).toEqual(['RESOLVED', 'OPEN']);
|
||||||
|
expect(all[1].dedupeKey).toBe(
|
||||||
|
`QUEEN_CELLS_SWARM_CHECK:${colonyId}:${secondId}`,
|
||||||
|
);
|
||||||
|
expect(dayOf(all[1].dueDate)).toBe('2026-05-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an inspection with neither queen seen nor eggs leaves the open acceptance check open; with eggs it resolves it', async () => {
|
||||||
|
await assignQueen('2026-05-05');
|
||||||
|
|
||||||
|
await inspect('2026-05-12');
|
||||||
|
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(1);
|
||||||
|
|
||||||
|
await inspect('2026-05-13', { eggsPresent: true });
|
||||||
|
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivering the same InspectionRecorded and ColonySplit twice leaves exactly one suggestion each', async () => {
|
||||||
|
const inspectionId = await inspect('2026-05-05', {
|
||||||
|
queenCellsPresent: true,
|
||||||
|
});
|
||||||
|
await split('RAISE_OWN');
|
||||||
|
const handlers = app.get(ChainEventHandlers);
|
||||||
|
const events = await prisma.domainEvent.findMany({
|
||||||
|
where: { aggregateId: { in: aggregateIds } },
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
|
||||||
|
// Redelivery after a crash: the handlers run again with the stored events.
|
||||||
|
for (const event of events) {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const envelope = { ...event, attempts: 1 } as never;
|
||||||
|
if (event.type === 'InspectionRecorded') {
|
||||||
|
await handlers.queenCells(envelope, tx);
|
||||||
|
} else {
|
||||||
|
await handlers.splitSchedule(envelope, tx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await chainSuggestions();
|
||||||
|
expect(all.map((row) => [row.intent, row.status])).toEqual([
|
||||||
|
['SWARM_CHECK', 'OPEN'],
|
||||||
|
['SPLIT_PLAN', 'OPEN'],
|
||||||
|
]);
|
||||||
|
expect(all[0].dedupeKey).toContain(inspectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a backdated inspection with queen cells does not replace the newer open check', async () => {
|
||||||
|
await inspect('2026-05-08', { queenCellsPresent: true });
|
||||||
|
|
||||||
|
await inspect('2026-05-02', { queenCellsPresent: true });
|
||||||
|
|
||||||
|
const all = await chainSuggestions();
|
||||||
|
expect(all.map((row) => row.status)).toEqual(['OPEN']);
|
||||||
|
expect(dayOf(all[0].dueDate)).toBe('2026-05-15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nothing for an ended colony', async () => {
|
||||||
|
await prisma.colony.update({
|
||||||
|
where: { id: colonyId },
|
||||||
|
data: { status: 'DISSOLVED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await inspect('2026-05-05', { queenCellsPresent: true });
|
||||||
|
await assignQueen('2026-05-05');
|
||||||
|
await split('QUEEN');
|
||||||
|
|
||||||
|
expect(await chainSuggestions()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,28 +86,30 @@ describe('Colonies (e2e)', () => {
|
|||||||
raceColonyId,
|
raceColonyId,
|
||||||
...splitColonyIds,
|
...splitColonyIds,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
// Rule tasks are written without a user - their audit entry and
|
// Rule suggestions are written without a user - their audit entry and
|
||||||
// TaskCreated are not covered by the userId cleanup below.
|
// warning are not covered by the userId cleanup below.
|
||||||
const ruleTaskIds = (
|
const suggestionIds = (
|
||||||
await prisma.task.findMany({
|
await prisma.suggestion.findMany({
|
||||||
where: { relatedId: { in: colonyIds } },
|
where: { colonyId: { in: colonyIds } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
).map((task) => task.id);
|
).map((suggestion) => suggestion.id);
|
||||||
await prisma.auditLog.deleteMany({
|
await prisma.auditLog.deleteMany({
|
||||||
where: { entityType: 'Task', entityId: { in: ruleTaskIds } },
|
where: { entityType: 'Suggestion', entityId: { in: suggestionIds } },
|
||||||
});
|
});
|
||||||
await prisma.domainEvent.deleteMany({
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.notification.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ aggregateType: 'Colony', aggregateId: { in: colonyIds } },
|
{ relatedId: { in: colonyIds } },
|
||||||
{ aggregateType: 'Task', aggregateId: { in: ruleTaskIds } },
|
{ relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
|
await prisma.suggestion.deleteMany({
|
||||||
await prisma.notification.deleteMany({
|
where: { id: { in: suggestionIds } },
|
||||||
where: { relatedId: { in: colonyIds } },
|
|
||||||
});
|
});
|
||||||
await prisma.colonyLineage.deleteMany({
|
await prisma.colonyLineage.deleteMany({
|
||||||
where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] },
|
where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] },
|
||||||
@@ -330,7 +332,7 @@ describe('Colonies (e2e)', () => {
|
|||||||
expect(colony.status).toBe('WEAK');
|
expect(colony.status).toBe('WEAK');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates the COLONY_HEALTH_CRITICAL task only after the event is processed, and only on the transition', async () => {
|
it('proposes the COLONY_HEALTH_CRITICAL check only after the event is processed, and only on the transition', async () => {
|
||||||
const created = await request(app.getHttpServer())
|
const created = await request(app.getHttpServer())
|
||||||
.post('/api/v1/colonies')
|
.post('/api/v1/colonies')
|
||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
@@ -356,15 +358,19 @@ describe('Colonies (e2e)', () => {
|
|||||||
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
|
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
await prisma.task.count({ where: { relatedId: healthColonyId } }),
|
await prisma.suggestion.count({
|
||||||
|
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
|
||||||
|
}),
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
|
|
||||||
await drainDomainEvents(app, [healthColonyId]);
|
await drainDomainEvents(app, [healthColonyId]);
|
||||||
expect(
|
expect(
|
||||||
await prisma.task.count({ where: { relatedId: healthColonyId } }),
|
await prisma.suggestion.count({
|
||||||
|
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
|
||||||
|
}),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
|
|
||||||
// Already critical: a further edit is no transition and opens no second task.
|
// Already critical: a further edit is no transition and proposes nothing a second time.
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.patch(`/api/v1/colonies/${healthColonyId}`)
|
.patch(`/api/v1/colonies/${healthColonyId}`)
|
||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
@@ -372,11 +378,13 @@ describe('Colonies (e2e)', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
await drainDomainEvents(app, [healthColonyId]);
|
await drainDomainEvents(app, [healthColonyId]);
|
||||||
expect(
|
expect(
|
||||||
await prisma.task.count({ where: { relatedId: healthColonyId } }),
|
await prisma.suggestion.count({
|
||||||
|
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
|
||||||
|
}),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('two concurrent PATCHes into CRITICAL produce one transition event and one task', async () => {
|
it('two concurrent PATCHes into CRITICAL produce one transition event and one suggestion', async () => {
|
||||||
const created = await request(app.getHttpServer())
|
const created = await request(app.getHttpServer())
|
||||||
.post('/api/v1/colonies')
|
.post('/api/v1/colonies')
|
||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
@@ -405,7 +413,9 @@ describe('Colonies (e2e)', () => {
|
|||||||
).toBe(1);
|
).toBe(1);
|
||||||
await drainDomainEvents(app, [raceColonyId]);
|
await drainDomainEvents(app, [raceColonyId]);
|
||||||
expect(
|
expect(
|
||||||
await prisma.task.count({ where: { relatedId: raceColonyId } }),
|
await prisma.suggestion.count({
|
||||||
|
where: { colonyId: raceColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
|
||||||
|
}),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { login } from './utils/api';
|
||||||
|
|
||||||
|
interface ImportReportBody {
|
||||||
|
created: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Colony strength in occupied frames (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = `${Date.now()}`;
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
let adminToken: string;
|
||||||
|
let adminId: string;
|
||||||
|
let apiaryId: string;
|
||||||
|
let apiaryName: string;
|
||||||
|
let colonyId: string;
|
||||||
|
|
||||||
|
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
const admin = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `strength-admin-${suffix}@example.com`,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
name: 'Admin',
|
||||||
|
role: { connect: { key: 'ADMIN' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
adminId = admin.id;
|
||||||
|
adminToken = (await login(app, admin.email, password)).accessToken;
|
||||||
|
apiaryName = `Strength ${suffix}`;
|
||||||
|
apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id;
|
||||||
|
colonyId = (
|
||||||
|
await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `ST-${suffix}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
strength: 7,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const colonies = await prisma.colony.findMany({
|
||||||
|
where: { apiaryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ids = colonies.map((row) => row.id);
|
||||||
|
const inspections = await prisma.inspection.findMany({
|
||||||
|
where: { colonyId: { in: ids } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const inspectionIds = inspections.map((row) => row.id);
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ aggregateType: 'Inspection', aggregateId: { in: inspectionIds } },
|
||||||
|
{ aggregateType: 'Colony', aggregateId: { in: ids } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||||
|
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
|
||||||
|
});
|
||||||
|
await prisma.inspection.deleteMany({ where: { colonyId: { in: ids } } });
|
||||||
|
await prisma.colony.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
|
await prisma.refreshToken.deleteMany({ where: { userId: adminId } });
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: adminId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: adminId } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH occupiedFrames keeps the legacy strength', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/colonies/${colonyId}`)
|
||||||
|
.set(auth(adminToken))
|
||||||
|
.send({ occupiedFrames: 16 })
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }),
|
||||||
|
).toMatchObject({ occupiedFrames: 16, strength: 7 });
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/colonies/${colonyId}`)
|
||||||
|
.set(auth(adminToken))
|
||||||
|
.send({ occupiedFrames: null })
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
(await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }))
|
||||||
|
.occupiedFrames,
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/colonies/${colonyId}`)
|
||||||
|
.set(auth(adminToken))
|
||||||
|
.send({ occupiedFrames: 41 })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a takeover from an inspection writes only occupiedFrames', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/inspections')
|
||||||
|
.set(auth(adminToken))
|
||||||
|
.send({
|
||||||
|
colonyId,
|
||||||
|
date: '2026-06-01T10:00:00.000Z',
|
||||||
|
colonyStrength: 12,
|
||||||
|
applyToColony: ['occupiedFrames'],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(
|
||||||
|
await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }),
|
||||||
|
).toMatchObject({
|
||||||
|
occupiedFrames: 12,
|
||||||
|
strength: 7,
|
||||||
|
broodStatus: null,
|
||||||
|
feedStatus: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colonyStrength 41 answers 400 "Höchstens 40 besetzte Waben."', async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/inspections')
|
||||||
|
.set(auth(adminToken))
|
||||||
|
.send({ colonyId, date: '2026-06-02T10:00:00.000Z', colonyStrength: 41 })
|
||||||
|
.expect(400);
|
||||||
|
expect(JSON.stringify(response.body)).toContain(
|
||||||
|
'Höchstens 40 besetzte Waben.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSV export and import carry strength and occupiedFrames', async () => {
|
||||||
|
const exported = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/export/colonies.csv')
|
||||||
|
.set(auth(adminToken))
|
||||||
|
.expect(200);
|
||||||
|
const lines = exported.text.split('\n');
|
||||||
|
expect(lines[0].trim().endsWith(',occupiedFrames')).toBe(true);
|
||||||
|
expect(lines[0]).toContain('strength,healthStatus');
|
||||||
|
const row = lines.find((line) => line.includes(`ST-${suffix}`)) ?? '';
|
||||||
|
expect(row).toContain(',7,');
|
||||||
|
expect(row.trim().endsWith(',12')).toBe(true);
|
||||||
|
|
||||||
|
const code = `ST-IMP-${suffix}`;
|
||||||
|
const csv = `code,apiaryName,colonyType,strength,occupiedFrames\n${code},${apiaryName},PRODUCTION_COLONY,5,20`;
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/import/colonies')
|
||||||
|
.set(auth(adminToken))
|
||||||
|
.attach('file', Buffer.from(csv), {
|
||||||
|
filename: 'colonies.csv',
|
||||||
|
contentType: 'text/csv',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect((response.body as ImportReportBody).created).toBe(1);
|
||||||
|
expect(
|
||||||
|
await prisma.colony.findUniqueOrThrow({ where: { code } }),
|
||||||
|
).toMatchObject({ strength: 5, occupiedFrames: 20 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { INestApplicationContext } from '@nestjs/common';
|
import { INestApplicationContext } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '../generated/prisma';
|
import { Prisma } from '../generated/prisma';
|
||||||
|
import { AutopilotStep } from '../src/autopilot/autopilot.step';
|
||||||
|
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
|
||||||
|
import { AutopilotService } from '../src/autopilot/autopilot.service';
|
||||||
import { JobsModule } from '../src/jobs/jobs.module';
|
import { JobsModule } from '../src/jobs/jobs.module';
|
||||||
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
@@ -27,7 +30,11 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
let app: INestApplicationContext;
|
let app: INestApplicationContext;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let step: TimeEventsStep;
|
let step: TimeEventsStep;
|
||||||
|
let autopilotStep: AutopilotStep;
|
||||||
|
let expiryStep: SuggestionsExpiryStep;
|
||||||
const ids: Record<string, string> = {};
|
const ids: Record<string, string> = {};
|
||||||
|
/** Colonies of the autopilot tests; the steps only see these. */
|
||||||
|
const autopilotColonyIds: string[] = [];
|
||||||
|
|
||||||
const eventsOf = (aggregateId: string) =>
|
const eventsOf = (aggregateId: string) =>
|
||||||
prisma.domainEvent.findMany({
|
prisma.domainEvent.findMany({
|
||||||
@@ -49,6 +56,25 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
app = await moduleRef.init();
|
app = await moduleRef.init();
|
||||||
prisma = app.get(PrismaService);
|
prisma = app.get(PrismaService);
|
||||||
step = app.get(TimeEventsStep);
|
step = app.get(TimeEventsStep);
|
||||||
|
expiryStep = app.get(SuggestionsExpiryStep);
|
||||||
|
// The real AutopilotStep, but on the colonies of this file only: other
|
||||||
|
// files share the database and must not be reconciled on a 2001 day.
|
||||||
|
autopilotStep = new AutopilotStep(
|
||||||
|
{
|
||||||
|
colony: {
|
||||||
|
findMany: (args: Prisma.ColonyFindManyArgs) =>
|
||||||
|
prisma.colony.findMany({
|
||||||
|
...args,
|
||||||
|
where: {
|
||||||
|
AND: [args.where ?? {}, { id: { in: autopilotColonyIds } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
automationRule: prisma.automationRule,
|
||||||
|
$transaction: prisma.$transaction.bind(prisma) as unknown,
|
||||||
|
} as unknown as PrismaService,
|
||||||
|
app.get(AutopilotService),
|
||||||
|
);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -155,7 +181,15 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
await prisma.domainEvent.deleteMany({
|
await prisma.domainEvent.deleteMany({
|
||||||
where: { aggregateId: { in: aggregateIds } },
|
where: { aggregateId: { in: aggregateIds } },
|
||||||
});
|
});
|
||||||
const colonyIds = [ids.living, ids.dead].filter(Boolean);
|
const colonyIds = [ids.living, ids.dead, ...autopilotColonyIds].filter(
|
||||||
|
Boolean,
|
||||||
|
);
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.varroaMeasurement.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
await prisma.treatment.deleteMany({
|
await prisma.treatment.deleteMany({
|
||||||
where: { colonyId: { in: colonyIds } },
|
where: { colonyId: { in: colonyIds } },
|
||||||
});
|
});
|
||||||
@@ -305,4 +339,128 @@ describe('daily-evaluation (e2e)', () => {
|
|||||||
expect(await eventsOf(ids['queen:young'])).toEqual([]);
|
expect(await eventsOf(ids['queen:young'])).toEqual([]);
|
||||||
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
|
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('autopilot and suggestions-expiry steps', () => {
|
||||||
|
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
|
||||||
|
async function lateColony(name: string): Promise<string> {
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `DE-E2E-${suffix}-ap-${name}`,
|
||||||
|
apiaryId: ids.apiary,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
createdAt: d('2001-04-20'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
autopilotColonyIds.push(colony.id);
|
||||||
|
await prisma.inspection.create({
|
||||||
|
data: { colonyId: colony.id, userId: ids.user, date: d('2001-05-03') },
|
||||||
|
});
|
||||||
|
await prisma.varroaMeasurement.create({
|
||||||
|
data: {
|
||||||
|
colonyId: colony.id,
|
||||||
|
userId: ids.user,
|
||||||
|
date: d('2001-05-10'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return colony.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspectSuggestions = (colonyId: string) =>
|
||||||
|
prisma.suggestion.findMany({
|
||||||
|
where: { colonyId, intent: 'INSPECT', source: 'AUTOPILOT' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('running autopilot and expiry twice yields the same suggestion counts', async () => {
|
||||||
|
const colonyId = await lateColony('twice');
|
||||||
|
|
||||||
|
const first = await autopilotStep.run('2001-05-12');
|
||||||
|
const firstExpiry = await expiryStep.run('2001-05-12');
|
||||||
|
const second = await autopilotStep.run('2001-05-12');
|
||||||
|
const secondExpiry = await expiryStep.run('2001-05-12');
|
||||||
|
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
autopilotColonies: 1,
|
||||||
|
suggestionsOpened: 1,
|
||||||
|
suggestionsResolved: 0,
|
||||||
|
});
|
||||||
|
expect(second).toMatchObject({
|
||||||
|
autopilotColonies: 1,
|
||||||
|
suggestionsOpened: 0,
|
||||||
|
suggestionsResolved: 0,
|
||||||
|
});
|
||||||
|
expect(firstExpiry).toEqual({ suggestionsExpired: 0 });
|
||||||
|
expect(secondExpiry).toEqual({ suggestionsExpired: 0 });
|
||||||
|
expect(await inspectSuggestions(colonyId)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => {
|
||||||
|
const colonyId = await lateColony('expires');
|
||||||
|
await autopilotStep.run('2001-05-12');
|
||||||
|
|
||||||
|
// expiresAt = 2001-06-11
|
||||||
|
expect(await expiryStep.run('2001-06-11')).toEqual({
|
||||||
|
suggestionsExpired: 0,
|
||||||
|
});
|
||||||
|
expect(await expiryStep.run('2001-06-12')).toEqual({
|
||||||
|
suggestionsExpired: expect.any(Number) as number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [suggestion] = await inspectSuggestions(colonyId);
|
||||||
|
expect(suggestion).toMatchObject({ status: 'EXPIRED', dedupeKey: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => {
|
||||||
|
const colonyId = await lateColony('again');
|
||||||
|
await autopilotStep.run('2001-05-12');
|
||||||
|
await expiryStep.run('2001-06-12');
|
||||||
|
|
||||||
|
const result = await autopilotStep.run('2001-06-12');
|
||||||
|
|
||||||
|
expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
||||||
|
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
|
||||||
|
['EXPIRED', 'OPEN'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a colony overdue for 20+ days gets its step from the daily step although no new event is recorded', async () => {
|
||||||
|
const colonyId = await lateColony('overdue20');
|
||||||
|
await prisma.inspection.updateMany({
|
||||||
|
where: { colonyId },
|
||||||
|
data: { date: d('2001-04-10') },
|
||||||
|
});
|
||||||
|
|
||||||
|
await step.run('2001-05-12');
|
||||||
|
await autopilotStep.run('2001-05-12');
|
||||||
|
|
||||||
|
// Due 2001-04-17: the event of 04-18 is outside the 7-day catch-up.
|
||||||
|
expect(
|
||||||
|
await prisma.domainEvent.count({
|
||||||
|
where: { aggregateId: colonyId, type: 'InspectionOverdue' },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
const [suggestion] = await inspectSuggestions(colonyId);
|
||||||
|
expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' });
|
||||||
|
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-04-17');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
|
||||||
|
const colonyId = await lateColony('returns');
|
||||||
|
await autopilotStep.run('2001-05-12');
|
||||||
|
const fresh = await prisma.inspection.create({
|
||||||
|
data: { colonyId, userId: ids.user, date: d('2001-05-12') },
|
||||||
|
});
|
||||||
|
const resolved = await autopilotStep.run('2001-05-12');
|
||||||
|
await prisma.inspection.delete({ where: { id: fresh.id } });
|
||||||
|
|
||||||
|
const returned = await autopilotStep.run('2001-05-12');
|
||||||
|
|
||||||
|
expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1);
|
||||||
|
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
|
||||||
|
['RESOLVED', 'OPEN'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -136,11 +136,8 @@ describe('Data integrity (e2e)', () => {
|
|||||||
await drainDomainEvents(app, [(first.body as { id: string }).id]);
|
await drainDomainEvents(app, [(first.body as { id: string }).id]);
|
||||||
// The automation fired once, not once per delivery attempt.
|
// The automation fired once, not once per delivery attempt.
|
||||||
expect(
|
expect(
|
||||||
await prisma.task.count({
|
await prisma.suggestion.count({
|
||||||
where: {
|
where: { colonyId: colony.id, source: 'RULE' },
|
||||||
relatedId: colony.id,
|
|
||||||
sourceAutomationTrigger: { not: null },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -261,7 +258,7 @@ describe('Data integrity (e2e)', () => {
|
|||||||
expect(after.quantity).toBe(5);
|
expect(after.quantity).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates only one open reorder task while the item stays below minimum', async () => {
|
it('proposes only one open reorder while the item stays below minimum', async () => {
|
||||||
const item = await prisma.stockItem.create({
|
const item = await prisma.stockItem.create({
|
||||||
data: {
|
data: {
|
||||||
name: `Deckel ${suffix}`,
|
name: `Deckel ${suffix}`,
|
||||||
@@ -279,11 +276,11 @@ describe('Data integrity (e2e)', () => {
|
|||||||
.expect(201);
|
.expect(201);
|
||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
await prisma.task.count({
|
await prisma.suggestion.count({
|
||||||
where: {
|
where: {
|
||||||
relatedId: item.id,
|
relatedId: item.id,
|
||||||
sourceAutomationTrigger: 'STOCK_ITEM_LOW',
|
ruleKey: 'STOCK_ITEM_LOW',
|
||||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
status: 'OPEN',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ describe('Feedings (e2e)', () => {
|
|||||||
where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } },
|
where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } },
|
||||||
});
|
});
|
||||||
await prisma.feeding.deleteMany({ where: { colonyId } });
|
await prisma.feeding.deleteMany({ where: { colonyId } });
|
||||||
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
await prisma.refreshToken.deleteMany({
|
await prisma.refreshToken.deleteMany({
|
||||||
@@ -254,6 +255,9 @@ describe('Feedings (e2e)', () => {
|
|||||||
await prisma.feeding.deleteMany({
|
await prisma.feeding.deleteMany({
|
||||||
where: { colonyId: { in: colonyIds } },
|
where: { colonyId: { in: colonyIds } },
|
||||||
});
|
});
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,16 +95,30 @@ describe('Follow-up controls (e2e)', () => {
|
|||||||
},
|
},
|
||||||
data: { active: false, config: {} },
|
data: { active: false, config: {} },
|
||||||
});
|
});
|
||||||
// Follow-up tasks are written without a user: remove their audit entry
|
// Follow-up suggestions are written without a user: remove them with
|
||||||
// and TaskCreated (deliveries go with the event, ON DELETE CASCADE).
|
// their audit entries (deliveries go with the event, ON DELETE CASCADE).
|
||||||
const followUpTaskIds = (
|
const suggestionIds = (
|
||||||
await prisma.task.findMany({
|
await prisma.suggestion.findMany({
|
||||||
where: { relatedId: { in: colonyIds } },
|
where: { colonyId: { in: colonyIds } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
).map((task) => task.id);
|
).map((suggestion) => suggestion.id);
|
||||||
await prisma.auditLog.deleteMany({
|
await prisma.auditLog.deleteMany({
|
||||||
where: { entityType: 'Task', entityId: { in: followUpTaskIds } },
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
|
||||||
|
...colonyIds.map((id) => ({
|
||||||
|
entityType: 'AutomationRule',
|
||||||
|
afterData: { path: ['relatedId'], equals: id },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.notification.deleteMany({
|
||||||
|
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: { id: { in: suggestionIds } },
|
||||||
});
|
});
|
||||||
const measureIds = [
|
const measureIds = [
|
||||||
...(await prisma.treatment.findMany({
|
...(await prisma.treatment.findMany({
|
||||||
@@ -122,9 +136,6 @@ describe('Follow-up controls (e2e)', () => {
|
|||||||
await prisma.auditLog.deleteMany({
|
await prisma.auditLog.deleteMany({
|
||||||
where: { entityId: { in: measureIds } },
|
where: { entityId: { in: measureIds } },
|
||||||
});
|
});
|
||||||
await prisma.domainEvent.deleteMany({
|
|
||||||
where: { aggregateType: 'Task', aggregateId: { in: followUpTaskIds } },
|
|
||||||
});
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,9 +147,9 @@ describe('Follow-up controls (e2e)', () => {
|
|||||||
|
|
||||||
const colony = await newColony('OFF');
|
const colony = await newColony('OFF');
|
||||||
await treat(colony.id);
|
await treat(colony.id);
|
||||||
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
|
expect(
|
||||||
0,
|
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
|
||||||
);
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('suggests a check due the configured days after the treatment ended', async () => {
|
it('suggests a check due the configured days after the treatment ended', async () => {
|
||||||
@@ -155,25 +166,30 @@ describe('Follow-up controls (e2e)', () => {
|
|||||||
endDate: '2026-08-10T00:00:00.000Z',
|
endDate: '2026-08-10T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({
|
const suggestions = await prisma.suggestion.findMany({
|
||||||
where: {
|
where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' },
|
||||||
relatedId: colony.id,
|
|
||||||
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(tasks).toHaveLength(1);
|
expect(suggestions).toHaveLength(1);
|
||||||
expect(tasks[0].dueDate?.toISOString()).toBe('2026-08-24T00:00:00.000Z');
|
expect(suggestions[0]).toMatchObject({
|
||||||
expect(tasks[0].description).toContain('Testmittel');
|
status: 'OPEN',
|
||||||
expect(tasks[0].status).toBe('OPEN');
|
intent: 'FOLLOW_UP',
|
||||||
|
reasonCode: 'TREATMENT_FOLLOW_UP',
|
||||||
|
requiredPermission: 'treatments:read',
|
||||||
|
evidence: { colonyId: colony.id, endedOn: '2026-08-10' },
|
||||||
|
});
|
||||||
|
expect(suggestions[0].dueDate?.toISOString()).toBe(
|
||||||
|
'2026-08-24T00:00:00.000Z',
|
||||||
|
);
|
||||||
|
// The rule proposes; no task exists before the suggestion is accepted.
|
||||||
|
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
// A second treatment while the check is still open adds nothing.
|
// A second treatment while the check is still open adds nothing.
|
||||||
await treat(colony.id, { date: '2026-08-12T00:00:00.000Z' });
|
await treat(colony.id, { date: '2026-08-12T00:00:00.000Z' });
|
||||||
expect(
|
expect(
|
||||||
await prisma.task.count({
|
await prisma.suggestion.count({
|
||||||
where: {
|
where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' },
|
||||||
relatedId: colony.id,
|
|
||||||
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -199,17 +215,15 @@ describe('Follow-up controls (e2e)', () => {
|
|||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
// Not before the FeedingRecorded handler ran.
|
// Not before the FeedingRecorded handler ran.
|
||||||
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
|
expect(
|
||||||
0,
|
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
|
||||||
);
|
).toBe(0);
|
||||||
await drainDomainEvents(app, [(response.body as { id: string }).id]);
|
await drainDomainEvents(app, [(response.body as { id: string }).id]);
|
||||||
|
|
||||||
const task = await prisma.task.findFirstOrThrow({
|
const suggestion = await prisma.suggestion.findFirstOrThrow({
|
||||||
where: {
|
where: { colonyId: colony.id, ruleKey: 'FEEDING_FOLLOW_UP' },
|
||||||
relatedId: colony.id,
|
|
||||||
sourceAutomationTrigger: 'FEEDING_FOLLOW_UP',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(task.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
|
expect(suggestion.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
|
||||||
|
expect(suggestion.evidence).toMatchObject({ fedOn: '2026-09-01' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { login } from './utils/api';
|
||||||
|
|
||||||
|
interface TakeoverBody {
|
||||||
|
id: string;
|
||||||
|
colonyTakeover: {
|
||||||
|
applied: string[];
|
||||||
|
skipped: { field: string; reason: string }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Inspection takeover (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = `${Date.now()}`;
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
let staffToken: string;
|
||||||
|
let narrowToken: string;
|
||||||
|
let staffId: string;
|
||||||
|
let narrowId: string;
|
||||||
|
let narrowRoleId: string;
|
||||||
|
let apiaryId: string;
|
||||||
|
const colonyIds: string[] = [];
|
||||||
|
// One colony per test: no test depends on what another one wrote.
|
||||||
|
let colonyId: string;
|
||||||
|
|
||||||
|
const post = (token: string, body: object) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post('/api/v1/inspections')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ colonyId, ...body });
|
||||||
|
|
||||||
|
const colony = () =>
|
||||||
|
prisma.colony.findUniqueOrThrow({ where: { id: colonyId } });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const staff = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `takeover-staff-${suffix}@example.com`,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Admin',
|
||||||
|
role: { connect: { key: 'ADMIN' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
staffId = staff.id;
|
||||||
|
const narrowRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `TAKEOVER_NARROW_${suffix}`,
|
||||||
|
name: 'Nur Durchsicht',
|
||||||
|
permissions: {
|
||||||
|
create: ['inspections:create', 'colonies:read'].map((key) => ({
|
||||||
|
permission: { connect: { key } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
narrowRoleId = narrowRole.id;
|
||||||
|
const narrow = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `takeover-narrow-${suffix}@example.com`,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Narrow',
|
||||||
|
roleId: narrowRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
narrowId = narrow.id;
|
||||||
|
staffToken = (await login(app, staff.email, password)).accessToken;
|
||||||
|
narrowToken = (await login(app, narrow.email, password)).accessToken;
|
||||||
|
apiaryId = (
|
||||||
|
await prisma.apiary.create({ data: { name: `Takeover ${suffix}` } })
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const created = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `TK-${suffix}-${colonyIds.length}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
strength: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
colonyId = created.id;
|
||||||
|
colonyIds.push(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const ids = (
|
||||||
|
await prisma.inspection.findMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((row) => row.id);
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ aggregateType: 'Inspection', aggregateId: { in: ids } },
|
||||||
|
{ aggregateType: 'Colony', aggregateId: { in: colonyIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||||
|
where: { sourceType: 'INSPECTION', sourceId: { in: ids } },
|
||||||
|
});
|
||||||
|
await prisma.inspection.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||||
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
|
const userIds = [staffId, narrowId];
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await prisma.role.deleteMany({ where: { id: narrowRoleId } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes over feed and brood in the same request and writes an audit entry Colony UPDATE', async () => {
|
||||||
|
const response = await post(staffToken, {
|
||||||
|
date: '2026-06-01T10:00:00.000Z',
|
||||||
|
feedStatus: 'WEAK',
|
||||||
|
broodPattern: 4,
|
||||||
|
colonyStrength: 18,
|
||||||
|
applyToColony: ['feedStatus', 'broodStatus', 'occupiedFrames'],
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
const body = response.body as TakeoverBody;
|
||||||
|
expect(body.colonyTakeover.applied).toEqual([
|
||||||
|
'feedStatus',
|
||||||
|
'broodStatus',
|
||||||
|
'occupiedFrames',
|
||||||
|
]);
|
||||||
|
expect(body.colonyTakeover.skipped).toEqual([]);
|
||||||
|
expect(await colony()).toMatchObject({
|
||||||
|
feedStatus: 'WEAK',
|
||||||
|
broodStatus: 'GOOD',
|
||||||
|
occupiedFrames: 18,
|
||||||
|
strength: 6,
|
||||||
|
});
|
||||||
|
const audit = await prisma.auditLog.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
entityType: 'Colony',
|
||||||
|
entityId: colonyId,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: staffId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audit.beforeData).toMatchObject({
|
||||||
|
feedStatus: null,
|
||||||
|
broodStatus: null,
|
||||||
|
occupiedFrames: null,
|
||||||
|
});
|
||||||
|
expect(audit.afterData).toMatchObject({
|
||||||
|
feedStatus: 'WEAK',
|
||||||
|
broodStatus: 'GOOD',
|
||||||
|
occupiedFrames: 18,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips with NEWER_INSPECTION when a newer inspection of the colony exists', async () => {
|
||||||
|
await post(staffToken, { date: '2026-06-01T10:00:00.000Z' }).expect(201);
|
||||||
|
|
||||||
|
const response = await post(staffToken, {
|
||||||
|
date: '2026-05-01T10:00:00.000Z',
|
||||||
|
colonyStrength: 22,
|
||||||
|
applyToColony: ['occupiedFrames'],
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
const body = response.body as TakeoverBody;
|
||||||
|
expect(body.colonyTakeover.applied).toEqual([]);
|
||||||
|
expect(body.colonyTakeover.skipped).toEqual([
|
||||||
|
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
|
||||||
|
]);
|
||||||
|
expect((await colony()).occupiedFrames).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 201 and skips with NO_PERMISSION without colonies:update', async () => {
|
||||||
|
const response = await post(narrowToken, {
|
||||||
|
date: '2026-07-01T10:00:00.000Z',
|
||||||
|
colonyStrength: 25,
|
||||||
|
applyToColony: ['occupiedFrames'],
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
const body = response.body as TakeoverBody;
|
||||||
|
expect(body.colonyTakeover.skipped).toEqual([
|
||||||
|
{ field: 'occupiedFrames', reason: 'NO_PERMISSION' },
|
||||||
|
]);
|
||||||
|
expect((await colony()).occupiedFrames).toBeNull();
|
||||||
|
expect(await prisma.inspection.count({ where: { id: body.id } })).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips with COLONY_ENDED for a dissolved colony and still saves the inspection', async () => {
|
||||||
|
await prisma.colony.update({
|
||||||
|
where: { id: colonyId },
|
||||||
|
data: { status: 'DISSOLVED', deactivatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await post(staffToken, {
|
||||||
|
date: '2026-07-01T10:00:00.000Z',
|
||||||
|
colonyStrength: 12,
|
||||||
|
applyToColony: ['occupiedFrames'],
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
const body = response.body as TakeoverBody;
|
||||||
|
expect(body.colonyTakeover.skipped).toEqual([
|
||||||
|
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
|
||||||
|
]);
|
||||||
|
expect(await prisma.inspection.count({ where: { id: body.id } })).toBe(1);
|
||||||
|
expect((await colony()).occupiedFrames).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes values only from the new inspection, never from an older stored row (even an out-of-range legacy value)', async () => {
|
||||||
|
await prisma.inspection.create({
|
||||||
|
data: {
|
||||||
|
colonyId,
|
||||||
|
date: new Date('2026-04-01T10:00:00.000Z'),
|
||||||
|
colonyStrength: 45,
|
||||||
|
userId: staffId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await post(staffToken, {
|
||||||
|
date: '2026-06-01T10:00:00.000Z',
|
||||||
|
applyToColony: ['occupiedFrames'],
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
expect((response.body as TakeoverBody).colonyTakeover.skipped).toEqual([
|
||||||
|
{ field: 'occupiedFrames', reason: 'NO_VALUE' },
|
||||||
|
]);
|
||||||
|
expect((await colony()).occupiedFrames).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a correction (PATCH) takes nothing over', async () => {
|
||||||
|
const created = await post(staffToken, {
|
||||||
|
date: '2026-06-01T10:00:00.000Z',
|
||||||
|
}).expect(201);
|
||||||
|
const inspectionId = (created.body as TakeoverBody).id;
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/inspections/${inspectionId}`)
|
||||||
|
.set('Authorization', `Bearer ${staffToken}`)
|
||||||
|
.send({ colonyStrength: 30 })
|
||||||
|
.expect(200);
|
||||||
|
expect((await colony()).occupiedFrames).toBeNull();
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.patch(`/api/v1/inspections/${inspectionId}`)
|
||||||
|
.set('Authorization', `Bearer ${staffToken}`)
|
||||||
|
.send({ applyToColony: ['occupiedFrames'] })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[['feedStatus', 'broodStatus', 'occupiedFrames', 'feedStatus']],
|
||||||
|
[['feedStatus', 'feedStatus']],
|
||||||
|
[['nonsense']],
|
||||||
|
])('applyToColony %p answers 400', async (applyToColony) => {
|
||||||
|
await post(staffToken, {
|
||||||
|
date: '2026-08-01T10:00:00.000Z',
|
||||||
|
applyToColony,
|
||||||
|
}).expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,6 +98,7 @@ describe('Inspections (e2e)', () => {
|
|||||||
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
|
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
|
||||||
});
|
});
|
||||||
await prisma.inspection.deleteMany({ where: { colonyId } });
|
await prisma.inspection.deleteMany({ where: { colonyId } });
|
||||||
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
await prisma.refreshToken.deleteMany({
|
await prisma.refreshToken.deleteMany({
|
||||||
@@ -159,7 +160,7 @@ describe('Inspections (e2e)', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records InspectionCorrected for an edit, whose only follow-up is the calendar', async () => {
|
it('records InspectionCorrected for an edit, whose follow-ups are the calendar and the autopilot', async () => {
|
||||||
await drainDomainEvents(app, [inspectionId]);
|
await drainDomainEvents(app, [inspectionId]);
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
@@ -188,11 +189,17 @@ describe('Inspections (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await drainDomainEvents(app, [inspectionId]);
|
await drainDomainEvents(app, [inspectionId]);
|
||||||
// A correction evaluates no automation rule and sends no webhook.
|
// A correction evaluates no automation rule and sends no webhook; the
|
||||||
|
// calendar and the autopilot of the colony follow.
|
||||||
const deliveries = await prisma.domainEventDelivery.findMany({
|
const deliveries = await prisma.domainEventDelivery.findMany({
|
||||||
where: { eventId: correction.id },
|
where: { eventId: correction.id },
|
||||||
|
orderBy: { handler: 'asc' },
|
||||||
});
|
});
|
||||||
expect(deliveries).toEqual([
|
expect(deliveries).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
handler: 'autopilot.colony-sync',
|
||||||
|
status: 'DONE',
|
||||||
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
handler: 'calendar-sync.inspection',
|
handler: 'calendar-sync.inspection',
|
||||||
status: 'DONE',
|
status: 'DONE',
|
||||||
|
|||||||
@@ -150,6 +150,38 @@ describe('Notifications (e2e)', () => {
|
|||||||
expect(listed).not.toContain(hidden.id);
|
expect(listed).not.toContain(hidden.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a row the user may not see cannot be read, marked or counted', async () => {
|
||||||
|
const hidden = await seedNotification(ids.bare, {
|
||||||
|
requiredPermissions: ['colonies:read'],
|
||||||
|
});
|
||||||
|
const open = await seedNotification(ids.bare);
|
||||||
|
|
||||||
|
await request(server())
|
||||||
|
.post(`/api/v1/notifications/${hidden.id}/read`)
|
||||||
|
.set(as('bare'))
|
||||||
|
.expect(404);
|
||||||
|
|
||||||
|
const response = await request(server())
|
||||||
|
.post('/api/v1/notifications/read-all')
|
||||||
|
.set(as('bare'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(response.body as { updated: number }).updated,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(
|
||||||
|
(await prisma.notification.findUniqueOrThrow({ where: { id: open.id } }))
|
||||||
|
.read,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.notification.findUniqueOrThrow({
|
||||||
|
where: { id: hidden.id },
|
||||||
|
})
|
||||||
|
).read,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('reading is per user: marking one copy read leaves the other unread', async () => {
|
it('reading is per user: marking one copy read leaves the other unread', async () => {
|
||||||
const [first, second] = await notifications.recordForUsers(
|
const [first, second] = await notifications.recordForUsers(
|
||||||
[ids.beekeeper, ids.staff],
|
[ids.beekeeper, ids.staff],
|
||||||
|
|||||||
@@ -55,20 +55,31 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
return colony;
|
return colony;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open or not: a rule task that exists at all. */
|
/** Open or not: a rule suggestion that exists at all. */
|
||||||
const taskCount = (trigger: AutomationTriggerType, relatedId: string) =>
|
const suggestionCount = (ruleKey: AutomationTriggerType, relatedId: string) =>
|
||||||
prisma.task.count({
|
prisma.suggestion.count({ where: { ruleKey, relatedId } });
|
||||||
where: { sourceAutomationTrigger: trigger, relatedId },
|
|
||||||
|
const openSuggestionCount = (
|
||||||
|
ruleKey: AutomationTriggerType,
|
||||||
|
relatedId: string,
|
||||||
|
) =>
|
||||||
|
prisma.suggestion.count({
|
||||||
|
where: { ruleKey, relatedId, status: 'OPEN' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const openTaskCount = (trigger: AutomationTriggerType, relatedId: string) =>
|
/** The notifications written for the suggestions of the given objects. */
|
||||||
prisma.task.count({
|
async function suggestionNotifications(relatedIds: string[]) {
|
||||||
where: {
|
const ids = (
|
||||||
sourceAutomationTrigger: trigger,
|
await prisma.suggestion.findMany({
|
||||||
relatedId,
|
where: { relatedId: { in: relatedIds } },
|
||||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
select: { id: true },
|
||||||
},
|
})
|
||||||
|
).map((suggestion) => suggestion.id);
|
||||||
|
return prisma.notification.findMany({
|
||||||
|
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
|
||||||
|
select: { userId: true },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Everything this file wrote, scoped by its own ids. */
|
/** Everything this file wrote, scoped by its own ids. */
|
||||||
async function cleanUp(): Promise<void> {
|
async function cleanUp(): Promise<void> {
|
||||||
@@ -86,13 +97,13 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
.flat()
|
.flat()
|
||||||
.map((record) => record.id);
|
.map((record) => record.id);
|
||||||
const relatedIds = [...colonyIds, ...stockItemIds];
|
const relatedIds = [...colonyIds, ...stockItemIds];
|
||||||
const taskIds = (
|
const suggestionIds = (
|
||||||
await prisma.task.findMany({
|
await prisma.suggestion.findMany({
|
||||||
where: { relatedId: { in: relatedIds } },
|
where: { relatedId: { in: relatedIds } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
).map((task) => task.id);
|
).map((suggestion) => suggestion.id);
|
||||||
const aggregateIds = [...recordIds, ...taskIds];
|
const aggregateIds = recordIds;
|
||||||
// Deliveries go with their event (ON DELETE CASCADE).
|
// Deliveries go with their event (ON DELETE CASCADE).
|
||||||
await prisma.domainEvent.deleteMany({
|
await prisma.domainEvent.deleteMany({
|
||||||
where: { aggregateId: { in: aggregateIds } },
|
where: { aggregateId: { in: aggregateIds } },
|
||||||
@@ -100,12 +111,12 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
await prisma.calendarSyncOutboxEntry.deleteMany({
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||||
where: { sourceId: { in: aggregateIds } },
|
where: { sourceId: { in: aggregateIds } },
|
||||||
});
|
});
|
||||||
// Rule tasks and the rules' own entries are written without a user.
|
// Rule suggestions and the rules' own entries are written without a user.
|
||||||
await prisma.auditLog.deleteMany({
|
await prisma.auditLog.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ userId },
|
{ userId },
|
||||||
{ entityType: 'Task', entityId: { in: taskIds } },
|
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
|
||||||
...relatedIds.map((relatedId) => ({
|
...relatedIds.map((relatedId) => ({
|
||||||
entityType: 'AutomationRule',
|
entityType: 'AutomationRule',
|
||||||
afterData: { path: ['relatedId'], equals: relatedId },
|
afterData: { path: ['relatedId'], equals: relatedId },
|
||||||
@@ -114,9 +125,11 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.notification.deleteMany({
|
await prisma.notification.deleteMany({
|
||||||
where: { relatedId: { in: relatedIds } },
|
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: { id: { in: suggestionIds } },
|
||||||
});
|
});
|
||||||
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
|
|
||||||
await prisma.inspection.deleteMany({ where: byColony.where });
|
await prisma.inspection.deleteMany({ where: byColony.where });
|
||||||
await prisma.treatment.deleteMany({ where: byColony.where });
|
await prisma.treatment.deleteMany({ where: byColony.where });
|
||||||
await prisma.varroaMeasurement.deleteMany({ where: byColony.where });
|
await prisma.varroaMeasurement.deleteMany({ where: byColony.where });
|
||||||
@@ -254,13 +267,11 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
attempts: 0,
|
attempts: 0,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
// No rule ran in the request, not even one that failed: the task
|
// No rule ran in the request, not even one that failed: the suggestion
|
||||||
// follows when the handler runs.
|
// follows when the handler runs.
|
||||||
expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]);
|
expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]);
|
||||||
expect(await taskCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
|
expect(await suggestionCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
|
||||||
expect(
|
expect(await suggestionNotifications([colony.id])).toEqual([]);
|
||||||
await prisma.notification.count({ where: { relatedId: colony.id } }),
|
|
||||||
).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => {
|
it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => {
|
||||||
@@ -286,7 +297,7 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when an automation handler fails', () => {
|
describe('when an automation handler fails', () => {
|
||||||
it('creates exactly one automation task when the handler fails once and is retried', async () => {
|
it('creates exactly one automation suggestion when the handler fails once and is retried', async () => {
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
const colonyId = (await createColony('PC-RETRY')).id;
|
const colonyId = (await createColony('PC-RETRY')).id;
|
||||||
jest
|
jest
|
||||||
@@ -302,20 +313,16 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
|
|
||||||
await drainDomainEvents(app, [body.id]);
|
await drainDomainEvents(app, [body.id]);
|
||||||
// The warning failed, so the whole evaluation rolled back.
|
// The warning failed, so the whole evaluation rolled back.
|
||||||
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
|
expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
|
||||||
expect(
|
expect(await suggestionNotifications([colonyId])).toEqual([]);
|
||||||
await prisma.notification.count({ where: { relatedId: colonyId } }),
|
|
||||||
).toBe(0);
|
|
||||||
|
|
||||||
// Due again now instead of after the 30 s backoff.
|
// Due again now instead of after the 30 s backoff.
|
||||||
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
|
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
|
||||||
await drainDomainEvents(app, [body.id]);
|
await drainDomainEvents(app, [body.id]);
|
||||||
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
|
expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
|
||||||
// One copy per recipient (everybody who may read tasks), written once.
|
// One copy per recipient (everybody who may read tasks and
|
||||||
const warnings = await prisma.notification.findMany({
|
// inspections), written once.
|
||||||
where: { relatedId: colonyId },
|
const warnings = await suggestionNotifications([colonyId]);
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
expect(warnings.length).toBeGreaterThan(0);
|
expect(warnings.length).toBeGreaterThan(0);
|
||||||
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
|
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
|
||||||
|
|
||||||
@@ -337,11 +344,11 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('automation tasks', () => {
|
describe('automation suggestions', () => {
|
||||||
it('leaves one open reorder task when several triggers for the same stock item arrive at once', async () => {
|
it('leaves one open reorder suggestion when several triggers for the same stock item arrive at once', async () => {
|
||||||
const automation = app.get(AutomationService);
|
const automation = app.get(AutomationService);
|
||||||
// The same object crossing the threshold on several pods/requests at
|
// The same object crossing the threshold on several pods/requests at
|
||||||
// once: every evaluation sees "no open task yet" unless checking and
|
// once: every evaluation sees "nothing open yet" unless checking and
|
||||||
// creating are serialised.
|
// creating are serialised.
|
||||||
const item = await prisma.stockItem.create({
|
const item = await prisma.stockItem.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -358,10 +365,10 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
Array.from({ length: 8 }, () => automation.evaluateStockItem(item)),
|
Array.from({ length: 8 }, () => automation.evaluateStockItem(item)),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await openTaskCount('STOCK_ITEM_LOW', item.id)).toBe(1);
|
expect(await openSuggestionCount('STOCK_ITEM_LOW', item.id)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves one open task when several triggers for the same colony arrive at once', async () => {
|
it('leaves one open suggestion when several triggers for the same colony arrive at once', async () => {
|
||||||
const colony = await createColony('PC-RACE');
|
const colony = await createColony('PC-RACE');
|
||||||
const measurement = {
|
const measurement = {
|
||||||
colonyId: colony.id,
|
colonyId: colony.id,
|
||||||
@@ -384,7 +391,7 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(await openTaskCount('VARROA_THRESHOLD', colony.id)).toBe(1);
|
expect(await openSuggestionCount('VARROA_THRESHOLD', colony.id)).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -59,8 +59,8 @@ describe('Raw materials and production (e2e)', () => {
|
|||||||
let propolisId: string;
|
let propolisId: string;
|
||||||
let productId: string;
|
let productId: string;
|
||||||
let batchId: string;
|
let batchId: string;
|
||||||
// Tasks the RAW_MATERIAL_LOW handler created for this file.
|
// Suggestions the RAW_MATERIAL_LOW handler created for this file.
|
||||||
const ruleTaskIds: string[] = [];
|
const ruleSuggestionIds: string[] = [];
|
||||||
|
|
||||||
const server = () => app.getHttpServer();
|
const server = () => app.getHttpServer();
|
||||||
const auth = (req: request.Test) =>
|
const auth = (req: request.Test) =>
|
||||||
@@ -133,43 +133,35 @@ describe('Raw materials and production (e2e)', () => {
|
|||||||
})
|
})
|
||||||
).map((movement) => movement.id);
|
).map((movement) => movement.id);
|
||||||
const batchIds = [batchId].filter(Boolean);
|
const batchIds = [batchId].filter(Boolean);
|
||||||
// Rule tasks are written without a user: their TaskCreated, audit
|
// Rule suggestions are written without a user: their audit entries and
|
||||||
// entries, warning and queued deliveries go by task id.
|
// warnings go by suggestion id.
|
||||||
for (const taskId of ruleTaskIds) {
|
await prisma.auditLog.deleteMany({
|
||||||
await prisma.auditLog.deleteMany({
|
where: {
|
||||||
where: {
|
OR: [
|
||||||
entityType: 'AutomationRule',
|
{ entityType: 'Suggestion', entityId: { in: ruleSuggestionIds } },
|
||||||
afterData: { path: ['taskId'], equals: taskId },
|
...ruleSuggestionIds.map((suggestionId) => ({
|
||||||
},
|
entityType: 'AutomationRule',
|
||||||
});
|
afterData: { path: ['suggestionId'], equals: suggestionId },
|
||||||
await prisma.webhookDelivery.deleteMany({
|
})),
|
||||||
where: { event: 'task.created', body: { contains: taskId } },
|
],
|
||||||
});
|
},
|
||||||
}
|
|
||||||
await prisma.calendarSyncOutboxEntry.deleteMany({
|
|
||||||
where: { sourceId: { in: ruleTaskIds } },
|
|
||||||
});
|
});
|
||||||
// Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer
|
await prisma.notification.deleteMany({
|
||||||
// of GENERAL/PROPOLIS warnings, and only this file switches the rule on
|
where: {
|
||||||
// (default: off, no minimum) - so such a warning since this file
|
relatedType: 'Suggestion',
|
||||||
// started belongs to the task above.
|
relatedId: { in: ruleSuggestionIds },
|
||||||
if (ruleTaskIds.length > 0) {
|
},
|
||||||
await prisma.notification.deleteMany({
|
});
|
||||||
where: {
|
await prisma.suggestion.deleteMany({
|
||||||
relatedType: 'GENERAL',
|
where: { id: { in: ruleSuggestionIds } },
|
||||||
relatedId: 'PROPOLIS',
|
});
|
||||||
createdAt: { gte: startedAt },
|
const aggregateIds = [...batchIds, ...movementIds];
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds];
|
|
||||||
await prisma.domainEvent.deleteMany({
|
await prisma.domainEvent.deleteMany({
|
||||||
where: { aggregateId: { in: aggregateIds } },
|
where: { aggregateId: { in: aggregateIds } },
|
||||||
});
|
});
|
||||||
await prisma.auditLog.deleteMany({
|
await prisma.auditLog.deleteMany({
|
||||||
where: { entityId: { in: [...lotIds, ...aggregateIds] } },
|
where: { entityId: { in: [...lotIds, ...aggregateIds] } },
|
||||||
});
|
});
|
||||||
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,22 +355,22 @@ describe('Raw materials and production (e2e)', () => {
|
|||||||
).expect(409);
|
).expect(409);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => {
|
it('proposes the RAW_MATERIAL_LOW reorder only once its StockChanged event is dispatched', async () => {
|
||||||
// The rule is shared by every e2e file: switch it on with a minimum no
|
// The rule is shared by every e2e file: switch it on with a minimum no
|
||||||
// test database reaches, and put it back right after.
|
// test database reaches, and put it back right after.
|
||||||
const before = await prisma.automationRule.findUnique({
|
const before = await prisma.automationRule.findUnique({
|
||||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||||
});
|
});
|
||||||
const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } };
|
const config = { minGrams: { PROPOLIS: 1_000_000_000 } };
|
||||||
// One open task per kind: a leftover from an aborted earlier run would
|
// One open suggestion per kind: a leftover from an aborted earlier run
|
||||||
// keep the handler from creating a new one - close it first.
|
// would keep the handler from proposing a new one - settle it first.
|
||||||
await prisma.task.updateMany({
|
await prisma.suggestion.updateMany({
|
||||||
where: {
|
where: {
|
||||||
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
|
ruleKey: 'RAW_MATERIAL_LOW',
|
||||||
relatedId: 'PROPOLIS',
|
relatedId: 'PROPOLIS',
|
||||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
status: 'OPEN',
|
||||||
},
|
},
|
||||||
data: { status: 'DONE', completedAt: new Date() },
|
data: { status: 'DISMISSED' },
|
||||||
});
|
});
|
||||||
await prisma.automationRule.upsert({
|
await prisma.automationRule.upsert({
|
||||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||||
@@ -400,23 +392,35 @@ describe('Raw materials and production (e2e)', () => {
|
|||||||
.expect(201);
|
.expect(201);
|
||||||
// Newest first: the withdrawal just booked.
|
// Newest first: the withdrawal just booked.
|
||||||
const [movement] = (response.body as StockBody).movements;
|
const [movement] = (response.body as StockBody).movements;
|
||||||
const [event] = await prisma.domainEvent.findMany({
|
const suggestionsOfThisChange = () =>
|
||||||
where: { type: 'StockChanged', aggregateId: movement.id },
|
prisma.suggestion.findMany({
|
||||||
select: { correlationId: true },
|
where: {
|
||||||
});
|
ruleKey: 'RAW_MATERIAL_LOW',
|
||||||
const ruleTasksOfThisChange = async () =>
|
relatedId: 'PROPOLIS',
|
||||||
(
|
createdAt: { gte: startedAt },
|
||||||
await prisma.domainEvent.findMany({
|
},
|
||||||
where: {
|
});
|
||||||
type: 'TaskCreated',
|
|
||||||
correlationId: event.correlationId,
|
|
||||||
},
|
|
||||||
select: { aggregateId: true },
|
|
||||||
})
|
|
||||||
).map((created) => created.aggregateId);
|
|
||||||
|
|
||||||
// The request itself creates no task - the handler does.
|
// The request itself proposes nothing - the handler does.
|
||||||
expect(await ruleTasksOfThisChange()).toEqual([]);
|
expect(await suggestionsOfThisChange()).toEqual([]);
|
||||||
|
|
||||||
|
await drainDomainEvents(app, [movement.id]);
|
||||||
|
|
||||||
|
const suggestions = await suggestionsOfThisChange();
|
||||||
|
ruleSuggestionIds.push(...suggestions.map((found) => found.id));
|
||||||
|
expect(suggestions).toHaveLength(1);
|
||||||
|
expect(suggestions[0]).toMatchObject({
|
||||||
|
ruleKey: 'RAW_MATERIAL_LOW',
|
||||||
|
intent: 'STOCK_REORDER',
|
||||||
|
reasonCode: 'RAW_MATERIAL_LOW',
|
||||||
|
relatedType: 'GENERAL',
|
||||||
|
relatedId: 'PROPOLIS',
|
||||||
|
colonyId: null,
|
||||||
|
requiredPermission: 'raw-material:read',
|
||||||
|
status: 'OPEN',
|
||||||
|
dedupeKey: `RAW_MATERIAL_LOW:PROPOLIS:${propolisId}`,
|
||||||
|
});
|
||||||
|
// The request creates no task either.
|
||||||
expect(
|
expect(
|
||||||
await prisma.task.count({
|
await prisma.task.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -426,20 +430,6 @@ describe('Raw materials and production (e2e)', () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
|
|
||||||
await drainDomainEvents(app, [movement.id]);
|
|
||||||
|
|
||||||
const taskIds = await ruleTasksOfThisChange();
|
|
||||||
ruleTaskIds.push(...taskIds);
|
|
||||||
expect(taskIds).toHaveLength(1);
|
|
||||||
expect(
|
|
||||||
await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }),
|
|
||||||
).toMatchObject({
|
|
||||||
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
|
|
||||||
relatedType: 'GENERAL',
|
|
||||||
relatedId: 'PROPOLIS',
|
|
||||||
status: 'OPEN',
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
// Leave the rule as found: restored, or gone again if this test
|
// Leave the rule as found: restored, or gone again if this test
|
||||||
// created it (ensureDefaults then recreates it with its defaults).
|
// created it (ensureDefaults then recreates it with its defaults).
|
||||||
|
|||||||
@@ -245,30 +245,20 @@ describe('Suggestions API (e2e)', () => {
|
|||||||
expect(other.body).toEqual([]);
|
expect(other.body).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => {
|
it('a user without varroa:read does not get the varroa suggestion in the list', async () => {
|
||||||
await proposeForTest(app, {
|
const created = await proposeForTest(app, {
|
||||||
...proposal('v1'),
|
...proposal('v1'),
|
||||||
ruleKey: 'VARROA_THRESHOLD',
|
ruleKey: 'VARROA_THRESHOLD',
|
||||||
intent: 'VARROA_TREAT',
|
intent: 'VARROA_TREAT',
|
||||||
requiredPermission: 'varroa:read',
|
requiredPermission: 'varroa:read',
|
||||||
});
|
});
|
||||||
|
|
||||||
const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`);
|
const hidden = (await get(tokens.noVarroa, `?colonyId=${colonyId}`))
|
||||||
const hiddenSummary = await get(tokens.noVarroa, '/summary');
|
.body as ViewBody[];
|
||||||
const seen = await get(tokens.full, `?colonyId=${colonyId}`);
|
const seen = (await get(tokens.full, `?colonyId=${colonyId}`))
|
||||||
const seenSummary = await get(tokens.full, '/summary');
|
.body as ViewBody[];
|
||||||
|
expect(hidden.map((view) => view.id)).not.toContain(created.id);
|
||||||
expect(hidden.body).toEqual([]);
|
expect(seen.map((view) => view.id)).toContain(created.id);
|
||||||
expect((hiddenSummary.body as { open: number }).open).toBe(0);
|
|
||||||
expect(seen.body).toHaveLength(1);
|
|
||||||
expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual(
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
(seenSummary.body as { byPriority: Record<string, number> }).byPriority,
|
|
||||||
).toEqual(
|
|
||||||
expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('answers 404 to a decision on a suggestion the user may not see', async () => {
|
it('answers 404 to a decision on a suggestion the user may not see', async () => {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
import { SuggestionsService } from '../src/suggestions/suggestions.service';
|
import { SuggestionsService } from '../src/suggestions/suggestions.service';
|
||||||
import type { ProposeInput } from '../src/suggestions/suggestion.types';
|
import type { ProposeInput } from '../src/suggestions/suggestion.types';
|
||||||
|
import { berlinToday } from '../src/common/dates';
|
||||||
|
import { login } from './utils/api';
|
||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { drainDomainEvents } from './utils/domain-events';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
|
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
|
||||||
@@ -17,6 +22,9 @@ describe('Suggestions (e2e)', () => {
|
|||||||
let apiaryId: string;
|
let apiaryId: string;
|
||||||
let colonyId: string;
|
let colonyId: string;
|
||||||
let userId: string;
|
let userId: string;
|
||||||
|
const roleIds: string[] = [];
|
||||||
|
const extraUserIds: string[] = [];
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
const proposal = (anchor: string): ProposeInput => ({
|
const proposal = (anchor: string): ProposeInput => ({
|
||||||
ruleKey: 'SPLIT_SCHEDULE',
|
ruleKey: 'SPLIT_SCHEDULE',
|
||||||
source: 'CHAIN',
|
source: 'CHAIN',
|
||||||
@@ -69,23 +77,64 @@ describe('Suggestions (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await prisma.task.deleteMany({
|
await clearSuggestions();
|
||||||
where: { sourceSuggestion: { colonyId } },
|
const inspectionIds = (
|
||||||
|
await prisma.inspection.findMany({
|
||||||
|
where: { colonyId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((inspection) => inspection.id);
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateId: { in: inspectionIds } },
|
||||||
});
|
});
|
||||||
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||||
|
where: { sourceId: { in: inspectionIds } },
|
||||||
|
});
|
||||||
|
await prisma.inspection.deleteMany({ where: { colonyId } });
|
||||||
await prisma.colony.delete({ where: { id: colonyId } });
|
await prisma.colony.delete({ where: { id: colonyId } });
|
||||||
await prisma.apiary.delete({ where: { id: apiaryId } });
|
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
await prisma.refreshToken.deleteMany({
|
||||||
await prisma.user.delete({ where: { id: userId } });
|
where: { userId: { in: extraUserIds } },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({
|
||||||
|
where: { userId: { in: [userId, ...extraUserIds] } },
|
||||||
|
});
|
||||||
|
await prisma.user.deleteMany({
|
||||||
|
where: { id: { in: [userId, ...extraUserIds] } },
|
||||||
|
});
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
/** The colony's suggestions with what hangs on them: tasks, notifications, audit. */
|
||||||
|
async function clearSuggestions(): Promise<void> {
|
||||||
|
const ids = (
|
||||||
|
await prisma.suggestion.findMany({
|
||||||
|
where: { colonyId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((suggestion) => suggestion.id);
|
||||||
await prisma.task.deleteMany({
|
await prisma.task.deleteMany({
|
||||||
where: { sourceSuggestion: { colonyId } },
|
where: { sourceSuggestion: { colonyId } },
|
||||||
});
|
});
|
||||||
|
await prisma.notification.deleteMany({
|
||||||
|
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ entityType: 'Suggestion', entityId: { in: ids } },
|
||||||
|
{
|
||||||
|
entityType: 'AutomationRule',
|
||||||
|
afterData: { path: ['relatedId'], equals: colonyId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
});
|
}
|
||||||
|
|
||||||
|
afterEach(clearSuggestions);
|
||||||
|
|
||||||
it('proposes once per anchor and writes an audit entry', async () => {
|
it('proposes once per anchor and writes an audit entry', async () => {
|
||||||
const created = await propose(proposal('a1'));
|
const created = await propose(proposal('a1'));
|
||||||
@@ -412,4 +461,179 @@ describe('Suggestions (e2e)', () => {
|
|||||||
expect(obsolete.status).toBe('OBSOLETE');
|
expect(obsolete.status).toBe('OBSOLETE');
|
||||||
expect(obsolete.dedupeKey).not.toBeNull();
|
expect(obsolete.dedupeKey).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
describe('rule suggestions', () => {
|
||||||
|
let writer: { id: string; token: string };
|
||||||
|
let withoutInspections: { id: string };
|
||||||
|
let withoutTasks: { id: string };
|
||||||
|
|
||||||
|
async function createUser(
|
||||||
|
label: string,
|
||||||
|
permissions: string[],
|
||||||
|
): Promise<{ id: string; token: string }> {
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `SUG_RULES_${label.toUpperCase()}_${suffix}`,
|
||||||
|
name: `Suggestions rules ${label}`,
|
||||||
|
permissions: {
|
||||||
|
create: permissions.map((key) => ({
|
||||||
|
permission: { connect: { key } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
roleIds.push(role.id);
|
||||||
|
const email = `suggestions-rules-${label}-${suffix}@example.com`;
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
name: `Suggestions rules ${label}`,
|
||||||
|
role: { connect: { id: role.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
extraUserIds.push(user.id);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
token: (await login(app, email, password)).accessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
writer = await createUser('writer', [
|
||||||
|
'inspections:create',
|
||||||
|
'inspections:read',
|
||||||
|
'tasks:read',
|
||||||
|
]);
|
||||||
|
withoutInspections = await createUser('noinsp', ['tasks:read']);
|
||||||
|
withoutTasks = await createUser('notasks', ['inspections:read']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Records an inspection and runs the handlers of its event. */
|
||||||
|
async function inspect(body: object): Promise<string> {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/inspections')
|
||||||
|
.set('Authorization', `Bearer ${writer.token}`)
|
||||||
|
.send({ colonyId, date: new Date().toISOString(), ...body })
|
||||||
|
.expect(201);
|
||||||
|
const id = (response.body as { id: string }).id;
|
||||||
|
await drainDomainEvents(app, [id]);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queenless = { queenSeen: false, eggsPresent: false };
|
||||||
|
const ruleSuggestions = () =>
|
||||||
|
prisma.suggestion.findMany({
|
||||||
|
where: { colonyId, ruleKey: 'QUEENLESS_NO_EGGS' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an inspection without queen and eggs leaves one OPEN suggestion and no task', async () => {
|
||||||
|
const inspectionId = await inspect(queenless);
|
||||||
|
|
||||||
|
const suggestions = await ruleSuggestions();
|
||||||
|
expect(suggestions).toHaveLength(1);
|
||||||
|
expect(suggestions[0]).toMatchObject({
|
||||||
|
status: 'OPEN',
|
||||||
|
source: 'RULE',
|
||||||
|
mode: 'PLAN',
|
||||||
|
intent: 'QUEEN_CHECK',
|
||||||
|
dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`,
|
||||||
|
});
|
||||||
|
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a dismissed suggestion is not proposed again for the same inspection', async () => {
|
||||||
|
const inspectionId = await inspect(queenless);
|
||||||
|
const [first] = await ruleSuggestions();
|
||||||
|
await service.dismiss(first.id, null, userId);
|
||||||
|
|
||||||
|
// The delivery runs again, as after a retry or a catch-up.
|
||||||
|
await prisma.domainEvent.updateMany({
|
||||||
|
where: { aggregateId: inspectionId },
|
||||||
|
data: { status: 'PENDING', processedAt: null, claimedAt: null },
|
||||||
|
});
|
||||||
|
await prisma.domainEventDelivery.updateMany({
|
||||||
|
where: {
|
||||||
|
handler: 'automation.inspection-rules',
|
||||||
|
event: { aggregateId: inspectionId },
|
||||||
|
},
|
||||||
|
data: { status: 'PENDING', attempts: 0, completedAt: null },
|
||||||
|
});
|
||||||
|
await drainDomainEvents(app, [inspectionId]);
|
||||||
|
|
||||||
|
const suggestions = await ruleSuggestions();
|
||||||
|
expect(suggestions).toHaveLength(1);
|
||||||
|
expect(suggestions[0].status).toBe('DISMISSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a later inspection with the same finding proposes again', async () => {
|
||||||
|
await inspect(queenless);
|
||||||
|
const [first] = await ruleSuggestions();
|
||||||
|
await service.dismiss(first.id, null, userId);
|
||||||
|
|
||||||
|
await inspect(queenless);
|
||||||
|
|
||||||
|
const suggestions = await ruleSuggestions();
|
||||||
|
expect(suggestions.map((suggestion) => suggestion.status).sort()).toEqual(
|
||||||
|
['DISMISSED', 'OPEN'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepting a rule suggestion without dueInDays creates a task due today', async () => {
|
||||||
|
await inspect(queenless);
|
||||||
|
const [open] = await ruleSuggestions();
|
||||||
|
expect(open.dueDate).toBeNull();
|
||||||
|
|
||||||
|
const result = await service.accept(open.id, {}, userId);
|
||||||
|
|
||||||
|
expect(result.tasks).toHaveLength(1);
|
||||||
|
expect(result.tasks[0].dueDate?.toISOString().slice(0, 10)).toBe(
|
||||||
|
berlinToday(),
|
||||||
|
);
|
||||||
|
expect(result.tasks[0]).toMatchObject({
|
||||||
|
sourceSuggestionId: open.id,
|
||||||
|
sourceAutomationTrigger: 'QUEENLESS_NO_EGGS',
|
||||||
|
priority: 'HIGH',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a chain suggestion proposed with notify true notifies like a rule suggestion', async () => {
|
||||||
|
const created = (await prisma.$transaction((tx) =>
|
||||||
|
service.proposeIn(tx, {
|
||||||
|
...proposal('notify-chain'),
|
||||||
|
notify: true,
|
||||||
|
requiredPermission: 'colonies:read',
|
||||||
|
}),
|
||||||
|
))!;
|
||||||
|
|
||||||
|
const rows = await prisma.notification.findMany({
|
||||||
|
where: { relatedType: 'Suggestion', relatedId: created.id },
|
||||||
|
});
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
expect(rows.every((row) => row.category === 'SUGGESTION')).toBe(true);
|
||||||
|
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
|
||||||
|
expect(rows.every((row) => row.link === `/colonies/${colonyId}`)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', async () => {
|
||||||
|
await inspect(queenless);
|
||||||
|
const [open] = await ruleSuggestions();
|
||||||
|
|
||||||
|
const rows = await prisma.notification.findMany({
|
||||||
|
where: { relatedType: 'Suggestion', relatedId: open.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipients = new Set(rows.map((row) => row.userId));
|
||||||
|
expect(recipients.has(writer.id)).toBe(true);
|
||||||
|
expect(recipients.has(withoutInspections.id)).toBe(false);
|
||||||
|
expect(recipients.has(withoutTasks.id)).toBe(false);
|
||||||
|
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
|
||||||
|
expect(
|
||||||
|
rows.every((row) => row.message.startsWith(`SUG-${suffix}: `)),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -89,6 +89,7 @@ describe('Treatments (e2e)', () => {
|
|||||||
where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } },
|
where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } },
|
||||||
});
|
});
|
||||||
await prisma.treatment.deleteMany({ where: { colonyId } });
|
await prisma.treatment.deleteMany({ where: { colonyId } });
|
||||||
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
await prisma.refreshToken.deleteMany({
|
await prisma.refreshToken.deleteMany({
|
||||||
@@ -482,6 +483,9 @@ describe('Treatments (e2e)', () => {
|
|||||||
await prisma.treatment.deleteMany({
|
await prisma.treatment.deleteMany({
|
||||||
where: { colonyId: { in: colonyIds } },
|
where: { colonyId: { in: colonyIds } },
|
||||||
});
|
});
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||||
await prisma.apiary.deleteMany({ where: { id: secondApiaryId } });
|
await prisma.apiary.deleteMany({ where: { id: secondApiaryId } });
|
||||||
await prisma.idempotencyRecord.deleteMany({
|
await prisma.idempotencyRecord.deleteMany({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
|
|||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
import { login } from './utils/api';
|
import { login } from './utils/api';
|
||||||
|
import { drainDomainEvents } from './utils/domain-events';
|
||||||
|
|
||||||
interface BandBody {
|
interface BandBody {
|
||||||
yellowFrom: number | null;
|
yellowFrom: number | null;
|
||||||
@@ -41,6 +42,9 @@ describe('Varroa seasonal bands (e2e)', () => {
|
|||||||
let apiaryId: string;
|
let apiaryId: string;
|
||||||
let productionColonyId: string;
|
let productionColonyId: string;
|
||||||
let youngColonyId: string;
|
let youngColonyId: string;
|
||||||
|
// Colonies of the rule cases: one per case, so no open suggestion of an
|
||||||
|
// earlier case keeps a later one from appearing.
|
||||||
|
const ruleColonyIds: string[] = [];
|
||||||
|
|
||||||
const get = (path: string) =>
|
const get = (path: string) =>
|
||||||
request(app.getHttpServer())
|
request(app.getHttpServer())
|
||||||
@@ -80,7 +84,39 @@ describe('Varroa seasonal bands (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
const colonyIds = [productionColonyId, youngColonyId];
|
const colonyIds = [productionColonyId, youngColonyId, ...ruleColonyIds];
|
||||||
|
const measurementIds = (
|
||||||
|
await prisma.varroaMeasurement.findMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((measurement) => measurement.id);
|
||||||
|
const suggestionIds = (
|
||||||
|
await prisma.suggestion.findMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((suggestion) => suggestion.id);
|
||||||
|
await prisma.notification.deleteMany({
|
||||||
|
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
|
||||||
|
...colonyIds.map((id) => ({
|
||||||
|
entityType: 'AutomationRule',
|
||||||
|
afterData: { path: ['relatedId'], equals: id },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.suggestion.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateId: { in: measurementIds } },
|
||||||
|
});
|
||||||
await prisma.treatment.deleteMany({
|
await prisma.treatment.deleteMany({
|
||||||
where: { colonyId: { in: colonyIds } },
|
where: { colonyId: { in: colonyIds } },
|
||||||
});
|
});
|
||||||
@@ -137,6 +173,53 @@ describe('Varroa seasonal bands (e2e)', () => {
|
|||||||
await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400);
|
await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('range form', () => {
|
||||||
|
interface SegmentsBody {
|
||||||
|
segments: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
bands: Record<string, unknown>;
|
||||||
|
postTreatmentUntil: string | null;
|
||||||
|
active: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
const range = (query: string) =>
|
||||||
|
get(`/varroa-measurements/thresholds?${query}`);
|
||||||
|
|
||||||
|
it('returns merged segments split at the season edges', async () => {
|
||||||
|
const body = (
|
||||||
|
await range(
|
||||||
|
`colonyId=${productionColonyId}&from=2026-10-30&to=2026-11-02`,
|
||||||
|
).expect(200)
|
||||||
|
).body as SegmentsBody;
|
||||||
|
|
||||||
|
expect(body.segments.map((s) => [s.from, s.to, s.active])).toEqual([
|
||||||
|
['2026-10-30', '2026-10-31', true],
|
||||||
|
['2026-11-01', '2026-11-02', true],
|
||||||
|
]);
|
||||||
|
expect(body.segments[0].bands.NATURAL_MITE_FALL).toMatchObject({
|
||||||
|
reason: 'NATURAL_FALL_SEASON',
|
||||||
|
});
|
||||||
|
expect(body.segments[1].bands.NATURAL_MITE_FALL).toMatchObject({
|
||||||
|
reason: 'NATURAL_FALL_WINTER',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts 731 days and rejects 732', async () => {
|
||||||
|
await range('from=2025-01-01&to=2027-01-01').expect(200);
|
||||||
|
await range('from=2025-01-01&to=2027-01-02').expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects from after to, a missing bound, a mix with date and a bad day', async () => {
|
||||||
|
await range('from=2026-06-02&to=2026-06-01').expect(400);
|
||||||
|
await range('from=2026-06-02').expect(400);
|
||||||
|
await range('to=2026-06-02').expect(400);
|
||||||
|
await range('date=2026-06-01&from=2026-06-01&to=2026-06-02').expect(400);
|
||||||
|
await range('from=2026-02-30&to=2026-03-05').expect(400);
|
||||||
|
await range('colonyId=unknown&from=2026-06-01&to=2026-06-02').expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('the old /threshold route is gone (404)', async () => {
|
it('the old /threshold route is gone (404)', async () => {
|
||||||
await get('/varroa-measurements/threshold').expect(404);
|
await get('/varroa-measurements/threshold').expect(404);
|
||||||
});
|
});
|
||||||
@@ -221,4 +304,87 @@ describe('Varroa seasonal bands (e2e)', () => {
|
|||||||
postTreatmentUntil: '2026-08-16',
|
postTreatmentUntil: '2026-08-16',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('the varroa rule', () => {
|
||||||
|
const newColony = async (): Promise<string> => {
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `VS-R-${suffix}-${ruleColonyIds.length}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ruleColonyIds.push(colony.id);
|
||||||
|
return colony.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Records a measurement and runs the handlers of its event. */
|
||||||
|
const measure = async (
|
||||||
|
colonyId: string,
|
||||||
|
date: string,
|
||||||
|
miteCount: number,
|
||||||
|
): Promise<string> => {
|
||||||
|
const created = (
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/varroa-measurements')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({
|
||||||
|
colonyId,
|
||||||
|
date,
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
miteCount,
|
||||||
|
// One day on the board: the count is the rate per day.
|
||||||
|
durationMinutes: 24 * 60,
|
||||||
|
})
|
||||||
|
.expect(201)
|
||||||
|
).body as { id: string };
|
||||||
|
await drainDomainEvents(app, [created.id]);
|
||||||
|
return created.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ruleSuggestions = (colonyId: string) =>
|
||||||
|
prisma.suggestion.findMany({
|
||||||
|
where: { colonyId, ruleKey: 'VARROA_THRESHOLD' },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('12 mites/day in August proposes a treatment check', async () => {
|
||||||
|
const colonyId = await newColony();
|
||||||
|
|
||||||
|
const id = await measure(colonyId, '2026-08-10T00:00:00.000Z', 12);
|
||||||
|
|
||||||
|
const suggestions = await ruleSuggestions(colonyId);
|
||||||
|
expect(suggestions).toHaveLength(1);
|
||||||
|
expect(suggestions[0]).toMatchObject({
|
||||||
|
intent: 'VARROA_TREAT',
|
||||||
|
reasonCode: 'VARROA_RED',
|
||||||
|
status: 'OPEN',
|
||||||
|
dedupeKey: `VARROA_THRESHOLD:${colonyId}:${id}`,
|
||||||
|
evidence: { value: 12, redAbove: 10, unit: 'MITES_PER_DAY' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('12 mites/day five days after treatment end proposes nothing', async () => {
|
||||||
|
const colonyId = await newColony();
|
||||||
|
await prisma.treatment.create({
|
||||||
|
data: {
|
||||||
|
colonyId,
|
||||||
|
userId,
|
||||||
|
date: new Date('2026-08-01T00:00:00.000Z'),
|
||||||
|
endDate: new Date('2026-08-05T00:00:00.000Z'),
|
||||||
|
product: 'Oxuvar',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await measure(colonyId, '2026-08-10T00:00:00.000Z', 12);
|
||||||
|
|
||||||
|
expect(await ruleSuggestions(colonyId)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('10.0 mites/day in August proposes nothing (yellow)', async () => {
|
||||||
|
const colonyId = await newColony();
|
||||||
|
|
||||||
|
await measure(colonyId, '2026-08-10T00:00:00.000Z', 10);
|
||||||
|
|
||||||
|
expect(await ruleSuggestions(colonyId)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ColonyVarroaCard } from "./colony-varroa";
|
import { ColonyVarroaCard } from "./colony-varroa";
|
||||||
import { fetchColonyVarroaMeasurements, fetchVarroaThreshold } from "@/lib/varroa/api";
|
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api";
|
||||||
|
import type { VarroaBand, VarroaRating, VarroaThresholdSegments } from "@/lib/varroa/bands";
|
||||||
import type { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
|
import type { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
|
||||||
|
|
||||||
vi.mock("@/lib/varroa/api", () => ({
|
vi.mock("@/lib/varroa/api", () => ({
|
||||||
fetchColonyVarroaMeasurements: vi.fn(),
|
fetchColonyVarroaMeasurements: vi.fn(),
|
||||||
fetchVarroaThreshold: vi.fn(),
|
fetchVarroaThresholdRange: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("./varroa-edit-dialog", () => ({ VarroaEditDialog: () => null }));
|
vi.mock("./varroa-edit-dialog", () => ({ VarroaEditDialog: () => null }));
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ function measurement(
|
|||||||
date: string,
|
date: string,
|
||||||
method: VarroaMethod,
|
method: VarroaMethod,
|
||||||
calculatedValue: number | null,
|
calculatedValue: number | null,
|
||||||
|
rating: VarroaRating | null = null,
|
||||||
): VarroaMeasurement {
|
): VarroaMeasurement {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -25,13 +27,39 @@ function measurement(
|
|||||||
method,
|
method,
|
||||||
durationMinutes: 4320,
|
durationMinutes: 4320,
|
||||||
miteCount: 12,
|
miteCount: 12,
|
||||||
|
sampleBees: null,
|
||||||
calculatedValue,
|
calculatedValue,
|
||||||
assessment: null,
|
assessment: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
createdAt: `${date}T00:00:00.000Z`,
|
createdAt: `${date}T00:00:00.000Z`,
|
||||||
|
rating,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEASON: VarroaBand = {
|
||||||
|
method: "NATURAL_MITE_FALL",
|
||||||
|
unit: "MITES_PER_DAY",
|
||||||
|
yellowFrom: 5,
|
||||||
|
redAbove: 10,
|
||||||
|
reason: "NATURAL_FALL_SEASON",
|
||||||
|
youngColony: false,
|
||||||
|
};
|
||||||
|
const WINTER: VarroaBand = { ...SEASON, yellowFrom: null, redAbove: 0.5, reason: "NATURAL_FALL_WINTER" };
|
||||||
|
|
||||||
|
/** Winter from 1 November, season before; one segment per side of the change inside the range. */
|
||||||
|
function segmentsFor(from: string, to: string): VarroaThresholdSegments {
|
||||||
|
const part = (start: string, end: string, band: VarroaBand) => ({
|
||||||
|
from: start,
|
||||||
|
to: end,
|
||||||
|
bands: { NATURAL_MITE_FALL: band },
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
if (to < "2026-11-01") return { segments: [part(from, to, SEASON)] };
|
||||||
|
if (from >= "2026-11-01") return { segments: [part(from, to, WINTER)] };
|
||||||
|
return { segments: [part(from, "2026-10-31", SEASON), part("2026-11-01", to, WINTER)] };
|
||||||
|
}
|
||||||
|
|
||||||
const MEASUREMENTS = [
|
const MEASUREMENTS = [
|
||||||
measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4),
|
measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4),
|
||||||
measurement("b", "2026-06-08", "POWDERED_SUGAR", 99),
|
measurement("b", "2026-06-08", "POWDERED_SUGAR", 99),
|
||||||
@@ -50,7 +78,10 @@ function renderCard() {
|
|||||||
describe("ColonyVarroaCard trend", () => {
|
describe("ColonyVarroaCard trend", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue(MEASUREMENTS);
|
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue(MEASUREMENTS);
|
||||||
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
|
vi.mocked(fetchVarroaThresholdRange).mockReset();
|
||||||
|
vi.mocked(fetchVarroaThresholdRange).mockImplementation(async (_colonyId, from, to) =>
|
||||||
|
segmentsFor(from, to),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the chart ignores powdered sugar counts", async () => {
|
it("the chart ignores powdered sugar counts", async () => {
|
||||||
@@ -59,18 +90,62 @@ describe("ColonyVarroaCard trend", () => {
|
|||||||
expect(container.querySelectorAll("svg circle")).toHaveLength(2);
|
expect(container.querySelectorAll("svg circle")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws the threshold line", async () => {
|
it("the chart draws the band as a monthly step line with an accessible summary", async () => {
|
||||||
const { container } = renderCard();
|
const { container } = renderCard();
|
||||||
expect(await screen.findByText("Schwelle 10/Tag")).toBeInTheDocument();
|
expect(await screen.findByText("Grenze 10/Tag")).toBeInTheDocument();
|
||||||
expect(container.querySelector("svg line[stroke-dasharray]")).not.toBeNull();
|
const red = container.querySelector("svg path[data-band='red']");
|
||||||
|
expect(red).not.toBeNull();
|
||||||
|
expect(container.querySelector("svg path[data-band='yellow']")).not.toBeNull();
|
||||||
|
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2026-06-01", "2026-06-30");
|
||||||
|
|
||||||
|
const chart = screen.getByRole("img");
|
||||||
|
expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Grenze 10\/Tag/);
|
||||||
|
expect(chart).toHaveAccessibleDescription(/2 Messungen.*Rote Grenze je Monat: Juni 10/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has an accessible summary", async () => {
|
it("steps down in winter and leaves a gap where there is no yellow stage", async () => {
|
||||||
|
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
|
||||||
|
measurement("a", "2026-10-20", "NATURAL_MITE_FALL", 4),
|
||||||
|
measurement("c", "2026-11-20", "NATURAL_MITE_FALL", 0.2),
|
||||||
|
]);
|
||||||
|
const { container } = renderCard();
|
||||||
|
await screen.findByText("Grenze 0,5/Tag");
|
||||||
|
const red = container.querySelector("svg path[data-band='red']")?.getAttribute("d") ?? "";
|
||||||
|
expect(red).toMatch(/^M[\d.]+,[\d.]+ H[\d.]+ V[\d.]+ H[\d.]+$/);
|
||||||
|
const yellow = container.querySelector("svg path[data-band='yellow']")?.getAttribute("d") ?? "";
|
||||||
|
expect(yellow.match(/M/g)).toHaveLength(1);
|
||||||
|
expect(yellow).not.toContain("V");
|
||||||
|
expect(screen.getByRole("img")).toHaveAccessibleDescription(/Oktober 10, November 0,5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks once for a 24-month trend", async () => {
|
||||||
|
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
|
||||||
|
measurement("a", "2024-08-10", "NATURAL_MITE_FALL", 4),
|
||||||
|
measurement("b", "2026-07-20", "NATURAL_MITE_FALL", 6),
|
||||||
|
]);
|
||||||
renderCard();
|
renderCard();
|
||||||
await screen.findByText("Schwelle 10/Tag");
|
await screen.findByText("Grenze 10/Tag");
|
||||||
const chart = screen.getByRole("img");
|
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledTimes(1);
|
||||||
expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Schwelle 10\/Tag/);
|
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2024-08-01", "2026-07-31");
|
||||||
expect(chart).toHaveAccessibleDescription(/2 Messungen/);
|
});
|
||||||
|
|
||||||
|
it("rates a late-evening UTC measurement on its Berlin day and month", async () => {
|
||||||
|
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
|
||||||
|
{ ...measurement("a", "2026-08-31", "NATURAL_MITE_FALL", 4), date: "2026-08-31T22:30:00.000Z" },
|
||||||
|
measurement("b", "2026-09-10", "NATURAL_MITE_FALL", 6),
|
||||||
|
]);
|
||||||
|
renderCard();
|
||||||
|
await screen.findByText("Grenze 10/Tag");
|
||||||
|
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2026-09-01", "2026-09-30");
|
||||||
|
expect(screen.getByRole("img")).toHaveAccessibleDescription(/2 Messungen von 01.09.2026 bis 10.09.2026/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws no band while the thresholds are unknown", async () => {
|
||||||
|
vi.mocked(fetchVarroaThresholdRange).mockRejectedValue(new Error("offline"));
|
||||||
|
const { container } = renderCard();
|
||||||
|
await screen.findByRole("img");
|
||||||
|
expect(container.querySelector("svg path[data-band]")).toBeNull();
|
||||||
|
expect(screen.getByRole("img")).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows only the value as text with fewer than two natural-fall points", async () => {
|
it("shows only the value as text with fewer than two natural-fall points", async () => {
|
||||||
@@ -90,4 +165,27 @@ describe("ColonyVarroaCard trend", () => {
|
|||||||
await screen.findByRole("img");
|
await screen.findByRole("img");
|
||||||
expect(container.querySelectorAll("svg circle")).toHaveLength(3);
|
expect(container.querySelectorAll("svg circle")).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names the stored rating of each measurement in words", async () => {
|
||||||
|
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
|
||||||
|
measurement("a", "2026-08-01", "NATURAL_MITE_FALL", 12.4, {
|
||||||
|
light: "red",
|
||||||
|
reasonCode: "NATURAL_FALL_SEASON",
|
||||||
|
band: SEASON,
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
}),
|
||||||
|
measurement("b", "2026-08-10", "NATURAL_MITE_FALL", 8, {
|
||||||
|
light: "POST_TREATMENT",
|
||||||
|
reasonCode: "POST_TREATMENT",
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: "2026-08-16",
|
||||||
|
}),
|
||||||
|
{ ...measurement("c", "2026-08-12", "POWDERED_SUGAR", 2.4), sampleBees: 500 },
|
||||||
|
]);
|
||||||
|
renderCard();
|
||||||
|
expect(await screen.findByText("Kritisch (Grenze 10, LWG)")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Behandlungseffekt – Nachkontrolle ab 16.08.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Probe 500 Bienen · 2,4 %/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/12 Milben · 12,4 Milben\/Tag/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,11 +4,13 @@ import Link from "next/link";
|
|||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { Bug } from "lucide-react";
|
import { Bug } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
|
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api";
|
||||||
|
import { VarroaBand, ratingNote, segmentAt, valueText } from "@/lib/varroa/bands";
|
||||||
import { VarroaMeasurement } from "@/lib/varroa/types";
|
import { VarroaMeasurement } from "@/lib/varroa/types";
|
||||||
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
|
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
|
||||||
import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold";
|
import { berlinCalendarDay } from "@/lib/dates";
|
||||||
import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
|
import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
|
||||||
|
import { VarroaNoteLabel } from "@/components/varroa/varroa-rate-light";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { QueryState } from "@/components/query-state";
|
import { QueryState } from "@/components/query-state";
|
||||||
@@ -23,42 +25,147 @@ const PAD_BOTTOM = 12;
|
|||||||
const MARKER_RADIUS = 4;
|
const MARKER_RADIUS = 4;
|
||||||
const LABEL_GAP = 4;
|
const LABEL_GAP = 4;
|
||||||
|
|
||||||
|
const MAX_BAND_MONTHS = 24;
|
||||||
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
interface TrendPoint {
|
interface TrendPoint {
|
||||||
id: string;
|
id: string;
|
||||||
time: number;
|
time: number;
|
||||||
rate: number;
|
rate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The band of the natural mite fall in force during one calendar month. */
|
||||||
|
interface MonthLimit {
|
||||||
|
/** First day of the month, UTC, in ms. */
|
||||||
|
start: number;
|
||||||
|
/** First day of the next month, UTC, in ms. */
|
||||||
|
end: number;
|
||||||
|
band: VarroaBand | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeRange {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LimitKey = "redAbove" | "yellowFrom";
|
||||||
|
|
||||||
const DAY_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
const DAY_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
const MONTH_FORMAT = new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", month: "long" });
|
||||||
|
|
||||||
function formatDate(time: number): string {
|
function formatDate(time: number): string {
|
||||||
return DAY_FORMAT.format(new Date(time));
|
return DAY_FORMAT.format(new Date(time));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only the natural mite fall is a rate per day; other methods are not comparable. */
|
/**
|
||||||
|
* Only the natural mite fall is a rate per day; other methods are not comparable.
|
||||||
|
* A point sits at midnight UTC of its Berlin calendar day, the day the backend rates it on.
|
||||||
|
*/
|
||||||
function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
|
function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
|
||||||
return measurements
|
return measurements
|
||||||
.filter((m) => m.method === "NATURAL_MITE_FALL" && m.calculatedValue !== null)
|
.filter((m) => m.method === "NATURAL_MITE_FALL" && m.calculatedValue !== null)
|
||||||
.map((m) => ({ id: m.id, time: new Date(m.date).getTime(), rate: m.calculatedValue as number }))
|
.map((m) => ({ id: m.id, time: Date.parse(`${berlinCalendarDay(m.date)}T00:00:00Z`), rate: m.calculatedValue as number }))
|
||||||
.sort((left, right) => left.time - right.time);
|
.sort((left, right) => left.time - right.time);
|
||||||
}
|
}
|
||||||
|
|
||||||
function summaryOf(last: TrendPoint, threshold: number | null): string {
|
function nextMonthStart(start: number): number {
|
||||||
|
const date = new Date(start);
|
||||||
|
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first days of the months the points span, the latest MAX_BAND_MONTHS at most. */
|
||||||
|
function monthStarts(points: TrendPoint[]): number[] {
|
||||||
|
if (points.length === 0) return [];
|
||||||
|
const first = new Date(points[0].time);
|
||||||
|
const last = new Date(points[points.length - 1].time);
|
||||||
|
const stop = Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), 1);
|
||||||
|
const starts: number[] = [];
|
||||||
|
let cursor = Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), 1);
|
||||||
|
while (cursor <= stop) {
|
||||||
|
starts.push(cursor);
|
||||||
|
cursor = nextMonthStart(cursor);
|
||||||
|
}
|
||||||
|
return starts.slice(-MAX_BAND_MONTHS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDay(time: number): string {
|
||||||
|
return new Date(time).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The natural-fall band of each month of the trend: one request for the whole range. */
|
||||||
|
function useMonthLimits(colonyId: string, points: TrendPoint[]): MonthLimit[] {
|
||||||
|
const starts = monthStarts(points);
|
||||||
|
const from = starts.length > 0 ? isoDay(starts[0]) : "";
|
||||||
|
const to = starts.length > 0 ? isoDay(nextMonthStart(starts[starts.length - 1]) - DAY_MS) : "";
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["varroa", "thresholds", colonyId, from, to],
|
||||||
|
queryFn: () => fetchVarroaThresholdRange(colonyId, from, to),
|
||||||
|
enabled: starts.length > 0,
|
||||||
|
staleTime: ONE_HOUR_MS,
|
||||||
|
});
|
||||||
|
const segments = query.data?.segments ?? [];
|
||||||
|
return starts.map((start) => ({
|
||||||
|
start,
|
||||||
|
end: nextMonthStart(start),
|
||||||
|
band: segmentAt(segments, isoDay(start))?.bands.NATURAL_MITE_FALL ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A step line through the monthly limits: flat inside a month, a vertical
|
||||||
|
* step at the month change, a gap where a month has no such limit.
|
||||||
|
*/
|
||||||
|
function stepPath(
|
||||||
|
limits: MonthLimit[],
|
||||||
|
key: LimitKey,
|
||||||
|
xOf: (time: number) => number,
|
||||||
|
yOf: (value: number) => number,
|
||||||
|
range: TimeRange,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let open = false;
|
||||||
|
for (const { start, end, band } of limits) {
|
||||||
|
const value = band ? band[key] : null;
|
||||||
|
const from = Math.max(start, range.from);
|
||||||
|
const to = Math.min(end, range.to);
|
||||||
|
if (value === null || to < from) {
|
||||||
|
open = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const y = yOf(value);
|
||||||
|
parts.push(open ? `V${y} H${xOf(to)}` : `M${xOf(from)},${y} H${xOf(to)}`);
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLimitsText(limits: MonthLimit[], range: TimeRange): string {
|
||||||
|
return limits
|
||||||
|
.filter(({ band, start, end }) => band && end > range.from && start <= range.to)
|
||||||
|
.map(
|
||||||
|
({ start, band }) =>
|
||||||
|
`${MONTH_FORMAT.format(new Date(start))} ${formatThreshold((band as VarroaBand).redAbove)}`,
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryOf(last: TrendPoint, lastLimit: VarroaBand | null): string {
|
||||||
const base = `Letzte Messung ${formatMitesPerDay(last.rate)} Milben/Tag am ${formatDate(last.time)}`;
|
const base = `Letzte Messung ${formatMitesPerDay(last.rate)} Milben/Tag am ${formatDate(last.time)}`;
|
||||||
return threshold === null ? base : `${base}, Schwelle ${formatThreshold(threshold)}/Tag`;
|
return lastLimit ? `${base}, Grenze ${formatThreshold(lastLimit.redAbove)}/Tag` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function VarroaTrendChart({
|
function VarroaTrendChart({
|
||||||
points,
|
points,
|
||||||
threshold,
|
limits,
|
||||||
}: {
|
}: {
|
||||||
points: TrendPoint[];
|
points: TrendPoint[];
|
||||||
threshold: number | null;
|
limits: MonthLimit[];
|
||||||
}) {
|
}) {
|
||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const descId = useId();
|
const descId = useId();
|
||||||
@@ -66,19 +173,26 @@ function VarroaTrendChart({
|
|||||||
if (!last) {
|
if (!last) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const summary = summaryOf(last, threshold);
|
const lastLimit =
|
||||||
|
limits.find(({ start, end }) => last.time >= start && last.time < end)?.band ?? null;
|
||||||
|
const summary = summaryOf(last, lastLimit);
|
||||||
if (points.length < 2) {
|
if (points.length < 2) {
|
||||||
return <p className="font-medium">{summary}</p>;
|
return <p className="font-medium">{summary}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = points[0];
|
const first = points[0];
|
||||||
const yMax = Math.max(...points.map((p) => p.rate), threshold ?? 0, 1);
|
const range = { from: first.time, to: last.time };
|
||||||
|
const limitValues = limits.flatMap(({ band }) => (band ? [band.redAbove] : []));
|
||||||
|
const yMax = Math.max(...points.map((p) => p.rate), ...limitValues, 1);
|
||||||
const timeRange = last.time - first.time || 1;
|
const timeRange = last.time - first.time || 1;
|
||||||
const usableWidth = CHART_WIDTH - PAD_X * 2;
|
const usableWidth = CHART_WIDTH - PAD_X * 2;
|
||||||
const usableHeight = CHART_HEIGHT - PAD_TOP - PAD_BOTTOM;
|
const usableHeight = CHART_HEIGHT - PAD_TOP - PAD_BOTTOM;
|
||||||
const xOf = (time: number) => PAD_X + ((time - first.time) / timeRange) * usableWidth;
|
const xOf = (time: number) => PAD_X + ((time - first.time) / timeRange) * usableWidth;
|
||||||
const yOf = (rate: number) => PAD_TOP + usableHeight - (rate / yMax) * usableHeight;
|
const yOf = (rate: number) => PAD_TOP + usableHeight - (rate / yMax) * usableHeight;
|
||||||
const line = points.map((p) => `${xOf(p.time)},${yOf(p.rate)}`).join(" ");
|
const line = points.map((p) => `${xOf(p.time)},${yOf(p.rate)}`).join(" ");
|
||||||
|
const redPath = stepPath(limits, "redAbove", xOf, yOf, range);
|
||||||
|
const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range);
|
||||||
|
const monthly = monthLimitsText(limits, range);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -90,30 +204,31 @@ function VarroaTrendChart({
|
|||||||
>
|
>
|
||||||
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
|
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
|
||||||
<desc id={descId}>
|
<desc id={descId}>
|
||||||
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}`}
|
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
|
||||||
|
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
|
||||||
|
}`}
|
||||||
</desc>
|
</desc>
|
||||||
{threshold !== null ? (
|
{redPath || yellowPath ? (
|
||||||
<g className="text-muted-foreground">
|
<g className="text-muted-foreground" fill="none" stroke="currentColor">
|
||||||
<line
|
{yellowPath ? (
|
||||||
x1={PAD_X}
|
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
|
||||||
x2={CHART_WIDTH - PAD_X}
|
) : null}
|
||||||
y1={yOf(threshold)}
|
{redPath ? (
|
||||||
y2={yOf(threshold)}
|
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
|
||||||
stroke="currentColor"
|
) : null}
|
||||||
strokeWidth={1.5}
|
|
||||||
strokeDasharray="6 4"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={CHART_WIDTH - PAD_X}
|
|
||||||
y={yOf(threshold) - LABEL_GAP}
|
|
||||||
textAnchor="end"
|
|
||||||
fontSize={10}
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
{`Schwelle ${formatThreshold(threshold)}/Tag`}
|
|
||||||
</text>
|
|
||||||
</g>
|
</g>
|
||||||
) : null}
|
) : null}
|
||||||
|
{lastLimit ? (
|
||||||
|
<text
|
||||||
|
x={CHART_WIDTH - PAD_X}
|
||||||
|
y={yOf(lastLimit.redAbove) - LABEL_GAP}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize={10}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
>
|
||||||
|
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
|
||||||
|
</text>
|
||||||
|
) : null}
|
||||||
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
|
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
|
||||||
{points.map((p) => (
|
{points.map((p) => (
|
||||||
<circle
|
<circle
|
||||||
@@ -130,13 +245,28 @@ function VarroaTrendChart({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MeasurementDetails({ measurement }: { measurement: VarroaMeasurement }) {
|
||||||
|
const note = ratingNote(measurement.rating);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
|
||||||
|
{measurement.sampleBees !== null ? ` · Probe ${measurement.sampleBees} Bienen` : ""}
|
||||||
|
{measurement.calculatedValue !== null
|
||||||
|
? ` · ${valueText(measurement.method, measurement.calculatedValue, formatMitesPerDay)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
{note ? <VarroaNoteLabel note={note} className="mt-2" /> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
||||||
const measurementsQuery = useQuery({
|
const measurementsQuery = useQuery({
|
||||||
queryKey: ["colonies", colonyId, "varroa-measurements"],
|
queryKey: ["colonies", colonyId, "varroa-measurements"],
|
||||||
queryFn: () => fetchColonyVarroaMeasurements(colonyId),
|
queryFn: () => fetchColonyVarroaMeasurements(colonyId),
|
||||||
});
|
});
|
||||||
|
const limits = useMonthLimits(colonyId, trendPoints(measurementsQuery.data ?? []));
|
||||||
const threshold = useVarroaThreshold();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -165,7 +295,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
|||||||
>
|
>
|
||||||
{(measurements) => (
|
{(measurements) => (
|
||||||
<>
|
<>
|
||||||
<VarroaTrendChart points={trendPoints(measurements)} threshold={threshold} />
|
<VarroaTrendChart points={trendPoints(measurements)} limits={limits} />
|
||||||
{measurements.map((measurement) => (
|
{measurements.map((measurement) => (
|
||||||
<div key={measurement.id} className="rounded-md border p-3">
|
<div key={measurement.id} className="rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -175,12 +305,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
|
|||||||
</p>
|
</p>
|
||||||
<VarroaEditDialog measurement={measurement} />
|
<VarroaEditDialog measurement={measurement} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<MeasurementDetails measurement={measurement} />
|
||||||
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
|
|
||||||
{measurement.calculatedValue !== null
|
|
||||||
? ` · ${measurement.calculatedValue.toFixed(1)} Milben/Tag`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
|
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { updateTreatment } from "@/lib/treatments/api";
|
import { updateTreatment } from "@/lib/treatments/api";
|
||||||
|
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
|
||||||
import { Treatment } from "@/lib/treatments/types";
|
import { Treatment } from "@/lib/treatments/types";
|
||||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||||
import { errorMessage } from "@/lib/forms/error-message";
|
import { errorMessage } from "@/lib/forms/error-message";
|
||||||
@@ -81,6 +82,8 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
|
|||||||
queryKey: ["colonies", affectedColonyId, "treatments"],
|
queryKey: ["colonies", affectedColonyId, "treatments"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// A treatment moves the post-treatment window of the varroa lights.
|
||||||
|
void invalidateVarroaBands(queryClient);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const measurement: VarroaMeasurement = {
|
|||||||
method: "NATURAL_MITE_FALL",
|
method: "NATURAL_MITE_FALL",
|
||||||
durationMinutes: 4320,
|
durationMinutes: 4320,
|
||||||
miteCount: 12,
|
miteCount: 12,
|
||||||
|
sampleBees: null,
|
||||||
calculatedValue: 4,
|
calculatedValue: 4,
|
||||||
assessment: "mittel",
|
assessment: "mittel",
|
||||||
notes: "Windel verrutscht",
|
notes: "Windel verrutscht",
|
||||||
@@ -90,11 +91,46 @@ describe("VarroaEditDialog", () => {
|
|||||||
expect(input).toMatchObject({ durationMinutes: 2000 });
|
expect(input).toMatchObject({ durationMinutes: 2000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the minutes field for other methods", async () => {
|
it("keeps the minutes field for the drone brood check", async () => {
|
||||||
await openDialog({ ...measurement, method: "WASHING_METHOD" });
|
await openDialog({ ...measurement, method: "DRONE_BROOD_CONTROL" });
|
||||||
expect(screen.getByLabelText(/^Zeitraum \(Minuten\)/)).toHaveValue(4320);
|
expect(screen.getByLabelText(/^Zeitraum \(Minuten\)/)).toHaveValue(4320);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the sample methods have no minutes field, a stored value is sent back", async () => {
|
||||||
|
await openDialog({ ...measurement, method: "WASHING_METHOD", sampleBees: 300 });
|
||||||
|
expect(screen.queryByLabelText(/^Zeitraum \(Minuten\)/)).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ durationMinutes: 4320 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["0", "2001", "2.5"])("refuses a sample of %s and sends nothing", async (bad) => {
|
||||||
|
await openDialog({ ...measurement, method: "POWDERED_SUGAR", sampleBees: 300 });
|
||||||
|
const field = screen.getByLabelText(/Probe \(Bienen\)/);
|
||||||
|
await userEvent.clear(field);
|
||||||
|
await userEvent.type(field, bad);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Bitte 1 bis 2000 Bienen angeben.")).toBeInTheDocument();
|
||||||
|
expect(updateVarroaMeasurement).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stored sampleBees is sent back unchanged from the edit dialog", async () => {
|
||||||
|
await openDialog({ ...measurement, method: "POWDERED_SUGAR", sampleBees: 300, calculatedValue: 4 });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/Probe \(Bienen\)/)).toHaveValue(300);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
|
||||||
|
expect(input).toMatchObject({ sampleBees: 300, miteCount: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no sample field for the natural mite fall and sends null", async () => {
|
||||||
|
await openDialog();
|
||||||
|
expect(screen.queryByLabelText(/Probe \(Bienen\)/)).not.toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||||
|
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ sampleBees: null });
|
||||||
|
});
|
||||||
|
|
||||||
it("explains that a manual mites-per-day value takes precedence", async () => {
|
it("explains that a manual mites-per-day value takes precedence", async () => {
|
||||||
await openDialog();
|
await openDialog();
|
||||||
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
|
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
} from "./edit-dialog-parts";
|
} from "./edit-dialog-parts";
|
||||||
import { toApiDate } from "@/lib/dates";
|
import { toApiDate } from "@/lib/dates";
|
||||||
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
|
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
|
||||||
|
import { SampleBeesField } from "@/components/varroa/sample-bees-field";
|
||||||
|
import { sampleBeesError, usesSample } from "@/lib/varroa/bands";
|
||||||
|
|
||||||
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
|
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -36,6 +38,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
const [date, setDate] = useState(measurement.date.slice(0, 10));
|
const [date, setDate] = useState(measurement.date.slice(0, 10));
|
||||||
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
|
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
|
||||||
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
|
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
|
||||||
|
const [sampleBees, setSampleBees] = useState(measurement.sampleBees?.toString() ?? "");
|
||||||
const [durationMinutes, setDurationMinutes] = useState(
|
const [durationMinutes, setDurationMinutes] = useState(
|
||||||
measurement.durationMinutes?.toString() ?? "",
|
measurement.durationMinutes?.toString() ?? "",
|
||||||
);
|
);
|
||||||
@@ -45,8 +48,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
const [assessment, setAssessment] = useState(measurement.assessment ?? "");
|
const [assessment, setAssessment] = useState(measurement.assessment ?? "");
|
||||||
const [notes, setNotes] = useState(measurement.notes ?? "");
|
const [notes, setNotes] = useState(measurement.notes ?? "");
|
||||||
const [dateError, setDateError] = useState<string | null>(null);
|
const [dateError, setDateError] = useState<string | null>(null);
|
||||||
|
const [sampleError, setSampleError] = useState<string | null>(null);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isNaturalFall = method === "NATURAL_MITE_FALL";
|
||||||
|
const hasSample = usesSample(method);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updateVarroaMeasurement(measurement.id, {
|
updateVarroaMeasurement(measurement.id, {
|
||||||
@@ -54,6 +61,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
date: toApiDate(date),
|
date: toApiDate(date),
|
||||||
method,
|
method,
|
||||||
miteCount: emptyToNull(miteCount, Number),
|
miteCount: emptyToNull(miteCount, Number),
|
||||||
|
sampleBees: hasSample ? emptyToNull(sampleBees, Number) : null,
|
||||||
durationMinutes: emptyToNull(durationMinutes, Number),
|
durationMinutes: emptyToNull(durationMinutes, Number),
|
||||||
calculatedValue: emptyToNull(calculatedValue, Number),
|
calculatedValue: emptyToNull(calculatedValue, Number),
|
||||||
assessment: emptyToNull(assessment),
|
assessment: emptyToNull(assessment),
|
||||||
@@ -73,7 +81,6 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
setSubmitError(errorMessage(error, "Varroamessung konnte nicht gespeichert werden.")),
|
setSubmitError(errorMessage(error, "Varroamessung konnte nicht gespeichert werden.")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNaturalFall = method === "NATURAL_MITE_FALL";
|
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -82,6 +89,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
setDateError(DATE_REQUIRED_MESSAGE);
|
setDateError(DATE_REQUIRED_MESSAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const invalidSample = hasSample ? sampleBeesError(sampleBees) : null;
|
||||||
|
setSampleError(invalidSample);
|
||||||
|
if (invalidSample) {
|
||||||
|
document.querySelector<HTMLElement>('[data-field="sampleBees"]')?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
mutation.mutate();
|
mutation.mutate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +160,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
{isNaturalFall ? null : (
|
{isNaturalFall || hasSample ? null : (
|
||||||
<Field label="Zeitraum (Minuten)">
|
<Field label="Zeitraum (Minuten)">
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
<Input
|
<Input
|
||||||
@@ -163,9 +176,19 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{hasSample ? (
|
||||||
|
<SampleBeesField
|
||||||
|
value={sampleBees}
|
||||||
|
onChange={(next) => {
|
||||||
|
setSampleBees(next);
|
||||||
|
setSampleError(null);
|
||||||
|
}}
|
||||||
|
error={sampleError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Field
|
<Field
|
||||||
label="Milben pro Tag"
|
label={hasSample ? "Befall (%)" : "Milben pro Tag"}
|
||||||
hint="Wird beim Speichern aus Milbenzahl und Zeitraum neu berechnet – ein eigener, geänderter Wert hat Vorrang."
|
hint={`Wird beim Speichern aus Milbenzahl und ${hasSample ? "Probe" : "Zeitraum"} neu berechnet – ein eigener, geänderter Wert hat Vorrang.`}
|
||||||
>
|
>
|
||||||
{({ id }) => (
|
{({ id }) => (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { Menu } from "lucide-react";
|
import { Menu } from "lucide-react";
|
||||||
import { SheetTrigger } from "@/components/ui/sheet";
|
import { SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { NavCounter } from "@/components/suggestions/nav-counter";
|
||||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||||
import { BOTTOM_NAV_HREFS, isNavActive, visibleNavGroups } from "./nav-config";
|
import { BOTTOM_NAV_HREFS, isNavActive, visibleNavGroups } from "./nav-config";
|
||||||
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
|
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
|
||||||
@@ -70,6 +71,7 @@ export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
|
|||||||
>
|
>
|
||||||
<Icon className="size-6 shrink-0" aria-hidden="true" />
|
<Icon className="size-6 shrink-0" aria-hidden="true" />
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -55,4 +55,14 @@ describe("nav-config", () => {
|
|||||||
expect(hrefs([])).not.toContain("/winter");
|
expect(hrefs([])).not.toContain("/winter");
|
||||||
expect(hrefs(["winter-checks:read"])).toContain("/winter");
|
expect(hrefs(["winter-checks:read"])).toContain("/winter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("„Vorschläge“ needs tasks:read and shows the open count", () => {
|
||||||
|
expect(hrefs([])).not.toContain("/suggestions");
|
||||||
|
const work = visibleNavGroups({ permissions: ["tasks:read"] }).find(
|
||||||
|
(group) => group.label === "Arbeit",
|
||||||
|
);
|
||||||
|
const item = work?.items.find((entry) => entry.href === "/suggestions");
|
||||||
|
expect(item?.label).toBe("Vorschläge");
|
||||||
|
expect(item?.counter).toBe("suggestions");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
History,
|
History,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Lightbulb,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -41,6 +42,8 @@ export interface NavItem {
|
|||||||
// together several previously-separate settings pages, each gated by its
|
// together several previously-separate settings pages, each gated by its
|
||||||
// own permission.
|
// own permission.
|
||||||
permission?: string | string[];
|
permission?: string | string[];
|
||||||
|
/** A number shown next to the label; the data comes from the matching hook. */
|
||||||
|
counter?: "suggestions";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavGroup {
|
export interface NavGroup {
|
||||||
@@ -79,6 +82,13 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: "Arbeit",
|
label: "Arbeit",
|
||||||
items: [
|
items: [
|
||||||
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
|
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
|
||||||
|
{
|
||||||
|
label: "Vorschläge",
|
||||||
|
href: "/suggestions",
|
||||||
|
icon: Lightbulb,
|
||||||
|
permission: "tasks:read",
|
||||||
|
counter: "suggestions",
|
||||||
|
},
|
||||||
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
|
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ function rule(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastClient: QueryClient;
|
||||||
|
|
||||||
function renderTab(rules: AutomationRule[]) {
|
function renderTab(rules: AutomationRule[]) {
|
||||||
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
|
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
|
||||||
vi.mocked(updateAutomationRule).mockImplementation(
|
vi.mocked(updateAutomationRule).mockImplementation(
|
||||||
@@ -38,6 +40,7 @@ function renderTab(rules: AutomationRule[]) {
|
|||||||
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
|
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
|
||||||
);
|
);
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
lastClient = client;
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<AutomationRulesTab />
|
<AutomationRulesTab />
|
||||||
@@ -93,6 +96,19 @@ describe("varroa rule form", () => {
|
|||||||
expect(config.notify).toBe(true);
|
expect(config.notify).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("saving the varroa rule marks the cached thresholds stale", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTab([VARROA]);
|
||||||
|
lastClient.setQueryData(["varroa", "thresholds", "c-1", "2026-08-10"], { bands: {} });
|
||||||
|
const view = await card(VARROA_TITLE);
|
||||||
|
|
||||||
|
await user.click(view.getByRole("button", { name: "Speichern" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(lastClient.getQueryState(["varroa", "thresholds", "c-1", "2026-08-10"])?.isInvalidated).toBe(true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("a 400 issue appears under its field", async () => {
|
it("a 400 issue appears under its field", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderTab([VARROA]);
|
renderTab([VARROA]);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { RuleFormProps } from "./rule-forms/rule-form-parts";
|
|||||||
import { SeasonAutopilotForm } from "./rule-forms/season-autopilot-form";
|
import { SeasonAutopilotForm } from "./rule-forms/season-autopilot-form";
|
||||||
import { SplitScheduleForm } from "./rule-forms/split-schedule-form";
|
import { SplitScheduleForm } from "./rule-forms/split-schedule-form";
|
||||||
import { RuleConfigValue } from "./rule-forms/use-rule-draft";
|
import { RuleConfigValue } from "./rule-forms/use-rule-draft";
|
||||||
|
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
|
||||||
import { VarroaThresholdForm } from "./rule-forms/varroa-threshold-form";
|
import { VarroaThresholdForm } from "./rule-forms/varroa-threshold-form";
|
||||||
import { WaterContentForm } from "./rule-forms/water-content-form";
|
import { WaterContentForm } from "./rule-forms/water-content-form";
|
||||||
|
|
||||||
@@ -54,6 +55,9 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setIssues({});
|
setIssues({});
|
||||||
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
|
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
|
||||||
|
if (rule.triggerType === "VARROA_THRESHOLD") {
|
||||||
|
void invalidateVarroaBands(queryClient);
|
||||||
|
}
|
||||||
toast.success("Regel gespeichert");
|
toast.success("Regel gespeichert");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { FileClock, Hexagon } from "lucide-react";
|
import { FileClock, Hexagon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { NavCounter } from "@/components/suggestions/nav-counter";
|
||||||
import type { AuthUser } from "@/lib/auth/auth-context";
|
import type { AuthUser } from "@/lib/auth/auth-context";
|
||||||
import { isNavActive, type NavItem, visibleNavGroups } from "./nav-config";
|
import { isNavActive, type NavItem, visibleNavGroups } from "./nav-config";
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
|
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
|
import {
|
||||||
|
acceptSuggestion,
|
||||||
|
bulkAcceptSuggestions,
|
||||||
|
dismissSuggestion,
|
||||||
|
fetchSuggestions,
|
||||||
|
fetchSuggestionSummary,
|
||||||
|
reopenSuggestion,
|
||||||
|
} from "@/lib/suggestions/api";
|
||||||
|
import { readRound } from "@/lib/rounds/round-state";
|
||||||
|
import type { Suggestion } from "@/lib/suggestions/types";
|
||||||
|
import SuggestionsPage from "./page";
|
||||||
|
|
||||||
|
const auth = vi.hoisted(() => ({
|
||||||
|
user: { id: "user-1" } as { id: string } | null,
|
||||||
|
permissions: new Set(["tasks:read", "tasks:create", "tasks:update"]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth/auth-context", () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
user: auth.user,
|
||||||
|
hasPermission: (permission: string) => auth.permissions.has(permission),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
|
||||||
|
fetchSuggestions: vi.fn(),
|
||||||
|
fetchSuggestionSummary: vi.fn(),
|
||||||
|
acceptSuggestion: vi.fn(),
|
||||||
|
bulkAcceptSuggestions: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
bulkDismissSuggestions: vi.fn(),
|
||||||
|
reopenSuggestion: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const OPEN: Suggestion[] = [
|
||||||
|
suggestionFixture({ id: "s1", colonyId: "c1", colonyCode: "A1", intent: "HONEY_SUPER" }),
|
||||||
|
suggestionFixture({ id: "s2", colonyId: "c2", colonyCode: "A2", intent: "HONEY_SUPER" }),
|
||||||
|
suggestionFixture({
|
||||||
|
id: "s3",
|
||||||
|
mode: "PLAN",
|
||||||
|
intent: "STOCK_REORDER",
|
||||||
|
apiaryId: null,
|
||||||
|
apiaryName: null,
|
||||||
|
colonyId: null,
|
||||||
|
colonyCode: null,
|
||||||
|
title: "Gläser nachbestellen",
|
||||||
|
reasons: [],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const SINGLE = OPEN[2];
|
||||||
|
|
||||||
|
function mockLists(
|
||||||
|
open: Suggestion[],
|
||||||
|
options: { dismissed?: Suggestion[]; pageSize?: number; total?: number } = {},
|
||||||
|
) {
|
||||||
|
const { dismissed = [], pageSize = Number.POSITIVE_INFINITY, total = open.length } = options;
|
||||||
|
vi.mocked(fetchSuggestions).mockImplementation(async (query) =>
|
||||||
|
query?.status === "DISMISSED"
|
||||||
|
? dismissed
|
||||||
|
: open.slice(query?.offset ?? 0, (query?.offset ?? 0) + pageSize),
|
||||||
|
);
|
||||||
|
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
|
||||||
|
open: total,
|
||||||
|
byPriority: { LOW: 0, NORMAL: total, HIGH: 0, URGENT: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<SuggestionsPage />
|
||||||
|
<Toaster />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function manyPlan(count: number): Suggestion[] {
|
||||||
|
return Array.from({ length: count }, (_, index) =>
|
||||||
|
suggestionFixture({ id: `m${index}`, colonyId: `c${index}`, colonyCode: `B${index}` }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsdom has no matchMedia; Sonner's Toaster asks for the color scheme.
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
addListener: () => undefined,
|
||||||
|
removeListener: () => undefined,
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sonner captures the pointer for swiping a toast.
|
||||||
|
HTMLElement.prototype.setPointerCapture = () => undefined;
|
||||||
|
HTMLElement.prototype.releasePointerCapture = () => undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
toast.dismiss();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
auth.user = { id: "user-1" };
|
||||||
|
auth.permissions = new Set(["tasks:read", "tasks:create", "tasks:update"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SuggestionsPage", () => {
|
||||||
|
it("shows „3 Vorschläge“ and the explanation", async () => {
|
||||||
|
mockLists(OPEN);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("3 Vorschläge")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Übernehmen plant eine Aufgabe. Verwerfen blendet den Vorschlag aus."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Hang" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Betrieb" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a row once when two pages overlap", async () => {
|
||||||
|
const rows = manyPlan(201);
|
||||||
|
vi.mocked(fetchSuggestions).mockImplementation(async (query) =>
|
||||||
|
query?.offset ? [rows[199], rows[200]] : rows.slice(0, 200),
|
||||||
|
);
|
||||||
|
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
|
||||||
|
open: 201,
|
||||||
|
byPriority: { LOW: 0, NORMAL: 201, HIGH: 0, URGENT: 0 },
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Mehr laden" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("201 Vorschläge")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer more when the last page was short", async () => {
|
||||||
|
mockLists(OPEN, { total: 5 });
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("3 von 5 Vorschlägen")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the count region mounted while loading and when empty", async () => {
|
||||||
|
mockLists([]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(screen.getByTestId("suggestion-count")).toHaveAttribute("role", "status");
|
||||||
|
expect(await screen.findByText("Keine offenen Vorschläge.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("suggestion-count")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts from the summary and loads more pages with an offset", async () => {
|
||||||
|
mockLists(manyPlan(201), { pageSize: 200 });
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("200 von 201 Vorschlägen")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Mehr laden" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("201 Vorschläge")).toBeInTheDocument();
|
||||||
|
expect(fetchSuggestions).toHaveBeenCalledWith({ status: "OPEN", limit: 200, offset: 200 });
|
||||||
|
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("„Alle übernehmen“ sends one bulk-accept with the group ids", async () => {
|
||||||
|
mockLists(OPEN);
|
||||||
|
vi.mocked(bulkAcceptSuggestions).mockResolvedValue([]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Alle übernehmen" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(bulkAcceptSuggestions).toHaveBeenCalledTimes(1));
|
||||||
|
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][0]).toEqual(["s1", "s2"]);
|
||||||
|
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][1]).toEqual({
|
||||||
|
idempotencyKey: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("2 Aufgaben angelegt")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Ansehen" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports how many were created when a later bulk chunk fails", async () => {
|
||||||
|
mockLists(manyPlan(51));
|
||||||
|
vi.mocked(bulkAcceptSuggestions)
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockRejectedValueOnce(new ApiError(409, "Der Vorschlag ist nicht mehr offen."));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Alle übernehmen" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("50 von 51 angelegt. Rest nicht übernommen – bitte erneut versuchen."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(bulkAcceptSuggestions).toHaveBeenCalledTimes(2);
|
||||||
|
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][0]).toHaveLength(50);
|
||||||
|
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[1][0]).toHaveLength(1);
|
||||||
|
const keys = vi.mocked(bulkAcceptSuggestions).mock.calls.map((call) => call[1]?.idempotencyKey);
|
||||||
|
expect(new Set(keys).size).toBe(2);
|
||||||
|
await waitFor(() => expect(vi.mocked(fetchSuggestions).mock.calls.length).toBeGreaterThan(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepting a single suggestion says „Aufgabe angelegt“ with „Ansehen“", async () => {
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
vi.mocked(acceptSuggestion).mockResolvedValue({ suggestion: SINGLE, tasks: [] });
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Aufgabe angelegt")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Ansehen" })).toBeInTheDocument();
|
||||||
|
expect(acceptSuggestion).toHaveBeenCalledWith("s3", {}, { idempotencyKey: expect.any(String) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the due-date sheet accepts with the chosen day", async () => {
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
vi.mocked(acceptSuggestion).mockResolvedValue({ suggestion: SINGLE, tasks: [] });
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Weitere Aktionen" }));
|
||||||
|
await userEvent.click(await screen.findByRole("menuitem", { name: "Termin ändern" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
const input = within(dialog).getByLabelText("Termin");
|
||||||
|
await userEvent.clear(input);
|
||||||
|
await userEvent.type(input, "2026-06-01");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(acceptSuggestion).toHaveBeenCalledWith(
|
||||||
|
"s3",
|
||||||
|
{ dueDate: "2026-06-01" },
|
||||||
|
{ idempotencyKey: expect.any(String) },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("„Verwerfen“ shows „Verworfen“ and the tapped „Rückgängig“ reopens", async () => {
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
|
||||||
|
vi.mocked(reopenSuggestion).mockResolvedValue(SINGLE);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Verworfen")).toBeInTheDocument();
|
||||||
|
expect(dismissSuggestion).toHaveBeenCalledWith("s3", null, {
|
||||||
|
idempotencyKey: expect.any(String),
|
||||||
|
});
|
||||||
|
const undo = screen.getByRole("button", { name: "Rückgängig" });
|
||||||
|
expect(undo.className).toContain("min-h-11");
|
||||||
|
await userEvent.click(undo);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(reopenSuggestion).toHaveBeenCalledWith("s3", { idempotencyKey: expect.any(String) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains it when „Rückgängig“ answers 409", async () => {
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
|
||||||
|
vi.mocked(reopenSuggestion).mockRejectedValue(
|
||||||
|
new ApiError(409, "Für dieses Volk ist schon ein Vorschlag „Honigraum prüfen“ offen."),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Rückgängig" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Für dieses Volk ist schon ein Vorschlag „Honigraum prüfen“ offen."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains a 409 in the server's words and reloads", async () => {
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
vi.mocked(acceptSuggestion).mockRejectedValue(
|
||||||
|
new ApiError(409, "Der Vorschlag ist nicht mehr offen."),
|
||||||
|
);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Der Vorschlag ist nicht mehr offen.")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(vi.mocked(fetchSuggestions).mock.calls.length).toBeGreaterThan(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows German texts for a 400 array, a 404 and a lost connection", async () => {
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
vi.mocked(acceptSuggestion)
|
||||||
|
.mockRejectedValueOnce(new ApiError(400, "dueDate must match", { message: ["dueDate must match"] }))
|
||||||
|
.mockRejectedValueOnce(new ApiError(404, "Not Found"))
|
||||||
|
.mockRejectedValueOnce(new TypeError("Failed to fetch"));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const accept = await screen.findByRole("button", { name: "Übernehmen" });
|
||||||
|
await userEvent.click(accept);
|
||||||
|
expect(await screen.findByText("Die Eingabe ist ungültig.")).toBeInTheDocument();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||||
|
expect(await screen.findByText("Der Vorschlag ist nicht mehr da.")).toBeInTheDocument();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||||
|
expect(await screen.findByText("Keine Verbindung – nichts gespeichert.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves focus to the next card after a decision", async () => {
|
||||||
|
mockLists([SINGLE, OPEN[0]]);
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({ ...OPEN[0], status: "DISMISSED" });
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
await userEvent.click(buttons[0]);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("heading", { name: "Gläser nachbestellen" })).toHaveFocus(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not move focus after a failed decision, even when the list changes", async () => {
|
||||||
|
mockLists([SINGLE, OPEN[0]]);
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue(new ApiError(409, "Der Vorschlag ist nicht mehr offen."));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
await userEvent.click(buttons[0]);
|
||||||
|
await screen.findByText("Der Vorschlag ist nicht mehr offen.");
|
||||||
|
await waitFor(() => expect(screen.queryByRole("heading", { name: "Honigraum prüfen" })).not.toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByTestId("suggestion-count")).not.toHaveFocus();
|
||||||
|
expect(screen.getByRole("heading", { name: "Gläser nachbestellen" })).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves focus to the count region when the last card is gone", async () => {
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const dismiss = await screen.findByRole("button", { name: "Verwerfen" });
|
||||||
|
mockLists([]);
|
||||||
|
await userEvent.click(dismiss);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId("suggestion-count")).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the restored card after „Rückgängig“", async () => {
|
||||||
|
mockLists([SINGLE, OPEN[0]]);
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({ ...OPEN[0], status: "DISMISSED" });
|
||||||
|
vi.mocked(reopenSuggestion).mockResolvedValue(OPEN[0]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
|
||||||
|
mockLists([SINGLE]);
|
||||||
|
await userEvent.click(buttons[0]);
|
||||||
|
const undo = await screen.findByRole("button", { name: "Rückgängig" });
|
||||||
|
mockLists([SINGLE, OPEN[0]]);
|
||||||
|
await userEvent.click(undo);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("heading", { name: "Honigraum prüfen" })).toHaveFocus(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists dismissed suggestions with „Wieder öffnen“", async () => {
|
||||||
|
const dismissed = suggestionFixture({
|
||||||
|
id: "d1",
|
||||||
|
status: "DISMISSED",
|
||||||
|
title: "Varroa messen",
|
||||||
|
decidedAt: "2026-05-10T08:00:00.000Z",
|
||||||
|
});
|
||||||
|
mockLists(OPEN, { dismissed: [dismissed] });
|
||||||
|
vi.mocked(reopenSuggestion).mockResolvedValue(dismissed);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
const list = await screen.findByRole("list", { name: "Verworfene Vorschläge" });
|
||||||
|
expect(within(list).getByText("Varroa messen")).toBeInTheDocument();
|
||||||
|
await userEvent.click(within(list).getByRole("button", { name: "Wieder öffnen" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(reopenSuggestion).toHaveBeenCalledWith("d1", { idempotencyKey: expect.any(String) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows „Keine offenen Vorschläge.“ when empty", async () => {
|
||||||
|
mockLists([]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("Keine offenen Vorschläge.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Verworfene (30 Tage)" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error with retry instead of an empty list when loading fails", async () => {
|
||||||
|
vi.mocked(fetchSuggestions).mockRejectedValue(new Error("boom"));
|
||||||
|
vi.mocked(fetchSuggestionSummary).mockRejectedValue(new Error("boom"));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent("Vorschläge konnten nicht geladen werden");
|
||||||
|
expect(screen.queryByText("Keine offenen Vorschläge.")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("„Runde starten“ starts a round with the due colonies in code order", async () => {
|
||||||
|
mockLists([
|
||||||
|
suggestionFixture({ id: "i1", mode: "DO", intent: "INSPECT", colonyId: "c10", colonyCode: "A10" }),
|
||||||
|
suggestionFixture({ id: "i2", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
|
||||||
|
]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const link = await screen.findByRole("link", { name: "Runde starten" });
|
||||||
|
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c2&round=a1");
|
||||||
|
await userEvent.click(link);
|
||||||
|
|
||||||
|
const round = readRound("user-1");
|
||||||
|
expect(round?.apiaryId).toBe("a1");
|
||||||
|
expect(round?.apiaryName).toBe("Hang");
|
||||||
|
expect(round?.colonies.map((c) => c.code)).toEqual(["A2", "A10"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("„Runde starten“ without a user is a plain link to the first inspection", async () => {
|
||||||
|
auth.user = null;
|
||||||
|
mockLists([
|
||||||
|
suggestionFixture({ id: "i1", mode: "DO", intent: "INSPECT", colonyId: "c10", colonyCode: "A10" }),
|
||||||
|
suggestionFixture({ id: "i2", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
|
||||||
|
]);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const link = await screen.findByRole("link", { name: "Runde starten" });
|
||||||
|
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the buttons the user may not use", async () => {
|
||||||
|
auth.permissions = new Set(["tasks:read"]);
|
||||||
|
mockLists(OPEN);
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
await screen.findByText("3 Vorschläge");
|
||||||
|
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Verwerfen" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user