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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

@@ -29,6 +29,7 @@ const BUSINESS_ENTITY_KEYS = [
'queenColonyHistory',
'inspection',
'taskTemplate',
'suggestion',
'task',
'varroaMeasurement',
'swarmControlCheck',
@@ -237,6 +238,157 @@ describe('BackupService', () => {
];
expect(call.create.broodFrames).toBe(5);
});
it('maps a legacy free-text feedStatus of an old bundle on import', async () => {
const base = { colonyId: 'colony-1', userId: 'known-user' };
const bundle = {
version: 3,
data: {
inspection: [
{ ...base, id: 'i-1', feedStatus: ' Niedrig ', notes: null },
{ ...base, id: 'i-2', feedStatus: 'halb voll', notes: 'x' },
{ ...base, id: 'i-3', feedStatus: 'CRITICAL', notes: null },
{ ...base, id: 'i-4', feedStatus: '', notes: 'y' },
],
},
};
await service.importAll(bundle as never, 'admin-1', TRUSTED);
const created = (
tx.inspection.upsert.mock.calls as [
[{ where: { id: string }; create: Record<string, unknown> }],
]
).map(([call]) => call.create);
expect(created).toEqual([
expect.objectContaining({ id: 'i-1', feedStatus: 'WEAK', notes: null }),
expect.objectContaining({
id: 'i-2',
feedStatus: null,
notes: 'x\nFutter (alt): halb voll',
}),
expect.objectContaining({
id: 'i-3',
feedStatus: 'CRITICAL',
notes: null,
}),
expect.objectContaining({ id: 'i-4', feedStatus: null, notes: 'y' }),
]);
});
});
describe('legacy feedStatus of unexpected type', () => {
it('keeps a non-string feedStatus in the notes as JSON instead of dropping it', async () => {
const base = { colonyId: 'colony-1', userId: 'known-user' };
const bundle = {
version: 3,
data: {
inspection: [
{ ...base, id: 'i-1', feedStatus: 3, notes: null },
{ ...base, id: 'i-2', feedStatus: { level: 'low' }, notes: 'x' },
{ ...base, id: 'i-3', feedStatus: null, notes: 'y' },
],
},
};
await service.importAll(bundle as never, 'admin-1', TRUSTED);
const created = (
tx.inspection.upsert.mock.calls as [
[{ create: Record<string, unknown> }],
]
).map(([call]) => call.create);
expect(created).toEqual([
expect.objectContaining({
feedStatus: null,
notes: 'Futter (alt): 3',
}),
expect.objectContaining({
feedStatus: null,
notes: 'x\nFutter (alt): {"level":"low"}',
}),
expect.objectContaining({ feedStatus: null, notes: 'y' }),
]);
});
});
describe('suggestions', () => {
it('exports and restores suggestions with their decision before tasks', async () => {
const suggestion = {
id: 'suggestion-1',
colonyId: 'colony-1',
status: 'ACCEPTED',
dedupeKey: 'INSPECTION_INTERVAL:colony-1:insp-9',
decidedAt: '2026-10-14T08:00:00.000Z',
decidedByUserId: 'known-user',
decisionNote: 'Passt',
};
const orphanDecision = {
...suggestion,
id: 'suggestion-2',
dedupeKey: null,
decidedByUserId: 'deleted-user',
};
const open = {
...suggestion,
id: 'suggestion-3',
status: 'OPEN',
decidedAt: null,
decidedByUserId: null,
decisionNote: null,
};
prisma.suggestion.findMany.mockResolvedValue([
suggestion,
orphanDecision,
open,
]);
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
prisma.task.findMany.mockResolvedValue([
{ id: 'task-1', sourceSuggestionId: 'suggestion-1' },
]);
const bundle = await service.exportAll();
expect(bundle.data.suggestion).toEqual([
suggestion,
orphanDecision,
open,
]);
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
expect(summary.imported.suggestion).toBe(3);
const creates = (
tx.suggestion.upsert.mock.calls as [
{ create: { id: string; decidedByUserId: string | null } },
][]
).map(([call]) => call.create);
// A decider that is gone resolves like any other user id; no decision
// stays without one.
expect(creates.map((row) => row.decidedByUserId)).toEqual([
'known-user',
'admin-1',
null,
]);
const suggestionOrder = tx.suggestion.upsert.mock.invocationCallOrder[0];
const taskOrder = tx.task.upsert.mock.invocationCallOrder[0];
expect(suggestionOrder).toBeLessThan(taskOrder);
const colonyOrder = tx.colony.upsert.mock.invocationCallOrder[0];
expect(colonyOrder).toBeLessThan(suggestionOrder);
});
it('does not restore suggestions from an unsealed file', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-10-14T00:00:00.000Z',
data: { suggestion: [{ id: 'suggestion-1' }] },
},
'admin-1',
{ trusted: false },
);
expect(tx.suggestion.upsert).not.toHaveBeenCalled();
});
});
describe('importAll', () => {