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
+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(
|
async reportPdf(
|
||||||
@Param('report') report: string,
|
@Param('report') report: string,
|
||||||
@Query('honeyBatchId') honeyBatchId: string | undefined,
|
@Query('honeyBatchId') honeyBatchId: string | undefined,
|
||||||
|
@Query('colonyId') colonyId: string | undefined,
|
||||||
|
@Query('apiaryId') apiaryId: string | undefined,
|
||||||
|
@Query('year') year: string | undefined,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): 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-Type', 'application/pdf');
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Disposition',
|
'Content-Disposition',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { ExportService } from './export.service';
|
import { ExportService } from './export.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
|
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
|
||||||
@@ -6,21 +6,42 @@ import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
|
|||||||
describe('ExportService', () => {
|
describe('ExportService', () => {
|
||||||
let service: ExportService;
|
let service: ExportService;
|
||||||
let prisma: {
|
let prisma: {
|
||||||
colony: { findMany: jest.Mock };
|
colony: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||||
honeyBatch: { findMany: jest.Mock };
|
honeyBatch: { findMany: jest.Mock };
|
||||||
apiary: { findMany: jest.Mock };
|
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||||
queen: { findMany: jest.Mock };
|
queen: { findMany: jest.Mock };
|
||||||
task: { 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 };
|
let honeyBatchesService: { traceability: jest.Mock };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = {
|
prisma = {
|
||||||
colony: { findMany: jest.fn() },
|
colony: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
honeyBatch: { findMany: jest.fn() },
|
honeyBatch: { findMany: jest.fn() },
|
||||||
apiary: { findMany: jest.fn() },
|
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||||
queen: { findMany: jest.fn() },
|
queen: { findMany: jest.fn() },
|
||||||
task: { 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() };
|
honeyBatchesService = { traceability: jest.fn() };
|
||||||
service = new ExportService(
|
service = new ExportService(
|
||||||
@@ -85,7 +106,7 @@ describe('ExportService', () => {
|
|||||||
|
|
||||||
it('rejects an unsupported PDF report', async () => {
|
it('rejects an unsupported PDF report', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.exportReportPdf('unknown', 'honey-batch-1'),
|
service.exportReportPdf('unknown', { honeyBatchId: 'honey-batch-1' }),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).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');
|
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,12 @@ import { Injectable, BadRequestException } from '@nestjs/common';
|
|||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
|
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
|
||||||
|
import {
|
||||||
|
buildApiaryAnnualReport,
|
||||||
|
buildColonyAnnualReport,
|
||||||
|
renderApiaryAnnualReportPdf,
|
||||||
|
renderColonyAnnualReportPdf,
|
||||||
|
} from './annual-reports';
|
||||||
|
|
||||||
const JSON_ENTITIES = [
|
const JSON_ENTITIES = [
|
||||||
'apiaries',
|
'apiaries',
|
||||||
@@ -12,9 +18,31 @@ const JSON_ENTITIES = [
|
|||||||
] as const;
|
] as const;
|
||||||
type JsonEntity = (typeof JSON_ENTITIES)[number];
|
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];
|
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 {
|
function isJsonEntity(value: string): value is JsonEntity {
|
||||||
return (JSON_ENTITIES as readonly string[]).includes(value);
|
return (JSON_ENTITIES as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
@@ -117,18 +145,50 @@ export class ExportService {
|
|||||||
|
|
||||||
async exportReportPdf(
|
async exportReportPdf(
|
||||||
report: string,
|
report: string,
|
||||||
honeyBatchId?: string,
|
options: ExportReportOptions = {},
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
if (!isPdfReport(report)) {
|
if (!isPdfReport(report)) {
|
||||||
throw new BadRequestException(`Unsupported export report: ${report}`);
|
throw new BadRequestException(`Unsupported export report: ${report}`);
|
||||||
}
|
}
|
||||||
if (!honeyBatchId) {
|
switch (report) {
|
||||||
throw new BadRequestException(
|
case 'honey-batch-traceability': {
|
||||||
'Query parameter honeyBatchId is required for this report',
|
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> {
|
private renderTraceabilityPdf(trace: TraceabilityReport): Promise<Buffer> {
|
||||||
|
|||||||
@@ -131,4 +131,47 @@ describe('Export (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
.expect(400);
|
.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 Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
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 { fetchApiary } from "@/lib/apiaries/api";
|
||||||
|
import { exportApiaryAnnualReportPdf } from "@/lib/export/api";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ApiaryEditCard } from "./apiary-edit-card";
|
import { ApiaryEditCard } from "./apiary-edit-card";
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
|
||||||
export default function ApiaryDetailPage() {
|
export default function ApiaryDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const { data: apiary, isLoading, isError } = useQuery({
|
const { data: apiary, isLoading, isError } = useQuery({
|
||||||
@@ -16,6 +19,10 @@ export default function ApiaryDetailPage() {
|
|||||||
queryFn: () => fetchApiary(params.id),
|
queryFn: () => fetchApiary(params.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportReportMutation = useMutation({
|
||||||
|
mutationFn: () => exportApiaryAnnualReportPdf(params.id, CURRENT_YEAR),
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
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>
|
</div>
|
||||||
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||||
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
|
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
|
||||||
<Button
|
<div className="flex flex-wrap gap-2">
|
||||||
className="w-fit"
|
<Button
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
|
render={<Link href={`/harvests/new?apiaryId=${apiary.id}`} />}
|
||||||
>
|
>
|
||||||
Neue Ernte
|
Neue Ernte
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={exportReportMutation.isPending}
|
||||||
|
onClick={() => exportReportMutation.mutate()}
|
||||||
|
>
|
||||||
|
{exportReportMutation.isPending
|
||||||
|
? "Wird erstellt…"
|
||||||
|
: `Jahresbericht ${CURRENT_YEAR}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
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 { fetchColony } from "@/lib/colonies/api";
|
||||||
|
import { exportColonyAnnualReportPdf } from "@/lib/export/api";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { ColonyEditCard } from "./colony-edit-card";
|
import { ColonyEditCard } from "./colony-edit-card";
|
||||||
import { ColonyActions } from "./colony-actions";
|
import { ColonyActions } from "./colony-actions";
|
||||||
import { ColonyHistoryCard } from "./colony-history";
|
import { ColonyHistoryCard } from "./colony-history";
|
||||||
@@ -14,6 +16,8 @@ import { ColonySwarmControlCard } from "./colony-swarm-control";
|
|||||||
import { ColonyTreatmentsCard } from "./colony-treatments";
|
import { ColonyTreatmentsCard } from "./colony-treatments";
|
||||||
import { ColonyFeedingsCard } from "./colony-feedings";
|
import { ColonyFeedingsCard } from "./colony-feedings";
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear();
|
||||||
|
|
||||||
export default function ColonyDetailPage() {
|
export default function ColonyDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const { data: colony, isLoading, isError } = useQuery({
|
const { data: colony, isLoading, isError } = useQuery({
|
||||||
@@ -21,6 +25,10 @@ export default function ColonyDetailPage() {
|
|||||||
queryFn: () => fetchColony(params.id),
|
queryFn: () => fetchColony(params.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportReportMutation = useMutation({
|
||||||
|
mutationFn: () => exportColonyAnnualReportPdf(params.id, CURRENT_YEAR),
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <p className="p-4 text-sm text-muted-foreground sm:p-6">Lädt…</p>;
|
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 (
|
return (
|
||||||
<main className="flex flex-col gap-4 p-4 sm:p-6">
|
<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" />
|
<QrCodeDialog entityType="colonies" id={colony.id} label="QR-Code für Durchsicht" />
|
||||||
</div>
|
</div>
|
||||||
<ColonyEditCard key={colony.id} colony={colony} />
|
<ColonyEditCard key={colony.id} colony={colony} />
|
||||||
|
|||||||
@@ -31,3 +31,19 @@ export async function exportHoneyBatchTraceabilityPdf(honeyBatchId: string): Pro
|
|||||||
);
|
);
|
||||||
downloadBlob(blob, filename);
|
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