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:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 17:32:03 +02:00
1 parent 0d23963f07
commit fac66882fa
26 files changed
+2020 -2

No files matched your search

+1
View File
@@ -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;
+50
View File
@@ -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)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+2
View File
@@ -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"));
}
+18 -2
View File
@@ -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).