feat(backend): Datenexport-Modul (Modul 21)

ExportModule mit GET /export/colonies.csv, /export/honey-batches.csv,
/export/:entity.json (apiaries/colonies/queens/honey-batches/tasks)
und /export/:report.pdf (honey-batch-traceability via pdfkit).
Ausserdem fehlendes "typecheck"-Skript in package.json ergaenzt
(von CLAUDE.md dokumentiert, war aber nicht vorhanden).
This commit is contained in:
Nick Szaunig committed 2026-08-22 10:05:46 +02:00
1 parent ab167d4e53
commit c281d04b16
9 files changed
+677 -4

No files matched your search

+2
View File
@@ -20,6 +20,7 @@ import { HoneyBatchesModule } from './honey-batches/honey-batches.module';
import { StorageModule } from './storage/storage.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { SearchModule } from './search/search.module';
import { ExportModule } from './export/export.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@@ -45,6 +46,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
StorageModule,
DashboardModule,
SearchModule,
ExportModule,
],
controllers: [],
providers: [
@@ -0,0 +1,59 @@
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { ExportService } from './export.service';
@ApiTags('export')
@ApiBearerAuth()
@Controller('export')
export class ExportController {
constructor(private readonly exportService: ExportService) {}
@Get('colonies.csv')
async colonies(@Res() res: Response): Promise<void> {
const csv = await this.exportService.exportColoniesCsv();
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="colonies.csv"');
res.send(csv);
}
@Get('honey-batches.csv')
async honeyBatches(@Res() res: Response): Promise<void> {
const csv = await this.exportService.exportHoneyBatchesCsv();
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader(
'Content-Disposition',
'attachment; filename="honey-batches.csv"',
);
res.send(csv);
}
@Get(':entity.json')
async entityJson(
@Param('entity') entity: string,
@Res() res: Response,
): Promise<void> {
const data = await this.exportService.exportEntityJson(entity);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader(
'Content-Disposition',
`attachment; filename="${entity}.json"`,
);
res.send(JSON.stringify(data, null, 2));
}
@Get(':report.pdf')
async reportPdf(
@Param('report') report: string,
@Query('honeyBatchId') honeyBatchId: string | undefined,
@Res() res: Response,
): Promise<void> {
const pdf = await this.exportService.exportReportPdf(report, honeyBatchId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${report}.pdf"`,
);
res.send(pdf);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { HoneyBatchesModule } from '../honey-batches/honey-batches.module';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [HoneyBatchesModule],
controllers: [ExportController],
providers: [ExportService],
})
export class ExportModule {}
@@ -0,0 +1,121 @@
import { BadRequestException } from '@nestjs/common';
import { ExportService } from './export.service';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
describe('ExportService', () => {
let service: ExportService;
let prisma: {
colony: { findMany: jest.Mock };
honeyBatch: { findMany: jest.Mock };
apiary: { findMany: jest.Mock };
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
let honeyBatchesService: { traceability: jest.Mock };
beforeEach(() => {
prisma = {
colony: { findMany: jest.fn() },
honeyBatch: { findMany: jest.fn() },
apiary: { findMany: jest.fn() },
queen: { findMany: jest.fn() },
task: { findMany: jest.fn() },
};
honeyBatchesService = { traceability: jest.fn() };
service = new ExportService(
prisma as unknown as PrismaService,
honeyBatchesService as unknown as HoneyBatchesService,
);
});
it('exports colonies as CSV with header and escaped fields', async () => {
prisma.colony.findMany.mockResolvedValueOnce([
{
id: 'c1',
code: 'V-1, Ost',
apiaryId: 'a1',
colonyType: 'PRODUCTION_COLONY',
status: 'ACTIVE',
strength: 8,
healthStatus: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
]);
const csv = await service.exportColoniesCsv();
const lines = csv.split('\n');
expect(lines[0]).toBe(
'id,code,apiaryId,colonyType,status,strength,healthStatus,createdAt',
);
expect(lines[1]).toContain('"V-1, Ost"');
});
it('exports honey batches as CSV', async () => {
prisma.honeyBatch.findMany.mockResolvedValueOnce([
{
id: 'h1',
batchNumber: 'HC-1',
honeyType: 'Blüte',
amount: 20,
waterContent: 17.2,
status: 'RAW',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
]);
const csv = await service.exportHoneyBatchesCsv();
expect(csv.split('\n')[0]).toBe(
'id,batchNumber,honeyType,amount,waterContent,status,createdAt',
);
});
it('exports a supported entity as JSON', async () => {
prisma.apiary.findMany.mockResolvedValueOnce([{ id: 'a1' }]);
const data = await service.exportEntityJson('apiaries');
expect(data).toEqual([{ id: 'a1' }]);
});
it('rejects an unsupported JSON entity', async () => {
await expect(service.exportEntityJson('unknown')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('rejects an unsupported PDF report', async () => {
await expect(
service.exportReportPdf('unknown', 'honey-batch-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects the traceability report without a honeyBatchId', async () => {
await expect(
service.exportReportPdf('honey-batch-traceability'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('renders a PDF buffer for the traceability report', async () => {
honeyBatchesService.traceability.mockResolvedValueOnce({
honeyBatch: {
id: 'h1',
batchNumber: 'HC-1',
honeyType: 'Blüte',
amount: 20,
status: 'RAW',
},
extraction: { id: 'e1', date: new Date('2026-01-01'), room: 'Raum 1' },
harvests: [
{
id: 'harvest-1',
date: new Date('2025-12-01'),
apiaryId: 'a1',
colonies: [{ id: 'c1', code: 'V-1' }],
},
],
});
const pdf = await service.exportReportPdf('honey-batch-traceability', 'h1');
expect(pdf.subarray(0, 4).toString('latin1')).toBe('%PDF');
});
});
@@ -0,0 +1,172 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import { HoneyBatchesService } from '../honey-batches/honey-batches.service';
const JSON_ENTITIES = [
'apiaries',
'colonies',
'queens',
'honey-batches',
'tasks',
] as const;
type JsonEntity = (typeof JSON_ENTITIES)[number];
const PDF_REPORTS = ['honey-batch-traceability'] as const;
type PdfReport = (typeof PDF_REPORTS)[number];
function isJsonEntity(value: string): value is JsonEntity {
return (JSON_ENTITIES as readonly string[]).includes(value);
}
function isPdfReport(value: string): value is PdfReport {
return (PDF_REPORTS as readonly string[]).includes(value);
}
function stringifyCsvValue(value: unknown): string {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
function toCsvField(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
const text = stringifyCsvValue(value);
if (/[",\n]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
function toCsv(rows: Record<string, unknown>[], columns: string[]): string {
const header = columns.join(',');
const lines = rows.map((row) =>
columns.map((column) => toCsvField(row[column])).join(','),
);
return [header, ...lines].join('\n');
}
type TraceabilityReport = Awaited<
ReturnType<HoneyBatchesService['traceability']>
>;
@Injectable()
export class ExportService {
constructor(
private readonly prisma: PrismaService,
private readonly honeyBatchesService: HoneyBatchesService,
) {}
async exportColoniesCsv(): Promise<string> {
const colonies = await this.prisma.colony.findMany({
orderBy: { code: 'asc' },
});
return toCsv(colonies, [
'id',
'code',
'apiaryId',
'colonyType',
'status',
'strength',
'healthStatus',
'createdAt',
]);
}
async exportHoneyBatchesCsv(): Promise<string> {
const honeyBatches = await this.prisma.honeyBatch.findMany({
orderBy: { batchNumber: 'asc' },
});
return toCsv(honeyBatches, [
'id',
'batchNumber',
'honeyType',
'amount',
'waterContent',
'status',
'createdAt',
]);
}
async exportEntityJson(entity: string): Promise<unknown> {
if (!isJsonEntity(entity)) {
throw new BadRequestException(`Unsupported export entity: ${entity}`);
}
switch (entity) {
case 'apiaries':
return this.prisma.apiary.findMany();
case 'colonies':
return this.prisma.colony.findMany();
case 'queens':
return this.prisma.queen.findMany();
case 'honey-batches':
return this.prisma.honeyBatch.findMany();
case 'tasks':
return this.prisma.task.findMany();
}
}
async exportReportPdf(
report: string,
honeyBatchId?: string,
): 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',
);
}
const trace = await this.honeyBatchesService.traceability(honeyBatchId);
return this.renderTraceabilityPdf(trace);
}
private renderTraceabilityPdf(trace: TraceabilityReport): 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('Rueckverfolgbarkeit Honigcharge', {
underline: true,
});
doc.moveDown();
doc.fontSize(12).text(`Charge: ${trace.honeyBatch.batchNumber}`);
doc.text(`Sorte: ${trace.honeyBatch.honeyType ?? '-'}`);
doc.text(`Menge: ${trace.honeyBatch.amount ?? '-'} kg`);
doc.text(`Status: ${trace.honeyBatch.status}`);
doc.moveDown();
doc.fontSize(14).text('Schleuderung');
doc
.fontSize(12)
.text(
`Datum: ${new Date(trace.extraction.date).toLocaleDateString('de-DE')}`,
);
doc.text(`Raum: ${trace.extraction.room ?? '-'}`);
doc.moveDown();
doc.fontSize(14).text('Ernten');
doc.fontSize(12);
for (const harvest of trace.harvests) {
const colonyCodes = harvest.colonies
.map((colony) => colony.code)
.join(', ');
doc.text(
`- ${new Date(harvest.date).toLocaleDateString('de-DE')}: ${colonyCodes}`,
);
}
doc.end();
});
}
}