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,4 +1,5 @@
import { DashboardService } from './dashboard.service';
import { berlinToday } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
describe('DashboardService', () => {
@@ -11,6 +12,7 @@ describe('DashboardService', () => {
honeyBatch: { count: jest.Mock };
stockItem: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
};
beforeEach(() => {
@@ -28,6 +30,8 @@ describe('DashboardService', () => {
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
// No stored SEASON_AUTOPILOT rule: the built-in setting applies.
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
};
service = new DashboardService(prisma as unknown as PrismaService);
});
@@ -137,33 +141,44 @@ describe('DashboardService', () => {
});
describe('OLD_QUEEN', () => {
it('flags a colony whose queen is at least 3 years old', async () => {
const currentYear = Number(berlinToday().slice(0, 4));
const queenedColony = (hatchYear: number) => [
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear } },
];
it('OLD_QUEEN starts at queenMaxAgeYears (2)', async () => {
prisma.colony.findMany
.mockResolvedValueOnce([]) // attentionColonies
.mockResolvedValueOnce([
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear: 2020 } },
]);
.mockResolvedValueOnce(queenedColony(currentYear - 2));
const alerts = await service.alerts();
expect(alerts).toHaveLength(1);
expect(alerts[0].type).toBe('OLD_QUEEN');
expect(alerts[0].relatedId).toBe('colony-1');
expect(alerts[0].message).toContain('2 Jahre');
});
it('does not flag a queen younger than the threshold', async () => {
const currentYear = new Date().getFullYear();
prisma.colony.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'colony-1',
code: 'V-001',
currentQueen: { hatchYear: currentYear },
},
]);
prisma.colony.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(queenedColony(currentYear - 1));
const alerts = await service.alerts();
expect(await service.alerts()).toHaveLength(0);
});
expect(alerts).toHaveLength(0);
it('follows queenMaxAgeYears of the SEASON_AUTOPILOT rule', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-1',
active: true,
actionTemplate: null,
config: { queenMaxAgeYears: 4 },
});
prisma.colony.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(queenedColony(currentYear - 3));
expect(await service.alerts()).toHaveLength(0);
});
});
});
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { readRule } from '../automation/rule-config';
import { berlinToday } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { isQueenAged } from '../season/queen-age';
export interface DashboardSummary {
apiariesActive: number;
@@ -24,10 +27,6 @@ export interface DashboardAlert {
}
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
// Queens rarely stay reliably productive past their third season - laying
// rate and swarming risk both worsen well before then, so this flags a
// colony for evaluation rather than demanding immediate requeening.
const OLD_QUEEN_AGE_YEARS = 3;
@Injectable()
export class DashboardService {
@@ -158,15 +157,23 @@ export class DashboardService {
});
}
}
const currentYear = now.getFullYear();
// The age comes from the rule, like QueenAged and the autopilot: a flag
// for evaluation rather than a demand for immediate requeening.
const { config: autopilot } = await readRule(
this.prisma,
'SEASON_AUTOPILOT',
);
const today = berlinToday(now);
const currentYear = Number(today.slice(0, 4));
for (const colony of queenedColonies) {
// The where clause above already requires currentQueen and its
// hatchYear to exist, but the relation is still nullable in the type.
if (!colony.currentQueen?.hatchYear) {
continue;
}
const ageYears = currentYear - colony.currentQueen.hatchYear;
if (ageYears >= OLD_QUEEN_AGE_YEARS) {
const { hatchYear } = colony.currentQueen;
if (isQueenAged(hatchYear, today, autopilot.queenMaxAgeYears)) {
const ageYears = currentYear - hatchYear;
alerts.push({
type: 'OLD_QUEEN',
message: `Königin in Volk ${colony.code} ist ${ageYears} Jahre alt - Nachzucht prüfen`,