feat(autopilot): /autopilot/today liefert Saison-Zusätze für das Bienenjahr-Rad (T5b B5)
- seasons[] bekommt segmentSince, reportedOn und note (NO_COORDINATES | WEATHER_GAPS | WEATHER_STALE). - Neues seasonCalendar: calendarFallback (8 Phasen, MM-DD), swarmSeason und winterVarroaWindow aus den Regel-Configs, auch bei inaktiver Regel. - Nur Zusätze, keine Migration, Recht bleibt colonies:read; SEASON_AUTOPILOT wird je Anfrage einmal gelesen. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
90d1c5244f
commit
bcc7cbfd13
4 files changed
+260
-11
No files matched your search
@@ -84,6 +84,56 @@ class SeasonOfApiaryDto {
|
||||
phase!: AutopilotToday['seasons'][number]['phase'];
|
||||
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
|
||||
phaseSource!: AutopilotToday['seasons'][number]['phaseSource'];
|
||||
@ApiProperty({ description: 'Day the current segment began (YYYY-MM-DD).' })
|
||||
segmentSince!: string;
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
type: String,
|
||||
format: 'date',
|
||||
description:
|
||||
'Day of the phase report the phase rests on; null without one.',
|
||||
})
|
||||
reportedOn!: string | null;
|
||||
@ApiProperty({
|
||||
enum: ['NO_COORDINATES', 'WEATHER_GAPS', 'WEATHER_STALE'],
|
||||
nullable: true,
|
||||
})
|
||||
note!: AutopilotToday['seasons'][number]['note'];
|
||||
}
|
||||
|
||||
class SwarmSeasonDto {
|
||||
@ApiProperty({ enum: PHENOLOGY_PHASES })
|
||||
from!: AutopilotToday['seasonCalendar']['swarmSeason']['from'];
|
||||
@ApiProperty({ enum: PHENOLOGY_PHASES })
|
||||
until!: AutopilotToday['seasonCalendar']['swarmSeason']['until'];
|
||||
}
|
||||
|
||||
class MonthDayWindowDto {
|
||||
@ApiProperty({ example: '11-15' }) from!: string;
|
||||
@ApiProperty({ example: '12-20' }) to!: string;
|
||||
}
|
||||
|
||||
/** Calendar day ('MM-DD') of each phenology phase. */
|
||||
class CalendarFallbackDto implements Record<
|
||||
(typeof PHENOLOGY_PHASES)[number],
|
||||
string
|
||||
> {
|
||||
@ApiProperty({ example: '03-20' }) SALWEIDE!: string;
|
||||
@ApiProperty({ example: '04-15' }) KIRSCHE!: string;
|
||||
@ApiProperty({ example: '04-28' }) APFEL!: string;
|
||||
@ApiProperty({ example: '05-30' }) HOLUNDER!: string;
|
||||
@ApiProperty({ example: '06-20' }) LINDE!: string;
|
||||
@ApiProperty({ example: '08-01' }) TRACHTENDE!: string;
|
||||
@ApiProperty({ example: '09-15' }) HERBST!: string;
|
||||
@ApiProperty({ example: '11-15' }) WINTER!: string;
|
||||
}
|
||||
|
||||
class SeasonCalendarDto {
|
||||
@ApiProperty({ type: CalendarFallbackDto })
|
||||
calendarFallback!: CalendarFallbackDto;
|
||||
@ApiProperty({ type: SwarmSeasonDto }) swarmSeason!: SwarmSeasonDto;
|
||||
@ApiProperty({ type: MonthDayWindowDto })
|
||||
winterVarroaWindow!: MonthDayWindowDto;
|
||||
}
|
||||
|
||||
export class AutopilotTodayDto {
|
||||
@@ -96,5 +146,7 @@ export class AutopilotTodayDto {
|
||||
inspectionDue!: InspectionDueDto[];
|
||||
@ApiProperty() suggestionsOpen!: number;
|
||||
@ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[];
|
||||
@ApiProperty({ type: SeasonCalendarDto })
|
||||
seasonCalendar!: SeasonCalendarDto;
|
||||
@ApiProperty() tasksVisible!: boolean;
|
||||
}
|
||||
@@ -352,10 +352,102 @@ describe('NextStepsService', () => {
|
||||
segment: 'SCHWARMZEIT',
|
||||
phase: 'APFEL',
|
||||
phaseSource: 'CALENDAR',
|
||||
segmentSince: '2026-04-15',
|
||||
reportedOn: null,
|
||||
note: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('today() maps reportedOn and note from the season state', async () => {
|
||||
grant('colonies:read');
|
||||
prisma.apiary.findMany.mockResolvedValue([
|
||||
{ id: 'a1', name: 'Heimstand' },
|
||||
]);
|
||||
const seasons = new SeasonService();
|
||||
jest.spyOn(seasons, 'statesFor').mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'a1',
|
||||
{
|
||||
apiaryId: 'a1',
|
||||
year: 2026,
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'REPORT',
|
||||
reportedOn: '2026-04-05',
|
||||
segment: 'SCHWARMZEIT',
|
||||
segmentSince: '2026-04-05',
|
||||
springSince: '2026-03-18',
|
||||
gts: null,
|
||||
gtsThroughDay: null,
|
||||
note: 'WEATHER_STALE',
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
service = new NextStepsService(
|
||||
prisma as unknown as PrismaService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
seasons,
|
||||
new SuggestionViewsService(
|
||||
prisma as unknown as PrismaService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
),
|
||||
);
|
||||
|
||||
const today = await service.today('u1', TODAY);
|
||||
|
||||
expect(today.seasons).toEqual([
|
||||
{
|
||||
apiaryId: 'a1',
|
||||
apiaryName: 'Heimstand',
|
||||
segment: 'SCHWARMZEIT',
|
||||
phase: 'KIRSCHE',
|
||||
phaseSource: 'REPORT',
|
||||
segmentSince: '2026-04-05',
|
||||
reportedOn: '2026-04-05',
|
||||
note: 'WEATHER_STALE',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the season calendar of the PHENOLOGY and SEASON_AUTOPILOT rules', async () => {
|
||||
grant('colonies:read');
|
||||
prisma.automationRule.findUnique.mockImplementation(
|
||||
(args: { where: { triggerType: string } }) =>
|
||||
Promise.resolve(
|
||||
args.where.triggerType === 'PHENOLOGY'
|
||||
? {
|
||||
id: 'r1',
|
||||
active: true,
|
||||
config: {
|
||||
calendarFallback: { KIRSCHE: '04-10' },
|
||||
swarmSeason: { from: 'APFEL', until: 'LINDE' },
|
||||
},
|
||||
actionTemplate: null,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
const today = await service.today('u1', TODAY);
|
||||
|
||||
expect(today.seasonCalendar).toEqual({
|
||||
calendarFallback: {
|
||||
SALWEIDE: '03-20',
|
||||
KIRSCHE: '04-10',
|
||||
APFEL: '04-28',
|
||||
HOLUNDER: '05-30',
|
||||
LINDE: '06-20',
|
||||
TRACHTENDE: '08-01',
|
||||
HERBST: '09-15',
|
||||
WINTER: '11-15',
|
||||
},
|
||||
swarmSeason: { from: 'APFEL', until: 'LINDE' },
|
||||
winterVarroaWindow: { from: '11-15', to: '12-20' },
|
||||
});
|
||||
});
|
||||
|
||||
it('has no next step and counts no suggestions without tasks:read', async () => {
|
||||
grant('colonies:read');
|
||||
|
||||
|
||||
@@ -20,7 +20,15 @@ import {
|
||||
inspectionDueOn,
|
||||
inspectionIntervalOf,
|
||||
} from '../season/inspection-due';
|
||||
import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
|
||||
import type {
|
||||
SeasonNote,
|
||||
SeasonSegmentKey,
|
||||
SeasonState,
|
||||
} from '../season/season-types';
|
||||
import type {
|
||||
PhenologyConfig,
|
||||
SeasonAutopilotConfig,
|
||||
} from '../automation/rule-config/rule-config.types';
|
||||
import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
|
||||
import { renderSuggestion } from '../suggestions/suggestion-texts';
|
||||
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
|
||||
@@ -62,7 +70,16 @@ export interface AutopilotToday {
|
||||
segment: SeasonSegmentKey;
|
||||
phase: ColonyNextSteps['phase'];
|
||||
phaseSource: ColonyNextSteps['phaseSource'];
|
||||
segmentSince: string;
|
||||
reportedOn: string | null;
|
||||
note: SeasonNote;
|
||||
}[];
|
||||
/** The calendar the season runs on (rule settings), for the year wheel. */
|
||||
seasonCalendar: {
|
||||
calendarFallback: PhenologyConfig['calendarFallback'];
|
||||
swarmSeason: PhenologyConfig['swarmSeason'];
|
||||
winterVarroaWindow: SeasonAutopilotConfig['winterVarroaWindow'];
|
||||
};
|
||||
tasksVisible: boolean;
|
||||
}
|
||||
|
||||
@@ -86,6 +103,7 @@ interface Computation {
|
||||
intervalDays: Readonly<
|
||||
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>
|
||||
>;
|
||||
winterVarroaWindow: SeasonAutopilotConfig['winterVarroaWindow'];
|
||||
}
|
||||
|
||||
/** A withdrawal period longer than this is not looked for. */
|
||||
@@ -211,15 +229,21 @@ export class NextStepsService {
|
||||
where: LIVING_COLONY_WHERE,
|
||||
select: { id: true },
|
||||
});
|
||||
const { colonies, seasons, intervalDays } = await this.compute(
|
||||
living.map((colony) => colony.id),
|
||||
held,
|
||||
today,
|
||||
);
|
||||
const apiaries = await this.prisma.apiary.findMany({
|
||||
where: { id: { in: [...seasons.keys()] } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const { colonies, seasons, intervalDays, winterVarroaWindow } =
|
||||
await this.compute(
|
||||
living.map((colony) => colony.id),
|
||||
held,
|
||||
today,
|
||||
);
|
||||
// SeasonService reads PHENOLOGY internally (and not at all without
|
||||
// apiaries), so the calendar needs its own read.
|
||||
const [apiaries, phenology] = await Promise.all([
|
||||
this.prisma.apiary.findMany({
|
||||
where: { id: { in: [...seasons.keys()] } },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
readRule(this.prisma, 'PHENOLOGY'),
|
||||
]);
|
||||
const apiaryName = new Map(apiaries.map((a) => [a.id, a.name] as const));
|
||||
const nameOf = (apiaryId: string): string => apiaryName.get(apiaryId) ?? '';
|
||||
const suggestionsOpen = held.has('tasks:read')
|
||||
@@ -250,7 +274,15 @@ export class NextStepsService {
|
||||
segment: season.segment,
|
||||
phase: season.phase,
|
||||
phaseSource: season.phaseSource,
|
||||
segmentSince: season.segmentSince,
|
||||
reportedOn: season.reportedOn,
|
||||
note: season.note,
|
||||
})),
|
||||
seasonCalendar: {
|
||||
calendarFallback: phenology.config.calendarFallback,
|
||||
swarmSeason: phenology.config.swarmSeason,
|
||||
winterVarroaWindow,
|
||||
},
|
||||
tasksVisible: held.has('tasks:read'),
|
||||
};
|
||||
}
|
||||
@@ -410,6 +442,7 @@ export class NextStepsService {
|
||||
colonies,
|
||||
seasons: states,
|
||||
intervalDays: interval.config.intervalDays,
|
||||
winterVarroaWindow: autopilot.config.winterVarroaWindow,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user