From f263ec79f22fa0cf428b89d8ae9a61e972c61dec Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 28 Sep 2026 06:27:55 +0200 Subject: [PATCH] feat(export): Stockkarte - a colony's complete record as PDF New report colony-record-card (button "Stockkarte (PDF)" on the colony page): master data, current queen and queen history, every inspection as a table row (strength, eggs, open/capped brood, queen seen, queen cells, gentleness, swarm drive, feed, measures, notes), treatments with batch number and withdrawal period, feedings, varroa counts and harvests. Landscape A4, table header repeated on each page. Whole history by default, one calendar year with ?year=. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/export/colony-record-card.ts | 518 ++++++++++++++++++ ApiCore-Backend/src/export/export.service.ts | 19 + ApiCore-Backend/test/export.e2e-spec.ts | 27 + .../src/app/(app)/colonies/[id]/page.tsx | 14 +- ApiCore-Frontend/src/lib/export/api.ts | 9 + 5 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Backend/src/export/colony-record-card.ts diff --git a/ApiCore-Backend/src/export/colony-record-card.ts b/ApiCore-Backend/src/export/colony-record-card.ts new file mode 100644 index 0000000..76ba97a --- /dev/null +++ b/ApiCore-Backend/src/export/colony-record-card.ts @@ -0,0 +1,518 @@ +import { NotFoundException } from '@nestjs/common'; +import PDFDocument from 'pdfkit'; +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Stockkarte: the complete written record of one colony - master data, queen, + * every inspection as a table row, treatments, feedings, varroa counts and + * harvests. For the folder at the apiary or to hand over with a sold colony. + * Whole history by default, or one calendar year. + */ + +const COLONY_TYPE_LABELS: Record = { + PRODUCTION_COLONY: 'Wirtschaftsvolk', + NUCLEUS_COLONY: 'Ableger', + YOUNG_COLONY: 'Jungvolk', + CARETAKER_COLONY: 'Pflegevolk', + MATING_NUCLEUS: 'Begattungseinheit', + RESERVE_COLONY: 'Reservevolk', +}; + +const COLONY_STATUS_LABELS: Record = { + ACTIVE: 'Aktiv', + WEAK: 'Schwach', + CONSPICUOUS: 'Auffällig', + QUEENLESS: 'Weisellos', + DISSOLVED: 'Aufgelöst', + SOLD: 'Verkauft', + DECEASED: 'Eingegangen', + MERGED: 'Vereinigt', +}; + +const FEED_TYPE_LABELS: Record = { + SUGAR_WATER: 'Zuckerwasser', + FEED_SYRUP: 'Futtersirup', + FEED_PASTE: 'Futterteig', + OWN_HONEY: 'Eigener Honig', + OTHER: 'Sonstiges', +}; + +const VARROA_METHOD_LABELS: Record = { + NATURAL_MITE_FALL: 'Natürlicher Milbenfall', + POWDERED_SUGAR: 'Puderzucker', + WASHING_METHOD: 'Auswaschen', + DRONE_BROOD_CONTROL: 'Drohnenbrut', +}; + +export interface ColonyRecordCard { + generatedAt: Date; + year: number | null; + colony: { + code: string; + colonyType: string; + status: string; + hiveType: string | null; + originText: string | null; + createdAt: Date; + apiaryName: string; + apiaryLocation: string | null; + }; + currentQueen: { + code: string; + hatchYear: number | null; + markingColor: string | null; + breed: string | null; + origin: string | null; + } | null; + queenHistory: { code: string; startDate: Date; endDate: Date | null }[]; + inspections: { + date: Date; + colonyStrength: number | null; + openBroodPresent: boolean | null; + cappedBroodPresent: boolean | null; + eggsPresent: boolean | null; + queenSeen: boolean | null; + queenCellsPresent: boolean | null; + gentleness: number | null; + swarmMood: number | null; + feedStatus: string | null; + measures: string | null; + notes: string | null; + }[]; + treatments: { + date: Date; + endDate: Date | null; + product: string; + activeIngredient: string | null; + amount: string | null; + batchNumber: string | null; + withdrawalPeriodDays: number | null; + }[]; + feedings: { + date: Date; + feedType: string; + product: string | null; + amount: number; + unit: string; + }[]; + varroaMeasurements: { + date: Date; + method: string; + miteCount: number | null; + calculatedValue: number | null; + }[]; + harvests: { + date: Date; + honeyType: string | null; + netWeight: number | null; + }[]; +} + +function dateFilter(year: number | null) { + return year === null + ? undefined + : { + gte: new Date(Date.UTC(year, 0, 1)), + lt: new Date(Date.UTC(year + 1, 0, 1)), + }; +} + +export async function buildColonyRecordCard( + prisma: PrismaService, + colonyId: string, + year: number | null, +): Promise { + const colony = await prisma.colony.findUnique({ + where: { id: colonyId }, + include: { apiary: true, currentQueen: true }, + }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + const date = dateFilter(year); + const [ + inspections, + treatments, + feedings, + varroa, + harvestLinks, + queenHistory, + ] = await Promise.all([ + prisma.inspection.findMany({ + where: { colonyId, date }, + orderBy: { date: 'asc' }, + }), + prisma.treatment.findMany({ + where: { colonyId, date }, + orderBy: { date: 'asc' }, + }), + prisma.feeding.findMany({ + where: { colonyId, date }, + orderBy: { date: 'asc' }, + }), + prisma.varroaMeasurement.findMany({ + where: { colonyId, date }, + orderBy: { date: 'asc' }, + }), + prisma.harvestColony.findMany({ + where: { colonyId, harvest: { date } }, + include: { harvest: true }, + orderBy: { harvest: { date: 'asc' } }, + }), + prisma.queenColonyHistory.findMany({ + where: { colonyId }, + include: { queen: true }, + orderBy: { startDate: 'asc' }, + }), + ]); + + const queen = colony.currentQueen; + return { + generatedAt: new Date(), + year, + colony: { + code: colony.code, + colonyType: colony.colonyType, + status: colony.status, + hiveType: colony.hiveType, + originText: colony.originText, + createdAt: colony.createdAt, + apiaryName: colony.apiary.name, + apiaryLocation: colony.apiary.location, + }, + currentQueen: queen + ? { + code: queen.code, + hatchYear: queen.hatchYear, + markingColor: queen.markingColor, + breed: queen.breed, + origin: queen.origin, + } + : null, + queenHistory: queenHistory.map((entry) => ({ + code: entry.queen.code, + startDate: entry.startDate, + endDate: entry.endDate, + })), + inspections: inspections.map((i) => ({ + date: i.date, + colonyStrength: i.colonyStrength, + openBroodPresent: i.openBroodPresent, + cappedBroodPresent: i.cappedBroodPresent, + eggsPresent: i.eggsPresent, + queenSeen: i.queenSeen, + queenCellsPresent: i.queenCellsPresent, + gentleness: i.gentleness, + swarmMood: i.swarmMood, + feedStatus: i.feedStatus, + measures: i.measures, + notes: i.notes, + })), + treatments: treatments.map((t) => ({ + date: t.date, + endDate: t.endDate, + product: t.product, + activeIngredient: t.activeIngredient, + amount: t.amount, + batchNumber: t.batchNumber, + withdrawalPeriodDays: t.withdrawalPeriodDays, + })), + feedings: feedings.map((f) => ({ + date: f.date, + feedType: f.feedType, + product: f.product, + amount: f.amount, + unit: f.unit, + })), + varroaMeasurements: varroa.map((v) => ({ + date: v.date, + method: v.method, + miteCount: v.miteCount, + calculatedValue: v.calculatedValue, + })), + harvests: harvestLinks.map((link) => ({ + date: link.harvest.date, + honeyType: link.harvest.honeyType, + netWeight: link.harvest.netWeight, + })), + }; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +const MARGIN = 36; + +function day(date: Date): string { + return date.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: 'UTC', + }); +} + +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 { + return new Promise((resolve, reject) => { + // Landscape: the inspection table needs the width. + const doc = new PDFDocument({ + size: 'A4', + layout: 'landscape', + margin: MARGIN, + }); + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + const { colony } = card; + doc + .font('Helvetica-Bold') + .fontSize(16) + .text(`Stockkarte ${colony.code}`, MARGIN, MARGIN); + doc + .font('Helvetica') + .fontSize(9) + .text( + `${card.year === null ? 'Gesamte Historie' : `Jahr ${card.year}`} · erstellt am ${day(card.generatedAt)}`, + ); + doc.moveDown(0.5); + + doc + .fontSize(9) + .text( + [ + `Stand: ${colony.apiaryName}${colony.apiaryLocation ? ` (${colony.apiaryLocation})` : ''}`, + `Art: ${COLONY_TYPE_LABELS[colony.colonyType] ?? colony.colonyType}`, + `Status: ${COLONY_STATUS_LABELS[colony.status] ?? colony.status}`, + `Beute: ${orDash(colony.hiveType)}`, + `Herkunft: ${orDash(colony.originText)}`, + `Erfasst seit: ${day(colony.createdAt)}`, + ].join(' · '), + ); + + section(doc, 'Königin'); + const queen = card.currentQueen; + doc + .font('Helvetica') + .fontSize(9) + .text( + queen + ? `${queen.code} · Schlupfjahr ${orDash(queen.hatchYear)} · Zeichen ${orDash(queen.markingColor)} · Rasse ${orDash(queen.breed)} · Herkunft ${orDash(queen.origin)}` + : 'Keine Königin zugeordnet.', + MARGIN, + ); + if (card.queenHistory.length > 0) { + doc.text( + `Verlauf: ${card.queenHistory + .map( + (q) => + `${q.code} (${day(q.startDate)} – ${q.endDate ? day(q.endDate) : 'heute'})`, + ) + .join('; ')}`, + ); + } + + section(doc, 'Durchsichten'); + drawTable( + doc, + fillWidth(doc, [ + { header: 'Datum', width: 58 }, + { header: 'Stärke (Waben)', width: 52 }, + { header: 'Stifte', width: 36 }, + { header: 'Offene Brut', width: 42 }, + { header: 'Verd. Brut', width: 42 }, + { header: 'Kö gesehen', width: 44 }, + { header: 'Weiselzellen', width: 60 }, + { header: 'Sanftmut 1–5', width: 44 }, + { header: 'Schwarmtrieb 1–5', width: 66 }, + { header: 'Futter', width: 56 }, + { header: 'Maßnahmen', width: 126 }, + { header: 'Notizen', width: 0 }, + ]), + card.inspections.map((i) => [ + day(i.date), + orDash(i.colonyStrength), + yesNo(i.eggsPresent), + yesNo(i.openBroodPresent), + yesNo(i.cappedBroodPresent), + yesNo(i.queenSeen), + yesNo(i.queenCellsPresent), + orDash(i.gentleness), + orDash(i.swarmMood), + orDash(i.feedStatus), + orDash(i.measures), + orDash(i.notes), + ]), + ); + + section(doc, 'Behandlungen'); + drawTable( + doc, + [ + { header: 'Datum', width: 70 }, + { header: 'Ende', width: 70 }, + { header: 'Mittel', width: 150 }, + { header: 'Wirkstoff', width: 120 }, + { header: 'Menge', width: 100 }, + { header: 'Chargen-Nr.', width: 100 }, + { header: 'Wartezeit (Tage)', width: 80 }, + ], + card.treatments.map((t) => [ + day(t.date), + t.endDate ? day(t.endDate) : '–', + t.product, + orDash(t.activeIngredient), + orDash(t.amount), + orDash(t.batchNumber), + orDash(t.withdrawalPeriodDays), + ]), + ); + + section(doc, 'Fütterungen'); + drawTable( + doc, + [ + { header: 'Datum', width: 70 }, + { header: 'Futterart', width: 120 }, + { header: 'Produkt', width: 200 }, + { header: 'Menge', width: 100 }, + ], + card.feedings.map((f) => [ + day(f.date), + FEED_TYPE_LABELS[f.feedType] ?? f.feedType, + orDash(f.product), + `${f.amount} ${f.unit}`, + ]), + ); + + section(doc, 'Varroa'); + drawTable( + doc, + [ + { header: 'Datum', width: 70 }, + { header: 'Methode', width: 150 }, + { header: 'Milben gezählt', width: 90 }, + { header: 'Berechneter Wert', width: 100 }, + ], + card.varroaMeasurements.map((v) => [ + day(v.date), + VARROA_METHOD_LABELS[v.method] ?? v.method, + orDash(v.miteCount), + v.calculatedValue === null ? '–' : v.calculatedValue.toFixed(1), + ]), + ); + + section(doc, 'Ernten'); + drawTable( + doc, + [ + { header: 'Datum', width: 70 }, + { header: 'Sorte', width: 200 }, + { header: 'Netto (kg, gesamte Ernte)', width: 140 }, + ], + card.harvests.map((h) => [ + day(h.date), + orDash(h.honeyType), + orDash(h.netWeight), + ]), + ); + + doc.end(); + }); +} diff --git a/ApiCore-Backend/src/export/export.service.ts b/ApiCore-Backend/src/export/export.service.ts index c35a97e..70ff941 100644 --- a/ApiCore-Backend/src/export/export.service.ts +++ b/ApiCore-Backend/src/export/export.service.ts @@ -1,5 +1,9 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import PDFDocument from 'pdfkit'; +import { + buildColonyRecordCard, + renderColonyRecordCardPdf, +} from './colony-record-card'; import { PrismaService } from '../prisma/prisma.service'; import { HoneyBatchesService } from '../honey-batches/honey-batches.service'; import { @@ -22,6 +26,7 @@ const PDF_REPORTS = [ 'honey-batch-traceability', 'colony-annual-report', 'apiary-annual-report', + 'colony-record-card', ] as const; type PdfReport = (typeof PDF_REPORTS)[number]; @@ -188,6 +193,20 @@ export class ExportService { ); return renderApiaryAnnualReportPdf(apiaryReport); } + case 'colony-record-card': { + if (!options.colonyId) { + throw new BadRequestException( + 'Query parameter colonyId is required for this report', + ); + } + // Without a year the record card covers the colony's whole history. + const card = await buildColonyRecordCard( + this.prisma, + options.colonyId, + options.year ? resolveYear(options.year) : null, + ); + return renderColonyRecordCardPdf(card); + } } } diff --git a/ApiCore-Backend/test/export.e2e-spec.ts b/ApiCore-Backend/test/export.e2e-spec.ts index cf91565..72fab08 100644 --- a/ApiCore-Backend/test/export.e2e-spec.ts +++ b/ApiCore-Backend/test/export.e2e-spec.ts @@ -157,6 +157,33 @@ describe('Export (e2e)', () => { ); }); + it('renders the record card (Stockkarte) for the whole history and for one year', async () => { + for (const query of [ + `colonyId=${colonyId}`, + `colonyId=${colonyId}&year=2026`, + ]) { + const response = await request(app.getHttpServer()) + .get(`/api/v1/export/colony-record-card.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 record card without a colonyId and for an unknown colony', async () => { + await request(app.getHttpServer()) + .get('/api/v1/export/colony-record-card.pdf') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(400); + await request(app.getHttpServer()) + .get('/api/v1/export/colony-record-card.pdf?colonyId=does-not-exist') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(404); + }); + 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)/colonies/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx index 074d1d9..0385de9 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/page.tsx @@ -4,7 +4,7 @@ import { useParams } from "next/navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { fetchColony } from "@/lib/colonies/api"; -import { exportColonyAnnualReportPdf } from "@/lib/export/api"; +import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api"; import { QrCodeDialog } from "@/components/qr-code-dialog"; import { DocumentUploadCard } from "@/components/document-upload-card"; import { Button } from "@/components/ui/button"; @@ -31,6 +31,11 @@ export default function ColonyDetailPage() { onError: () => toast.error("Jahresbericht konnte nicht exportiert werden"), }); + const recordCardMutation = useMutation({ + mutationFn: () => exportColonyRecordCardPdf(params.id, colony?.code ?? "volk"), + onError: () => toast.error("Stockkarte konnte nicht erstellt werden. Bitte noch einmal versuchen."), + }); + if (isLoading) { return

Lädt…

; } @@ -45,6 +50,13 @@ export default function ColonyDetailPage() { return (
+