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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-28 06:27:55 +02:00
1 parent 5cf4e87ca3
commit f263ec79f2
5 files changed
+586 -1

No files matched your search

@@ -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<string, string> = {
PRODUCTION_COLONY: 'Wirtschaftsvolk',
NUCLEUS_COLONY: 'Ableger',
YOUNG_COLONY: 'Jungvolk',
CARETAKER_COLONY: 'Pflegevolk',
MATING_NUCLEUS: 'Begattungseinheit',
RESERVE_COLONY: 'Reservevolk',
};
const COLONY_STATUS_LABELS: Record<string, string> = {
ACTIVE: 'Aktiv',
WEAK: 'Schwach',
CONSPICUOUS: 'Auffällig',
QUEENLESS: 'Weisellos',
DISSOLVED: 'Aufgelöst',
SOLD: 'Verkauft',
DECEASED: 'Eingegangen',
MERGED: 'Vereinigt',
};
const FEED_TYPE_LABELS: Record<string, string> = {
SUGAR_WATER: 'Zuckerwasser',
FEED_SYRUP: 'Futtersirup',
FEED_PASTE: 'Futterteig',
OWN_HONEY: 'Eigener Honig',
OTHER: 'Sonstiges',
};
const VARROA_METHOD_LABELS: Record<string, string> = {
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<ColonyRecordCard> {
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<Buffer> {
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();
});
}
@@ -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);
}
}
}
+27
View File
@@ -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')