feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI
- 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]>
This commit is contained in:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -154,12 +154,17 @@ describe('AutomationEventHandlers', () => {
|
||||
async (method, type, model, core) => {
|
||||
const record = { id: 'record-1', colonyId: 'colony-1' };
|
||||
tx[model].findUnique.mockResolvedValueOnce(record);
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||
|
||||
await run(method, type);
|
||||
|
||||
expect(tx[model].findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
});
|
||||
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(automation[core]).toHaveBeenCalledWith(tx, record);
|
||||
},
|
||||
);
|
||||
@@ -174,8 +179,41 @@ describe('AutomationEventHandlers', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'rule handlers write nothing for an ended colony: %s on %s',
|
||||
async (method, type, model, core) => {
|
||||
tx[model].findUnique.mockResolvedValueOnce({
|
||||
id: 'record-1',
|
||||
colonyId: 'colony-1',
|
||||
});
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'DISSOLVED' });
|
||||
|
||||
await run(method, type);
|
||||
|
||||
expect(automation[core]).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(cases)(
|
||||
'rule handlers write nothing for a colony that is gone: %s on %s',
|
||||
async (method, type, model, core) => {
|
||||
tx[model].findUnique.mockResolvedValueOnce({
|
||||
id: 'record-1',
|
||||
colonyId: 'colony-1',
|
||||
});
|
||||
|
||||
await run(method, type);
|
||||
|
||||
expect(automation[core]).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
|
||||
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' });
|
||||
tx.inspection.findUnique.mockResolvedValueOnce({
|
||||
id: 'record-1',
|
||||
colonyId: 'colony-1',
|
||||
});
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||
automation.evaluateInspectionIn.mockRejectedValue(
|
||||
new Error('notifications down'),
|
||||
);
|
||||
@@ -192,22 +230,24 @@ describe('AutomationEventHandlers', () => {
|
||||
const event = () =>
|
||||
envelope('ColonyStatusChanged', 'colony-1', { from, to });
|
||||
|
||||
it('hands the transition of the event to evaluateColonyHealthIn', async () => {
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
|
||||
it('hands the transition and the event to evaluateColonyHealthIn', async () => {
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
|
||||
|
||||
await handlers.colonyHealth(event(), asTx());
|
||||
|
||||
expect(tx.colony.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
select: { id: true },
|
||||
select: { status: true },
|
||||
});
|
||||
// The rule decides on the snapshots of the event, not on the colony
|
||||
// as it is now: a later change must not hide the transition.
|
||||
// as it is now: a later change must not hide the transition. The
|
||||
// event is the anchor of the suggestion.
|
||||
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
|
||||
tx,
|
||||
'colony-1',
|
||||
from,
|
||||
to,
|
||||
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -216,6 +256,14 @@ describe('AutomationEventHandlers', () => {
|
||||
|
||||
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a colony that ended', async () => {
|
||||
tx.colony.findUnique.mockResolvedValueOnce({ status: 'SOLD' });
|
||||
|
||||
await handlers.colonyHealth(event(), asTx());
|
||||
|
||||
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw-material-low', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
import { AutomationService } from './automation.service';
|
||||
@@ -10,9 +11,10 @@ type Tx = Prisma.TransactionClient;
|
||||
/**
|
||||
* Evaluates the automation rules on domain events. Pure DB writer: it reads
|
||||
* the record the event is about through the delivery transaction and lets
|
||||
* AutomationService write task, warning and audit entries in it. A record
|
||||
* deleted before the event was dispatched is skipped. Errors propagate, so
|
||||
* the delivery is rolled back as a whole and retried.
|
||||
* AutomationService write suggestion, notification and audit entries in it.
|
||||
* A record deleted before the event was dispatched, or of a colony that
|
||||
* ended, is skipped. Errors propagate, so the delivery is rolled back as a
|
||||
* whole and retried.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AutomationEventHandlers {
|
||||
@@ -21,6 +23,18 @@ export class AutomationEventHandlers {
|
||||
private readonly stock: StockService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fast path only: proposeForRuleIn re-checks the colony under its lock.
|
||||
* Whether the colony still lives (a colony that ended takes no proposals).
|
||||
*/
|
||||
private async isLivingColonyIn(tx: Tx, colonyId: string): Promise<boolean> {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true },
|
||||
});
|
||||
return colony !== null && isLivingColonyStatus(colony.status);
|
||||
}
|
||||
|
||||
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
|
||||
async inspectionRules(
|
||||
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
|
||||
@@ -29,14 +43,17 @@ export class AutomationEventHandlers {
|
||||
const inspection = await tx.inspection.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (inspection === null) {
|
||||
if (
|
||||
inspection === null ||
|
||||
!(await this.isLivingColonyIn(tx, inspection.colonyId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateInspectionIn(tx, inspection);
|
||||
}
|
||||
|
||||
// A correction can lift a count over the threshold, so it is evaluated
|
||||
// like a new measurement; the open-task check keeps it to one task.
|
||||
// like a new measurement; the open-suggestion check keeps it to one.
|
||||
@OnDomainEvent(
|
||||
['VarroaMeasured', 'VarroaCorrected'],
|
||||
'automation.varroa-threshold',
|
||||
@@ -48,7 +65,10 @@ export class AutomationEventHandlers {
|
||||
const measurement = await tx.varroaMeasurement.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (measurement === null) {
|
||||
if (
|
||||
measurement === null ||
|
||||
!(await this.isLivingColonyIn(tx, measurement.colonyId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
|
||||
@@ -62,7 +82,10 @@ export class AutomationEventHandlers {
|
||||
const treatment = await tx.treatment.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (treatment === null) {
|
||||
if (
|
||||
treatment === null ||
|
||||
!(await this.isLivingColonyIn(tx, treatment.colonyId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateTreatmentIn(tx, treatment);
|
||||
@@ -76,7 +99,10 @@ export class AutomationEventHandlers {
|
||||
const feeding = await tx.feeding.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
});
|
||||
if (feeding === null) {
|
||||
if (
|
||||
feeding === null ||
|
||||
!(await this.isLivingColonyIn(tx, feeding.colonyId))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateFeedingIn(tx, feeding);
|
||||
@@ -89,18 +115,15 @@ export class AutomationEventHandlers {
|
||||
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
|
||||
tx: Tx,
|
||||
): Promise<void> {
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: event.aggregateId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (colony === null) {
|
||||
if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
|
||||
return;
|
||||
}
|
||||
await this.automation.evaluateColonyHealthIn(
|
||||
tx,
|
||||
colony.id,
|
||||
event.aggregateId,
|
||||
event.payload.from,
|
||||
event.payload.to,
|
||||
{ id: event.id, occurredAt: event.occurredAt },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { StockModule } from '../stock/stock.module';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
import { SuggestionsModule } from '../suggestions/suggestions.module';
|
||||
import { AutomationController } from './automation.controller';
|
||||
import { AutomationEventHandlers } from './automation.handlers';
|
||||
import { AutomationService } from './automation.service';
|
||||
|
||||
/**
|
||||
* Automation rules and the handlers that evaluate them on domain events.
|
||||
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
|
||||
* it calendar entry and task.created webhook), so no WebhooksService here.
|
||||
* The rules propose through SuggestionsService; the task follows when the
|
||||
* beekeeper accepts (TasksService, with calendar entry and task.created
|
||||
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TasksModule, NotificationsModule, StockModule],
|
||||
imports: [SuggestionsModule, StockModule],
|
||||
controllers: [AutomationController],
|
||||
providers: [AutomationService, AutomationEventHandlers],
|
||||
exports: [AutomationService],
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -9,9 +9,16 @@ import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import { assertTransactionClient, DbClient } from '../prisma/db-client';
|
||||
import { runAfterCommit } from '../common/after-commit';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
import { addDays, berlinDayOf, berlinToday } from '../common/dates';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import type { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
|
||||
import { SuggestionsService } from '../suggestions/suggestions.service';
|
||||
import {
|
||||
suggestionDedupeKey,
|
||||
type SuggestionEvidence,
|
||||
} from '../suggestions/suggestion.types';
|
||||
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
||||
import {
|
||||
AutomationRule,
|
||||
AutomationTriggerType,
|
||||
@@ -20,6 +27,8 @@ import {
|
||||
Prisma,
|
||||
RawMaterialKind,
|
||||
StockItem,
|
||||
Suggestion,
|
||||
SuggestionIntent,
|
||||
SwarmControlCheck,
|
||||
TaskRelatedType,
|
||||
Treatment,
|
||||
@@ -27,8 +36,13 @@ import {
|
||||
} from '../../generated/prisma';
|
||||
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
|
||||
import { formatGeneratedNumber } from './number-format';
|
||||
import { DEFAULT_RULES, validateRuleConfig } from './rule-config';
|
||||
import { varroaThresholdOf } from './varroa-threshold';
|
||||
import {
|
||||
DEFAULT_RULES,
|
||||
lockRuleIn,
|
||||
readRule,
|
||||
validateRuleConfig,
|
||||
} from './rule-config';
|
||||
import { rateMeasurement } from './varroa-bands';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
@@ -39,11 +53,6 @@ export type NumberFormatTrigger =
|
||||
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
|
||||
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
|
||||
|
||||
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
|
||||
BEESWAX: 'Bienenwachs',
|
||||
PROPOLIS: 'Propolis',
|
||||
};
|
||||
|
||||
/** What {ART} turns into in the raw material number format. */
|
||||
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
|
||||
BEESWAX: 'WA',
|
||||
@@ -63,37 +72,44 @@ function isUniqueViolation(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
});
|
||||
/** The rules that propose instead of creating anything themselves. */
|
||||
export type RuleSuggestionTrigger =
|
||||
| 'QUEENLESS_NO_EGGS'
|
||||
| 'FEED_STATUS_CRITICAL'
|
||||
| 'VARROA_THRESHOLD'
|
||||
| 'SWARM_MOOD_HIGH'
|
||||
| 'COLONY_HEALTH_CRITICAL'
|
||||
| 'STOCK_ITEM_LOW'
|
||||
| 'RAW_MATERIAL_LOW'
|
||||
| 'TREATMENT_FOLLOW_UP'
|
||||
| 'FEEDING_FOLLOW_UP';
|
||||
|
||||
export interface RuleSuggestionTarget {
|
||||
relatedType: TaskRelatedType;
|
||||
relatedId: string;
|
||||
colonyId: string | null;
|
||||
intent: SuggestionIntent;
|
||||
reasonCode: string;
|
||||
evidence: SuggestionEvidence;
|
||||
anchor: string;
|
||||
requiredPermission: PermissionKey;
|
||||
dueDate?: string | null;
|
||||
sourceEventId?: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
/** The anchor of a finding that has no record to hang on. */
|
||||
const NO_ANCHOR = 'none';
|
||||
|
||||
/**
|
||||
* Optional per-rule follow-up interval (config.dueInDays) counted from the
|
||||
* business day. 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
|
||||
* suggestion stays date-less unless the beekeeper configures one (accepting
|
||||
* it then creates a task due today).
|
||||
*/
|
||||
function dueDayOf(config: object, baseDay: string): string | null {
|
||||
const { dueInDays } = config as { dueInDays?: unknown };
|
||||
return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -103,8 +119,7 @@ export class AutomationService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly tasks: TasksService,
|
||||
private readonly suggestions: SuggestionsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -216,42 +231,58 @@ export class AutomationService {
|
||||
|
||||
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
|
||||
await this.ensureDefaults(tx);
|
||||
const rules = await tx.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.triggerActionIn(
|
||||
if (inspection.queenSeen === false && inspection.eggsPresent === false) {
|
||||
await this.proposeOnInspectionIn(
|
||||
tx,
|
||||
queenlessRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
'QUEENLESS_NO_EGGS',
|
||||
'QUEEN_CHECK',
|
||||
inspection,
|
||||
);
|
||||
}
|
||||
|
||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||
if (feedRule && inspection.feedStatus === 'CRITICAL') {
|
||||
await this.triggerActionIn(
|
||||
if (inspection.feedStatus === 'CRITICAL') {
|
||||
await this.proposeOnInspectionIn(
|
||||
tx,
|
||||
feedRule,
|
||||
'COLONY',
|
||||
inspection.colonyId,
|
||||
inspection.id,
|
||||
'FEED_STATUS_CRITICAL',
|
||||
'FEED',
|
||||
inspection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The reason code of an inspection rule is the rule's own name. */
|
||||
private async proposeOnInspectionIn(
|
||||
tx: Tx,
|
||||
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
|
||||
intent: SuggestionIntent,
|
||||
inspection: Inspection,
|
||||
): Promise<void> {
|
||||
const rule = await readRule(tx, trigger);
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const inspectedOn = berlinDayOf(inspection.date);
|
||||
await this.proposeForRuleIn(tx, trigger, {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: inspection.colonyId,
|
||||
colonyId: inspection.colonyId,
|
||||
intent,
|
||||
reasonCode: trigger,
|
||||
evidence: {
|
||||
colonyId: inspection.colonyId,
|
||||
inspectionId: inspection.id,
|
||||
inspectedOn,
|
||||
},
|
||||
anchor: inspection.id,
|
||||
requiredPermission: 'inspections:read',
|
||||
dueDate: dueDayOf(rule.config, inspectedOn),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A red measurement outside the effect of a treatment proposes a
|
||||
* treatment check. The light comes from the same bands as the colony view
|
||||
* (season, young colony, method), so rule and screen never disagree.
|
||||
*/
|
||||
async evaluateVarroaMeasurementIn(
|
||||
tx: Tx,
|
||||
measurement: VarroaMeasurement,
|
||||
@@ -260,26 +291,78 @@ export class AutomationService {
|
||||
tx,
|
||||
'AutomationService.evaluateVarroaMeasurementIn',
|
||||
);
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||
const rule = await readRule(tx, 'VARROA_THRESHOLD');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: measurement.colonyId },
|
||||
select: { colonyType: true },
|
||||
});
|
||||
// null for an inactive rule; the one source of the default value.
|
||||
const threshold = varroaThresholdOf(rule);
|
||||
if (!rule || threshold === null) {
|
||||
if (colony === null) {
|
||||
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') {
|
||||
const rating = rateMeasurement(
|
||||
measurement,
|
||||
colony.colonyType,
|
||||
rule.config,
|
||||
await treatmentWindowsIn(tx, measurement.colonyId),
|
||||
);
|
||||
// A correction can move a measurement out of red (or into it): an open
|
||||
// suggestion of this measurement follows the corrected value.
|
||||
const open = await tx.suggestion.findFirst({
|
||||
where: {
|
||||
ruleKey: 'VARROA_THRESHOLD',
|
||||
status: 'OPEN',
|
||||
dedupeKey: suggestionDedupeKey(
|
||||
'VARROA_THRESHOLD',
|
||||
measurement.colonyId,
|
||||
measurement.id,
|
||||
),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (
|
||||
rating.light !== 'red' ||
|
||||
rating.band === null ||
|
||||
measurement.calculatedValue === null
|
||||
) {
|
||||
if (open !== null) {
|
||||
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
|
||||
const measuredOn = berlinDayOf(measurement.date);
|
||||
const evidence = {
|
||||
colonyId: measurement.colonyId,
|
||||
measurementId: measurement.id,
|
||||
measuredOn,
|
||||
value: measurement.calculatedValue,
|
||||
redAbove: rating.band.redAbove,
|
||||
unit: rating.band.unit,
|
||||
};
|
||||
const dueDate = dueDayOf(rule.config, measuredOn);
|
||||
if (open !== null) {
|
||||
await this.suggestions.refreshIn(tx, open.id, {
|
||||
reasonCode: 'VARROA_RED',
|
||||
evidence,
|
||||
dueDate,
|
||||
priority: 'HIGH',
|
||||
expiresAt: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.proposeForRuleIn(tx, 'VARROA_THRESHOLD', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: measurement.colonyId,
|
||||
colonyId: measurement.colonyId,
|
||||
intent: 'VARROA_TREAT',
|
||||
reasonCode: 'VARROA_RED',
|
||||
evidence,
|
||||
anchor: measurement.id,
|
||||
requiredPermission: 'varroa:read',
|
||||
dueDate,
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateSwarmControlCheckIn(
|
||||
@@ -290,24 +373,38 @@ export class AutomationService {
|
||||
tx,
|
||||
'AutomationService.evaluateSwarmControlCheckIn',
|
||||
);
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'SWARM_MOOD_HIGH' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
if (check.swarmMood !== 'HIGH') {
|
||||
return;
|
||||
}
|
||||
if (check.swarmMood === 'HIGH') {
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
|
||||
const rule = await readRule(tx, 'SWARM_MOOD_HIGH');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const checkedOn = berlinDayOf(check.date);
|
||||
await this.proposeForRuleIn(tx, 'SWARM_MOOD_HIGH', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: check.colonyId,
|
||||
colonyId: check.colonyId,
|
||||
intent: 'SWARM_CHECK',
|
||||
reasonCode: 'SWARM_MOOD_HIGH',
|
||||
evidence: { colonyId: check.colonyId, checkId: check.id, checkedOn },
|
||||
anchor: check.id,
|
||||
requiredPermission: 'swarm-control:read',
|
||||
dueDate: dueDayOf(rule.config, checkedOn),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
|
||||
/**
|
||||
* Fires only on the transition into CRITICAL, not on every edit while
|
||||
* already critical. The event is the anchor: a later transition into
|
||||
* CRITICAL is a new finding.
|
||||
*/
|
||||
async evaluateColonyHealthIn(
|
||||
tx: Tx,
|
||||
colonyId: string,
|
||||
from: ColonyStateSnapshot,
|
||||
to: ColonyStateSnapshot,
|
||||
event: { id: string; occurredAt: Date },
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
|
||||
// ColonyStatusChanged comes with every status or health edit: decide on
|
||||
@@ -315,16 +412,29 @@ export class AutomationService {
|
||||
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
|
||||
return;
|
||||
}
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
const rule = await readRule(tx, 'COLONY_HEALTH_CRITICAL');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
|
||||
const changedOn = berlinDayOf(event.occurredAt);
|
||||
await this.proposeForRuleIn(tx, 'COLONY_HEALTH_CRITICAL', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: colonyId,
|
||||
colonyId,
|
||||
intent: 'HEALTH_CHECK',
|
||||
reasonCode: 'COLONY_HEALTH_CRITICAL',
|
||||
evidence: { colonyId, changedOn },
|
||||
anchor: event.id,
|
||||
requiredPermission: 'colonies:read',
|
||||
dueDate: dueDayOf(rule.config, changedOn),
|
||||
sourceEventId: event.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchored on the newest incoming movement: a dismissed reorder stays
|
||||
* dismissed until the item was restocked and ran low again.
|
||||
*/
|
||||
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
|
||||
// Every movement of the item comes here: only one below its minimum
|
||||
@@ -332,22 +442,39 @@ export class AutomationService {
|
||||
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
|
||||
return;
|
||||
}
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'STOCK_ITEM_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
const rule = await readRule(tx, 'STOCK_ITEM_LOW');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
|
||||
const restock = await tx.stockMovement.findFirst({
|
||||
where: { stockItemId: item.id, type: 'IN' },
|
||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: { id: true },
|
||||
});
|
||||
await this.proposeForRuleIn(tx, 'STOCK_ITEM_LOW', {
|
||||
relatedType: 'STORAGE',
|
||||
relatedId: item.id,
|
||||
colonyId: null,
|
||||
intent: 'STOCK_REORDER',
|
||||
reasonCode: 'STOCK_ITEM_LOW',
|
||||
evidence: {
|
||||
stockItemId: item.id,
|
||||
quantity: item.quantity,
|
||||
minThreshold: item.minThreshold,
|
||||
},
|
||||
anchor: restock?.id ?? NO_ANCHOR,
|
||||
requiredPermission: 'storage:read',
|
||||
dueDate: dueDayOf(rule.config, berlinToday()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After raw material left its lots (consumption, withdrawal, inventory):
|
||||
* compares what is left of the kind across all lots with config.minGrams.
|
||||
* The task is about the kind, not one lot - a new lot of the same kind is
|
||||
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
|
||||
* keeps one open task per kind.
|
||||
* The suggestion is about the kind, not one lot - a new lot of the same
|
||||
* kind is the remedy - so it hangs on GENERAL with the kind as relatedId,
|
||||
* which keeps one open suggestion per kind, and is anchored on the newest
|
||||
* lot of the kind.
|
||||
*/
|
||||
async evaluateRawMaterialLowIn(
|
||||
tx: Tx,
|
||||
@@ -355,69 +482,73 @@ export class AutomationService {
|
||||
remainingGrams: number,
|
||||
): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType: 'RAW_MATERIAL_LOW' },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
const rule = await readRule(tx, 'RAW_MATERIAL_LOW');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as {
|
||||
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
|
||||
} | null;
|
||||
const minGrams = config?.minGrams?.[kind];
|
||||
const minGrams = rule.config.minGrams?.[kind];
|
||||
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
|
||||
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
|
||||
const newestLot = await tx.rawMaterialLot.findFirst({
|
||||
where: { kind },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: { id: true },
|
||||
});
|
||||
await this.proposeForRuleIn(tx, 'RAW_MATERIAL_LOW', {
|
||||
relatedType: 'GENERAL',
|
||||
relatedId: kind,
|
||||
colonyId: null,
|
||||
intent: 'STOCK_REORDER',
|
||||
reasonCode: 'RAW_MATERIAL_LOW',
|
||||
evidence: { kind, remainingGrams, minGrams },
|
||||
anchor: newestLot?.id ?? NO_ANCHOR,
|
||||
requiredPermission: 'raw-material:read',
|
||||
dueDate: dueDayOf(rule.config, berlinToday()),
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
|
||||
const measureDate = treatment.endDate ?? treatment.date;
|
||||
await this.evaluateFollowUpIn(
|
||||
tx,
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
treatment.colonyId,
|
||||
measureDate,
|
||||
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
|
||||
);
|
||||
const rule = await readRule(tx, 'TREATMENT_FOLLOW_UP');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
const endedOn = berlinDayOf(treatment.endDate ?? treatment.date);
|
||||
await this.proposeForRuleIn(tx, 'TREATMENT_FOLLOW_UP', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: treatment.colonyId,
|
||||
colonyId: treatment.colonyId,
|
||||
intent: 'FOLLOW_UP',
|
||||
reasonCode: 'TREATMENT_FOLLOW_UP',
|
||||
evidence: {
|
||||
colonyId: treatment.colonyId,
|
||||
treatmentId: treatment.id,
|
||||
endedOn,
|
||||
},
|
||||
anchor: treatment.id,
|
||||
requiredPermission: 'treatments:read',
|
||||
dueDate: dueDayOf(rule.config, endedOn),
|
||||
});
|
||||
}
|
||||
|
||||
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
|
||||
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
|
||||
await this.evaluateFollowUpIn(
|
||||
tx,
|
||||
'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. Its public
|
||||
* callers check the transaction client, so an error names them.
|
||||
*/
|
||||
private async evaluateFollowUpIn(
|
||||
tx: Tx,
|
||||
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
|
||||
colonyId: string,
|
||||
measureDate: Date,
|
||||
description: string,
|
||||
): Promise<void> {
|
||||
await this.ensureDefaults(tx);
|
||||
const rule = await tx.automationRule.findUnique({
|
||||
where: { triggerType },
|
||||
});
|
||||
if (!rule || !rule.active) {
|
||||
const rule = await readRule(tx, 'FEEDING_FOLLOW_UP');
|
||||
if (!rule.active) {
|
||||
return;
|
||||
}
|
||||
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
|
||||
baseDate: measureDate,
|
||||
description,
|
||||
const fedOn = berlinDayOf(feeding.date);
|
||||
await this.proposeForRuleIn(tx, 'FEEDING_FOLLOW_UP', {
|
||||
relatedType: 'COLONY',
|
||||
relatedId: feeding.colonyId,
|
||||
colonyId: feeding.colonyId,
|
||||
intent: 'FOLLOW_UP',
|
||||
reasonCode: 'FEEDING_FOLLOW_UP',
|
||||
evidence: { colonyId: feeding.colonyId, feedingId: feeding.id, fedOn },
|
||||
anchor: feeding.id,
|
||||
requiredPermission: 'feedings:read',
|
||||
dueDate: dueDayOf(rule.config, fedOn),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -547,84 +678,80 @@ export class AutomationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves a suggestion task (never a record of work done) plus a warning,
|
||||
* all on the caller's transaction: task, its audit entry and TaskCreated
|
||||
* (TasksService.createInTx - the task.created webhook and the calendar
|
||||
* entry follow from that event), the warning and the rule's audit entry.
|
||||
* A failing write rolls back all of it; a handler delivery is retried.
|
||||
* Proposes what a rule found, on the caller's transaction: the suggestion
|
||||
* (SuggestionsService.proposeIn: colony then rule lock, one open thing per
|
||||
* intent and object, an anchor is proposed once, the notification when the
|
||||
* rule says `notify`) and the rule's audit entry. A failing write rolls
|
||||
* back all of it; a handler delivery is retried. Resolves to null when
|
||||
* nothing was proposed (rule off, colony ended, anchor or intent taken).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* The check runs under a lock on the rule's row, held until the caller
|
||||
* commits: two evaluations at the same moment (parallel requests, a
|
||||
* handler and a legacy path, several pods) would otherwise both see
|
||||
* "no open task" and both create one.
|
||||
* The colony is locked and read before the rule is locked - the order
|
||||
* every proposal uses - so a colony that ends in between is not missed.
|
||||
*/
|
||||
private async triggerActionIn(
|
||||
async proposeForRuleIn(
|
||||
tx: Tx,
|
||||
rule: AutomationRule,
|
||||
relatedType: TaskRelatedType,
|
||||
relatedId: string,
|
||||
sourceInspectionId?: string,
|
||||
options: { baseDate?: Date; description?: string } = {},
|
||||
): Promise<void> {
|
||||
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
|
||||
const alreadyOpen = await tx.task.findFirst({
|
||||
where: {
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
relatedType,
|
||||
relatedId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
trigger: RuleSuggestionTrigger,
|
||||
target: RuleSuggestionTarget,
|
||||
): Promise<Suggestion | null> {
|
||||
assertTransactionClient(tx, 'AutomationService.proposeForRuleIn');
|
||||
if (
|
||||
target.colonyId !== null &&
|
||||
!(await this.isLivingColonyLockedIn(tx, target.colonyId))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const rule = await lockRuleIn(tx, trigger);
|
||||
if (!rule.active) {
|
||||
return null;
|
||||
}
|
||||
const suggestion = await this.suggestions.proposeIn(tx, {
|
||||
ruleKey: trigger,
|
||||
source: 'RULE',
|
||||
mode: 'PLAN',
|
||||
intent: target.intent,
|
||||
priority: 'HIGH',
|
||||
relatedType: target.relatedType,
|
||||
relatedId: target.relatedId,
|
||||
colonyId: target.colonyId,
|
||||
reasonCode: target.reasonCode,
|
||||
evidence: target.evidence,
|
||||
requiredPermission: target.requiredPermission,
|
||||
dueDate: target.dueDate ?? null,
|
||||
anchor: target.anchor,
|
||||
sourceEventId: target.sourceEventId ?? null,
|
||||
notify: rule.config.notify,
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return;
|
||||
if (suggestion === null) {
|
||||
return null;
|
||||
}
|
||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||
const config = rule.config as { dueInDays?: unknown } | null;
|
||||
const task = await this.tasks.createInTx(
|
||||
tx,
|
||||
{
|
||||
title,
|
||||
priority: 'HIGH',
|
||||
relatedType,
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
description: options.description,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
|
||||
},
|
||||
null,
|
||||
);
|
||||
// Task 7 replaces this path by suggestions.
|
||||
await this.notifications.recordForPermission(
|
||||
'tasks:read',
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'SUGGESTION',
|
||||
message: title,
|
||||
relatedType,
|
||||
relatedId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'AutomationRule',
|
||||
entityId: rule.id,
|
||||
action: 'CREATE',
|
||||
afterData: {
|
||||
taskId: task.id,
|
||||
relatedType,
|
||||
relatedId,
|
||||
triggerType: rule.triggerType,
|
||||
suggestionId: suggestion.id,
|
||||
relatedType: target.relatedType,
|
||||
relatedId: target.relatedId,
|
||||
triggerType: trigger,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
private async isLivingColonyLockedIn(
|
||||
tx: Tx,
|
||||
colonyId: string,
|
||||
): Promise<boolean> {
|
||||
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
|
||||
return false;
|
||||
}
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { status: true },
|
||||
});
|
||||
return colony !== null && isLivingColonyStatus(colony.status);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export interface SimpleRuleOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules that create one task: notify, an optional follow-up interval
|
||||
* Rules that propose one suggestion: notify, an optional follow-up interval
|
||||
* (dueInDays) and, for the raw material rule, minGrams per kind.
|
||||
*/
|
||||
export function simpleParser(
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
||||
varroaThresholdOf,
|
||||
} from './varroa-threshold';
|
||||
|
||||
describe('varroaThresholdOf', () => {
|
||||
it('uses the default when no rule exists yet (default rule is active)', () => {
|
||||
expect(varroaThresholdOf(null)).toBe(
|
||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
||||
);
|
||||
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
|
||||
});
|
||||
|
||||
it('returns null for an inactive rule', () => {
|
||||
expect(
|
||||
varroaThresholdOf({
|
||||
active: false,
|
||||
config: { thresholdMitesPerDay: 7 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the configured threshold of an active rule', () => {
|
||||
expect(
|
||||
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('reads naturalFall.redAbove from the band format', () => {
|
||||
expect(
|
||||
varroaThresholdOf({
|
||||
active: true,
|
||||
config: { naturalFall: { redAbove: 8 } },
|
||||
}),
|
||||
).toBe(8);
|
||||
});
|
||||
|
||||
it('prefers the band format over the legacy key', () => {
|
||||
expect(
|
||||
varroaThresholdOf({
|
||||
active: true,
|
||||
config: { thresholdMitesPerDay: 7, naturalFall: { redAbove: 8 } },
|
||||
}),
|
||||
).toBe(8);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a string', { thresholdMitesPerDay: 'x' }],
|
||||
['zero', { thresholdMitesPerDay: 0 }],
|
||||
['a negative number', { thresholdMitesPerDay: -1 }],
|
||||
['NaN', { thresholdMitesPerDay: Number.NaN }],
|
||||
['a missing key', {}],
|
||||
['a null config', null],
|
||||
])('falls back to the default for %s', (_label, config) => {
|
||||
expect(varroaThresholdOf({ active: true, config })).toBe(
|
||||
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { AutomationRule } from '../../generated/prisma';
|
||||
import { ruleConfigOf } from './rule-config';
|
||||
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
|
||||
|
||||
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
|
||||
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY =
|
||||
DEFAULT_VARROA_THRESHOLD_CONFIG.naturalFall.redAbove;
|
||||
|
||||
/**
|
||||
* Effective varroa threshold of the VARROA_THRESHOLD rule: the red limit of
|
||||
* the natural mite fall (naturalFall.redAbove; the legacy key
|
||||
* thresholdMitesPerDay reads the same). Task 7 replaces this with the bands.
|
||||
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
|
||||
* - inactive rule: null (no threshold in force)
|
||||
* - active rule: the configured value, or the default if it is not usable
|
||||
* (a hand-edited config must never disable the check).
|
||||
*/
|
||||
export function varroaThresholdOf(
|
||||
rule: Pick<AutomationRule, 'active' | 'config'> | null,
|
||||
): number | null {
|
||||
if (rule === null) {
|
||||
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
||||
}
|
||||
if (!rule.active) {
|
||||
return null;
|
||||
}
|
||||
return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove;
|
||||
}
|
||||
Reference in new issue
Block a user