feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s

- 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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 21:13:23 +02:00
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']);
});
});
+243 -10
View File
@@ -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'),