feat: Jahresbericht-PDF pro Volk/Stand

Neue PDF-Reports im Export-Modul: colony-annual-report (Durchsichten,
Behandlungen, Varroakontrollen, Schwarmkontrolle, Ernten eines Volkes fuer
ein Jahr) und apiary-annual-report (Voelker-Uebersicht + Ernten eines
Stands fuer ein Jahr). Jahr ist optional, faellt auf das aktuelle Jahr
zurueck.

Frontend: "Jahresbericht"-Button auf Volk- und Stand-Detailseite, laedt
das PDF ueber den bestehenden Blob-Download-Mechanismus herunter.
This commit is contained in:
Nick Szaunig committed 2026-08-25 11:51:41 +02:00
1 parent f1298eff08
commit 3f7c4ed404
8 files changed
+630 -26

No files matched your search

@@ -0,0 +1,372 @@
import { NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
function yearRange(year: number): { start: Date; end: Date } {
return {
start: new Date(Date.UTC(year, 0, 1)),
end: new Date(Date.UTC(year + 1, 0, 1)),
};
}
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE');
}
const SWARM_MOOD_LABELS: Record<string, string> = {
NONE: 'Keine',
LOW: 'Gering',
HIGH: 'Hoch',
};
const SWARM_ACTION_LABELS: Record<string, string> = {
NONE: 'Keine',
QUEEN_CELLS_REMOVED: 'Weiselzellen gebrochen',
ARTIFICIAL_SWARM: 'Kunstschwarm gebildet',
COLONY_DIVIDED: 'Volk geteilt',
OTHER: 'Sonstiges',
};
export interface ColonyAnnualReport {
colony: { id: string; code: string; colonyType: string; status: string };
apiary: { id: string; name: string } | null;
year: number;
inspections: {
date: Date;
colonyStrength: number | null;
queenSeen: boolean | null;
broodAmount: string | null;
}[];
treatments: { date: Date; product: string; amount: string | null }[];
varroaMeasurements: {
date: Date;
method: string;
miteCount: number | null;
calculatedValue: number | null;
}[];
swarmControlChecks: {
date: Date;
queenCellsFound: number | null;
queenCellsCapped: boolean;
swarmMood: string;
actionTaken: string;
}[];
harvests: {
date: Date;
honeyType: string | null;
netWeight: number | null;
}[];
}
export interface ApiaryAnnualReport {
apiary: { id: string; name: string };
year: number;
colonies: {
code: string;
inspectionCount: number;
treatmentCount: number;
harvestCount: number;
}[];
harvests: {
date: Date;
honeyType: string | null;
netWeight: number | null;
grossWeight: number | null;
}[];
}
export async function buildColonyAnnualReport(
prisma: PrismaService,
colonyId: string,
year: number,
): Promise<ColonyAnnualReport> {
const colony = await prisma.colony.findUnique({
where: { id: colonyId },
include: { apiary: true },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
const { start, end } = yearRange(year);
const dateInYear = { gte: start, lt: end };
const [
inspections,
treatments,
varroaMeasurements,
swarmControlChecks,
harvestLinks,
] = await Promise.all([
prisma.inspection.findMany({
where: { colonyId, date: dateInYear },
orderBy: { date: 'asc' },
}),
prisma.treatment.findMany({
where: { colonyId, date: dateInYear },
orderBy: { date: 'asc' },
}),
prisma.varroaMeasurement.findMany({
where: { colonyId, date: dateInYear },
orderBy: { date: 'asc' },
}),
prisma.swarmControlCheck.findMany({
where: { colonyId, date: dateInYear },
orderBy: { date: 'asc' },
}),
prisma.harvestColony.findMany({
where: { colonyId, harvest: { date: dateInYear } },
include: { harvest: true },
orderBy: { harvest: { date: 'asc' } },
}),
]);
return {
colony: {
id: colony.id,
code: colony.code,
colonyType: colony.colonyType,
status: colony.status,
},
apiary: colony.apiary
? { id: colony.apiary.id, name: colony.apiary.name }
: null,
year,
inspections: inspections.map((entry) => ({
date: entry.date,
colonyStrength: entry.colonyStrength,
queenSeen: entry.queenSeen,
broodAmount: entry.broodAmount,
})),
treatments: treatments.map((entry) => ({
date: entry.date,
product: entry.product,
amount: entry.amount,
})),
varroaMeasurements: varroaMeasurements.map((entry) => ({
date: entry.date,
method: entry.method,
miteCount: entry.miteCount,
calculatedValue: entry.calculatedValue,
})),
swarmControlChecks: swarmControlChecks.map((entry) => ({
date: entry.date,
queenCellsFound: entry.queenCellsFound,
queenCellsCapped: entry.queenCellsCapped,
swarmMood: entry.swarmMood,
actionTaken: entry.actionTaken,
})),
harvests: harvestLinks.map((link) => ({
date: link.harvest.date,
honeyType: link.harvest.honeyType,
netWeight: link.harvest.netWeight,
})),
};
}
export async function buildApiaryAnnualReport(
prisma: PrismaService,
apiaryId: string,
year: number,
): Promise<ApiaryAnnualReport> {
const apiary = await prisma.apiary.findUnique({ where: { id: apiaryId } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
const { start, end } = yearRange(year);
const dateInYear = { gte: start, lt: end };
const colonies = await prisma.colony.findMany({
where: { apiaryId },
orderBy: { code: 'asc' },
});
const harvests = await prisma.harvest.findMany({
where: { apiaryId, date: dateInYear },
orderBy: { date: 'asc' },
});
const colonySummaries = await Promise.all(
colonies.map(async (colony) => {
const [inspectionCount, treatmentCount, harvestCount] = await Promise.all(
[
prisma.inspection.count({
where: { colonyId: colony.id, date: dateInYear },
}),
prisma.treatment.count({
where: { colonyId: colony.id, date: dateInYear },
}),
prisma.harvestColony.count({
where: { colonyId: colony.id, harvest: { date: dateInYear } },
}),
],
);
return {
code: colony.code,
inspectionCount,
treatmentCount,
harvestCount,
};
}),
);
return {
apiary: { id: apiary.id, name: apiary.name },
year,
colonies: colonySummaries,
harvests: harvests.map((harvest) => ({
date: harvest.date,
honeyType: harvest.honeyType,
netWeight: harvest.netWeight,
grossWeight: harvest.grossWeight,
})),
};
}
export function renderColonyAnnualReportPdf(
report: ColonyAnnualReport,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', (error: Error) => reject(error));
doc.fontSize(18).text(`Jahresbericht Volk ${report.colony.code}`, {
underline: true,
});
doc.fontSize(12).text(`Jahr: ${report.year}`);
if (report.apiary) {
doc.text(`Stand: ${report.apiary.name}`);
}
doc.text(`Status: ${report.colony.status}`);
doc.moveDown();
doc.fontSize(14).text(`Durchsichten (${report.inspections.length})`);
doc.fontSize(12);
if (report.inspections.length === 0) {
doc.text('Keine Durchsichten im Berichtsjahr.');
}
for (const entry of report.inspections) {
doc.text(
`- ${formatDate(entry.date)}: Staerke ${entry.colonyStrength ?? '-'}, ` +
`Koenigin gesehen: ${entry.queenSeen === null ? '-' : entry.queenSeen ? 'ja' : 'nein'}, ` +
`Brut: ${entry.broodAmount ?? '-'}`,
);
}
doc.moveDown();
doc.fontSize(14).text(`Behandlungen (${report.treatments.length})`);
doc.fontSize(12);
if (report.treatments.length === 0) {
doc.text('Keine Behandlungen im Berichtsjahr.');
}
for (const entry of report.treatments) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.product} (${entry.amount ?? '-'})`,
);
}
doc.moveDown();
doc
.fontSize(14)
.text(`Varroakontrollen (${report.varroaMeasurements.length})`);
doc.fontSize(12);
if (report.varroaMeasurements.length === 0) {
doc.text('Keine Varroakontrollen im Berichtsjahr.');
}
for (const entry of report.varroaMeasurements) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.method}, ` +
`${entry.miteCount ?? '-'} Milben` +
(entry.calculatedValue !== null
? ` (${entry.calculatedValue.toFixed(1)} Milben/Tag)`
: ''),
);
}
doc.moveDown();
doc
.fontSize(14)
.text(`Schwarmkontrolle (${report.swarmControlChecks.length})`);
doc.fontSize(12);
if (report.swarmControlChecks.length === 0) {
doc.text('Keine Schwarmkontrolle im Berichtsjahr.');
}
for (const entry of report.swarmControlChecks) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.queenCellsFound ?? 0} Weiselzelle(n)` +
`${entry.queenCellsCapped ? ' (verdeckelt)' : ''}, ` +
`Stimmung: ${SWARM_MOOD_LABELS[entry.swarmMood] ?? entry.swarmMood}, ` +
`Massnahme: ${SWARM_ACTION_LABELS[entry.actionTaken] ?? entry.actionTaken}`,
);
}
doc.moveDown();
doc.fontSize(14).text(`Ernten (${report.harvests.length})`);
doc.fontSize(12);
if (report.harvests.length === 0) {
doc.text('Keine Ernten im Berichtsjahr.');
}
for (const entry of report.harvests) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.honeyType ?? '-'}, ` +
`${entry.netWeight ?? '-'} kg (Stand-Ernte, ggf. mit anderen Voelkern geteilt)`,
);
}
doc.end();
});
}
export function renderApiaryAnnualReportPdf(
report: ApiaryAnnualReport,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ margin: 50 });
const chunks: Buffer[] = [];
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', (error: Error) => reject(error));
doc.fontSize(18).text(`Jahresbericht Stand ${report.apiary.name}`, {
underline: true,
});
doc.fontSize(12).text(`Jahr: ${report.year}`);
doc.moveDown();
doc.fontSize(14).text(`Voelker (${report.colonies.length})`);
doc.fontSize(12);
if (report.colonies.length === 0) {
doc.text('Keine Voelker an diesem Stand.');
}
for (const colony of report.colonies) {
doc.text(
`- ${colony.code}: ${colony.inspectionCount} Durchsicht(en), ` +
`${colony.treatmentCount} Behandlung(en), ${colony.harvestCount} Ernte(n)`,
);
}
doc.moveDown();
const totalNetWeight = report.harvests.reduce(
(sum, harvest) => sum + (harvest.netWeight ?? 0),
0,
);
doc
.fontSize(14)
.text(
`Ernten (${report.harvests.length}), gesamt ${totalNetWeight.toFixed(1)} kg`,
);
doc.fontSize(12);
if (report.harvests.length === 0) {
doc.text('Keine Ernten im Berichtsjahr.');
}
for (const entry of report.harvests) {
doc.text(
`- ${formatDate(entry.date)}: ${entry.honeyType ?? '-'}, ` +
`${entry.netWeight ?? '-'} kg netto / ${entry.grossWeight ?? '-'} kg brutto`,
);
}
doc.end();
});
}
@@ -46,9 +46,17 @@ export class ExportController {
async reportPdf(
@Param('report') report: string,
@Query('honeyBatchId') honeyBatchId: string | undefined,
@Query('colonyId') colonyId: string | undefined,
@Query('apiaryId') apiaryId: string | undefined,
@Query('year') year: string | undefined,
@Res() res: Response,
): Promise<void> {
const pdf = await this.exportService.exportReportPdf(report, honeyBatchId);
const pdf = await this.exportService.exportReportPdf(report, {
honeyBatchId,
colonyId,
apiaryId,
year,
});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExportService } from './export.service';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
@@ -6,21 +6,42 @@ import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
describe('ExportService', () => {
let service: ExportService;
let prisma: {
colony: { findMany: jest.Mock };
colony: { findMany: jest.Mock; findUnique: jest.Mock };
honeyBatch: { findMany: jest.Mock };
apiary: { findMany: jest.Mock };
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
inspection: { findMany: jest.Mock; count: jest.Mock };
treatment: { findMany: jest.Mock; count: jest.Mock };
varroaMeasurement: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
harvestColony: { findMany: jest.Mock; count: jest.Mock };
};
let honeyBatchesService: { traceability: jest.Mock };
beforeEach(() => {
prisma = {
colony: { findMany: jest.fn() },
colony: { findMany: jest.fn(), findUnique: jest.fn() },
honeyBatch: { findMany: jest.fn() },
apiary: { findMany: jest.fn() },
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
queen: { findMany: jest.fn() },
task: { findMany: jest.fn() },
inspection: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
treatment: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
harvestColony: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
};
honeyBatchesService = { traceability: jest.fn() };
service = new ExportService(
@@ -85,7 +106,7 @@ describe('ExportService', () => {
it('rejects an unsupported PDF report', async () => {
await expect(
service.exportReportPdf('unknown', 'honey-batch-1'),
service.exportReportPdf('unknown', { honeyBatchId: 'honey-batch-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -115,7 +136,57 @@ describe('ExportService', () => {
],
});
const pdf = await service.exportReportPdf('honey-batch-traceability', 'h1');
const pdf = await service.exportReportPdf('honey-batch-traceability', {
honeyBatchId: 'h1',
});
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
it('rejects the colony annual report without a colonyId', async () => {
await expect(
service.exportReportPdf('colony-annual-report'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects the colony annual report when the colony does not exist', async () => {
prisma.colony.findUnique.mockResolvedValueOnce(null);
await expect(
service.exportReportPdf('colony-annual-report', { colonyId: 'missing' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('renders a PDF buffer for the colony annual report', async () => {
prisma.colony.findUnique.mockResolvedValueOnce({
id: 'c1',
code: 'V-1',
colonyType: 'PRODUCTION_COLONY',
status: 'ACTIVE',
apiary: { id: 'a1', name: 'Stand Ost' },
});
const pdf = await service.exportReportPdf('colony-annual-report', {
colonyId: 'c1',
year: '2026',
});
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
it('rejects the apiary annual report without an apiaryId', async () => {
await expect(
service.exportReportPdf('apiary-annual-report'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('renders a PDF buffer for the apiary annual report', async () => {
prisma.apiary.findUnique.mockResolvedValueOnce({
id: 'a1',
name: 'Stand Ost',
});
prisma.colony.findMany.mockResolvedValueOnce([{ id: 'c1', code: 'V-1' }]);
const pdf = await service.exportReportPdf('apiary-annual-report', {
apiaryId: 'a1',
});
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
});
+68 -8
View File
@@ -2,6 +2,12 @@ import { Injectable, BadRequestException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
import {
buildApiaryAnnualReport,
buildColonyAnnualReport,
renderApiaryAnnualReportPdf,
renderColonyAnnualReportPdf,
} from './annual-reports';
const JSON_ENTITIES = [
'apiaries',
@@ -12,9 +18,31 @@ const JSON_ENTITIES = [
] as const;
type JsonEntity = (typeof JSON_ENTITIES)[number];
const PDF_REPORTS = ['honey-batch-traceability'] as const;
const PDF_REPORTS = [
'honey-batch-traceability',
'colony-annual-report',
'apiary-annual-report',
] as const;
type PdfReport = (typeof PDF_REPORTS)[number];
export interface ExportReportOptions {
honeyBatchId?: string;
colonyId?: string;
apiaryId?: string;
year?: string;
}
function resolveYear(year: string | undefined): number {
if (!year) {
return new Date().getUTCFullYear();
}
const parsed = Number(year);
if (!Number.isInteger(parsed)) {
throw new BadRequestException('Query parameter year must be an integer');
}
return parsed;
}
function isJsonEntity(value: string): value is JsonEntity {
return (JSON_ENTITIES as readonly string[]).includes(value);
}
@@ -117,18 +145,50 @@ export class ExportService {
async exportReportPdf(
report: string,
honeyBatchId?: string,
options: ExportReportOptions = {},
): Promise<Buffer> {
if (!isPdfReport(report)) {
throw new BadRequestException(`Unsupported export report: ${report}`);
}
if (!honeyBatchId) {
throw new BadRequestException(
'Query parameter honeyBatchId is required for this report',
);
switch (report) {
case 'honey-batch-traceability': {
if (!options.honeyBatchId) {
throw new BadRequestException(
'Query parameter honeyBatchId is required for this report',
);
}
const trace = await this.honeyBatchesService.traceability(
options.honeyBatchId,
);
return this.renderTraceabilityPdf(trace);
}
case 'colony-annual-report': {
if (!options.colonyId) {
throw new BadRequestException(
'Query parameter colonyId is required for this report',
);
}
const colonyReport = await buildColonyAnnualReport(
this.prisma,
options.colonyId,
resolveYear(options.year),
);
return renderColonyAnnualReportPdf(colonyReport);
}
case 'apiary-annual-report': {
if (!options.apiaryId) {
throw new BadRequestException(
'Query parameter apiaryId is required for this report',
);
}
const apiaryReport = await buildApiaryAnnualReport(
this.prisma,
options.apiaryId,
resolveYear(options.year),
);
return renderApiaryAnnualReportPdf(apiaryReport);
}
}
const trace = await this.honeyBatchesService.traceability(honeyBatchId);
return this.renderTraceabilityPdf(trace);
}
private renderTraceabilityPdf(trace: TraceabilityReport): Promise<Buffer> {
+43
View File
@@ -131,4 +131,47 @@ describe('Export (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(400);
});
it('rejects the colony annual report without a colonyId', () => {
return request(app.getHttpServer())
.get('/api/v1/export/colony-annual-report.pdf')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(400);
});
it('rejects the colony annual report for an unknown colony', () => {
return request(app.getHttpServer())
.get('/api/v1/export/colony-annual-report.pdf?colonyId=does-not-exist')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(404);
});
it('renders a colony annual report PDF', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/export/colony-annual-report.pdf?colonyId=${colonyId}`)
.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')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(400);
});
it('renders an apiary annual report PDF', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/export/apiary-annual-report.pdf?apiaryId=${apiaryId}`)
.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',
);
});
});