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
@@ -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) };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user