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]>
This commit is contained in:
1 parent
888edf7e1d
commit
5cf4e87ca3
13 files changed
+318
-11
No files matched your search
@@ -4,6 +4,7 @@ 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 { AutomationTriggerType } from '../../generated/prisma';
|
||||
|
||||
describe('AutomationService', () => {
|
||||
let service: AutomationService;
|
||||
@@ -82,7 +83,9 @@ describe('AutomationService', () => {
|
||||
|
||||
const result = await service.findAll();
|
||||
|
||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(8);
|
||||
expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(
|
||||
Object.values(AutomationTriggerType).length,
|
||||
);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
AutomationTriggerType,
|
||||
Colony,
|
||||
Inspection,
|
||||
Feeding,
|
||||
Prisma,
|
||||
StockItem,
|
||||
SwarmControlCheck,
|
||||
TaskRelatedType,
|
||||
Treatment,
|
||||
VarroaMeasurement,
|
||||
} from '../../generated/prisma';
|
||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||
@@ -20,6 +22,8 @@ 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> = {
|
||||
@@ -60,6 +64,20 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -68,7 +86,19 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
* look depends on season and practice, so the rule stays date-less unless the
|
||||
* beekeeper configures one.
|
||||
*/
|
||||
function dueDateFromConfig(dueInDays: unknown): Date | undefined {
|
||||
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) ||
|
||||
@@ -77,7 +107,7 @@ function dueDateFromConfig(dueInDays: unknown): Date | undefined {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const due = new Date();
|
||||
const due = new Date(baseDate);
|
||||
due.setUTCHours(0, 0, 0, 0);
|
||||
due.setUTCDate(due.getUTCDate() + dueInDays);
|
||||
return due;
|
||||
@@ -102,6 +132,7 @@ export class AutomationService {
|
||||
update: {},
|
||||
create: {
|
||||
triggerType,
|
||||
active: defaults.active ?? true,
|
||||
config: defaults.config as Prisma.InputJsonValue,
|
||||
actionTemplate: defaults.actionTemplate,
|
||||
},
|
||||
@@ -255,6 +286,44 @@ export class AutomationService {
|
||||
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(
|
||||
@@ -315,6 +384,7 @@ export class AutomationService {
|
||||
relatedType: TaskRelatedType,
|
||||
relatedId: string,
|
||||
sourceInspectionId?: string,
|
||||
options: { baseDate?: Date; description?: string } = {},
|
||||
): Promise<void> {
|
||||
const alreadyOpen = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
@@ -339,7 +409,8 @@ export class AutomationService {
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays),
|
||||
description: options.description,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||
},
|
||||
});
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { FeedingsController } from './feedings.controller';
|
||||
import { ColonyFeedingsController } from './colony-feedings.controller';
|
||||
import { FeedingsService } from './feedings.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationModule],
|
||||
controllers: [FeedingsController, ColonyFeedingsController],
|
||||
providers: [FeedingsService],
|
||||
exports: [FeedingsService],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { FeedingsService } from './feedings.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -35,6 +36,7 @@ describe('FeedingsService', () => {
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
{ evaluateFeeding: jest.fn() } as unknown as AutomationService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
@@ -17,6 +18,7 @@ export class FeedingsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
private readonly automation: AutomationService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Feeding[]> {
|
||||
@@ -62,6 +64,7 @@ export class FeedingsService {
|
||||
afterData: feeding,
|
||||
});
|
||||
await this.calendarSync.enqueue('FEEDING', feeding.id);
|
||||
await this.automation.evaluateFeeding(feeding);
|
||||
return feeding;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { TreatmentsController } from './treatments.controller';
|
||||
import { ColonyTreatmentsController } from './colony-treatments.controller';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationModule],
|
||||
controllers: [TreatmentsController, ColonyTreatmentsController],
|
||||
providers: [TreatmentsService],
|
||||
exports: [TreatmentsService],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { TreatmentsService } from './treatments.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -37,6 +38,7 @@ describe('TreatmentsService', () => {
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
calendarSync as unknown as CalendarSyncService,
|
||||
{ evaluateTreatment: jest.fn() } as unknown as AutomationService,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from '../automation/automation.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
||||
@@ -18,6 +19,7 @@ export class TreatmentsService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly calendarSync: CalendarSyncService,
|
||||
private readonly automation: AutomationService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Treatment[]> {
|
||||
@@ -98,6 +100,7 @@ export class TreatmentsService {
|
||||
afterData: treatment,
|
||||
});
|
||||
await this.calendarSync.enqueue('TREATMENT', treatment.id);
|
||||
await this.automation.evaluateTreatment(treatment);
|
||||
return treatment;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user