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; } 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 { 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 { 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 { 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, }; } }