- 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]>
162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
import type { Prisma } from '../../generated/prisma';
|
|
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
|
|
import type { SeasonState } from '../season/season-types';
|
|
import type { AutopilotInput } from './autopilot.types';
|
|
|
|
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* What the autopilot rules need of one colony, in a fixed sequence of
|
|
* queries (no query per rule, none per record). Resolves to null for a
|
|
* colony that does not exist. The caller holds the colony row lock, so the
|
|
* picture stays consistent while the rules run.
|
|
*/
|
|
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 },
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
status: true,
|
|
colonyType: true,
|
|
apiaryId: true,
|
|
createdAt: true,
|
|
feedStatus: true,
|
|
currentQueen: { select: { id: true, hatchYear: true } },
|
|
},
|
|
});
|
|
if (colony === null) {
|
|
return null;
|
|
}
|
|
const inspection = await tx.inspection.findFirst({
|
|
where: { colonyId },
|
|
orderBy: [...LATEST_FIRST],
|
|
select: {
|
|
id: true,
|
|
date: true,
|
|
colonyStrength: true,
|
|
feedStatus: true,
|
|
queenCellsPresent: true,
|
|
},
|
|
});
|
|
const varroa = await tx.varroaMeasurement.findFirst({
|
|
where: { colonyId },
|
|
orderBy: [...LATEST_FIRST],
|
|
select: { id: true, date: true },
|
|
});
|
|
// The latest END day wins, not the latest entry: a backdated or
|
|
// overlapping treatment must not hide one that ends later.
|
|
const treatments = await tx.treatment.findMany({
|
|
where: { colonyId },
|
|
orderBy: [{ date: 'desc' }, { id: 'asc' }],
|
|
select: { id: true, date: true, startDate: true, endDate: true },
|
|
});
|
|
const lastTreatment = treatments
|
|
.map((treatment) => ({
|
|
id: treatment.id,
|
|
startDay: berlinDayOf(treatment.startDate ?? treatment.date),
|
|
endDay: berlinDayOf(treatment.endDate ?? treatment.date),
|
|
}))
|
|
.reduce<{ id: string; startDay: string; endDay: string } | null>(
|
|
(latest, candidate) =>
|
|
latest === null || candidate.endDay > latest.endDay
|
|
? candidate
|
|
: latest,
|
|
null,
|
|
);
|
|
const varroaSinceLastTreatmentEnd =
|
|
lastTreatment !== null &&
|
|
(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, date: { gte: feedingsSince } },
|
|
});
|
|
const lateSummer = lateSummerStart(season, calendarTrachtende);
|
|
const sinceLateSummer =
|
|
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
|
|
const feedingsSinceLateSummer =
|
|
sinceLateSummer === null
|
|
? 0
|
|
: await tx.feeding.count({ where: { colonyId, date: sinceLateSummer } });
|
|
const treatmentsSinceLateSummer =
|
|
sinceLateSummer === null
|
|
? 0
|
|
: await tx.treatment.count({
|
|
where: { colonyId, date: sinceLateSummer },
|
|
});
|
|
const winterChecks = await tx.winterCheck.findMany({
|
|
where: { colonyId, winterYear: { gte: season.year - 1 } },
|
|
select: { winterYear: true, season: true },
|
|
orderBy: [{ winterYear: 'asc' }, { season: 'asc' }],
|
|
});
|
|
return {
|
|
colony: {
|
|
id: colony.id,
|
|
code: colony.code,
|
|
status: colony.status,
|
|
colonyType: colony.colonyType,
|
|
apiaryId: colony.apiaryId,
|
|
createdOn: berlinDayOf(colony.createdAt),
|
|
feedStatus: colony.feedStatus,
|
|
},
|
|
lastInspection:
|
|
inspection === null
|
|
? null
|
|
: { ...inspection, date: berlinDayOf(inspection.date) },
|
|
lastVarroa:
|
|
varroa === null
|
|
? null
|
|
: { id: varroa.id, date: berlinDayOf(varroa.date) },
|
|
lastTreatment,
|
|
varroaSinceLastTreatmentEnd,
|
|
feedingsSinceInspection,
|
|
feedingsSinceLateSummer,
|
|
treatmentsSinceLateSummer,
|
|
queen: colony.currentQueen,
|
|
winterChecks,
|
|
season,
|
|
};
|
|
}
|