- Honey batch create omits an empty batch number (was 400); product form
labels the legacy total weight, detail shows jar weight and shelf life;
bottling hints next to their fields, values cleared on product change,
post-save water-content wording; withdraw reason placeholder not
selectable; honey batch actions offline-disabled with reason, 44 px.
- Offline signal: one slow request no longer flips the app offline; two
consecutive timeouts do, any HTTP answer clears it, aborts never count.
- Phenology sheet as a side card on desktop with focus return; confirm
dialogs as alertdialog; step-up texts in du-form ("Das Passwort stimmt
nicht."); backup import gives its disabled reason; missing event labels
plus a guard test; profile buttons 44 px.
- GET /phenology/state returns intervalDays via the shared
inspectionIntervalOf (autopilot, digest, season state use one source).
- T6 plan aligned with the code after T4, plus owner decisions of
2026-10-15 (Berlin day for restrictions, COMPLIANCE category, override
without step-up).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
632 lines
19 KiB
TypeScript
632 lines
19 KiB
TypeScript
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,
|
|
inspectionIntervalOf,
|
|
} 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: this.dueInspections(
|
|
colonies,
|
|
sortedApiaries,
|
|
intervalDays,
|
|
nameOf,
|
|
today,
|
|
),
|
|
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'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Only the due inspections of all living colonies, whatever the reader
|
|
* may see (the caller filters by right): the digest's input.
|
|
*/
|
|
async inspectionDueAll(
|
|
today: string = berlinToday(),
|
|
): Promise<AutopilotToday['inspectionDue']> {
|
|
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),
|
|
new Set(['inspections:read']),
|
|
today,
|
|
);
|
|
const apiaries = await this.prisma.apiary.findMany({
|
|
where: { id: { in: [...seasons.keys()] } },
|
|
select: { id: true, name: true },
|
|
});
|
|
const names = new Map(apiaries.map((a) => [a.id, a.name] as const));
|
|
const nameOf = (apiaryId: string): string => names.get(apiaryId) ?? '';
|
|
const sorted = [...seasons.values()].sort((a, b) =>
|
|
nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', {
|
|
numeric: true,
|
|
}),
|
|
);
|
|
return this.dueInspections(colonies, sorted, intervalDays, nameOf, today);
|
|
}
|
|
|
|
private dueInspections(
|
|
colonies: ReadonlyMap<string, ColonyComputation>,
|
|
sortedSeasons: readonly SeasonState[],
|
|
intervalDays: Computation['intervalDays'],
|
|
nameOf: (apiaryId: string) => string,
|
|
today: string,
|
|
): AutopilotToday['inspectionDue'] {
|
|
return sortedSeasons.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: inspectionIntervalOf(season.segment, intervalDays),
|
|
colonies: due,
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
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),
|
|
);
|
|
}
|
|
}
|