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

@@ -1,13 +1,11 @@
import { INestApplicationContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Prisma } from '../generated/prisma';
import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job';
import { JobsModule } from '../src/jobs/jobs.module';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
const NOTHING_NEW = {
WithdrawalEnded: 0,
@@ -21,14 +19,14 @@ const NOTHING_NEW = {
* The job evaluates the whole database. The e2e files share it and run in
* parallel, so the evaluation runs on days in 2001: no other file has
* records there, and the events of this file stay its own. Called through
* evaluate(day) instead of runCli, which would also write JobRun rows that
* test/jobs.e2e-spec.ts counts.
* TimeEventsStep.run(day) instead of runCli, which would also write JobRun
* rows that test/jobs.e2e-spec.ts counts.
*/
describe('daily-evaluation (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let app: INestApplicationContext;
let prisma: PrismaService;
let job: DailyEvaluationJob;
let step: TimeEventsStep;
const ids: Record<string, string> = {};
const eventsOf = (aggregateId: string) =>
@@ -50,7 +48,7 @@ describe('daily-evaluation (e2e)', () => {
}).compile();
app = await moduleRef.init();
prisma = app.get(PrismaService);
job = app.get(DailyEvaluationJob);
step = app.get(TimeEventsStep);
const user = await prisma.user.create({
data: {
@@ -99,7 +97,8 @@ describe('daily-evaluation (e2e)', () => {
},
});
ids.dead = dead.id;
// Inspected 2001-04-28: the next inspection is overdue on 2001-05-19.
// Inspected 2001-04-28, in the swarm season (every 7 days): the next
// inspection is due 2001-05-05 and overdue on 2001-05-06.
for (const colonyId of [living.id, dead.id]) {
await prisma.inspection.create({
data: { colonyId, userId: user.id, date: d('2001-04-28') },
@@ -180,10 +179,10 @@ describe('daily-evaluation (e2e)', () => {
});
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
const first = await job.evaluate(at('2001-05-20'));
const second = await job.evaluate(at('2001-05-20'));
const first = await step.run('2001-05-20');
const second = await step.run('2001-05-20');
// The next day the occurrence is still in the window - and still known.
const nextDay = await job.evaluate(at('2001-05-21'));
const nextDay = await step.run('2001-05-21');
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
expect(second).toEqual(NOTHING_NEW);
@@ -201,8 +200,8 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
});
it('records due tasks and overdue checks only for open tasks and living colonies', async () => {
await job.evaluate(at('2001-05-20'));
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
await step.run('2001-05-20');
expect(await eventsOf(ids['task:open'])).toEqual([
{
@@ -215,17 +214,15 @@ describe('daily-evaluation (e2e)', () => {
]);
expect(await eventsOf(ids['task:done'])).toEqual([]);
expect(await eventsOf(ids.living)).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: { dueOn: '2001-05-19', lastInspectedOn: '2001-04-28' },
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-19`,
actorUserId: null,
},
{
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
payload: { dueOn: '2001-05-18', lastMeasuredOn: null },
payload: {
dueOn: '2001-05-18',
lastMeasuredOn: null,
kind: 'INTERVAL',
anchor: 'none',
},
dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`,
actorUserId: null,
},
@@ -233,11 +230,70 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids.dead)).toEqual([]);
});
it('reports active queens from their second year in the first days of January', async () => {
await job.evaluate(at('2001-01-05'));
expect(await job.evaluate(at('2001-01-08'))).toEqual(NOTHING_NEW);
it('InspectionOverdue follows the segment interval', async () => {
await step.run('2001-05-12');
expect(await eventsOf(ids['queen:aged'])).toEqual([
// SCHWARMZEIT since 2001-04-15: 2001-04-28 + 7 = 2001-05-05, not + 21.
expect(
(await eventsOf(ids.living)).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: {
dueOn: '2001-05-05',
lastInspectedOn: '2001-04-28',
firstOfYear: false,
},
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-06`,
actorUserId: null,
},
]);
expect(await eventsOf(ids.dead)).toEqual([]);
});
it('records no inspection in WINTERRUHE and the first one the day after FRUEHJAHR begins', async () => {
await step.run('2002-03-10');
expect(
(await eventsOf(ids.living)).filter((event) =>
event.dedupeKey?.endsWith(':2002-03-21'),
),
).toEqual([]);
// FRUEHJAHR begins 2002-03-20; the colony was last inspected in 2001.
await step.run('2002-03-21');
expect(
(await eventsOf(ids.living)).filter((event) =>
event.dedupeKey?.endsWith(':2002-03-21'),
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: {
dueOn: '2002-03-20',
lastInspectedOn: '2001-04-28',
firstOfYear: true,
},
dedupeKey: `InspectionOverdue:${ids.living}:2002-03-21`,
actorUserId: null,
},
]);
});
it('reports the current queen of a living colony once she is aged, on any job day of the year', async () => {
await step.run('2001-09-09');
await step.run('2001-09-10');
// One per queen and year: the 2002 run of the test above added its own.
const events = await eventsOf(ids['queen:aged']);
expect(events.map((event) => event.dedupeKey)).toEqual([
`QueenAged:${ids['queen:aged']}:2001-01-01`,
`QueenAged:${ids['queen:aged']}:2002-01-01`,
]);
expect(events.slice(0, 1)).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',