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 (
+