fix: keep quantities, colony/queen states and tasks consistent under retries
Data-integrity fixes found in a systematic review of ApiCore: - Bottling booked the honey batch amount from a value read outside the transaction and wrote an absolute result back, so two concurrent bottlings (double tap, two devices) could both pass the check and one deduction was lost. Bookings now use a conditional UPDATE inside the transaction; corrections and deletions give amounts back atomically. - Outgoing stock movements could drive stock below zero; the removal is now a conditional decrement that refuses when stock is insufficient. - Weak, conspicuous and queenless colonies were treated as "not active" and could not be moved, split or merged - uniting a queenless colony is the textbook merge. Only terminal statuses lock a colony now; merging a colony into itself and dissolving twice are rejected. - Moving a queen to another colony failed on the unique currentQueenId because the old colony kept pointing at her. The old colony now releases her (and becomes QUEENLESS); a queenless colony receiving a queen becomes ACTIVE; dissolved colonies cannot receive a queen. - Completing a task twice moved completedAt and re-sent the webhook; reopening a task kept completedAt. Recurring templates created tasks without a due date and caught up missed weeks one stale task at a time. - Automation rules created a new task and warning on every trigger (every stock removal below minimum). Tasks now record their source trigger and at most one open task per rule and object exists. The varroa threshold (mites per day) is only compared with natural-mite-fall rates, not with sample-based counts in another unit. - New opt-in Idempotency-Key support for record-creating endpoints, so the offline queue can retry after a lost response without duplicates. - Permission catalog sync crashed replicas booting concurrently on a fresh database (unique violation); it now uses createMany/skipDuplicates. - Server-side validation for inspection rating scales (1-5), unique colony/harvest lists and net weight <= gross weight. - Stale e2e expectations updated (permission count, trigger list, real PNG fixture for the file-signature check). Migration is additive only (nullable Task column, new table). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
786d929df9
commit
5d11756a87
37 files changed
+1420
-164
No files matched your search
@@ -62,6 +62,27 @@ const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional per-rule follow-up interval (config.dueInDays). There is no
|
||||
* built-in default: how soon a queenless colony or a high mite fall needs a
|
||||
* look depends on season and practice, so the rule stays date-less unless the
|
||||
* beekeeper configures one.
|
||||
*/
|
||||
function dueDateFromConfig(dueInDays: unknown): Date | undefined {
|
||||
if (
|
||||
typeof dueInDays !== 'number' ||
|
||||
!Number.isInteger(dueInDays) ||
|
||||
dueInDays < 0 ||
|
||||
dueInDays > 365
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const due = new Date();
|
||||
due.setUTCHours(0, 0, 0, 0);
|
||||
due.setUTCDate(due.getUTCDate() + dueInDays);
|
||||
return due;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AutomationService {
|
||||
constructor(
|
||||
@@ -174,9 +195,16 @@ export class AutomationService {
|
||||
if (!rule || !rule.active) {
|
||||
return;
|
||||
}
|
||||
// The threshold is a natural mite fall rate (mites per day). Only that
|
||||
// method yields a value in this unit - a mite count from a sugar roll or
|
||||
// wash is per bee sample and would be compared against the wrong scale,
|
||||
// as would a raw board count without the number of days it covered.
|
||||
if (measurement.method !== 'NATURAL_MITE_FALL') {
|
||||
return;
|
||||
}
|
||||
const config = rule.config as { thresholdMitesPerDay?: number } | null;
|
||||
const threshold = config?.thresholdMitesPerDay ?? 10;
|
||||
const value = measurement.calculatedValue ?? measurement.miteCount;
|
||||
const value = measurement.calculatedValue;
|
||||
if (value !== null && value >= threshold) {
|
||||
await this.triggerAction(rule, 'COLONY', measurement.colonyId);
|
||||
}
|
||||
@@ -274,13 +302,35 @@ export class AutomationService {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves a suggestion task (never a record of work done) plus a warning.
|
||||
*
|
||||
* At most one open task per rule and object: while the reorder task for a
|
||||
* stock item or the check task for a colony is still open, further triggers
|
||||
* (the next removal below minimum, a second high varroa count) add nothing.
|
||||
* Repeating the same alarm would only bury the one task that matters.
|
||||
*/
|
||||
private async triggerAction(
|
||||
rule: AutomationRule,
|
||||
relatedType: TaskRelatedType,
|
||||
relatedId: string,
|
||||
sourceInspectionId?: string,
|
||||
): Promise<void> {
|
||||
const alreadyOpen = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
relatedType,
|
||||
relatedId,
|
||||
status: { in: ['OPEN', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (alreadyOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
|
||||
const config = rule.config as { dueInDays?: unknown } | null;
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
title,
|
||||
@@ -288,6 +338,8 @@ export class AutomationService {
|
||||
relatedType,
|
||||
relatedId,
|
||||
sourceInspectionId,
|
||||
sourceAutomationTrigger: rule.triggerType,
|
||||
dueDate: dueDateFromConfig(config?.dueInDays),
|
||||
},
|
||||
});
|
||||
await this.webhooks.dispatch('task.created', task);
|
||||
|
||||
Reference in new issue
Block a user