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

@@ -133,6 +133,42 @@ describe('ImportService', () => {
expect(createArg.data.code).toBe('V-100');
});
it('maps CSV feed text "niedrig" to WEAK and keeps unknown text in the notes', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue(null);
prisma.colony.create.mockResolvedValue({ id: 'colony-1' });
const report = await service.importCsv(
'colonies',
Buffer.from(
[
'code,apiaryName,colonyType,feedStatus,notes',
'V-1,Teststand,PRODUCTION_COLONY,niedrig,',
'V-2,Teststand,PRODUCTION_COLONY,halb voll,Hinweis',
'V-3,Teststand,PRODUCTION_COLONY,CRITICAL,',
].join('\n'),
),
'user-1',
);
expect(report.created).toBe(3);
const data = (
prisma.colony.create.mock.calls as [
[{ data: { feedStatus?: string; notes?: string } }],
]
).map(([call]) => call.data);
expect(data[0]).toMatchObject({ feedStatus: 'WEAK' });
expect(data[0].notes).toBeUndefined();
expect(data[1].feedStatus).toBeUndefined();
expect(data[1].notes).toBe('Hinweis\nFutter (alt): halb voll');
expect(data[2]).toMatchObject({ feedStatus: 'CRITICAL' });
expect(report.rows.map((row) => row.message)).toEqual([
undefined,
'Futterangabe „halb voll“ nicht erkannt, in die Notizen übernommen.',
undefined,
]);
});
it('reports a validation error for an invalid colonyType', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
+22 -3
View File
@@ -6,6 +6,10 @@ import { AuditLogService } from '../audit/audit-log.service';
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
import {
appendLegacyFeedNote,
feedConditionFromInput,
} from '../common/feed-vocabulary';
import { parseCsv } from './csv';
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
@@ -166,6 +170,8 @@ export class ImportService {
};
}
// Old exports carry free text here; known words map to the enum.
const feed = feedConditionFromInput(record.feedStatus);
const plain = {
code: emptyToUndefined(record.code),
apiaryId: apiary.id,
@@ -174,9 +180,13 @@ export class ImportService {
originText: emptyToUndefined(record.originText),
strength: parseNumberField(record.strength),
broodStatus: emptyToUndefined(record.broodStatus),
feedStatus: emptyToUndefined(record.feedStatus),
feedStatus: feed.feedStatus ?? undefined,
healthStatus: emptyToUndefined(record.healthStatus),
notes: emptyToUndefined(record.notes),
notes:
appendLegacyFeedNote(
emptyToUndefined(record.notes) ?? null,
feed.notesSuffix,
) ?? undefined,
};
const dto = plainToInstance(CreateColonyDto, plain);
const errors = await validate(dto);
@@ -199,7 +209,16 @@ export class ImportService {
};
}
const colony = await this.prisma.colony.create({ data: { ...dto } });
return { row: rowNumber, status: 'created', id: colony.id };
return {
row: rowNumber,
status: 'created',
id: colony.id,
...(feed.notesSuffix
? {
message: `Futterangabe „${record.feedStatus}“ nicht erkannt, in die Notizen übernommen.`,
}
: {}),
};
}
private async importQueenRow(