diff --git a/ApiCore-Backend/src/varroa/varroa.service.spec.ts b/ApiCore-Backend/src/varroa/varroa.service.spec.ts index 9237cc3..20f0de0 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.spec.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.spec.ts @@ -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(); + }); + }); }); diff --git a/ApiCore-Backend/src/varroa/varroa.service.ts b/ApiCore-Backend/src/varroa/varroa.service.ts index 53e068c..76fdeca 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.ts @@ -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 { + 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 { 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',