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.
|
||||
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).
|
||||
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
|
||||
|
||||
|
||||
@@ -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[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
winterChecks WinterCheck[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests Harvest[]
|
||||
@@ -270,6 +271,7 @@ model Colony {
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
swarmControlChecks SwarmControlCheck[]
|
||||
winterChecks WinterCheck[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests HarvestColony[]
|
||||
@@ -595,6 +597,54 @@ model SwarmControlCheck {
|
||||
@@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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ImportModule } from './import/import.module';
|
||||
import { PublicModule } from './public/public.module';
|
||||
import { TaskTemplatesModule } from './task-templates/task-templates.module';
|
||||
import { SwarmControlModule } from './swarm-control/swarm-control.module';
|
||||
import { WinterChecksModule } from './winter-checks/winter-checks.module';
|
||||
import { WeatherModule } from './weather/weather.module';
|
||||
import { ProfileModule } from './profile/profile.module';
|
||||
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||
@@ -95,6 +96,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
||||
PublicModule,
|
||||
TaskTemplatesModule,
|
||||
SwarmControlModule,
|
||||
WinterChecksModule,
|
||||
WeatherModule,
|
||||
ProfileModule,
|
||||
SiteSettingsModule,
|
||||
|
||||
@@ -111,6 +111,18 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
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(
|
||||
'Behandlungen',
|
||||
'treatments',
|
||||
|
||||
@@ -24,6 +24,7 @@ const BUSINESS_ENTITY_KEYS = [
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
|
||||
@@ -93,6 +93,7 @@ export class BackupService {
|
||||
data.task = await this.prisma.task.findMany();
|
||||
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
|
||||
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
|
||||
data.winterCheck = await this.prisma.winterCheck.findMany();
|
||||
data.treatment = await this.prisma.treatment.findMany();
|
||||
data.feeding = await this.prisma.feeding.findMany();
|
||||
data.harvest = await this.prisma.harvest.findMany();
|
||||
@@ -430,6 +431,21 @@ export class BackupService {
|
||||
}
|
||||
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')) {
|
||||
await tx.treatment.upsert({
|
||||
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,
|
||||
Scissors,
|
||||
Search,
|
||||
Snowflake,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
Warehouse,
|
||||
@@ -58,6 +59,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{ label: "Bienenstände", href: "/apiaries", icon: MapPin },
|
||||
{ label: "Völker", href: "/colonies", icon: Layers },
|
||||
{ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## Prüfungen (letzter Lauf)
|
||||
|
||||
- Backend: Typecheck, ESLint, 539 Unit-Tests, 203 E2E-Tests (isolierte Postgres 17), `nest build` – grün.
|
||||
- Frontend: Typecheck, ESLint, 44 Tests, `next build` – grün. Design-Detektor (impeccable): 0 Befunde.
|
||||
- Backend: Typecheck, ESLint, 550 Unit-Tests, 211 E2E-Tests (isolierte Postgres 17), `nest build` – grün.
|
||||
- Frontend: Typecheck, ESLint, 48 Tests, `next build` – grün. Design-Detektor (impeccable): 0 Befunde.
|
||||
- Wabenhain: Typecheck, ESLint (geänderte Dateien), 538 Tests – grün.
|
||||
- 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.
|
||||
@@ -90,3 +91,18 @@ Aufbewahrung 5 Jahre.
|
||||
- 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
|
||||
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