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
@@ -5,8 +5,10 @@ import { AutopilotStep } from '../src/autopilot/autopilot.step';
|
||||
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
|
||||
import { AutopilotService } from '../src/autopilot/autopilot.service';
|
||||
import { JobsModule } from '../src/jobs/jobs.module';
|
||||
import { PhenologyStep } from '../src/phenology/phenology.step';
|
||||
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { JobPartialFailure } from '../src/system/job-run.service';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
@@ -30,6 +32,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
let app: INestApplicationContext;
|
||||
let prisma: PrismaService;
|
||||
let step: TimeEventsStep;
|
||||
let phenologyStep: PhenologyStep;
|
||||
let autopilotStep: AutopilotStep;
|
||||
let expiryStep: SuggestionsExpiryStep;
|
||||
const ids: Record<string, string> = {};
|
||||
@@ -56,6 +59,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
app = await moduleRef.init();
|
||||
prisma = app.get(PrismaService);
|
||||
step = app.get(TimeEventsStep);
|
||||
phenologyStep = app.get(PhenologyStep);
|
||||
expiryStep = app.get(SuggestionsExpiryStep);
|
||||
// The real AutopilotStep, but on the colonies of this file only: other
|
||||
// files share the database and must not be reconciled on a 2001 day.
|
||||
@@ -71,6 +75,7 @@ describe('daily-evaluation (e2e)', () => {
|
||||
}),
|
||||
},
|
||||
automationRule: prisma.automationRule,
|
||||
apiaryPhenology: prisma.apiaryPhenology,
|
||||
$transaction: prisma.$transaction.bind(prisma) as unknown,
|
||||
} as unknown as PrismaService,
|
||||
app.get(AutopilotService),
|
||||
@@ -340,6 +345,119 @@ describe('daily-evaluation (e2e)', () => {
|
||||
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
|
||||
});
|
||||
|
||||
describe('phenology step', () => {
|
||||
const DAY = '2001-04-30';
|
||||
let weatherApiaryId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
const apiary = await prisma.apiary.create({
|
||||
data: {
|
||||
name: `Daily Evaluation Weather ${suffix}`,
|
||||
latitude: 48.1,
|
||||
longitude: 9.9,
|
||||
},
|
||||
});
|
||||
weatherApiaryId = apiary.id;
|
||||
// 10 degrees every day from January 1st to April 29th: GTS 965.
|
||||
const days: Prisma.ApiaryWeatherDayCreateManyInput[] = [];
|
||||
for (
|
||||
let t = d('2001-01-01').getTime();
|
||||
t <= d('2001-04-29').getTime();
|
||||
t += 86_400_000
|
||||
) {
|
||||
days.push({
|
||||
apiaryId: apiary.id,
|
||||
day: new Date(t),
|
||||
meanTempC: 10,
|
||||
source: 'archive',
|
||||
});
|
||||
}
|
||||
await prisma.apiaryWeatherDay.createMany({ data: days });
|
||||
await prisma.phenologyReport.create({
|
||||
data: {
|
||||
apiaryId: apiary.id,
|
||||
phase: 'HOLUNDER',
|
||||
date: d('2001-04-29'),
|
||||
userId: ids.user,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: weatherApiaryId },
|
||||
});
|
||||
await prisma.phenologyReport.deleteMany({
|
||||
where: { apiaryId: weatherApiaryId },
|
||||
});
|
||||
// Rows of 2001 would feed the season of the other tests of this file.
|
||||
await prisma.apiaryPhenology.deleteMany({ where: { year: 2001 } });
|
||||
await prisma.apiary.deleteMany({ where: { id: weatherApiaryId } });
|
||||
});
|
||||
|
||||
// Other files create and delete apiaries concurrently: an apiary that
|
||||
// vanishes mid-run fails the step, the apiaries of this file do not.
|
||||
const runStep = () =>
|
||||
phenologyStep.run(DAY).catch((error: unknown) => {
|
||||
if (!(error instanceof JobPartialFailure)) {
|
||||
throw error;
|
||||
}
|
||||
return error.result;
|
||||
});
|
||||
|
||||
it('the phenology step writes ApiaryPhenology for every active apiary', async () => {
|
||||
const result = await runStep();
|
||||
|
||||
expect(Number(result.phenologyApiaries)).toBeGreaterThanOrEqual(2);
|
||||
const rows = await prisma.apiaryPhenology.findMany({
|
||||
where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 2001 },
|
||||
orderBy: { apiaryId: 'asc' },
|
||||
});
|
||||
expect(rows).toHaveLength(2);
|
||||
const byApiary = new Map(rows.map((row) => [row.apiaryId, row]));
|
||||
// Calendar only (no coordinates): APFEL from April 28th.
|
||||
expect(byApiary.get(ids.apiary)).toMatchObject({
|
||||
gts: null,
|
||||
effectivePhase: 'APFEL',
|
||||
phaseSource: 'CALENDAR',
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
// A report for HOLUNDER beats GTS and calendar.
|
||||
expect(byApiary.get(weatherApiaryId)).toMatchObject({
|
||||
gts: 965,
|
||||
gtsThroughDay: d('2001-04-29'),
|
||||
estimatedPhase: 'APFEL',
|
||||
reportedPhase: 'HOLUNDER',
|
||||
effectivePhase: 'HOLUNDER',
|
||||
phaseSource: 'REPORT',
|
||||
segment: 'SCHWARMZEIT',
|
||||
});
|
||||
});
|
||||
|
||||
it('records the phase change once per apiary and day', async () => {
|
||||
await runStep();
|
||||
await runStep();
|
||||
|
||||
expect(await eventsOf(weatherApiaryId)).toEqual([
|
||||
{
|
||||
type: 'SeasonPhaseChanged',
|
||||
aggregateType: 'Apiary',
|
||||
payload: {
|
||||
apiaryId: weatherApiaryId,
|
||||
phase: 'HOLUNDER',
|
||||
segment: 'SCHWARMZEIT',
|
||||
previousPhase: 'APFEL',
|
||||
previousSegment: 'SCHWARMZEIT',
|
||||
source: 'REPORT',
|
||||
},
|
||||
dedupeKey: `SeasonPhaseChanged:${weatherApiaryId}:${DAY}`,
|
||||
actorUserId: null,
|
||||
},
|
||||
]);
|
||||
expect(await eventsOf(ids.apiary)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autopilot and suggestions-expiry steps', () => {
|
||||
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
|
||||
async function lateColony(name: string): Promise<string> {
|
||||
|
||||
Reference in new issue
Block a user