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,303 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type {
|
||||
AnyDomainEventEnvelope,
|
||||
DomainEventType,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import type { NotificationsService } from '../notifications/notifications.service';
|
||||
import type {
|
||||
WithdrawalCheckService,
|
||||
WithdrawalConflict,
|
||||
} from './withdrawal-check.service';
|
||||
import { WithdrawalEventHandlers } from './withdrawal.handlers';
|
||||
|
||||
const conflict: WithdrawalConflict = {
|
||||
harvestId: 'h1',
|
||||
harvestedOn: '2026-08-10',
|
||||
colonyId: 'c1',
|
||||
treatmentId: 't1',
|
||||
product: 'Oxuvar',
|
||||
untilDay: '2026-08-20',
|
||||
};
|
||||
|
||||
const envelope = <T extends DomainEventType>(
|
||||
type: T,
|
||||
aggregateId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope<T>;
|
||||
|
||||
const treatmentRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 't1',
|
||||
colonyId: 'c1',
|
||||
product: 'Oxuvar',
|
||||
date: new Date('2026-07-31T00:00:00.000Z'),
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
withdrawalPeriodDays: 20,
|
||||
colony: { code: 'B3', status: 'ACTIVE' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('WithdrawalEventHandlers', () => {
|
||||
let check: {
|
||||
conflictsForHarvests: jest.Mock;
|
||||
conflictsForHoneyBatch: jest.Mock;
|
||||
conflictsForTreatment: jest.Mock;
|
||||
notifyConflictsIn: jest.Mock;
|
||||
};
|
||||
let notifications: { recordForPermission: jest.Mock };
|
||||
let tx: {
|
||||
treatment: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
automationRule: { findUnique: jest.Mock };
|
||||
};
|
||||
let handlers: WithdrawalEventHandlers;
|
||||
|
||||
beforeEach(() => {
|
||||
check = {
|
||||
conflictsForHarvests: jest.fn().mockResolvedValue([conflict]),
|
||||
conflictsForHoneyBatch: jest.fn().mockResolvedValue([conflict]),
|
||||
conflictsForTreatment: jest.fn().mockResolvedValue([conflict]),
|
||||
notifyConflictsIn: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
notifications = { recordForPermission: jest.fn().mockResolvedValue([]) };
|
||||
tx = {
|
||||
treatment: {
|
||||
findUnique: jest.fn().mockResolvedValue(treatmentRow()),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
};
|
||||
handlers = new WithdrawalEventHandlers(
|
||||
check as unknown as WithdrawalCheckService,
|
||||
notifications as unknown as NotificationsService,
|
||||
);
|
||||
});
|
||||
|
||||
const asTx = () => tx as unknown as Prisma.TransactionClient;
|
||||
|
||||
it('registers the three handlers under their ids', () => {
|
||||
const entries = (method: keyof WithdrawalEventHandlers) =>
|
||||
domainEventHandlerMetadata(
|
||||
Object.getOwnPropertyDescriptor(
|
||||
WithdrawalEventHandlers.prototype,
|
||||
method,
|
||||
)?.value as object,
|
||||
);
|
||||
|
||||
expect(entries('harvestCheck')).toEqual([
|
||||
{
|
||||
types: ['HarvestRecorded', 'HarvestCorrected'],
|
||||
handlerId: 'withdrawal.harvest-check',
|
||||
},
|
||||
]);
|
||||
expect(entries('bottlingCheck')).toEqual([
|
||||
{
|
||||
types: ['BatchBottled', 'BottlingCorrected'],
|
||||
handlerId: 'withdrawal.bottling-check',
|
||||
},
|
||||
]);
|
||||
expect(entries('treatmentCheck')).toEqual([
|
||||
{
|
||||
types: ['TreatmentRecorded', 'TreatmentCorrected'],
|
||||
handlerId: 'withdrawal.treatment-check',
|
||||
},
|
||||
]);
|
||||
expect(entries('withdrawalEnded')).toEqual([
|
||||
{
|
||||
types: ['WithdrawalEnded'],
|
||||
handlerId: 'notifications.withdrawal-ended',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('harvest check', () => {
|
||||
it.each(['HarvestRecorded', 'HarvestCorrected'] as const)(
|
||||
'notifies the conflicts of the harvest on %s',
|
||||
async (type) => {
|
||||
await handlers.harvestCheck(
|
||||
envelope(type, 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(check.conflictsForHarvests).toHaveBeenCalledWith(tx, ['h1']);
|
||||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('treatment check', () => {
|
||||
it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)(
|
||||
'notifies harvests that already lie in the window of the treatment on %s',
|
||||
async (type) => {
|
||||
await handlers.treatmentCheck(
|
||||
envelope(type, 't1', { colonyId: 'c1' }),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(check.conflictsForTreatment).toHaveBeenCalledWith(tx, 't1');
|
||||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('bottling check', () => {
|
||||
it('checks the harvests behind the honey batch on BatchBottled', async () => {
|
||||
await handlers.bottlingCheck(
|
||||
envelope('BatchBottled', 'bb1', { honeyBatchId: 'hb1' }),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(check.conflictsForHoneyBatch).toHaveBeenCalledWith(tx, 'hb1');
|
||||
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
|
||||
});
|
||||
|
||||
it('checks on a corrected bottling too', async () => {
|
||||
await handlers.bottlingCheck(
|
||||
envelope('BottlingCorrected', 'bb1', {
|
||||
honeyBatchId: 'hb1',
|
||||
removed: false,
|
||||
}),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(check.notifyConflictsIn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing for a removed bottling', async () => {
|
||||
await handlers.bottlingCheck(
|
||||
envelope('BottlingCorrected', 'bb1', {
|
||||
honeyBatchId: 'hb1',
|
||||
removed: true,
|
||||
}),
|
||||
asTx(),
|
||||
);
|
||||
|
||||
expect(check.conflictsForHoneyBatch).not.toHaveBeenCalled();
|
||||
expect(check.notifyConflictsIn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withdrawal ended', () => {
|
||||
const ended = () =>
|
||||
envelope('WithdrawalEnded', 't1', {
|
||||
colonyId: 'c1',
|
||||
endedOn: '2026-08-20',
|
||||
});
|
||||
|
||||
it('tells readiness to harvest once, with colony, product and end day', async () => {
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
expect(notifications.recordForPermission).toHaveBeenCalledWith(
|
||||
['harvests:create', 'treatments:read'],
|
||||
{
|
||||
priority: 'INFO',
|
||||
category: 'WITHDRAWAL',
|
||||
message:
|
||||
'B3 ist wieder erntefähig. Wartezeit nach Oxuvar endete am 20.08.2026.',
|
||||
relatedType: 'Colony',
|
||||
relatedId: 'c1',
|
||||
link: '/colonies/c1',
|
||||
dedupeKeyPrefix: 'withdrawal-ended:t1:2026-08-20',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('stays quiet while another window of the colony still runs', async () => {
|
||||
tx.treatment.findMany.mockResolvedValue([
|
||||
treatmentRow({
|
||||
id: 't2',
|
||||
date: new Date('2026-08-10T00:00:00.000Z'),
|
||||
withdrawalPeriodDays: 14,
|
||||
}),
|
||||
]);
|
||||
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a window of another treatment that has ended or not begun', async () => {
|
||||
tx.treatment.findMany.mockResolvedValue([
|
||||
// Ended on 2026-08-20 itself: the day is free again.
|
||||
treatmentRow({
|
||||
id: 't2',
|
||||
date: new Date('2026-07-01T00:00:00.000Z'),
|
||||
withdrawalPeriodDays: 50,
|
||||
}),
|
||||
// Starts after the end day.
|
||||
treatmentRow({
|
||||
id: 't3',
|
||||
date: new Date('2026-08-21T00:00:00.000Z'),
|
||||
withdrawalPeriodDays: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads the other treatments of the colony without the ended one', async () => {
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
expect(tx.treatment.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
colonyId: 'c1',
|
||||
id: { not: 't1' },
|
||||
withdrawalPeriodDays: { not: null },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('is silent when the rule is switched off', async () => {
|
||||
tx.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'r1',
|
||||
active: false,
|
||||
config: { notify: true },
|
||||
actionTemplate: null,
|
||||
});
|
||||
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is silent when notify is off', async () => {
|
||||
tx.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'r1',
|
||||
active: true,
|
||||
config: { notify: false },
|
||||
actionTemplate: null,
|
||||
});
|
||||
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is silent for a colony that no longer exists as a living unit', async () => {
|
||||
tx.treatment.findUnique.mockResolvedValue(
|
||||
treatmentRow({ colony: { code: 'B3', status: 'DISSOLVED' } }),
|
||||
);
|
||||
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is silent when the treatment is gone or its window moved since the event', async () => {
|
||||
tx.treatment.findUnique.mockResolvedValueOnce(null);
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
tx.treatment.findUnique.mockResolvedValueOnce(
|
||||
treatmentRow({ withdrawalPeriodDays: 25 }),
|
||||
);
|
||||
await handlers.withdrawalEnded(ended(), asTx());
|
||||
|
||||
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user