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

@@ -32,6 +32,7 @@ describe('AutomationService', () => {
};
colony: { findUnique: jest.Mock };
suggestion: { findFirst: jest.Mock };
inspection: { findFirst: jest.Mock };
treatment: { findMany: jest.Mock };
stockMovement: { findFirst: jest.Mock };
rawMaterialLot: { findFirst: jest.Mock };
@@ -90,6 +91,16 @@ describe('AutomationService', () => {
};
const asTx = () => tx as unknown as Prisma.TransactionClient;
// The inspection rules decide on the colony's latest inspection: the one
// under test is it.
const evaluateInspection = (inspection: never) => {
tx.inspection.findFirst.mockResolvedValue(inspection);
return service.evaluateInspectionIn(asTx(), inspection);
};
const evaluateInspectionCorrection = (inspection: never) => {
tx.inspection.findFirst.mockResolvedValue(inspection);
return service.evaluateInspectionCorrectionIn(asTx(), inspection);
};
const withRules = (...list: Rule[]) => {
for (const rule of list) {
rules.set(rule.triggerType, rule);
@@ -125,6 +136,7 @@ describe('AutomationService', () => {
}),
},
suggestion: { findFirst: jest.fn().mockResolvedValue(null) },
inspection: { findFirst: jest.fn().mockResolvedValue(null) },
treatment: { findMany: jest.fn().mockResolvedValue([]) },
stockMovement: { findFirst: jest.fn().mockResolvedValue(null) },
rawMaterialLot: { findFirst: jest.fn().mockResolvedValue(null) },
@@ -502,7 +514,7 @@ describe('AutomationService', () => {
});
it('read the rules on the given transaction client and write no seed', async () => {
await service.evaluateInspectionIn(asTx(), queenlessInspection as never);
await evaluateInspection(queenlessInspection as never);
expect(tx.automationRule.findUnique).toHaveBeenCalled();
expect(prisma.automationRule.createMany).not.toHaveBeenCalled();
@@ -520,7 +532,7 @@ describe('AutomationService', () => {
it('proposes a queen check anchored on the inspection when the queen was not seen and no eggs are present', async () => {
withRules(queenlessRule);
await service.evaluateInspectionIn(asTx(), queenlessInspection as never);
await evaluateInspection(queenlessInspection as never);
expect(propose).toHaveBeenCalledTimes(1);
expect(propose).toHaveBeenCalledWith(tx, 'QUEENLESS_NO_EGGS', {
@@ -543,7 +555,7 @@ describe('AutomationService', () => {
it('dates the suggestion from the inspection day when the rule has dueInDays', async () => {
withRules({ ...queenlessRule, config: { dueInDays: 3 } });
await service.evaluateInspectionIn(asTx(), queenlessInspection as never);
await evaluateInspection(queenlessInspection as never);
expect(propose).toHaveBeenCalledWith(
tx,
@@ -555,7 +567,7 @@ describe('AutomationService', () => {
it('does not trigger when the queen was seen', async () => {
withRules(queenlessRule);
await service.evaluateInspectionIn(asTx(), {
await evaluateInspection({
...queenlessInspection,
queenSeen: true,
} as never);
@@ -566,7 +578,7 @@ describe('AutomationService', () => {
it('proposes feeding when the feed status is CRITICAL', async () => {
withRules(feedRule);
await service.evaluateInspectionIn(asTx(), {
await evaluateInspection({
...queenlessInspection,
id: 'inspection-3',
queenSeen: true,
@@ -590,7 +602,7 @@ describe('AutomationService', () => {
it('proposes both findings of one inspection', async () => {
withRules(queenlessRule, feedRule);
await service.evaluateInspectionIn(asTx(), {
await evaluateInspection({
...queenlessInspection,
feedStatus: 'CRITICAL',
} as never);
@@ -604,7 +616,7 @@ describe('AutomationService', () => {
{ ...feedRule, active: false },
);
await service.evaluateInspectionIn(asTx(), {
await evaluateInspection({
...queenlessInspection,
feedStatus: 'CRITICAL',
} as never);
@@ -613,7 +625,7 @@ describe('AutomationService', () => {
});
it('reads no rule when there is no finding', async () => {
await service.evaluateInspectionIn(asTx(), {
await evaluateInspection({
...queenlessInspection,
queenSeen: true,
} as never);
@@ -622,6 +634,212 @@ describe('AutomationService', () => {
});
});
describe('open inspection suggestions follow the latest inspection', () => {
let propose: jest.SpyInstance;
const latestOf = (overrides: Record<string, unknown> = {}) =>
({
...queenlessInspection,
id: 'inspection-2',
date: new Date('2026-05-12T00:00:00.000Z'),
createdAt: new Date('2026-05-12T08:00:00.000Z'),
queenSeen: true,
eggsPresent: true,
...overrides,
}) as never;
const lockedTables = () =>
(tx.$queryRaw.mock.calls as [Prisma.Sql][]).map(
([query]) => /FROM "(\w+)"/.exec(query.sql)?.[1],
);
beforeEach(() => {
propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null);
withRules(queenlessRule, feedRule);
tx.suggestion.findFirst.mockImplementation(
({ where }: { where: { ruleKey: string } }) =>
Promise.resolve(
where.ruleKey === 'QUEENLESS_NO_EGGS' ? { id: 'open-1' } : null,
),
);
});
it('locks the colony first, reads the latest inspection and the open suggestion under it, then locks the suggestion', async () => {
await evaluateInspection(latestOf());
expect(lockedTables()).toEqual(['Colony', 'Suggestion']);
const [colonyLock, suggestionLock] =
tx.$queryRaw.mock.invocationCallOrder;
const [latestRead] = tx.inspection.findFirst.mock.invocationCallOrder;
const [openRead] = tx.suggestion.findFirst.mock.invocationCallOrder;
expect(colonyLock).toBeLessThan(latestRead);
expect(latestRead).toBeLessThan(openRead);
expect(openRead).toBeLessThan(suggestionLock);
});
it('takes the latest inspection by date, then creation time, then id', async () => {
await evaluateInspection(latestOf());
expect(tx.inspection.findFirst).toHaveBeenCalledWith({
where: { colonyId: 'colony-1' },
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
});
});
it('looks for the open suggestion of each rule for the colony', async () => {
await evaluateInspection(latestOf());
expect(tx.suggestion.findFirst).toHaveBeenCalledWith({
where: {
ruleKey: 'QUEENLESS_NO_EGGS',
colonyId: 'colony-1',
status: 'OPEN',
},
select: { id: true },
});
});
it('resolves it when the latest inspection no longer shows the finding', async () => {
await evaluateInspection(latestOf());
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['open-1'],
'Bedingung entfällt',
);
expect(propose).not.toHaveBeenCalled();
});
it('refreshes it with the latest inspection when the finding persists', async () => {
await evaluateInspection(
latestOf({ queenSeen: false, eggsPresent: false }),
);
expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'open-1', {
evidence: {
colonyId: 'colony-1',
inspectionId: 'inspection-2',
inspectedOn: '2026-05-12',
},
dueDate: null,
priority: 'HIGH',
expiresAt: null,
});
expect(suggestions.resolveIn).not.toHaveBeenCalled();
expect(propose).not.toHaveBeenCalled();
});
it('recomputes the due date from dueInDays when it refreshes', async () => {
withRules({ ...queenlessRule, config: { dueInDays: 4 } });
await evaluateInspection(
latestOf({ queenSeen: false, eggsPresent: false }),
);
expect(suggestions.refreshIn).toHaveBeenCalledWith(
tx,
'open-1',
expect.objectContaining({ dueDate: '2026-05-16' }),
);
});
it('is idempotent: a re-delivery refreshes the same evidence again', async () => {
const inspection = latestOf({ queenSeen: false, eggsPresent: false });
await evaluateInspection(inspection);
await evaluateInspection(inspection);
const [first, second] = suggestions.refreshIn.mock.calls as unknown[][];
expect(second).toEqual(first);
expect(suggestions.resolveIn).not.toHaveBeenCalled();
});
it('proposes nothing and changes nothing when the event inspection is not the latest (late, retried or backdated)', async () => {
tx.inspection.findFirst.mockResolvedValue(latestOf({ id: 'newer' }));
await service.evaluateInspectionIn(
asTx(),
latestOf({
id: 'older',
queenSeen: false,
eggsPresent: false,
date: new Date('2026-05-01T00:00:00.000Z'),
}),
);
await service.evaluateInspectionCorrectionIn(asTx(), latestOf());
expect(propose).not.toHaveBeenCalled();
expect(suggestions.resolveIn).not.toHaveBeenCalled();
expect(suggestions.refreshIn).not.toHaveBeenCalled();
expect(tx.suggestion.findFirst).not.toHaveBeenCalled();
});
it('does nothing when the colony is gone or has no inspection any more', async () => {
tx.$queryRaw.mockResolvedValueOnce([]);
await evaluateInspection(latestOf());
tx.inspection.findFirst.mockResolvedValue(null);
await service.evaluateInspectionIn(asTx(), latestOf());
expect(suggestions.resolveIn).not.toHaveBeenCalled();
expect(suggestions.refreshIn).not.toHaveBeenCalled();
});
it('the correction of the latest inspection resolves it when the finding is gone', async () => {
await evaluateInspectionCorrection(latestOf());
expect(suggestions.resolveIn).toHaveBeenCalledTimes(1);
});
it('keeps it OPEN when the corrected latest inspection still shows the finding', async () => {
await evaluateInspectionCorrection(
latestOf({ queenSeen: false, eggsPresent: false }),
);
expect(suggestions.resolveIn).not.toHaveBeenCalled();
expect(suggestions.refreshIn).toHaveBeenCalledTimes(1);
});
it('a correction never proposes', async () => {
tx.suggestion.findFirst.mockResolvedValue(null);
await evaluateInspectionCorrection(
latestOf({ queenSeen: false, eggsPresent: false }),
);
expect(propose).not.toHaveBeenCalled();
});
it('leaves open suggestions of an inactive rule untouched', async () => {
withRules({ ...queenlessRule, active: false });
await evaluateInspection(latestOf());
await evaluateInspection(
latestOf({ queenSeen: false, eggsPresent: false }),
);
expect(suggestions.resolveIn).not.toHaveBeenCalled();
expect(suggestions.refreshIn).not.toHaveBeenCalled();
});
it('follows the feed rule the same way', async () => {
tx.suggestion.findFirst.mockImplementation(
({ where }: { where: { ruleKey: string } }) =>
Promise.resolve(
where.ruleKey === 'FEED_STATUS_CRITICAL'
? { id: 'open-feed' }
: null,
),
);
await evaluateInspection(latestOf());
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['open-feed'],
'Bedingung entfällt',
);
});
});
describe('evaluateVarroaMeasurementIn', () => {
let propose: jest.SpyInstance;
const measurement = (