feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -0,0 +1,585 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
AutomationTriggerType,
|
||||
SuggestionIntent,
|
||||
TaskPriority,
|
||||
} from '../../generated/prisma';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import {
|
||||
addDays,
|
||||
berlinDayOf,
|
||||
berlinToday,
|
||||
daysBetween,
|
||||
dayToDate,
|
||||
} from '../common/dates';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SeasonService } from '../season/season.service';
|
||||
import { inspectionDueOn } from '../season/inspection-due';
|
||||
import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
|
||||
import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
|
||||
import { renderSuggestion } from '../suggestions/suggestion-texts';
|
||||
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
|
||||
import {
|
||||
INTENT_OF_LEGACY_TRIGGER,
|
||||
type SuggestionEvidence,
|
||||
} from '../suggestions/suggestion.types';
|
||||
import {
|
||||
isInWindow,
|
||||
withdrawalWindowOf,
|
||||
} from '../treatments/withdrawal-window';
|
||||
import {
|
||||
compareSources,
|
||||
topSteps,
|
||||
type ColonyNextSteps,
|
||||
type NextStep,
|
||||
type StepSource,
|
||||
} from './next-step-ranking';
|
||||
|
||||
export interface AutopilotToday {
|
||||
nextStep: (NextStep & { colonyId: string; colonyCode: string }) | null;
|
||||
/** Colonies whose inspection is due today or overdue (`dueOn <= today`), not those coming up. */
|
||||
inspectionDue: {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
segment: SeasonSegmentKey;
|
||||
intervalDays: number | null;
|
||||
colonies: {
|
||||
id: string;
|
||||
code: string;
|
||||
dueOn: string;
|
||||
overdueDays: number;
|
||||
}[];
|
||||
}[];
|
||||
suggestionsOpen: number;
|
||||
seasons: {
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
segment: SeasonSegmentKey;
|
||||
phase: ColonyNextSteps['phase'];
|
||||
phaseSource: ColonyNextSteps['phaseSource'];
|
||||
}[];
|
||||
tasksVisible: boolean;
|
||||
}
|
||||
|
||||
/** Open suggestions of HIGH / URGENT priority that the caller may see. */
|
||||
export interface WarningCount {
|
||||
warnings: number;
|
||||
critical: number;
|
||||
}
|
||||
|
||||
interface ColonyComputation {
|
||||
code: string;
|
||||
apiaryId: string;
|
||||
/** Leading source of the first step, for the comparison across colonies. */
|
||||
head: StepSource | null;
|
||||
view: ColonyNextSteps;
|
||||
}
|
||||
|
||||
interface Computation {
|
||||
colonies: Map<string, ColonyComputation>;
|
||||
seasons: Map<string, SeasonState>;
|
||||
intervalDays: Readonly<
|
||||
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>
|
||||
>;
|
||||
}
|
||||
|
||||
/** A withdrawal period longer than this is not looked for. */
|
||||
const WITHDRAWAL_LOOKBACK_DAYS = 400;
|
||||
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
|
||||
const WARNING_PRIORITIES: readonly TaskPriority[] = ['HIGH', 'URGENT'];
|
||||
|
||||
/** The intent an open task stands for; null for a plain task. */
|
||||
function taskIntentOf(task: {
|
||||
sourceSuggestion: { intent: SuggestionIntent } | null;
|
||||
sourceAutomationTrigger: AutomationTriggerType | null;
|
||||
}): SuggestionIntent | null {
|
||||
return (
|
||||
task.sourceSuggestion?.intent ??
|
||||
(task.sourceAutomationTrigger === null
|
||||
? null
|
||||
: (INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger] ?? null))
|
||||
);
|
||||
}
|
||||
|
||||
function byCode(a: { code: string }, b: { code: string }): number {
|
||||
return a.code.localeCompare(b.code, 'de', { numeric: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* "The next 3 steps" of a colony (Spec 8.4, 8.5) and the day overview: what
|
||||
* the user may see of the open tasks and suggestions, ranked, merged by form
|
||||
* and cut to `maxStepsPerColony`. Reads only; a fixed number of queries for
|
||||
* any number of colonies.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NextStepsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly seasons: SeasonService,
|
||||
private readonly suggestionViews: SuggestionViewsService,
|
||||
) {}
|
||||
|
||||
async forColony(
|
||||
colonyId: string,
|
||||
userId: string,
|
||||
today: string = berlinToday(),
|
||||
): Promise<ColonyNextSteps> {
|
||||
const view = (await this.forColonies([colonyId], userId, today)).get(
|
||||
colonyId,
|
||||
);
|
||||
if (view === undefined) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Unknown colonies are left out of the map. */
|
||||
async forColonies(
|
||||
colonyIds: readonly string[],
|
||||
userId: string,
|
||||
today: string = berlinToday(),
|
||||
permissions?: ReadonlySet<string>,
|
||||
): Promise<Map<string, ColonyNextSteps>> {
|
||||
const held =
|
||||
permissions ?? (await this.permissionResolver.getPermissions(userId));
|
||||
const { colonies } = await this.compute(colonyIds, held, today);
|
||||
return new Map(
|
||||
[...colonies].map(([id, colony]) => [id, colony.view] as const),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open HIGH / URGENT suggestions per colony: the "warnings" and
|
||||
* "critical" of the colony traffic light. Only what the user may see
|
||||
* (`tasks:read` and the permission of each suggestion).
|
||||
*/
|
||||
async warningCounts(
|
||||
colonyIds: readonly string[],
|
||||
userId: string,
|
||||
permissions?: ReadonlySet<string>,
|
||||
): Promise<Map<string, WarningCount>> {
|
||||
const held =
|
||||
permissions ?? (await this.permissionResolver.getPermissions(userId));
|
||||
const counts = new Map<string, WarningCount>();
|
||||
if (!held.has('tasks:read') || colonyIds.length === 0) {
|
||||
return counts;
|
||||
}
|
||||
const groups = await this.prisma.suggestion.groupBy({
|
||||
by: ['colonyId', 'priority'],
|
||||
where: {
|
||||
AND: [
|
||||
this.suggestionViews.visibleWhere(held),
|
||||
{
|
||||
status: 'OPEN',
|
||||
colonyId: { in: [...colonyIds] },
|
||||
priority: { in: [...WARNING_PRIORITIES] },
|
||||
},
|
||||
],
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
for (const group of groups) {
|
||||
if (group.colonyId === null) {
|
||||
continue;
|
||||
}
|
||||
const current = counts.get(group.colonyId) ?? {
|
||||
warnings: 0,
|
||||
critical: 0,
|
||||
};
|
||||
counts.set(
|
||||
group.colonyId,
|
||||
group.priority === 'URGENT'
|
||||
? { ...current, critical: group._count._all }
|
||||
: { ...current, warnings: group._count._all },
|
||||
);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async today(
|
||||
userId: string,
|
||||
today: string = berlinToday(),
|
||||
): Promise<AutopilotToday> {
|
||||
const held = await this.permissionResolver.getPermissions(userId);
|
||||
const living = await this.prisma.colony.findMany({
|
||||
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 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')
|
||||
? await this.prisma.suggestion.count({
|
||||
where: {
|
||||
AND: [this.suggestionViews.visibleWhere(held), { status: 'OPEN' }],
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
const sortedApiaries = [...seasons.values()].sort((a, b) =>
|
||||
nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', {
|
||||
numeric: true,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
nextStep: this.mostUrgent(colonies, today),
|
||||
inspectionDue: sortedApiaries.flatMap((season) => {
|
||||
const due = [...colonies]
|
||||
.filter(
|
||||
([, c]) =>
|
||||
c.apiaryId === season.apiaryId &&
|
||||
c.view.inspectionDueOn !== null &&
|
||||
c.view.inspectionDueOn <= today,
|
||||
)
|
||||
.map(([id, c]) => ({
|
||||
id,
|
||||
code: c.code,
|
||||
dueOn: c.view.inspectionDueOn ?? today,
|
||||
overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today),
|
||||
}))
|
||||
.sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b));
|
||||
return due.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
apiaryId: season.apiaryId,
|
||||
apiaryName: nameOf(season.apiaryId),
|
||||
segment: season.segment,
|
||||
intervalDays:
|
||||
season.segment === 'WINTERRUHE'
|
||||
? null
|
||||
: intervalDays[season.segment],
|
||||
colonies: due,
|
||||
},
|
||||
];
|
||||
}),
|
||||
suggestionsOpen,
|
||||
seasons: sortedApiaries.map((season) => ({
|
||||
apiaryId: season.apiaryId,
|
||||
apiaryName: nameOf(season.apiaryId),
|
||||
segment: season.segment,
|
||||
phase: season.phase,
|
||||
phaseSource: season.phaseSource,
|
||||
})),
|
||||
tasksVisible: held.has('tasks:read'),
|
||||
};
|
||||
}
|
||||
|
||||
private mostUrgent(
|
||||
colonies: ReadonlyMap<string, ColonyComputation>,
|
||||
today: string,
|
||||
): AutopilotToday['nextStep'] {
|
||||
const candidates = [...colonies].flatMap(([colonyId, colony]) =>
|
||||
colony.head === null || colony.view.steps.length === 0
|
||||
? []
|
||||
: [{ colonyId, colony, head: colony.head }],
|
||||
);
|
||||
candidates.sort((a, b) => compareSources(a.head, b.head, today));
|
||||
const best = candidates.at(0);
|
||||
return best === undefined
|
||||
? null
|
||||
: {
|
||||
...best.colony.view.steps[0],
|
||||
colonyId: best.colonyId,
|
||||
colonyCode: best.colony.code,
|
||||
};
|
||||
}
|
||||
|
||||
private async compute(
|
||||
colonyIds: readonly string[],
|
||||
held: ReadonlySet<string>,
|
||||
today: string,
|
||||
): Promise<Computation> {
|
||||
const ids = [...new Set(colonyIds)];
|
||||
const rows =
|
||||
ids.length === 0
|
||||
? []
|
||||
: await this.prisma.colony.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, code: true, apiaryId: true, createdAt: true },
|
||||
});
|
||||
const [states, interval, autopilot] = await Promise.all([
|
||||
this.seasons.statesFor(
|
||||
this.prisma,
|
||||
rows.map((row) => row.apiaryId),
|
||||
today,
|
||||
),
|
||||
readRule(this.prisma, 'INSPECTION_INTERVAL'),
|
||||
readRule(this.prisma, 'SEASON_AUTOPILOT'),
|
||||
]);
|
||||
const foundIds = rows.map((row) => row.id);
|
||||
const [lastInspections, sources, withdrawals] = await Promise.all([
|
||||
this.lastInspectionDays(foundIds, held),
|
||||
this.stepSources(foundIds, held, today),
|
||||
this.withdrawalUntil(foundIds, held, today),
|
||||
]);
|
||||
const colonies = new Map<string, ColonyComputation>();
|
||||
for (const row of rows) {
|
||||
const season = states.get(row.apiaryId);
|
||||
if (season === undefined) {
|
||||
continue;
|
||||
}
|
||||
const due = interval.active
|
||||
? inspectionDueOn(
|
||||
{
|
||||
createdOn: berlinDayOf(row.createdAt),
|
||||
lastInspectedOn: lastInspections.get(row.id) ?? null,
|
||||
},
|
||||
season,
|
||||
interval.config,
|
||||
)
|
||||
: null;
|
||||
const own = sources.get(row.id) ?? [];
|
||||
const steps = topSteps(own, today, {
|
||||
limit: autopilot.config.maxStepsPerColony,
|
||||
canAccept: held.has('tasks:create'),
|
||||
});
|
||||
colonies.set(row.id, {
|
||||
code: row.code,
|
||||
apiaryId: row.apiaryId,
|
||||
head: this.headOf(own, steps),
|
||||
view: {
|
||||
colonyId: row.id,
|
||||
segment: season.segment,
|
||||
phase: season.phase,
|
||||
phaseSource: season.phaseSource,
|
||||
inspectionDueOn:
|
||||
due !== null && held.has('inspections:read') ? due.dueOn : null,
|
||||
withdrawalUntil: withdrawals.get(row.id) ?? null,
|
||||
steps,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
colonies,
|
||||
seasons: states,
|
||||
intervalDays: interval.config.intervalDays,
|
||||
};
|
||||
}
|
||||
|
||||
/** The source that stands for the first step (for the cross-colony order). */
|
||||
private headOf(
|
||||
own: readonly StepSource[],
|
||||
steps: readonly NextStep[],
|
||||
): StepSource | null {
|
||||
const first = steps.at(0);
|
||||
if (first === undefined) {
|
||||
return null;
|
||||
}
|
||||
const byId = new Map(own.map((source) => [source.id, source] as const));
|
||||
const id =
|
||||
first.suggestionIds.at(0) ??
|
||||
(first.action.type === 'OPEN_TASK' ? first.action.taskId : null);
|
||||
return id === null ? null : (byId.get(id) ?? null);
|
||||
}
|
||||
|
||||
/** Last inspection day per colony; nothing without `inspections:read`. */
|
||||
private async lastInspectionDays(
|
||||
colonyIds: readonly string[],
|
||||
held: ReadonlySet<string>,
|
||||
): Promise<Map<string, string>> {
|
||||
const days = new Map<string, string>();
|
||||
if (!held.has('inspections:read') || colonyIds.length === 0) {
|
||||
return days;
|
||||
}
|
||||
const groups = await this.prisma.inspection.groupBy({
|
||||
by: ['colonyId'],
|
||||
where: { colonyId: { in: [...colonyIds] } },
|
||||
_max: { date: true },
|
||||
});
|
||||
for (const group of groups) {
|
||||
if (group._max.date) {
|
||||
days.set(group.colonyId, berlinDayOf(group._max.date));
|
||||
}
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/** The youngest running withdrawal period per colony (treatments:read). */
|
||||
private async withdrawalUntil(
|
||||
colonyIds: readonly string[],
|
||||
held: ReadonlySet<string>,
|
||||
today: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const until = new Map<string, string>();
|
||||
if (!held.has('treatments:read') || colonyIds.length === 0) {
|
||||
return until;
|
||||
}
|
||||
const cutoff = dayToDate(addDays(today, -WITHDRAWAL_LOOKBACK_DAYS));
|
||||
const treatments = await this.prisma.treatment.findMany({
|
||||
where: {
|
||||
colonyId: { in: [...colonyIds] },
|
||||
withdrawalPeriodDays: { not: null },
|
||||
OR: [{ endDate: { gte: cutoff } }, { date: { gte: cutoff } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
colonyId: true,
|
||||
product: true,
|
||||
date: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
withdrawalPeriodDays: true,
|
||||
},
|
||||
});
|
||||
for (const treatment of treatments) {
|
||||
const window = withdrawalWindowOf(treatment);
|
||||
if (window === null || !isInWindow(window, today)) {
|
||||
continue;
|
||||
}
|
||||
const current = until.get(treatment.colonyId);
|
||||
if (current === undefined || window.untilDay > current) {
|
||||
until.set(treatment.colonyId, window.untilDay);
|
||||
}
|
||||
}
|
||||
return until;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open suggestions (visible ones, and only with `tasks:read`) and open
|
||||
* tasks (`tasks:read`) of the colonies, as rankable sources.
|
||||
*/
|
||||
private async stepSources(
|
||||
colonyIds: readonly string[],
|
||||
held: ReadonlySet<string>,
|
||||
today: string,
|
||||
): Promise<Map<string, StepSource[]>> {
|
||||
const sources = new Map<string, StepSource[]>();
|
||||
if (!held.has('tasks:read') || colonyIds.length === 0) {
|
||||
return sources;
|
||||
}
|
||||
const [suggestions, tasks] = await Promise.all([
|
||||
this.prisma.suggestion.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
this.suggestionViews.visibleWhere(held),
|
||||
{ status: 'OPEN', colonyId: { in: [...colonyIds] } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
ruleKey: true,
|
||||
reasonCode: true,
|
||||
evidence: true,
|
||||
intent: true,
|
||||
mode: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
dueDate: true,
|
||||
createdAt: true,
|
||||
colonyId: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.task.findMany({
|
||||
where: {
|
||||
status: { in: [...OPEN_TASK_STATUSES] },
|
||||
relatedType: 'COLONY',
|
||||
relatedId: { in: [...colonyIds] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
dueDate: true,
|
||||
createdAt: true,
|
||||
relatedId: true,
|
||||
sourceAutomationTrigger: true,
|
||||
sourceSuggestion: { select: { intent: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const [lookup, templates] = await Promise.all([
|
||||
buildEvidenceLookup(
|
||||
this.prisma,
|
||||
suggestions.map((row) => row.evidence as SuggestionEvidence),
|
||||
),
|
||||
this.titleTemplates(suggestions.map((row) => row.ruleKey)),
|
||||
]);
|
||||
const add = (colonyId: string, source: StepSource): void => {
|
||||
sources.set(colonyId, [...(sources.get(colonyId) ?? []), source]);
|
||||
};
|
||||
// One open thing per intent and colony: an open task of the same intent
|
||||
// (accepted suggestion or legacy rule task) hides an open suggestion.
|
||||
const openTaskIntents = new Set(
|
||||
tasks.flatMap((row) => {
|
||||
const intent = taskIntentOf(row);
|
||||
return row.relatedId === null || intent === null
|
||||
? []
|
||||
: [`${row.relatedId}|${intent}`];
|
||||
}),
|
||||
);
|
||||
for (const row of suggestions) {
|
||||
if (
|
||||
row.colonyId === null ||
|
||||
openTaskIntents.has(`${row.colonyId}|${row.intent}`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const text = renderSuggestion(
|
||||
row,
|
||||
lookup,
|
||||
today,
|
||||
templates.get(row.ruleKey),
|
||||
);
|
||||
add(row.colonyId, {
|
||||
kind: 'SUGGESTION',
|
||||
id: row.id,
|
||||
intent: row.intent,
|
||||
mode: row.mode,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate),
|
||||
createdAt: row.createdAt,
|
||||
title: text.title,
|
||||
reasons: text.reasons,
|
||||
colonyId: row.colonyId,
|
||||
});
|
||||
}
|
||||
for (const row of tasks) {
|
||||
if (row.relatedId === null) {
|
||||
continue;
|
||||
}
|
||||
add(row.relatedId, {
|
||||
kind: 'TASK',
|
||||
id: row.id,
|
||||
intent: taskIntentOf(row),
|
||||
mode: null,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate),
|
||||
createdAt: row.createdAt,
|
||||
title: row.title,
|
||||
reasons: [],
|
||||
colonyId: row.relatedId,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** The `actionTemplate` of each rule replaces the title of its suggestions. */
|
||||
private async titleTemplates(
|
||||
ruleKeys: readonly AutomationTriggerType[],
|
||||
): Promise<Map<AutomationTriggerType, string | null>> {
|
||||
if (ruleKeys.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rules = await this.prisma.automationRule.findMany({
|
||||
where: { triggerType: { in: [...new Set(ruleKeys)] } },
|
||||
select: { triggerType: true, actionTemplate: true },
|
||||
});
|
||||
return new Map(
|
||||
rules.map((rule) => [rule.triggerType, rule.actionTemplate] as const),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user