feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration, category, link, requiredPermissions filter), notification preferences and settings endpoints, calendar withdrawal switch, occupiedFrames - Suggestions API: list/summary with permission visibility and paging, accept/dismiss/reopen incl. bulk, SuggestionView on all responses - Varroa traffic light by season and method, young colonies, treatment effect window, sample-based percentage; shared withdrawal-window helper on Berlin calendar days; GET /varroa-measurements/thresholds - ColonySplit and QueenAssigned domain events with calendar and webhook handlers - Outbox claims compare against now() rounded to timestamp(3); fixes the flaky e2e claims of rows written with SQL now() Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
d99a697739
commit
f468dc5f75
87 files changed
+6022
-510
No files matched your search
@@ -1,17 +1,25 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { VarroaBandsService } from './varroa-bands.service';
|
||||
import { VarroaService } from './varroa.service';
|
||||
|
||||
@ApiTags('varroa-measurements')
|
||||
@ApiBearerAuth()
|
||||
@Controller('colonies')
|
||||
export class ColonyVarroaMeasurementsController {
|
||||
constructor(private readonly varroaService: VarroaService) {}
|
||||
constructor(
|
||||
private readonly varroaService: VarroaService,
|
||||
private readonly varroaBandsService: VarroaBandsService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('varroa:read')
|
||||
@Get(':id/varroa-measurements')
|
||||
findByColony(@Param('id') id: string) {
|
||||
return this.varroaService.findByColony(id);
|
||||
/** The measurements of the colony, each with its rating (light, band, reason). */
|
||||
async findByColony(@Param('id') id: string) {
|
||||
return this.varroaBandsService.rate(
|
||||
id,
|
||||
await this.varroaService.findByColony(id),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { VarroaMethod } from '../../../generated/prisma';
|
||||
@@ -35,6 +36,18 @@ export class CreateVarroaMeasurementDto {
|
||||
@Min(0)
|
||||
miteCount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Sample size in bees (powdered sugar, washing); the percentage is mites / sample * 100.',
|
||||
minimum: 1,
|
||||
maximum: 2000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(2000)
|
||||
sampleBees?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
const MAX_ID_LENGTH = 64;
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* GET /varroa-measurements/thresholds. The date is only shape-checked here;
|
||||
* an impossible day (2026-02-30) is rejected by the service.
|
||||
*/
|
||||
export class VarroaThresholdsQuery {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Volk, für dessen Typ die Bänder gelten',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_ID_LENGTH)
|
||||
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'colonyId ist ungültig' })
|
||||
colonyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Messtag (JJJJ-MM-TT), ohne Angabe heute (Berlin)',
|
||||
example: '2026-08-10',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' })
|
||||
date?: string;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { VarroaBandsService } from './varroa-bands.service';
|
||||
|
||||
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||
|
||||
describe('VarroaBandsService', () => {
|
||||
let service: VarroaBandsService;
|
||||
let prisma: {
|
||||
automationRule: { findUnique: jest.Mock };
|
||||
colony: { findUnique: jest.Mock };
|
||||
treatment: { findMany: jest.Mock };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
// No row: the built-in (active) rule applies.
|
||||
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
colony: { findUnique: jest.fn() },
|
||||
treatment: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
service = new VarroaBandsService(prisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
describe('thresholds', () => {
|
||||
it('halves the natural mite fall bands for a young colony', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
colonyType: 'YOUNG_COLONY',
|
||||
});
|
||||
|
||||
const result = await service.thresholds({
|
||||
colonyId: 'c1',
|
||||
date: '2026-06-10',
|
||||
});
|
||||
|
||||
expect(result.active).toBe(true);
|
||||
expect(result.bands.NATURAL_MITE_FALL).toMatchObject({
|
||||
yellowFrom: 2.5,
|
||||
redAbove: 5,
|
||||
youngColony: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no powdered sugar band in April', async () => {
|
||||
const result = await service.thresholds({ date: '2026-04-10' });
|
||||
|
||||
expect(result.bands.POWDERED_SUGAR).toBeUndefined();
|
||||
expect(result.bands.NATURAL_MITE_FALL).toBeDefined();
|
||||
});
|
||||
|
||||
it('names the recheck day while a treatment of the colony is in effect', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
});
|
||||
prisma.treatment.findMany.mockResolvedValue([
|
||||
{ date: d('2026-08-02'), startDate: d('2026-07-20'), endDate: null },
|
||||
]);
|
||||
|
||||
const result = await service.thresholds({
|
||||
colonyId: 'c1',
|
||||
date: '2026-08-07',
|
||||
});
|
||||
|
||||
expect(result.postTreatmentUntil).toBe('2026-08-16');
|
||||
});
|
||||
|
||||
it('returns no bands while the rule is switched off', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'r',
|
||||
active: false,
|
||||
config: {},
|
||||
actionTemplate: null,
|
||||
});
|
||||
|
||||
await expect(service.thresholds({ date: '2026-08-10' })).resolves.toEqual(
|
||||
{
|
||||
day: '2026-08-10',
|
||||
bands: {},
|
||||
postTreatmentUntil: null,
|
||||
active: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown colony with 404', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.thresholds({ colonyId: 'missing' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it.each(['2026-02-30', '2026-13-01', '10.08.2026', 'x'])(
|
||||
'rejects the invalid date %s with 400',
|
||||
async (date) => {
|
||||
await expect(service.thresholds({ date })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('rate', () => {
|
||||
const measurement = {
|
||||
id: 'm1',
|
||||
colonyId: 'c1',
|
||||
date: d('2026-08-20'),
|
||||
method: 'NATURAL_MITE_FALL' as const,
|
||||
calculatedValue: 12,
|
||||
};
|
||||
|
||||
it('adds the rating of every measurement', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
colonyType: 'PRODUCTION_COLONY',
|
||||
});
|
||||
|
||||
const [rated] = await service.rate('c1', [measurement]);
|
||||
|
||||
expect(rated).toMatchObject({ id: 'm1' });
|
||||
expect(rated.rating).toMatchObject({
|
||||
light: 'red',
|
||||
reasonCode: 'NATURAL_FALL_SEASON',
|
||||
});
|
||||
});
|
||||
|
||||
it('rates nothing while the rule is switched off', async () => {
|
||||
prisma.automationRule.findUnique.mockResolvedValue({
|
||||
id: 'r',
|
||||
active: false,
|
||||
config: {},
|
||||
actionTemplate: null,
|
||||
});
|
||||
|
||||
const [rated] = await service.rate('c1', [measurement]);
|
||||
|
||||
expect(rated.rating).toEqual({
|
||||
light: null,
|
||||
reasonCode: null,
|
||||
band: null,
|
||||
postTreatmentUntil: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not read the colony for an empty list', async () => {
|
||||
await expect(service.rate('c1', [])).resolves.toEqual([]);
|
||||
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { ColonyType, VarroaMeasurement } from '../../generated/prisma';
|
||||
import { readRule } from '../automation/rule-config';
|
||||
import {
|
||||
bandsFor,
|
||||
rateMeasurement,
|
||||
type VarroaBands,
|
||||
type VarroaRating,
|
||||
} from '../automation/varroa-bands';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
||||
|
||||
export interface VarroaThresholds extends VarroaBands {
|
||||
/** false while the VARROA_THRESHOLD rule is switched off: no bands. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
type RatableMeasurement = Pick<
|
||||
VarroaMeasurement,
|
||||
'date' | 'method' | 'calculatedValue'
|
||||
>;
|
||||
|
||||
const NO_RATING: VarroaRating = {
|
||||
light: null,
|
||||
reasonCode: null,
|
||||
band: null,
|
||||
postTreatmentUntil: null,
|
||||
};
|
||||
|
||||
function validDay(day: string): string {
|
||||
try {
|
||||
dayToDate(day);
|
||||
} catch {
|
||||
throw new BadRequestException('Bitte einen gültigen Tag angeben.');
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
/**
|
||||
* The varroa bands (green/yellow/red limits) of the VARROA_THRESHOLD rule for
|
||||
* a day and colony, and the rating of measurements against them.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VarroaBandsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async thresholds(query: {
|
||||
colonyId?: string;
|
||||
date?: string;
|
||||
}): Promise<VarroaThresholds> {
|
||||
const day = validDay(query.date ?? berlinToday());
|
||||
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
|
||||
const colonyType =
|
||||
query.colonyId === undefined
|
||||
? null
|
||||
: await this.colonyTypeOf(query.colonyId);
|
||||
if (!rule.active) {
|
||||
return { day, bands: {}, postTreatmentUntil: null, active: false };
|
||||
}
|
||||
const treatments =
|
||||
query.colonyId === undefined
|
||||
? []
|
||||
: await treatmentWindowsIn(this.prisma, query.colonyId);
|
||||
return {
|
||||
...bandsFor(day, colonyType, rule.config, treatments),
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Each measurement of one colony with its rating. */
|
||||
async rate<M extends RatableMeasurement>(
|
||||
colonyId: string,
|
||||
measurements: readonly M[],
|
||||
): Promise<(M & { rating: VarroaRating })[]> {
|
||||
if (measurements.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
|
||||
if (!rule.active) {
|
||||
return measurements.map((m) => ({ ...m, rating: NO_RATING }));
|
||||
}
|
||||
const colony = await this.prisma.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { colonyType: true },
|
||||
});
|
||||
const treatments = await treatmentWindowsIn(this.prisma, colonyId);
|
||||
return measurements.map((m) => ({
|
||||
...m,
|
||||
rating: rateMeasurement(
|
||||
m,
|
||||
colony?.colonyType ?? null,
|
||||
rule.config,
|
||||
treatments,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private async colonyTypeOf(colonyId: string): Promise<ColonyType> {
|
||||
const colony = await this.prisma.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
select: { colonyType: true },
|
||||
});
|
||||
if (!colony) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
return colony.colonyType;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { varroaThresholdOf } from '../automation/varroa-threshold';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface VarroaThreshold {
|
||||
// null when the VARROA_THRESHOLD rule is switched off.
|
||||
thresholdMitesPerDay: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VarroaThresholdService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async current(): Promise<VarroaThreshold> {
|
||||
const rule = await this.prisma.automationRule.findUnique({
|
||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
||||
select: { active: true, config: true },
|
||||
});
|
||||
return { thresholdMitesPerDay: varroaThresholdOf(rule) };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
@@ -7,7 +15,8 @@ import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'
|
||||
import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto';
|
||||
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
||||
import { VarroaService } from './varroa.service';
|
||||
import { VarroaThresholdService } from './varroa-threshold.service';
|
||||
import { VarroaBandsService } from './varroa-bands.service';
|
||||
import { VarroaThresholdsQuery } from './dto/varroa-thresholds.query';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
|
||||
@ApiTags('varroa-measurements')
|
||||
@@ -16,7 +25,7 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
export class VarroaController {
|
||||
constructor(
|
||||
private readonly varroaService: VarroaService,
|
||||
private readonly varroaThresholdService: VarroaThresholdService,
|
||||
private readonly varroaBandsService: VarroaBandsService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('varroa:read')
|
||||
@@ -25,11 +34,11 @@ export class VarroaController {
|
||||
return this.varroaService.findAll();
|
||||
}
|
||||
|
||||
/** Effective alarm threshold; declared before :id so it is not shadowed. */
|
||||
/** Bands in force on a day for a colony; declared before :id so it is not shadowed. */
|
||||
@RequirePermission('varroa:read', 'varroa:create')
|
||||
@Get('threshold')
|
||||
getThreshold() {
|
||||
return this.varroaThresholdService.current();
|
||||
@Get('thresholds')
|
||||
getThresholds(@Query() query: VarroaThresholdsQuery) {
|
||||
return this.varroaBandsService.thresholds(query);
|
||||
}
|
||||
|
||||
@RequirePermission('varroa:read')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { VarroaController } from './varroa.controller';
|
||||
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
|
||||
import { VarroaService } from './varroa.service';
|
||||
import { VarroaThresholdService } from './varroa-threshold.service';
|
||||
import { VarroaBandsService } from './varroa-bands.service';
|
||||
|
||||
/**
|
||||
* The threshold rule and the varroa.measured webhook follow from the
|
||||
@@ -11,7 +11,7 @@ import { VarroaThresholdService } from './varroa-threshold.service';
|
||||
*/
|
||||
@Module({
|
||||
controllers: [VarroaController, ColonyVarroaMeasurementsController],
|
||||
providers: [VarroaService, VarroaThresholdService],
|
||||
providers: [VarroaService, VarroaBandsService],
|
||||
exports: [VarroaService],
|
||||
})
|
||||
export class VarroaModule {}
|
||||
@@ -284,6 +284,57 @@ describe('VarroaService', () => {
|
||||
expect(createArg.data.calculatedValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores mites per 100 bees for powdered sugar with a sample size', async () => {
|
||||
tx.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-07-01',
|
||||
method: 'POWDERED_SUGAR',
|
||||
miteCount: 10,
|
||||
sampleBees: 500,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
|
||||
[{ data: { calculatedValue?: number; sampleBees?: number } }],
|
||||
];
|
||||
expect(createArg.data.calculatedValue).toBe(2);
|
||||
expect(createArg.data.sampleBees).toBe(500);
|
||||
});
|
||||
|
||||
it('does not derive a percentage from the sample for natural mite fall or without a sample', async () => {
|
||||
tx.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-07-01',
|
||||
method: 'WASHING_METHOD',
|
||||
miteCount: 10,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-07-01',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 10,
|
||||
sampleBees: 500,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const calls = tx.varroaMeasurement.create.mock.calls as [
|
||||
{ data: { calculatedValue?: number } },
|
||||
][];
|
||||
expect(calls[0][0].data.calculatedValue).toBeUndefined();
|
||||
expect(calls[1][0].data.calculatedValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
|
||||
tx.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
@@ -373,6 +424,16 @@ describe('VarroaService', () => {
|
||||
).resolves.toBe(20);
|
||||
});
|
||||
|
||||
it('rounds mites/day to two decimals so the stored value rates as shown', async () => {
|
||||
// 7203 / 720 days = 10.0042: stored as 10, which is yellow, not red.
|
||||
await expect(
|
||||
storedCalculatedValueAfter(naturalMiteFall, {
|
||||
miteCount: 7203,
|
||||
durationMinutes: 720 * 24 * 60,
|
||||
}),
|
||||
).resolves.toBe(10);
|
||||
});
|
||||
|
||||
it('recomputes mites/day when the duration changes', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(naturalMiteFall, {
|
||||
@@ -443,6 +504,108 @@ describe('VarroaService', () => {
|
||||
).resolves.toBe(15);
|
||||
});
|
||||
|
||||
describe('with a sample size', () => {
|
||||
const washed = {
|
||||
...measurement,
|
||||
method: 'WASHING_METHOD',
|
||||
miteCount: 10,
|
||||
durationMinutes: null,
|
||||
sampleBees: 500,
|
||||
calculatedValue: 2,
|
||||
};
|
||||
|
||||
it('recomputes the percentage when the sample size changes', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(washed, { sampleBees: 250 }),
|
||||
).resolves.toBe(4);
|
||||
});
|
||||
|
||||
it('update with sampleBees null clears the sample and keeps a manually entered value', async () => {
|
||||
tx.varroaMeasurement.findUnique.mockResolvedValue({
|
||||
...washed,
|
||||
calculatedValue: 3,
|
||||
});
|
||||
tx.varroaMeasurement.update.mockResolvedValue(measurement);
|
||||
|
||||
await service.update(
|
||||
'varroa-1',
|
||||
{ sampleBees: null as unknown as number },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [
|
||||
[
|
||||
{
|
||||
data: {
|
||||
sampleBees?: number | null;
|
||||
calculatedValue?: number | null;
|
||||
};
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(updateArg.data.sampleBees).toBeNull();
|
||||
expect(updateArg.data.calculatedValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a derived percentage when the sample is removed', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(washed, {
|
||||
sampleBees: null as unknown as number,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rounds the derived percentage to two decimals', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(washed, {
|
||||
miteCount: 7,
|
||||
sampleBees: 300,
|
||||
}),
|
||||
).resolves.toBe(2.33);
|
||||
});
|
||||
|
||||
it('recomputes the percentage when the mite count changes', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(washed, { miteCount: 15 }),
|
||||
).resolves.toBe(3);
|
||||
});
|
||||
|
||||
it('leaves a manual percentage alone when only the notes change', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(
|
||||
{ ...washed, calculatedValue: 3 },
|
||||
{ notes: 'x' },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives the percentage when the method switches from drone brood', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(
|
||||
{ ...washed, method: 'DRONE_BROOD_CONTROL', calculatedValue: 9 },
|
||||
{ method: 'POWDERED_SUGAR' },
|
||||
),
|
||||
).resolves.toBe(2);
|
||||
});
|
||||
|
||||
it('clears the percentage when the method switches to drone brood', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(washed, {
|
||||
method: 'DRONE_BROOD_CONTROL',
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a changed percentage as a manual override', async () => {
|
||||
await expect(
|
||||
storedCalculatedValueAfter(washed, {
|
||||
sampleBees: 250,
|
||||
calculatedValue: 7,
|
||||
}),
|
||||
).resolves.toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
// @IsOptional lets an explicit null through the validation pipe.
|
||||
const clearedValue = { calculatedValue: null as unknown as number };
|
||||
|
||||
|
||||
@@ -19,16 +19,19 @@ import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measure
|
||||
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
||||
|
||||
const MINUTES_PER_DAY = 24 * 60;
|
||||
const PERCENT = 100;
|
||||
const DECIMALS_FACTOR = 100;
|
||||
|
||||
// Stored and rated value are the same: rounded once, on write.
|
||||
function roundToTwoDecimals(value: number): number {
|
||||
return Math.round(value * DECIMALS_FACTOR) / DECIMALS_FACTOR;
|
||||
}
|
||||
|
||||
// Natural mite fall is counted on a sticky-board insert left in the hive
|
||||
// for some duration and is conventionally normalized to a daily rate
|
||||
// ("Milben/Tag") - both the VARROA_THRESHOLD automation config
|
||||
// (thresholdMitesPerDay) and the annual PDF report already assume
|
||||
// calculatedValue is in that unit. The other methods (Puderzucker,
|
||||
// Waschmethode, Drohnenbrutkontrolle) report a different, non-time-based
|
||||
// metric (mites per fixed bee sample); there is no single verified
|
||||
// formula for those here, so calculatedValue is left for the user to
|
||||
// enter manually for those methods instead of guessing one.
|
||||
// ("Milben/Tag"). Powdered sugar and washing report the infestation as mites
|
||||
// per 100 bees of a sample of sampleBees (percent). The drone brood check has
|
||||
// no verified formula, so its calculatedValue is left for the user to enter.
|
||||
function computeNaturalMiteFallPerDay(
|
||||
miteCount: number | null | undefined,
|
||||
durationMinutes: number | null | undefined,
|
||||
@@ -39,16 +42,44 @@ function computeNaturalMiteFallPerDay(
|
||||
if (!durationMinutes || durationMinutes <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
|
||||
return roundToTwoDecimals((miteCount / durationMinutes) * MINUTES_PER_DAY);
|
||||
}
|
||||
|
||||
function deriveCalculatedValue(
|
||||
method: VarroaMethod,
|
||||
function computeInfestationPercent(
|
||||
miteCount: number | null | undefined,
|
||||
durationMinutes: number | null | undefined,
|
||||
sampleBees: number | null | undefined,
|
||||
): number | undefined {
|
||||
return method === 'NATURAL_MITE_FALL'
|
||||
? computeNaturalMiteFallPerDay(miteCount, durationMinutes)
|
||||
if (miteCount === null || miteCount === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!sampleBees || sampleBees <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return roundToTwoDecimals((miteCount / sampleBees) * PERCENT);
|
||||
}
|
||||
|
||||
function isPercentMethod(method: VarroaMethod): boolean {
|
||||
return method === 'POWDERED_SUGAR' || method === 'WASHING_METHOD';
|
||||
}
|
||||
|
||||
interface Derivation {
|
||||
method: VarroaMethod;
|
||||
miteCount?: number | null;
|
||||
durationMinutes?: number | null;
|
||||
sampleBees?: number | null;
|
||||
}
|
||||
|
||||
function deriveCalculatedValue({
|
||||
method,
|
||||
miteCount,
|
||||
durationMinutes,
|
||||
sampleBees,
|
||||
}: Derivation): number | undefined {
|
||||
if (method === 'NATURAL_MITE_FALL') {
|
||||
return computeNaturalMiteFallPerDay(miteCount, durationMinutes);
|
||||
}
|
||||
return isPercentMethod(method)
|
||||
? computeInfestationPercent(miteCount, sampleBees)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -69,18 +100,25 @@ function calculatedValueForUpdate(
|
||||
if (sent !== undefined && sent !== null && sent !== before.calculatedValue) {
|
||||
return sent;
|
||||
}
|
||||
const method = dto.method ?? before.method;
|
||||
const miteCount =
|
||||
dto.miteCount === undefined ? before.miteCount : dto.miteCount;
|
||||
const durationMinutes =
|
||||
dto.durationMinutes === undefined
|
||||
? before.durationMinutes
|
||||
: dto.durationMinutes;
|
||||
const merged: Derivation = {
|
||||
method: dto.method ?? before.method,
|
||||
miteCount: dto.miteCount === undefined ? before.miteCount : dto.miteCount,
|
||||
durationMinutes:
|
||||
dto.durationMinutes === undefined
|
||||
? before.durationMinutes
|
||||
: dto.durationMinutes,
|
||||
sampleBees:
|
||||
dto.sampleBees === undefined ? before.sampleBees : dto.sampleBees,
|
||||
};
|
||||
const { method } = merged;
|
||||
const derivationInputChanged =
|
||||
method !== before.method ||
|
||||
(method === 'NATURAL_MITE_FALL' &&
|
||||
(miteCount !== before.miteCount ||
|
||||
durationMinutes !== before.durationMinutes));
|
||||
(merged.miteCount !== before.miteCount ||
|
||||
merged.durationMinutes !== before.durationMinutes)) ||
|
||||
(isPercentMethod(method) &&
|
||||
(merged.miteCount !== before.miteCount ||
|
||||
merged.sampleBees !== before.sampleBees));
|
||||
// An explicit null asks for the value to be cleared - like a create
|
||||
// without a value, that means "derive it where a rule exists".
|
||||
const mustDerive =
|
||||
@@ -88,8 +126,20 @@ function calculatedValueForUpdate(
|
||||
if (!mustDerive) {
|
||||
return undefined;
|
||||
}
|
||||
const derived = deriveCalculatedValue(merged);
|
||||
// A percentage that no longer derives (the sample was removed) is cleared
|
||||
// only if it was derived itself; a value entered by hand stays.
|
||||
if (
|
||||
derived === undefined &&
|
||||
sent !== null &&
|
||||
method === before.method &&
|
||||
isPercentMethod(method) &&
|
||||
before.calculatedValue !== deriveCalculatedValue(before)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// null clears a value the merged record no longer supports.
|
||||
return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null;
|
||||
return derived ?? null;
|
||||
}
|
||||
|
||||
// The row a new measurement is stored as - for create and createBulk alike,
|
||||
@@ -101,9 +151,7 @@ function measurementRow(
|
||||
): Prisma.VarroaMeasurementUncheckedCreateInput {
|
||||
return {
|
||||
...dto,
|
||||
calculatedValue:
|
||||
dto.calculatedValue ??
|
||||
deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes),
|
||||
calculatedValue: dto.calculatedValue ?? deriveCalculatedValue(dto),
|
||||
date: new Date(dto.date),
|
||||
userId: actingUserId,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user