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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

+114 -1
View File
@@ -89,6 +89,9 @@ describe('Apiaries (e2e)', () => {
await prisma.task.deleteMany({
where: { title: { startsWith: 'field-e2e-' } },
});
await prisma.suggestion.deleteMany({
where: { colony: { apiaryId: fieldApiaryId } },
});
await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } });
await prisma.apiary.delete({ where: { id: fieldApiaryId } });
}
@@ -210,7 +213,14 @@ describe('Apiaries (e2e)', () => {
describe('apiary field endpoints', () => {
interface OverviewBody {
apiaryId: string;
rows: { code: string }[];
rows: {
code: string;
inspectionDueOn: string | null;
segment: string;
nextStepTitle: string | null;
openWarnings: number;
openCritical: number;
}[];
}
interface PackBody {
version: number;
@@ -285,6 +295,109 @@ describe('Apiaries (e2e)', () => {
]);
});
it('colony-overview carries inspectionDueOn, segment and nextStepTitle; warnings come from suggestions', async () => {
const permissions = [
'colonies:read',
'tasks:read',
'inspections:read',
'varroa:read',
];
const role = await prisma.role.create({
data: {
key: `FIELD_OVERVIEW_${Date.now()}`,
name: 'Feld-Übersicht',
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
const email = `apiaries-e2e-overview-${Date.now()}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Overview',
roleId: role.id,
},
});
const colony = await prisma.colony.findFirstOrThrow({
where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-10-' } },
});
const base = {
ruleKey: 'VARROA_THRESHOLD' as const,
source: 'RULE' as const,
mode: 'PLAN' as const,
intent: 'VARROA_TREAT' as const,
relatedType: 'COLONY' as const,
relatedId: colony.id,
colonyId: colony.id,
reasonCode: 'FIELD_E2E',
evidence: { colonyId: colony.id },
};
try {
await prisma.suggestion.createMany({
data: [
{ ...base, priority: 'HIGH', requiredPermission: 'varroa:read' },
{
...base,
intent: 'HONEY_SUPER',
priority: 'URGENT',
requiredPermission: 'inspections:read',
},
{
...base,
intent: 'FEED',
priority: 'URGENT',
requiredPermission: 'feedings:read',
},
{ ...base, intent: 'INSPECT', priority: 'NORMAL' },
],
});
const token = (await login(app, email, password)).accessToken;
const response = await request(app.getHttpServer())
.get(`/api/v1/apiaries/${fieldApiaryId}/colony-overview`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
const rows = (response.body as OverviewBody).rows;
const row = rows.find((item) => item.code.startsWith('V-10-'));
// HIGH (varroa:read) and URGENT (inspections:read) are visible, the
// URGENT one that needs feedings:read is not.
expect(row).toMatchObject({ openWarnings: 1, openCritical: 1 });
expect(row?.nextStepTitle).toEqual(expect.any(String));
expect(row?.segment).toEqual(expect.any(String));
expect(row).toHaveProperty('inspectionDueOn');
if (row?.segment === 'WINTERRUHE') {
expect(row.inspectionDueOn).toBeNull();
} else {
expect(row?.inspectionDueOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
const quiet = rows.find((item) => item.code.startsWith('V-2-'));
expect(quiet).toMatchObject({
openWarnings: 0,
openCritical: 0,
nextStepTitle: null,
});
const pack = await request(app.getHttpServer())
.get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
const steps = (pack.body as { nextSteps?: Record<string, unknown[]> })
.nextSteps;
expect(steps?.[colony.id]?.length).toBeGreaterThan(0);
expect(steps?.[colony.id]?.length).toBeLessThanOrEqual(3);
} finally {
await prisma.suggestion.deleteMany({ where: { colonyId: colony.id } });
await prisma.refreshToken.deleteMany({ where: { userId: user.id } });
await prisma.user.delete({ where: { id: user.id } });
await prisma.role.delete({ where: { id: role.id } });
}
});
it('offline-pack contains the open tasks of the apiary and its colonies', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`)