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

+50 -8
View File
@@ -9,6 +9,10 @@ import {
isAllowedDocumentMimeType,
} from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature';
import {
appendLegacyFeedNote,
feedConditionFromInput,
} from '../common/feed-vocabulary';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType, Prisma } from '../../generated/prisma';
import {
@@ -162,6 +166,28 @@ function asString(row: Row, key: string): string | null {
return typeof value === 'string' ? value : null;
}
// Bundles written before the feed vocabulary change carry free text in
// Inspection.feedStatus: known words map to the enum, the rest goes to notes.
function withCurrentFeedVocabulary(row: Row): Row {
if (!('feedStatus' in row)) {
return row;
}
const raw = row.feedStatus;
// A value that is neither text nor empty cannot map; keep it in the notes.
const { feedStatus, notesSuffix } =
raw === null || raw === undefined || typeof raw === 'string'
? feedConditionFromInput(raw)
: {
feedStatus: null,
notesSuffix: `Futter (alt): ${JSON.stringify(raw)}`,
};
return {
...row,
feedStatus,
notes: appendLegacyFeedNote(asString(row, 'notes'), notesSuffix),
};
}
function documentsStorageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
}
@@ -233,6 +259,7 @@ export class BackupService {
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
data.inspection = await this.prisma.inspection.findMany();
data.taskTemplate = await this.prisma.taskTemplate.findMany();
data.suggestion = await this.prisma.suggestion.findMany();
data.task = await this.prisma.task.findMany();
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
@@ -538,16 +565,14 @@ export class BackupService {
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
for (const row of rowsOf('inspection')) {
const inspection = {
...withCurrentFeedVocabulary(row),
userId: resolveUserId(asString(row, 'userId')),
};
await tx.inspection.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
}) as never,
create: inspection as never,
update: updateWith(inspection) as never,
});
}
imported.inspection = rowsOf('inspection').length;
@@ -577,6 +602,23 @@ export class BackupService {
}
imported.taskTemplate = rowsOf('taskTemplate').length;
// Before the tasks: Task.sourceSuggestionId points at them. After the
// colonies: a suggestion may hang on one.
for (const row of rowsOf('suggestion')) {
const data = {
...row,
decidedByUserId: resolveOptionalUserId(
asString(row, 'decidedByUserId'),
),
};
await tx.suggestion.upsert({
where: { id: row.id as string },
create: data as never,
update: updateWith(data) as never,
});
}
imported.suggestion = rowsOf('suggestion').length;
for (const row of rowsOf('task')) {
await tx.task.upsert({
where: { id: row.id as string },