Files
ApiCore/ApiCore-Backend/src/automation/automation.service.ts
T
Nick SzaunigandClaude Opus 5.5 5cf4e87ca3 feat(automation): suggest follow-up checks after treatments and feedings
Two new rules, "Kontrolle nach Behandlung" and "Kontrolle nach
Fütterung", create a check task when a treatment or feeding is recorded.
The due date counts from the measure itself (end of treatment, else its
date; feeding date) plus the configured number of days, and the task
names the product and date. At most one open check per colony and rule.

Both rules ship switched off and without an interval: the right timing
depends on the product (package leaflet) and season, so the beekeeper
sets it. Every task-creating rule can now carry a "due after N days"
setting in the automation page.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:23:13 +02:00

436 lines
13 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import {
AutomationRule,
AutomationTriggerType,
Colony,
Inspection,
Feeding,
Prisma,
StockItem,
SwarmControlCheck,
TaskRelatedType,
Treatment,
VarroaMeasurement,
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
}
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
QUEENLESS_NO_EGGS: {
config: {},
actionTemplate:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
},
VARROA_THRESHOLD: {
config: { thresholdMitesPerDay: 10 },
actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
},
FEED_STATUS_CRITICAL: {
config: {},
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
},
SWARM_MOOD_HIGH: {
config: {},
actionTemplate:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
},
COLONY_HEALTH_CRITICAL: {
config: {},
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
},
STOCK_ITEM_LOW: {
config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
},
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
BOTTLING_LOT_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
TREATMENT_FOLLOW_UP: {
config: {},
actionTemplate: 'Behandlungserfolg kontrollieren',
active: false,
},
FEEDING_FOLLOW_UP: {
config: {},
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
active: false,
},
};
/**
* Optional per-rule follow-up interval (config.dueInDays). There is no
* built-in default: how soon a queenless colony or a high mite fall needs a
* look depends on season and practice, so the rule stays date-less unless the
* beekeeper configures one.
*/
function formatDay(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
}
function dueDateFromConfig(
dueInDays: unknown,
baseDate: Date = new Date(),
): Date | undefined {
if (
typeof dueInDays !== 'number' ||
!Number.isInteger(dueInDays) ||
dueInDays < 0 ||
dueInDays > 365
) {
return undefined;
}
const due = new Date(baseDate);
due.setUTCHours(0, 0, 0, 0);
due.setUTCDate(due.getUTCDate() + dueInDays);
return due;
}
@Injectable()
export class AutomationService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly notifications: NotificationsService,
private readonly webhooks: WebhooksService,
) {}
private async ensureDefaults(): Promise<void> {
for (const triggerType of Object.keys(
DEFAULT_RULES,
) as AutomationTriggerType[]) {
const defaults = DEFAULT_RULES[triggerType];
await this.prisma.automationRule.upsert({
where: { triggerType },
update: {},
create: {
triggerType,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
});
}
}
async findAll(): Promise<AutomationRule[]> {
await this.ensureDefaults();
return this.prisma.automationRule.findMany({
orderBy: { triggerType: 'asc' },
});
}
async findById(id: string): Promise<AutomationRule> {
const rule = await this.prisma.automationRule.findUnique({
where: { id },
});
if (!rule) {
throw new NotFoundException('Automation rule not found');
}
return rule;
}
async update(
id: string,
dto: UpdateAutomationRuleDto,
actingUserId: string,
): Promise<AutomationRule> {
const before = await this.findById(id);
const rule = await this.prisma.automationRule.update({
where: { id },
data: {
...dto,
config: dto.config as Prisma.InputJsonValue | undefined,
},
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
});
return rule;
}
async evaluateInspection(inspection: Inspection): Promise<void> {
await this.ensureDefaults();
const rules = await this.prisma.automationRule.findMany({
where: {
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
active: true,
},
});
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
if (
queenlessRule &&
inspection.queenSeen === false &&
inspection.eggsPresent === false
) {
await this.triggerAction(
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
await this.triggerAction(
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
}
async evaluateVarroaMeasurement(
measurement: VarroaMeasurement,
): Promise<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
});
if (!rule || !rule.active) {
return;
}
// The threshold is a natural mite fall rate (mites per day). Only that
// method yields a value in this unit - a mite count from a sugar roll or
// wash is per bee sample and would be compared against the wrong scale,
// as would a raw board count without the number of days it covered.
if (measurement.method !== 'NATURAL_MITE_FALL') {
return;
}
const config = rule.config as { thresholdMitesPerDay?: number } | null;
const threshold = config?.thresholdMitesPerDay ?? 10;
const value = measurement.calculatedValue;
if (value !== null && value >= threshold) {
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
}
}
async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerAction(rule, 'COLONY', check.colonyId);
}
}
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
async evaluateColonyHealth(before: Colony, after: Colony): Promise<void> {
await this.ensureDefaults();
if (
after.healthStatus !== 'CRITICAL' ||
before.healthStatus === 'CRITICAL'
) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', after.id);
}
async evaluateStockItem(item: StockItem): Promise<void> {
await this.ensureDefaults();
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'STORAGE', item.id);
}
async evaluateTreatment(treatment: Treatment): Promise<void> {
await this.evaluateFollowUp(
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
treatment.endDate ?? treatment.date,
`Nach Behandlung mit ${treatment.product} am ${formatDay(treatment.endDate ?? treatment.date)}.`,
);
}
async evaluateFeeding(feeding: Feeding): Promise<void> {
await this.evaluateFollowUp(
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
);
}
/** A check counted from the measure's own date, not from today. */
private async evaluateFollowUp(
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
colonyId: string,
measureDate: Date,
description: string,
): Promise<void> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return;
}
await this.triggerAction(rule, 'COLONY', colonyId, undefined, {
baseDate: measureDate,
description,
});
}
// Returns null (never throws) when the rule is inactive or has no format
// configured, so the caller can fall back to requiring manual entry.
async generateNumber(
triggerType: 'HONEY_BATCH_NUMBER_FORMAT' | 'BOTTLING_LOT_NUMBER_FORMAT',
): Promise<string | null> {
await this.ensureDefaults();
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
return null;
}
const config = rule.config as { format?: string } | null;
const format = config?.format;
if (!format) {
return null;
}
// Atomic `SET sequenceValue = sequenceValue + 1` - safe under concurrent
// batch/bottling creation, no explicit transaction/lock needed.
const updated = await this.prisma.automationRule.update({
where: { triggerType },
data: { sequenceValue: { increment: 1 } },
select: { sequenceValue: true },
});
return formatGeneratedNumber(format, updated.sequenceValue, new Date());
}
async resetSequence(
id: string,
actingUserId: string,
): Promise<AutomationRule> {
const before = await this.findById(id);
const rule = await this.prisma.automationRule.update({
where: { id },
data: { sequenceValue: 0 },
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
});
return rule;
}
/**
* Leaves a suggestion task (never a record of work done) plus a warning.
*
* At most one open task per rule and object: while the reorder task for a
* stock item or the check task for a colony is still open, further triggers
* (the next removal below minimum, a second high varroa count) add nothing.
* Repeating the same alarm would only bury the one task that matters.
*/
private async triggerAction(
rule: AutomationRule,
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {},
): Promise<void> {
const alreadyOpen = await this.prisma.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
});
if (alreadyOpen) {
return;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.prisma.task.create({
data: {
title,
priority: 'HIGH',
relatedType,
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
});
await this.webhooks.dispatch('task.created', task);
await this.notifications.record({
priority: 'WARNUNG',
message: title,
relatedType,
relatedId,
});
await this.auditLog.record({
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: {
taskId: task.id,
relatedType,
relatedId,
triggerType: rule.triggerType,
},
});
}
}