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

@@ -0,0 +1,374 @@
import type { DbClient } from '../prisma/db-client';
import type { Prisma } from '../../generated/prisma';
import type { NotificationsService } from '../notifications/notifications.service';
import {
WithdrawalCheckService,
type WithdrawalConflict,
} from './withdrawal-check.service';
interface TreatmentRow {
id: string;
colonyId: string;
product: string;
date: Date;
startDate: Date | null;
endDate: Date | null;
withdrawalPeriodDays: number | null;
}
const treatment = (overrides: Partial<TreatmentRow>): TreatmentRow => ({
id: 't1',
colonyId: 'c1',
product: 'Oxuvar',
date: new Date('2026-07-31T00:00:00.000Z'),
startDate: null,
endDate: null,
withdrawalPeriodDays: 20,
...overrides,
});
const conflict = (
overrides: Partial<WithdrawalConflict> = {},
): WithdrawalConflict => ({
harvestId: 'h1',
harvestedOn: '2026-08-10',
colonyId: 'c1',
treatmentId: 't1',
product: 'Oxuvar',
untilDay: '2026-08-20',
...overrides,
});
describe('WithdrawalCheckService', () => {
let db: {
harvest: { findMany: jest.Mock };
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
colony: { findMany: jest.Mock };
honeyBatch: { findUnique: jest.Mock };
bottlingBatch: { findMany: jest.Mock };
};
let notifications: { recordForPermission: jest.Mock };
let service: WithdrawalCheckService;
beforeEach(() => {
db = {
harvest: { findMany: jest.fn().mockResolvedValue([]) },
treatment: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
},
colony: {
findMany: jest.fn().mockResolvedValue([{ id: 'c1', code: 'B3' }]),
},
honeyBatch: { findUnique: jest.fn() },
bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) },
};
notifications = { recordForPermission: jest.fn().mockResolvedValue([]) };
service = new WithdrawalCheckService(
notifications as unknown as NotificationsService,
);
});
const asDb = () => db as unknown as DbClient;
const harvestOn = (id: string, day: string, colonyIds: string[]) => ({
id,
date: new Date(`${day}T00:00:00.000Z`),
colonies: colonyIds.map((colonyId) => ({ colonyId })),
});
describe('conflictsForHarvests', () => {
it('finds the treatment whose window includes the harvest date per colony', async () => {
db.harvest.findMany.mockResolvedValue([
harvestOn('h1', '2026-08-10', ['c1', 'c2']),
]);
db.treatment.findMany.mockResolvedValue([
treatment({ id: 't1', colonyId: 'c1' }),
// Window ended before the harvest: no conflict for c2.
treatment({
id: 't2',
colonyId: 'c2',
date: new Date('2026-06-01T00:00:00.000Z'),
withdrawalPeriodDays: 10,
}),
]);
const conflicts = await service.conflictsForHarvests(asDb(), ['h1']);
expect(conflicts).toEqual([conflict()]);
});
it('treats the first harvestable day as free and the treatment day as covered', async () => {
// t1: 2026-07-31 + 20 days = first harvestable day 2026-08-20.
db.harvest.findMany.mockResolvedValue([
harvestOn('h-start', '2026-07-31', ['c1']),
harvestOn('h-last', '2026-08-19', ['c1']),
harvestOn('h-free', '2026-08-20', ['c1']),
harvestOn('h-before', '2026-07-30', ['c1']),
]);
db.treatment.findMany.mockResolvedValue([treatment({})]);
const conflicts = await service.conflictsForHarvests(asDb(), [
'h-start',
'h-last',
'h-free',
'h-before',
]);
expect(conflicts.map((c) => c.harvestId)).toEqual(['h-start', 'h-last']);
});
it('starts the window at startDate when it lies before the treatment date', async () => {
db.harvest.findMany.mockResolvedValue([
harvestOn('h1', '2026-07-25', ['c1']),
]);
db.treatment.findMany.mockResolvedValue([
treatment({ startDate: new Date('2026-07-24T00:00:00.000Z') }),
]);
const conflicts = await service.conflictsForHarvests(asDb(), ['h1']);
expect(conflicts).toHaveLength(1);
});
it('reports one conflict per harvest and colony, the one that lasts longest', async () => {
db.harvest.findMany.mockResolvedValue([
harvestOn('h1', '2026-08-10', ['c1']),
]);
db.treatment.findMany.mockResolvedValue([
treatment({ id: 't-short', withdrawalPeriodDays: 12 }),
treatment({
id: 't-long',
withdrawalPeriodDays: 30,
product: 'Apivar',
}),
]);
const conflicts = await service.conflictsForHarvests(asDb(), ['h1']);
expect(conflicts).toEqual([
conflict({
treatmentId: 't-long',
product: 'Apivar',
untilDay: '2026-08-30',
}),
]);
});
it('ignores treatments without a withdrawal period and colonies without any', async () => {
db.harvest.findMany.mockResolvedValue([
harvestOn('h1', '2026-08-10', ['c1']),
harvestOn('h2', '2026-08-10', []),
]);
db.treatment.findMany.mockResolvedValue([
treatment({ withdrawalPeriodDays: null }),
]);
expect(await service.conflictsForHarvests(asDb(), ['h1', 'h2'])).toEqual(
[],
);
});
it('answers at once without ids and does not read anything', async () => {
expect(await service.conflictsForHarvests(asDb(), [])).toEqual([]);
expect(db.harvest.findMany).not.toHaveBeenCalled();
});
it('compares the Berlin calendar day of the harvest', async () => {
// 2026-08-19T22:30Z is 00:30 on 2026-08-20 in Berlin: already free.
db.harvest.findMany.mockResolvedValue([
{
id: 'h1',
date: new Date('2026-08-19T22:30:00.000Z'),
colonies: [{ colonyId: 'c1' }],
},
]);
db.treatment.findMany.mockResolvedValue([treatment({})]);
expect(await service.conflictsForHarvests(asDb(), ['h1'])).toEqual([]);
});
});
describe('conflictsForTreatment', () => {
it('finds the harvests of the colony inside the window, entered before the treatment', async () => {
db.treatment.findUnique.mockResolvedValue(treatment({}));
// Candidates (date range) first, then the harvests re-read for the check.
db.harvest.findMany
.mockResolvedValueOnce([
{ id: 'h-in', date: new Date('2026-08-10T00:00:00.000Z') },
{ id: 'h-free', date: new Date('2026-08-20T00:00:00.000Z') },
{ id: 'h-before', date: new Date('2026-07-30T00:00:00.000Z') },
])
.mockResolvedValueOnce([harvestOn('h-in', '2026-08-10', ['c1', 'c2'])]);
db.treatment.findMany.mockResolvedValue([treatment({})]);
const conflicts = await service.conflictsForTreatment(asDb(), 't1');
expect(db.harvest.findMany).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: {
colonies: { some: { colonyId: 'c1' } },
date: {
gte: new Date('2026-07-30T00:00:00.000Z'),
lt: new Date('2026-08-21T00:00:00.000Z'),
},
},
}),
);
expect(db.harvest.findMany).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ where: { id: { in: ['h-in'] } } }),
);
// Only the colony of the treatment.
expect(conflicts).toEqual([conflict({ harvestId: 'h-in' })]);
});
it('answers nothing without a withdrawal span or for an unknown treatment', async () => {
db.treatment.findUnique.mockResolvedValueOnce(null);
expect(await service.conflictsForTreatment(asDb(), 'x')).toEqual([]);
db.treatment.findUnique.mockResolvedValueOnce(
treatment({ withdrawalPeriodDays: null }),
);
expect(await service.conflictsForTreatment(asDb(), 't1')).toEqual([]);
expect(db.harvest.findMany).not.toHaveBeenCalled();
});
});
describe('conflictsForHoneyBatch', () => {
it('follows batch, extraction and harvests', async () => {
db.honeyBatch.findUnique.mockResolvedValue({
extraction: { harvests: [{ harvestId: 'h1' }, { harvestId: 'h2' }] },
});
db.harvest.findMany.mockResolvedValue([
harvestOn('h1', '2026-08-10', ['c1']),
]);
db.treatment.findMany.mockResolvedValue([treatment({})]);
const conflicts = await service.conflictsForHoneyBatch(asDb(), 'b1');
expect(db.harvest.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ['h1', 'h2'] } },
}),
);
expect(conflicts).toEqual([conflict()]);
});
it('answers nothing for an unknown batch', async () => {
db.honeyBatch.findUnique.mockResolvedValue(null);
expect(await service.conflictsForHoneyBatch(asDb(), 'nope')).toEqual([]);
});
});
describe('warningsFor', () => {
it('words the warning with colony code, product and the end day', async () => {
const warnings = await service.warningsFor(asDb(), [conflict()]);
expect(warnings).toEqual([
{
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.',
refs: [
{ type: 'Colony', id: 'c1' },
{ type: 'Treatment', id: 't1' },
{ type: 'Harvest', id: 'h1' },
],
},
]);
});
it('answers nothing without reading when there is no conflict', async () => {
expect(await service.warningsFor(asDb(), [])).toEqual([]);
expect(db.colony.findMany).not.toHaveBeenCalled();
});
});
describe('notifyConflictsIn', () => {
const tx = () => db as unknown as Prisma.TransactionClient;
it('notifies readers of harvests and treatments once per harvest and colony', async () => {
notifications.recordForPermission.mockResolvedValue([{ id: 'n1' }]);
const count = await service.notifyConflictsIn(tx(), [conflict()]);
expect(count).toBe(1);
expect(notifications.recordForPermission).toHaveBeenCalledWith(
['harvests:read', 'treatments:read'],
{
priority: 'WARNUNG',
category: 'WITHDRAWAL',
message:
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026.',
relatedType: 'Harvest',
relatedId: 'h1',
link: '/harvests/h1',
dedupeKeyPrefix: 'withdrawal-conflict:h1:c1',
},
db,
);
});
it('names the bottling lots when the harvest was already bottled', async () => {
db.bottlingBatch.findMany.mockResolvedValue([
{
lotNumber: 'L-2',
honeyBatch: { extraction: { harvests: [{ harvestId: 'h1' }] } },
},
{
lotNumber: 'L-1',
honeyBatch: {
extraction: {
harvests: [{ harvestId: 'h1' }, { harvestId: 'h9' }],
},
},
},
{
lotNumber: 'L-other',
honeyBatch: { extraction: { harvests: [{ harvestId: 'h9' }] } },
},
]);
await service.notifyConflictsIn(tx(), [conflict()]);
const [, input] = notifications.recordForPermission.mock.calls[0] as [
unknown,
{ message: string; dedupeKeyPrefix: string },
];
expect(input.message).toBe(
'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026. Bereits abgefüllt: Los L-1, L-2.',
);
// Same key as without the lots: one notification per harvest and colony.
expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1');
});
it('counts only the rows that were really written', async () => {
notifications.recordForPermission
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'n1' }, { id: 'n2' }]);
const count = await service.notifyConflictsIn(tx(), [
conflict(),
conflict({ harvestId: 'h2' }),
]);
expect(count).toBe(2);
});
it('does nothing without conflicts', async () => {
expect(await service.notifyConflictsIn(tx(), [])).toBe(0);
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
it('refuses the root client', async () => {
const root = {
$connect: jest.fn(),
} as unknown as Prisma.TransactionClient;
await expect(
service.notifyConflictsIn(root, [conflict()]),
).rejects.toThrow('needs the transaction client');
});
});
});