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
+254
-5
No files matched your search
@@ -84,6 +84,56 @@ class SeasonOfApiaryDto {
|
|||||||
phase!: AutopilotToday['seasons'][number]['phase'];
|
phase!: AutopilotToday['seasons'][number]['phase'];
|
||||||
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
|
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
|
||||||
phaseSource!: AutopilotToday['seasons'][number]['phaseSource'];
|
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 {
|
export class AutopilotTodayDto {
|
||||||
@@ -96,5 +146,7 @@ export class AutopilotTodayDto {
|
|||||||
inspectionDue!: InspectionDueDto[];
|
inspectionDue!: InspectionDueDto[];
|
||||||
@ApiProperty() suggestionsOpen!: number;
|
@ApiProperty() suggestionsOpen!: number;
|
||||||
@ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[];
|
@ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[];
|
||||||
|
@ApiProperty({ type: SeasonCalendarDto })
|
||||||
|
seasonCalendar!: SeasonCalendarDto;
|
||||||
@ApiProperty() tasksVisible!: boolean;
|
@ApiProperty() tasksVisible!: boolean;
|
||||||
}
|
}
|
||||||
@@ -352,10 +352,102 @@ describe('NextStepsService', () => {
|
|||||||
segment: 'SCHWARMZEIT',
|
segment: 'SCHWARMZEIT',
|
||||||
phase: 'APFEL',
|
phase: 'APFEL',
|
||||||
phaseSource: 'CALENDAR',
|
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 () => {
|
it('has no next step and counts no suggestions without tasks:read', async () => {
|
||||||
grant('colonies:read');
|
grant('colonies:read');
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ import {
|
|||||||
inspectionDueOn,
|
inspectionDueOn,
|
||||||
inspectionIntervalOf,
|
inspectionIntervalOf,
|
||||||
} from '../season/inspection-due';
|
} 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 { buildEvidenceLookup } from '../suggestions/evidence-lookup';
|
||||||
import { renderSuggestion } from '../suggestions/suggestion-texts';
|
import { renderSuggestion } from '../suggestions/suggestion-texts';
|
||||||
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
|
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
|
||||||
@@ -62,7 +70,16 @@ export interface AutopilotToday {
|
|||||||
segment: SeasonSegmentKey;
|
segment: SeasonSegmentKey;
|
||||||
phase: ColonyNextSteps['phase'];
|
phase: ColonyNextSteps['phase'];
|
||||||
phaseSource: ColonyNextSteps['phaseSource'];
|
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;
|
tasksVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +103,7 @@ interface Computation {
|
|||||||
intervalDays: Readonly<
|
intervalDays: Readonly<
|
||||||
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>
|
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>
|
||||||
>;
|
>;
|
||||||
|
winterVarroaWindow: SeasonAutopilotConfig['winterVarroaWindow'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A withdrawal period longer than this is not looked for. */
|
/** A withdrawal period longer than this is not looked for. */
|
||||||
@@ -211,15 +229,21 @@ export class NextStepsService {
|
|||||||
where: LIVING_COLONY_WHERE,
|
where: LIVING_COLONY_WHERE,
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const { colonies, seasons, intervalDays } = await this.compute(
|
const { colonies, seasons, intervalDays, winterVarroaWindow } =
|
||||||
|
await this.compute(
|
||||||
living.map((colony) => colony.id),
|
living.map((colony) => colony.id),
|
||||||
held,
|
held,
|
||||||
today,
|
today,
|
||||||
);
|
);
|
||||||
const apiaries = await this.prisma.apiary.findMany({
|
// 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()] } },
|
where: { id: { in: [...seasons.keys()] } },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
});
|
}),
|
||||||
|
readRule(this.prisma, 'PHENOLOGY'),
|
||||||
|
]);
|
||||||
const apiaryName = new Map(apiaries.map((a) => [a.id, a.name] as const));
|
const apiaryName = new Map(apiaries.map((a) => [a.id, a.name] as const));
|
||||||
const nameOf = (apiaryId: string): string => apiaryName.get(apiaryId) ?? '';
|
const nameOf = (apiaryId: string): string => apiaryName.get(apiaryId) ?? '';
|
||||||
const suggestionsOpen = held.has('tasks:read')
|
const suggestionsOpen = held.has('tasks:read')
|
||||||
@@ -250,7 +274,15 @@ export class NextStepsService {
|
|||||||
segment: season.segment,
|
segment: season.segment,
|
||||||
phase: season.phase,
|
phase: season.phase,
|
||||||
phaseSource: season.phaseSource,
|
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'),
|
tasksVisible: held.has('tasks:read'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -410,6 +442,7 @@ export class NextStepsService {
|
|||||||
colonies,
|
colonies,
|
||||||
seasons: states,
|
seasons: states,
|
||||||
intervalDays: interval.config.intervalDays,
|
intervalDays: interval.config.intervalDays,
|
||||||
|
winterVarroaWindow: autopilot.config.winterVarroaWindow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,18 @@ interface TodayBody {
|
|||||||
nextStep: { colonyId: string } | null;
|
nextStep: { colonyId: string } | null;
|
||||||
inspectionDue: { apiaryId: string }[];
|
inspectionDue: { apiaryId: string }[];
|
||||||
suggestionsOpen: number;
|
suggestionsOpen: number;
|
||||||
seasons: { apiaryId: string; segment: string }[];
|
seasons: {
|
||||||
|
apiaryId: string;
|
||||||
|
segment: string;
|
||||||
|
segmentSince: string;
|
||||||
|
reportedOn: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}[];
|
||||||
|
seasonCalendar?: {
|
||||||
|
calendarFallback: Record<string, string>;
|
||||||
|
swarmSeason: { from: string; until: string };
|
||||||
|
winterVarroaWindow: { from: string; to: string };
|
||||||
|
};
|
||||||
tasksVisible: boolean;
|
tasksVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,4 +465,65 @@ describe('Next steps (e2e)', () => {
|
|||||||
expect(body.suggestionsOpen).toBeGreaterThanOrEqual(4);
|
expect(body.suggestionsOpen).toBeGreaterThanOrEqual(4);
|
||||||
expect(body.nextStep).not.toBeNull();
|
expect(body.nextStep).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /autopilot/today carries seasonCalendar and segmentSince per apiary', async () => {
|
||||||
|
const example = await nextSteps.today(users.full.id, EXAMPLE_DAY);
|
||||||
|
expect(example.seasons).toContainEqual({
|
||||||
|
apiaryId,
|
||||||
|
apiaryName: `Next steps ${suffix}`,
|
||||||
|
segment: 'SCHWARMZEIT',
|
||||||
|
phase: 'APFEL',
|
||||||
|
phaseSource: 'CALENDAR',
|
||||||
|
segmentSince: '2026-04-15',
|
||||||
|
reportedOn: null,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await get(users.noTasks.token, '/autopilot/today').expect(
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
const body = response.body as TodayBody;
|
||||||
|
const own = body.seasons.find((season) => season.apiaryId === apiaryId);
|
||||||
|
expect(own?.segmentSince).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(own).toHaveProperty('reportedOn');
|
||||||
|
expect(own).toHaveProperty('note');
|
||||||
|
expect(body.seasonCalendar).toEqual({
|
||||||
|
calendarFallback: expect.objectContaining({
|
||||||
|
SALWEIDE: expect.stringMatching(/^\d{2}-\d{2}$/) as string,
|
||||||
|
WINTER: expect.stringMatching(/^\d{2}-\d{2}$/) as string,
|
||||||
|
}) as Record<string, string>,
|
||||||
|
swarmSeason: {
|
||||||
|
from: expect.any(String) as string,
|
||||||
|
until: expect.any(String) as string,
|
||||||
|
},
|
||||||
|
winterVarroaWindow: {
|
||||||
|
from: expect.stringMatching(/^\d{2}-\d{2}$/) as string,
|
||||||
|
to: expect.stringMatching(/^\d{2}-\d{2}$/) as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(Object.keys(body.seasonCalendar?.calendarFallback ?? {})).toEqual([
|
||||||
|
'SALWEIDE',
|
||||||
|
'KIRSCHE',
|
||||||
|
'APFEL',
|
||||||
|
'HOLUNDER',
|
||||||
|
'LINDE',
|
||||||
|
'TRACHTENDE',
|
||||||
|
'HERBST',
|
||||||
|
'WINTER',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A changed PHENOLOGY config is covered by the unit test: mutating the shared
|
||||||
|
// rule row here would race the other e2e suites that read it in parallel.
|
||||||
|
|
||||||
|
it('a user without colonies:read still gets 403 and no calendar', async () => {
|
||||||
|
const noColonies = await createUser('nocalendar', ['tasks:read']);
|
||||||
|
|
||||||
|
const response = await get(noColonies.token, '/autopilot/today').expect(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.body).not.toHaveProperty('seasonCalendar');
|
||||||
|
expect(JSON.stringify(response.body)).not.toMatch(/calendarFallback/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user