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:
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { berlinDayOf, daysBetween } from '../common/dates';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { seasonStateFromRow } from '../phenology/phenology-row';
|
||||
import type { DbClient } from '../prisma/db-client';
|
||||
import { calendarSeasonState } from './season-calendar';
|
||||
import type { SeasonState } from './season-types';
|
||||
|
||||
/** A stored row not updated for longer than this (days) means the job missed its runs. */
|
||||
const MAX_ROW_AGE_DAYS = 2;
|
||||
|
||||
/**
|
||||
* The season of apiaries. Until the phenology data exists (Task 25) every
|
||||
* apiary gets the calendar state; then ApiaryPhenology takes over here, and
|
||||
* the callers stay as they are.
|
||||
* The season of apiaries: the stored state of the current year
|
||||
* (ApiaryPhenology, written by the phenology step and the reports) where
|
||||
* there is one and the job kept it fresh (updated at most MAX_ROW_AGE_DAYS
|
||||
* days ago), else the calendar state. A stale row falls back to the calendar
|
||||
* instead of recomputing GTS on the fly: that would read a year of weather per
|
||||
* apiary on every read path, the calendar costs nothing. Callers do not see
|
||||
* the difference.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeasonService {
|
||||
@@ -21,9 +30,31 @@ export class SeasonService {
|
||||
if (apiaryIds.length === 0) {
|
||||
return states;
|
||||
}
|
||||
const ids = [...new Set(apiaryIds)];
|
||||
const { config } = await readRule(db, 'PHENOLOGY');
|
||||
for (const apiaryId of new Set(apiaryIds)) {
|
||||
states.set(apiaryId, calendarSeasonState(apiaryId, today, config));
|
||||
const rows = await db.apiaryPhenology.findMany({
|
||||
where: { apiaryId: { in: ids }, year: Number(today.slice(0, 4)) },
|
||||
include: { apiary: { select: { latitude: true, longitude: true } } },
|
||||
});
|
||||
const stored = new Map(rows.map((row) => [row.apiaryId, row]));
|
||||
for (const apiaryId of ids) {
|
||||
const found = stored.get(apiaryId);
|
||||
const row =
|
||||
found !== undefined &&
|
||||
daysBetween(berlinDayOf(found.updatedAt), today) <= MAX_ROW_AGE_DAYS
|
||||
? found
|
||||
: undefined;
|
||||
states.set(
|
||||
apiaryId,
|
||||
row === undefined
|
||||
? calendarSeasonState(apiaryId, today, config)
|
||||
: seasonStateFromRow(
|
||||
row,
|
||||
config,
|
||||
row.apiary.latitude !== null && row.apiary.longitude !== null,
|
||||
today,
|
||||
),
|
||||
);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user