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:
1 parent
ab167d4e53
commit
c281d04b16
9 files changed
+677
-4
No files matched your search
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user