feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base

- Weather history from Open-Meteo behind an adapter: worker loop
  phenology-weather with backoff, Retry-After, User-Agent, chunked
  writes and forecast-to-archive correction
- Phenology: growing degree sum per DWD, phase from reports, GTS and
  calendar, calibration from previous years, daily job step,
  SeasonService reads the stored state
- Honey chain: prefill for extractions and batches (proposals only),
  plausibility and withdrawal warnings, Extraction lock, chain events
  and batch.created webhook from HoneyBatchRecorded
- Telegram base: schema, NotificationDelivery outbox, hardened bot
  client, startup check without crash, no-network-outside-loops guard
- ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table

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 20:00:46 +02:00
1 parent 22382d53a2
commit 1971b2d9c4
90 files changed
+8431 -155

No files matched your search

@@ -4,11 +4,16 @@ import { SeasonService } from './season.service';
describe('SeasonService', () => {
const findUnique = jest.fn();
const db = { automationRule: { findUnique } } as unknown as DbClient;
const findMany = jest.fn();
const db = {
automationRule: { findUnique },
apiaryPhenology: { findMany },
} as unknown as DbClient;
const service = new SeasonService();
beforeEach(() => {
findUnique.mockReset().mockResolvedValue(null);
findMany.mockReset().mockResolvedValue([]);
});
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
@@ -55,4 +60,130 @@ describe('SeasonService', () => {
springSince: '2026-03-01',
});
});
describe('with stored phenology', () => {
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const row = (overrides: Record<string, unknown> = {}) => ({
apiaryId: 'apiary-1',
year: 2026,
gts: 412.5,
gtsThroughDay: d('2026-05-11'),
missingDays: 0,
estimatedPhase: 'KIRSCHE',
reportedPhase: 'APFEL',
effectivePhase: 'APFEL',
phaseSource: 'REPORT',
segment: 'SCHWARMZEIT',
segmentSince: d('2026-05-02'),
updatedAt: d('2026-05-12'),
apiary: { latitude: 48.1, longitude: 9.9 },
...overrides,
});
it('prefers the row of the current year over the calendar', async () => {
findMany.mockResolvedValue([row()]);
const states = await service.statesFor(
db,
['apiary-1', 'apiary-2'],
'2026-05-12',
);
expect(states.get('apiary-1')).toEqual({
apiaryId: 'apiary-1',
year: 2026,
phase: 'APFEL',
phaseSource: 'REPORT',
reportedOn: null,
segment: 'SCHWARMZEIT',
segmentSince: '2026-05-02',
springSince: '2026-03-20',
gts: 412.5,
gtsThroughDay: '2026-05-11',
note: null,
});
expect(states.get('apiary-2')).toMatchObject({ phaseSource: 'CALENDAR' });
expect(findMany).toHaveBeenCalledTimes(1);
expect(findUnique).toHaveBeenCalledTimes(1);
});
it('asks for the rows of the year of today', async () => {
await service.statesFor(db, ['apiary-1'], '2026-05-12');
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { apiaryId: { in: ['apiary-1'] }, year: 2026 },
}),
);
});
it('takes the segment start as spring start while in FRUEHJAHR', async () => {
findMany.mockResolvedValue([
row({ segment: 'FRUEHJAHR', segmentSince: d('2026-03-28') }),
]);
const states = await service.statesFor(db, ['apiary-1'], '2026-04-01');
expect(states.get('apiary-1')?.springSince).toBe('2026-03-28');
});
it('has no spring start before the spring', async () => {
findMany.mockResolvedValue([
row({
segment: 'WINTERRUHE',
effectivePhase: null,
phaseSource: 'CALENDAR',
segmentSince: d('2026-01-01'),
gts: 60,
}),
]);
const states = await service.statesFor(db, ['apiary-1'], '2026-02-10');
expect(states.get('apiary-1')?.springSince).toBeNull();
});
it('explains why the weather was not used', async () => {
findMany.mockResolvedValue([
row({ gts: null, gtsThroughDay: d('2026-05-02') }),
row({
apiaryId: 'apiary-2',
apiary: { latitude: null, longitude: null },
gts: null,
gtsThroughDay: null,
}),
row({ apiaryId: 'apiary-3', gts: null }),
]);
const states = await service.statesFor(
db,
['apiary-1', 'apiary-2', 'apiary-3'],
'2026-05-12',
);
expect(states.get('apiary-1')?.note).toBe('WEATHER_STALE');
expect(states.get('apiary-2')?.note).toBe('NO_COORDINATES');
expect(states.get('apiary-3')?.note).toBe('WEATHER_GAPS');
});
it('uses a row updated 2 days ago but not one updated 3 days ago', async () => {
findMany.mockResolvedValue([
row({ updatedAt: new Date('2026-05-10T04:31:00.000Z') }),
row({
apiaryId: 'apiary-2',
updatedAt: new Date('2026-05-09T04:31:00.000Z'),
}),
]);
const states = await service.statesFor(
db,
['apiary-1', 'apiary-2'],
'2026-05-12',
);
expect(states.get('apiary-1')?.phaseSource).toBe('REPORT');
// The job missed its runs: back to the calendar.
expect(states.get('apiary-2')?.phaseSource).toBe('CALENDAR');
});
});
});