Files
ApiCore/ApiCore-Backend/src/season/season.service.ts
T
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 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]>
2026-10-14 20:00:46 +02:00

62 lines
2.3 KiB
TypeScript

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: 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 {
/** The season state of each given apiary on `today` (YYYY-MM-DD, Berlin). */
async statesFor(
db: DbClient,
apiaryIds: readonly string[],
today: string,
): Promise<Map<string, SeasonState>> {
const states = new Map<string, SeasonState>();
if (apiaryIds.length === 0) {
return states;
}
const ids = [...new Set(apiaryIds)];
const { config } = await readRule(db, 'PHENOLOGY');
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;
}
}