feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9634
-340
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import { loadAutopilotInputIn } from './autopilot-input';
|
||||
import { lateSummerStart, loadAutopilotInputIn } from './autopilot-input';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
const season = calendarSeasonState(
|
||||
@@ -55,7 +55,13 @@ describe('loadAutopilotInputIn', () => {
|
||||
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
|
||||
]);
|
||||
|
||||
const input = await loadAutopilotInputIn(db as never, 'c1', season);
|
||||
const input = await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(input?.lastTreatment).toEqual({
|
||||
id: 'old',
|
||||
@@ -74,7 +80,13 @@ describe('loadAutopilotInputIn', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await loadAutopilotInputIn(db as never, 'c1', season);
|
||||
await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
|
||||
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
|
||||
@@ -82,9 +94,119 @@ describe('loadAutopilotInputIn', () => {
|
||||
});
|
||||
|
||||
it('has no last treatment without treatments', async () => {
|
||||
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season);
|
||||
const input = await loadAutopilotInputIn(
|
||||
tx([]) as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(input?.lastTreatment).toBeNull();
|
||||
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
|
||||
});
|
||||
|
||||
it('counts feedings and treatments from the real start of SPAETSOMMER', async () => {
|
||||
const lateSummer = {
|
||||
...season,
|
||||
segment: 'SPAETSOMMER' as const,
|
||||
segmentSince: '2026-07-25',
|
||||
};
|
||||
const db = tx([]);
|
||||
|
||||
await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
lateSummer,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(db.feeding.count).toHaveBeenLastCalledWith({
|
||||
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
|
||||
});
|
||||
expect(db.treatment.count).toHaveBeenCalledWith({
|
||||
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
|
||||
});
|
||||
});
|
||||
|
||||
it('counts nothing for late summer outside SPAETSOMMER and HERBST', async () => {
|
||||
const db = tx([]);
|
||||
|
||||
const input = await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
{ ...season, segment: 'SOMMER' },
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
expect(input?.feedingsSinceLateSummer).toBe(0);
|
||||
expect(input?.treatmentsSinceLateSummer).toBe(0);
|
||||
expect(db.treatment.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('without an inspection counts feedings from the later of creation and the last 60 days', async () => {
|
||||
const db = tx([]);
|
||||
|
||||
await loadAutopilotInputIn(
|
||||
db as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
|
||||
// created 2026-04-01, today 2026-08-20: 60 days back wins
|
||||
expect(db.feeding.count).toHaveBeenNthCalledWith(1, {
|
||||
where: { colonyId: 'c1', date: { gte: d('2026-06-21') } },
|
||||
});
|
||||
const young = tx([]);
|
||||
young.colony.findUnique.mockResolvedValue({
|
||||
id: 'c1',
|
||||
code: 'B3',
|
||||
status: 'ACTIVE',
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
apiaryId: 'api-1',
|
||||
createdAt: d('2026-08-10'),
|
||||
feedStatus: null,
|
||||
currentQueen: null,
|
||||
});
|
||||
await loadAutopilotInputIn(
|
||||
young as never,
|
||||
'c1',
|
||||
season,
|
||||
'08-01',
|
||||
'2026-08-20',
|
||||
);
|
||||
expect(young.feeding.count).toHaveBeenNthCalledWith(1, {
|
||||
where: { colonyId: 'c1', date: { gte: d('2026-08-10') } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('lateSummerStart', () => {
|
||||
const state = (
|
||||
segment: 'SPAETSOMMER' | 'HERBST' | 'SOMMER',
|
||||
segmentSince: string,
|
||||
) => ({ ...season, year: 2026, segment, segmentSince });
|
||||
|
||||
it('is the start of the segment in SPAETSOMMER', () => {
|
||||
expect(lateSummerStart(state('SPAETSOMMER', '2026-07-25'), '08-01')).toBe(
|
||||
'2026-07-25',
|
||||
);
|
||||
});
|
||||
|
||||
it('is the calendar day of TRACHTENDE in HERBST, at the latest the start of HERBST', () => {
|
||||
expect(lateSummerStart(state('HERBST', '2026-09-15'), '08-01')).toBe(
|
||||
'2026-08-01',
|
||||
);
|
||||
expect(lateSummerStart(state('HERBST', '2026-07-20'), '08-01')).toBe(
|
||||
'2026-07-20',
|
||||
);
|
||||
});
|
||||
|
||||
it('is null in every other segment', () => {
|
||||
expect(lateSummerStart(state('SOMMER', '2026-06-20'), '08-01')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,35 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import { berlinDayOf, dayToDate } from '../common/dates';
|
||||
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
|
||||
import type { SeasonState } from '../season/season-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;
|
||||
/** Without an inspection a feeding counts for A6 if it is at most this old. */
|
||||
const FEEDING_LOOKBACK_DAYS = 60;
|
||||
|
||||
/**
|
||||
* The day SPAETSOMMER began, for the feedings and treatments of A4/A5. In
|
||||
* SPAETSOMMER that is the real start of the segment. The stored state keeps
|
||||
* only the start of the current segment, so in HERBST it is the calendar day
|
||||
* of TRACHTENDE (`calendarTrachtende`, MM-DD), at the latest the start of
|
||||
* HERBST.
|
||||
*
|
||||
* TODO: store the real start as `lateSummerSince` on ApiaryPhenology (needs
|
||||
* a migration) so HERBST no longer depends on the calendar day.
|
||||
*/
|
||||
export function lateSummerStart(
|
||||
season: SeasonState,
|
||||
calendarTrachtende: string,
|
||||
): string | null {
|
||||
if (season.segment === 'SPAETSOMMER') {
|
||||
return season.segmentSince;
|
||||
}
|
||||
if (season.segment !== 'HERBST') {
|
||||
return null;
|
||||
}
|
||||
const calendarDay = `${season.year}-${calendarTrachtende}`;
|
||||
return calendarDay < season.segmentSince ? calendarDay : season.segmentSince;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,6 +42,8 @@ export async function loadAutopilotInputIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyId: string,
|
||||
season: SeasonState,
|
||||
calendarTrachtende: string,
|
||||
today: string,
|
||||
): Promise<AutopilotInput | null> {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
@@ -86,13 +102,18 @@ export async function loadAutopilotInputIn(
|
||||
(await tx.varroaMeasurement.count({
|
||||
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
|
||||
})) > 0;
|
||||
// Feedings that answer a low feed status: since the inspection; without
|
||||
// one only recent ones, from the colony's creation at the earliest.
|
||||
const createdOn = berlinDayOf(colony.createdAt);
|
||||
const lookbackFrom = addDays(today, -FEEDING_LOOKBACK_DAYS);
|
||||
const feedingsSince =
|
||||
inspection === null
|
||||
? dayToDate(createdOn > lookbackFrom ? createdOn : lookbackFrom)
|
||||
: inspection.date;
|
||||
const feedingsSinceInspection = await tx.feeding.count({
|
||||
where: {
|
||||
colonyId,
|
||||
...(inspection === null ? {} : { date: { gte: inspection.date } }),
|
||||
},
|
||||
where: { colonyId, date: { gte: feedingsSince } },
|
||||
});
|
||||
const lateSummer = lateSummerStart(season);
|
||||
const lateSummer = lateSummerStart(season, calendarTrachtende);
|
||||
const sinceLateSummer =
|
||||
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
|
||||
const feedingsSinceLateSummer =
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import type {
|
||||
PhaseSourceKey,
|
||||
PhenologyPhaseKey,
|
||||
SeasonSegmentKey,
|
||||
SeasonState,
|
||||
} from '../season/season-types';
|
||||
import { evaluateAutopilot } from './autopilot-rules';
|
||||
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
|
||||
|
||||
@@ -62,6 +68,12 @@ const inspectionOn = (date: string) => ({
|
||||
queenCellsPresent: null,
|
||||
});
|
||||
|
||||
/** Treated and fed in late summer: keeps A4 and A5 out of the A3 tests. */
|
||||
const LATE_SUMMER_DONE = {
|
||||
treatmentsSinceLateSummer: 1,
|
||||
feedingsSinceLateSummer: 1,
|
||||
};
|
||||
|
||||
const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
|
||||
|
||||
describe('autopilot rules', () => {
|
||||
@@ -227,6 +239,7 @@ describe('autopilot rules', () => {
|
||||
lastInspection: inspectionOn('2026-08-10'),
|
||||
lastVarroa: { id: 'var_3', date: '2026-08-01' },
|
||||
lastTreatment: TREATMENT,
|
||||
...LATE_SUMMER_DONE,
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
@@ -256,6 +269,8 @@ describe('autopilot rules', () => {
|
||||
input(early, {
|
||||
lastInspection: inspectionOn('2026-08-10'),
|
||||
lastTreatment: TREATMENT,
|
||||
...LATE_SUMMER_DONE,
|
||||
...LATE_SUMMER_DONE,
|
||||
}),
|
||||
configs(),
|
||||
early,
|
||||
@@ -268,6 +283,8 @@ describe('autopilot rules', () => {
|
||||
input(late, {
|
||||
lastInspection: inspectionOn('2026-08-15'),
|
||||
lastTreatment: TREATMENT,
|
||||
...LATE_SUMMER_DONE,
|
||||
...LATE_SUMMER_DONE,
|
||||
varroaSinceLastTreatmentEnd: true,
|
||||
}),
|
||||
configs(),
|
||||
@@ -286,6 +303,7 @@ describe('autopilot rules', () => {
|
||||
input(today, {
|
||||
lastInspection: inspectionOn('2026-08-05'),
|
||||
lastTreatment: TREATMENT,
|
||||
...LATE_SUMMER_DONE,
|
||||
}),
|
||||
cfg,
|
||||
today,
|
||||
@@ -370,3 +388,437 @@ describe('autopilot rules', () => {
|
||||
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autopilot rules A4-A10', () => {
|
||||
/** A stored season state; the helper keeps the tests free of calendar maths. */
|
||||
function inSeason(
|
||||
segment: SeasonSegmentKey,
|
||||
phase: PhenologyPhaseKey | null,
|
||||
segmentSince: string,
|
||||
phaseSource: PhaseSourceKey = 'REPORT',
|
||||
): SeasonState {
|
||||
return {
|
||||
apiaryId: 'api_1',
|
||||
year: Number(segmentSince.slice(0, 4)),
|
||||
phase,
|
||||
phaseSource,
|
||||
reportedOn: null,
|
||||
segment,
|
||||
segmentSince,
|
||||
springSince: `${segmentSince.slice(0, 4)}-03-20`,
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
note: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps A1 and A2 quiet: a fresh inspection and measurement. */
|
||||
function quiet(today: string, patch: Partial<AutopilotInput>) {
|
||||
return input(today, {
|
||||
lastInspection: inspectionOn(today),
|
||||
lastVarroa: { id: 'var_1', date: today },
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
const only = (rule: string, candidates: { rule: string }[]) =>
|
||||
candidates.filter((candidate) => candidate.rule === rule);
|
||||
|
||||
it('A4: SPAETSOMMER without treatment since segment start -> SUMMER_TREATMENT, PLAN, HIGH, anchor year', () => {
|
||||
const today = '2026-08-05';
|
||||
const result = evaluateAutopilot(
|
||||
quiet(today, {
|
||||
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
|
||||
expect(only('A4', result)).toEqual([
|
||||
{
|
||||
rule: 'A4',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'SUMMER_TREATMENT',
|
||||
mode: 'PLAN',
|
||||
reasonCode: 'SUMMER_TREATMENT_DUE',
|
||||
evidence: {
|
||||
colonyId: 'col_1',
|
||||
phase: 'TRACHTENDE',
|
||||
phaseSource: 'REPORT',
|
||||
since: '2026-07-25',
|
||||
reportedOn: null,
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'HIGH',
|
||||
anchor: '2026',
|
||||
requiredPermission: 'treatments:read',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('A4: a treatment since the segment start, another segment or the rule off -> nothing', () => {
|
||||
const today = '2026-08-05';
|
||||
const season = inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25');
|
||||
const a4 = (candidates: { rule: string }[]) => only('A4', candidates);
|
||||
expect(
|
||||
a4(
|
||||
evaluateAutopilot(
|
||||
quiet(today, { season, treatmentsSinceLateSummer: 1 }),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
a4(
|
||||
evaluateAutopilot(
|
||||
quiet(today, { season: inSeason('SOMMER', 'LINDE', '2026-06-20') }),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
a4(
|
||||
evaluateAutopilot(
|
||||
quiet(today, { season }),
|
||||
configs({
|
||||
rules: { ...configs().autopilot.config.rules, A4: false },
|
||||
}),
|
||||
today,
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('A5: SPAETSOMMER or HERBST without feeding since late summer -> FEED, DO, NORMAL, anchor year', () => {
|
||||
for (const [segment, since, today] of [
|
||||
['SPAETSOMMER', '2026-07-25', '2026-08-05'],
|
||||
['HERBST', '2026-09-15', '2026-09-20'],
|
||||
] as const) {
|
||||
const result = evaluateAutopilot(
|
||||
quiet(today, { season: inSeason(segment, 'TRACHTENDE', since) }),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
expect(only('A5', result)).toMatchObject([
|
||||
{
|
||||
rule: 'A5',
|
||||
intent: 'FEED',
|
||||
mode: 'DO',
|
||||
reasonCode: 'AUTUMN_FEED_MISSING',
|
||||
priority: 'NORMAL',
|
||||
anchor: '2026',
|
||||
dueDate: today,
|
||||
requiredPermission: 'feedings:read',
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('A5 and A6 never fire in WINTERRUHE; A5 is quiet after a feeding', () => {
|
||||
const today = '2026-12-05';
|
||||
const winter = quiet(today, {
|
||||
season: inSeason('WINTERRUHE', 'WINTER', '2026-11-15'),
|
||||
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
|
||||
lastInspection: { ...inspectionOn('2026-11-01'), feedStatus: 'CRITICAL' },
|
||||
});
|
||||
expect(
|
||||
evaluateAutopilot(winter, configs(), today).filter((c) =>
|
||||
['A5', 'A6'].includes(c.rule),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
const augustToday = '2026-08-05';
|
||||
const fed = quiet(augustToday, {
|
||||
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||
feedingsSinceLateSummer: 1,
|
||||
});
|
||||
expect(only('A5', evaluateAutopilot(fed, configs(), augustToday))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('A6 anchors on the inspection: WEAK or CRITICAL feed of the last inspection, no feeding since', () => {
|
||||
const today = '2026-04-10';
|
||||
const base: Partial<AutopilotInput> = {
|
||||
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'WEAK' },
|
||||
lastVarroa: { id: 'var_1', date: today },
|
||||
};
|
||||
const result = only(
|
||||
'A6',
|
||||
evaluateAutopilot(input(today, base), configs(), today),
|
||||
);
|
||||
expect(result).toMatchObject([
|
||||
{
|
||||
intent: 'FEED',
|
||||
mode: 'DO',
|
||||
reasonCode: 'FEED_LOW',
|
||||
anchor: 'insp_9',
|
||||
priority: 'NORMAL',
|
||||
dueDate: today,
|
||||
requiredPermission: 'inspections:read',
|
||||
evidence: {
|
||||
colonyId: 'col_1',
|
||||
inspectionId: 'insp_9',
|
||||
inspectedOn: '2026-04-03',
|
||||
condition: 'WEAK',
|
||||
source: 'INSPECTION',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const fedSince = input(today, { ...base, feedingsSinceInspection: 1 });
|
||||
expect(only('A6', evaluateAutopilot(fedSince, configs(), today))).toEqual(
|
||||
[],
|
||||
);
|
||||
const good = input(today, {
|
||||
...base,
|
||||
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'GOOD' },
|
||||
});
|
||||
expect(only('A6', evaluateAutopilot(good, configs(), today))).toEqual([]);
|
||||
});
|
||||
|
||||
it('A6: a CRITICAL colony card counts too, anchored on the inspection or none', () => {
|
||||
const today = '2026-04-10';
|
||||
const critical = input(today, {
|
||||
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
|
||||
lastInspection: inspectionOn('2026-04-03'),
|
||||
});
|
||||
expect(
|
||||
only('A6', evaluateAutopilot(critical, configs(), today)),
|
||||
).toMatchObject([
|
||||
{
|
||||
anchor: 'insp_9',
|
||||
evidence: { condition: 'CRITICAL', source: 'COLONY_CARD' },
|
||||
},
|
||||
]);
|
||||
const noInspection = { ...critical, lastInspection: null };
|
||||
expect(
|
||||
only('A6', evaluateAutopilot(noInspection, configs(), today)),
|
||||
).toMatchObject([{ anchor: 'none' }]);
|
||||
});
|
||||
|
||||
it('A7: KIRSCHE and 9 frames -> HONEY_SUPER; 7 frames -> nothing; LINDE -> nothing', () => {
|
||||
const today = '2026-04-28';
|
||||
const withFrames = (frames: number | null, phase: PhenologyPhaseKey) =>
|
||||
only(
|
||||
'A7',
|
||||
evaluateAutopilot(
|
||||
quiet(today, {
|
||||
season: inSeason('SCHWARMZEIT', phase, '2026-04-15', 'GTS'),
|
||||
lastInspection: { ...inspectionOn(today), colonyStrength: frames },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
);
|
||||
|
||||
expect(withFrames(9, 'KIRSCHE')).toMatchObject([
|
||||
{
|
||||
rule: 'A7',
|
||||
intent: 'HONEY_SUPER',
|
||||
mode: 'DO',
|
||||
reasonCode: 'HONEY_SUPER_CHECK',
|
||||
anchor: '2026',
|
||||
priority: 'NORMAL',
|
||||
dueDate: today,
|
||||
requiredPermission: 'inspections:read',
|
||||
evidence: {
|
||||
colonyId: 'col_1',
|
||||
inspectionId: 'insp_9',
|
||||
frames: 9,
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'GTS',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(withFrames(8, 'APFEL')).toHaveLength(1);
|
||||
expect(withFrames(7, 'KIRSCHE')).toEqual([]);
|
||||
expect(withFrames(null, 'KIRSCHE')).toEqual([]);
|
||||
expect(withFrames(9, 'SALWEIDE')).toEqual([]);
|
||||
expect(withFrames(9, 'LINDE')).toEqual([]);
|
||||
});
|
||||
|
||||
it('A7 ignores an inspection from before the spring of this year', () => {
|
||||
const today = '2026-04-28';
|
||||
// inSeason puts springSince on 2026-03-20
|
||||
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||
const withInspection = (date: string) =>
|
||||
only(
|
||||
'A7',
|
||||
evaluateAutopilot(
|
||||
quiet(today, {
|
||||
season,
|
||||
lastInspection: { ...inspectionOn(date), colonyStrength: 12 },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
),
|
||||
);
|
||||
|
||||
expect(withInspection('2025-09-10')).toEqual([]);
|
||||
expect(withInspection('2026-03-19')).toEqual([]);
|
||||
expect(withInspection('2026-03-20')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('A7 honours honeySuperMinFrames and a missing inspection', () => {
|
||||
const today = '2026-04-28';
|
||||
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||
const nine = quiet(today, {
|
||||
season,
|
||||
lastInspection: { ...inspectionOn(today), colonyStrength: 9 },
|
||||
});
|
||||
expect(
|
||||
only(
|
||||
'A7',
|
||||
evaluateAutopilot(nine, configs({ honeySuperMinFrames: 10 }), today),
|
||||
),
|
||||
).toEqual([]);
|
||||
const none = input(today, { season, lastInspection: null });
|
||||
expect(only('A7', evaluateAutopilot(none, configs(), today))).toEqual([]);
|
||||
});
|
||||
|
||||
it('A8: hatchYear 2024 in 2026 SCHWARMZEIT -> QUEEN_REPLACE, PLAN, anchor queen-year', () => {
|
||||
const today = '2026-05-12';
|
||||
const aged = quiet(today, {
|
||||
season: inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15'),
|
||||
queen: { id: 'q_12', hatchYear: 2024 },
|
||||
});
|
||||
expect(only('A8', evaluateAutopilot(aged, configs(), today))).toMatchObject(
|
||||
[
|
||||
{
|
||||
rule: 'A8',
|
||||
intent: 'QUEEN_REPLACE',
|
||||
mode: 'PLAN',
|
||||
reasonCode: 'QUEEN_OLD',
|
||||
anchor: 'q_12-2026',
|
||||
priority: 'NORMAL',
|
||||
requiredPermission: 'queens:read',
|
||||
evidence: { colonyId: 'col_1', queenId: 'q_12', hatchYear: 2024 },
|
||||
},
|
||||
],
|
||||
);
|
||||
const sommerDay = '2026-07-01';
|
||||
const sommer = {
|
||||
...aged,
|
||||
season: inSeason('SOMMER', 'LINDE', '2026-06-20'),
|
||||
};
|
||||
expect(
|
||||
only('A8', evaluateAutopilot(sommer, configs(), sommerDay)),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('A8: young queen, unknown hatch year, other segment or queenMaxAgeYears 3 -> nothing', () => {
|
||||
const today = '2026-05-12';
|
||||
const swarm = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
|
||||
const a8 = (
|
||||
queen: AutopilotInput['queen'],
|
||||
over?: object,
|
||||
season = swarm,
|
||||
) =>
|
||||
only(
|
||||
'A8',
|
||||
evaluateAutopilot(
|
||||
quiet(today, { season, queen }),
|
||||
configs(over),
|
||||
today,
|
||||
),
|
||||
);
|
||||
expect(a8({ id: 'q', hatchYear: 2025 })).toEqual([]);
|
||||
expect(a8({ id: 'q', hatchYear: null })).toEqual([]);
|
||||
expect(a8(null)).toEqual([]);
|
||||
expect(a8({ id: 'q', hatchYear: 2024 }, { queenMaxAgeYears: 3 })).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
a8(
|
||||
{ id: 'q', hatchYear: 2024 },
|
||||
undefined,
|
||||
inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('A9 and A10 follow the WinterCheck rows', () => {
|
||||
const autumnDay = '2026-09-20';
|
||||
const autumn = (winterChecks: AutopilotInput['winterChecks']) =>
|
||||
only(
|
||||
'A9',
|
||||
evaluateAutopilot(
|
||||
quiet(autumnDay, {
|
||||
season: inSeason('HERBST', 'HERBST', '2026-09-15'),
|
||||
winterChecks,
|
||||
}),
|
||||
configs(),
|
||||
autumnDay,
|
||||
),
|
||||
);
|
||||
expect(autumn([])).toMatchObject([
|
||||
{
|
||||
rule: 'A9',
|
||||
intent: 'WINTER_CHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
|
||||
anchor: '2026-AUTUMN',
|
||||
priority: 'NORMAL',
|
||||
dueDate: autumnDay,
|
||||
requiredPermission: 'winter-checks:read',
|
||||
evidence: { colonyId: 'col_1', winterYear: 2026 },
|
||||
},
|
||||
]);
|
||||
expect(autumn([{ winterYear: 2026, season: 'AUTUMN' }])).toEqual([]);
|
||||
// The previous winter does not count for this one.
|
||||
expect(autumn([{ winterYear: 2025, season: 'AUTUMN' }])).toHaveLength(1);
|
||||
|
||||
const springDay = '2026-03-25';
|
||||
const spring = (winterChecks: AutopilotInput['winterChecks']) =>
|
||||
only(
|
||||
'A10',
|
||||
evaluateAutopilot(
|
||||
quiet(springDay, {
|
||||
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
|
||||
winterChecks,
|
||||
}),
|
||||
configs(),
|
||||
springDay,
|
||||
),
|
||||
);
|
||||
expect(spring([{ winterYear: 2025, season: 'AUTUMN' }])).toMatchObject([
|
||||
{
|
||||
rule: 'A10',
|
||||
intent: 'WINTER_CHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
|
||||
anchor: '2025-SPRING',
|
||||
dueDate: springDay,
|
||||
requiredPermission: 'winter-checks:read',
|
||||
evidence: { colonyId: 'col_1', winterYear: 2025, phase: 'SALWEIDE' },
|
||||
},
|
||||
]);
|
||||
// No wintering-in last autumn -> nothing to wind out; done -> nothing.
|
||||
expect(spring([])).toEqual([]);
|
||||
expect(
|
||||
spring([
|
||||
{ winterYear: 2025, season: 'AUTUMN' },
|
||||
{ winterYear: 2025, season: 'SPRING' },
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the order A1...A10', () => {
|
||||
const today = '2026-08-05';
|
||||
const result = evaluateAutopilot(
|
||||
input(today, {
|
||||
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
|
||||
lastInspection: inspectionOn('2026-07-01'),
|
||||
lastVarroa: { id: 'var_0', date: '2026-06-01' },
|
||||
}),
|
||||
configs(),
|
||||
today,
|
||||
);
|
||||
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2', 'A4', 'A5']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AutopilotRuleId } from '../automation/rule-config';
|
||||
import { addDays, daysBetween } from '../common/dates';
|
||||
import { inspectionDueOn } from '../season/inspection-due';
|
||||
import { isQueenAged } from '../season/queen-age';
|
||||
import { PHENOLOGY_PHASES } from '../season/season-types';
|
||||
import { varroaMeasurementDue } from '../season/varroa-due';
|
||||
import type {
|
||||
AutopilotCandidate,
|
||||
@@ -127,15 +129,247 @@ const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** A4-A10 follow with Task 27. */
|
||||
export const AUTOPILOT_RULES: Readonly<
|
||||
Partial<Record<AutopilotRuleId, AutopilotRule>>
|
||||
> = {
|
||||
A1: a1Inspection,
|
||||
A2: a2VarroaMeasure,
|
||||
A3: a3VarroaRecheck,
|
||||
/** The phase window of A7: from KIRSCHE up to, not including, LINDE. */
|
||||
const HONEY_SUPER_FROM_PHASE = PHENOLOGY_PHASES.indexOf('KIRSCHE');
|
||||
const HONEY_SUPER_UNTIL_PHASE = PHENOLOGY_PHASES.indexOf('LINDE');
|
||||
|
||||
/** The season facts the texts of A4, A5, A7 and A10 quote. */
|
||||
function seasonEvidence(input: AutopilotInput): {
|
||||
phase: string | null;
|
||||
phaseSource: string | null;
|
||||
} {
|
||||
return {
|
||||
phase: input.season.phase,
|
||||
phaseSource: input.season.phaseSource,
|
||||
};
|
||||
}
|
||||
|
||||
/** A4: the honey flow is over and nothing was treated since. */
|
||||
const a4SummerTreatment: AutopilotRule = (input, _configs, today) => {
|
||||
const { season } = input;
|
||||
if (season.segment !== 'SPAETSOMMER' || input.treatmentsSinceLateSummer > 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A4',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'SUMMER_TREATMENT',
|
||||
mode: 'PLAN',
|
||||
reasonCode: 'SUMMER_TREATMENT_DUE',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
...seasonEvidence(input),
|
||||
since: season.segmentSince,
|
||||
reportedOn: season.reportedOn,
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'HIGH',
|
||||
anchor: String(season.year),
|
||||
requiredPermission: 'treatments:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A5: late summer or autumn and no feeding since the honey flow ended. */
|
||||
const a5AutumnFeeding: AutopilotRule = (input, _configs, today) => {
|
||||
const { season } = input;
|
||||
if (
|
||||
(season.segment !== 'SPAETSOMMER' && season.segment !== 'HERBST') ||
|
||||
input.feedingsSinceLateSummer > 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A5',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'FEED',
|
||||
mode: 'DO',
|
||||
reasonCode: 'AUTUMN_FEED_MISSING',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
...seasonEvidence(input),
|
||||
since: season.segmentSince,
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: String(season.year),
|
||||
requiredPermission: 'feedings:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A6: low feed at the last inspection (or a critical colony card), not fed since. */
|
||||
const a6LowFeed: AutopilotRule = (input, _configs, today) => {
|
||||
if (
|
||||
input.season.segment === 'WINTERRUHE' ||
|
||||
input.feedingsSinceInspection > 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const { lastInspection, colony } = input;
|
||||
const inspectionFeed = lastInspection?.feedStatus ?? null;
|
||||
const isInspectionLow =
|
||||
lastInspection !== null &&
|
||||
(inspectionFeed === 'WEAK' || inspectionFeed === 'CRITICAL');
|
||||
if (!isInspectionLow && colony.feedStatus !== 'CRITICAL') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A6',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'FEED',
|
||||
mode: 'DO',
|
||||
reasonCode: 'FEED_LOW',
|
||||
evidence: {
|
||||
colonyId: colony.id,
|
||||
inspectionId: lastInspection?.id ?? null,
|
||||
inspectedOn: isInspectionLow ? lastInspection.date : null,
|
||||
condition: isInspectionLow ? inspectionFeed : 'CRITICAL',
|
||||
source: isInspectionLow ? 'INSPECTION' : 'COLONY_CARD',
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: lastInspection?.id ?? 'none',
|
||||
requiredPermission: 'inspections:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A7: the cherry blooms and the colony covers enough frames for a honey super. */
|
||||
const a7HoneySuper: AutopilotRule = (input, configs, today) => {
|
||||
const phaseIndex =
|
||||
input.season.phase === null
|
||||
? -1
|
||||
: PHENOLOGY_PHASES.indexOf(input.season.phase);
|
||||
// Only an inspection of this season says how strong the colony is now.
|
||||
const seasonStart = input.season.springSince ?? `${input.season.year}-01-01`;
|
||||
const inspection =
|
||||
input.lastInspection !== null && input.lastInspection.date >= seasonStart
|
||||
? input.lastInspection
|
||||
: null;
|
||||
const frames = inspection?.colonyStrength ?? null;
|
||||
if (
|
||||
phaseIndex < HONEY_SUPER_FROM_PHASE ||
|
||||
phaseIndex >= HONEY_SUPER_UNTIL_PHASE ||
|
||||
frames === null ||
|
||||
frames < configs.autopilot.config.honeySuperMinFrames
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A7',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'HONEY_SUPER',
|
||||
mode: 'DO',
|
||||
reasonCode: 'HONEY_SUPER_CHECK',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
inspectionId: inspection?.id ?? null,
|
||||
frames,
|
||||
...seasonEvidence(input),
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: String(input.season.year),
|
||||
requiredPermission: 'inspections:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A8: the queen has reached the age the beekeeper plans to replace her at. */
|
||||
const a8QueenReplace: AutopilotRule = (input, configs, today) => {
|
||||
const { queen, season } = input;
|
||||
if (
|
||||
queen === null ||
|
||||
(season.segment !== 'SCHWARMZEIT' && season.segment !== 'SOMMER') ||
|
||||
!isQueenAged(
|
||||
queen.hatchYear,
|
||||
today,
|
||||
configs.autopilot.config.queenMaxAgeYears,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A8',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'QUEEN_REPLACE',
|
||||
mode: 'PLAN',
|
||||
reasonCode: 'QUEEN_OLD',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
queenId: queen.id,
|
||||
hatchYear: queen.hatchYear,
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: `${queen.id}-${season.year}`,
|
||||
requiredPermission: 'queens:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A9: autumn and no wintering-in entry for the winter that begins now. */
|
||||
const a9WinteringIn: AutopilotRule = (input, _configs, today) => {
|
||||
const { season } = input;
|
||||
const isRecorded = input.winterChecks.some(
|
||||
(check) => check.winterYear === season.year && check.season === 'AUTUMN',
|
||||
);
|
||||
if (season.segment !== 'HERBST' || isRecorded) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A9',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'WINTER_CHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
|
||||
evidence: { colonyId: input.colony.id, winterYear: season.year },
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: `${season.year}-AUTUMN`,
|
||||
requiredPermission: 'winter-checks:read',
|
||||
};
|
||||
};
|
||||
|
||||
/** A10: spring, wintered in last autumn, not yet wintered out. */
|
||||
const a10WinteringOut: AutopilotRule = (input, _configs, today) => {
|
||||
const { season } = input;
|
||||
const winterYear = season.year - 1;
|
||||
const has = (kind: 'AUTUMN' | 'SPRING') =>
|
||||
input.winterChecks.some(
|
||||
(check) => check.winterYear === winterYear && check.season === kind,
|
||||
);
|
||||
if (season.segment !== 'FRUEHJAHR' || !has('AUTUMN') || has('SPRING')) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rule: 'A10',
|
||||
ruleKey: 'SEASON_AUTOPILOT',
|
||||
intent: 'WINTER_CHECK',
|
||||
mode: 'DO',
|
||||
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
|
||||
evidence: {
|
||||
colonyId: input.colony.id,
|
||||
winterYear,
|
||||
...seasonEvidence(input),
|
||||
},
|
||||
dueDate: today,
|
||||
priority: 'NORMAL',
|
||||
anchor: `${winterYear}-SPRING`,
|
||||
requiredPermission: 'winter-checks:read',
|
||||
};
|
||||
};
|
||||
|
||||
export const AUTOPILOT_RULES: Readonly<Record<AutopilotRuleId, AutopilotRule>> =
|
||||
{
|
||||
A1: a1Inspection,
|
||||
A2: a2VarroaMeasure,
|
||||
A3: a3VarroaRecheck,
|
||||
A4: a4SummerTreatment,
|
||||
A5: a5AutumnFeeding,
|
||||
A6: a6LowFeed,
|
||||
A7: a7HoneySuper,
|
||||
A8: a8QueenReplace,
|
||||
A9: a9WinteringIn,
|
||||
A10: a10WinteringOut,
|
||||
};
|
||||
|
||||
const RULE_ORDER: readonly AutopilotRuleId[] = [
|
||||
'A1',
|
||||
'A2',
|
||||
@@ -164,11 +398,10 @@ export function evaluateAutopilot(
|
||||
}
|
||||
const candidates: AutopilotCandidate[] = [];
|
||||
for (const id of RULE_ORDER) {
|
||||
const rule = AUTOPILOT_RULES[id];
|
||||
if (rule === undefined || !configs.autopilot.config.rules[id]) {
|
||||
if (!configs.autopilot.config.rules[id]) {
|
||||
continue;
|
||||
}
|
||||
const candidate = rule(input, configs, today);
|
||||
const candidate = AUTOPILOT_RULES[id](input, configs, today);
|
||||
if (candidate !== null) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ import type {
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { AutopilotEventHandlers } from './autopilot.handlers';
|
||||
import type { AutopilotService } from './autopilot.service';
|
||||
|
||||
jest.mock('../prisma/row-lock');
|
||||
const lockRows = jest.mocked(lockRowsForUpdate);
|
||||
|
||||
const EXPECTED_TYPES: DomainEventType[] = [
|
||||
'InspectionRecorded',
|
||||
'InspectionCorrected',
|
||||
@@ -27,6 +31,7 @@ const EXPECTED_TYPES: DomainEventType[] = [
|
||||
'QueenAged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
'WinterCheckRecorded',
|
||||
];
|
||||
|
||||
function event(
|
||||
@@ -48,13 +53,17 @@ function event(
|
||||
}
|
||||
|
||||
describe('AutopilotEventHandlers', () => {
|
||||
let autopilot: { syncColonyIn: jest.Mock };
|
||||
let autopilot: { syncColonyIn: jest.Mock; syncApiaryIn: jest.Mock };
|
||||
let tx: { task: { findUnique: jest.Mock } };
|
||||
let handlers: AutopilotEventHandlers;
|
||||
|
||||
beforeEach(() => {
|
||||
lockRows.mockReset();
|
||||
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
|
||||
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
|
||||
autopilot = {
|
||||
syncColonyIn: jest.fn().mockResolvedValue({}),
|
||||
syncApiaryIn: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
tx = { task: { findUnique: jest.fn() } };
|
||||
handlers = new AutopilotEventHandlers(
|
||||
autopilot as unknown as AutopilotService,
|
||||
@@ -93,6 +102,7 @@ describe('AutopilotEventHandlers', () => {
|
||||
['TreatmentCorrected', { colonyId: 'c1' }],
|
||||
['FeedingRecorded', { colonyId: 'c1' }],
|
||||
['FeedingCorrected', { colonyId: 'c1' }],
|
||||
['WinterCheckRecorded', { colonyId: 'c1' }],
|
||||
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
|
||||
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
|
||||
] as [DomainEventType, object][])(
|
||||
@@ -130,6 +140,26 @@ describe('AutopilotEventHandlers', () => {
|
||||
expect(synced()).toEqual(['c1', 'c2', 'c3']);
|
||||
});
|
||||
|
||||
it('locks all colonies of an event in one statement before syncing any', async () => {
|
||||
await run(
|
||||
event('HarvestRecorded', 'h1', {
|
||||
apiaryId: 'a',
|
||||
colonyIds: ['c3', 'c1', 'c2'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['c1', 'c2', 'c3']);
|
||||
expect(lockRows.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
autopilot.syncColonyIn.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('takes no extra lock for a single colony', async () => {
|
||||
await run(event('InspectionRecorded', 'i1', { colonyId: 'c1' }));
|
||||
|
||||
expect(lockRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('syncs the new and the parent colony of a split', async () => {
|
||||
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
|
||||
|
||||
@@ -178,4 +208,54 @@ describe('AutopilotEventHandlers', () => {
|
||||
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
describe('apiarySync', () => {
|
||||
it('is registered as autopilot.apiary-sync for SeasonPhaseChanged only', () => {
|
||||
const entries = domainEventHandlerMetadata(
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
AutopilotEventHandlers.prototype.apiarySync,
|
||||
);
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].handlerId).toBe('autopilot.apiary-sync');
|
||||
expect([...entries[0].types]).toEqual(['SeasonPhaseChanged']);
|
||||
});
|
||||
|
||||
// One event per apiary and day (dailyDedupeKey): a change later the same
|
||||
// day is caught by the daily autopilot step.
|
||||
it('syncs the apiary of the payload for the Berlin day', async () => {
|
||||
await handlers.apiarySync(
|
||||
event('SeasonPhaseChanged', 'api-1', {
|
||||
apiaryId: 'api-1',
|
||||
phase: 'KIRSCHE',
|
||||
segment: 'SCHWARMZEIT',
|
||||
previousPhase: 'SALWEIDE',
|
||||
previousSegment: 'FRUEHJAHR',
|
||||
source: 'REPORT',
|
||||
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
|
||||
tx as unknown as Prisma.TransactionClient,
|
||||
);
|
||||
|
||||
expect(autopilot.syncApiaryIn).toHaveBeenCalledTimes(1);
|
||||
expect(autopilot.syncApiaryIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'api-1',
|
||||
'2026-05-12',
|
||||
);
|
||||
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets errors propagate so the delivery is retried', async () => {
|
||||
autopilot.syncApiaryIn.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
handlers.apiarySync(
|
||||
event('SeasonPhaseChanged', 'api-1', {
|
||||
apiaryId: 'api-1',
|
||||
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
|
||||
tx as unknown as Prisma.TransactionClient,
|
||||
),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { AutopilotService } from './autopilot.service';
|
||||
|
||||
const COLONY_SYNC_EVENTS = [
|
||||
@@ -28,6 +29,7 @@ const COLONY_SYNC_EVENTS = [
|
||||
'QueenAged',
|
||||
'InspectionOverdue',
|
||||
'VarroaMeasurementDue',
|
||||
'WinterCheckRecorded',
|
||||
] as const satisfies readonly DomainEventType[];
|
||||
|
||||
type ColonySyncEvent = AnyDomainEventEnvelope<
|
||||
@@ -61,6 +63,7 @@ async function colonyIdsOf(
|
||||
case 'FeedingRecorded':
|
||||
case 'FeedingCorrected':
|
||||
case 'WithdrawalEnded':
|
||||
case 'WinterCheckRecorded':
|
||||
case 'QueenAged':
|
||||
return [event.payload.colonyId];
|
||||
case 'HarvestRecorded':
|
||||
@@ -85,7 +88,9 @@ async function colonyIdsOf(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles the autopilot suggestions of the colonies an event touches.
|
||||
* Reconciles the autopilot suggestions of the colonies an event touches
|
||||
* (autopilot.colony-sync) and, for a season change, of all colonies of the
|
||||
* apiary (autopilot.apiary-sync).
|
||||
* Pure DB writer through the delivery transaction; the colonies are synced
|
||||
* in id order, so two deliveries never lock them the other way round.
|
||||
*/
|
||||
@@ -104,9 +109,31 @@ export class AutopilotEventHandlers {
|
||||
if (colonyIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (colonyIds.length > 1) {
|
||||
// All colonies in one statement before any sync, like syncApiaryIn.
|
||||
await lockRowsForUpdate(tx, 'Colony', colonyIds);
|
||||
}
|
||||
const today = berlinToday();
|
||||
for (const colonyId of colonyIds) {
|
||||
await this.autopilot.syncColonyIn(tx, colonyId, today);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A season change (new phase or segment) alters the rules for every colony
|
||||
* of the apiary: reconcile them all. There is one such event per apiary and
|
||||
* day (dailyDedupeKey); a change later the same day is caught by the daily
|
||||
* autopilot step.
|
||||
*/
|
||||
@OnDomainEvent(['SeasonPhaseChanged'], 'autopilot.apiary-sync')
|
||||
async apiarySync(
|
||||
event: AnyDomainEventEnvelope<'SeasonPhaseChanged'>,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
await this.autopilot.syncApiaryIn(
|
||||
tx,
|
||||
event.payload.apiaryId,
|
||||
berlinToday(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
readRule,
|
||||
ruleConfigOf,
|
||||
} from '../automation/rule-config';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { calendarSeasonState } from '../season/season-calendar';
|
||||
import type { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
import { loadAutopilotInputIn } from './autopilot-input';
|
||||
@@ -19,6 +19,7 @@ jest.mock('../automation/rule-config', () => ({
|
||||
jest.mock('./autopilot-input');
|
||||
|
||||
const lockRow = jest.mocked(lockRowForUpdate);
|
||||
const lockRows = jest.mocked(lockRowsForUpdate);
|
||||
const readRuleMock = jest.mocked(readRule);
|
||||
const loadInput = jest.mocked(loadAutopilotInputIn);
|
||||
|
||||
@@ -73,7 +74,7 @@ function input(lastInspectedOn: string | null): AutopilotInput {
|
||||
describe('AutopilotService', () => {
|
||||
let tx: {
|
||||
$connect?: undefined;
|
||||
colony: { findUnique: jest.Mock };
|
||||
colony: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
suggestion: { findMany: jest.Mock };
|
||||
};
|
||||
let suggestions: {
|
||||
@@ -92,6 +93,7 @@ describe('AutopilotService', () => {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
@@ -301,7 +303,7 @@ describe('AutopilotService', () => {
|
||||
expect(result.opened).toBe(1);
|
||||
expect(readRuleMock).not.toHaveBeenCalled();
|
||||
expect(seasons.statesFor).not.toHaveBeenCalled();
|
||||
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season);
|
||||
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season, '08-01', TODAY);
|
||||
});
|
||||
|
||||
it('preloadFor reads the four rules and the seasons once', async () => {
|
||||
@@ -330,4 +332,66 @@ describe('AutopilotService', () => {
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
describe('syncApiaryIn', () => {
|
||||
it('syncs every living colony of the apiary in id order with one read of rules and seasons', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
|
||||
loadInput.mockResolvedValue(input(null));
|
||||
readRuleMock.mockClear();
|
||||
|
||||
const result = await service.syncApiaryIn(tx as never, 'api-1', TODAY);
|
||||
|
||||
expect(tx.colony.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
apiaryId: 'api-1',
|
||||
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
expect(lockRow.mock.calls.map((call) => call.slice(1))).toEqual([
|
||||
['Apiary', 'api-1'],
|
||||
['Colony', 'col-1'],
|
||||
['Colony', 'col-2'],
|
||||
]);
|
||||
expect(readRuleMock).toHaveBeenCalledTimes(4);
|
||||
expect(seasons.statesFor).toHaveBeenCalledTimes(1);
|
||||
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
|
||||
expect(result).toEqual({ opened: 2, refreshed: 0, resolved: 0 });
|
||||
});
|
||||
|
||||
it('locks the apiary, then all its colonies in one statement, before any colony is synced', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
|
||||
loadInput.mockResolvedValue(input(null));
|
||||
|
||||
await service.syncApiaryIn(tx as never, 'api-1', TODAY);
|
||||
|
||||
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['col-1', 'col-2']);
|
||||
const apiaryLock = lockRow.mock.invocationCallOrder[0];
|
||||
const listing = tx.colony.findMany.mock.invocationCallOrder[0];
|
||||
const colonyLock = lockRows.mock.invocationCallOrder[0];
|
||||
const firstSync = lockRow.mock.invocationCallOrder[1];
|
||||
expect(apiaryLock).toBeLessThan(listing);
|
||||
expect(listing).toBeLessThan(colonyLock);
|
||||
expect(colonyLock).toBeLessThan(firstSync);
|
||||
});
|
||||
|
||||
it('does nothing and reads nothing for an apiary without living colonies', async () => {
|
||||
readRuleMock.mockClear();
|
||||
|
||||
expect(await service.syncApiaryIn(tx as never, 'api-1', TODAY)).toEqual({
|
||||
opened: 0,
|
||||
refreshed: 0,
|
||||
resolved: 0,
|
||||
});
|
||||
expect(readRuleMock).not.toHaveBeenCalled();
|
||||
expect(seasons.statesFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a client that is not a transaction', async () => {
|
||||
await expect(
|
||||
service.syncApiaryIn({ $connect: jest.fn() } as never, 'api-1', TODAY),
|
||||
).rejects.toThrow('AutopilotService.syncApiaryIn');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
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 {
|
||||
isLivingColonyStatus,
|
||||
LIVING_COLONY_WHERE,
|
||||
} 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 { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import type { SeasonState } from '../season/season-types';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
@@ -113,7 +116,13 @@ export class AutopilotService {
|
||||
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
|
||||
return NOTHING;
|
||||
}
|
||||
const input = await loadAutopilotInputIn(tx, colonyId, season);
|
||||
const input = await loadAutopilotInputIn(
|
||||
tx,
|
||||
colonyId,
|
||||
season,
|
||||
configs.phenology.calendarFallback.TRACHTENDE,
|
||||
today,
|
||||
);
|
||||
if (input === null) {
|
||||
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
|
||||
return NOTHING;
|
||||
@@ -173,6 +182,51 @@ export class AutopilotService {
|
||||
return { opened, refreshed, resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles every living colony of the apiary, for a season change.
|
||||
* Lock order: the apiary row, then (after reading the list under that
|
||||
* lock) all its colonies in ONE statement in ascending id order, before any
|
||||
* colony is synced. A concurrent single-colony or multi-colony sync takes
|
||||
* its colony locks the same way, so no two syncs wait for each other while
|
||||
* the proposals lock suggestion and rule rows. Rules and season are read
|
||||
* once for all colonies. About ten queries per colony run in this one
|
||||
* transaction.
|
||||
*/
|
||||
async syncApiaryIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
apiaryId: string,
|
||||
today: string,
|
||||
): Promise<AutopilotSyncResult> {
|
||||
assertTransactionClient(tx, 'AutopilotService.syncApiaryIn');
|
||||
if (!(await lockRowForUpdate(tx, 'Apiary', apiaryId))) {
|
||||
return NOTHING;
|
||||
}
|
||||
const colonies = await tx.colony.findMany({
|
||||
where: { apiaryId, ...LIVING_COLONY_WHERE },
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (colonies.length === 0) {
|
||||
return NOTHING;
|
||||
}
|
||||
await lockRowsForUpdate(
|
||||
tx,
|
||||
'Colony',
|
||||
colonies.map((colony) => colony.id),
|
||||
);
|
||||
const preload = await this.preloadFor(tx, [apiaryId], today);
|
||||
let total = NOTHING;
|
||||
for (const { id } of colonies) {
|
||||
const result = await this.syncColonyIn(tx, id, today, preload);
|
||||
total = {
|
||||
opened: total.opened + result.opened,
|
||||
refreshed: total.refreshed + result.refreshed,
|
||||
resolved: total.resolved + result.resolved,
|
||||
};
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
|
||||
return {
|
||||
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
|
||||
|
||||
Reference in new issue
Block a user