fix: compute mites/day for natural mite fall varroa measurements
VarroaMeasurement.calculatedValue was already used as "Milben/Tag" by the VARROA_THRESHOLD automation (thresholdMitesPerDay) and the annual PDF report, but no client ever actually computed it - the frontend form only collects raw miteCount and durationMinutes. That meant the threshold check was comparing a raw, un-normalized mite count against a per-day threshold: e.g. 12 mites counted over 5 days (2.4/day, healthy) would incorrectly trigger the same alert as 12 mites counted over 1 day (a real problem). VarroaService now derives calculatedValue = miteCount / duration in days for NATURAL_MITE_FALL measurements, on create and (only when no value exists yet) on update, without ever overwriting an explicitly provided or already-stored value. Other methods (Puderzucker, Waschmethode, Drohnenbrutkontrolle) use a different, non-time-based metric and are left for manual entry rather than guessing an unverified formula. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
985ea09556
commit
792806b967
2 files changed
+156
-2
No files matched your search
@@ -100,4 +100,106 @@ describe('VarroaService', () => {
|
||||
expect(queryArg.where.colonyId).toBe('colony-1');
|
||||
expect(queryArg.orderBy.date).toBe('asc');
|
||||
});
|
||||
|
||||
describe('calculatedValue auto-computation', () => {
|
||||
it('computes mites/day for a natural mite fall count on create', async () => {
|
||||
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 45,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(createArg.data.calculatedValue).toBe(15);
|
||||
});
|
||||
|
||||
it('does not compute calculatedValue for a non-time-based method', async () => {
|
||||
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
method: 'POWDERED_SUGAR',
|
||||
miteCount: 8,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(createArg.data.calculatedValue).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
|
||||
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||
|
||||
await service.create(
|
||||
{
|
||||
colonyId: 'colony-1',
|
||||
date: '2026-01-01',
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 45,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
calculatedValue: 99,
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(createArg.data.calculatedValue).toBe(99);
|
||||
});
|
||||
|
||||
it('backfills calculatedValue on update when it was never set', async () => {
|
||||
prisma.varroaMeasurement.findUnique.mockResolvedValue({
|
||||
...measurement,
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: null,
|
||||
durationMinutes: null,
|
||||
calculatedValue: null,
|
||||
});
|
||||
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
|
||||
|
||||
await service.update(
|
||||
'varroa-1',
|
||||
{ miteCount: 20, durationMinutes: 24 * 60 },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(updateArg.data.calculatedValue).toBe(20);
|
||||
});
|
||||
|
||||
it('never overwrites an already-stored calculatedValue on an unrelated edit', async () => {
|
||||
prisma.varroaMeasurement.findUnique.mockResolvedValue({
|
||||
...measurement,
|
||||
method: 'NATURAL_MITE_FALL',
|
||||
miteCount: 45,
|
||||
durationMinutes: 3 * 24 * 60,
|
||||
calculatedValue: 15,
|
||||
});
|
||||
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
|
||||
|
||||
await service.update('varroa-1', { assessment: 'stabil' }, 'user-1');
|
||||
|
||||
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
|
||||
[{ data: { calculatedValue?: number } }],
|
||||
];
|
||||
expect(updateArg.data.calculatedValue).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,30 @@ import { VarroaMeasurement } from '../../generated/prisma';
|
||||
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
|
||||
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
||||
|
||||
const MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
// 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.
|
||||
function computeNaturalMiteFallPerDay(
|
||||
miteCount: number | null | undefined,
|
||||
durationMinutes: number | null | undefined,
|
||||
): number | undefined {
|
||||
if (miteCount === null || miteCount === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!durationMinutes || durationMinutes <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VarroaService {
|
||||
constructor(
|
||||
@@ -41,8 +65,18 @@ export class VarroaService {
|
||||
dto: CreateVarroaMeasurementDto,
|
||||
actingUserId: string,
|
||||
): Promise<VarroaMeasurement> {
|
||||
const calculatedValue =
|
||||
dto.calculatedValue ??
|
||||
(dto.method === 'NATURAL_MITE_FALL'
|
||||
? computeNaturalMiteFallPerDay(dto.miteCount, dto.durationMinutes)
|
||||
: undefined);
|
||||
const measurement = await this.prisma.varroaMeasurement.create({
|
||||
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
|
||||
data: {
|
||||
...dto,
|
||||
calculatedValue,
|
||||
date: new Date(dto.date),
|
||||
userId: actingUserId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'VarroaMeasurement',
|
||||
@@ -61,9 +95,27 @@ export class VarroaService {
|
||||
actingUserId: string,
|
||||
): Promise<VarroaMeasurement> {
|
||||
const before = await this.findById(id);
|
||||
// Only auto-fill calculatedValue when nothing has set it yet - never
|
||||
// overwrite a value the user (or a prior auto-fill) already stored,
|
||||
// even if this edit only touches an unrelated field like notes.
|
||||
const shouldAutoCompute =
|
||||
dto.calculatedValue === undefined && before.calculatedValue === null;
|
||||
const method = dto.method ?? before.method;
|
||||
const calculatedValue =
|
||||
dto.calculatedValue ??
|
||||
(shouldAutoCompute && method === 'NATURAL_MITE_FALL'
|
||||
? computeNaturalMiteFallPerDay(
|
||||
dto.miteCount ?? before.miteCount,
|
||||
dto.durationMinutes ?? before.durationMinutes,
|
||||
)
|
||||
: undefined);
|
||||
const measurement = await this.prisma.varroaMeasurement.update({
|
||||
where: { id },
|
||||
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
|
||||
data: {
|
||||
...dto,
|
||||
calculatedValue,
|
||||
date: dto.date ? new Date(dto.date) : undefined,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'VarroaMeasurement',
|
||||
|
||||
Reference in new issue
Block a user