feat(07-03): DkvExportService — xlsx generation + user-files/ prune (DKV-04)
- buildExcelBuffer: 5-column xlsx per D-13 (Lieferdatum as string, never Date), SheetJS aoa_to_sheet
- resolveFahrzeug: replaces {Marke}/{Modell}/{Kennzeichen}/{Fahrer} tokens in format string (D-19)
- writeAndPrune: server-side filename DKV_YYYY-MM_<nr>.xlsx (T-07-09 path-traversal prevention),
writes to user-files/ (resolved from monorepo root, not request input), prunes to last 10 DKV_*.xlsx
files sorted by mtime ascending (D-15, Pitfall 7 atomicity)
This commit is contained in:
@@ -0,0 +1,187 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { ExportRow } from './dkv.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of export files to keep in user-files/ (D-15).
|
||||||
|
* Prune logic deletes oldest files when count exceeds this limit.
|
||||||
|
*/
|
||||||
|
const MAX_EXPORT_FILES = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glob pattern for DKV export files — used for prune selection.
|
||||||
|
*/
|
||||||
|
const DKV_FILE_PREFIX = 'DKV_';
|
||||||
|
const DKV_FILE_SUFFIX = '.xlsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vehicle master data shape — used for resolveFahrzeug.
|
||||||
|
* Mirrors DkvVehicleMaster Prisma model fields relevant to display formatting.
|
||||||
|
*/
|
||||||
|
export interface DkvVehicleMaster {
|
||||||
|
kennzeichen: string;
|
||||||
|
marke: string;
|
||||||
|
modell: string;
|
||||||
|
fahrer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DkvExportService — Excel file generation and user-files/ management.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Build xlsx Buffer from ExportRow[] with the exact 5-column contract (D-13)
|
||||||
|
* - Resolve the Fahrzeug format string (D-19)
|
||||||
|
* - Write export files to user-files/ with server-generated filenames (D-12)
|
||||||
|
* - Prune user-files/ to keep the last MAX_EXPORT_FILES DKV_*.xlsx files (D-15)
|
||||||
|
*
|
||||||
|
* Security: T-07-09 — export filename is generated server-side (never from request input)
|
||||||
|
* to prevent path-traversal attacks.
|
||||||
|
*
|
||||||
|
* Race condition: writeAndPrune is a single atomic method. The caller (DkvService/Plan 04)
|
||||||
|
* is responsible for serialising invoice processing via the processing-lock pattern (Pitfall 7).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DkvExportService {
|
||||||
|
private readonly logger = new Logger(DkvExportService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the user-files/ directory path relative to the monorepo root.
|
||||||
|
* Computed once and cached. Path is NEVER derived from user input (T-07-09).
|
||||||
|
*/
|
||||||
|
private readonly userFilesDir: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Resolve relative to the running process — apps/api/ is the cwd at runtime.
|
||||||
|
// Go up two levels to reach the monorepo root: apps/api/ -> apps/ -> root/.
|
||||||
|
// Then append user-files/.
|
||||||
|
this.userFilesDir = path.resolve(__dirname, '..', '..', '..', '..', 'user-files');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Fahrzeug column value from a vehicle record and a format string.
|
||||||
|
*
|
||||||
|
* Default format: "{Marke}/{Modell}/{Kennzeichen}" (D-19).
|
||||||
|
* Supported tokens: {Marke}, {Modell}, {Kennzeichen}, {Fahrer}.
|
||||||
|
*
|
||||||
|
* @param vehicle - Vehicle master data record
|
||||||
|
* @param formatString - Template string with placeholder tokens
|
||||||
|
* @returns Resolved string, e.g. "Mercedes/GLC 300 de 4MATIC/GP-JL 728E"
|
||||||
|
*/
|
||||||
|
resolveFahrzeug(vehicle: DkvVehicleMaster, formatString: string): string {
|
||||||
|
return formatString
|
||||||
|
.replace('{Marke}', vehicle.marke)
|
||||||
|
.replace('{Modell}', vehicle.modell)
|
||||||
|
.replace('{Kennzeichen}', vehicle.kennzeichen)
|
||||||
|
.replace('{Fahrer}', vehicle.fahrer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an xlsx Buffer from the given export rows.
|
||||||
|
*
|
||||||
|
* Column order is fixed per D-13:
|
||||||
|
* 1. Lieferdatum (string "DD.MM.YYYY" — written as-is, NOT as a JS Date)
|
||||||
|
* 2. Fahrzeug (string from format template)
|
||||||
|
* 3. Fahrer (string "Vorname Nachname")
|
||||||
|
* 4. Ort (string, service station city)
|
||||||
|
* 5. Kilometerstand (number — no unit)
|
||||||
|
*
|
||||||
|
* Research anti-pattern avoidance: Lieferdatum is written as the already-formatted
|
||||||
|
* German date string from ExportRow.lieferdatum — never wrapped in `new Date()`.
|
||||||
|
*
|
||||||
|
* @param rows - Export rows built by the orchestrator (Plan 04)
|
||||||
|
* @returns Buffer containing the xlsx file content
|
||||||
|
*/
|
||||||
|
buildExcelBuffer(rows: ExportRow[]): Buffer {
|
||||||
|
const headers = ['Lieferdatum', 'Fahrzeug', 'Fahrer', 'Ort', 'Kilometerstand'];
|
||||||
|
|
||||||
|
const data = rows.map((r) => [
|
||||||
|
r.lieferdatum, // string: "DD.MM.YYYY" — no Date conversion
|
||||||
|
r.fahrzeug, // string: resolved format
|
||||||
|
r.fahrer, // string: "Vorname Nachname"
|
||||||
|
r.ort, // string: service station city
|
||||||
|
r.kilometerstand, // number: numeric odometer reading
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([headers, ...data]);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'DKV Export');
|
||||||
|
|
||||||
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the export buffer to user-files/ and prune old exports.
|
||||||
|
*
|
||||||
|
* Filename format (D-12): DKV_YYYY-MM_<Rechnungsnummer>.xlsx
|
||||||
|
* Example: DKV_2026-04_26-651566449-001.xlsx
|
||||||
|
*
|
||||||
|
* The filename is computed entirely server-side from invoiceMonth and rechnungsnummer
|
||||||
|
* parameters that come from the parsed PDF — never from user-supplied request input (T-07-09).
|
||||||
|
*
|
||||||
|
* After writing, prune DKV_*.xlsx files by mtime (oldest first) to keep at most
|
||||||
|
* MAX_EXPORT_FILES (10) files (D-15).
|
||||||
|
*
|
||||||
|
* @param buffer - xlsx buffer from buildExcelBuffer()
|
||||||
|
* @param rechnungsnummer - Invoice number from PDF, e.g. "26-651566449-001"
|
||||||
|
* @param invoiceMonth - Month string "YYYY-MM", e.g. "2026-04"
|
||||||
|
* @returns The filename that was written (relative to user-files/)
|
||||||
|
*/
|
||||||
|
writeAndPrune(buffer: Buffer, rechnungsnummer: string, invoiceMonth: string): string {
|
||||||
|
// Ensure the directory exists
|
||||||
|
if (!fs.existsSync(this.userFilesDir)) {
|
||||||
|
fs.mkdirSync(this.userFilesDir, { recursive: true });
|
||||||
|
this.logger.log(`Created user-files directory at ${this.userFilesDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filename server-side — NEVER from request input (T-07-09 path-traversal mitigation)
|
||||||
|
const filename = `DKV_${invoiceMonth}_${rechnungsnummer}.xlsx`;
|
||||||
|
const filePath = path.join(this.userFilesDir, filename);
|
||||||
|
|
||||||
|
// Write the file
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
this.logger.log(`DKV export written: ${filename}`);
|
||||||
|
|
||||||
|
// Prune: keep only the last MAX_EXPORT_FILES files (D-15)
|
||||||
|
this.pruneExports();
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune DKV_*.xlsx files in user-files/ to keep the last MAX_EXPORT_FILES.
|
||||||
|
* Files are sorted by mtime ascending — oldest files are deleted first.
|
||||||
|
*
|
||||||
|
* This is called synchronously inside writeAndPrune to ensure atomicity (Pitfall 7).
|
||||||
|
* The caller's processing lock prevents interleaved write+prune operations.
|
||||||
|
*/
|
||||||
|
private pruneExports(): void {
|
||||||
|
try {
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(this.userFilesDir)
|
||||||
|
.filter(
|
||||||
|
(f) => f.startsWith(DKV_FILE_PREFIX) && f.endsWith(DKV_FILE_SUFFIX),
|
||||||
|
)
|
||||||
|
.map((f) => {
|
||||||
|
const fullPath = path.join(this.userFilesDir, f);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
return { name: f, path: fullPath, mtime: stat.mtime.getTime() };
|
||||||
|
})
|
||||||
|
// Sort ascending by mtime (oldest first)
|
||||||
|
.sort((a, b) => a.mtime - b.mtime);
|
||||||
|
|
||||||
|
if (entries.length > MAX_EXPORT_FILES) {
|
||||||
|
const toDelete = entries.slice(0, entries.length - MAX_EXPORT_FILES);
|
||||||
|
for (const file of toDelete) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
this.logger.log(`Pruned old DKV export: ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to prune DKV exports: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user