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

@@ -0,0 +1,218 @@
import {
ScratchDatabase,
createScratchDatabase,
} from './utils/migration-harness';
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
const TIMEOUT_MS = 120_000;
interface InspectionSeed {
id: string;
createdAt: string;
feedStatus: string | null;
notes: string | null;
}
/** Minimal FK targets, written as raw SQL (Prisma ids and updatedAt have no DB default). */
async function seedColony(db: ScratchDatabase): Promise<void> {
await db.query(
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
);
await db.query(
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt")
VALUES ('user-1', '[email protected]', 'x', 'Mig', 'role-1', now())`,
);
await db.query(
`INSERT INTO "Apiary" ("id", "name", "updatedAt")
VALUES ('apiary-1', 'Stand', now())`,
);
await db.query(
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
VALUES ('colony-1', 'V-1', 'apiary-1', 'PRODUCTION_COLONY', now())`,
);
}
async function seedInspections(
db: ScratchDatabase,
seeds: InspectionSeed[],
): Promise<void> {
for (const seed of seeds) {
await db.query(
`INSERT INTO "Inspection" ("id", "colonyId", "userId", "date", "feedStatus", "notes", "createdAt")
VALUES ($1, 'colony-1', 'user-1', '2026-05-01', $2, $3, $4)`,
[seed.id, seed.feedStatus, seed.notes, seed.createdAt],
);
}
}
describe('T4 schema II migrations (e2e)', () => {
let db: ScratchDatabase | null = null;
afterEach(async () => {
if (db) {
await db.drop();
db = null;
}
});
it(
'free-text feed moves into notes and known words map to the enum',
async () => {
db = await createScratchDatabase(FEED_VOCABULARY);
await seedColony(db);
await seedInspections(db, [
{
id: 'i-1',
createdAt: '2026-05-01T10:00:00Z',
feedStatus: 'niedrig',
notes: null,
},
{
id: 'i-2',
createdAt: '2026-05-01T11:00:00Z',
feedStatus: 'halb voll',
notes: null,
},
{
id: 'i-3',
createdAt: '2026-05-01T12:00:00Z',
feedStatus: 'halb voll',
notes: 'x',
},
]);
await db.migrateThrough(FEED_VOCABULARY);
expect(
await db.query(
'SELECT "feedStatus", notes FROM "Inspection" ORDER BY "createdAt"',
),
).toEqual([
{ feedStatus: 'WEAK', notes: null },
{ feedStatus: null, notes: 'Futter (alt): halb voll' },
{ feedStatus: null, notes: 'x\nFutter (alt): halb voll' },
]);
},
TIMEOUT_MS,
);
it(
'leaves no inspection whose old feed text was lost',
async () => {
db = await createScratchDatabase(FEED_VOCABULARY);
await seedColony(db);
const rawSeeds: [string | null, string | null][] = [
[' Ausreichend ', null],
['KRITISCH', 'Hinweis'],
['Niedrig', ''],
['sehr viel', 'a'],
[' Wabe voll ', null],
['', 'leer'],
[' ', null],
[null, 'ohne'],
['\tniedrig', null],
['\r\n Halb\tvoll\n', null],
];
const seeds: InspectionSeed[] = rawSeeds.map(
([feedStatus, notes], index) => ({
id: `i-${index}`,
createdAt: `2026-05-01T0${index}:00:00Z`,
feedStatus,
notes,
}),
);
await seedInspections(db, seeds);
await db.migrateThrough(FEED_VOCABULARY);
const rows = await db.query<{
id: string;
feedStatus: string | null;
notes: string | null;
}>('SELECT id, "feedStatus", notes FROM "Inspection" ORDER BY id');
const byId = new Map(rows.map((row) => [row.id, row]));
// Spec 4.12: every old text ends up as an enum value or in the notes.
const known = ['ausreichend', 'niedrig', 'kritisch'];
for (const seed of seeds) {
const text = seed.feedStatus?.trim() ?? '';
if (text === '') {
continue;
}
const row = byId.get(seed.id)!;
if (known.includes(text.toLowerCase())) {
expect(row.feedStatus).not.toBeNull();
} else {
expect(row.feedStatus).toBeNull();
expect(row.notes).toContain(`Futter (alt): ${text}`);
}
}
expect(
rows.filter(
(row) => row.feedStatus === null && (row.notes ?? '') === '',
),
).toEqual([
// Only the blank text with no notes remains empty - nothing was lost.
{ id: 'i-6', feedStatus: null, notes: null },
]);
expect(byId.get('i-1')?.notes).toBe('Hinweis');
expect(byId.get('i-3')?.notes).toBe('a\nFutter (alt): sehr viel');
expect(byId.get('i-5')?.notes).toBe('leer');
expect(byId.get('i-7')?.notes).toBe('ohne');
expect(byId.get('i-8')).toMatchObject({
feedStatus: 'WEAK',
notes: null,
});
expect(byId.get('i-9')).toMatchObject({
feedStatus: null,
notes: 'Futter (alt): Halb\tvoll',
});
},
TIMEOUT_MS,
);
it(
'adds the optional split queen source and varroa sample size without touching old rows',
async () => {
db = await createScratchDatabase(SPLIT_QUEEN_SOURCE);
await seedColony(db);
await db.query(
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
VALUES ('colony-2', 'V-2', 'apiary-1', 'NUCLEUS_COLONY', now())`,
);
await db.query(
`INSERT INTO "ColonyLineage" ("id", "type", "fromColonyId", "toColonyId", "date")
VALUES ('l-1', 'SPLIT', 'colony-1', 'colony-2', '2026-05-01')`,
);
await db.query(
`INSERT INTO "VarroaMeasurement" ("id", "colonyId", "userId", "date", "method", "miteCount")
VALUES ('v-1', 'colony-1', 'user-1', '2026-05-02', 'WASHING_METHOD', 4)`,
);
await db.migrateThrough(SPLIT_QUEEN_SOURCE);
await db.migrateThrough(VARROA_SAMPLE_SIZE);
expect(
await db.query('SELECT "queenSource" FROM "ColonyLineage"'),
).toEqual([{ queenSource: null }]);
expect(
await db.query(
'SELECT "sampleBees", "miteCount" FROM "VarroaMeasurement"',
),
).toEqual([{ sampleBees: null, miteCount: 4 }]);
await db.query(
`UPDATE "ColonyLineage" SET "queenSource" = 'RAISE_OWN' WHERE id = 'l-1'`,
);
await expect(
db.query(
`UPDATE "ColonyLineage" SET "queenSource" = 'BOUGHT' WHERE id = 'l-1'`,
),
).rejects.toThrow();
},
TIMEOUT_MS,
);
});