- 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]>
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import type { Prisma } from '../../generated/prisma';
|
|
import { berlinToday } from '../common/dates';
|
|
import type {
|
|
AnyDomainEventEnvelope,
|
|
DomainEventType,
|
|
} from '../domain-events/domain-event-types';
|
|
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
|
import { lockRowsForUpdate } from '../prisma/row-lock';
|
|
import { AutopilotService } from './autopilot.service';
|
|
|
|
const COLONY_SYNC_EVENTS = [
|
|
'InspectionRecorded',
|
|
'InspectionCorrected',
|
|
'VarroaMeasured',
|
|
'VarroaCorrected',
|
|
'TreatmentRecorded',
|
|
'TreatmentCorrected',
|
|
'FeedingRecorded',
|
|
'FeedingCorrected',
|
|
'HarvestRecorded',
|
|
'HarvestCorrected',
|
|
'ColonyStatusChanged',
|
|
'ColonySplit',
|
|
'QueenAssigned',
|
|
'TaskCompleted',
|
|
'TaskUpdated',
|
|
'WithdrawalEnded',
|
|
'QueenAged',
|
|
'InspectionOverdue',
|
|
'VarroaMeasurementDue',
|
|
'WinterCheckRecorded',
|
|
] as const satisfies readonly DomainEventType[];
|
|
|
|
type ColonySyncEvent = AnyDomainEventEnvelope<
|
|
(typeof COLONY_SYNC_EVENTS)[number]
|
|
>;
|
|
|
|
/** The colony of a task, if it is about one. */
|
|
async function taskColonyIdIn(
|
|
tx: Prisma.TransactionClient,
|
|
taskId: string,
|
|
): Promise<string | null> {
|
|
const task = await tx.task.findUnique({
|
|
where: { id: taskId },
|
|
select: { relatedType: true, relatedId: true },
|
|
});
|
|
return task?.relatedType === 'COLONY' ? task.relatedId : null;
|
|
}
|
|
|
|
/** The colonies an event is about; none if it is about no colony. */
|
|
async function colonyIdsOf(
|
|
event: ColonySyncEvent,
|
|
tx: Prisma.TransactionClient,
|
|
): Promise<(string | null)[]> {
|
|
switch (event.type) {
|
|
case 'InspectionRecorded':
|
|
case 'InspectionCorrected':
|
|
case 'VarroaMeasured':
|
|
case 'VarroaCorrected':
|
|
case 'TreatmentRecorded':
|
|
case 'TreatmentCorrected':
|
|
case 'FeedingRecorded':
|
|
case 'FeedingCorrected':
|
|
case 'WithdrawalEnded':
|
|
case 'WinterCheckRecorded':
|
|
case 'QueenAged':
|
|
return [event.payload.colonyId];
|
|
case 'HarvestRecorded':
|
|
case 'HarvestCorrected':
|
|
return event.payload.colonyIds;
|
|
case 'ColonyStatusChanged':
|
|
case 'InspectionOverdue':
|
|
case 'VarroaMeasurementDue':
|
|
return [event.aggregateId];
|
|
case 'ColonySplit':
|
|
// The aggregate is the new colony.
|
|
return [event.aggregateId, event.payload.parentColonyId];
|
|
case 'QueenAssigned':
|
|
return [event.payload.colonyId, event.payload.previousColonyId];
|
|
case 'TaskCompleted':
|
|
return [
|
|
event.payload.relatedType === 'COLONY' ? event.payload.relatedId : null,
|
|
];
|
|
case 'TaskUpdated':
|
|
return [await taskColonyIdIn(tx, event.aggregateId)];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconciles the autopilot suggestions of the colonies an event touches
|
|
* (autopilot.colony-sync) and, for a season change, of all colonies of the
|
|
* apiary (autopilot.apiary-sync).
|
|
* Pure DB writer through the delivery transaction; the colonies are synced
|
|
* in id order, so two deliveries never lock them the other way round.
|
|
*/
|
|
@Injectable()
|
|
export class AutopilotEventHandlers {
|
|
constructor(private readonly autopilot: AutopilotService) {}
|
|
|
|
@OnDomainEvent([...COLONY_SYNC_EVENTS], 'autopilot.colony-sync')
|
|
async colonySync(
|
|
event: ColonySyncEvent,
|
|
tx: Prisma.TransactionClient,
|
|
): Promise<void> {
|
|
const colonyIds = [
|
|
...new Set((await colonyIdsOf(event, tx)).filter((id) => id !== null)),
|
|
].sort();
|
|
if (colonyIds.length === 0) {
|
|
return;
|
|
}
|
|
if (colonyIds.length > 1) {
|
|
// All colonies in one statement before any sync, like syncApiaryIn.
|
|
await lockRowsForUpdate(tx, 'Colony', colonyIds);
|
|
}
|
|
const today = berlinToday();
|
|
for (const colonyId of colonyIds) {
|
|
await this.autopilot.syncColonyIn(tx, colonyId, today);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A season change (new phase or segment) alters the rules for every colony
|
|
* of the apiary: reconcile them all. There is one such event per apiary and
|
|
* day (dailyDedupeKey); a change later the same day is caught by the daily
|
|
* autopilot step.
|
|
*/
|
|
@OnDomainEvent(['SeasonPhaseChanged'], 'autopilot.apiary-sync')
|
|
async apiarySync(
|
|
event: AnyDomainEventEnvelope<'SeasonPhaseChanged'>,
|
|
tx: Prisma.TransactionClient,
|
|
): Promise<void> {
|
|
await this.autopilot.syncApiaryIn(
|
|
tx,
|
|
event.payload.apiaryId,
|
|
berlinToday(),
|
|
);
|
|
}
|
|
}
|