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,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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user