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
@@ -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),
|
||||
};
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user