- 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]>
107 lines
3.0 KiB
TypeScript
107 lines
3.0 KiB
TypeScript
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),
|
|
}),
|
|
);
|