From 0d23963f07fbb16ff52fde7d7431bdf276b21abb Mon Sep 17 00:00:00 2001
From: Nick Szaunig
Date: Mon, 28 Sep 2026 11:10:07 +0200
Subject: [PATCH] =?UTF-8?q?feat(export):=20Bestandsbuch=20f=C3=BCr=20Tiera?=
=?UTF-8?q?rzneimittel=20nach=20Art.=20108=20VO=20(EU)=202019/6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Treatment records supplier, purchase proof and prescribing vet, and
keeps the apiary the colony stood at on the treatment day.
- Holder name and address in site settings (never in public output;
regression test in public.service.spec).
- treatment-log.pdf: one sheet per apiary, identical treatments grouped,
missing mandatory fields printed as FEHLT, uncertain locations marked.
- Shared PDF table helpers extracted from the record card.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
ARCHITECTURE.md | 1 +
.../migration.sql | 13 +
ApiCore-Backend/prisma/schema.prisma | 25 +-
.../src/export/colony-record-card.ts | 109 +-----
ApiCore-Backend/src/export/export.service.ts | 10 +
ApiCore-Backend/src/export/pdf-table.ts | 107 ++++++
.../src/export/treatment-log.spec.ts | 126 +++++++
ApiCore-Backend/src/export/treatment-log.ts | 353 ++++++++++++++++++
.../src/public/public.service.spec.ts | 29 ++
.../dto/update-site-settings.dto.ts | 22 +-
.../site-settings/site-settings.service.ts | 8 +
.../treatments/dto/create-treatment.dto.ts | 26 ++
.../src/treatments/treatments.service.spec.ts | 17 +-
.../src/treatments/treatments.service.ts | 19 +
ApiCore-Backend/test/export.e2e-spec.ts | 13 +
.../src/app/(app)/apiaries/[id]/page.tsx | 14 +-
.../colonies/[id]/treatment-edit-dialog.tsx | 42 +++
.../app/(app)/settings/automation/page.tsx | 2 +-
.../settings/automation/social-media-tab.tsx | 26 ++
.../src/app/(app)/treatments/new/page.tsx | 42 +++
ApiCore-Frontend/src/lib/export/api.ts | 10 +
.../src/lib/site-settings/types.ts | 4 +
ApiCore-Frontend/src/lib/treatments/types.ts | 9 +
docs/pruefung-2026-09.md | 27 +-
24 files changed, 941 insertions(+), 113 deletions(-)
create mode 100644 ApiCore-Backend/prisma/migrations/20260928043245_bestandsbuch_fields/migration.sql
create mode 100644 ApiCore-Backend/src/export/pdf-table.ts
create mode 100644 ApiCore-Backend/src/export/treatment-log.spec.ts
create mode 100644 ApiCore-Backend/src/export/treatment-log.ts
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 763cba4..e367d58 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -104,6 +104,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen.
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).
## 7. Kubernetes
diff --git a/ApiCore-Backend/prisma/migrations/20260928043245_bestandsbuch_fields/migration.sql b/ApiCore-Backend/prisma/migrations/20260928043245_bestandsbuch_fields/migration.sql
new file mode 100644
index 0000000..2aea6ee
--- /dev/null
+++ b/ApiCore-Backend/prisma/migrations/20260928043245_bestandsbuch_fields/migration.sql
@@ -0,0 +1,13 @@
+-- AlterTable
+ALTER TABLE "SiteSettings" ADD COLUMN "holderAddress" TEXT,
+ADD COLUMN "holderName" TEXT;
+
+-- AlterTable
+ALTER TABLE "Treatment" ADD COLUMN "apiaryId" TEXT,
+ADD COLUMN "prescribingVet" TEXT,
+ADD COLUMN "purchaseProof" TEXT,
+ADD COLUMN "supplier" TEXT;
+
+-- AddForeignKey
+ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma
index 82ad6bc..00d5d3d 100644
--- a/ApiCore-Backend/prisma/schema.prisma
+++ b/ApiCore-Backend/prisma/schema.prisma
@@ -205,6 +205,7 @@ model Apiary {
colonies Colony[]
colonyMoves ColonyApiaryHistory[]
harvests Harvest[]
+ treatments Treatment[]
@@index([status])
}
@@ -615,6 +616,14 @@ model Treatment {
batchNumber String?
notes String?
withdrawalPeriodDays Int?
+ // Bestandsbuch (Art. 108 VO (EU) 2019/6): where the colony stood on the
+ // day of the treatment - kept with the treatment because the colony may
+ // be moved later - and the supplier / purchase proof / prescribing vet.
+ apiaryId String?
+ apiary Apiary? @relation(fields: [apiaryId], references: [id])
+ supplier String?
+ purchaseProof String?
+ prescribingVet String?
createdAt DateTime @default(now())
@@index([colonyId, date])
@@ -1102,12 +1111,16 @@ model AutomationRule {
// site-wide social links exists, and a fixed-id upsert is simpler than
// enforcing "at most one row" any other way.
model SiteSettings {
- id String @id @default("singleton")
- youtubeUrl String?
- instagramUrl String?
- tiktokUrl String?
- websiteUrl String?
- updatedAt DateTime @updatedAt
+ id String @id @default("singleton")
+ youtubeUrl String?
+ instagramUrl String?
+ tiktokUrl String?
+ websiteUrl String?
+ // Name and address of the bee keeper, printed on the Bestandsbuch. Never
+ // part of any public page.
+ holderName String?
+ holderAddress String?
+ updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
diff --git a/ApiCore-Backend/src/export/colony-record-card.ts b/ApiCore-Backend/src/export/colony-record-card.ts
index 76ba97a..cbf3258 100644
--- a/ApiCore-Backend/src/export/colony-record-card.ts
+++ b/ApiCore-Backend/src/export/colony-record-card.ts
@@ -1,6 +1,14 @@
import { NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
+import {
+ PDF_MARGIN,
+ drawSection,
+ drawTable,
+ fillWidth,
+ formatDay,
+ orDash,
+} from './pdf-table';
/**
* Stockkarte: the complete written record of one colony - master data, queen,
@@ -242,109 +250,14 @@ export async function buildColonyRecordCard(
// Rendering
// ---------------------------------------------------------------------------
-const MARGIN = 36;
-
-function day(date: Date): string {
- return date.toLocaleDateString('de-DE', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- timeZone: 'UTC',
- });
-}
+const MARGIN = PDF_MARGIN;
+const day = formatDay;
+const section = drawSection;
function yesNo(value: boolean | null): string {
return value === null ? '–' : value ? 'ja' : 'nein';
}
-function orDash(value: string | number | null | undefined): string {
- return value === null || value === undefined || value === ''
- ? '–'
- : String(value);
-}
-
-interface Column {
- header: string;
- width: number;
-}
-
-/** Draws a simple table, repeating the header on every new page. */
-function drawTable(
- doc: PDFKit.PDFDocument,
- columns: Column[],
- rows: string[][],
-): void {
- const left = MARGIN;
- const bottomLimit = doc.page.height - MARGIN - 20;
-
- const drawRow = (cells: string[], bold: boolean) => {
- doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
- const heights = cells.map((cell, index) =>
- doc.heightOfString(cell, { width: columns[index].width - 4 }),
- );
- const rowHeight = Math.max(...heights, 10) + 4;
- if (doc.y + rowHeight > bottomLimit) {
- doc.addPage();
- if (!bold) {
- drawRow(
- columns.map((c) => c.header),
- true,
- );
- doc.font('Helvetica').fontSize(8);
- }
- }
- const top = doc.y;
- let x = left;
- cells.forEach((cell, index) => {
- doc.text(cell, x + 2, top + 2, { width: columns[index].width - 4 });
- x += columns[index].width;
- });
- doc
- .moveTo(left, top + rowHeight)
- .lineTo(x, top + rowHeight)
- .lineWidth(0.3)
- .strokeColor('#999999')
- .stroke();
- doc.y = top + rowHeight;
- };
-
- drawRow(
- columns.map((c) => c.header),
- true,
- );
- if (rows.length === 0) {
- doc
- .font('Helvetica-Oblique')
- .fontSize(8)
- .text('Keine Einträge.', left + 2, doc.y + 2);
- doc.moveDown(0.5);
- return;
- }
- for (const row of rows) drawRow(row, false);
- doc.moveDown(0.5);
-}
-
-function section(doc: PDFKit.PDFDocument, title: string): void {
- if (doc.y > doc.page.height - MARGIN - 80) doc.addPage();
- doc.moveDown(0.6);
- doc
- .font('Helvetica-Bold')
- .fontSize(11)
- .fillColor('#000000')
- .text(title, MARGIN);
- doc.moveDown(0.2);
-}
-
-/** Widens the last column to fill the page. */
-function fillWidth(doc: PDFKit.PDFDocument, columns: Column[]): Column[] {
- const fixed = columns.slice(0, -1).reduce((sum, c) => sum + c.width, 0);
- const last = columns[columns.length - 1];
- return [
- ...columns.slice(0, -1),
- { ...last, width: doc.page.width - 2 * MARGIN - fixed },
- ];
-}
-
export function renderColonyRecordCardPdf(
card: ColonyRecordCard,
): Promise {
diff --git a/ApiCore-Backend/src/export/export.service.ts b/ApiCore-Backend/src/export/export.service.ts
index 70ff941..0870c4d 100644
--- a/ApiCore-Backend/src/export/export.service.ts
+++ b/ApiCore-Backend/src/export/export.service.ts
@@ -4,6 +4,7 @@ import {
buildColonyRecordCard,
renderColonyRecordCardPdf,
} from './colony-record-card';
+import { buildTreatmentLog, renderTreatmentLogPdf } from './treatment-log';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
import {
@@ -27,6 +28,7 @@ const PDF_REPORTS = [
'colony-annual-report',
'apiary-annual-report',
'colony-record-card',
+ 'treatment-log',
] as const;
type PdfReport = (typeof PDF_REPORTS)[number];
@@ -207,6 +209,14 @@ export class ExportService {
);
return renderColonyRecordCardPdf(card);
}
+ case 'treatment-log': {
+ const log = await buildTreatmentLog(
+ this.prisma,
+ resolveYear(options.year),
+ options.apiaryId,
+ );
+ return renderTreatmentLogPdf(log);
+ }
}
}
diff --git a/ApiCore-Backend/src/export/pdf-table.ts b/ApiCore-Backend/src/export/pdf-table.ts
new file mode 100644
index 0000000..4e159e0
--- /dev/null
+++ b/ApiCore-Backend/src/export/pdf-table.ts
@@ -0,0 +1,107 @@
+/**
+ * Small pdfkit helpers shared by the printable records (Stockkarte,
+ * Bestandsbuch): dates, empty values and a table that repeats its header on
+ * every page.
+ */
+
+export const PDF_MARGIN = 36;
+
+export interface PdfColumn {
+ header: string;
+ width: number;
+}
+
+export function formatDay(date: Date): string {
+ return date.toLocaleDateString('de-DE', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ timeZone: 'UTC',
+ });
+}
+
+export function orDash(value: string | number | null | undefined): string {
+ return value === null || value === undefined || value === ''
+ ? '–'
+ : String(value);
+}
+
+/** Widens the last column so the table fills the page width. */
+export function fillWidth(
+ doc: PDFKit.PDFDocument,
+ columns: PdfColumn[],
+): PdfColumn[] {
+ const fixed = columns.slice(0, -1).reduce((sum, c) => sum + c.width, 0);
+ const last = columns[columns.length - 1];
+ return [
+ ...columns.slice(0, -1),
+ { ...last, width: doc.page.width - 2 * PDF_MARGIN - fixed },
+ ];
+}
+
+export function drawSection(doc: PDFKit.PDFDocument, title: string): void {
+ if (doc.y > doc.page.height - PDF_MARGIN - 80) doc.addPage();
+ doc.moveDown(0.6);
+ doc
+ .font('Helvetica-Bold')
+ .fontSize(11)
+ .fillColor('#000000')
+ .text(title, PDF_MARGIN);
+ doc.moveDown(0.2);
+}
+
+/** Draws a simple table, repeating the header on every new page. */
+export function drawTable(
+ doc: PDFKit.PDFDocument,
+ columns: PdfColumn[],
+ rows: string[][],
+): void {
+ const left = PDF_MARGIN;
+ const bottomLimit = doc.page.height - PDF_MARGIN - 20;
+
+ const drawRow = (cells: string[], bold: boolean) => {
+ doc.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8);
+ const heights = cells.map((cell, index) =>
+ doc.heightOfString(cell, { width: columns[index].width - 4 }),
+ );
+ const rowHeight = Math.max(...heights, 10) + 4;
+ if (doc.y + rowHeight > bottomLimit) {
+ doc.addPage();
+ if (!bold) {
+ drawRow(
+ columns.map((c) => c.header),
+ true,
+ );
+ doc.font('Helvetica').fontSize(8);
+ }
+ }
+ const top = doc.y;
+ let x = left;
+ cells.forEach((cell, index) => {
+ doc.text(cell, x + 2, top + 2, { width: columns[index].width - 4 });
+ x += columns[index].width;
+ });
+ doc
+ .moveTo(left, top + rowHeight)
+ .lineTo(x, top + rowHeight)
+ .lineWidth(0.3)
+ .strokeColor('#999999')
+ .stroke();
+ doc.y = top + rowHeight;
+ };
+
+ drawRow(
+ columns.map((c) => c.header),
+ true,
+ );
+ if (rows.length === 0) {
+ doc
+ .font('Helvetica-Oblique')
+ .fontSize(8)
+ .text('Keine Einträge.', left + 2, doc.y + 2);
+ doc.moveDown(0.5);
+ return;
+ }
+ for (const row of rows) drawRow(row, false);
+ doc.moveDown(0.5);
+}
diff --git a/ApiCore-Backend/src/export/treatment-log.spec.ts b/ApiCore-Backend/src/export/treatment-log.spec.ts
new file mode 100644
index 0000000..ef2a86b
--- /dev/null
+++ b/ApiCore-Backend/src/export/treatment-log.spec.ts
@@ -0,0 +1,126 @@
+import {
+ apiaryOnDate,
+ groupTreatmentLog,
+ TreatmentLogSource,
+} from './treatment-log';
+
+const d = (value: string) => new Date(`${value}T00:00:00.000Z`);
+
+function source(overrides: Partial): TreatmentLogSource {
+ return {
+ id: 't',
+ colonyId: 'c1',
+ colonyCode: 'V-01',
+ colonyApiaryId: 'today',
+ apiaryId: null,
+ date: d('2026-08-01'),
+ startDate: null,
+ endDate: null,
+ product: 'Oxalsäure',
+ amount: '30 ml',
+ batchNumber: 'B1',
+ supplier: 'Imkereibedarf X',
+ purchaseProof: 'Rechnung 1',
+ prescribingVet: null,
+ withdrawalPeriodDays: 0,
+ administeredBy: 'Imker',
+ ...overrides,
+ };
+}
+
+describe('apiaryOnDate', () => {
+ it('uses the apiary stored with the treatment', () => {
+ expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
+ apiaryId: 'stored',
+ uncertain: false,
+ });
+ });
+
+ it('uses the last move on or before the treatment day', () => {
+ const moves = [
+ { colonyId: 'c1', apiaryId: 'summer', movedAt: d('2026-05-01') },
+ { colonyId: 'c1', apiaryId: 'winter', movedAt: d('2026-10-01') },
+ ];
+ expect(apiaryOnDate(source({}), moves)).toEqual({
+ apiaryId: 'summer',
+ uncertain: false,
+ });
+ });
+
+ it('flags the location when the colony was only moved after the treatment', () => {
+ const moves = [
+ { colonyId: 'c1', apiaryId: 'today', movedAt: d('2026-09-01') },
+ ];
+ expect(apiaryOnDate(source({}), moves)).toEqual({
+ apiaryId: 'today',
+ uncertain: true,
+ });
+ });
+
+ it('trusts today when the colony was never moved', () => {
+ expect(apiaryOnDate(source({}), [])).toEqual({
+ apiaryId: 'today',
+ uncertain: false,
+ });
+ });
+});
+
+describe('groupTreatmentLog', () => {
+ it('puts identical treatments of several colonies into one row, per apiary', () => {
+ const sheets = groupTreatmentLog(
+ [
+ source({ id: '1', colonyId: 'c1', colonyCode: 'V-02', apiaryId: 'a' }),
+ source({ id: '2', colonyId: 'c2', colonyCode: 'V-01', apiaryId: 'a' }),
+ source({ id: '3', colonyId: 'c3', colonyCode: 'V-09', apiaryId: 'b' }),
+ source({
+ id: '4',
+ colonyId: 'c4',
+ colonyCode: 'V-03',
+ apiaryId: 'a',
+ amount: '50 ml',
+ }),
+ ],
+ [],
+ );
+
+ const sheetA = sheets.find((sheet) => sheet.apiaryId === 'a');
+ expect(sheetA?.rows).toHaveLength(2);
+ expect(sheetA?.rows[0].colonyCodes).toEqual(['V-01', 'V-02']);
+ expect(sheets.find((sheet) => sheet.apiaryId === 'b')?.rows).toHaveLength(
+ 1,
+ );
+ });
+
+ it('computes the treatment duration in days, inclusive', () => {
+ const [sheet] = groupTreatmentLog(
+ [
+ source({
+ apiaryId: 'a',
+ startDate: d('2026-08-01'),
+ endDate: d('2026-08-14'),
+ }),
+ ],
+ [],
+ );
+ expect(sheet.rows[0].durationDays).toBe(14);
+ });
+
+ it('keeps missing mandatory fields empty so the PDF can flag them', () => {
+ const [sheet] = groupTreatmentLog(
+ [
+ source({
+ apiaryId: 'a',
+ supplier: null,
+ purchaseProof: null,
+ withdrawalPeriodDays: null,
+ }),
+ ],
+ [],
+ );
+ expect(sheet.rows[0]).toMatchObject({
+ supplier: null,
+ purchaseProof: null,
+ withdrawalPeriodDays: null,
+ });
+ });
+});
diff --git a/ApiCore-Backend/src/export/treatment-log.ts b/ApiCore-Backend/src/export/treatment-log.ts
new file mode 100644
index 0000000..4ff74b5
--- /dev/null
+++ b/ApiCore-Backend/src/export/treatment-log.ts
@@ -0,0 +1,353 @@
+import PDFDocument from 'pdfkit';
+import { PrismaService } from '../prisma/prisma.service';
+import {
+ PDF_MARGIN,
+ drawTable,
+ fillWidth,
+ formatDay,
+ orDash,
+} from './pdf-table';
+
+/**
+ * Bestandsbuch: record of every veterinary medicine applied to bees, as
+ * required for food-producing animals by Art. 108 Regulation (EU) 2019/6 -
+ * for every product, including over-the-counter formic or oxalic acid. The
+ * columns follow the model sheet of the German veterinary authorities
+ * (Veterinäramt Kassel, "Buchführung über die Anwendung von TAM bei
+ * Bienen", March 2026): one sheet per apiary; colonies, product, supplier
+ * and purchase proof, date and duration, amount, withdrawal period (also
+ * when 0), administering person. Keep 5 years, as well as purchase proofs.
+ *
+ * Missing mandatory information is printed as "FEHLT" so it can be added
+ * before an inspection, instead of silently leaving a gap.
+ */
+
+export const MISSING = 'FEHLT';
+
+export interface TreatmentLogSource {
+ id: string;
+ colonyId: string;
+ colonyCode: string;
+ colonyApiaryId: string;
+ /** Apiary recorded with the treatment (set since this report exists). */
+ apiaryId: string | null;
+ date: Date;
+ startDate: Date | null;
+ endDate: Date | null;
+ product: string;
+ amount: string | null;
+ batchNumber: string | null;
+ supplier: string | null;
+ purchaseProof: string | null;
+ prescribingVet: string | null;
+ withdrawalPeriodDays: number | null;
+ administeredBy: string;
+}
+
+export interface ColonyMove {
+ colonyId: string;
+ apiaryId: string;
+ movedAt: Date;
+}
+
+export interface TreatmentLogRow {
+ colonyCodes: string[];
+ product: string;
+ batchNumber: string | null;
+ supplier: string | null;
+ prescribingVet: string | null;
+ purchaseProof: string | null;
+ date: Date;
+ endDate: Date | null;
+ durationDays: number;
+ amount: string | null;
+ withdrawalPeriodDays: number | null;
+ administeredBy: string;
+ /** Apiary derived from today's assignment, not known for that date. */
+ locationUncertain: boolean;
+}
+
+export interface TreatmentLogSheet {
+ apiaryId: string;
+ rows: TreatmentLogRow[];
+}
+
+/**
+ * Where the colony stood on the day of the treatment: the apiary stored with
+ * the treatment, else the last recorded move on or before that day. Before
+ * the first recorded move the original apiary is not known - then today's
+ * apiary is used and the row is flagged.
+ */
+export function apiaryOnDate(
+ source: TreatmentLogSource,
+ moves: ColonyMove[],
+): { apiaryId: string; uncertain: boolean } {
+ if (source.apiaryId) return { apiaryId: source.apiaryId, uncertain: false };
+ const colonyMoves = moves
+ .filter((move) => move.colonyId === source.colonyId)
+ .sort((a, b) => a.movedAt.getTime() - b.movedAt.getTime());
+ const before = colonyMoves.filter(
+ (move) => move.movedAt.getTime() <= source.date.getTime(),
+ );
+ if (before.length > 0) {
+ return { apiaryId: before[before.length - 1].apiaryId, uncertain: false };
+ }
+ return { apiaryId: source.colonyApiaryId, uncertain: colonyMoves.length > 0 };
+}
+
+function durationDays(start: Date, end: Date | null): number {
+ if (!end) return 1;
+ const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
+ return Math.max(days, 1);
+}
+
+/**
+ * One sheet per apiary; treatments that are identical except for the colony
+ * (same day, product, batch, amount, supplier, person) become one row listing
+ * all colonies - as in the paper form.
+ */
+export function groupTreatmentLog(
+ sources: TreatmentLogSource[],
+ moves: ColonyMove[],
+): TreatmentLogSheet[] {
+ const sheets = new Map>();
+ const sorted = [...sources].sort(
+ (a, b) =>
+ a.date.getTime() - b.date.getTime() ||
+ a.colonyCode.localeCompare(b.colonyCode, 'de', { numeric: true }),
+ );
+
+ for (const source of sorted) {
+ const { apiaryId, uncertain } = apiaryOnDate(source, moves);
+ const start = source.startDate ?? source.date;
+ const key = [
+ source.date.toISOString(),
+ source.endDate?.toISOString() ?? '',
+ source.product,
+ source.batchNumber ?? '',
+ source.amount ?? '',
+ source.supplier ?? '',
+ source.purchaseProof ?? '',
+ source.prescribingVet ?? '',
+ source.withdrawalPeriodDays ?? '',
+ source.administeredBy,
+ uncertain,
+ ].join('|');
+
+ const rows = sheets.get(apiaryId) ?? new Map();
+ const existing = rows.get(key);
+ if (existing) {
+ existing.colonyCodes.push(source.colonyCode);
+ } else {
+ rows.set(key, {
+ colonyCodes: [source.colonyCode],
+ product: source.product,
+ batchNumber: source.batchNumber,
+ supplier: source.supplier,
+ prescribingVet: source.prescribingVet,
+ purchaseProof: source.purchaseProof,
+ date: source.date,
+ endDate: source.endDate,
+ durationDays: durationDays(start, source.endDate),
+ amount: source.amount,
+ withdrawalPeriodDays: source.withdrawalPeriodDays,
+ administeredBy: source.administeredBy,
+ locationUncertain: uncertain,
+ });
+ }
+ sheets.set(apiaryId, rows);
+ }
+
+ return [...sheets.entries()].map(([apiaryId, rows]) => ({
+ apiaryId,
+ rows: [...rows.values()],
+ }));
+}
+
+// ---------------------------------------------------------------------------
+// Loading and rendering
+// ---------------------------------------------------------------------------
+
+export interface TreatmentLog {
+ year: number;
+ generatedAt: Date;
+ holderName: string | null;
+ holderAddress: string | null;
+ sheets: (TreatmentLogSheet & {
+ apiaryName: string;
+ apiaryLocation: string | null;
+ })[];
+}
+
+export async function buildTreatmentLog(
+ prisma: PrismaService,
+ year: number,
+ apiaryId?: string,
+): Promise {
+ const treatments = await prisma.treatment.findMany({
+ where: {
+ date: {
+ gte: new Date(Date.UTC(year, 0, 1)),
+ lt: new Date(Date.UTC(year + 1, 0, 1)),
+ },
+ },
+ include: {
+ colony: { select: { code: true, apiaryId: true } },
+ user: { select: { name: true } },
+ },
+ });
+ const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
+ const [moves, settings, apiaries] = await Promise.all([
+ prisma.colonyApiaryHistory.findMany({
+ where: { colonyId: { in: colonyIds } },
+ select: { colonyId: true, apiaryId: true, movedAt: true },
+ }),
+ prisma.siteSettings.findUnique({ where: { id: 'singleton' } }),
+ prisma.apiary.findMany({
+ select: { id: true, name: true, location: true, address: true },
+ }),
+ ]);
+
+ const sheets = groupTreatmentLog(
+ treatments.map((t) => ({
+ id: t.id,
+ colonyId: t.colonyId,
+ colonyCode: t.colony.code,
+ colonyApiaryId: t.colony.apiaryId,
+ apiaryId: t.apiaryId,
+ date: t.date,
+ startDate: t.startDate,
+ endDate: t.endDate,
+ product: t.product,
+ amount: t.amount,
+ batchNumber: t.batchNumber,
+ supplier: t.supplier,
+ purchaseProof: t.purchaseProof,
+ prescribingVet: t.prescribingVet,
+ withdrawalPeriodDays: t.withdrawalPeriodDays,
+ administeredBy: t.user.name,
+ })),
+ moves,
+ ).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
+
+ const apiaryById = new Map(apiaries.map((a) => [a.id, a]));
+ return {
+ year,
+ generatedAt: new Date(),
+ holderName: settings?.holderName ?? null,
+ holderAddress: settings?.holderAddress ?? null,
+ sheets: sheets
+ .map((sheet) => {
+ const apiary = apiaryById.get(sheet.apiaryId);
+ return {
+ ...sheet,
+ apiaryName: apiary?.name ?? 'Unbekannter Stand',
+ apiaryLocation: apiary?.address ?? apiary?.location ?? null,
+ };
+ })
+ .sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de')),
+ };
+}
+
+function orMissing(value: string | number | null | undefined): string {
+ return value === null || value === undefined || value === ''
+ ? MISSING
+ : String(value);
+}
+
+function renderSheet(
+ doc: PDFKit.PDFDocument,
+ log: TreatmentLog,
+ sheet: TreatmentLog['sheets'][number] | null,
+): void {
+ doc
+ .font('Helvetica-Bold')
+ .fontSize(14)
+ .text(
+ 'Buchführung (Bestandsbuch) über die Anwendung von Tierarzneimitteln bei Bienen',
+ PDF_MARGIN,
+ PDF_MARGIN,
+ );
+ doc.font('Helvetica').fontSize(9);
+ doc.text(
+ `Jahr ${log.year} · erstellt am ${formatDay(log.generatedAt)} · Art. 108 VO (EU) 2019/6`,
+ );
+ doc.moveDown(0.4);
+ doc.text(
+ `Name und Anschrift des Bienenhalters: ${
+ log.holderName || log.holderAddress
+ ? [log.holderName, log.holderAddress].filter(Boolean).join(', ')
+ : `${MISSING} (in ApiCore unter Verwaltung → Automatisierung → Stammdaten & Web eintragen)`
+ }`,
+ );
+ if (sheet) {
+ doc.text(
+ `Standort der Bienen: ${sheet.apiaryName}${sheet.apiaryLocation ? `, ${sheet.apiaryLocation}` : ''}`,
+ );
+ }
+ doc.moveDown(0.5);
+
+ if (!sheet) {
+ doc.text(`Im Jahr ${log.year} wurden keine Behandlungen erfasst.`);
+ return;
+ }
+
+ const uncertain = sheet.rows.some((row) => row.locationUncertain);
+ drawTable(
+ doc,
+ fillWidth(doc, [
+ { header: 'Anzahl und Nummern der Völker', width: 110 },
+ { header: 'Arzneimittel (Handelsname), Ch.-B.', width: 120 },
+ { header: 'a) Lieferant (Name, Anschrift), ggf. Tierarzt', width: 150 },
+ { header: 'b) Beleg für den Erwerb', width: 110 },
+ { header: 'Datum der Verabreichung, Dauer', width: 90 },
+ { header: 'Verabreichte Menge', width: 70 },
+ { header: 'Wartezeit (Tage)', width: 50 },
+ { header: 'Anwendende Person', width: 0 },
+ ]),
+ sheet.rows.map((row) => [
+ `${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
+ `${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
+ `${orMissing(row.supplier)}${row.prescribingVet ? `; Tierarzt: ${row.prescribingVet}` : ''}`,
+ orMissing(row.purchaseProof),
+ `${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
+ orMissing(row.amount),
+ orMissing(row.withdrawalPeriodDays),
+ orDash(row.administeredBy),
+ ]),
+ );
+ doc.font('Helvetica').fontSize(7);
+ if (uncertain) {
+ doc.text(
+ '* Standort aus der heutigen Zuordnung ermittelt: das Volk wurde später umgesetzt, der Stand am Behandlungstag ist nicht erfasst. Bitte prüfen.',
+ PDF_MARGIN,
+ );
+ }
+ doc.text(
+ `"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung nachtragen. Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
+ PDF_MARGIN,
+ );
+}
+
+export function renderTreatmentLogPdf(log: TreatmentLog): Promise {
+ return new Promise((resolve, reject) => {
+ const doc = new PDFDocument({
+ size: 'A4',
+ layout: 'landscape',
+ margin: PDF_MARGIN,
+ });
+ const chunks: Buffer[] = [];
+ doc.on('data', (chunk: Buffer) => chunks.push(chunk));
+ doc.on('end', () => resolve(Buffer.concat(chunks)));
+ doc.on('error', reject);
+
+ if (log.sheets.length === 0) {
+ renderSheet(doc, log, null);
+ }
+ log.sheets.forEach((sheet, index) => {
+ if (index > 0) doc.addPage();
+ renderSheet(doc, log, sheet);
+ });
+ doc.end();
+ });
+}
diff --git a/ApiCore-Backend/src/public/public.service.spec.ts b/ApiCore-Backend/src/public/public.service.spec.ts
index 3c9561d..2351779 100644
--- a/ApiCore-Backend/src/public/public.service.spec.ts
+++ b/ApiCore-Backend/src/public/public.service.spec.ts
@@ -106,4 +106,33 @@ describe('PublicService', () => {
tiktok: null,
});
});
+
+ it('never exposes the holder name or address kept for the Bestandsbuch', async () => {
+ siteSettings.get.mockResolvedValue({
+ youtubeUrl: null,
+ instagramUrl: null,
+ tiktokUrl: null,
+ websiteUrl: null,
+ holderName: 'Nick Muster',
+ holderAddress: 'Musterweg 3, 86450 Altenmünster',
+ });
+ prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
+ lotNumber: 'WH-2026-001',
+ date: new Date('2026-08-20'),
+ bestBeforeDate: new Date('2028-08-20'),
+ jarSize: 500,
+ product: null,
+ honeyBatch: {
+ honeyType: null,
+ waterContent: null,
+ extraction: { date: null, harvests: [] },
+ },
+ });
+
+ const result = await service.findBatchByLot('WH-2026-001');
+
+ const serialized = JSON.stringify(result);
+ expect(serialized).not.toContain('Nick Muster');
+ expect(serialized).not.toContain('Musterweg');
+ });
});
diff --git a/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts b/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts
index 6bab41f..41af257 100644
--- a/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts
+++ b/ApiCore-Backend/src/site-settings/dto/update-site-settings.dto.ts
@@ -1,5 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
-import { IsOptional, IsUrl, MaxLength, ValidateIf } from 'class-validator';
+import {
+ IsOptional,
+ IsString,
+ IsUrl,
+ MaxLength,
+ ValidateIf,
+} from 'class-validator';
// An empty string clears the link (stored as null); anything else must be a
// real URL - ValidateIf skips the @IsUrl check only for the empty-string
@@ -32,4 +38,18 @@ export class UpdateSiteSettingsDto {
@IsUrl({ require_protocol: true })
@MaxLength(300)
websiteUrl?: string;
+
+ @ApiPropertyOptional({ description: 'Name des Bienenhalters (Bestandsbuch)' })
+ @IsOptional()
+ @IsString()
+ @MaxLength(200)
+ holderName?: string;
+
+ @ApiPropertyOptional({
+ description: 'Anschrift des Bienenhalters (Bestandsbuch)',
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(300)
+ holderAddress?: string;
}
diff --git a/ApiCore-Backend/src/site-settings/site-settings.service.ts b/ApiCore-Backend/src/site-settings/site-settings.service.ts
index 6d3e0a9..985b40d 100644
--- a/ApiCore-Backend/src/site-settings/site-settings.service.ts
+++ b/ApiCore-Backend/src/site-settings/site-settings.service.ts
@@ -52,6 +52,14 @@ export class SiteSettingsService {
dto.websiteUrl !== undefined
? emptyToNull(dto.websiteUrl)
: undefined,
+ holderName:
+ dto.holderName !== undefined
+ ? emptyToNull(dto.holderName.trim())
+ : undefined,
+ holderAddress:
+ dto.holderAddress !== undefined
+ ? emptyToNull(dto.holderAddress.trim())
+ : undefined,
},
});
await this.auditLog.record({
diff --git a/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts b/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
index 4ede718..8c481e3 100644
--- a/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
+++ b/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts
@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
Min,
+ MaxLength,
} from 'class-validator';
export class CreateTreatmentDto {
@@ -55,6 +56,31 @@ export class CreateTreatmentDto {
@IsString()
batchNumber?: string;
+ @ApiPropertyOptional({
+ description: 'Lieferant (Name, Anschrift), z. B. Apotheke oder Fachhandel',
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(300)
+ supplier?: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Beleg für den Erwerb, z. B. "Rechnung Nr. 123 vom 01.08.2026"',
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(300)
+ purchaseProof?: string;
+
+ @ApiPropertyOptional({
+ description: 'Verschreibender Tierarzt, falls zutreffend',
+ })
+ @IsOptional()
+ @IsString()
+ @MaxLength(300)
+ prescribingVet?: string;
+
@ApiPropertyOptional()
@IsOptional()
@IsString()
diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts
index 365e85e..cb07bc9 100644
--- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts
+++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts
@@ -14,6 +14,7 @@ describe('TreatmentsService', () => {
create: jest.Mock;
update: jest.Mock;
};
+ colony: { findUnique: jest.Mock };
};
let auditLog: { record: jest.Mock };
@@ -31,6 +32,9 @@ describe('TreatmentsService', () => {
create: jest.fn(),
update: jest.fn(),
},
+ colony: {
+ findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }),
+ },
};
auditLog = { record: jest.fn() };
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
@@ -58,9 +62,20 @@ describe('TreatmentsService', () => {
);
const [[createArg]] = prisma.treatment.create.mock.calls as [
- [{ data: { colonyId: string; userId: string; product: string } }],
+ [
+ {
+ data: {
+ colonyId: string;
+ userId: string;
+ product: string;
+ apiaryId: string;
+ };
+ },
+ ],
];
expect(createArg.data.colonyId).toBe('colony-1');
+ // The apiary is kept with the treatment for the Bestandsbuch.
+ expect(createArg.data.apiaryId).toBe('apiary-1');
expect(createArg.data.userId).toBe('user-1');
expect(createArg.data.product).toBe('Ameisensäure');
});
diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts
index aeaca5b..0bdab48 100644
--- a/ApiCore-Backend/src/treatments/treatments.service.ts
+++ b/ApiCore-Backend/src/treatments/treatments.service.ts
@@ -83,9 +83,19 @@ export class TreatmentsService {
dto: CreateTreatmentDto,
actingUserId: string,
): Promise {
+ const colony = await this.prisma.colony.findUnique({
+ where: { id: dto.colonyId },
+ select: { apiaryId: true },
+ });
+ if (!colony) {
+ throw new NotFoundException('Colony not found');
+ }
const treatment = await this.prisma.treatment.create({
data: {
...dto,
+ // Where the colony stands today is where it was treated - kept on the
+ // treatment for the Bestandsbuch, the colony may be moved later.
+ apiaryId: colony.apiaryId,
date: new Date(dto.date),
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
@@ -110,10 +120,19 @@ export class TreatmentsService {
actingUserId: string,
): Promise {
const before = await this.findById(id);
+ // Re-filed to another colony: take that colony's apiary as the location.
+ const movedColony =
+ dto.colonyId !== undefined && dto.colonyId !== before.colonyId
+ ? await this.prisma.colony.findUnique({
+ where: { id: dto.colonyId },
+ select: { apiaryId: true },
+ })
+ : null;
const treatment = await this.prisma.treatment.update({
where: { id },
data: {
...dto,
+ apiaryId: movedColony ? movedColony.apiaryId : undefined,
date: dto.date ? new Date(dto.date) : undefined,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
diff --git a/ApiCore-Backend/test/export.e2e-spec.ts b/ApiCore-Backend/test/export.e2e-spec.ts
index 72fab08..9a5133d 100644
--- a/ApiCore-Backend/test/export.e2e-spec.ts
+++ b/ApiCore-Backend/test/export.e2e-spec.ts
@@ -184,6 +184,19 @@ describe('Export (e2e)', () => {
.expect(404);
});
+ it('renders the Bestandsbuch for a year, also when nothing was treated', async () => {
+ for (const query of ['year=2026', 'year=1999']) {
+ const response = await request(app.getHttpServer())
+ .get(`/api/v1/export/treatment-log.pdf?${query}`)
+ .set('Authorization', `Bearer ${beekeeperToken}`)
+ .expect(200);
+ expect(response.headers['content-type']).toContain('application/pdf');
+ expect(
+ (response.body as Buffer).subarray(0, 4).toString('latin1'),
+ ).toBe('%PDF');
+ }
+ });
+
it('rejects the apiary annual report without an apiaryId', () => {
return request(app.getHttpServer())
.get('/api/v1/export/apiary-annual-report.pdf')
diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx
index ee73f3b..b1790a1 100644
--- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx
+++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx
@@ -5,7 +5,7 @@ import { useParams } from "next/navigation";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchApiary } from "@/lib/apiaries/api";
-import { exportApiaryAnnualReportPdf } from "@/lib/export/api";
+import { exportApiaryAnnualReportPdf, exportTreatmentLogPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { Button } from "@/components/ui/button";
@@ -26,6 +26,11 @@ export default function ApiaryDetailPage() {
onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"),
});
+ const treatmentLogMutation = useMutation({
+ mutationFn: () => exportTreatmentLogPdf(CURRENT_YEAR, params.id),
+ onError: () => toast.error("Bestandsbuch konnte nicht erstellt werden. Bitte noch einmal versuchen."),
+ });
+
if (isLoading) {
return Lädt…
;
}
@@ -52,6 +57,13 @@ export default function ApiaryDetailPage() {
>
Neue Ernte
+
+