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:
1 parent
f1298eff08
commit
3f7c4ed404
8 files changed
+620
-16
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');
|
||||
});
|
||||
});
|
||||
@@ -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,19 +145,51 @@ 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) {
|
||||
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(honeyBatchId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderTraceabilityPdf(trace: TraceabilityReport): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { fetchApiary } from "@/lib/apiaries/api";
|
||||
import { exportApiaryAnnualReportPdf } from "@/lib/export/api";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ApiaryEditCard } from "./apiary-edit-card";
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
export default function ApiaryDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { data: apiary, isLoading, isError } = useQuery({
|
||||
@@ -16,6 +19,10 @@ export default function ApiaryDetailPage() {
|
||||
queryFn: () => fetchApiary(params.id),
|
||||
});
|
||||
|
||||
const exportReportMutation = useMutation({
|
||||
mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
||||
}
|
||||
@@ -34,13 +41,23 @@ export default function ApiaryDetailPage() {
|
||||
</div>
|
||||
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="w-fit"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
|
||||
>
|
||||
Neue Ernte
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={exportReportMutation.isPending}
|
||||
onClick={() => exportReportMutation.mutate()}
|
||||
>
|
||||
{exportReportMutation.isPending
|
||||
? "Wird erstellt…"
|
||||
: `Jahresbericht ${CURRENT_YEAR}`}
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { exportColonyAnnualReportPdf } from "@/lib/export/api";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ColonyEditCard } from "./colony-edit-card";
|
||||
import { ColonyActions } from "./colony-actions";
|
||||
import { ColonyHistoryCard } from "./colony-history";
|
||||
@@ -14,6 +16,8 @@ import { ColonySwarmControlCard } from "./colony-swarm-control";
|
||||
import { ColonyTreatmentsCard } from "./colony-treatments";
|
||||
import { ColonyFeedingsCard } from "./colony-feedings";
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
|
||||
export default function ColonyDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { data: colony, isLoading, isError } = useQuery({
|
||||
@@ -21,6 +25,10 @@ export default function ColonyDetailPage() {
|
||||
queryFn: () => fetchColony(params.id),
|
||||
});
|
||||
|
||||
const exportReportMutation = useMutation({
|
||||
mutationFn: () => exportColonyAnnualReportPdf(params.id, CURRENT_YEAR),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
||||
}
|
||||
@@ -34,7 +42,16 @@ export default function ColonyDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={exportReportMutation.isPending}
|
||||
onClick={() => exportReportMutation.mutate()}
|
||||
>
|
||||
{exportReportMutation.isPending
|
||||
? "Wird erstellt…"
|
||||
: `Jahresbericht ${CURRENT_YEAR}`}
|
||||
</Button>
|
||||
<QrCodeDialog entityType="colonies" id={colony.id} label="QR-Code für Durchsicht" />
|
||||
</div>
|
||||
<ColonyEditCard key={colony.id} colony={colony} />
|
||||
|
||||
@@ -31,3 +31,19 @@ export async function exportHoneyBatchTraceabilityPdf(honeyBatchId: string): Pro
|
||||
);
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
|
||||
export async function exportColonyAnnualReportPdf(colonyId: string, year: number): Promise<void> {
|
||||
const { blob, filename } = await apiFetchBlob(
|
||||
`/export/colony-annual-report.pdf?colonyId=${colonyId}&year=${year}`,
|
||||
`jahresbericht-volk-${year}.pdf`,
|
||||
);
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
|
||||
export async function exportApiaryAnnualReportPdf(apiaryId: string, year: number): Promise<void> {
|
||||
const { blob, filename } = await apiFetchBlob(
|
||||
`/export/apiary-annual-report.pdf?apiaryId=${apiaryId}&year=${year}`,
|
||||
`jahresbericht-stand-${year}.pdf`,
|
||||
);
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
Reference in new issue
Block a user