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:
1 parent
64a2b7dad9
commit
02eb16924e
142 files changed
+11847
-1251
No files matched your search
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user