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

@@ -42,6 +42,7 @@ describe('AutomationEventHandlers', () => {
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
let automation: Record<
| 'evaluateInspectionIn'
| 'evaluateInspectionCorrectionIn'
| 'evaluateVarroaMeasurementIn'
| 'evaluateTreatmentIn'
| 'evaluateFeedingIn'
@@ -63,6 +64,7 @@ describe('AutomationEventHandlers', () => {
) as Record<RecordModel, { findUnique: jest.Mock }>;
automation = {
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
evaluateInspectionCorrectionIn: jest.fn().mockResolvedValue(undefined),
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
@@ -84,7 +86,7 @@ describe('AutomationEventHandlers', () => {
expect(registered('inspectionRules')).toEqual([
{
types: ['InspectionRecorded'],
types: ['InspectionRecorded', 'InspectionCorrected'],
handlerId: 'automation.inspection-rules',
},
]);
@@ -208,6 +210,21 @@ describe('AutomationEventHandlers', () => {
},
);
it('a corrected inspection only follows open suggestions', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await run('inspectionRules', 'InspectionCorrected' as never);
expect(automation.evaluateInspectionCorrectionIn).toHaveBeenCalledTimes(
1,
);
expect(automation.evaluateInspectionIn).not.toHaveBeenCalled();
});
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({
id: 'record-1',
@@ -35,9 +35,14 @@ export class AutomationEventHandlers {
return colony !== null && isLivingColonyStatus(colony.status);
}
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
// A correction proposes nothing new; it only lets an open suggestion
// follow the corrected values.
@OnDomainEvent(
['InspectionRecorded', 'InspectionCorrected'],
'automation.inspection-rules',
)
async inspectionRules(
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
tx: Tx,
): Promise<void> {
const inspection = await tx.inspection.findUnique({
@@ -49,7 +54,11 @@ export class AutomationEventHandlers {
) {
return;
}
await this.automation.evaluateInspectionIn(tx, inspection);
if (event.type === 'InspectionRecorded') {
await this.automation.evaluateInspectionIn(tx, inspection);
} else {
await this.automation.evaluateInspectionCorrectionIn(tx, inspection);
}
}
// A correction can lift a count over the threshold, so it is evaluated
@@ -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 = (
@@ -112,6 +112,17 @@ function dueDayOf(config: object, baseDay: string): string | null {
return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null;
}
function inspectionEvidence(
inspection: Inspection,
inspectedOn: string,
): SuggestionEvidence {
return {
colonyId: inspection.colonyId,
inspectionId: inspection.id,
inspectedOn,
};
}
@Injectable()
export class AutomationService {
private readonly logger = new Logger(AutomationService.name);
@@ -229,24 +240,119 @@ export class AutomationService {
// transaction, the legacy wrappers below with a transaction of their own.
// -------------------------------------------------------------------------
/** A new inspection: proposes new findings and follows the open ones. */
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
if (inspection.queenSeen === false && inspection.eggsPresent === false) {
await this.proposeOnInspectionIn(
tx,
'QUEENLESS_NO_EGGS',
'QUEEN_CHECK',
inspection,
);
await this.syncInspectionRulesIn(tx, inspection, true);
}
/**
* A corrected inspection proposes nothing new, but an open suggestion it
* supersedes follows the corrected values (resolved or refreshed).
*/
async evaluateInspectionCorrectionIn(
tx: Tx,
inspection: Inspection,
): Promise<void> {
assertTransactionClient(
tx,
'AutomationService.evaluateInspectionCorrectionIn',
);
await this.syncInspectionRulesIn(tx, inspection, false);
}
/**
* Colony lock first, then everything that decides is read under it: the
* colony's actual latest inspection (date, createdAt, id) is the only one
* that counts. An event of any other inspection - a late or retried
* delivery, a backdated entry, the correction of an older one - proposes
* nothing and changes nothing; the latest inspection has its own event.
* (An inspection moved to another colony raises no event for the colony
* it left, so a suggestion there stays until that colony's next
* inspection.)
*/
private async syncInspectionRulesIn(
tx: Tx,
inspection: Inspection,
proposeNew: boolean,
): Promise<void> {
if (!(await lockRowForUpdate(tx, 'Colony', inspection.colonyId))) {
return;
}
if (inspection.feedStatus === 'CRITICAL') {
await this.proposeOnInspectionIn(
tx,
'FEED_STATUS_CRITICAL',
'FEED',
inspection,
);
const latest = await tx.inspection.findFirst({
where: { colonyId: inspection.colonyId },
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
});
if (latest === null || latest.id !== inspection.id) {
return;
}
await this.syncInspectionRuleIn(
tx,
'QUEENLESS_NO_EGGS',
'QUEEN_CHECK',
latest,
latest.queenSeen === false && latest.eggsPresent === false,
proposeNew,
);
await this.syncInspectionRuleIn(
tx,
'FEED_STATUS_CRITICAL',
'FEED',
latest,
latest.feedStatus === 'CRITICAL',
proposeNew,
);
}
/**
* One inspection rule against the colony's latest inspection (colony
* locked). No open suggestion: a met condition proposes - new inspections
* only, a correction never proposes (a corrected value is not a new
* finding; the beekeeper was told about the original). An open one follows
* the latest inspection: condition gone, it is resolved; still met, its
* evidence is refreshed. The refresh keeps the original dedupeKey/anchor,
* so evidence.inspectionId may differ from the anchor. An inactive rule
* leaves open suggestions untouched (as the varroa rule does).
*/
private async syncInspectionRuleIn(
tx: Tx,
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
intent: SuggestionIntent,
latest: Inspection,
met: boolean,
proposeNew: boolean,
): Promise<void> {
const open = await tx.suggestion.findFirst({
where: {
ruleKey: trigger,
colonyId: latest.colonyId,
status: 'OPEN',
},
select: { id: true },
});
if (open === null) {
if (met && proposeNew) {
await this.proposeOnInspectionIn(tx, trigger, intent, latest);
}
return;
}
const rule = await readRule(tx, trigger);
if (!rule.active) {
return;
}
// Colony (held), then suggestion.
await lockRowForUpdate(tx, 'Suggestion', open.id);
if (!met) {
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
return;
}
const inspectedOn = berlinDayOf(latest.date);
await this.suggestions.refreshIn(tx, open.id, {
evidence: inspectionEvidence(latest, inspectedOn),
dueDate: dueDayOf(rule.config, inspectedOn),
priority: 'HIGH',
expiresAt: null,
});
}
/** The reason code of an inspection rule is the rule's own name. */
@@ -267,11 +373,7 @@ export class AutomationService {
colonyId: inspection.colonyId,
intent,
reasonCode: trigger,
evidence: {
colonyId: inspection.colonyId,
inspectionId: inspection.id,
inspectedOn,
},
evidence: inspectionEvidence(inspection, inspectedOn),
anchor: inspection.id,
requiredPermission: 'inspections:read',
dueDate: dueDayOf(rule.config, inspectedOn),