feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers - Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as ColonyCondition with legacy text mapping), split queen source, varroa sample size; migration harness for data migrations - SuggestionsService: propose/refresh/resolve/obsolete/expire, accept/dismiss/reopen incl. bulk, lock order, concurrency e2e - SeasonService: phenology-based segments, inspection/varroa due dates, queen age; TimeEventsStep with JobPartialFailure - Frontend: per-rule settings forms with visible value sources Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { AutomationService } from './automation.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
@@ -26,6 +26,7 @@ describe('AutomationService', () => {
|
||||
createMany: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
task: { findFirst: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
@@ -84,6 +85,7 @@ describe('AutomationService', () => {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
},
|
||||
task: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
@@ -438,7 +440,7 @@ describe('AutomationService', () => {
|
||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers when feed status is kritisch, case-insensitive', async () => {
|
||||
it('triggers when feed status is CRITICAL', async () => {
|
||||
tx.automationRule.findMany.mockResolvedValue([feedRule]);
|
||||
|
||||
await service.evaluateInspectionIn(asTx(), {
|
||||
@@ -446,7 +448,7 @@ describe('AutomationService', () => {
|
||||
colonyId: 'colony-1',
|
||||
queenSeen: true,
|
||||
eggsPresent: true,
|
||||
feedStatus: 'Kritisch',
|
||||
feedStatus: 'CRITICAL',
|
||||
} as never);
|
||||
|
||||
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
|
||||
@@ -461,7 +463,7 @@ describe('AutomationService', () => {
|
||||
|
||||
await service.evaluateInspectionIn(asTx(), {
|
||||
...queenlessInspection,
|
||||
feedStatus: 'kritisch',
|
||||
feedStatus: 'CRITICAL',
|
||||
} as never);
|
||||
|
||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||
@@ -917,9 +919,55 @@ describe('AutomationService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('records an audit log entry with before/after data', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
// The rule row is locked first, then read, so "before" is the committed
|
||||
// state at the moment of the write.
|
||||
beforeEach(() => {
|
||||
tx.$queryRaw.mockResolvedValue([{ locked: 1 }]);
|
||||
});
|
||||
|
||||
it('locks the rule row and reads before inside the transaction', async () => {
|
||||
const calls: string[] = [];
|
||||
tx.$queryRaw.mockImplementation(() => {
|
||||
calls.push('lock');
|
||||
return Promise.resolve([{ locked: 1 }]);
|
||||
});
|
||||
tx.automationRule.findUnique.mockImplementation(() => {
|
||||
calls.push('read');
|
||||
return Promise.resolve(varroaRule);
|
||||
});
|
||||
tx.automationRule.update.mockImplementation(() => {
|
||||
calls.push('write');
|
||||
return Promise.resolve({ ...varroaRule, active: false });
|
||||
});
|
||||
auditLog.record.mockImplementation(() => {
|
||||
calls.push('audit');
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await service.update('rule-3', { active: false }, 'user-1');
|
||||
|
||||
expect(calls).toEqual(['lock', 'read', 'write', 'audit']);
|
||||
const [[lockQuery]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
|
||||
expect(lockQuery.sql).toContain('FROM "AutomationRule"');
|
||||
expect(lockQuery.sql).toContain('FOR UPDATE');
|
||||
expect(lockQuery.values).toEqual(['rule-3']);
|
||||
// Nothing is read or written outside the transaction.
|
||||
expect(prisma.automationRule.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.automationRule.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 404 when the rule does not exist', async () => {
|
||||
tx.$queryRaw.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { active: false }, 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(tx.automationRule.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records an audit log entry with before/after data in the transaction', async () => {
|
||||
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.update.mockResolvedValue({
|
||||
...varroaRule,
|
||||
active: false,
|
||||
});
|
||||
@@ -931,20 +979,22 @@ describe('AutomationService', () => {
|
||||
entityType: 'AutomationRule',
|
||||
entityId: 'rule-3',
|
||||
action: 'UPDATE',
|
||||
beforeData: varroaRule,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the config on null so the rule falls back to its defaults', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue({
|
||||
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.update.mockResolvedValue({
|
||||
...varroaRule,
|
||||
config: null,
|
||||
});
|
||||
|
||||
await service.update('rule-3', { config: null as never }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
||||
const [[updateArg]] = tx.automationRule.update.mock.calls as [
|
||||
[{ data: { config?: unknown } }],
|
||||
];
|
||||
// A Json column is cleared with DbNull - a plain null fails the write.
|
||||
@@ -952,16 +1002,60 @@ describe('AutomationService', () => {
|
||||
});
|
||||
|
||||
it('leaves the config untouched when it is omitted', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
prisma.automationRule.update.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.update.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.update('rule-3', { active: true }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
|
||||
const [[updateArg]] = tx.automationRule.update.mock.calls as [
|
||||
[{ data: { config?: unknown } }],
|
||||
];
|
||||
expect(updateArg.data.config).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an invalid known value with the field and writes nothing', async () => {
|
||||
tx.automationRule.findUnique.mockResolvedValue({
|
||||
...varroaRule,
|
||||
id: 'rule-9',
|
||||
triggerType: 'SEASON_AUTOPILOT',
|
||||
config: {},
|
||||
});
|
||||
|
||||
const result = service.update(
|
||||
'rule-9',
|
||||
{ config: { queenMaxAgeYears: 0, somethingElse: true } },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
response: {
|
||||
message: 'Die Einstellung ist ungültig.',
|
||||
issues: [{ field: 'queenMaxAgeYears' }],
|
||||
},
|
||||
});
|
||||
expect(tx.automationRule.update).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps unknown keys of a valid config as sent', async () => {
|
||||
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
|
||||
tx.automationRule.update.mockResolvedValue(varroaRule);
|
||||
|
||||
await service.update(
|
||||
'rule-3',
|
||||
{ config: { thresholdMitesPerDay: 7, somethingElse: true } },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[updateArg]] = tx.automationRule.update.mock.calls as [
|
||||
[{ data: { config?: unknown } }],
|
||||
];
|
||||
expect(updateArg.data.config).toEqual({
|
||||
thresholdMitesPerDay: 7,
|
||||
somethingElse: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateNumber', () => {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
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';
|
||||
@@ -21,6 +27,7 @@ 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';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@@ -56,84 +63,6 @@ function isUniqueViolation(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
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: '',
|
||||
},
|
||||
// {ART} (WA/PR) gives wax and propolis a counter each.
|
||||
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
|
||||
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
|
||||
actionTemplate: '',
|
||||
},
|
||||
PRODUCTION_BATCH_NUMBER_FORMAT: {
|
||||
config: { format: 'HS-{YYYY}-{SEQ:4}' },
|
||||
actionTemplate: '',
|
||||
},
|
||||
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
|
||||
// no minimum of their own, so the minimum per kind (config.minGrams) lives
|
||||
// here - and without one there is nothing to compare, hence off by default.
|
||||
RAW_MATERIAL_LOW: {
|
||||
config: {},
|
||||
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
||||
active: false,
|
||||
},
|
||||
// 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
|
||||
@@ -223,33 +152,58 @@ export class AutomationService {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row is locked before it is read, so "before" (and the audit entry)
|
||||
* is the state this write replaces. A config with an invalid known value
|
||||
* is refused with the fields named; unknown keys pass unchanged.
|
||||
*/
|
||||
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,
|
||||
// null resets the rule to its defaults; a Json column is cleared
|
||||
// with DbNull, a plain null would fail the write.
|
||||
config:
|
||||
dto.config === null
|
||||
? Prisma.DbNull
|
||||
: (dto.config as Prisma.InputJsonValue | undefined),
|
||||
},
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const locked = await lockRowForUpdate(tx, 'AutomationRule', id);
|
||||
const before = locked
|
||||
? await tx.automationRule.findUnique({ where: { id } })
|
||||
: null;
|
||||
if (!before) {
|
||||
throw new NotFoundException('Automation rule not found');
|
||||
}
|
||||
if (typeof dto.config === 'object' && dto.config !== null) {
|
||||
const issues = validateRuleConfig(before.triggerType, dto.config);
|
||||
if (issues.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'Die Einstellung ist ungültig.',
|
||||
issues,
|
||||
});
|
||||
}
|
||||
}
|
||||
const rule = await tx.automationRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...dto,
|
||||
// null resets the rule to its defaults; a Json column is cleared
|
||||
// with DbNull, a plain null would fail the write.
|
||||
config:
|
||||
dto.config === null
|
||||
? Prisma.DbNull
|
||||
: (dto.config as Prisma.InputJsonValue | undefined),
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'AutomationRule',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: rule,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return rule;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'AutomationRule',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: rule,
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -287,7 +241,7 @@ export class AutomationService {
|
||||
}
|
||||
|
||||
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
|
||||
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
|
||||
if (feedRule && inspection.feedStatus === 'CRITICAL') {
|
||||
await this.triggerActionIn(
|
||||
tx,
|
||||
feedRule,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { bool, intIn, makeParser } from './parse-helpers';
|
||||
import type { ChainConfig } from './rule-config.types';
|
||||
|
||||
const MAX_DUE_IN_DAYS = 365;
|
||||
|
||||
/** Follow-up chains (queen cells, queen acceptance): one check some days later. */
|
||||
export function chainParser(trigger: string, defaults: ChainConfig) {
|
||||
return makeParser<ChainConfig>(trigger, defaults, (scope) => ({
|
||||
dueInDays: intIn(
|
||||
scope,
|
||||
'dueInDays',
|
||||
defaults.dueInDays,
|
||||
0,
|
||||
MAX_DUE_IN_DAYS,
|
||||
),
|
||||
notify: bool(scope, 'notify', defaults.notify),
|
||||
}));
|
||||
}
|
||||
|
||||
export const DEFAULT_CHAIN_CONFIG: ChainConfig = {
|
||||
dueInDays: 7,
|
||||
notify: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_WITHDRAWAL_ENDED_CONFIG = { notify: true };
|
||||
|
||||
export const withdrawalEndedParser = makeParser<{ notify: boolean }>(
|
||||
'WITHDRAWAL_ENDED',
|
||||
DEFAULT_WITHDRAWAL_ENDED_CONFIG,
|
||||
(scope) => ({
|
||||
notify: bool(scope, 'notify', DEFAULT_WITHDRAWAL_ENDED_CONFIG.notify),
|
||||
}),
|
||||
);
|
||||
|
||||
/** The cleanup rule has no setting; it is only switched on or off. */
|
||||
export const colonyEndedCleanupParser = makeParser<Record<string, never>>(
|
||||
'COLONY_ENDED_CLEANUP',
|
||||
{},
|
||||
() => ({}),
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||
import { RULE_CONFIG_PARSERS } from './rule-config.registry';
|
||||
|
||||
export interface RuleDefaults {
|
||||
config: Record<string, unknown>;
|
||||
actionTemplate: string;
|
||||
/** Rules without a sensible built-in setting start switched off. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/** Texts of the rules that create a task; the new rules have none yet. */
|
||||
const ACTION_TEMPLATES: Record<AutomationTriggerType, string> = {
|
||||
QUEENLESS_NO_EGGS:
|
||||
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
|
||||
VARROA_THRESHOLD: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
|
||||
FEED_STATUS_CRITICAL: 'Fütterung erforderlich: Futterstatus kritisch.',
|
||||
SWARM_MOOD_HIGH:
|
||||
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
|
||||
COLONY_HEALTH_CRITICAL: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
|
||||
STOCK_ITEM_LOW: '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: '',
|
||||
BOTTLING_LOT_NUMBER_FORMAT: '',
|
||||
RAW_MATERIAL_LOT_NUMBER_FORMAT: '',
|
||||
PRODUCTION_BATCH_NUMBER_FORMAT: '',
|
||||
RAW_MATERIAL_LOW: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
|
||||
TREATMENT_FOLLOW_UP: 'Behandlungserfolg kontrollieren',
|
||||
FEEDING_FOLLOW_UP: 'Futterabnahme und Futtervorrat kontrollieren',
|
||||
INSPECTION_INTERVAL: '',
|
||||
QUEEN_CELLS_SWARM_CHECK: '',
|
||||
QUEEN_ACCEPTANCE_CHECK: '',
|
||||
SPLIT_SCHEDULE: '',
|
||||
COLONY_ENDED_CLEANUP: '',
|
||||
WITHDRAWAL_ENDED: '',
|
||||
HONEY_WATER_CONTENT: '',
|
||||
SEASON_AUTOPILOT: '',
|
||||
PHENOLOGY: '',
|
||||
};
|
||||
|
||||
// - RAW_MATERIAL_LOW: raw material lots carry no minimum of their own, so
|
||||
// the minimum per kind (config.minGrams) lives on the rule - and without
|
||||
// one there is nothing to compare, hence off by default.
|
||||
// - 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.
|
||||
const INACTIVE_BY_DEFAULT: ReadonlySet<AutomationTriggerType> = new Set([
|
||||
'RAW_MATERIAL_LOW',
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
'FEEDING_FOLLOW_UP',
|
||||
]);
|
||||
|
||||
function defaultsOf(trigger: AutomationTriggerType): RuleDefaults {
|
||||
return {
|
||||
config: structuredClone(RULE_CONFIG_PARSERS[trigger].defaults) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
actionTemplate: ACTION_TEMPLATES[trigger],
|
||||
...(INACTIVE_BY_DEFAULT.has(trigger) ? { active: false } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The rule rows created on first use; the config is the parser's default. */
|
||||
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> =
|
||||
Object.fromEntries(
|
||||
(Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map(
|
||||
(trigger) => [trigger, defaultsOf(trigger)],
|
||||
),
|
||||
) as Record<AutomationTriggerType, RuleDefaults>;
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './rule-config.types';
|
||||
export {
|
||||
RULE_CONFIG_PARSERS,
|
||||
ruleConfigOf,
|
||||
validateRuleConfig,
|
||||
} from './rule-config.registry';
|
||||
export { DEFAULT_RULES, type RuleDefaults } from './default-rules';
|
||||
export { lockRuleIn, readRule, type ActiveRule } from './rule-config.reader';
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SeasonSegmentKey } from '../../season/season-types';
|
||||
import { childScope, intIn, makeParser } from './parse-helpers';
|
||||
import type { InspectionIntervalConfig } from './rule-config.types';
|
||||
|
||||
type IntervalSegment = Exclude<SeasonSegmentKey, 'WINTERRUHE'>;
|
||||
|
||||
const MIN_INTERVAL_DAYS = 7;
|
||||
const MAX_INTERVAL_DAYS = 35;
|
||||
/** In the swarm season a colony is looked at weekly, at the latest every 9 days. */
|
||||
const MAX_SWARM_INTERVAL_DAYS = 9;
|
||||
|
||||
export const DEFAULT_INSPECTION_INTERVAL_CONFIG: InspectionIntervalConfig = {
|
||||
intervalDays: {
|
||||
FRUEHJAHR: 14,
|
||||
SCHWARMZEIT: 7,
|
||||
SOMMER: 14,
|
||||
SPAETSOMMER: 21,
|
||||
HERBST: 21,
|
||||
},
|
||||
};
|
||||
|
||||
const SEGMENTS = Object.keys(
|
||||
DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays,
|
||||
) as IntervalSegment[];
|
||||
|
||||
export const inspectionIntervalParser = makeParser<InspectionIntervalConfig>(
|
||||
'INSPECTION_INTERVAL',
|
||||
DEFAULT_INSPECTION_INTERVAL_CONFIG,
|
||||
(scope) => {
|
||||
const days = childScope(scope, 'intervalDays');
|
||||
const defaults = DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays;
|
||||
const intervalDays = { ...defaults };
|
||||
for (const segment of SEGMENTS) {
|
||||
intervalDays[segment] = intIn(
|
||||
days,
|
||||
segment,
|
||||
defaults[segment],
|
||||
MIN_INTERVAL_DAYS,
|
||||
segment === 'SCHWARMZEIT' ? MAX_SWARM_INTERVAL_DAYS : MAX_INTERVAL_DAYS,
|
||||
);
|
||||
}
|
||||
return { intervalDays };
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,349 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { RuleConfigIssue, RuleConfigParser } from './rule-config.types';
|
||||
|
||||
/**
|
||||
* Where a value is read from: the object, its field path (for messages) and
|
||||
* the list the issues go to. Helpers never throw - an invalid or missing key
|
||||
* gives the fallback, and an invalid one that is present is reported.
|
||||
*/
|
||||
export interface Scope {
|
||||
readonly raw: Readonly<Record<string, unknown>>;
|
||||
readonly path: string;
|
||||
readonly issues: RuleConfigIssue[];
|
||||
}
|
||||
|
||||
const NO_VALUES: Readonly<Record<string, unknown>> = Object.freeze({});
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function rootScope(json: unknown, issues: RuleConfigIssue[]): Scope {
|
||||
return { raw: asRecord(json) ?? NO_VALUES, path: '', issues };
|
||||
}
|
||||
|
||||
export function fieldPath(scope: Scope, key: string): string {
|
||||
return scope.path === '' ? key : `${scope.path}.${key}`;
|
||||
}
|
||||
|
||||
export function report(scope: Scope, key: string, message: string): void {
|
||||
scope.issues.push({ field: fieldPath(scope, key), message });
|
||||
}
|
||||
|
||||
/**
|
||||
* A key counts as given when it is present - an explicit null too, which the
|
||||
* readers then report as invalid instead of silently using the default.
|
||||
*/
|
||||
export function has(scope: Scope, key: string): boolean {
|
||||
if (!Object.hasOwn(scope.raw, key)) {
|
||||
return false;
|
||||
}
|
||||
return scope.raw[key] !== undefined;
|
||||
}
|
||||
|
||||
/** An explicit null: on an optional key it clears the value. */
|
||||
export function isCleared(scope: Scope, key: string): boolean {
|
||||
return Object.hasOwn(scope.raw, key) && scope.raw[key] === null;
|
||||
}
|
||||
|
||||
/** The nested object under `key`; a non-object is reported and read as empty. */
|
||||
export function childScope(scope: Scope, key: string): Scope {
|
||||
const path = fieldPath(scope, key);
|
||||
if (!has(scope, key)) {
|
||||
return { raw: NO_VALUES, path, issues: scope.issues };
|
||||
}
|
||||
const nested = asRecord(scope.raw[key]);
|
||||
if (nested === null) {
|
||||
report(scope, key, 'Bitte die Angaben als Gruppe senden.');
|
||||
return { raw: NO_VALUES, path, issues: scope.issues };
|
||||
}
|
||||
return { raw: nested, path, issues: scope.issues };
|
||||
}
|
||||
|
||||
/** Runs `read`; if it reported anything, `fallback()` replaces its result. */
|
||||
export function orFallback<T>(
|
||||
scope: Scope,
|
||||
read: () => T,
|
||||
fallback: () => T,
|
||||
): T {
|
||||
const before = scope.issues.length;
|
||||
const value = read();
|
||||
return scope.issues.length > before ? fallback() : value;
|
||||
}
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('de-DE', {
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
|
||||
export function formatDe(value: number): string {
|
||||
return numberFormat.format(value);
|
||||
}
|
||||
|
||||
export function intIn(
|
||||
scope: Scope,
|
||||
key: string,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
if (!has(scope, key)) {
|
||||
return fallback;
|
||||
}
|
||||
const value = scope.raw[key];
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= min &&
|
||||
value <= max
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
report(
|
||||
scope,
|
||||
key,
|
||||
`Bitte eine ganze Zahl zwischen ${formatDe(min)} und ${formatDe(max)} wählen.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Like intIn, but an absent key stays undefined. */
|
||||
export function optionalIntIn(
|
||||
scope: Scope,
|
||||
key: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): number | undefined {
|
||||
if (!has(scope, key) || isCleared(scope, key)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = intIn(scope, key, Number.NaN, min, max);
|
||||
return Number.isNaN(value) ? undefined : value;
|
||||
}
|
||||
|
||||
export interface NumberOptions {
|
||||
/** The lower limit itself is not allowed (min < x). */
|
||||
exclusiveMin?: boolean;
|
||||
}
|
||||
|
||||
export function numberIn(
|
||||
scope: Scope,
|
||||
key: string,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
options: NumberOptions = {},
|
||||
): number {
|
||||
if (!has(scope, key)) {
|
||||
return fallback;
|
||||
}
|
||||
const value = scope.raw[key];
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
(options.exclusiveMin ? value > min : value >= min) &&
|
||||
value <= max
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
const lower = options.exclusiveMin
|
||||
? `größer als ${formatDe(min)}`
|
||||
: `mindestens ${formatDe(min)}`;
|
||||
report(
|
||||
scope,
|
||||
key,
|
||||
Number.isFinite(max)
|
||||
? `Bitte eine Zahl ${lower} und höchstens ${formatDe(max)} angeben.`
|
||||
: `Bitte eine Zahl ${lower} angeben.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Like numberIn, but an absent key stays undefined. */
|
||||
export function optionalNumberIn(
|
||||
scope: Scope,
|
||||
key: string,
|
||||
min: number,
|
||||
max: number,
|
||||
options: NumberOptions = {},
|
||||
): number | undefined {
|
||||
if (!has(scope, key) || isCleared(scope, key)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = numberIn(scope, key, Number.NaN, min, max, options);
|
||||
return Number.isNaN(value) ? undefined : value;
|
||||
}
|
||||
|
||||
export function bool(scope: Scope, key: string, fallback: boolean): boolean {
|
||||
if (!has(scope, key)) {
|
||||
return fallback;
|
||||
}
|
||||
const value = scope.raw[key];
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
report(scope, key, 'Bitte Ja oder Nein wählen.');
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function stringIn(
|
||||
scope: Scope,
|
||||
key: string,
|
||||
fallback: string,
|
||||
minLength: number,
|
||||
maxLength: number,
|
||||
pattern?: RegExp,
|
||||
): string {
|
||||
if (!has(scope, key)) {
|
||||
return fallback;
|
||||
}
|
||||
const value = scope.raw[key];
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
value.length >= minLength &&
|
||||
value.length <= maxLength &&
|
||||
(pattern === undefined || pattern.test(value))
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
report(
|
||||
scope,
|
||||
key,
|
||||
`Bitte einen Text mit ${minLength} bis ${maxLength} Zeichen angeben.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const MONTH_DAY = /^(\d{2})-(\d{2})$/;
|
||||
|
||||
/** 'MM-DD' that exists in a leap year (02-29 is allowed). */
|
||||
export function isMonthDay(value: unknown): value is string {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
const match = MONTH_DAY.exec(value);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const month = Number(match[1]);
|
||||
const day = Number(match[2]);
|
||||
const date = new Date(Date.UTC(2024, month - 1, day));
|
||||
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||
}
|
||||
|
||||
export function monthDay(scope: Scope, key: string, fallback: string): string {
|
||||
if (!has(scope, key)) {
|
||||
return fallback;
|
||||
}
|
||||
const value = scope.raw[key];
|
||||
if (isMonthDay(value)) {
|
||||
return value;
|
||||
}
|
||||
report(scope, key, 'Bitte ein Datum als MM-TT angeben, zum Beispiel 03-20.');
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function months(
|
||||
scope: Scope,
|
||||
key: string,
|
||||
fallback: readonly number[],
|
||||
): number[] {
|
||||
if (!has(scope, key)) {
|
||||
return [...fallback];
|
||||
}
|
||||
const value = scope.raw[key];
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(month): month is number =>
|
||||
typeof month === 'number' &&
|
||||
Number.isInteger(month) &&
|
||||
month >= 1 &&
|
||||
month <= 12,
|
||||
) &&
|
||||
new Set(value).size === value.length
|
||||
) {
|
||||
return [...value];
|
||||
}
|
||||
report(scope, key, 'Bitte Monate von 1 bis 12 angeben, jeden nur einmal.');
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
export function enumValue<T extends string>(
|
||||
scope: Scope,
|
||||
key: string,
|
||||
fallback: T,
|
||||
allowed: readonly T[],
|
||||
): T {
|
||||
if (!has(scope, key)) {
|
||||
return fallback;
|
||||
}
|
||||
const value = scope.raw[key];
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
(allowed as readonly string[]).includes(value)
|
||||
) {
|
||||
return value as T;
|
||||
}
|
||||
report(scope, key, 'Bitte einen gültigen Wert wählen.');
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function enumList<T extends string>(
|
||||
scope: Scope,
|
||||
key: string,
|
||||
fallback: readonly T[],
|
||||
allowed: readonly T[],
|
||||
): T[] {
|
||||
if (!has(scope, key)) {
|
||||
return [...fallback];
|
||||
}
|
||||
const value = scope.raw[key];
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(entry): entry is T =>
|
||||
typeof entry === 'string' &&
|
||||
(allowed as readonly string[]).includes(entry),
|
||||
) &&
|
||||
new Set(value).size === value.length
|
||||
) {
|
||||
return [...value];
|
||||
}
|
||||
report(scope, key, 'Bitte nur gültige Werte wählen, jeden nur einmal.');
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
const logger = new Logger('RuleConfig');
|
||||
|
||||
/**
|
||||
* Builds the parser of one rule from its reader. The reader turns the scope
|
||||
* into a complete config and reports every invalid key; `parse` logs those
|
||||
* (trigger and field, never the value) and returns the config, `validate`
|
||||
* returns them.
|
||||
*/
|
||||
export function makeParser<T>(
|
||||
trigger: string,
|
||||
defaults: T,
|
||||
read: (scope: Scope) => T,
|
||||
): RuleConfigParser<T> {
|
||||
return {
|
||||
defaults,
|
||||
parse(json) {
|
||||
const issues: RuleConfigIssue[] = [];
|
||||
const value = read(rootScope(json, issues));
|
||||
for (const issue of issues) {
|
||||
logger.warn(
|
||||
`Invalid setting ${trigger}.${issue.field}; using the default`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
validate(json) {
|
||||
const issues: RuleConfigIssue[] = [];
|
||||
read(rootScope(json, issues));
|
||||
return issues;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
PHENOLOGY_PHASES,
|
||||
type PhenologyPhaseKey,
|
||||
} from '../../season/season-types';
|
||||
import {
|
||||
bool,
|
||||
childScope,
|
||||
enumList,
|
||||
enumValue,
|
||||
has,
|
||||
isCleared,
|
||||
makeParser,
|
||||
monthDay,
|
||||
numberIn,
|
||||
report,
|
||||
type Scope,
|
||||
} from './parse-helpers';
|
||||
import type { PhenologyConfig } from './rule-config.types';
|
||||
|
||||
/** Upper limit of a growing-degree-day threshold; far above any phase. */
|
||||
const MAX_GTS_THRESHOLD = 5000;
|
||||
const MAX_GAP_SHARE = 0.5;
|
||||
|
||||
export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = {
|
||||
useGts: true,
|
||||
gtsThresholds: { SALWEIDE: 200 },
|
||||
calibrateFromReports: true,
|
||||
calibratePhases: ['KIRSCHE', 'APFEL'],
|
||||
calendarFallback: {
|
||||
SALWEIDE: '03-20',
|
||||
KIRSCHE: '04-15',
|
||||
APFEL: '04-28',
|
||||
HOLUNDER: '05-30',
|
||||
LINDE: '06-20',
|
||||
TRACHTENDE: '08-01',
|
||||
HERBST: '09-15',
|
||||
WINTER: '11-15',
|
||||
},
|
||||
swarmSeason: { from: 'KIRSCHE', until: 'LINDE' },
|
||||
maxGapShare: 0.1,
|
||||
};
|
||||
|
||||
function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] {
|
||||
// Without the group the defaults apply; with it, only the phases given
|
||||
// have a threshold - leaving one out removes it.
|
||||
if (!has(scope, 'gtsThresholds')) {
|
||||
return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds };
|
||||
}
|
||||
const thresholds = childScope(scope, 'gtsThresholds');
|
||||
const result: PhenologyConfig['gtsThresholds'] = {};
|
||||
for (const phase of PHENOLOGY_PHASES) {
|
||||
if (isCleared(thresholds, phase)) {
|
||||
continue;
|
||||
}
|
||||
const value = numberIn(
|
||||
thresholds,
|
||||
phase,
|
||||
Number.NaN,
|
||||
0,
|
||||
MAX_GTS_THRESHOLD,
|
||||
{ exclusiveMin: true },
|
||||
);
|
||||
if (!Number.isNaN(value)) {
|
||||
result[phase] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar dates of the phases must not run backwards in phase order.
|
||||
* One violation sends the whole map back to the defaults, so it stays
|
||||
* consistent; the issue names a key the caller actually sent.
|
||||
*/
|
||||
function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] {
|
||||
const calendar = childScope(scope, 'calendarFallback');
|
||||
const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
|
||||
const before = scope.issues.length;
|
||||
const result = { ...defaults };
|
||||
for (const phase of PHENOLOGY_PHASES) {
|
||||
result[phase] = monthDay(calendar, phase, defaults[phase]);
|
||||
}
|
||||
if (scope.issues.length > before) {
|
||||
return { ...defaults };
|
||||
}
|
||||
for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) {
|
||||
const previous = PHENOLOGY_PHASES[index - 1];
|
||||
const phase = PHENOLOGY_PHASES[index];
|
||||
if (result[phase] < result[previous]) {
|
||||
const culprit = has(calendar, phase) ? phase : previous;
|
||||
report(
|
||||
calendar,
|
||||
culprit,
|
||||
`Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`,
|
||||
);
|
||||
return { ...defaults };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] {
|
||||
const season = childScope(scope, 'swarmSeason');
|
||||
const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason;
|
||||
const from: PhenologyPhaseKey = enumValue(
|
||||
season,
|
||||
'from',
|
||||
defaults.from,
|
||||
PHENOLOGY_PHASES,
|
||||
);
|
||||
const until: PhenologyPhaseKey = enumValue(
|
||||
season,
|
||||
'until',
|
||||
defaults.until,
|
||||
PHENOLOGY_PHASES,
|
||||
);
|
||||
if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) {
|
||||
report(
|
||||
season,
|
||||
has(season, 'from') ? 'from' : 'until',
|
||||
'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.',
|
||||
);
|
||||
return { ...defaults };
|
||||
}
|
||||
return { from, until };
|
||||
}
|
||||
|
||||
export const phenologyParser = makeParser<PhenologyConfig>(
|
||||
'PHENOLOGY',
|
||||
DEFAULT_PHENOLOGY_CONFIG,
|
||||
(scope) => {
|
||||
const d = DEFAULT_PHENOLOGY_CONFIG;
|
||||
return {
|
||||
useGts: bool(scope, 'useGts', d.useGts),
|
||||
gtsThresholds: readThresholds(scope),
|
||||
calibrateFromReports: bool(
|
||||
scope,
|
||||
'calibrateFromReports',
|
||||
d.calibrateFromReports,
|
||||
),
|
||||
calibratePhases: enumList(
|
||||
scope,
|
||||
'calibratePhases',
|
||||
d.calibratePhases,
|
||||
PHENOLOGY_PHASES,
|
||||
),
|
||||
calendarFallback: readCalendar(scope),
|
||||
swarmSeason: readSwarmSeason(scope),
|
||||
maxGapShare: numberIn(
|
||||
scope,
|
||||
'maxGapShare',
|
||||
d.maxGapShare,
|
||||
0,
|
||||
MAX_GAP_SHARE,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||
import { assertTransactionClient, type DbClient } from '../../prisma/db-client';
|
||||
import { lockRowForUpdate } from '../../prisma/row-lock';
|
||||
import { DEFAULT_RULES } from './default-rules';
|
||||
import { ruleConfigOf } from './rule-config.registry';
|
||||
import type { RuleConfigMap } from './rule-config.types';
|
||||
|
||||
export interface ActiveRule<K extends AutomationTriggerType> {
|
||||
/** null while no row exists yet: the built-in setting applies. */
|
||||
id: string | null;
|
||||
active: boolean;
|
||||
config: RuleConfigMap[K];
|
||||
actionTemplate: string | null;
|
||||
}
|
||||
|
||||
interface RuleRow {
|
||||
id: string;
|
||||
active: boolean;
|
||||
config: Prisma.JsonValue;
|
||||
actionTemplate: string | null;
|
||||
}
|
||||
|
||||
const RULE_COLUMNS = {
|
||||
id: true,
|
||||
active: true,
|
||||
config: true,
|
||||
actionTemplate: true,
|
||||
} as const;
|
||||
|
||||
function toActiveRule<K extends AutomationTriggerType>(
|
||||
trigger: K,
|
||||
row: RuleRow | null,
|
||||
): ActiveRule<K> {
|
||||
if (row === null) {
|
||||
const defaults = DEFAULT_RULES[trigger];
|
||||
return {
|
||||
id: null,
|
||||
active: defaults.active ?? true,
|
||||
config: ruleConfigOf(trigger, defaults.config),
|
||||
actionTemplate: defaults.actionTemplate,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
active: row.active,
|
||||
config: ruleConfigOf(trigger, row.config),
|
||||
actionTemplate: row.actionTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule as it is in force: its row, or the built-in setting while there is
|
||||
* none. The config is parsed, so every key is valid.
|
||||
*/
|
||||
export async function readRule<K extends AutomationTriggerType>(
|
||||
db: DbClient,
|
||||
trigger: K,
|
||||
): Promise<ActiveRule<K>> {
|
||||
const row = await db.automationRule.findUnique({
|
||||
where: { triggerType: trigger },
|
||||
select: RULE_COLUMNS,
|
||||
});
|
||||
return toActiveRule(trigger, row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the rule row if it is missing (without touching an existing one),
|
||||
* locks it until the transaction ends and reads it. Serialises everything
|
||||
* that decides "is there already an open task/suggestion" under the rule.
|
||||
*/
|
||||
export async function lockRuleIn<K extends AutomationTriggerType>(
|
||||
tx: Prisma.TransactionClient,
|
||||
trigger: K,
|
||||
): Promise<ActiveRule<K> & { id: string }> {
|
||||
assertTransactionClient(tx, 'lockRuleIn');
|
||||
const defaults = DEFAULT_RULES[trigger];
|
||||
await tx.automationRule.createMany({
|
||||
data: [
|
||||
{
|
||||
triggerType: trigger,
|
||||
active: defaults.active ?? true,
|
||||
config: defaults.config as Prisma.InputJsonValue,
|
||||
actionTemplate: defaults.actionTemplate,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
const { id } = await tx.automationRule.findUniqueOrThrow({
|
||||
where: { triggerType: trigger },
|
||||
select: { id: true },
|
||||
});
|
||||
await lockRowForUpdate(tx, 'AutomationRule', id);
|
||||
// Read after the lock: a concurrent edit of the rule is visible now.
|
||||
const row = await tx.automationRule.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: RULE_COLUMNS,
|
||||
});
|
||||
return { ...toActiveRule(trigger, row), id };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AutomationTriggerType } from '../../../generated/prisma';
|
||||
import {
|
||||
chainParser,
|
||||
colonyEndedCleanupParser,
|
||||
DEFAULT_CHAIN_CONFIG,
|
||||
withdrawalEndedParser,
|
||||
} from './chain.config';
|
||||
import { inspectionIntervalParser } from './inspection-interval.config';
|
||||
import { phenologyParser } from './phenology.config';
|
||||
import { seasonAutopilotParser } from './season-autopilot.config';
|
||||
import { numberFormatParser, simpleParser } from './simple.config';
|
||||
import { splitScheduleParser } from './split-schedule.config';
|
||||
import { varroaThresholdParser } from './varroa-threshold.config';
|
||||
import { waterContentParser } from './water-content.config';
|
||||
import type {
|
||||
RuleConfigIssue,
|
||||
RuleConfigMap,
|
||||
RuleConfigParser,
|
||||
} from './rule-config.types';
|
||||
|
||||
/** The number-format rules keep the defaults they always had. */
|
||||
const DEFAULT_BATCH_FORMAT = 'WH-{YYYY}-{SEQ:4}';
|
||||
|
||||
export const RULE_CONFIG_PARSERS: {
|
||||
[K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]>;
|
||||
} = {
|
||||
VARROA_THRESHOLD: varroaThresholdParser,
|
||||
INSPECTION_INTERVAL: inspectionIntervalParser,
|
||||
QUEEN_CELLS_SWARM_CHECK: chainParser(
|
||||
'QUEEN_CELLS_SWARM_CHECK',
|
||||
DEFAULT_CHAIN_CONFIG,
|
||||
),
|
||||
QUEEN_ACCEPTANCE_CHECK: chainParser(
|
||||
'QUEEN_ACCEPTANCE_CHECK',
|
||||
DEFAULT_CHAIN_CONFIG,
|
||||
),
|
||||
SPLIT_SCHEDULE: splitScheduleParser,
|
||||
COLONY_ENDED_CLEANUP: colonyEndedCleanupParser,
|
||||
WITHDRAWAL_ENDED: withdrawalEndedParser,
|
||||
HONEY_WATER_CONTENT: waterContentParser,
|
||||
SEASON_AUTOPILOT: seasonAutopilotParser,
|
||||
PHENOLOGY: phenologyParser,
|
||||
HONEY_BATCH_NUMBER_FORMAT: numberFormatParser(
|
||||
'HONEY_BATCH_NUMBER_FORMAT',
|
||||
DEFAULT_BATCH_FORMAT,
|
||||
),
|
||||
BOTTLING_LOT_NUMBER_FORMAT: numberFormatParser(
|
||||
'BOTTLING_LOT_NUMBER_FORMAT',
|
||||
DEFAULT_BATCH_FORMAT,
|
||||
),
|
||||
// {ART} (WA/PR) gives wax and propolis a counter each.
|
||||
RAW_MATERIAL_LOT_NUMBER_FORMAT: numberFormatParser(
|
||||
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
|
||||
'{ART}-{YYYY}-{SEQ:4}',
|
||||
),
|
||||
PRODUCTION_BATCH_NUMBER_FORMAT: numberFormatParser(
|
||||
'PRODUCTION_BATCH_NUMBER_FORMAT',
|
||||
'HS-{YYYY}-{SEQ:4}',
|
||||
),
|
||||
QUEENLESS_NO_EGGS: simpleParser('QUEENLESS_NO_EGGS', { notify: true }),
|
||||
FEED_STATUS_CRITICAL: simpleParser('FEED_STATUS_CRITICAL', {
|
||||
notify: false,
|
||||
}),
|
||||
SWARM_MOOD_HIGH: simpleParser('SWARM_MOOD_HIGH', { notify: false }),
|
||||
COLONY_HEALTH_CRITICAL: simpleParser('COLONY_HEALTH_CRITICAL', {
|
||||
notify: true,
|
||||
}),
|
||||
STOCK_ITEM_LOW: simpleParser('STOCK_ITEM_LOW', { notify: false }),
|
||||
RAW_MATERIAL_LOW: simpleParser('RAW_MATERIAL_LOW', {
|
||||
notify: false,
|
||||
withMinGrams: true,
|
||||
}),
|
||||
TREATMENT_FOLLOW_UP: simpleParser('TREATMENT_FOLLOW_UP', { notify: false }),
|
||||
FEEDING_FOLLOW_UP: simpleParser('FEEDING_FOLLOW_UP', { notify: false }),
|
||||
};
|
||||
|
||||
/** The effective setting of a rule: valid keys as stored, the rest default. */
|
||||
export function ruleConfigOf<K extends AutomationTriggerType>(
|
||||
trigger: K,
|
||||
json: unknown,
|
||||
): RuleConfigMap[K] {
|
||||
return RULE_CONFIG_PARSERS[trigger].parse(json);
|
||||
}
|
||||
|
||||
/** The invalid known keys of a config a client wants to save. */
|
||||
export function validateRuleConfig(
|
||||
trigger: AutomationTriggerType,
|
||||
json: unknown,
|
||||
): RuleConfigIssue[] {
|
||||
return RULE_CONFIG_PARSERS[trigger].validate(json);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AutomationTriggerType } from '../../../generated/prisma';
|
||||
import { DEFAULT_RULES } from './default-rules';
|
||||
import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index';
|
||||
|
||||
const ALL_TRIGGERS = Object.values(AutomationTriggerType);
|
||||
|
||||
function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] {
|
||||
return validateRuleConfig(trigger, config).map((issue) => issue.field);
|
||||
}
|
||||
|
||||
describe('rule config', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('every trigger has defaults and a parser', () => {
|
||||
for (const t of ALL_TRIGGERS) {
|
||||
expect(DEFAULT_RULES[t]).toBeDefined();
|
||||
expect(RULE_CONFIG_PARSERS[t]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(ALL_TRIGGERS)(
|
||||
'the default config of %s parses to itself and validates without issues',
|
||||
(trigger) => {
|
||||
const { config } = DEFAULT_RULES[trigger];
|
||||
expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config);
|
||||
expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config);
|
||||
expect(validateRuleConfig(trigger, config)).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_TRIGGERS)(
|
||||
'%s falls back to its defaults for null, arrays and scalars',
|
||||
(trigger) => {
|
||||
const { config } = DEFAULT_RULES[trigger];
|
||||
for (const junk of [null, undefined, [], 'x', 5]) {
|
||||
expect(ruleConfigOf(trigger, junk)).toEqual(config);
|
||||
expect(validateRuleConfig(trigger, junk)).toEqual([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
|
||||
expect(
|
||||
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall,
|
||||
).toEqual({
|
||||
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||
yellowFrom: 5,
|
||||
redAbove: 7,
|
||||
winterRedAbove: 0.5,
|
||||
}));
|
||||
|
||||
it('lets the band format win over the legacy key', () =>
|
||||
expect(
|
||||
ruleConfigOf('VARROA_THRESHOLD', {
|
||||
thresholdMitesPerDay: 7,
|
||||
naturalFall: { redAbove: 9 },
|
||||
}).naturalFall.redAbove,
|
||||
).toBe(9));
|
||||
|
||||
it('ignores an invalid legacy threshold', () =>
|
||||
expect(
|
||||
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall
|
||||
.redAbove,
|
||||
).toBe(10));
|
||||
|
||||
it('reports an invalid legacy threshold with field and message', () => {
|
||||
const issues = validateRuleConfig('VARROA_THRESHOLD', {
|
||||
thresholdMitesPerDay: 0,
|
||||
});
|
||||
expect(issues).toEqual([
|
||||
{
|
||||
field: 'thresholdMitesPerDay',
|
||||
message: expect.any(String) as string,
|
||||
},
|
||||
]);
|
||||
expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => {
|
||||
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
|
||||
expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([
|
||||
'naturalFall.redAbove',
|
||||
]);
|
||||
expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([
|
||||
'naturalFall.redAbove',
|
||||
]);
|
||||
expect(
|
||||
fields({ infestationPercent: { summer: { redAbove: 101 } } }),
|
||||
).toEqual(['infestationPercent.summer.redAbove']);
|
||||
expect(
|
||||
fields({ infestationPercent: { autumn: { redAbove: 100 } } }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports an explicit null on a known key', () => {
|
||||
expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([
|
||||
'queenMaxAgeYears',
|
||||
]);
|
||||
expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([
|
||||
'swarmSeason',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts null on optional keys as clearing them', () => {
|
||||
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]);
|
||||
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({
|
||||
notify: false,
|
||||
});
|
||||
expect(
|
||||
ruleConfigOf('RAW_MATERIAL_LOW', {
|
||||
minGrams: { BEESWAX: null, PROPOLIS: 50 },
|
||||
}),
|
||||
).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } });
|
||||
expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({
|
||||
notify: false,
|
||||
});
|
||||
expect(
|
||||
ruleConfigOf('SPLIT_SCHEDULE', {
|
||||
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }],
|
||||
}).QUEEN,
|
||||
).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
|
||||
expect(
|
||||
fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } })
|
||||
.gtsThresholds,
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('lets a client remove a gts threshold by leaving it out', () => {
|
||||
expect(
|
||||
ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } })
|
||||
.gtsThresholds,
|
||||
).toEqual({ KIRSCHE: 400 });
|
||||
expect(
|
||||
ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds,
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps yellowFrom at or below a legacy threshold under five', () => {
|
||||
const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', {
|
||||
thresholdMitesPerDay: 3,
|
||||
});
|
||||
expect(naturalFall.redAbove).toBe(3);
|
||||
expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('falls back per invalid key and keeps the valid ones', () =>
|
||||
expect(
|
||||
ruleConfigOf('SEASON_AUTOPILOT', {
|
||||
queenMaxAgeYears: 'x',
|
||||
honeySuperMinFrames: 10,
|
||||
}),
|
||||
).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
|
||||
|
||||
it('logs a warning with trigger and field but without the value', () => {
|
||||
const warn = jest.spyOn(Logger.prototype, 'warn');
|
||||
ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' });
|
||||
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
expect(logged).toContain('SEASON_AUTOPILOT');
|
||||
expect(logged).toContain('queenMaxAgeYears');
|
||||
expect(logged).not.toContain('secret-value');
|
||||
});
|
||||
|
||||
it('rejects SCHWARMZEIT 10 days with the field name', () =>
|
||||
expect(
|
||||
validateRuleConfig('INSPECTION_INTERVAL', {
|
||||
intervalDays: { SCHWARMZEIT: 10 },
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
field: 'intervalDays.SCHWARMZEIT',
|
||||
message: expect.stringContaining('7') as string,
|
||||
},
|
||||
]));
|
||||
|
||||
it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => {
|
||||
expect(
|
||||
validateRuleConfig('INSPECTION_INTERVAL', {
|
||||
intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 },
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }),
|
||||
).toEqual(['intervalDays.SOMMER']);
|
||||
expect(
|
||||
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }),
|
||||
).toEqual(['intervalDays.HERBST']);
|
||||
});
|
||||
|
||||
it('merges a partial interval config over the defaults', () =>
|
||||
expect(
|
||||
ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }),
|
||||
).toEqual({
|
||||
intervalDays: {
|
||||
FRUEHJAHR: 14,
|
||||
SCHWARMZEIT: 7,
|
||||
SOMMER: 10,
|
||||
SPAETSOMMER: 21,
|
||||
HERBST: 21,
|
||||
},
|
||||
}));
|
||||
|
||||
it('rejects yellowFrom above redAbove', () =>
|
||||
expect(
|
||||
fieldsOf('VARROA_THRESHOLD', {
|
||||
naturalFall: { yellowFrom: 12, redAbove: 8 },
|
||||
}),
|
||||
).toEqual(['naturalFall.yellowFrom']));
|
||||
|
||||
it('rejects percent bands where yellowFrom is above redAbove', () =>
|
||||
expect(
|
||||
fieldsOf('VARROA_THRESHOLD', {
|
||||
infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } },
|
||||
}),
|
||||
).toEqual(['infestationPercent.summer.yellowFrom']));
|
||||
|
||||
it('rejects duplicate or out-of-range months', () => {
|
||||
expect(
|
||||
fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }),
|
||||
).toEqual(['naturalFall.seasonMonths']);
|
||||
expect(
|
||||
fieldsOf('VARROA_THRESHOLD', {
|
||||
infestationPercent: { summerMonths: [0, 13] },
|
||||
}),
|
||||
).toEqual(['infestationPercent.summerMonths']);
|
||||
});
|
||||
|
||||
it('checks the varroa limits', () => {
|
||||
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
|
||||
expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']);
|
||||
expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']);
|
||||
expect(fields({ youngColonyFactor: 1 })).toEqual([]);
|
||||
expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']);
|
||||
expect(fields({ recheckAfterDays: 0 })).toEqual([]);
|
||||
expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([
|
||||
'youngColonyTypes',
|
||||
]);
|
||||
expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([
|
||||
'naturalFall.redAbove',
|
||||
]);
|
||||
expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([
|
||||
'naturalFall.redAbove',
|
||||
]);
|
||||
expect(fields({ notify: 'yes' })).toEqual(['notify']);
|
||||
});
|
||||
|
||||
it('reports no issue for keys it does not know', () =>
|
||||
expect(
|
||||
validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }),
|
||||
).toEqual([]));
|
||||
|
||||
it('rejects a calendar fallback that runs backwards', () => {
|
||||
expect(
|
||||
fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }),
|
||||
).toEqual(['calendarFallback.KIRSCHE']);
|
||||
// The whole map falls back so the phases stay in order.
|
||||
expect(
|
||||
ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } })
|
||||
.calendarFallback,
|
||||
).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback);
|
||||
});
|
||||
|
||||
it('rejects an invalid month-day', () => {
|
||||
expect(
|
||||
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }),
|
||||
).toEqual(['calendarFallback.SALWEIDE']);
|
||||
expect(
|
||||
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }),
|
||||
).toEqual(['calendarFallback.SALWEIDE']);
|
||||
expect(
|
||||
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('checks the phenology limits', () => {
|
||||
const fields = (config: unknown) => fieldsOf('PHENOLOGY', config);
|
||||
expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']);
|
||||
expect(fields({ maxGapShare: 0.5 })).toEqual([]);
|
||||
expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([
|
||||
'gtsThresholds.SALWEIDE',
|
||||
]);
|
||||
expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]);
|
||||
expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([
|
||||
'calibratePhases',
|
||||
]);
|
||||
expect(
|
||||
fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }),
|
||||
).toEqual(['swarmSeason.from']);
|
||||
});
|
||||
|
||||
it('defaults gtsThresholds to SALWEIDE 200 only', () =>
|
||||
expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({
|
||||
SALWEIDE: 200,
|
||||
}));
|
||||
|
||||
it('checks the season autopilot limits', () => {
|
||||
const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config);
|
||||
expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']);
|
||||
expect(fields({ honeySuperMinFrames: 41 })).toEqual([
|
||||
'honeySuperMinFrames',
|
||||
]);
|
||||
expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([
|
||||
'varroaMeasureIntervalDays',
|
||||
]);
|
||||
expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']);
|
||||
expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']);
|
||||
expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']);
|
||||
expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']);
|
||||
expect(
|
||||
fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }),
|
||||
).toEqual(['winterVarroaWindow.from']);
|
||||
expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']);
|
||||
expect(fields({ rules: { A3: false } })).toEqual([]);
|
||||
expect(
|
||||
ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules,
|
||||
).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true }));
|
||||
});
|
||||
|
||||
it('checks the water content limits', () => {
|
||||
const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config);
|
||||
expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']);
|
||||
expect(fields({ confirmAbovePercent: 31 })).toEqual([
|
||||
'confirmAbovePercent',
|
||||
]);
|
||||
expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']);
|
||||
expect(
|
||||
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }),
|
||||
).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']);
|
||||
});
|
||||
|
||||
it('checks the split schedule steps', () => {
|
||||
const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config);
|
||||
expect(
|
||||
fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }),
|
||||
).toEqual(['QUEEN[0].offsetDays']);
|
||||
expect(
|
||||
fields({
|
||||
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }],
|
||||
}),
|
||||
).toEqual(['QUEEN_CELL[0].windowDays']);
|
||||
expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([
|
||||
'RAISE_OWN[0].step',
|
||||
]);
|
||||
expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']);
|
||||
expect(
|
||||
fields({
|
||||
RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('checks dueInDays of chains and simple rules', () => {
|
||||
expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([
|
||||
'dueInDays',
|
||||
]);
|
||||
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([
|
||||
'dueInDays',
|
||||
]);
|
||||
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({
|
||||
notify: false,
|
||||
dueInDays: 14,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads minGrams per raw material kind', () => {
|
||||
expect(
|
||||
ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }),
|
||||
).toEqual({ notify: false, minGrams: { BEESWAX: 500 } });
|
||||
expect(
|
||||
fieldsOf('RAW_MATERIAL_LOW', {
|
||||
minGrams: { BEESWAX: -5, PROPOLIS: 'x' },
|
||||
}),
|
||||
).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']);
|
||||
});
|
||||
|
||||
it('keeps a number format and rejects an empty or oversized one', () => {
|
||||
expect(
|
||||
ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }),
|
||||
).toEqual({ format: 'HB-{SEQ:3}' });
|
||||
expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([
|
||||
'format',
|
||||
]);
|
||||
expect(
|
||||
fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }),
|
||||
).toEqual(['format']);
|
||||
});
|
||||
|
||||
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => {
|
||||
const withNotify = ALL_TRIGGERS.filter(
|
||||
(trigger) =>
|
||||
(ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true,
|
||||
);
|
||||
expect([...withNotify].sort()).toEqual(
|
||||
[
|
||||
'COLONY_HEALTH_CRITICAL',
|
||||
'QUEENLESS_NO_EGGS',
|
||||
'VARROA_THRESHOLD',
|
||||
'WITHDRAWAL_ENDED',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('starts the optional rules switched off', () => {
|
||||
const off = ALL_TRIGGERS.filter(
|
||||
(trigger) => DEFAULT_RULES[trigger].active === false,
|
||||
).sort();
|
||||
expect(off).toEqual([
|
||||
'FEEDING_FOLLOW_UP',
|
||||
'RAW_MATERIAL_LOW',
|
||||
'TREATMENT_FOLLOW_UP',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives every new rule an empty action template', () => {
|
||||
for (const trigger of [
|
||||
'INSPECTION_INTERVAL',
|
||||
'QUEEN_CELLS_SWARM_CHECK',
|
||||
'QUEEN_ACCEPTANCE_CHECK',
|
||||
'SPLIT_SCHEDULE',
|
||||
'COLONY_ENDED_CLEANUP',
|
||||
'WITHDRAWAL_ENDED',
|
||||
'HONEY_WATER_CONTENT',
|
||||
'SEASON_AUTOPILOT',
|
||||
'PHENOLOGY',
|
||||
] as const) {
|
||||
expect(DEFAULT_RULES[trigger].actionTemplate).toBe('');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ColonyType, RawMaterialKind } from '../../../generated/prisma';
|
||||
import type {
|
||||
PhenologyPhaseKey,
|
||||
SeasonSegmentKey,
|
||||
} from '../../season/season-types';
|
||||
|
||||
/** One invalid setting of a rule: the field path and a German message. */
|
||||
export interface RuleConfigIssue {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RuleConfigParser<T> {
|
||||
/** The built-in setting; also what an empty or unusable config reads as. */
|
||||
readonly defaults: T;
|
||||
/** Never throws: each invalid key falls back to its default (and is logged). */
|
||||
parse(json: unknown): T;
|
||||
/** Reports the known keys that are present and invalid; nothing else. */
|
||||
validate(json: unknown): RuleConfigIssue[];
|
||||
}
|
||||
|
||||
export interface SimpleRuleConfig {
|
||||
notify: boolean;
|
||||
dueInDays?: number;
|
||||
minGrams?: Partial<Record<RawMaterialKind, number>>;
|
||||
}
|
||||
|
||||
export interface NumberFormatConfig {
|
||||
format: string;
|
||||
}
|
||||
|
||||
export interface VarroaThresholdConfig {
|
||||
naturalFall: {
|
||||
seasonMonths: number[];
|
||||
yellowFrom: number;
|
||||
redAbove: number;
|
||||
winterRedAbove: number;
|
||||
};
|
||||
infestationPercent: {
|
||||
summerMonths: number[];
|
||||
summer: { yellowFrom: number; redAbove: number };
|
||||
autumnMonths: number[];
|
||||
autumn: { yellowFrom: number; redAbove: number };
|
||||
};
|
||||
youngColonyFactor: number;
|
||||
youngColonyTypes: ColonyType[];
|
||||
recheckAfterDays: number;
|
||||
notify: boolean;
|
||||
}
|
||||
|
||||
export interface InspectionIntervalConfig {
|
||||
intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>;
|
||||
}
|
||||
|
||||
export interface ChainConfig {
|
||||
dueInDays: number;
|
||||
notify: boolean;
|
||||
}
|
||||
|
||||
export interface PlanStepConfig {
|
||||
step: string;
|
||||
offsetDays: number;
|
||||
windowDays?: number;
|
||||
}
|
||||
|
||||
export interface SplitScheduleConfig {
|
||||
RAISE_OWN: PlanStepConfig[];
|
||||
QUEEN_CELL: PlanStepConfig[];
|
||||
QUEEN: PlanStepConfig[];
|
||||
notify: boolean;
|
||||
}
|
||||
|
||||
export interface WaterContentConfig {
|
||||
warnAbovePercent: number;
|
||||
confirmAbovePercent: number;
|
||||
byHoneyType: Record<
|
||||
string,
|
||||
{ warnAbovePercent?: number; confirmAbovePercent?: number }
|
||||
>;
|
||||
}
|
||||
|
||||
export const AUTOPILOT_RULE_IDS = [
|
||||
'A1',
|
||||
'A2',
|
||||
'A3',
|
||||
'A4',
|
||||
'A5',
|
||||
'A6',
|
||||
'A7',
|
||||
'A8',
|
||||
'A9',
|
||||
'A10',
|
||||
] as const;
|
||||
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
|
||||
|
||||
export interface SeasonAutopilotConfig {
|
||||
honeySuperMinFrames: number;
|
||||
varroaMeasureIntervalDays: number;
|
||||
winterVarroaWindow: { from: string; to: string };
|
||||
queenMaxAgeYears: number;
|
||||
suggestionTtlDays: number;
|
||||
maxStepsPerColony: number;
|
||||
rules: Record<AutopilotRuleId, boolean>;
|
||||
}
|
||||
|
||||
export interface PhenologyConfig {
|
||||
useGts: boolean;
|
||||
gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>;
|
||||
calibrateFromReports: boolean;
|
||||
calibratePhases: PhenologyPhaseKey[];
|
||||
calendarFallback: Record<PhenologyPhaseKey, string>;
|
||||
swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey };
|
||||
maxGapShare: number;
|
||||
}
|
||||
|
||||
/** One entry per AutomationTriggerType. */
|
||||
export interface RuleConfigMap {
|
||||
VARROA_THRESHOLD: VarroaThresholdConfig;
|
||||
INSPECTION_INTERVAL: InspectionIntervalConfig;
|
||||
QUEEN_CELLS_SWARM_CHECK: ChainConfig;
|
||||
QUEEN_ACCEPTANCE_CHECK: ChainConfig;
|
||||
SPLIT_SCHEDULE: SplitScheduleConfig;
|
||||
COLONY_ENDED_CLEANUP: Record<string, never>;
|
||||
WITHDRAWAL_ENDED: { notify: boolean };
|
||||
HONEY_WATER_CONTENT: WaterContentConfig;
|
||||
SEASON_AUTOPILOT: SeasonAutopilotConfig;
|
||||
PHENOLOGY: PhenologyConfig;
|
||||
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||||
BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig;
|
||||
RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig;
|
||||
PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||||
QUEENLESS_NO_EGGS: SimpleRuleConfig;
|
||||
FEED_STATUS_CRITICAL: SimpleRuleConfig;
|
||||
SWARM_MOOD_HIGH: SimpleRuleConfig;
|
||||
COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
|
||||
STOCK_ITEM_LOW: SimpleRuleConfig;
|
||||
RAW_MATERIAL_LOW: SimpleRuleConfig;
|
||||
TREATMENT_FOLLOW_UP: SimpleRuleConfig;
|
||||
FEEDING_FOLLOW_UP: SimpleRuleConfig;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
bool,
|
||||
childScope,
|
||||
has,
|
||||
intIn,
|
||||
makeParser,
|
||||
monthDay,
|
||||
report,
|
||||
} from './parse-helpers';
|
||||
import {
|
||||
AUTOPILOT_RULE_IDS,
|
||||
type SeasonAutopilotConfig,
|
||||
} from './rule-config.types';
|
||||
|
||||
export const DEFAULT_SEASON_AUTOPILOT_CONFIG: SeasonAutopilotConfig = {
|
||||
honeySuperMinFrames: 8,
|
||||
varroaMeasureIntervalDays: 28,
|
||||
winterVarroaWindow: { from: '11-15', to: '12-20' },
|
||||
queenMaxAgeYears: 2,
|
||||
suggestionTtlDays: 30,
|
||||
maxStepsPerColony: 3,
|
||||
rules: {
|
||||
A1: true,
|
||||
A2: true,
|
||||
A3: true,
|
||||
A4: true,
|
||||
A5: true,
|
||||
A6: true,
|
||||
A7: true,
|
||||
A8: true,
|
||||
A9: true,
|
||||
A10: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const seasonAutopilotParser = makeParser<SeasonAutopilotConfig>(
|
||||
'SEASON_AUTOPILOT',
|
||||
DEFAULT_SEASON_AUTOPILOT_CONFIG,
|
||||
(scope) => {
|
||||
const d = DEFAULT_SEASON_AUTOPILOT_CONFIG;
|
||||
|
||||
const windowScope = childScope(scope, 'winterVarroaWindow');
|
||||
let winterVarroaWindow = {
|
||||
from: monthDay(windowScope, 'from', d.winterVarroaWindow.from),
|
||||
to: monthDay(windowScope, 'to', d.winterVarroaWindow.to),
|
||||
};
|
||||
if (winterVarroaWindow.from > winterVarroaWindow.to) {
|
||||
report(
|
||||
windowScope,
|
||||
has(windowScope, 'from') ? 'from' : 'to',
|
||||
'Das Fenster muss innerhalb eines Jahres liegen: Beginn vor Ende.',
|
||||
);
|
||||
winterVarroaWindow = { ...d.winterVarroaWindow };
|
||||
}
|
||||
|
||||
const ruleScope = childScope(scope, 'rules');
|
||||
const rules = { ...d.rules };
|
||||
for (const id of AUTOPILOT_RULE_IDS) {
|
||||
rules[id] = bool(ruleScope, id, d.rules[id]);
|
||||
}
|
||||
|
||||
return {
|
||||
honeySuperMinFrames: intIn(
|
||||
scope,
|
||||
'honeySuperMinFrames',
|
||||
d.honeySuperMinFrames,
|
||||
1,
|
||||
40,
|
||||
),
|
||||
varroaMeasureIntervalDays: intIn(
|
||||
scope,
|
||||
'varroaMeasureIntervalDays',
|
||||
d.varroaMeasureIntervalDays,
|
||||
7,
|
||||
90,
|
||||
),
|
||||
winterVarroaWindow,
|
||||
queenMaxAgeYears: intIn(
|
||||
scope,
|
||||
'queenMaxAgeYears',
|
||||
d.queenMaxAgeYears,
|
||||
1,
|
||||
10,
|
||||
),
|
||||
suggestionTtlDays: intIn(
|
||||
scope,
|
||||
'suggestionTtlDays',
|
||||
d.suggestionTtlDays,
|
||||
1,
|
||||
365,
|
||||
),
|
||||
maxStepsPerColony: intIn(
|
||||
scope,
|
||||
'maxStepsPerColony',
|
||||
d.maxStepsPerColony,
|
||||
1,
|
||||
3,
|
||||
),
|
||||
rules,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { RawMaterialKind } from '../../../generated/prisma';
|
||||
import {
|
||||
bool,
|
||||
childScope,
|
||||
has,
|
||||
isCleared,
|
||||
makeParser,
|
||||
numberIn,
|
||||
optionalIntIn,
|
||||
stringIn,
|
||||
} from './parse-helpers';
|
||||
import type {
|
||||
NumberFormatConfig,
|
||||
RuleConfigParser,
|
||||
SimpleRuleConfig,
|
||||
} from './rule-config.types';
|
||||
|
||||
const MAX_DUE_IN_DAYS = 365;
|
||||
/** Far above any minimum stock of wax or propolis. */
|
||||
const MAX_MIN_GRAMS = 1_000_000_000;
|
||||
const MAX_NUMBER_FORMAT_LENGTH = 100;
|
||||
const RAW_MATERIAL_KINDS = Object.values(RawMaterialKind);
|
||||
|
||||
export interface SimpleRuleOptions {
|
||||
/** Whether the rule notifies by default. */
|
||||
notify: boolean;
|
||||
/** RAW_MATERIAL_LOW reads a minimum per kind. */
|
||||
withMinGrams?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules that create one task: notify, an optional follow-up interval
|
||||
* (dueInDays) and, for the raw material rule, minGrams per kind.
|
||||
*/
|
||||
export function simpleParser(
|
||||
trigger: string,
|
||||
options: SimpleRuleOptions,
|
||||
): RuleConfigParser<SimpleRuleConfig> {
|
||||
return makeParser<SimpleRuleConfig>(
|
||||
trigger,
|
||||
{ notify: options.notify },
|
||||
(scope) => {
|
||||
const config: SimpleRuleConfig = {
|
||||
notify: bool(scope, 'notify', options.notify),
|
||||
};
|
||||
const dueInDays = optionalIntIn(scope, 'dueInDays', 0, MAX_DUE_IN_DAYS);
|
||||
if (dueInDays !== undefined) {
|
||||
config.dueInDays = dueInDays;
|
||||
}
|
||||
if (
|
||||
options.withMinGrams &&
|
||||
has(scope, 'minGrams') &&
|
||||
!isCleared(scope, 'minGrams')
|
||||
) {
|
||||
const minScope = childScope(scope, 'minGrams');
|
||||
const minGrams: Partial<Record<RawMaterialKind, number>> = {};
|
||||
for (const kind of RAW_MATERIAL_KINDS) {
|
||||
if (isCleared(minScope, kind)) {
|
||||
continue;
|
||||
}
|
||||
const grams = numberIn(minScope, kind, Number.NaN, 0, MAX_MIN_GRAMS);
|
||||
if (!Number.isNaN(grams)) {
|
||||
minGrams[kind] = grams;
|
||||
}
|
||||
}
|
||||
if (Object.keys(minGrams).length > 0) {
|
||||
config.minGrams = minGrams;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** The batch/lot number rules: one format string with placeholders. */
|
||||
export function numberFormatParser(
|
||||
trigger: string,
|
||||
defaultFormat: string,
|
||||
): RuleConfigParser<NumberFormatConfig> {
|
||||
return makeParser<NumberFormatConfig>(
|
||||
trigger,
|
||||
{ format: defaultFormat },
|
||||
(scope) => ({
|
||||
format: stringIn(
|
||||
scope,
|
||||
'format',
|
||||
defaultFormat,
|
||||
1,
|
||||
MAX_NUMBER_FORMAT_LENGTH,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
asRecord,
|
||||
bool,
|
||||
fieldPath,
|
||||
has,
|
||||
intIn,
|
||||
makeParser,
|
||||
optionalIntIn,
|
||||
report,
|
||||
stringIn,
|
||||
type Scope,
|
||||
} from './parse-helpers';
|
||||
import type { PlanStepConfig, SplitScheduleConfig } from './rule-config.types';
|
||||
|
||||
type SplitVariant = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN';
|
||||
|
||||
const MAX_STEPS_PER_VARIANT = 10;
|
||||
const MAX_OFFSET_DAYS = 120;
|
||||
const MAX_WINDOW_DAYS = 30;
|
||||
const STEP_NAME = /^[A-Z][A-Z_]{1,39}$/;
|
||||
|
||||
export const DEFAULT_SPLIT_SCHEDULE_CONFIG: SplitScheduleConfig = {
|
||||
RAISE_OWN: [
|
||||
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
|
||||
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
|
||||
{ step: 'EGGS_CHECK', offsetDays: 28 },
|
||||
],
|
||||
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21 }],
|
||||
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }],
|
||||
notify: false,
|
||||
};
|
||||
|
||||
function clonedSteps(variant: SplitVariant): PlanStepConfig[] {
|
||||
return DEFAULT_SPLIT_SCHEDULE_CONFIG[variant].map((step) => ({ ...step }));
|
||||
}
|
||||
|
||||
/** Scope of one list entry: its path is `VARIANT[index]`. */
|
||||
function stepScope(
|
||||
scope: Scope,
|
||||
variant: SplitVariant,
|
||||
index: number,
|
||||
raw: unknown,
|
||||
): Scope | null {
|
||||
const record = asRecord(raw);
|
||||
const path = `${fieldPath(scope, variant)}[${index}]`;
|
||||
if (record === null) {
|
||||
scope.issues.push({
|
||||
field: path,
|
||||
message: 'Bitte jeden Schritt als Gruppe senden.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { raw: record, path, issues: scope.issues };
|
||||
}
|
||||
|
||||
function readSteps(scope: Scope, variant: SplitVariant): PlanStepConfig[] {
|
||||
if (!has(scope, variant)) {
|
||||
return clonedSteps(variant);
|
||||
}
|
||||
const list = scope.raw[variant];
|
||||
if (!Array.isArray(list) || list.length > MAX_STEPS_PER_VARIANT) {
|
||||
report(
|
||||
scope,
|
||||
variant,
|
||||
`Bitte eine Liste mit höchstens ${MAX_STEPS_PER_VARIANT} Schritten senden.`,
|
||||
);
|
||||
return clonedSteps(variant);
|
||||
}
|
||||
const before = scope.issues.length;
|
||||
const steps: PlanStepConfig[] = [];
|
||||
list.forEach((entry: unknown, index) => {
|
||||
const item = stepScope(scope, variant, index, entry);
|
||||
if (item === null) {
|
||||
return;
|
||||
}
|
||||
if (!has(item, 'step')) {
|
||||
scope.issues.push({
|
||||
field: `${item.path}.step`,
|
||||
message: 'Bitte den Schritt angeben.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const step: PlanStepConfig = {
|
||||
step: stringIn(item, 'step', '', 2, 40, STEP_NAME),
|
||||
offsetDays: intIn(item, 'offsetDays', 0, 0, MAX_OFFSET_DAYS),
|
||||
};
|
||||
const windowDays = optionalIntIn(item, 'windowDays', 0, MAX_WINDOW_DAYS);
|
||||
if (windowDays !== undefined) {
|
||||
step.windowDays = windowDays;
|
||||
}
|
||||
steps.push(step);
|
||||
});
|
||||
// One bad entry makes the whole list unreliable: back to the default plan.
|
||||
return scope.issues.length > before ? clonedSteps(variant) : steps;
|
||||
}
|
||||
|
||||
export const splitScheduleParser = makeParser<SplitScheduleConfig>(
|
||||
'SPLIT_SCHEDULE',
|
||||
DEFAULT_SPLIT_SCHEDULE_CONFIG,
|
||||
(scope) => ({
|
||||
RAISE_OWN: readSteps(scope, 'RAISE_OWN'),
|
||||
QUEEN_CELL: readSteps(scope, 'QUEEN_CELL'),
|
||||
QUEEN: readSteps(scope, 'QUEEN'),
|
||||
notify: bool(scope, 'notify', DEFAULT_SPLIT_SCHEDULE_CONFIG.notify),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,188 @@
|
||||
import { ColonyType } from '../../../generated/prisma';
|
||||
import {
|
||||
bool,
|
||||
childScope,
|
||||
enumList,
|
||||
has,
|
||||
intIn,
|
||||
makeParser,
|
||||
months,
|
||||
numberIn,
|
||||
report,
|
||||
type Scope,
|
||||
} from './parse-helpers';
|
||||
import type { VarroaThresholdConfig } from './rule-config.types';
|
||||
|
||||
const MAX_RECHECK_DAYS = 60;
|
||||
|
||||
export const DEFAULT_VARROA_THRESHOLD_CONFIG: VarroaThresholdConfig = {
|
||||
naturalFall: {
|
||||
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
|
||||
yellowFrom: 5,
|
||||
redAbove: 10,
|
||||
winterRedAbove: 0.5,
|
||||
},
|
||||
infestationPercent: {
|
||||
summerMonths: [5, 6, 7],
|
||||
summer: { yellowFrom: 1, redAbove: 5 },
|
||||
autumnMonths: [8, 9, 10],
|
||||
autumn: { yellowFrom: 2, redAbove: 5 },
|
||||
},
|
||||
youngColonyFactor: 0.5,
|
||||
youngColonyTypes: ['YOUNG_COLONY', 'NUCLEUS_COLONY', 'MATING_NUCLEUS'],
|
||||
recheckAfterDays: 14,
|
||||
notify: true,
|
||||
};
|
||||
|
||||
/** Upper limits: mites per day cannot get near 1000; a percentage stops at 100. */
|
||||
const MAX_MITES_PER_DAY = 1000;
|
||||
const MAX_PERCENT = 100;
|
||||
|
||||
/**
|
||||
* The key before the bands existed: natural mite fall that counts as red.
|
||||
* A present but unusable value is reported.
|
||||
*/
|
||||
function legacyThreshold(scope: Scope): number | undefined {
|
||||
if (!has(scope, 'thresholdMitesPerDay')) {
|
||||
return undefined;
|
||||
}
|
||||
const value = scope.raw.thresholdMitesPerDay;
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
report(
|
||||
scope,
|
||||
'thresholdMitesPerDay',
|
||||
'Bitte eine Zahl größer als 0 angeben.',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface Band {
|
||||
yellowFrom: number;
|
||||
redAbove: number;
|
||||
}
|
||||
|
||||
interface BandLimits {
|
||||
max: number;
|
||||
/** The red limit itself must be above zero. */
|
||||
redAboveZero?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* yellowFrom and redAbove of one band. An absent yellowFrom never ends above
|
||||
* redAbove; a given one that does is reported and replaced.
|
||||
*/
|
||||
function readBand(
|
||||
scope: Scope,
|
||||
defaults: Band,
|
||||
limits: BandLimits,
|
||||
legacyRed?: number,
|
||||
): Band {
|
||||
const redAbove = numberIn(
|
||||
scope,
|
||||
'redAbove',
|
||||
legacyRed ?? defaults.redAbove,
|
||||
0,
|
||||
limits.max,
|
||||
{ exclusiveMin: limits.redAboveZero },
|
||||
);
|
||||
const yellowDefault = Math.min(defaults.yellowFrom, redAbove);
|
||||
const yellowFrom = numberIn(
|
||||
scope,
|
||||
'yellowFrom',
|
||||
yellowDefault,
|
||||
0,
|
||||
limits.max,
|
||||
);
|
||||
if (yellowFrom > redAbove) {
|
||||
report(
|
||||
scope,
|
||||
'yellowFrom',
|
||||
'Der gelbe Wert darf nicht über dem roten Wert liegen.',
|
||||
);
|
||||
return { yellowFrom: yellowDefault, redAbove };
|
||||
}
|
||||
return { yellowFrom, redAbove };
|
||||
}
|
||||
|
||||
const COLONY_TYPES = Object.values(ColonyType);
|
||||
|
||||
export const varroaThresholdParser = makeParser<VarroaThresholdConfig>(
|
||||
'VARROA_THRESHOLD',
|
||||
DEFAULT_VARROA_THRESHOLD_CONFIG,
|
||||
(scope) => {
|
||||
const d = DEFAULT_VARROA_THRESHOLD_CONFIG;
|
||||
|
||||
const legacy = legacyThreshold(scope);
|
||||
const fall = childScope(scope, 'naturalFall');
|
||||
const fallBand = readBand(
|
||||
fall,
|
||||
d.naturalFall,
|
||||
{ max: MAX_MITES_PER_DAY, redAboveZero: true },
|
||||
legacy,
|
||||
);
|
||||
const naturalFall = {
|
||||
seasonMonths: months(fall, 'seasonMonths', d.naturalFall.seasonMonths),
|
||||
...fallBand,
|
||||
winterRedAbove: numberIn(
|
||||
fall,
|
||||
'winterRedAbove',
|
||||
d.naturalFall.winterRedAbove,
|
||||
0,
|
||||
MAX_MITES_PER_DAY,
|
||||
),
|
||||
};
|
||||
|
||||
const percent = childScope(scope, 'infestationPercent');
|
||||
const infestationPercent = {
|
||||
summerMonths: months(
|
||||
percent,
|
||||
'summerMonths',
|
||||
d.infestationPercent.summerMonths,
|
||||
),
|
||||
summer: readBand(
|
||||
childScope(percent, 'summer'),
|
||||
d.infestationPercent.summer,
|
||||
{ max: MAX_PERCENT },
|
||||
),
|
||||
autumnMonths: months(
|
||||
percent,
|
||||
'autumnMonths',
|
||||
d.infestationPercent.autumnMonths,
|
||||
),
|
||||
autumn: readBand(
|
||||
childScope(percent, 'autumn'),
|
||||
d.infestationPercent.autumn,
|
||||
{ max: MAX_PERCENT },
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
naturalFall,
|
||||
infestationPercent,
|
||||
youngColonyFactor: numberIn(
|
||||
scope,
|
||||
'youngColonyFactor',
|
||||
d.youngColonyFactor,
|
||||
0,
|
||||
1,
|
||||
{ exclusiveMin: true },
|
||||
),
|
||||
youngColonyTypes: enumList(
|
||||
scope,
|
||||
'youngColonyTypes',
|
||||
d.youngColonyTypes,
|
||||
COLONY_TYPES,
|
||||
),
|
||||
recheckAfterDays: intIn(
|
||||
scope,
|
||||
'recheckAfterDays',
|
||||
d.recheckAfterDays,
|
||||
0,
|
||||
MAX_RECHECK_DAYS,
|
||||
),
|
||||
notify: bool(scope, 'notify', d.notify),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
childScope,
|
||||
has,
|
||||
makeParser,
|
||||
numberIn,
|
||||
optionalNumberIn,
|
||||
report,
|
||||
type Scope,
|
||||
} from './parse-helpers';
|
||||
import type { WaterContentConfig } from './rule-config.types';
|
||||
|
||||
const MIN_WATER_PERCENT = 10;
|
||||
const MAX_WATER_PERCENT = 30;
|
||||
const MAX_HONEY_TYPES = 50;
|
||||
const MAX_HONEY_TYPE_LENGTH = 100;
|
||||
|
||||
export const DEFAULT_WATER_CONTENT_CONFIG: WaterContentConfig = {
|
||||
warnAbovePercent: 18,
|
||||
confirmAbovePercent: 20,
|
||||
byHoneyType: {},
|
||||
};
|
||||
|
||||
function percent(scope: Scope, key: string, fallback: number): number {
|
||||
return numberIn(scope, key, fallback, MIN_WATER_PERCENT, MAX_WATER_PERCENT);
|
||||
}
|
||||
|
||||
const ORDER_MESSAGE =
|
||||
'Die Warnschwelle darf nicht über der Bestätigungsschwelle liegen.';
|
||||
|
||||
function readHoneyTypes(scope: Scope): WaterContentConfig['byHoneyType'] {
|
||||
const types = childScope(scope, 'byHoneyType');
|
||||
const names = Object.keys(types.raw);
|
||||
if (names.length > MAX_HONEY_TYPES) {
|
||||
report(scope, 'byHoneyType', `Höchstens ${MAX_HONEY_TYPES} Sorten.`);
|
||||
return {};
|
||||
}
|
||||
const entries: [
|
||||
string,
|
||||
{ warnAbovePercent?: number; confirmAbovePercent?: number },
|
||||
][] = [];
|
||||
for (const name of names) {
|
||||
if (name.length === 0 || name.length > MAX_HONEY_TYPE_LENGTH) {
|
||||
report(types, name.slice(0, 30), 'Der Sortenname ist ungültig.');
|
||||
continue;
|
||||
}
|
||||
const entry = childScope(types, name);
|
||||
const before = entry.issues.length;
|
||||
const value: { warnAbovePercent?: number; confirmAbovePercent?: number } =
|
||||
{};
|
||||
const warn = optionalNumberIn(
|
||||
entry,
|
||||
'warnAbovePercent',
|
||||
MIN_WATER_PERCENT,
|
||||
MAX_WATER_PERCENT,
|
||||
);
|
||||
const confirm = optionalNumberIn(
|
||||
entry,
|
||||
'confirmAbovePercent',
|
||||
MIN_WATER_PERCENT,
|
||||
MAX_WATER_PERCENT,
|
||||
);
|
||||
if (warn !== undefined && confirm !== undefined && warn > confirm) {
|
||||
report(entry, 'warnAbovePercent', ORDER_MESSAGE);
|
||||
}
|
||||
if (entry.issues.length > before) {
|
||||
continue;
|
||||
}
|
||||
if (warn !== undefined) {
|
||||
value.warnAbovePercent = warn;
|
||||
}
|
||||
if (confirm !== undefined) {
|
||||
value.confirmAbovePercent = confirm;
|
||||
}
|
||||
entries.push([name, value]);
|
||||
}
|
||||
// fromEntries defines own properties, so a name like "__proto__" is harmless.
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export const waterContentParser = makeParser<WaterContentConfig>(
|
||||
'HONEY_WATER_CONTENT',
|
||||
DEFAULT_WATER_CONTENT_CONFIG,
|
||||
(scope) => {
|
||||
const d = DEFAULT_WATER_CONTENT_CONFIG;
|
||||
let warnAbovePercent = percent(
|
||||
scope,
|
||||
'warnAbovePercent',
|
||||
d.warnAbovePercent,
|
||||
);
|
||||
let confirmAbovePercent = percent(
|
||||
scope,
|
||||
'confirmAbovePercent',
|
||||
d.confirmAbovePercent,
|
||||
);
|
||||
if (warnAbovePercent > confirmAbovePercent) {
|
||||
report(
|
||||
scope,
|
||||
has(scope, 'warnAbovePercent')
|
||||
? 'warnAbovePercent'
|
||||
: 'confirmAbovePercent',
|
||||
ORDER_MESSAGE,
|
||||
);
|
||||
warnAbovePercent = d.warnAbovePercent;
|
||||
confirmAbovePercent = d.confirmAbovePercent;
|
||||
}
|
||||
return {
|
||||
warnAbovePercent,
|
||||
confirmAbovePercent,
|
||||
byHoneyType: readHoneyTypes(scope),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -26,6 +26,24 @@ describe('varroaThresholdOf', () => {
|
||||
).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 }],
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
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 = 10;
|
||||
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY =
|
||||
DEFAULT_VARROA_THRESHOLD_CONFIG.naturalFall.redAbove;
|
||||
|
||||
/**
|
||||
* Effective varroa threshold of the VARROA_THRESHOLD rule.
|
||||
* 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 a
|
||||
* positive finite number (a hand-edited config must never disable the check).
|
||||
* - 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,
|
||||
@@ -19,12 +24,5 @@ export function varroaThresholdOf(
|
||||
if (!rule.active) {
|
||||
return null;
|
||||
}
|
||||
const config = rule.config;
|
||||
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
||||
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
||||
}
|
||||
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? value
|
||||
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
|
||||
return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove;
|
||||
}
|
||||
Reference in new issue
Block a user