Files
tessera-ctl/apps/api/src/dkv/dkv-parser.validate.ts
T
schalli 6235aaf31c
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
feat(07-01): DKV PDF parser validated against real invoice + injectable service
- 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.
2026-06-26 19:36:45 +02:00

208 lines
7.9 KiB
TypeScript

/**
* 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);
});