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
@@ -1,8 +1,9 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
|
||||
import { NextStepsService } from '../autopilot/next-steps.service';
|
||||
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
ColonyOverview,
|
||||
ColonyOverviewRow,
|
||||
@@ -18,6 +19,7 @@ interface FieldData {
|
||||
colonies: PackColony[];
|
||||
permissions: ReadonlySet<string>;
|
||||
overview: ColonyOverview;
|
||||
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +32,7 @@ export class ApiaryFieldService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
private readonly nextSteps: NextStepsService,
|
||||
) {}
|
||||
|
||||
async colonyOverview(
|
||||
@@ -40,10 +43,8 @@ export class ApiaryFieldService {
|
||||
}
|
||||
|
||||
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
|
||||
const { apiary, colonies, permissions, overview } = await this.load(
|
||||
apiaryId,
|
||||
userId,
|
||||
);
|
||||
const { apiary, colonies, permissions, overview, nextSteps } =
|
||||
await this.load(apiaryId, userId);
|
||||
const [queens, inspections, tasks] = await Promise.all([
|
||||
permissions.has('queens:read') ? this.loadQueens(colonies) : null,
|
||||
permissions.has('inspections:read')
|
||||
@@ -60,6 +61,12 @@ export class ApiaryFieldService {
|
||||
queens,
|
||||
inspections,
|
||||
tasks,
|
||||
nextSteps: Object.fromEntries(
|
||||
colonies.map((colony) => [
|
||||
colony.id,
|
||||
nextSteps.get(colony.id)?.steps ?? [],
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,9 +103,11 @@ export class ApiaryFieldService {
|
||||
a.code.localeCompare(b.code, 'de', { numeric: true }),
|
||||
);
|
||||
const inspectionsVisible = permissions.has('inspections:read');
|
||||
const [lastInspections, warnings] = await Promise.all([
|
||||
const colonyIds = colonies.map((colony) => colony.id);
|
||||
const [lastInspections, warnings, nextSteps] = await Promise.all([
|
||||
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
|
||||
this.warningCounts(colonies, userId, permissions),
|
||||
this.nextSteps.warningCounts(colonyIds, userId, permissions),
|
||||
this.nextSteps.forColonies(colonyIds, userId, undefined, permissions),
|
||||
]);
|
||||
const rows = colonies.map((colony): ColonyOverviewRow => ({
|
||||
id: colony.id,
|
||||
@@ -110,14 +119,18 @@ export class ApiaryFieldService {
|
||||
feedStatus: colony.feedStatus,
|
||||
healthStatus: colony.healthStatus,
|
||||
lastInspectionDate: lastInspections?.get(colony.id) ?? null,
|
||||
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0,
|
||||
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0,
|
||||
inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
|
||||
segment: nextSteps.get(colony.id)?.segment ?? null,
|
||||
nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null,
|
||||
openWarnings: warnings.get(colony.id)?.warnings ?? 0,
|
||||
openCritical: warnings.get(colony.id)?.critical ?? 0,
|
||||
}));
|
||||
return {
|
||||
apiary,
|
||||
colonies,
|
||||
permissions,
|
||||
overview: { apiaryId, inspectionsVisible, rows },
|
||||
nextSteps,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,42 +151,6 @@ export class ApiaryFieldService {
|
||||
return dates;
|
||||
}
|
||||
|
||||
private async warningCounts(
|
||||
colonies: PackColony[],
|
||||
userId: string,
|
||||
held: ReadonlySet<string>,
|
||||
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
|
||||
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
|
||||
if (colonies.length === 0) {
|
||||
return counts;
|
||||
}
|
||||
// Exactly the rule of NotificationsService.findOwn, decided in SQL: a
|
||||
// row counts only when every key in requiredPermissions is held (subset
|
||||
// test, so a key outside the catalog hides the row too).
|
||||
const groups = await this.prisma.$queryRaw<
|
||||
{ relatedId: string; priority: string; count: number }[]
|
||||
>(Prisma.sql`
|
||||
SELECT "relatedId", "priority"::text AS "priority", count(*)::int AS "count"
|
||||
FROM "Notification"
|
||||
WHERE "relatedType" = 'COLONY'
|
||||
AND "relatedId" IN (${Prisma.join(colonies.map((colony) => colony.id))})
|
||||
AND "read" = false
|
||||
AND "priority"::text IN ('WARNUNG', 'KRITISCH')
|
||||
AND "userId" = ${userId}
|
||||
AND "requiredPermissions" <@ ${[...held]}::text[]
|
||||
GROUP BY "relatedId", "priority"
|
||||
`);
|
||||
for (const group of groups) {
|
||||
if (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') {
|
||||
counts.set(group.relatedId, {
|
||||
...counts.get(group.relatedId),
|
||||
[group.priority]: group.count,
|
||||
});
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
|
||||
const queenIds = colonies.flatMap((colony) =>
|
||||
colony.currentQueenId ? [colony.currentQueenId] : [],
|
||||
|
||||
Reference in new issue
Block a user