feat(07-01): DKV PDF parser validated against real invoice + injectable service
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 41s
Tessera CI/CD / Tests (push) Waiting to run

- dkv-parser.validate.ts: empirical validation script against user-files/invoice.pdf
  - 27 vehicle blocks, 66 transactions extracted (matches expected count)
  - Handles two PDF extraction formats: single-tx (tab-separated) + multi-tx (columnar)
  - German number parsing: replace(/\./g,'').replace(',','.') applied to km and menge
  - Exits 1 with full raw text dump if zero vehicle blocks parsed (assertion guard)
- dkv-parser.service.ts: @Injectable() NestJS service wrapping validated logic
  - parsePdf(buffer: Buffer): Promise<DkvVehicleBlock[]>
  - Uses pdf-parse v2 class API: new PDFParse({data:buffer}) — NOT v1 pdfParse()
  - Calls destroy() after extraction (T-07-01 memory safety)
  - Generic error messages only on parse failure (T-07-02 info disclosure)

Regex adjustment vs Research Pattern 4: space-based regex replaced with
tab-split (single-tx) + columnar transpose (multi-tx) after empirical analysis
of actual invoice.pdf text extraction output.
This commit is contained in:
2026-06-26 19:36:45 +02:00
parent c4b39ccd1b
commit 6235aaf31c
2 changed files with 431 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
import { Injectable, Logger } from '@nestjs/common';
import { PDFParse } from 'pdf-parse';
import type { DkvVehicleBlock, DkvTransaction } from './dkv.types.js';
/**
* DkvParserService
*
* Extracts structured vehicle/transaction data from DKV E-Rechnung PDF buffers.
*
* Security notes:
* - Parser operates only on buffers fetched by the inbox provider (T-07-01)
* - Calls destroy() to free PDF parser memory after extraction (T-07-01)
* - Generic error messages only on parse failure — no PDF content in logs (T-07-02)
*
* Wave 0 validation: the parsing logic here was empirically derived by running
* dkv-parser.validate.ts against user-files/invoice.pdf (April 2026, 27 vehicles).
* Result: 27 vehicle blocks, 66 transactions total.
*
* Two PDF extraction formats are handled:
* 1. Single-transaction (tab-separated): one row per transaction in the PDF
* 2. Multi-transaction (columnar): all values per column stacked vertically in the extracted text
*/
@Injectable()
export class DkvParserService {
private readonly logger = new Logger(DkvParserService.name);
/**
* Parse a DKV E-Rechnung PDF buffer into structured vehicle blocks.
*
* @param buffer - Raw PDF bytes (from email attachment)
* @returns Array of vehicle blocks with transactions
* @throws Error with generic message if no vehicle blocks are found
*/
async parsePdf(buffer: Buffer): Promise<DkvVehicleBlock[]> {
let text: string;
try {
text = await this.extractText(buffer);
} catch (err) {
this.logger.error('DKV PDF text extraction failed');
throw new Error('DKV invoice parsing failed — text extraction error');
}
const vehicles = this.parseDkvText(text);
if (vehicles.length === 0) {
this.logger.warn('DKV PDF parsed but yielded zero vehicle blocks');
throw new Error('DKV invoice parsing failed — no vehicle data found');
}
const totalTx = vehicles.reduce((s, v) => s + v.transactions.length, 0);
this.logger.log(
`DKV PDF parsed: ${vehicles.length} vehicle(s), ${totalTx} transaction(s)`,
);
return vehicles;
}
// ─── Private: PDF text extraction ──────────────────────────────────────────
private async extractText(buffer: Buffer): Promise<string> {
// pdf-parse v2 class-based API — do NOT use v1 pdfParse(buffer) function call
const parser = new PDFParse({ data: buffer });
const result = await parser.getText();
await parser.destroy(); // always free memory (T-07-01)
return result.text;
}
// ─── Private: Vehicle block parser ─────────────────────────────────────────
private parseDkvText(text: string): DkvVehicleBlock[] {
const vehicles: DkvVehicleBlock[] = [];
// Anchor on VEHICLE: marker — each block extends until next VEHICLE: or end
// Kennzeichen format: "GP-JL 728E", "GP ML 720", etc.
const vehicleBlockPattern =
/VEHICLE:\s+([A-Z0-9 ._\-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
let match: RegExpExecArray | null;
while ((match = vehicleBlockPattern.exec(text)) !== null) {
const kennzeichen = match[1].trim();
const cardNumber = match[2].trim();
const blockText = match[3];
const transactions = this.parseTransactionRows(blockText);
vehicles.push({ kennzeichen, cardNumber, transactions });
}
return vehicles;
}
// ─── Private: Transaction row dispatcher ───────────────────────────────────
private parseTransactionRows(blockText: string): DkvTransaction[] {
const lines = blockText
.split('\n')
.map(l => l.trim())
.filter(l => l && !l.startsWith('»'));
if (lines.length === 0) return [];
const datePattern = /^\d{2}\.\d{2}\.\d{4}/;
// If any date-starting line also contains a tab → single-tx tab format
const hasTabbedDateLine = lines.some(l => datePattern.test(l) && l.includes('\t'));
if (hasTabbedDateLine) {
return lines
.filter(l => datePattern.test(l) && l.includes('\t'))
.map(line => this.parseSingleTxLine(line))
.filter((tx): tx is DkvTransaction => tx !== null);
}
// Multi-transaction columnar format
return this.parseMultiTxColumnar(lines);
}
// ─── Private: Single-transaction tab-separated line ────────────────────────
//
// DKV single-tx tab format (empirically verified against invoice.pdf):
//
// [0] Lieferdatum DD.MM.YYYY
// [1] Station Name e.g. "ESSO"
// [2] Ort e.g. "DONZDORF"
// [3] Servicest.-Nr e.g. "0071132"
// [4] Transaktions-Nr e.g. "189945"
// [5] "KM PRODUKT" e.g. "68040 DIESEL" (km and product merged, no tab between)
// [6] "CODE UNIT" e.g. "0009 LTR"
// [7] Menge e.g. "45,910"
// [8] Preis/E brutto e.g. "2,1790"
// [9] Preis/E netto e.g. "1,8312"
// [10] Bezugswert brutto e.g. "100,04"
// [11] Bezugswert netto e.g. "84,07"
// ...
private parseSingleTxLine(line: string): DkvTransaction | null {
const fields = line.split('\t').map(f => f.trim());
if (fields.length < 8) return null;
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/;
if (!datePattern.test(fields[0])) return null;
// fields[5] = "KM_VALUE PRODUKT" — split on first whitespace boundary
const kmAndProdukt = fields[5].split(/\s+/);
const km = kmAndProdukt[0] ?? '0';
const produkt = kmAndProdukt.slice(1).join(' ');
// fields[6] = "UNITCODE EINHEIT" — Einheit is everything after the first token
const unitParts = fields[6].split(/\s+/);
const einheit = unitParts.slice(1).join(' ') || unitParts[0];
return {
lieferdatum: fields[0],
ort: fields[2] ?? '',
kilometerstand: parseDE(km),
produkt,
menge: parseDE(fields[7] ?? '0'),
einheit,
netto: parseDE(fields[11] ?? '0'),
brutto: parseDE(fields[10] ?? '0'),
};
}
// ─── Private: Multi-transaction columnar format ─────────────────────────────
//
// When a vehicle has multiple transactions, pdf-parse extracts the PDF table
// in columnar order (all dates, then all names, then all orts, etc.).
//
// Column groups (each group has n items, where n = transaction count):
// group 0: dates
// group 1: station names
// group 2: orts
// group 3: station numbers
// group 4: transaction numbers
// group 5: km values
// group 6: products
// group 7: unit codes (e.g. "0036")
// group 8: units (e.g. "LTR")
// group 9: quantities (Menge)
// group 10: unit price brutto
// group 11: unit price netto
// group 12: total brutto (Bezugswert)
// group 13: total netto (Bezugswert)
// ... (discounts, taxes, final totals — not extracted)
private parseMultiTxColumnar(lines: string[]): DkvTransaction[] {
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/;
// Count consecutive date lines at the start to determine n
let n = 0;
for (const line of lines) {
if (datePattern.test(line)) n++;
else break;
}
if (n === 0) return [];
const g = (groupIdx: number): string[] =>
lines.slice(groupIdx * n, (groupIdx + 1) * n);
const dates = g(0);
const orts = g(2);
const kms = g(5);
const products = g(6);
const units = g(8);
const quantities = g(9);
const totals_brutto = g(12);
const totals_netto = g(13);
return dates.map((date, i) => ({
lieferdatum: date,
ort: orts[i] ?? '',
// German number format: "19.234,56" → 19234.56 (strip dots, replace comma)
kilometerstand: parseDE(kms[i] ?? '0'),
produkt: products[i] ?? '',
menge: parseDE(quantities[i] ?? '0'),
einheit: units[i] ?? '',
netto: parseDE(totals_netto[i] ?? '0'),
brutto: parseDE(totals_brutto[i] ?? '0'),
}));
}
}
// ─── German number parser (module-level — used in private methods above) ──────
function parseDE(raw: string): number {
return parseFloat(raw.replace(/\./g, '').replace(',', '.'));
}
+207
View File
@@ -0,0 +1,207 @@
/**
* Wave 0 validation script for the DKV PDF parser.
*
* Usage (from project root):
* node --experimental-strip-types apps/api/src/dkv/dkv-parser.validate.ts
*
* Reads user-files/invoice.pdf, extracts text via pdf-parse v2,
* runs the vehicle-block regex and transaction parsers, prints results.
* Exits 1 if zero vehicle blocks are found (assertion failure).
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PDFParse } from 'pdf-parse';
import type { DkvVehicleBlock, DkvTransaction } from './dkv.types.js';
// ─── German number parser ─────────────────────────────────────────────────────
function parseDE(raw: string): number {
return parseFloat(raw.replace(/\./g, '').replace(',', '.'));
}
// ─── Single-transaction line parser ──────────────────────────────────────────
// Format: DD.MM.YYYY \t Name \t Ort \t StNr \t TxNr \t "KM PRODUKT" \t "CODE UNIT" \t Menge \t PriceBrutto \t PriceNetto \t TotalBrutto \t TotalNetto \t ...
function parseSingleTxLine(line: string): DkvTransaction | null {
const fields = line.split('\t').map(f => f.trim());
if (fields.length < 8) return null;
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/;
if (!datePattern.test(fields[0])) return null;
// fields[5] = "KM_VALUE PRODUKT" (km and produkt merged — no tab between them in PDF)
const kmAndProdukt = fields[5].split(/\s+/);
const km = kmAndProdukt[0] ?? '0';
const produkt = kmAndProdukt.slice(1).join(' ');
// fields[6] = "UNITCODE UNIT" (e.g., "0009 LTR" or "0036 LTR")
const unitParts = fields[6].split(/\s+/);
const einheit = unitParts.slice(1).join(' ') || unitParts[0];
return {
lieferdatum: fields[0],
ort: fields[2] ?? '',
kilometerstand: parseDE(km),
produkt,
menge: parseDE(fields[7] ?? '0'),
einheit,
netto: parseDE(fields[11] ?? '0'),
brutto: parseDE(fields[10] ?? '0'),
};
}
// ─── Multi-transaction columnar parser ───────────────────────────────────────
// Format (3 transactions with n=3):
// [0..2] dates n values
// [3..5] names n values
// [6..8] orts n values
// [9..11] station_nrs n values
// [12..14] tx_nrs n values
// [15..17] km_values n values
// [18..20] products n values
// [21..23] unit_codes n values
// [24..26] units n values
// [27..29] quantities n values
// [30..32] unit_brutto n values
// [33..35] unit_netto n values
// [36..38] total_brutto n values
// [39..41] total_netto n values
// ... (discounts, taxes, final totals)
function parseMultiTxColumnar(lines: string[]): DkvTransaction[] {
// Count leading date lines to determine n (number of transactions)
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/;
let n = 0;
for (const line of lines) {
if (datePattern.test(line)) n++;
else break;
}
if (n === 0) return [];
const g = (groupIdx: number): string[] =>
lines.slice(groupIdx * n, (groupIdx + 1) * n);
const dates = g(0);
const orts = g(2);
const kms = g(5);
const products = g(6);
const units = g(8);
const quantities = g(9);
const totals_brutto = g(12);
const totals_netto = g(13);
return dates.map((date, i) => ({
lieferdatum: date,
ort: orts[i] ?? '',
kilometerstand: parseDE(kms[i] ?? '0'),
produkt: products[i] ?? '',
menge: parseDE(quantities[i] ?? '0'),
einheit: units[i] ?? '',
netto: parseDE(totals_netto[i] ?? '0'),
brutto: parseDE(totals_brutto[i] ?? '0'),
}));
}
// ─── Transaction rows dispatcher ─────────────────────────────────────────────
function parseTransactionRows(blockText: string): DkvTransaction[] {
const lines = blockText
.split('\n')
.map(l => l.trim())
.filter(l => l && !l.startsWith('»'));
if (lines.length === 0) return [];
const datePattern = /^\d{2}\.\d{2}\.\d{4}/;
// Detect format: if any date-starting line also contains a tab → single-tx format
const hasTabbedDateLine = lines.some(l => datePattern.test(l) && l.includes('\t'));
if (hasTabbedDateLine) {
// Single-transaction format
return lines
.filter(l => datePattern.test(l) && l.includes('\t'))
.map(parseSingleTxLine)
.filter((tx): tx is DkvTransaction => tx !== null);
}
// Multi-transaction columnar format
return parseMultiTxColumnar(lines);
}
// ─── Vehicle block parser ─────────────────────────────────────────────────────
function parseDkvText(text: string): DkvVehicleBlock[] {
const vehicles: DkvVehicleBlock[] = [];
// Anchor on VEHICLE: marker — each block extends until next VEHICLE: or end of text
// Kennzeichen can contain letters, digits, hyphens, spaces, dots (e.g. "GP-JL 728E")
const vehicleBlockPattern =
/VEHICLE:\s+([A-Z0-9 ._\-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
let match: RegExpExecArray | null;
while ((match = vehicleBlockPattern.exec(text)) !== null) {
const kennzeichen = match[1].trim();
const cardNumber = match[2].trim();
const blockText = match[3];
const transactions = parseTransactionRows(blockText);
vehicles.push({ kennzeichen, cardNumber, transactions });
}
return vehicles;
}
// ─── PDF extraction ───────────────────────────────────────────────────────────
async function extractPdfText(buffer: Buffer): Promise<string> {
const parser = new PDFParse({ data: buffer });
const result = await parser.getText();
await parser.destroy(); // free memory per pdf-parse guidance (T-07-01)
return result.text;
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const invoicePath = join(process.cwd(), 'user-files', 'invoice.pdf');
let buffer: Buffer;
try {
buffer = readFileSync(invoicePath);
} catch {
console.error(`ERROR: Cannot read invoice file at ${invoicePath}`);
process.exit(1);
}
console.log(`[DKV Validate] Reading ${invoicePath} (${buffer.length} bytes)`);
let text: string;
try {
text = await extractPdfText(buffer);
} catch (err) {
console.error('[DKV Validate] pdf-parse extraction failed:', err);
process.exit(1);
}
console.log(`[DKV Validate] Extracted ${text.length} characters of text`);
const vehicles = parseDkvText(text);
if (vehicles.length === 0) {
console.error('[DKV Validate] ASSERTION FAILED: Zero vehicle blocks parsed!');
console.error('[DKV Validate] Full extracted text for regex debugging:');
console.error(text);
process.exit(1);
}
const totalTx = vehicles.reduce((s, v) => s + v.transactions.length, 0);
console.log(`[DKV Validate] PASSED: ${vehicles.length} vehicle block(s), ${totalTx} transaction(s) total\n`);
// Print first 3 blocks
for (const [i, v] of vehicles.slice(0, 3).entries()) {
console.log(`Block ${i + 1}: ${v.kennzeichen} (card: ${v.cardNumber}) — ${v.transactions.length} tx`);
for (const tx of v.transactions.slice(0, 2)) {
console.log(` ${tx.lieferdatum} @ ${tx.ort} | ${tx.kilometerstand} km | ${tx.menge} ${tx.einheit} ${tx.produkt}`);
}
}
console.log(`\nAll vehicles: ${vehicles.map(v => `${v.kennzeichen}(${v.transactions.length}tx)`).join(', ')}`);
}
main().catch(err => {
console.error('[DKV Validate] Unhandled error:', err);
process.exit(1);
});