feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI

- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

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 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

+60 -70
View File
@@ -59,8 +59,8 @@ describe('Raw materials and production (e2e)', () => {
let propolisId: string;
let productId: string;
let batchId: string;
// Tasks the RAW_MATERIAL_LOW handler created for this file.
const ruleTaskIds: string[] = [];
// Suggestions the RAW_MATERIAL_LOW handler created for this file.
const ruleSuggestionIds: string[] = [];
const server = () => app.getHttpServer();
const auth = (req: request.Test) =>
@@ -133,43 +133,35 @@ describe('Raw materials and production (e2e)', () => {
})
).map((movement) => movement.id);
const batchIds = [batchId].filter(Boolean);
// Rule tasks are written without a user: their TaskCreated, audit
// entries, warning and queued deliveries go by task id.
for (const taskId of ruleTaskIds) {
await prisma.auditLog.deleteMany({
where: {
entityType: 'AutomationRule',
afterData: { path: ['taskId'], equals: taskId },
},
});
await prisma.webhookDelivery.deleteMany({
where: { event: 'task.created', body: { contains: taskId } },
});
}
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: ruleTaskIds } },
// Rule suggestions are written without a user: their audit entries and
// warnings go by suggestion id.
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: ruleSuggestionIds } },
...ruleSuggestionIds.map((suggestionId) => ({
entityType: 'AutomationRule',
afterData: { path: ['suggestionId'], equals: suggestionId },
})),
],
},
});
// Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer
// of GENERAL/PROPOLIS warnings, and only this file switches the rule on
// (default: off, no minimum) - so such a warning since this file
// started belongs to the task above.
if (ruleTaskIds.length > 0) {
await prisma.notification.deleteMany({
where: {
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
});
}
const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds];
await prisma.notification.deleteMany({
where: {
relatedType: 'Suggestion',
relatedId: { in: ruleSuggestionIds },
},
});
await prisma.suggestion.deleteMany({
where: { id: { in: ruleSuggestionIds } },
});
const aggregateIds = [...batchIds, ...movementIds];
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.auditLog.deleteMany({
where: { entityId: { in: [...lotIds, ...aggregateIds] } },
});
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
await app.close();
});
@@ -363,22 +355,22 @@ describe('Raw materials and production (e2e)', () => {
).expect(409);
});
it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => {
it('proposes the RAW_MATERIAL_LOW reorder only once its StockChanged event is dispatched', async () => {
// The rule is shared by every e2e file: switch it on with a minimum no
// test database reaches, and put it back right after.
const before = await prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } };
// One open task per kind: a leftover from an aborted earlier run would
// keep the handler from creating a new one - close it first.
await prisma.task.updateMany({
const config = { minGrams: { PROPOLIS: 1_000_000_000 } };
// One open suggestion per kind: a leftover from an aborted earlier run
// would keep the handler from proposing a new one - settle it first.
await prisma.suggestion.updateMany({
where: {
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
ruleKey: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
status: { in: ['OPEN', 'IN_PROGRESS'] },
status: 'OPEN',
},
data: { status: 'DONE', completedAt: new Date() },
data: { status: 'DISMISSED' },
});
await prisma.automationRule.upsert({
where: { triggerType: 'RAW_MATERIAL_LOW' },
@@ -400,23 +392,35 @@ describe('Raw materials and production (e2e)', () => {
.expect(201);
// Newest first: the withdrawal just booked.
const [movement] = (response.body as StockBody).movements;
const [event] = await prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId: movement.id },
select: { correlationId: true },
});
const ruleTasksOfThisChange = async () =>
(
await prisma.domainEvent.findMany({
where: {
type: 'TaskCreated',
correlationId: event.correlationId,
},
select: { aggregateId: true },
})
).map((created) => created.aggregateId);
const suggestionsOfThisChange = () =>
prisma.suggestion.findMany({
where: {
ruleKey: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
});
// The request itself creates no task - the handler does.
expect(await ruleTasksOfThisChange()).toEqual([]);
// The request itself proposes nothing - the handler does.
expect(await suggestionsOfThisChange()).toEqual([]);
await drainDomainEvents(app, [movement.id]);
const suggestions = await suggestionsOfThisChange();
ruleSuggestionIds.push(...suggestions.map((found) => found.id));
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
ruleKey: 'RAW_MATERIAL_LOW',
intent: 'STOCK_REORDER',
reasonCode: 'RAW_MATERIAL_LOW',
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
colonyId: null,
requiredPermission: 'raw-material:read',
status: 'OPEN',
dedupeKey: `RAW_MATERIAL_LOW:PROPOLIS:${propolisId}`,
});
// The request creates no task either.
expect(
await prisma.task.count({
where: {
@@ -426,20 +430,6 @@ describe('Raw materials and production (e2e)', () => {
},
}),
).toBe(0);
await drainDomainEvents(app, [movement.id]);
const taskIds = await ruleTasksOfThisChange();
ruleTaskIds.push(...taskIds);
expect(taskIds).toHaveLength(1);
expect(
await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }),
).toMatchObject({
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
status: 'OPEN',
});
} finally {
// Leave the rule as found: restored, or gone again if this test
// created it (ensureDefaults then recreates it with its defaults).