diff --git a/apps/api/src/dkv/dkv-export.service.ts b/apps/api/src/dkv/dkv-export.service.ts index 73a26ac..53b5559 100644 --- a/apps/api/src/dkv/dkv-export.service.ts +++ b/apps/api/src/dkv/dkv-export.service.ts @@ -13,7 +13,7 @@ const MAX_EXPORT_FILES = 10; /** * Glob pattern for DKV export files — used for prune selection. */ -const DKV_FILE_PREFIX = 'DKV_'; +const DKV_FILE_PREFIX = 'RG-DKV-'; const DKV_FILE_SUFFIX = '.xlsx'; /** @@ -128,7 +128,7 @@ export class DkvExportService { * @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 { + writeAndPrune(buffer: Buffer, baseName: string): string { // Ensure the directory exists if (!fs.existsSync(this.userFilesDir)) { fs.mkdirSync(this.userFilesDir, { recursive: true }); @@ -136,7 +136,7 @@ export class DkvExportService { } // Build filename server-side — NEVER from request input (T-07-09 path-traversal mitigation) - const filename = `DKV_${invoiceMonth}_${rechnungsnummer}.xlsx`; + const filename = `${baseName}.xlsx`; const filePath = path.join(this.userFilesDir, filename); // Write the file diff --git a/apps/api/src/dkv/dkv-parser.service.ts b/apps/api/src/dkv/dkv-parser.service.ts index 012ea78..6d1f67e 100644 --- a/apps/api/src/dkv/dkv-parser.service.ts +++ b/apps/api/src/dkv/dkv-parser.service.ts @@ -31,7 +31,11 @@ export class DkvParserService { * @returns Array of vehicle blocks with transactions * @throws Error with generic message if no vehicle blocks are found */ - async parsePdf(buffer: Buffer): Promise { + async parsePdf(buffer: Buffer): Promise<{ + vehicles: DkvVehicleBlock[]; + rechnungsnummer: string | null; + rechnungsdatum: string | null; + }> { let text: string; try { text = await this.extractText(buffer); @@ -52,7 +56,30 @@ export class DkvParserService { `DKV PDF parsed: ${vehicles.length} vehicle(s), ${totalTx} transaction(s)`, ); - return vehicles; + return { + vehicles, + rechnungsnummer: this.extractRechnungsnummer(text), + rechnungsdatum: this.extractRechnungsdatum(text), + }; + } + + /** Extract first DKV invoice number (DD/DDDDDDDDD/DDD) from PDF text. */ + private extractRechnungsnummer(text: string): string | null { + const m = text.match(/\b(\d{2}\/\d{9}\/\d{3})\b/); + return m ? m[1] : null; + } + + /** Extract invoice date ("DD.MM.YYYY") appearing near "Rechnungsdatum" in PDF text. */ + private extractRechnungsdatum(text: string): string | null { + // "Rechnungsdatum" is followed (within ~200 chars) by a date string + const m = text.match(/Rechnungsdatum[:\s\t]*(\d{2}\.\d{2}\.\d{4})/); + if (m) return m[1]; + // Fallback: second line after "Rechnungsdatum:" marker + const idx = text.indexOf('Rechnungsdatum'); + if (idx === -1) return null; + const after = text.slice(idx, idx + 200); + const dm = after.match(/(\d{2}\.\d{2}\.\d{4})/); + return dm ? dm[1] : null; } // ─── Private: PDF text extraction ────────────────────────────────────────── diff --git a/apps/api/src/dkv/dkv.service.ts b/apps/api/src/dkv/dkv.service.ts index 4ac77f5..302b1a3 100644 --- a/apps/api/src/dkv/dkv.service.ts +++ b/apps/api/src/dkv/dkv.service.ts @@ -342,12 +342,12 @@ export class DkvService { vehicleFormatString: string, ): Promise { // D-10: Up to 3 parse retries - let vehicles: DkvVehicleBlock[] | null = null; + let parseResult: Awaited> | null = null; let parseError: string | null = null; for (let attempt = 1; attempt <= 3; attempt++) { try { - vehicles = await this.parser.parsePdf(pdfBuffer); + parseResult = await this.parser.parsePdf(pdfBuffer); parseError = null; break; } catch (err) { @@ -358,12 +358,12 @@ export class DkvService { } } - if (!vehicles) { + if (!parseResult) { // Record parse failure in history (D-10) await this.prisma.dkvInvoiceHistory.create({ data: { tenantId, - rechnungsnummer: this._extractInvoiceNumber(email.subject, email.uid), + rechnungsnummer: this._buildRechnungsnummer(null, email.subject, email.uid), anzahlFahrzeuge: 0, anzahlTransaktionen: 0, status: 'Fehler', @@ -373,9 +373,13 @@ export class DkvService { return; } - // Extract invoice metadata for filename + history record - const rechnungsnummer = this._extractInvoiceNumber(email.subject, email.uid); - const invoiceMonth = this._extractInvoiceMonth(vehicles); + const { vehicles, rechnungsnummer: rgNr, rechnungsdatum: rgDat } = parseResult; + + // Build filename: RG-DKV-{nr}-{date}.xlsx + const rechnungsnummer = this._buildRechnungsnummer(rgNr, email.subject, email.uid); + const datePart = rgDat ? _formatDateYYMMDD(rgDat) : this._extractInvoiceMonth(vehicles).replace('-', ''); + const exportBaseName = `RG-DKV-${rechnungsnummer}-${datePart}`; + const anzahlTransaktionen = vehicles.reduce((s, v) => s + v.transactions.length, 0); // Build export rows — look up drivers from vehicle master @@ -383,7 +387,7 @@ export class DkvService { // Generate xlsx buffer and write to user-files/ (DkvExportService) const xlsxBuffer = this.exporter.buildExcelBuffer(exportRows); - const exportFilename = this.exporter.writeAndPrune(xlsxBuffer, rechnungsnummer, invoiceMonth); + const exportFilename = this.exporter.writeAndPrune(xlsxBuffer, exportBaseName); // D-16: SMTP send with 3-retry exponential backoff let smtpStatus: 'Verarbeitet' | 'Versand fehlgeschlagen' = 'Verarbeitet'; @@ -552,7 +556,7 @@ export class DkvService { filename.includes('/') || filename.includes('\\') || filename.includes('..') || - !/^DKV_[\w\-]+\.xlsx$/.test(filename) + !/^(RG-DKV-|DKV_)[\w\-]+\.xlsx$/.test(filename) ) { throw new BadRequestException('Invalid export filename'); } @@ -624,17 +628,19 @@ export class DkvService { } /** - * Extract a DKV invoice number from the email subject line. - * Pattern: DD-DDDDDDDDD-DDD (e.g. "26-651566449-001") - * Falls back to "email-{uid}" when no match is found. + * Build a safe rechnungsnummer string for use in filenames. + * Priority: PDF-extracted number → email subject → uid fallback. + * All slashes and non-safe chars are replaced so the value is path-safe. */ - private _extractInvoiceNumber(subject: string, uid: number | string): string { - const match = subject?.match(/(\d{2}-\d{9}-\d{3})/); - if (match?.[1]) return match[1]; - // Fallback when invoice number cannot be parsed from subject. - // Exchange UniqueIds are base64 and can contain '+', '/', '=' which would - // introduce path separators into the generated filename (WR-04). - // Sanitise to [a-zA-Z0-9-] before the value reaches the filesystem. + private _buildRechnungsnummer( + fromPdf: string | null, + subject: string, + uid: number | string, + ): string { + if (fromPdf) return fromPdf.replace(/\//g, '-'); + // DKV email subjects carry the invoice number with slashes: "26/650869002/002" + const m = subject?.match(/(\d{2}[\/\-]\d{9}[\/\-]\d{3})/); + if (m?.[1]) return m[1].replace(/\//g, '-'); return `email-${String(uid).replace(/[^a-zA-Z0-9\-]/g, '_')}`; } @@ -694,6 +700,13 @@ function _delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** Format "DD.MM.YYYY" → "YYMMDD" for compact filename date segment. */ +function _formatDateYYMMDD(date: string): string { + const [d, m, y] = date.split('.'); + if (!d || !m || !y) return date.replace(/\./g, ''); + return `${y.slice(2)}${m}${d}`; +} + /** * Normalize a Kennzeichen for fuzzy vehicle lookup. * DKV PDF extraction may omit hyphens or use spaces as separators.