feat: Ein-/Auswinterung und Winterverluste je Volk
- WinterCheck per colony, winter and season (autumn/spring), every field editable; loss causes limited to the COLOSS monitoring categories. - Summary per winter and apiary: loss rate lost/wintered, marked provisional while colonies are not yet checked in spring - an unchecked colony never counts as survived. - A loss marks the colony deceased only on explicit request, in the same transaction and audited. - Permissions winter-checks:read/create/update granted by migration to beekeeper and staff roles; winter checks included in backups. - Page "Überwinterung": next action first, one button per row until the colony is wintered, German number format. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0d23963f07
commit
fac66882fa
26 files changed
+2020
-2
No files matched your search
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||
import { WinterChecksService } from './winter-checks.service';
|
||||
|
||||
@ApiTags('winter-checks')
|
||||
@ApiBearerAuth()
|
||||
@Controller('colonies')
|
||||
export class ColonyWinterChecksController {
|
||||
constructor(private readonly winterChecksService: WinterChecksService) {}
|
||||
|
||||
@RequirePermission('winter-checks:read')
|
||||
@Get(':id/winter-checks')
|
||||
findByColony(@Param('id') id: string) {
|
||||
return this.winterChecksService.findByColony(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
ColonyLossCause,
|
||||
WinterCheckSeason,
|
||||
WinterOutcome,
|
||||
} from '../../../generated/prisma';
|
||||
|
||||
export class CreateWinterCheckDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
colonyId!: string;
|
||||
|
||||
@ApiProperty({ enum: WinterCheckSeason })
|
||||
@IsEnum(WinterCheckSeason)
|
||||
season!: WinterCheckSeason;
|
||||
|
||||
@ApiProperty()
|
||||
@IsDateString()
|
||||
date!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Jahr, in dem der Winter beginnt (2026 = Winter 2026/27). Ohne Angabe aus dem Datum abgeleitet.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(2000)
|
||||
@Max(2100)
|
||||
winterYear?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Besetzte Wabengassen' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(60)
|
||||
occupiedSeams?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Geschätzter Futtervorrat in kg' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
feedStockKg?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
queenPresent?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: WinterOutcome, description: 'Nur Auswinterung' })
|
||||
@IsOptional()
|
||||
@IsEnum(WinterOutcome)
|
||||
outcome?: WinterOutcome;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ColonyLossCause,
|
||||
description: 'Nur bei Ergebnis LOST (Kategorien des COLOSS-Monitorings)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ColonyLossCause)
|
||||
lossCause?: ColonyLossCause;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Bei Ergebnis LOST das Volk zugleich als abgestorben (DECEASED) kennzeichnen.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
markColonyDeceased?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateWinterCheckDto } from './create-winter-check.dto';
|
||||
|
||||
/**
|
||||
* Every field stays correctable, colony and season included: a check filed
|
||||
* against the wrong colony or season is a data-entry error, and re-filing it
|
||||
* is the only way to repair the winter record without deleting it.
|
||||
*/
|
||||
export class UpdateWinterCheckDto extends PartialType(CreateWinterCheckDto) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class WinterCheckQueryDto {
|
||||
@ApiPropertyOptional({ description: '2026 = Winter 2026/27' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(2000)
|
||||
@Max(2100)
|
||||
winterYear?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
apiaryId?: string;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
summarizeWinter,
|
||||
validateWinterCheck,
|
||||
WinterSummarySource,
|
||||
winterYearFor,
|
||||
} from './winter-check-rules';
|
||||
|
||||
const d = (value: string) => new Date(`${value}T12:00:00.000Z`);
|
||||
|
||||
describe('winterYearFor', () => {
|
||||
it('assigns autumn dates to the winter starting that year', () => {
|
||||
expect(winterYearFor(d('2026-09-15'))).toBe(2026);
|
||||
expect(winterYearFor(d('2026-07-01'))).toBe(2026);
|
||||
});
|
||||
|
||||
it('assigns spring dates to the winter that started the year before', () => {
|
||||
expect(winterYearFor(d('2027-03-20'))).toBe(2026);
|
||||
expect(winterYearFor(d('2027-06-30'))).toBe(2026);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateWinterCheck', () => {
|
||||
it('accepts a plain autumn check', () => {
|
||||
expect(
|
||||
validateWinterCheck({ season: 'AUTUMN', outcome: null, lossCause: null }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an outcome on an autumn check', () => {
|
||||
expect(
|
||||
validateWinterCheck({
|
||||
season: 'AUTUMN',
|
||||
outcome: 'SURVIVED',
|
||||
lossCause: null,
|
||||
}),
|
||||
).toMatch(/Einwinterung/);
|
||||
});
|
||||
|
||||
it('requires an outcome on a spring check', () => {
|
||||
expect(
|
||||
validateWinterCheck({ season: 'SPRING', outcome: null, lossCause: null }),
|
||||
).toMatch(/Ergebnis/);
|
||||
});
|
||||
|
||||
it('requires a loss cause exactly when the colony was lost', () => {
|
||||
expect(
|
||||
validateWinterCheck({
|
||||
season: 'SPRING',
|
||||
outcome: 'LOST',
|
||||
lossCause: null,
|
||||
}),
|
||||
).toMatch(/Verlustursache/);
|
||||
expect(
|
||||
validateWinterCheck({
|
||||
season: 'SPRING',
|
||||
outcome: 'SURVIVED',
|
||||
lossCause: 'DEAD_OR_EMPTY',
|
||||
}),
|
||||
).toMatch(/Verlustursache/);
|
||||
expect(
|
||||
validateWinterCheck({
|
||||
season: 'SPRING',
|
||||
outcome: 'LOST',
|
||||
lossCause: 'QUEEN_PROBLEM',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function check(overrides: Partial<WinterSummarySource>): WinterSummarySource {
|
||||
return {
|
||||
colonyId: 'c1',
|
||||
apiaryId: 'a',
|
||||
apiaryName: 'Stand A',
|
||||
season: 'AUTUMN',
|
||||
outcome: null,
|
||||
lossCause: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('summarizeWinter', () => {
|
||||
it('counts outcomes against the colonies that went into winter', () => {
|
||||
const summary = summarizeWinter([
|
||||
check({ colonyId: 'c1' }),
|
||||
check({ colonyId: 'c1', season: 'SPRING', outcome: 'SURVIVED' }),
|
||||
check({ colonyId: 'c2' }),
|
||||
check({
|
||||
colonyId: 'c2',
|
||||
season: 'SPRING',
|
||||
outcome: 'LOST',
|
||||
lossCause: 'DEAD_OR_EMPTY',
|
||||
}),
|
||||
check({ colonyId: 'c3' }),
|
||||
check({ colonyId: 'c3', season: 'SPRING', outcome: 'WEAK' }),
|
||||
check({ colonyId: 'c4' }),
|
||||
]);
|
||||
|
||||
expect(summary.total).toMatchObject({
|
||||
wintered: 4,
|
||||
survived: 1,
|
||||
weak: 1,
|
||||
lost: 1,
|
||||
lostByCause: { DEAD_OR_EMPTY: 1, QUEEN_PROBLEM: 0, NATURAL_DISASTER: 0 },
|
||||
pendingSpring: 1,
|
||||
springWithoutAutumn: 0,
|
||||
lossRatePercent: 25,
|
||||
isProvisional: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('never counts a colony without spring check as survived', () => {
|
||||
const summary = summarizeWinter([check({ colonyId: 'c1' })]);
|
||||
expect(summary.total.survived).toBe(0);
|
||||
expect(summary.total.pendingSpring).toBe(1);
|
||||
expect(summary.total.isProvisional).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps spring checks without autumn check out of the loss rate', () => {
|
||||
const summary = summarizeWinter([
|
||||
check({ colonyId: 'c1' }),
|
||||
check({ colonyId: 'c1', season: 'SPRING', outcome: 'SURVIVED' }),
|
||||
check({
|
||||
colonyId: 'c9',
|
||||
season: 'SPRING',
|
||||
outcome: 'LOST',
|
||||
lossCause: 'NATURAL_DISASTER',
|
||||
}),
|
||||
]);
|
||||
expect(summary.total).toMatchObject({
|
||||
wintered: 1,
|
||||
lost: 0,
|
||||
springWithoutAutumn: 1,
|
||||
lossRatePercent: 0,
|
||||
isProvisional: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no loss rate when nothing was wintered', () => {
|
||||
expect(summarizeWinter([]).total.lossRatePercent).toBeNull();
|
||||
});
|
||||
|
||||
it('groups by apiary, sorted by name', () => {
|
||||
const summary = summarizeWinter([
|
||||
check({ colonyId: 'c1', apiaryId: 'b', apiaryName: 'Wald' }),
|
||||
check({ colonyId: 'c2', apiaryId: 'a', apiaryName: 'Obstwiese' }),
|
||||
]);
|
||||
expect(summary.apiaries.map((apiary) => apiary.apiaryName)).toEqual([
|
||||
'Obstwiese',
|
||||
'Wald',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type {
|
||||
ColonyLossCause,
|
||||
WinterCheckSeason,
|
||||
WinterOutcome,
|
||||
} from '../../generated/prisma';
|
||||
|
||||
/**
|
||||
* Checks from July on belong to the winter starting that year, earlier ones
|
||||
* to the winter that started the year before (Winter 2026/27 = 2026). The cut
|
||||
* sits between the latest Auswinterung (spring) and the earliest
|
||||
* Einwinterung preparations (late summer).
|
||||
*/
|
||||
export function winterYearFor(date: Date): number {
|
||||
const year = date.getUTCFullYear();
|
||||
return date.getUTCMonth() >= 6 ? year : year - 1;
|
||||
}
|
||||
|
||||
export interface WinterCheckShape {
|
||||
season: WinterCheckSeason;
|
||||
outcome: WinterOutcome | null;
|
||||
lossCause: ColonyLossCause | null;
|
||||
}
|
||||
|
||||
/** German error message, or null when the combination is consistent. */
|
||||
export function validateWinterCheck(check: WinterCheckShape): string | null {
|
||||
if (check.season === 'AUTUMN') {
|
||||
return check.outcome || check.lossCause
|
||||
? 'Eine Einwinterung hat kein Ergebnis und keine Verlustursache - das gehört zur Auswinterung.'
|
||||
: null;
|
||||
}
|
||||
if (!check.outcome) {
|
||||
return 'Bei der Auswinterung bitte das Ergebnis angeben (überlebt, schwach oder verloren).';
|
||||
}
|
||||
if (check.outcome === 'LOST' && !check.lossCause) {
|
||||
return 'Bei einem verlorenen Volk bitte die Verlustursache angeben.';
|
||||
}
|
||||
if (check.outcome !== 'LOST' && check.lossCause) {
|
||||
return 'Eine Verlustursache gibt es nur bei einem verlorenen Volk.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface WinterSummarySource extends WinterCheckShape {
|
||||
colonyId: string;
|
||||
apiaryId: string;
|
||||
apiaryName: string;
|
||||
}
|
||||
|
||||
export interface WinterCounts {
|
||||
/** Colonies with an Einwinterung record - the denominator of the loss rate. */
|
||||
wintered: number;
|
||||
survived: number;
|
||||
weak: number;
|
||||
lost: number;
|
||||
lostByCause: Record<ColonyLossCause, number>;
|
||||
/** Wintered colonies without Auswinterung yet: unknown, never "survived". */
|
||||
pendingSpring: number;
|
||||
/** Auswinterung without Einwinterung: shown, but outside the loss rate. */
|
||||
springWithoutAutumn: number;
|
||||
/** lost / wintered as in the COLOSS monitoring; null if nothing wintered. */
|
||||
lossRatePercent: number | null;
|
||||
/** True while pendingSpring > 0 - the rate can still rise. */
|
||||
isProvisional: boolean;
|
||||
}
|
||||
|
||||
export interface WinterSummary {
|
||||
total: WinterCounts;
|
||||
apiaries: (WinterCounts & { apiaryId: string; apiaryName: string })[];
|
||||
}
|
||||
|
||||
function countWinter(checks: WinterSummarySource[]): WinterCounts {
|
||||
const autumn = new Set(
|
||||
checks.filter((c) => c.season === 'AUTUMN').map((c) => c.colonyId),
|
||||
);
|
||||
const counts: WinterCounts = {
|
||||
wintered: autumn.size,
|
||||
survived: 0,
|
||||
weak: 0,
|
||||
lost: 0,
|
||||
lostByCause: { DEAD_OR_EMPTY: 0, QUEEN_PROBLEM: 0, NATURAL_DISASTER: 0 },
|
||||
pendingSpring: 0,
|
||||
springWithoutAutumn: 0,
|
||||
lossRatePercent: null,
|
||||
isProvisional: false,
|
||||
};
|
||||
const springChecked = new Set<string>();
|
||||
for (const check of checks) {
|
||||
if (check.season !== 'SPRING') continue;
|
||||
springChecked.add(check.colonyId);
|
||||
if (!autumn.has(check.colonyId)) {
|
||||
counts.springWithoutAutumn += 1;
|
||||
continue;
|
||||
}
|
||||
if (check.outcome === 'SURVIVED') counts.survived += 1;
|
||||
if (check.outcome === 'WEAK') counts.weak += 1;
|
||||
if (check.outcome === 'LOST') {
|
||||
counts.lost += 1;
|
||||
if (check.lossCause) counts.lostByCause[check.lossCause] += 1;
|
||||
}
|
||||
}
|
||||
counts.pendingSpring = [...autumn].filter(
|
||||
(colonyId) => !springChecked.has(colonyId),
|
||||
).length;
|
||||
counts.isProvisional = counts.pendingSpring > 0;
|
||||
counts.lossRatePercent =
|
||||
counts.wintered > 0
|
||||
? Math.round((counts.lost / counts.wintered) * 1000) / 10
|
||||
: null;
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function summarizeWinter(checks: WinterSummarySource[]): WinterSummary {
|
||||
const byApiary = new Map<string, WinterSummarySource[]>();
|
||||
for (const check of checks) {
|
||||
byApiary.set(check.apiaryId, [
|
||||
...(byApiary.get(check.apiaryId) ?? []),
|
||||
check,
|
||||
]);
|
||||
}
|
||||
const apiaries = [...byApiary.entries()]
|
||||
.map(([apiaryId, apiaryChecks]) => ({
|
||||
apiaryId,
|
||||
apiaryName: apiaryChecks[0].apiaryName,
|
||||
...countWinter(apiaryChecks),
|
||||
}))
|
||||
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
|
||||
return { total: countWinter(checks), apiaries };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
|
||||
import { UpdateWinterCheckDto } from './dto/update-winter-check.dto';
|
||||
import { WinterCheckQueryDto } from './dto/winter-check-query.dto';
|
||||
import { winterYearFor } from './winter-check-rules';
|
||||
import { WinterChecksService } from './winter-checks.service';
|
||||
|
||||
@ApiTags('winter-checks')
|
||||
@ApiBearerAuth()
|
||||
@Controller('winter-checks')
|
||||
export class WinterChecksController {
|
||||
constructor(private readonly winterChecksService: WinterChecksService) {}
|
||||
|
||||
@RequirePermission('winter-checks:read')
|
||||
@Get()
|
||||
findAll(@Query() query: WinterCheckQueryDto) {
|
||||
return this.winterChecksService.findAll(query);
|
||||
}
|
||||
|
||||
/** Loss figures of one winter; without winterYear the current winter. */
|
||||
@RequirePermission('winter-checks:read')
|
||||
@Get('summary')
|
||||
summary(@Query() query: WinterCheckQueryDto) {
|
||||
return this.winterChecksService.summary(
|
||||
query.winterYear ?? winterYearFor(new Date()),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('winter-checks:read')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.winterChecksService.findById(id);
|
||||
}
|
||||
|
||||
@RequirePermission('winter-checks:create')
|
||||
@Idempotent()
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateWinterCheckDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.winterChecksService.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('winter-checks:update')
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateWinterCheckDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.winterChecksService.update(id, dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ColonyWinterChecksController } from './colony-winter-checks.controller';
|
||||
import { WinterChecksController } from './winter-checks.controller';
|
||||
import { WinterChecksService } from './winter-checks.service';
|
||||
|
||||
@Module({
|
||||
controllers: [WinterChecksController, ColonyWinterChecksController],
|
||||
providers: [WinterChecksService],
|
||||
exports: [WinterChecksService],
|
||||
})
|
||||
export class WinterChecksModule {}
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { Colony, Prisma, WinterCheck } from '../../generated/prisma';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
|
||||
import { UpdateWinterCheckDto } from './dto/update-winter-check.dto';
|
||||
import { WinterCheckQueryDto } from './dto/winter-check-query.dto';
|
||||
import {
|
||||
summarizeWinter,
|
||||
validateWinterCheck,
|
||||
WinterCheckShape,
|
||||
WinterSummary,
|
||||
winterYearFor,
|
||||
} from './winter-check-rules';
|
||||
|
||||
const WITH_COLONY = {
|
||||
colony: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
status: true,
|
||||
apiaryId: true,
|
||||
apiary: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.WinterCheckInclude;
|
||||
|
||||
export type WinterCheckWithColony = Prisma.WinterCheckGetPayload<{
|
||||
include: typeof WITH_COLONY;
|
||||
}>;
|
||||
|
||||
const DUPLICATE_MESSAGE =
|
||||
'Für dieses Volk gibt es in diesem Winter schon einen Eintrag dieser Art - bitte den vorhandenen bearbeiten.';
|
||||
|
||||
@Injectable()
|
||||
export class WinterChecksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
findAll(query: WinterCheckQueryDto): Promise<WinterCheckWithColony[]> {
|
||||
return this.prisma.winterCheck.findMany({
|
||||
where: {
|
||||
winterYear: query.winterYear,
|
||||
colony: query.apiaryId ? { apiaryId: query.apiaryId } : undefined,
|
||||
},
|
||||
include: WITH_COLONY,
|
||||
orderBy: [{ winterYear: 'desc' }, { date: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
findByColony(colonyId: string): Promise<WinterCheck[]> {
|
||||
return this.prisma.winterCheck.findMany({
|
||||
where: { colonyId },
|
||||
orderBy: [{ winterYear: 'desc' }, { season: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WinterCheckWithColony> {
|
||||
const check = await this.prisma.winterCheck.findUnique({
|
||||
where: { id },
|
||||
include: WITH_COLONY,
|
||||
});
|
||||
if (!check) {
|
||||
throw new NotFoundException('Überwinterungs-Eintrag nicht gefunden');
|
||||
}
|
||||
return check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loss figures per winter. Colonies are grouped by the apiary they stand at
|
||||
* now - a dead colony keeps its last apiary, so the loss stays where it
|
||||
* happened unless the colony was moved after the winter.
|
||||
*/
|
||||
async summary(winterYear: number): Promise<WinterSummary> {
|
||||
const checks = await this.prisma.winterCheck.findMany({
|
||||
where: { winterYear },
|
||||
include: WITH_COLONY,
|
||||
});
|
||||
return summarizeWinter(
|
||||
checks.map((check) => ({
|
||||
colonyId: check.colonyId,
|
||||
apiaryId: check.colony.apiaryId,
|
||||
apiaryName: check.colony.apiary.name,
|
||||
season: check.season,
|
||||
outcome: check.outcome,
|
||||
lossCause: check.lossCause,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateWinterCheckDto,
|
||||
actingUserId: string,
|
||||
): Promise<WinterCheck> {
|
||||
const { markColonyDeceased, ...fields } = dto;
|
||||
const colony = await this.findColony(fields.colonyId);
|
||||
// Wintering a colony that no longer exists is a mistake; documenting the
|
||||
// Auswinterung of one that already died is not - that is the loss record.
|
||||
if (fields.season === 'AUTUMN' && !isLivingColonyStatus(colony.status)) {
|
||||
throw new BadRequestException(
|
||||
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann nicht eingewintert werden`,
|
||||
);
|
||||
}
|
||||
const date = new Date(fields.date);
|
||||
const data = {
|
||||
...fields,
|
||||
date,
|
||||
winterYear: fields.winterYear ?? winterYearFor(date),
|
||||
outcome: fields.outcome ?? null,
|
||||
lossCause: fields.lossCause ?? null,
|
||||
};
|
||||
this.assertConsistent(data);
|
||||
|
||||
const check = await this.withDuplicateMessage(() =>
|
||||
this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.winterCheck.create({
|
||||
data: { ...data, userId: actingUserId },
|
||||
});
|
||||
await this.markDeceasedIfRequested(
|
||||
tx,
|
||||
colony,
|
||||
created,
|
||||
markColonyDeceased,
|
||||
actingUserId,
|
||||
);
|
||||
return created;
|
||||
}),
|
||||
);
|
||||
await this.auditLog.record({
|
||||
entityType: 'WinterCheck',
|
||||
entityId: check.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: check,
|
||||
});
|
||||
return check;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateWinterCheckDto,
|
||||
actingUserId: string,
|
||||
): Promise<WinterCheck> {
|
||||
const { markColonyDeceased, ...fields } = dto;
|
||||
const { colony: _currentColony, ...before } = await this.findById(id);
|
||||
void _currentColony;
|
||||
const colony = await this.findColony(fields.colonyId ?? before.colonyId);
|
||||
const date = fields.date ? new Date(fields.date) : before.date;
|
||||
const merged = mergeOutcome(before, fields);
|
||||
this.assertConsistent(merged);
|
||||
|
||||
const check = await this.withDuplicateMessage(() =>
|
||||
this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.winterCheck.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...fields,
|
||||
...merged,
|
||||
date,
|
||||
winterYear:
|
||||
fields.winterYear ??
|
||||
(fields.date ? winterYearFor(date) : undefined),
|
||||
},
|
||||
});
|
||||
await this.markDeceasedIfRequested(
|
||||
tx,
|
||||
colony,
|
||||
updated,
|
||||
markColonyDeceased,
|
||||
actingUserId,
|
||||
);
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
await this.auditLog.record({
|
||||
entityType: 'WinterCheck',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: check,
|
||||
});
|
||||
return check;
|
||||
}
|
||||
|
||||
private assertConsistent(check: WinterCheckShape): void {
|
||||
const problem = validateWinterCheck(check);
|
||||
if (problem) {
|
||||
throw new BadRequestException(problem);
|
||||
}
|
||||
}
|
||||
|
||||
private async findColony(colonyId: string): Promise<Colony> {
|
||||
const colony = await this.prisma.colony.findUnique({
|
||||
where: { id: colonyId },
|
||||
});
|
||||
if (!colony) {
|
||||
throw new NotFoundException('Volk nicht gefunden');
|
||||
}
|
||||
return colony;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only on explicit request, only for a LOST outcome and only while the
|
||||
* colony is still recorded as living - a loss must never be booked
|
||||
* implicitly, and an already dissolved or merged colony keeps its status.
|
||||
*/
|
||||
private async markDeceasedIfRequested(
|
||||
tx: Prisma.TransactionClient,
|
||||
colony: Colony,
|
||||
check: WinterCheck,
|
||||
requested: boolean | undefined,
|
||||
actingUserId: string,
|
||||
): Promise<void> {
|
||||
if (!requested || check.outcome !== 'LOST') return;
|
||||
if (!isLivingColonyStatus(colony.status)) return;
|
||||
const after = await tx.colony.update({
|
||||
where: { id: colony.id },
|
||||
data: { status: 'DECEASED', deactivatedAt: check.date },
|
||||
});
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
entityType: 'Colony',
|
||||
entityId: colony.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: colony,
|
||||
afterData: after,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async withDuplicateMessage<T>(run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException(DUPLICATE_MESSAGE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an edit to season/outcome/lossCause. Switching to an Einwinterung,
|
||||
* or away from LOST, clears the fields that no longer apply instead of making
|
||||
* the caller blank them by hand - an explicitly sent value still wins and is
|
||||
* then validated.
|
||||
*/
|
||||
export function mergeOutcome(
|
||||
before: WinterCheckShape,
|
||||
fields: Partial<WinterCheckShape>,
|
||||
): WinterCheckShape {
|
||||
const season = fields.season ?? before.season;
|
||||
let outcome = fields.outcome !== undefined ? fields.outcome : before.outcome;
|
||||
if (season === 'AUTUMN' && fields.outcome === undefined) {
|
||||
outcome = null;
|
||||
}
|
||||
let lossCause =
|
||||
fields.lossCause !== undefined ? fields.lossCause : before.lossCause;
|
||||
if (outcome !== 'LOST' && fields.lossCause === undefined) {
|
||||
lossCause = null;
|
||||
}
|
||||
return { season, outcome, lossCause };
|
||||
}
|
||||
Reference in new issue
Block a user