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 +