- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
185 lines
6.8 KiB
TypeScript
185 lines
6.8 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import type { Prisma } from '../../generated/prisma';
|
|
import { readRule } from '../automation/rule-config';
|
|
import { isLivingColonyStatus } from '../colonies/colony-status';
|
|
import { addDays } from '../common/dates';
|
|
import { utcDay } from '../domain-events/domain-event-types';
|
|
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
|
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
|
import type { SeasonState } from '../season/season-types';
|
|
import { SeasonService } from '../season/season.service';
|
|
import { SuggestionsService } from '../suggestions/suggestions.service';
|
|
import { suggestionDedupeKey } from '../suggestions/suggestion.types';
|
|
import { loadAutopilotInputIn } from './autopilot-input';
|
|
import { evaluateAutopilot } from './autopilot-rules';
|
|
import type {
|
|
AutopilotCandidate,
|
|
AutopilotConfigs,
|
|
AutopilotSyncResult,
|
|
} from './autopilot.types';
|
|
|
|
/** Rules and seasons a daily step reads once for all its colonies. */
|
|
export interface AutopilotPreload {
|
|
configs: AutopilotConfigs;
|
|
seasons: ReadonlyMap<string, SeasonState>;
|
|
}
|
|
|
|
const NOTHING: AutopilotSyncResult = { opened: 0, refreshed: 0, resolved: 0 };
|
|
|
|
/**
|
|
* A suggestion lives suggestionTtlDays from its due day, from today if it is
|
|
* already overdue: a condition that has lasted longer than the ttl would
|
|
* otherwise expire the day it is proposed and never be seen.
|
|
*/
|
|
function expiresOn(dueDate: string, today: string, ttlDays: number): string {
|
|
return addDays(dueDate > today ? dueDate : today, ttlDays);
|
|
}
|
|
|
|
/**
|
|
* Reconciles the open autopilot suggestions of one colony with what the
|
|
* rules say today. It writes suggestions only, never tasks or colony data,
|
|
* and leaves rule and chain suggestions alone.
|
|
*/
|
|
@Injectable()
|
|
export class AutopilotService {
|
|
private readonly logger = new Logger(AutopilotService.name);
|
|
|
|
constructor(
|
|
private readonly suggestions: SuggestionsService,
|
|
private readonly seasons: SeasonService,
|
|
) {}
|
|
|
|
/** The rules and the season of the given apiaries, read once for many colonies. */
|
|
async preloadFor(
|
|
db: DbClient,
|
|
apiaryIds: readonly string[],
|
|
today: string,
|
|
): Promise<AutopilotPreload> {
|
|
return {
|
|
configs: await this.readConfigs(db),
|
|
seasons: await this.seasons.statesFor(db, apiaryIds, today),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Locks the colony row, then: ended colony - nothing; autopilot off - all
|
|
* open autopilot suggestions RESOLVED; otherwise suggestions whose key is
|
|
* gone are RESOLVED, those with the same key refreshed, new candidates
|
|
* proposed (in the order A1...A10, so concurrent syncs take the proposal
|
|
* locks in the same order). A candidate is dropped by `proposeIn` when an
|
|
* open suggestion or task of the same intent exists. A colony that has
|
|
* ended is skipped here; its open suggestions become OBSOLETE through the
|
|
* handler `suggestions.colony-ended`. `maxStepsPerColony` is not applied:
|
|
* it limits the next-steps view, not what is proposed. `preload` spares a
|
|
* daily step the rule and season reads per colony.
|
|
*/
|
|
async syncColonyIn(
|
|
tx: Prisma.TransactionClient,
|
|
colonyId: string,
|
|
today: string,
|
|
preload?: AutopilotPreload,
|
|
): Promise<AutopilotSyncResult> {
|
|
assertTransactionClient(tx, 'AutopilotService.syncColonyIn');
|
|
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
|
return NOTHING;
|
|
}
|
|
const colony = await tx.colony.findUnique({
|
|
where: { id: colonyId },
|
|
select: { status: true, apiaryId: true },
|
|
});
|
|
if (colony === null || !isLivingColonyStatus(colony.status)) {
|
|
return NOTHING;
|
|
}
|
|
const configs = preload?.configs ?? (await this.readConfigs(tx));
|
|
const autopilot = configs.autopilot;
|
|
const open = await tx.suggestion.findMany({
|
|
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
|
|
select: { id: true, dedupeKey: true, expiresAt: true },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
if (!autopilot.active) {
|
|
const resolved = await this.suggestions.resolveIn(
|
|
tx,
|
|
open.map((suggestion) => suggestion.id),
|
|
'Autopilot aus',
|
|
);
|
|
return { ...NOTHING, resolved };
|
|
}
|
|
const season = (
|
|
preload?.seasons ??
|
|
(await this.seasons.statesFor(tx, [colony.apiaryId], today))
|
|
).get(colony.apiaryId);
|
|
if (season === undefined) {
|
|
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
|
|
return NOTHING;
|
|
}
|
|
const input = await loadAutopilotInputIn(tx, colonyId, season);
|
|
if (input === null) {
|
|
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
|
|
return NOTHING;
|
|
}
|
|
const candidates = evaluateAutopilot(input, configs, today);
|
|
const keyOf = (candidate: AutopilotCandidate): string =>
|
|
suggestionDedupeKey(candidate.ruleKey, colonyId, candidate.anchor);
|
|
const currentKeys = new Set(candidates.map(keyOf));
|
|
const resolved = await this.suggestions.resolveIn(
|
|
tx,
|
|
open
|
|
.filter((s) => s.dedupeKey === null || !currentKeys.has(s.dedupeKey))
|
|
.map((s) => s.id),
|
|
'Bedingung entfällt',
|
|
);
|
|
const openByKey = new Map(
|
|
open.flatMap((s) => (s.dedupeKey === null ? [] : [[s.dedupeKey, s]])),
|
|
);
|
|
const ttlDays = autopilot.config.suggestionTtlDays;
|
|
let opened = 0;
|
|
let refreshed = 0;
|
|
for (const candidate of candidates) {
|
|
const existing = openByKey.get(keyOf(candidate));
|
|
if (existing !== undefined) {
|
|
await this.suggestions.refreshIn(tx, existing.id, {
|
|
reasonCode: candidate.reasonCode,
|
|
evidence: candidate.evidence,
|
|
dueDate: candidate.dueDate,
|
|
priority: candidate.priority,
|
|
expiresAt:
|
|
existing.expiresAt === null ? null : utcDay(existing.expiresAt),
|
|
});
|
|
refreshed += 1;
|
|
continue;
|
|
}
|
|
const created = await this.suggestions.proposeIn(tx, {
|
|
ruleKey: candidate.ruleKey,
|
|
source: 'AUTOPILOT',
|
|
mode: candidate.mode,
|
|
intent: candidate.intent,
|
|
priority: candidate.priority,
|
|
relatedType: 'COLONY',
|
|
relatedId: colonyId,
|
|
colonyId,
|
|
reasonCode: candidate.reasonCode,
|
|
evidence: candidate.evidence,
|
|
requiredPermission: candidate.requiredPermission,
|
|
dueDate: candidate.dueDate,
|
|
expiresAt: expiresOn(candidate.dueDate, today, ttlDays),
|
|
anchor: candidate.anchor,
|
|
notify: false,
|
|
});
|
|
if (created !== null) {
|
|
opened += 1;
|
|
}
|
|
}
|
|
return { opened, refreshed, resolved };
|
|
}
|
|
|
|
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
|
|
return {
|
|
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
|
|
interval: await readRule(db, 'INSPECTION_INTERVAL'),
|
|
varroa: await readRule(db, 'VARROA_THRESHOLD'),
|
|
phenology: (await readRule(db, 'PHENOLOGY')).config,
|
|
};
|
|
}
|
|
}
|