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
@@ -105,6 +105,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
|
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
|
||||||
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
|
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
|
||||||
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
|
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
|
||||||
|
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
|
||||||
|
|
||||||
## 7. Kubernetes
|
## 7. Kubernetes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WinterCheckSeason" AS ENUM ('AUTUMN', 'SPRING');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WinterOutcome" AS ENUM ('SURVIVED', 'WEAK', 'LOST');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ColonyLossCause" AS ENUM ('DEAD_OR_EMPTY', 'QUEEN_PROBLEM', 'NATURAL_DISASTER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WinterCheck" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"colonyId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"winterYear" INTEGER NOT NULL,
|
||||||
|
"season" "WinterCheckSeason" NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"occupiedSeams" INTEGER,
|
||||||
|
"feedStockKg" DOUBLE PRECISION,
|
||||||
|
"queenPresent" BOOLEAN,
|
||||||
|
"outcome" "WinterOutcome",
|
||||||
|
"lossCause" "ColonyLossCause",
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WinterCheck_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WinterCheck_winterYear_idx" ON "WinterCheck"("winterYear");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WinterCheck_colonyId_winterYear_season_key" ON "WinterCheck"("colonyId", "winterYear", "season");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WinterCheck" ADD CONSTRAINT "WinterCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Permissions: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- system roles (ADMIN). Winter checks are everyday beekeeping records, so the
|
||||||
|
-- other roles get the same access they have to inspections.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:read', 'winter-checks', 'read', 'Ansehen', 'Überwinterung', 850),
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:create', 'winter-checks', 'create', 'Anlegen', 'Überwinterung', 851),
|
||||||
|
(gen_random_uuid()::text, 'winter-checks:update', 'winter-checks', 'update', 'Bearbeiten', 'Überwinterung', 852)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE p.resource = 'winter-checks'
|
||||||
|
AND (
|
||||||
|
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||||
|
OR (r.key IN ('READONLY', 'API_CLIENT') AND p.action = 'read')
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -93,6 +93,7 @@ model User {
|
|||||||
inspections Inspection[]
|
inspections Inspection[]
|
||||||
varroaMeasurements VarroaMeasurement[]
|
varroaMeasurements VarroaMeasurement[]
|
||||||
swarmControlChecks SwarmControlCheck[]
|
swarmControlChecks SwarmControlCheck[]
|
||||||
|
winterChecks WinterCheck[]
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
feedings Feeding[]
|
feedings Feeding[]
|
||||||
harvests Harvest[]
|
harvests Harvest[]
|
||||||
@@ -270,6 +271,7 @@ model Colony {
|
|||||||
inspections Inspection[]
|
inspections Inspection[]
|
||||||
varroaMeasurements VarroaMeasurement[]
|
varroaMeasurements VarroaMeasurement[]
|
||||||
swarmControlChecks SwarmControlCheck[]
|
swarmControlChecks SwarmControlCheck[]
|
||||||
|
winterChecks WinterCheck[]
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
feedings Feeding[]
|
feedings Feeding[]
|
||||||
harvests HarvestColony[]
|
harvests HarvestColony[]
|
||||||
@@ -595,6 +597,54 @@ model SwarmControlCheck {
|
|||||||
@@index([colonyId, date])
|
@@index([colonyId, date])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ein- und Auswinterung, Winterverluste
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum WinterCheckSeason {
|
||||||
|
AUTUMN // Einwinterung
|
||||||
|
SPRING // Auswinterung
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WinterOutcome {
|
||||||
|
SURVIVED
|
||||||
|
WEAK
|
||||||
|
LOST
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verlustkategorien des COLOSS-Monitorings: totes/leeres Volk, unloesbares
|
||||||
|
// Koeniginnenproblem, Naturereignis. Bewusst keine Diagnose (Varroa, Virus
|
||||||
|
// ...): die gehoert, wenn ueberhaupt bekannt, in die Notiz.
|
||||||
|
enum ColonyLossCause {
|
||||||
|
DEAD_OR_EMPTY
|
||||||
|
QUEEN_PROBLEM
|
||||||
|
NATURAL_DISASTER
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Einwinterung und eine Auswinterung je Volk und Winter. winterYear ist
|
||||||
|
// das Jahr, in dem der Winter beginnt (Winter 2026/27 = 2026).
|
||||||
|
model WinterCheck {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
colonyId String
|
||||||
|
colony Colony @relation(fields: [colonyId], references: [id])
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
winterYear Int
|
||||||
|
season WinterCheckSeason
|
||||||
|
date DateTime
|
||||||
|
occupiedSeams Int?
|
||||||
|
feedStockKg Float?
|
||||||
|
queenPresent Boolean?
|
||||||
|
outcome WinterOutcome?
|
||||||
|
lossCause ColonyLossCause?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([colonyId, winterYear, season])
|
||||||
|
@@index([winterYear])
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Behandlung / Tierarzneimittel (Abschnitt 4.11)
|
// Behandlung / Tierarzneimittel (Abschnitt 4.11)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { ImportModule } from './import/import.module';
|
|||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||||
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
||||||
|
import { WinterChecksModule } from './winter-checks/winter-checks.module';
|
||||||
import { WeatherModule } from './weather/weather.module';
|
import { WeatherModule } from './weather/weather.module';
|
||||||
import { ProfileModule } from './profile/profile.module';
|
import { ProfileModule } from './profile/profile.module';
|
||||||
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||||
@@ -95,6 +96,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
|||||||
PublicModule,
|
PublicModule,
|
||||||
TaskTemplatesModule,
|
TaskTemplatesModule,
|
||||||
SwarmControlModule,
|
SwarmControlModule,
|
||||||
|
WinterChecksModule,
|
||||||
WeatherModule,
|
WeatherModule,
|
||||||
ProfileModule,
|
ProfileModule,
|
||||||
SiteSettingsModule,
|
SiteSettingsModule,
|
||||||
|
|||||||
@@ -111,6 +111,18 @@ export const PERMISSION_CATALOG = [
|
|||||||
],
|
],
|
||||||
800,
|
800,
|
||||||
),
|
),
|
||||||
|
// Seeded with role grants by the winter_checks migration (everyday records
|
||||||
|
// like inspections, not an admin-only area).
|
||||||
|
...group(
|
||||||
|
'Überwinterung',
|
||||||
|
'winter-checks',
|
||||||
|
[
|
||||||
|
['read', 'Ansehen'],
|
||||||
|
['create', 'Anlegen'],
|
||||||
|
['update', 'Bearbeiten'],
|
||||||
|
],
|
||||||
|
850,
|
||||||
|
),
|
||||||
...group(
|
...group(
|
||||||
'Behandlungen',
|
'Behandlungen',
|
||||||
'treatments',
|
'treatments',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const BUSINESS_ENTITY_KEYS = [
|
|||||||
'task',
|
'task',
|
||||||
'varroaMeasurement',
|
'varroaMeasurement',
|
||||||
'swarmControlCheck',
|
'swarmControlCheck',
|
||||||
|
'winterCheck',
|
||||||
'treatment',
|
'treatment',
|
||||||
'feeding',
|
'feeding',
|
||||||
'harvest',
|
'harvest',
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export class BackupService {
|
|||||||
data.task = await this.prisma.task.findMany();
|
data.task = await this.prisma.task.findMany();
|
||||||
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||||
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||||
|
data.winterCheck = await this.prisma.winterCheck.findMany();
|
||||||
data.treatment = await this.prisma.treatment.findMany();
|
data.treatment = await this.prisma.treatment.findMany();
|
||||||
data.feeding = await this.prisma.feeding.findMany();
|
data.feeding = await this.prisma.feeding.findMany();
|
||||||
data.harvest = await this.prisma.harvest.findMany();
|
data.harvest = await this.prisma.harvest.findMany();
|
||||||
@@ -430,6 +431,21 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
|
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
|
||||||
|
|
||||||
|
for (const row of rowsOf('winterCheck')) {
|
||||||
|
await tx.winterCheck.upsert({
|
||||||
|
where: { id: row.id as string },
|
||||||
|
create: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
update: {
|
||||||
|
...row,
|
||||||
|
userId: resolveUserId(asString(row, 'userId')),
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
imported.winterCheck = rowsOf('winterCheck').length;
|
||||||
|
|
||||||
for (const row of rowsOf('treatment')) {
|
for (const row of rowsOf('treatment')) {
|
||||||
await tx.treatment.upsert({
|
await tx.treatment.upsert({
|
||||||
where: { id: row.id as string },
|
where: { id: row.id as string },
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { login } from './utils/api';
|
||||||
|
|
||||||
|
interface WinterCheckBody {
|
||||||
|
id: string;
|
||||||
|
colonyId: string;
|
||||||
|
winterYear: number;
|
||||||
|
season: string;
|
||||||
|
outcome: string | null;
|
||||||
|
lossCause: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WinterSummaryBody {
|
||||||
|
total: {
|
||||||
|
wintered: number;
|
||||||
|
survived: number;
|
||||||
|
lost: number;
|
||||||
|
pendingSpring: number;
|
||||||
|
lossRatePercent: number | null;
|
||||||
|
isProvisional: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A winter far in the past so the summary only sees this spec's records.
|
||||||
|
const WINTER = 2001;
|
||||||
|
|
||||||
|
describe('Winter checks (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const stamp = Date.now();
|
||||||
|
const beekeeperEmail = `winter-e2e-beekeeper-${stamp}@example.com`;
|
||||||
|
const readonlyEmail = `winter-e2e-readonly-${stamp}@example.com`;
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
let userIds: string[] = [];
|
||||||
|
let beekeeperToken: string;
|
||||||
|
let readonlyToken: string;
|
||||||
|
let apiaryId: string;
|
||||||
|
let survivorId: string;
|
||||||
|
let lostId: string;
|
||||||
|
let pendingId: string;
|
||||||
|
|
||||||
|
const api = () => request(app.getHttpServer());
|
||||||
|
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const beekeeper = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: beekeeperEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Beekeeper',
|
||||||
|
role: { connect: { key: 'BEEKEEPER' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const readonly = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: readonlyEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: 'Readonly',
|
||||||
|
role: { connect: { key: 'READONLY' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userIds = [beekeeper.id, readonly.id];
|
||||||
|
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
||||||
|
readonlyToken = (await login(app, readonlyEmail, password)).accessToken;
|
||||||
|
|
||||||
|
const apiary = await prisma.apiary.create({
|
||||||
|
data: { name: `Winterstand ${stamp}` },
|
||||||
|
});
|
||||||
|
apiaryId = apiary.id;
|
||||||
|
const [survivor, lost, pending] = await Promise.all(
|
||||||
|
['A', 'B', 'C'].map((suffix) =>
|
||||||
|
prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `W-${stamp}-${suffix}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
survivorId = survivor.id;
|
||||||
|
lostId = lost.id;
|
||||||
|
pendingId = pending.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const colonyIds = [survivorId, lostId, pendingId];
|
||||||
|
await prisma.winterCheck.deleteMany({
|
||||||
|
where: { colonyId: { in: colonyIds } },
|
||||||
|
});
|
||||||
|
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
|
||||||
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function winterIn(colonyId: string): Promise<WinterCheckBody> {
|
||||||
|
const response = await api()
|
||||||
|
.post('/api/v1/winter-checks')
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.send({
|
||||||
|
colonyId,
|
||||||
|
season: 'AUTUMN',
|
||||||
|
date: `${WINTER}-10-01`,
|
||||||
|
occupiedSeams: 8,
|
||||||
|
feedStockKg: 18,
|
||||||
|
queenPresent: true,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
return response.body as WinterCheckBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('records an Einwinterung and derives the winter from the date', async () => {
|
||||||
|
const check = await winterIn(survivorId);
|
||||||
|
expect(check).toMatchObject({
|
||||||
|
winterYear: WINTER,
|
||||||
|
season: 'AUTUMN',
|
||||||
|
outcome: null,
|
||||||
|
});
|
||||||
|
await winterIn(lostId);
|
||||||
|
await winterIn(pendingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a second Einwinterung of the same colony in the same winter with 409', () => {
|
||||||
|
return api()
|
||||||
|
.post('/api/v1/winter-checks')
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.send({ colonyId: survivorId, season: 'AUTUMN', date: `${WINTER}-10-20` })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an Auswinterung without outcome and a loss without cause', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/winter-checks')
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.send({ colonyId: survivorId, season: 'SPRING', date: '2002-03-20' })
|
||||||
|
.expect(400);
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/winter-checks')
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.send({
|
||||||
|
colonyId: lostId,
|
||||||
|
season: 'SPRING',
|
||||||
|
date: '2002-03-20',
|
||||||
|
outcome: 'LOST',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('books a loss and marks the colony deceased only when asked to', async () => {
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/winter-checks')
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.send({
|
||||||
|
colonyId: survivorId,
|
||||||
|
season: 'SPRING',
|
||||||
|
date: '2002-03-20',
|
||||||
|
outcome: 'SURVIVED',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const lost = await api()
|
||||||
|
.post('/api/v1/winter-checks')
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.send({
|
||||||
|
colonyId: lostId,
|
||||||
|
season: 'SPRING',
|
||||||
|
date: '2002-03-20',
|
||||||
|
outcome: 'LOST',
|
||||||
|
lossCause: 'DEAD_OR_EMPTY',
|
||||||
|
markColonyDeceased: true,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect((lost.body as WinterCheckBody).winterYear).toBe(WINTER);
|
||||||
|
|
||||||
|
const colony = await prisma.colony.findUniqueOrThrow({
|
||||||
|
where: { id: lostId },
|
||||||
|
});
|
||||||
|
expect(colony.status).toBe('DECEASED');
|
||||||
|
expect(colony.deactivatedAt?.toISOString()).toBe(
|
||||||
|
'2002-03-20T00:00:00.000Z',
|
||||||
|
);
|
||||||
|
const survivor = await prisma.colony.findUniqueOrThrow({
|
||||||
|
where: { id: survivorId },
|
||||||
|
});
|
||||||
|
expect(survivor.status).toBe('ACTIVE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to winter a colony that has died', () => {
|
||||||
|
return api()
|
||||||
|
.post('/api/v1/winter-checks')
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.send({ colonyId: lostId, season: 'AUTUMN', date: '2002-10-01' })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarizes the winter, keeping unchecked colonies out of "survived"', async () => {
|
||||||
|
const response = await api()
|
||||||
|
.get(`/api/v1/winter-checks/summary?winterYear=${WINTER}`)
|
||||||
|
.set(auth(readonlyToken))
|
||||||
|
.expect(200);
|
||||||
|
expect((response.body as WinterSummaryBody).total).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
wintered: 3,
|
||||||
|
survived: 1,
|
||||||
|
lost: 1,
|
||||||
|
pendingSpring: 1,
|
||||||
|
lossRatePercent: 33.3,
|
||||||
|
isProvisional: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every field editable and clears the cause when the outcome changes', async () => {
|
||||||
|
const checks = (
|
||||||
|
await api()
|
||||||
|
.get(`/api/v1/colonies/${lostId}/winter-checks`)
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.expect(200)
|
||||||
|
).body as WinterCheckBody[];
|
||||||
|
const spring = checks.find((check) => check.season === 'SPRING');
|
||||||
|
const response = await api()
|
||||||
|
.patch(`/api/v1/winter-checks/${spring?.id}`)
|
||||||
|
.set(auth(beekeeperToken))
|
||||||
|
.send({ outcome: 'WEAK' })
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toMatchObject({ outcome: 'WEAK', lossCause: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets read-only users view but not record', async () => {
|
||||||
|
await api()
|
||||||
|
.get(`/api/v1/winter-checks?winterYear=${WINTER}&apiaryId=${apiaryId}`)
|
||||||
|
.set(auth(readonlyToken))
|
||||||
|
.expect(200);
|
||||||
|
await api()
|
||||||
|
.post('/api/v1/winter-checks')
|
||||||
|
.set(auth(readonlyToken))
|
||||||
|
.send({ colonyId: pendingId, season: 'SPRING', date: '2002-03-20' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Scissors,
|
Scissors,
|
||||||
Search,
|
Search,
|
||||||
|
Snowflake,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Users,
|
Users,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
@@ -58,6 +59,12 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
|
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
|
||||||
{ label: "Völker", href: "/colonies", icon: Layers },
|
{ label: "Völker", href: "/colonies", icon: Layers },
|
||||||
{ label: "Königinnen", href: "/queens", icon: Crown },
|
{ label: "Königinnen", href: "/queens", icon: Crown },
|
||||||
|
{
|
||||||
|
label: "Überwinterung",
|
||||||
|
href: "/winter",
|
||||||
|
icon: Snowflake,
|
||||||
|
permission: "winter-checks:read",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChevronLeft, ChevronRight, Snowflake } from "lucide-react";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||||
|
import { fetchColonies } from "@/lib/colonies/api";
|
||||||
|
import { fetchWinterChecks, fetchWinterSummary } from "@/lib/winter-checks/api";
|
||||||
|
import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels";
|
||||||
|
import { ColonyLossCause, WinterCheckWithColony, WinterCounts } from "@/lib/winter-checks/types";
|
||||||
|
import { buildWinterRows, WinterRow, winterLabel, winterYearFor } from "@/lib/winter-checks/winter";
|
||||||
|
import { PageHeader } from "@/components/page-header";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { WinterCheckDialog, WinterCheckTarget } from "./winter-check-dialog";
|
||||||
|
|
||||||
|
const DATE = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit" });
|
||||||
|
|
||||||
|
function autumnText(check: WinterCheckWithColony): string {
|
||||||
|
const parts = [DATE.format(new Date(check.date))];
|
||||||
|
if (check.occupiedSeams !== null) parts.push(`${check.occupiedSeams} Gassen`);
|
||||||
|
if (check.feedStockKg !== null) {
|
||||||
|
parts.push(`${check.feedStockKg.toLocaleString("de-DE")} kg Futter`);
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCards({ counts }: { counts: WinterCounts }) {
|
||||||
|
// Before the first Auswinterung a rate of "0 %" would read as "no losses".
|
||||||
|
const hasSpringResults = counts.survived + counts.weak + counts.lost > 0;
|
||||||
|
const causes = (Object.keys(counts.lostByCause) as ColonyLossCause[]).filter(
|
||||||
|
(cause) => counts.lostByCause[cause] > 0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||||
|
<Stat label="Eingewintert" value={counts.wintered} />
|
||||||
|
<Stat label="Überlebt" value={counts.survived} />
|
||||||
|
<Stat label="Schwach" value={counts.weak} />
|
||||||
|
<Stat
|
||||||
|
label="Verloren"
|
||||||
|
value={counts.lost}
|
||||||
|
hint={causes.map((cause) => `${counts.lostByCause[cause]}× ${LOSS_CAUSE_LABELS[cause]}`).join(", ")}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label={counts.isProvisional ? "Verlustrate (vorläufig)" : "Verlustrate"}
|
||||||
|
value={
|
||||||
|
counts.lossRatePercent === null || !hasSpringResults
|
||||||
|
? "–"
|
||||||
|
: `${counts.lossRatePercent.toLocaleString("de-DE")} %`
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
counts.pendingSpring > 0
|
||||||
|
? `${counts.pendingSpring} Volk/Völker noch nicht ausgewintert`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value, hint }: { label: string; value: number | string; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="font-heading text-2xl font-semibold tabular-nums">{value}</p>
|
||||||
|
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowActions({
|
||||||
|
row,
|
||||||
|
canCreate,
|
||||||
|
canUpdate,
|
||||||
|
isSpringDue,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
row: WinterRow;
|
||||||
|
canCreate: boolean;
|
||||||
|
canUpdate: boolean;
|
||||||
|
/** The winter is over (January on) - Auswinterung also without Einwinterung record. */
|
||||||
|
isSpringDue: boolean;
|
||||||
|
onOpen: (season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{row.autumn ? (
|
||||||
|
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("AUTUMN", row.autumn)}>
|
||||||
|
Eingewintert {autumnText(row.autumn)}
|
||||||
|
</Button>
|
||||||
|
) : row.isLiving && canCreate ? (
|
||||||
|
<Button variant="outline" size="touch" onClick={() => onOpen("AUTUMN", null)}>
|
||||||
|
Einwintern
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-muted-foreground">Nicht eingewintert</span>
|
||||||
|
)}
|
||||||
|
{row.spring?.outcome ? (
|
||||||
|
<Button variant="ghost" size="sm" disabled={!canUpdate} onClick={() => onOpen("SPRING", row.spring)}>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
row.spring.outcome === "LOST"
|
||||||
|
? "destructive"
|
||||||
|
: row.spring.outcome === "WEAK"
|
||||||
|
? "warning"
|
||||||
|
: "default"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{WINTER_OUTCOME_LABELS[row.spring.outcome]}
|
||||||
|
</Badge>
|
||||||
|
{row.spring.lossCause ? LOSS_CAUSE_LABELS[row.spring.lossCause] : null}
|
||||||
|
</Button>
|
||||||
|
) : canCreate && (row.autumn || isSpringDue) ? (
|
||||||
|
<Button variant="outline" size="touch" onClick={() => onOpen("SPRING", null)}>
|
||||||
|
Auswintern
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WinterPage() {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const [winterYear, setWinterYear] = useState(() => winterYearFor(new Date()));
|
||||||
|
const [target, setTarget] = useState<WinterCheckTarget | null>(null);
|
||||||
|
const canCreate = hasPermission("winter-checks:create");
|
||||||
|
const canUpdate = hasPermission("winter-checks:update");
|
||||||
|
|
||||||
|
const checksQuery = useQuery({
|
||||||
|
queryKey: ["winter-checks", winterYear],
|
||||||
|
queryFn: () => fetchWinterChecks(winterYear),
|
||||||
|
});
|
||||||
|
const summaryQuery = useQuery({
|
||||||
|
queryKey: ["winter-checks", "summary", winterYear],
|
||||||
|
queryFn: () => fetchWinterSummary(winterYear),
|
||||||
|
});
|
||||||
|
const coloniesQuery = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies() });
|
||||||
|
const apiariesQuery = useQuery({ queryKey: ["apiaries"], queryFn: () => fetchApiaries() });
|
||||||
|
|
||||||
|
const groups = useMemo(
|
||||||
|
() =>
|
||||||
|
buildWinterRows(
|
||||||
|
coloniesQuery.data ?? [],
|
||||||
|
checksQuery.data ?? [],
|
||||||
|
new Map((apiariesQuery.data ?? []).map((apiary) => [apiary.id, apiary.name])),
|
||||||
|
),
|
||||||
|
[coloniesQuery.data, checksQuery.data, apiariesQuery.data],
|
||||||
|
);
|
||||||
|
// One action per row until the colony is wintered: from January of the
|
||||||
|
// following year on, Auswinterung is offered for every colony.
|
||||||
|
const isSpringDue = new Date() >= new Date(winterYear + 1, 0, 1);
|
||||||
|
const openAutumn = groups.reduce(
|
||||||
|
(sum, group) => sum + group.rows.filter((row) => row.isLiving && !row.autumn).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
function open(row: WinterRow, season: "AUTUMN" | "SPRING", existing: WinterCheckWithColony | null) {
|
||||||
|
setTarget({
|
||||||
|
colonyId: row.colonyId,
|
||||||
|
code: row.code,
|
||||||
|
isLiving: row.isLiving,
|
||||||
|
season,
|
||||||
|
winterYear,
|
||||||
|
existing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = checksQuery.isLoading || coloniesQuery.isLoading;
|
||||||
|
const isError = checksQuery.isError || coloniesQuery.isError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<PageHeader
|
||||||
|
icon={Snowflake}
|
||||||
|
title="Überwinterung"
|
||||||
|
description="Ein- und Auswinterung je Volk, Verluste mit Ursache."
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Vorheriger Winter"
|
||||||
|
onClick={() => setWinterYear((year) => year - 1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-36 text-center font-medium tabular-nums">{winterLabel(winterYear)}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Nächster Winter"
|
||||||
|
onClick={() => setWinterYear((year) => year + 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{openAutumn > 0 && canCreate && !isSpringDue ? (
|
||||||
|
<p className="rounded-xl bg-muted px-4 py-3 text-sm">
|
||||||
|
<span className="font-medium">Als Nächstes:</span> {openAutumn} Volk/Völker noch nicht
|
||||||
|
eingewintert.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{summaryQuery.data ? <SummaryCards counts={summaryQuery.data.total} /> : null}
|
||||||
|
|
||||||
|
{isError ? (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Daten konnten nicht geladen werden. Bitte die Seite neu laden.
|
||||||
|
</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Wird geladen…</p>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Snowflake}
|
||||||
|
title="Keine Völker"
|
||||||
|
description="Sobald Völker angelegt sind, lassen sie sich hier ein- und auswintern."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
groups.map((group) => (
|
||||||
|
<Card key={group.apiaryId}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{group.apiaryName}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{group.rows.map((row) => (
|
||||||
|
<li
|
||||||
|
key={row.colonyId}
|
||||||
|
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{row.code}
|
||||||
|
{!row.isLiving ? (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">(besteht nicht mehr)</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<RowActions
|
||||||
|
row={row}
|
||||||
|
canCreate={canCreate}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
isSpringDue={isSpringDue}
|
||||||
|
onOpen={(season, existing) => open(row, season, existing)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{target ? <WinterCheckDialog target={target} onClose={() => setTarget(null)} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ApiError } from "@/lib/auth/api-client";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
|
import { createWinterCheck, updateWinterCheck } from "@/lib/winter-checks/api";
|
||||||
|
import { LOSS_CAUSE_LABELS, WINTER_OUTCOME_LABELS } from "@/lib/winter-checks/labels";
|
||||||
|
import {
|
||||||
|
ColonyLossCause,
|
||||||
|
WinterCheck,
|
||||||
|
WinterCheckInput,
|
||||||
|
WinterCheckSeason,
|
||||||
|
WinterOutcome,
|
||||||
|
} from "@/lib/winter-checks/types";
|
||||||
|
import { winterLabel } from "@/lib/winter-checks/winter";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface WinterCheckTarget {
|
||||||
|
colonyId: string;
|
||||||
|
code: string;
|
||||||
|
isLiving: boolean;
|
||||||
|
season: WinterCheckSeason;
|
||||||
|
winterYear: number;
|
||||||
|
existing: WinterCheck | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUEEN_OPTIONS = { yes: "Ja", no: "Nein", unknown: "Nicht geprüft" } as const;
|
||||||
|
type QueenOption = keyof typeof QUEEN_OPTIONS;
|
||||||
|
|
||||||
|
function queenOption(value: boolean | null | undefined): QueenOption {
|
||||||
|
if (value === true) return "yes";
|
||||||
|
if (value === false) return "no";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: string): number | undefined {
|
||||||
|
const trimmed = value.trim().replace(",", ".");
|
||||||
|
return trimmed === "" ? undefined : Number(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTCOMES: WinterOutcome[] = ["SURVIVED", "WEAK", "LOST"];
|
||||||
|
|
||||||
|
export function WinterCheckDialog({
|
||||||
|
target,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
target: WinterCheckTarget;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const existing = target.existing;
|
||||||
|
const isSpring = target.season === "SPRING";
|
||||||
|
const [date, setDate] = useState(
|
||||||
|
existing?.date.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const [occupiedSeams, setOccupiedSeams] = useState(existing?.occupiedSeams?.toString() ?? "");
|
||||||
|
const [feedStockKg, setFeedStockKg] = useState(existing?.feedStockKg?.toString() ?? "");
|
||||||
|
const [queen, setQueen] = useState<QueenOption>(queenOption(existing?.queenPresent));
|
||||||
|
const [outcome, setOutcome] = useState<WinterOutcome | null>(existing?.outcome ?? null);
|
||||||
|
const [lossCause, setLossCause] = useState<ColonyLossCause | "">(existing?.lossCause ?? "");
|
||||||
|
const [markDeceased, setMarkDeceased] = useState(target.isLiving);
|
||||||
|
const [notes, setNotes] = useState(existing?.notes ?? "");
|
||||||
|
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||||
|
|
||||||
|
const isLost = outcome === "LOST";
|
||||||
|
const missing = isSpring && (!outcome || (isLost && !lossCause));
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const input: WinterCheckInput = {
|
||||||
|
colonyId: target.colonyId,
|
||||||
|
season: target.season,
|
||||||
|
winterYear: target.winterYear,
|
||||||
|
date: new Date(date).toISOString(),
|
||||||
|
occupiedSeams: toNumber(occupiedSeams),
|
||||||
|
feedStockKg: isSpring ? undefined : toNumber(feedStockKg),
|
||||||
|
queenPresent: queen === "unknown" ? undefined : queen === "yes",
|
||||||
|
outcome: outcome ?? undefined,
|
||||||
|
lossCause: isLost && lossCause ? lossCause : undefined,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
markColonyDeceased: isLost && target.isLiving ? markDeceased : undefined,
|
||||||
|
};
|
||||||
|
return existing
|
||||||
|
? updateWinterCheck(existing.id, input)
|
||||||
|
: createWinterCheck(input, { idempotencyKey });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["winter-checks"] });
|
||||||
|
if (isLost && markDeceased) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["colonies"] });
|
||||||
|
}
|
||||||
|
toast.success(`${target.code}: gespeichert`);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(
|
||||||
|
error instanceof ApiError ? error.message : "Speichern fehlgeschlagen. Bitte noch einmal versuchen.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (missing) return;
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = `${target.code} ${isSpring ? "auswintern" : "einwintern"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => (open ? undefined : onClose())}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{winterLabel(target.winterYear)}</p>
|
||||||
|
</DialogHeader>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
{isSpring ? (
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<legend className="mb-2 text-sm font-medium">Wie ist das Volk durch den Winter gekommen?</legend>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{OUTCOMES.map((value) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
size="touch"
|
||||||
|
variant={outcome === value ? (value === "LOST" ? "destructive" : "default") : "outline"}
|
||||||
|
aria-pressed={outcome === value}
|
||||||
|
onClick={() => setOutcome(value)}
|
||||||
|
>
|
||||||
|
{WINTER_OUTCOME_LABELS[value]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLost ? (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-destructive/30 p-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="winter-loss-cause">Verlustursache</Label>
|
||||||
|
<Select
|
||||||
|
value={lossCause}
|
||||||
|
onValueChange={(value) => setLossCause((value ?? "") as ColonyLossCause | "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="winter-loss-cause" className="h-11 w-full">
|
||||||
|
<SelectValue placeholder="Ursache wählen">
|
||||||
|
{lossCause ? LOSS_CAUSE_LABELS[lossCause] : null}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(LOSS_CAUSE_LABELS) as ColonyLossCause[]).map((cause) => (
|
||||||
|
<SelectItem key={cause} value={cause}>
|
||||||
|
{LOSS_CAUSE_LABELS[cause]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Kategorien wie im COLOSS-Monitoring. Vermutete Gründe (z. B. Varroa) gern in die Notiz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{target.isLiving ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="winter-mark-deceased"
|
||||||
|
checked={markDeceased}
|
||||||
|
onCheckedChange={(checked) => setMarkDeceased(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="winter-mark-deceased">Volk als abgestorben kennzeichnen</Label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="winter-date">Datum</Label>
|
||||||
|
<Input
|
||||||
|
id="winter-date"
|
||||||
|
type="date"
|
||||||
|
className="h-11"
|
||||||
|
value={date}
|
||||||
|
onChange={(event) => setDate(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLost ? (
|
||||||
|
<div className={cn("grid gap-3", isSpring ? "grid-cols-1" : "grid-cols-2")}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="winter-seams">Besetzte Wabengassen</Label>
|
||||||
|
<Input
|
||||||
|
id="winter-seams"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="h-11"
|
||||||
|
value={occupiedSeams}
|
||||||
|
onChange={(event) => setOccupiedSeams(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isSpring ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="winter-feed">Futtervorrat (kg, geschätzt)</Label>
|
||||||
|
<Input
|
||||||
|
id="winter-feed"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-11"
|
||||||
|
value={feedStockKg}
|
||||||
|
onChange={(event) => setFeedStockKg(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLost ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="winter-queen">Königin/Stifte gesehen</Label>
|
||||||
|
<Select value={queen} onValueChange={(value) => setQueen((value ?? "unknown") as QueenOption)}>
|
||||||
|
<SelectTrigger id="winter-queen" className="h-11 w-full">
|
||||||
|
<SelectValue>{QUEEN_OPTIONS[queen]}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(QUEEN_OPTIONS) as QueenOption[]).map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{QUEEN_OPTIONS[option]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="winter-notes">Notiz</Label>
|
||||||
|
<Textarea id="winter-notes" value={notes} onChange={(event) => setNotes(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="touch" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="touch" disabled={missing || mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Wird gespeichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{missing ? (
|
||||||
|
<p className="text-right text-xs text-muted-foreground">
|
||||||
|
{outcome ? "Bitte die Verlustursache wählen." : "Bitte zuerst das Ergebnis wählen."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { apiFetch } from "@/lib/auth/api-client";
|
||||||
|
import { CreateOptions, idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||||
|
import {
|
||||||
|
WinterCheck,
|
||||||
|
WinterCheckInput,
|
||||||
|
WinterCheckWithColony,
|
||||||
|
WinterSummary,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export function fetchWinterChecks(winterYear: number): Promise<WinterCheckWithColony[]> {
|
||||||
|
return apiFetch<WinterCheckWithColony[]>(`/winter-checks?winterYear=${winterYear}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchWinterSummary(winterYear: number): Promise<WinterSummary> {
|
||||||
|
return apiFetch<WinterSummary>(`/winter-checks/summary?winterYear=${winterYear}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWinterCheck(
|
||||||
|
input: WinterCheckInput,
|
||||||
|
options: CreateOptions = {},
|
||||||
|
): Promise<WinterCheck> {
|
||||||
|
return apiFetch<WinterCheck>("/winter-checks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: idempotencyHeaders(options),
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWinterCheck(
|
||||||
|
id: string,
|
||||||
|
input: Partial<WinterCheckInput>,
|
||||||
|
): Promise<WinterCheck> {
|
||||||
|
return apiFetch<WinterCheck>(`/winter-checks/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { ColonyLossCause, WinterOutcome } from "./types";
|
||||||
|
|
||||||
|
export const WINTER_OUTCOME_LABELS: Record<WinterOutcome, string> = {
|
||||||
|
SURVIVED: "Überlebt",
|
||||||
|
WEAK: "Schwach",
|
||||||
|
LOST: "Verloren",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Loss categories of the COLOSS monitoring - no diagnosis. */
|
||||||
|
export const LOSS_CAUSE_LABELS: Record<ColonyLossCause, string> = {
|
||||||
|
DEAD_OR_EMPTY: "Tot oder Beute leer",
|
||||||
|
QUEEN_PROBLEM: "Unlösbares Königinnenproblem",
|
||||||
|
NATURAL_DISASTER: "Naturereignis (Sturm, Hochwasser, Tiere …)",
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type WinterCheckSeason = "AUTUMN" | "SPRING";
|
||||||
|
export type WinterOutcome = "SURVIVED" | "WEAK" | "LOST";
|
||||||
|
export type ColonyLossCause = "DEAD_OR_EMPTY" | "QUEEN_PROBLEM" | "NATURAL_DISASTER";
|
||||||
|
|
||||||
|
export interface WinterCheck {
|
||||||
|
id: string;
|
||||||
|
colonyId: string;
|
||||||
|
userId: string;
|
||||||
|
winterYear: number;
|
||||||
|
season: WinterCheckSeason;
|
||||||
|
date: string;
|
||||||
|
occupiedSeams: number | null;
|
||||||
|
feedStockKg: number | null;
|
||||||
|
queenPresent: boolean | null;
|
||||||
|
outcome: WinterOutcome | null;
|
||||||
|
lossCause: ColonyLossCause | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WinterCheckWithColony extends WinterCheck {
|
||||||
|
colony: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
apiaryId: string;
|
||||||
|
apiary: { id: string; name: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WinterCheckInput {
|
||||||
|
colonyId: string;
|
||||||
|
season: WinterCheckSeason;
|
||||||
|
date: string;
|
||||||
|
winterYear?: number;
|
||||||
|
occupiedSeams?: number;
|
||||||
|
feedStockKg?: number;
|
||||||
|
queenPresent?: boolean;
|
||||||
|
outcome?: WinterOutcome;
|
||||||
|
lossCause?: ColonyLossCause;
|
||||||
|
notes?: string;
|
||||||
|
/** With outcome LOST: also mark the colony as deceased. */
|
||||||
|
markColonyDeceased?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WinterCounts {
|
||||||
|
wintered: number;
|
||||||
|
survived: number;
|
||||||
|
weak: number;
|
||||||
|
lost: number;
|
||||||
|
lostByCause: Record<ColonyLossCause, number>;
|
||||||
|
pendingSpring: number;
|
||||||
|
springWithoutAutumn: number;
|
||||||
|
lossRatePercent: number | null;
|
||||||
|
isProvisional: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WinterSummary {
|
||||||
|
total: WinterCounts;
|
||||||
|
apiaries: (WinterCounts & { apiaryId: string; apiaryName: string })[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Colony } from "@/lib/colonies/types";
|
||||||
|
import type { WinterCheckWithColony } from "./types";
|
||||||
|
import { buildWinterRows, winterLabel, winterYearFor } from "./winter";
|
||||||
|
|
||||||
|
function colony(overrides: Partial<Colony>): Colony {
|
||||||
|
return {
|
||||||
|
id: "c1",
|
||||||
|
code: "V-01",
|
||||||
|
apiaryId: "a",
|
||||||
|
hiveType: null,
|
||||||
|
originText: null,
|
||||||
|
colonyType: "PRODUCTION_COLONY",
|
||||||
|
status: "ACTIVE",
|
||||||
|
strength: null,
|
||||||
|
broodStatus: null,
|
||||||
|
feedStatus: null,
|
||||||
|
healthStatus: null,
|
||||||
|
notes: null,
|
||||||
|
currentQueenId: null,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
deactivatedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadColony = {
|
||||||
|
id: "c1",
|
||||||
|
code: "V-01",
|
||||||
|
status: "DECEASED",
|
||||||
|
apiaryId: "a",
|
||||||
|
apiary: { id: "a", name: "Obstwiese" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function check(overrides: Partial<WinterCheckWithColony>): WinterCheckWithColony {
|
||||||
|
return {
|
||||||
|
id: "w1",
|
||||||
|
colonyId: "c1",
|
||||||
|
userId: "u",
|
||||||
|
winterYear: 2026,
|
||||||
|
season: "AUTUMN",
|
||||||
|
date: "2026-10-01T00:00:00.000Z",
|
||||||
|
occupiedSeams: null,
|
||||||
|
feedStockKg: null,
|
||||||
|
queenPresent: null,
|
||||||
|
outcome: null,
|
||||||
|
lossCause: null,
|
||||||
|
notes: null,
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: "",
|
||||||
|
colony: { ...deadColony, status: "ACTIVE" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("winterYearFor / winterLabel", () => {
|
||||||
|
it("maps autumn and spring to the same winter", () => {
|
||||||
|
expect(winterYearFor(new Date(2026, 9, 1))).toBe(2026);
|
||||||
|
expect(winterYearFor(new Date(2027, 2, 20))).toBe(2026);
|
||||||
|
expect(winterLabel(2026)).toBe("Winter 2026/27");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildWinterRows", () => {
|
||||||
|
it("lists living colonies without records and hides old dead ones", () => {
|
||||||
|
const groups = buildWinterRows(
|
||||||
|
[colony({ id: "c1" }), colony({ id: "c2", code: "V-02", status: "DECEASED" })],
|
||||||
|
[],
|
||||||
|
new Map([["a", "Obstwiese"]]),
|
||||||
|
);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].rows.map((row) => row.colonyId)).toEqual(["c1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a colony that died this winter, with its loss record", () => {
|
||||||
|
const groups = buildWinterRows(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
check({ colony: deadColony }),
|
||||||
|
check({
|
||||||
|
id: "w2",
|
||||||
|
season: "SPRING",
|
||||||
|
outcome: "LOST",
|
||||||
|
lossCause: "DEAD_OR_EMPTY",
|
||||||
|
colony: deadColony,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
const [row] = groups[0].rows;
|
||||||
|
expect(row).toMatchObject({ isLiving: false });
|
||||||
|
expect(row.autumn?.id).toBe("w1");
|
||||||
|
expect(row.spring?.outcome).toBe("LOST");
|
||||||
|
expect(groups[0].apiaryName).toBe("Obstwiese");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts colony codes naturally", () => {
|
||||||
|
const groups = buildWinterRows(
|
||||||
|
[colony({ id: "c10", code: "V-10" }), colony({ id: "c2", code: "V-2" })],
|
||||||
|
[],
|
||||||
|
new Map([["a", "Obstwiese"]]),
|
||||||
|
);
|
||||||
|
expect(groups[0].rows.map((row) => row.code)).toEqual(["V-2", "V-10"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Colony } from "@/lib/colonies/types";
|
||||||
|
import type { WinterCheckWithColony } from "./types";
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES = new Set(["DISSOLVED", "SOLD", "DECEASED", "MERGED"]);
|
||||||
|
|
||||||
|
/** From July on a date belongs to the winter starting that year. */
|
||||||
|
export function winterYearFor(date: Date): number {
|
||||||
|
return date.getMonth() >= 6 ? date.getFullYear() : date.getFullYear() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function winterLabel(winterYear: number): string {
|
||||||
|
return `Winter ${winterYear}/${String(winterYear + 1).slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WinterRow {
|
||||||
|
colonyId: string;
|
||||||
|
code: string;
|
||||||
|
isLiving: boolean;
|
||||||
|
autumn: WinterCheckWithColony | null;
|
||||||
|
spring: WinterCheckWithColony | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WinterApiaryGroup {
|
||||||
|
apiaryId: string;
|
||||||
|
apiaryName: string;
|
||||||
|
rows: WinterRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per colony that is alive now or has a record in this winter, so a
|
||||||
|
* colony that died still shows up with its loss, grouped by apiary.
|
||||||
|
*/
|
||||||
|
export function buildWinterRows(
|
||||||
|
colonies: Colony[],
|
||||||
|
checks: WinterCheckWithColony[],
|
||||||
|
apiaryNames: ReadonlyMap<string, string>,
|
||||||
|
): WinterApiaryGroup[] {
|
||||||
|
const names = new Map(apiaryNames);
|
||||||
|
const rows = new Map<string, WinterRow & { apiaryId: string }>();
|
||||||
|
for (const colony of colonies) {
|
||||||
|
if (TERMINAL_STATUSES.has(colony.status)) continue;
|
||||||
|
rows.set(colony.id, {
|
||||||
|
colonyId: colony.id,
|
||||||
|
code: colony.code,
|
||||||
|
apiaryId: colony.apiaryId,
|
||||||
|
isLiving: true,
|
||||||
|
autumn: null,
|
||||||
|
spring: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const check of checks) {
|
||||||
|
const existing = rows.get(check.colonyId) ?? {
|
||||||
|
colonyId: check.colonyId,
|
||||||
|
code: check.colony.code,
|
||||||
|
apiaryId: check.colony.apiaryId,
|
||||||
|
isLiving: !TERMINAL_STATUSES.has(check.colony.status),
|
||||||
|
autumn: null,
|
||||||
|
spring: null,
|
||||||
|
};
|
||||||
|
rows.set(check.colonyId, {
|
||||||
|
...existing,
|
||||||
|
...(check.season === "AUTUMN" ? { autumn: check } : { spring: check }),
|
||||||
|
});
|
||||||
|
if (!names.has(check.colony.apiaryId)) {
|
||||||
|
names.set(check.colony.apiaryId, check.colony.apiary.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groups = new Map<string, WinterApiaryGroup>();
|
||||||
|
for (const { apiaryId, ...row } of rows.values()) {
|
||||||
|
const group = groups.get(apiaryId) ?? {
|
||||||
|
apiaryId,
|
||||||
|
apiaryName: names.get(apiaryId) ?? "Unbekannter Stand",
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
groups.set(apiaryId, { ...group, rows: [...group.rows, row] });
|
||||||
|
}
|
||||||
|
return [...groups.values()]
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
rows: [...group.rows].sort((a, b) => a.code.localeCompare(b.code, "de", { numeric: true })),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, "de"));
|
||||||
|
}
|
||||||
@@ -31,12 +31,13 @@ Verifikationsstufen: **CR** = durch Codeprüfung beurteilt, **AT** = automatisie
|
|||||||
| Folge-Kontrollen | Nachkontrolle nach Maßnahme | Keine | Mittel | Regeln „Nach Behandlung/Fütterung“, standardmäßig aus, Frist „Fällig nach (Tagen)“ | AT |
|
| Folge-Kontrollen | Nachkontrolle nach Maßnahme | Keine | Mittel | Regeln „Nach Behandlung/Fütterung“, standardmäßig aus, Frist „Fällig nach (Tagen)“ | AT |
|
||||||
| Stockkarte je Volk | Vollständige Historie als PDF | Nur Jahresbericht | Mittel | `colony-record-card.pdf`, optional je Jahr | AT + MP |
|
| Stockkarte je Volk | Vollständige Historie als PDF | Nur Jahresbericht | Mittel | `colony-record-card.pdf`, optional je Jahr | AT + MP |
|
||||||
| Bestandsbuch Tierarzneimittel | Pflichtangaben Art. 108 VO (EU) 2019/6 | Lieferant, Erwerbsbeleg, Tierarzt, Standort am Behandlungstag, Halter fehlten | Hoch (Recht) | Felder ergänzt, Stand je Behandlung gespeichert, PDF je Stand, fehlende Pflichtangaben als „FEHLT“ | AT + MP |
|
| Bestandsbuch Tierarzneimittel | Pflichtangaben Art. 108 VO (EU) 2019/6 | Lieferant, Erwerbsbeleg, Tierarzt, Standort am Behandlungstag, Halter fehlten | Hoch (Recht) | Felder ergänzt, Stand je Behandlung gespeichert, PDF je Stand, fehlende Pflichtangaben als „FEHLT“ | AT + MP |
|
||||||
|
| Ein-/Auswinterung & Verluste | Winterverluste je Volk und Stand nachvollziehbar | Nur Volksstatus, kein Bezug zum Winter, keine Ursache | Mittel | `WinterCheck` je Volk/Winter/Saison, Verlustkategorien nach COLOSS, Übersicht mit Verlustrate; nicht ausgewinterte Völker zählen nie als überlebt | AT + MP |
|
||||||
| E2E-Tests | Grün | 23 rot (1 Produktfehler, 3 veraltete Tests) | – | behoben | AT |
|
| E2E-Tests | Grün | 23 rot (1 Produktfehler, 3 veraltete Tests) | – | behoben | AT |
|
||||||
|
|
||||||
## Prüfungen (letzter Lauf)
|
## Prüfungen (letzter Lauf)
|
||||||
|
|
||||||
- Backend: Typecheck, ESLint, 539 Unit-Tests, 203 E2E-Tests (isolierte Postgres 17), `nest build` – grün.
|
- Backend: Typecheck, ESLint, 550 Unit-Tests, 211 E2E-Tests (isolierte Postgres 17), `nest build` – grün.
|
||||||
- Frontend: Typecheck, ESLint, 44 Tests, `next build` – grün. Design-Detektor (impeccable): 0 Befunde.
|
- Frontend: Typecheck, ESLint, 48 Tests, `next build` – grün. Design-Detektor (impeccable): 0 Befunde.
|
||||||
- Wabenhain: Typecheck, ESLint (geänderte Dateien), 538 Tests – grün.
|
- Wabenhain: Typecheck, ESLint (geänderte Dateien), 538 Tests – grün.
|
||||||
- Manuell (Browser, lokale Demo-Daten): Login, Heute-Ansicht Desktop/Mobil, Abhaken in Liste und
|
- Manuell (Browser, lokale Demo-Daten): Login, Heute-Ansicht Desktop/Mobil, Abhaken in Liste und
|
||||||
„Als Nächstes“, Rückgängig (stellt vorherigen Status her), Profil-Bewegung „Aus“ wirkt, Aufgabenliste mobil.
|
„Als Nächstes“, Rückgängig (stellt vorherigen Status her), Profil-Bewegung „Aus“ wirkt, Aufgabenliste mobil.
|
||||||
@@ -90,3 +91,18 @@ Aufbewahrung 5 Jahre.
|
|||||||
- Fehlende Pflichtangaben erscheinen als „FEHLT“ statt leer – das Blatt sieht nie vollständiger aus, als es ist.
|
- Fehlende Pflichtangaben erscheinen als „FEHLT“ statt leer – das Blatt sieht nie vollständiger aus, als es ist.
|
||||||
- Name und Anschrift des Halters: Verwaltung → Automatisierung → Stammdaten & Web. Erscheinen nur im
|
- Name und Anschrift des Halters: Verwaltung → Automatisierung → Stammdaten & Web. Erscheinen nur im
|
||||||
Bestandsbuch; die öffentliche Chargen-Seite gibt sie nachweislich nicht aus (Test in `public.service.spec.ts`).
|
Bestandsbuch; die öffentliche Chargen-Seite gibt sie nachweislich nicht aus (Test in `public.service.spec.ts`).
|
||||||
|
|
||||||
|
## Überwinterung (Ein-/Auswinterung & Verluste)
|
||||||
|
|
||||||
|
- Seite „Überwinterung“ (Betrieb): je Winter (2026 = Winter 2026/27) eine Einwinterung und eine
|
||||||
|
Auswinterung pro Volk. „Als Nächstes“ nennt die noch nicht eingewinterten Völker; „Auswintern“ erscheint
|
||||||
|
erst nach der Einwinterung oder ab Januar.
|
||||||
|
- Verlustursachen: die drei Kategorien des COLOSS-Monitorings (tot/leer, unlösbares Königinnenproblem,
|
||||||
|
Naturereignis). Keine Diagnose-Liste – vermutete Gründe gehören in die Notiz.
|
||||||
|
- Verlustrate = verloren / eingewintert (wie COLOSS). Solange Völker nicht ausgewintert sind, heißt sie
|
||||||
|
„vorläufig“; ohne jede Auswinterung wird „–“ statt „0 %“ angezeigt.
|
||||||
|
- „Volk als abgestorben kennzeichnen“ setzt den Status nur auf ausdrücklichen Wunsch (Häkchen, vorbelegt),
|
||||||
|
in derselben Transaktion und mit Audit-Eintrag.
|
||||||
|
- Rechte `winter-checks:read/create/update`: per Migration an Imker und Mitarbeiter wie Durchsichten,
|
||||||
|
Nur-Lesen-Rollen nur lesen. Einträge sind im Backup enthalten.
|
||||||
|
- Quelle Verlustkategorien: COLOSS-Monitoring (z. B. Gray et al. 2020, J. Apic. Res., doi:10.1080/00218839.2020.1797272).
|
||||||
Reference in new issue
Block a user