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.where.colonyId).toBe('colony-1');
|
||||||
expect(queryArg.orderBy.date).toBe('asc');
|
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 { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
|
||||||
import { UpdateVarroaMeasurementDto } from './dto/update-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()
|
@Injectable()
|
||||||
export class VarroaService {
|
export class VarroaService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -41,8 +65,18 @@ export class VarroaService {
|
|||||||
dto: CreateVarroaMeasurementDto,
|
dto: CreateVarroaMeasurementDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<VarroaMeasurement> {
|
): Promise<VarroaMeasurement> {
|
||||||
|
const calculatedValue =
|
||||||
|
dto.calculatedValue ??
|
||||||
|
(dto.method === 'NATURAL_MITE_FALL'
|
||||||
|
? computeNaturalMiteFallPerDay(dto.miteCount, dto.durationMinutes)
|
||||||
|
: undefined);
|
||||||
const measurement = await this.prisma.varroaMeasurement.create({
|
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({
|
await this.auditLog.record({
|
||||||
entityType: 'VarroaMeasurement',
|
entityType: 'VarroaMeasurement',
|
||||||
@@ -61,9 +95,27 @@ export class VarroaService {
|
|||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<VarroaMeasurement> {
|
): Promise<VarroaMeasurement> {
|
||||||
const before = await this.findById(id);
|
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({
|
const measurement = await this.prisma.varroaMeasurement.update({
|
||||||
where: { id },
|
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({
|
await this.auditLog.record({
|
||||||
entityType: 'VarroaMeasurement',
|
entityType: 'VarroaMeasurement',
|
||||||
|
|||||||
Reference in new issue
Block a user