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
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user