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
@@ -518,6 +518,44 @@ describe('ColoniesService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('writing occupiedFrames leaves strength untouched', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
strength: 7,
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
strength: 7,
|
||||
occupiedFrames: 14,
|
||||
});
|
||||
|
||||
await service.update('colony-1', { occupiedFrames: 14 }, 'user-1');
|
||||
|
||||
expect(tx.colony.update).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
data: { occupiedFrames: 14 },
|
||||
});
|
||||
});
|
||||
|
||||
it('updateIn locks the colony row before reading before', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
const result = await service.updateIn(
|
||||
tx as never,
|
||||
'colony-1',
|
||||
{ notes: 'x' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.colony.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(result.before.id).toBe('colony-1');
|
||||
expect(result.after).toBe(parentColony);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update records no event when status and health stay the same', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { Colony, ColonyStatus } from '../../generated/prisma';
|
||||
import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
|
||||
import { CreateColonyDto } from './dto/create-colony.dto';
|
||||
import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||
import { MoveColonyDto } from './dto/move-colony.dto';
|
||||
@@ -105,51 +105,63 @@ export class ColoniesService {
|
||||
dto: UpdateColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Colony> {
|
||||
// Fail fast with 404; everything else is decided on the locked row.
|
||||
await this.findById(id);
|
||||
const { after } = await this.prisma.$transaction((tx) =>
|
||||
this.updateIn(tx, id, dto, actingUserId),
|
||||
);
|
||||
return after;
|
||||
}
|
||||
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const before = await lockColony(tx, id);
|
||||
const movesApiary =
|
||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||
if (movesApiary) {
|
||||
await this.assertApiaryActive(dto.apiaryId!);
|
||||
}
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
// Correcting the apiary has to leave the same trace a deliberate move
|
||||
// does - otherwise the colony's location history would silently skip a
|
||||
// stand it was recorded on.
|
||||
if (movesApiary) {
|
||||
await tx.colonyApiaryHistory.create({
|
||||
data: {
|
||||
colonyId: id,
|
||||
apiaryId: updated.apiaryId,
|
||||
movedByUserId: actingUserId,
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(before, updated, actingUserId),
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
/**
|
||||
* The core of update, for callers that already hold a transaction (an
|
||||
* inspection handing values over to the colony card). Locks the colony row
|
||||
* and reads "before" only afterwards (404 when missing); writes the apiary
|
||||
* history, the status events and the audit entry with the change.
|
||||
*/
|
||||
async updateIn(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
dto: UpdateColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<{ before: Colony; after: Colony }> {
|
||||
const before = await lockColony(tx, id);
|
||||
const movesApiary =
|
||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||
if (movesApiary) {
|
||||
await this.assertApiaryActive(dto.apiaryId!);
|
||||
}
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
});
|
||||
return colony;
|
||||
// Correcting the apiary has to leave the same trace a deliberate move
|
||||
// does - otherwise the colony's location history would silently skip a
|
||||
// stand it was recorded on.
|
||||
if (movesApiary) {
|
||||
await tx.colonyApiaryHistory.create({
|
||||
data: {
|
||||
colonyId: id,
|
||||
apiaryId: updated.apiaryId,
|
||||
movedByUserId: actingUserId,
|
||||
notes: 'Korrektur über Volk bearbeiten',
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(before, updated, actingUserId),
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return { before, after: updated };
|
||||
}
|
||||
|
||||
async move(
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
broodConditionFromPattern,
|
||||
planColonyTakeover,
|
||||
} from './colony-takeover';
|
||||
|
||||
const inspection = {
|
||||
colonyStrength: 12 as number | null,
|
||||
broodPattern: 4 as number | null,
|
||||
feedStatus: 'GOOD' as 'GOOD' | null,
|
||||
};
|
||||
const allowed = {
|
||||
canUpdateColony: true,
|
||||
colonyEnded: false,
|
||||
hasNewerInspection: false,
|
||||
};
|
||||
|
||||
describe('colony takeover', () => {
|
||||
it.each([
|
||||
[5, 'EXCELLENT'],
|
||||
[4, 'GOOD'],
|
||||
[3, 'AVERAGE'],
|
||||
[2, 'WEAK'],
|
||||
[1, 'CRITICAL'],
|
||||
[null, null],
|
||||
])('brood pattern %p -> %p', (pattern, expected) => {
|
||||
expect(broodConditionFromPattern(pattern)).toBe(expected);
|
||||
});
|
||||
|
||||
it('takes over only requested fields that have a value', () => {
|
||||
const result = planColonyTakeover(
|
||||
{ ...inspection, feedStatus: null },
|
||||
['occupiedFrames', 'feedStatus'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.patch).toEqual({ occupiedFrames: 12 });
|
||||
expect(result.takeover).toEqual({
|
||||
applied: ['occupiedFrames'],
|
||||
skipped: [{ field: 'feedStatus', reason: 'NO_VALUE' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps the brood pattern to the brood status', () => {
|
||||
const result = planColonyTakeover(inspection, ['broodStatus'], allowed);
|
||||
expect(result.patch).toEqual({ broodStatus: 'GOOD' });
|
||||
});
|
||||
|
||||
it('takes nothing over when nothing is requested', () => {
|
||||
const result = planColonyTakeover(inspection, [], allowed);
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover).toEqual({ applied: [], skipped: [] });
|
||||
});
|
||||
|
||||
it('skips everything with NO_PERMISSION when the caller may not update colonies', () => {
|
||||
const result = planColonyTakeover(
|
||||
inspection,
|
||||
['occupiedFrames', 'broodStatus', 'feedStatus'],
|
||||
{ ...allowed, canUpdateColony: false, hasNewerInspection: true },
|
||||
);
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover.applied).toEqual([]);
|
||||
expect(result.takeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'NO_PERMISSION' },
|
||||
{ field: 'broodStatus', reason: 'NO_PERMISSION' },
|
||||
{ field: 'feedStatus', reason: 'NO_PERMISSION' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips everything with NEWER_INSPECTION when a later inspection exists', () => {
|
||||
const result = planColonyTakeover(inspection, ['occupiedFrames'], {
|
||||
...allowed,
|
||||
hasNewerInspection: true,
|
||||
});
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips every field with COLONY_ENDED when the colony no longer exists', () => {
|
||||
const result = planColonyTakeover(
|
||||
inspection,
|
||||
['occupiedFrames', 'feedStatus'],
|
||||
{ ...allowed, colonyEnded: true },
|
||||
);
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
|
||||
{ field: 'feedStatus', reason: 'COLONY_ENDED' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([45, -1])(
|
||||
'skips OUT_OF_RANGE for the value %p outside the colony card range',
|
||||
(value) => {
|
||||
const result = planColonyTakeover(
|
||||
{ ...inspection, colonyStrength: value },
|
||||
['occupiedFrames'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.patch).toEqual({});
|
||||
expect(result.takeover.skipped).toEqual([
|
||||
{ field: 'occupiedFrames', reason: 'OUT_OF_RANGE' },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts the bounds 0 and 40', () => {
|
||||
for (const value of [0, 40]) {
|
||||
const result = planColonyTakeover(
|
||||
{ ...inspection, colonyStrength: value },
|
||||
['occupiedFrames'],
|
||||
allowed,
|
||||
);
|
||||
expect(result.patch).toEqual({ occupiedFrames: value });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
Colony,
|
||||
ColonyCondition,
|
||||
Inspection,
|
||||
} from '../../generated/prisma';
|
||||
|
||||
export const COLONY_TAKEOVER_FIELDS = [
|
||||
'occupiedFrames',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
] as const;
|
||||
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
|
||||
|
||||
export type TakeoverSkipReason =
|
||||
| 'NO_VALUE'
|
||||
| 'NO_PERMISSION'
|
||||
| 'NEWER_INSPECTION'
|
||||
| 'COLONY_ENDED'
|
||||
| 'OUT_OF_RANGE';
|
||||
|
||||
export interface ColonyTakeover {
|
||||
applied: ColonyTakeoverField[];
|
||||
skipped: { field: ColonyTakeoverField; reason: TakeoverSkipReason }[];
|
||||
}
|
||||
|
||||
export const MAX_OCCUPIED_FRAMES = 40;
|
||||
|
||||
const CONDITION_BY_BROOD_PATTERN: Record<number, ColonyCondition> = {
|
||||
5: 'EXCELLENT',
|
||||
4: 'GOOD',
|
||||
3: 'AVERAGE',
|
||||
2: 'WEAK',
|
||||
1: 'CRITICAL',
|
||||
};
|
||||
|
||||
/** Brood pattern 1-5 as the colony card's brood status; the mapping is an assumption. */
|
||||
export function broodConditionFromPattern(
|
||||
pattern: number | null,
|
||||
): ColonyCondition | null {
|
||||
if (pattern === null) {
|
||||
return null;
|
||||
}
|
||||
return CONDITION_BY_BROOD_PATTERN[pattern] ?? null;
|
||||
}
|
||||
|
||||
/** Only values that exist: a takeover never writes null. */
|
||||
type TakeoverPatch = {
|
||||
[K in ColonyTakeoverField]?: NonNullable<Colony[K]>;
|
||||
};
|
||||
type TakeoverSource = Pick<
|
||||
Inspection,
|
||||
'colonyStrength' | 'broodPattern' | 'feedStatus'
|
||||
>;
|
||||
|
||||
interface FieldPlan {
|
||||
patch: TakeoverPatch | null;
|
||||
skip: TakeoverSkipReason | null;
|
||||
}
|
||||
|
||||
function planField(
|
||||
field: ColonyTakeoverField,
|
||||
inspection: TakeoverSource,
|
||||
): FieldPlan {
|
||||
switch (field) {
|
||||
case 'occupiedFrames': {
|
||||
const frames = inspection.colonyStrength;
|
||||
if (frames === null) {
|
||||
return { patch: null, skip: 'NO_VALUE' };
|
||||
}
|
||||
if (frames < 0 || frames > MAX_OCCUPIED_FRAMES) {
|
||||
return { patch: null, skip: 'OUT_OF_RANGE' };
|
||||
}
|
||||
return { patch: { occupiedFrames: frames }, skip: null };
|
||||
}
|
||||
case 'broodStatus': {
|
||||
const status = broodConditionFromPattern(inspection.broodPattern);
|
||||
return status === null
|
||||
? { patch: null, skip: 'NO_VALUE' }
|
||||
: { patch: { broodStatus: status }, skip: null };
|
||||
}
|
||||
case 'feedStatus':
|
||||
return inspection.feedStatus === null
|
||||
? { patch: null, skip: 'NO_VALUE' }
|
||||
: { patch: { feedStatus: inspection.feedStatus }, skip: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a saved inspection hands over to the colony card. Values come from the
|
||||
* stored inspection only. A missing permission, an ended colony and a newer
|
||||
* inspection each skip every requested field: a newer inspection blocks all
|
||||
* fields, not only the one it would overwrite (spec 7.9).
|
||||
*/
|
||||
export function planColonyTakeover(
|
||||
inspection: TakeoverSource,
|
||||
requested: readonly ColonyTakeoverField[],
|
||||
ctx: {
|
||||
canUpdateColony: boolean;
|
||||
colonyEnded: boolean;
|
||||
hasNewerInspection: boolean;
|
||||
},
|
||||
): { patch: TakeoverPatch; takeover: ColonyTakeover } {
|
||||
const patch: TakeoverPatch = {};
|
||||
const takeover: ColonyTakeover = { applied: [], skipped: [] };
|
||||
for (const field of requested) {
|
||||
const plan: FieldPlan = !ctx.canUpdateColony
|
||||
? { patch: null, skip: 'NO_PERMISSION' }
|
||||
: ctx.colonyEnded
|
||||
? { patch: null, skip: 'COLONY_ENDED' }
|
||||
: ctx.hasNewerInspection
|
||||
? { patch: null, skip: 'NEWER_INSPECTION' }
|
||||
: planField(field, inspection);
|
||||
if (plan.skip !== null) {
|
||||
takeover.skipped.push({ field, reason: plan.skip });
|
||||
continue;
|
||||
}
|
||||
Object.assign(patch, plan.patch);
|
||||
takeover.applied.push(field);
|
||||
}
|
||||
return { patch, takeover };
|
||||
}
|
||||
@@ -25,13 +25,29 @@ export class CreateColonyDto {
|
||||
@IsString()
|
||||
originText?: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 10 })
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
deprecated: true,
|
||||
description: 'Volksstärke (alt, Skala 1–10)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
strength?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
maximum: 40,
|
||||
description: 'Volksstärke in besetzten Waben',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(40, { message: 'Höchstens 40 besetzte Waben.' })
|
||||
occupiedFrames?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: ColonyCondition })
|
||||
@IsOptional()
|
||||
@IsEnum(ColonyCondition)
|
||||
|
||||
@@ -18,6 +18,7 @@ class UpdatableColonyFieldsDto extends PartialType(
|
||||
'originText',
|
||||
'colonyType',
|
||||
'strength',
|
||||
'occupiedFrames',
|
||||
'broodStatus',
|
||||
'feedStatus',
|
||||
'healthStatus',
|
||||
|
||||
Reference in new issue
Block a user