fix(dkv): handle EV charging rows and service rows in single-tx tab parser
Tessera CI/CD / Lint & Type Check (push) Successful in 40s
Tessera CI/CD / Tests (push) Successful in 37s
Tessera CI/CD / Build & Publish Images (push) Successful in 21s

Analyzed Invoice-4302486921-26_650869002_000.pdf text structure. Three cases:

1. FUEL (fields[4] = numeric tx-nr): km+product merged in fields[5], unit in
   fields[6]. Already working; no change.

2. EV CHARGING (fields contains "DDDD KWH" or "DDDD MIN" unit): column layout
   shifts — no km field, station+ort sometimes merged in fields[1]. Detected by
   regex on unit field; kwhIdx drives relative offset for menge/netto/brutto.
   Ort extracted from fields[2] (kwhIdx>=5) or fields[1] (kwhIdx=4, compact).
   Kilometerstand = 0 (EV chargers don't record odometer).

3. SERVICE ROWS (e.g. "DKV Analytics Premiu"): appear inside a VEHICLE: block but
   fields[4] is non-numeric (product description, not a transaction number). These
   were being parsed as fake vehicle transactions producing wrong ort/km values.
   Now filtered out (return null).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 09:20:12 +02:00
parent a759e816a0
commit 5c1aa03270
+46 -12
View File
@@ -115,21 +115,31 @@ export class DkvParserService {
// ─── Private: Single-transaction tab-separated line ──────────────────────── // ─── Private: Single-transaction tab-separated line ────────────────────────
// //
// DKV single-tx tab format (empirically verified against invoice.pdf): // DKV produces two distinct single-tx tab formats:
// //
// FUEL format:
// [0] Lieferdatum DD.MM.YYYY // [0] Lieferdatum DD.MM.YYYY
// [1] Station Name e.g. "ESSO" // [1] Station Name e.g. "ESSO"
// [2] Ort e.g. "DONZDORF" // [2] Ort e.g. "DONZDORF"
// [3] Servicest.-Nr e.g. "0071132" // [3] Servicest.-Nr e.g. "0071132"
// [4] Transaktions-Nr e.g. "189945" // [4] Transaktions-Nr e.g. "189945" ← purely numeric
// [5] "KM PRODUKT" e.g. "68040 DIESEL" (km and product merged, no tab between) // [5] "KM PRODUKT" e.g. "68040 DIESEL" (km and product merged)
// or just PRODUKT when driver did not enter km
// [6] "CODE UNIT" e.g. "0009 LTR" // [6] "CODE UNIT" e.g. "0009 LTR"
// [7] Menge e.g. "45,910" // [7] Menge e.g. "45,910"
// [8] Preis/E brutto e.g. "2,1790" // [10] Bezugswert brutto
// [9] Preis/E netto e.g. "1,8312" // [11] Bezugswert netto
// [10] Bezugswert brutto e.g. "100,04" //
// [11] Bezugswert netto e.g. "84,07" // EV CHARGING format — detected by "DDDD KWH" unit field:
// ... // kwhIdx=5 (standard EV, station and ort separate):
// [1] Station Name, [2] Ort, [3] Charge Point ID,
// [4] Produkt, [5] "0964 KWH", [6] Menge, [9] netto, [12] brutto
// kwhIdx=4 (compact EV, station+ort merged into [1]):
// [1] "Name ORT" merged, [2] Charge Point ID,
// [3] Produkt, [4] "0963 KWH", [5] Menge, [8] netto, [11] brutto
//
// SERVICE ROWS (e.g. "DKV Analytics Premiu") appear inside a VEHICLE: block but
// are not vehicle transactions — detected by non-numeric fields[4] → skip.
private parseSingleTxLine(line: string): DkvTransaction | null { private parseSingleTxLine(line: string): DkvTransaction | null {
const fields = line.split('\t').map(f => f.trim()); const fields = line.split('\t').map(f => f.trim());
@@ -138,7 +148,31 @@ export class DkvParserService {
const datePattern = /^\d{2}\.\d{2}\.\d{4}$/; const datePattern = /^\d{2}\.\d{2}\.\d{4}$/;
if (!datePattern.test(fields[0])) return null; if (!datePattern.test(fields[0])) return null;
// fields[5] = "KM_VALUE PRODUKT" — split on first whitespace boundary // Detect EV charging: look for a unit field matching "DDDD KWH" or "DDDD MIN"
const kwhIdx = fields.findIndex(f => /^\d{4}\s+(KWH|MIN)$/i.test(f));
if (kwhIdx !== -1) {
// kwhIdx=5: ort is separate in fields[2]; kwhIdx=4: station+ort merged in fields[1]
const ort = kwhIdx >= 5 ? (fields[2] ?? '') : (fields[1] ?? '');
const mengeIdx = kwhIdx + 1;
const nettoIdx = kwhIdx + 3;
const bruttoIdx = kwhIdx + 6;
return {
lieferdatum: fields[0],
ort,
kilometerstand: 0,
produkt: fields[kwhIdx - 1] ?? '',
menge: parseDE(fields[mengeIdx] || '0'),
einheit: fields[kwhIdx]?.split(/\s+/)[1] ?? 'KWH',
netto: parseDE(fields[nettoIdx] || '0'),
brutto: parseDE(fields[bruttoIdx] || '0'),
};
}
// Standard fuel rows: fields[4] must be a numeric transaction number.
// Non-numeric field[4] = service charge row (e.g. DKV Analytics fee) → skip.
if (!/^\d+$/.test(fields[4] ?? '')) return null;
// fields[5] = "KM PRODUKT" merged, or just PRODUKT when driver skipped km entry
const kmAndProdukt = fields[5].split(/\s+/); const kmAndProdukt = fields[5].split(/\s+/);
const km = kmAndProdukt[0] ?? '0'; const km = kmAndProdukt[0] ?? '0';
const produkt = kmAndProdukt.slice(1).join(' '); const produkt = kmAndProdukt.slice(1).join(' ');
@@ -152,10 +186,10 @@ export class DkvParserService {
ort: fields[2] ?? '', ort: fields[2] ?? '',
kilometerstand: parseDE(km), kilometerstand: parseDE(km),
produkt, produkt,
menge: parseDE(fields[7] ?? '0'), menge: parseDE(fields[7] || '0'),
einheit, einheit,
netto: parseDE(fields[11] ?? '0'), netto: parseDE(fields[11] || '0'),
brutto: parseDE(fields[10] ?? '0'), brutto: parseDE(fields[10] || '0'),
}; };
} }