diff --git a/AGENTS.md b/AGENTS.md index 36d2b85..3e64517 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10726,3 +10726,132 @@ Beleg liest sich `Nürnberger Str., Ansbach`, und der Einzelbeleg zeigt **Nicht geprueft, weil es ohne Mac nicht geht:** dass die Registrierung auf dem Geraet greift. Das zeigt erst der naechste Xcode-Lauf - und der bricht jetzt ab, wenn sie fehlt. + +## CN. Die Marke stand auf jedem Beleg - und war nirgends gespeichert. Dazu: warum ein eingelesener Beleg nur Datum und Uhrzeit fuellte (2026.9.4.21) + +### 1. "In einem Beleg soll die Marke, falls vorhanden, erkannt werden" + +Der Einzelbeleg vom 10.11.2025 las sich nach Abschnitt CM +`Nürnberger Str. 74` / `91522 Ansbach` - richtig zerlegt, aber ohne Marke. Die +Frage war also nicht, ob die Zerlegung stimmt, sondern woher die Marke kommen +soll. + +**Sie steht auf dem Beleg.** Nachgesehen statt vermutet - die erste Zeile jedes +abgelegten PDFs: + +| Beleg | Zeile 0 | gespeicherter `station_name` | +|---|---|---| +| e-receipt9 | `Shell-Station` | `Hermann Mogler Mineralölg. GmbH` | +| e-receipt5 | `Shell Station` | `TC Sengül GmbH` | +| e-receipt | `SHELL STATION` | `A. Zrenner GmbH` | +| receipt_2026… | `SHELL TANKSTELLE, 6450 SÖLDEN` | `Shell, SÖLDEN` | + +Der Parser **erkennt** die Marke seit dem 16.08.2026 (`_marke()`, nur im +Belegkopf gesucht) - er hat sie aber nur in den Anzeigenamen gefaltet +(`_tankstelle()`). Fuer jeden Beleg, den eine aeltere Fassung eingelesen hat, +blieb dort der Betreibername stehen, und die Marke war danach **nirgends mehr +zu holen**. Ein Feld, das nur als Nebenprodukt einer Zeichenkette existiert, +ist kein Feld. + +**Jetzt eigenes Feld** `station_brand`, aus beiden Parser-Wegen. Dazu zwei +oeffentliche Funktionen: `marke_aus_text()` und `marke_aus_pdf()` - sie lesen +nur den Kopf und haengen damit **nicht** am vollstaendigen Einlesen. Ein Beleg, +an dem beide Parser scheitern wuerden, gibt seine Marke trotzdem her. + +**Das Nachtragen** (`belege.marken_nachtragen`) laeuft einmal beim Start, an +genau der Stelle, die dafuer schon da war (`_nach_start`, direkt hinter +`screening.durchfuehren` - deren Kommentar beschreibt woertlich diesen Fall: +"was ihre neuen Felder nachtragen koennen, soll nicht bis zur naechsten Fahrt +warten"). Vorsichtig gehalten: + +* angefasst wird nur, wo `station_brand` **gar nicht vorkommt** und die + Belegdatei tatsaechlich liegt; +* ein einmal eingetragenes Feld - auch mit Wert `None` - wird nie + ueberschrieben. Damit ueberschreibt es keine Korrektur von Hand und laeuft + nicht bei jedem Start erneut ueber dieselben Belege; +* geschrieben wird nur, wenn sich wirklich etwas geaendert hat. + +Am Testcontainer geprueft: erster Start +`Tankstellenmarke bei 5 Beleg(en) nachgetragen (Shell)`, zweiter Start **keine +Zeile mehr**. Genau die fuenf Vorgaenge mit Belegdatei tragen das Feld. + +**Gespeichert schlaegt geraten.** In beiden Codebasen nimmt +`tankstelleTeile(name, adresse, marke)` die gespeicherte Angabe vorweg und +faellt nur dort auf `markeErkennen(name)` zurueck, wo keine da ist: bei +Vorgaengen ohne Beleg, bei von Hand eingetragenen Tankstellen, und solange das +Nachtragen nicht gelaufen ist. + +Beim Nebeneinanderlegen von Panel und App fiel noch etwas auf, das vorher nicht +auffallen konnte: steht im Namen eine ANDERE Marke als die gespeicherte, rutschte +die aus dem Namen in den **Ort** ("Aral" als Ortsname). Die alte Regel verglich +nur mit der gewaehlten Marke. Jetzt gilt: ein Teil, der fuer sich genommen eine +Kopfmarke IST, taugt nie als Ort. + +### 2. Punkt 5 aus Abschnitt CM: der Beleg kam mit Datum und Uhrzeit an + +Der Eigentuemer hat den Beleg geschickt (Shell, Richard-Wagner-Str. 9, +Ingolstadt, 43,87 l fuer 95,60 EUR) - liegt als +`tests/belegparser/belege/geteilt-2026-09-04.pdf`, gitignored. + +**Der Parser liest ihn vollstaendig.** `_parsen()` greift, alle Werte stimmen. +Der Fehler sass in der App: + +| App fragte ab | belege.py veroeffentlicht | +|---|---| +| `liter` | `liters` | +| `kosten` | `fuel_total_eur` | +| `ersparnis` | `discount` | +| `station` | `station_name` | +| `kraftstoff` | `fuel_type` | +| `km` | *(gar nicht - §7.7 Regel 2)* | +| `ts` | `ts` | + +Uebereingestimmt hat **allein `ts`** - deshalb Datum und Uhrzeit und sonst +nichts. Der Kraftstoff im Bildschirmfoto war nicht vom Beleg, sondern +`KRAFTSTOFFSORTEN[0]`. Das Panel hat die Namen in `tankFelder()` immer richtig +benutzt; die App hatte sich ihre eigenen ausgedacht. + +**Meine Einschaetzung in Abschnitt CM war zu kurz gegriffen.** Dass der +Teilen-Weg und die Dateiauswahl denselben `belegLesen()` benutzen, stimmte - +daraus folgte aber nicht "also nicht die App". Beide Wege waren gleich kaputt. +Richtig war nur, es nicht ohne den Beleg zu entscheiden. + +Die Zuordnung steht jetzt als `belegFormularwerte()` in +`daten/belegentwurf.ts`, nicht mehr als `if`-Zeilen im Bildschirm - **als +Funktion laesst sie sich gegen eine echte Nutzlast pruefen, in den `if`-Zeilen +konnte sie es nicht**. Die Nutzlast im Test ist die echte Ausgabe fuer diesen +Beleg, kein erfundenes Beispiel. + +Nebenbei mitgenommen: `0` ist ein Wert, kein fehlendes Feld. Ein Beleg mit +0,00 EUR Rabatt darf nicht wie "kein Rabatt angegeben" aussehen - deshalb +`== null` und nicht `!wert`. + +### Verifiziert + +`tsc --noEmit` sauber, **258 Tests** (von 246: 6 fuer die gespeicherte Marke, +6 fuer die Formularwerte), `vite build` sauber, Panel als **Modul** geparst, +Parser-Suite **11 Tests** gegen jetzt elf echte Belege. + +**Die drei Marken-Tests gegen den alten Stand laufen lassen** (`marke`-Parameter +ignoriert): 3 von 36 scheitern, danach wieder 36 gruen. Der Test misst also, +was er behauptet. + +**Panel und App byteweise gleich** fuer elf Faelle mit und ohne gespeicherte +Marke - die Panel-Funktionen aus dem Buendel geschnitten und in Node gegen +dieselben Eingaben laufen lassen, nicht nachgebaut. + +**Live in `audi_ha_test` (2026.9.4.21, 0 Tracebacks):** +* Uebersicht `Shell, Nürnberger Str., Ansbach`, Einzelbeleg + `Shell, Nürnberger Str. 74` / `91522 Ansbach`. +* Den echten geteilten Beleg ueber `beleg_hochladen` durchs Backend geschickt + und die veroeffentlichte Entitaet gelesen: `station_brand: "Shell"`, und die + Formularzuordnung ergibt daraus `43.87` / `95.6` / `11.84` / + `Shell, Richard-Wagner-Str. 9, Ingolstadt` / `V-Power Racing` / + `2026-04-03T00:55` - also das ganze Formular, nicht nur die Uhrzeit. +* Die Prueflast danach wieder entfernt (`/config/www/pruefbeleg.pdf` und die + verwaiste `belege/geteilt-pruefung.pdf`; kein Tankvorgang zeigte darauf). + +**Nicht geprueft:** die iOS-Huelle - die Aenderungen der App liegen im +OTA-Buendel (2026.9.4.21) und brauchen keinen Xcode-Lauf. Der offene Punkt aus +Abschnitt CM bleibt: dass die `packageClassList`-Registrierung auf dem Geraet +greift, zeigt erst der naechste Xcode-Lauf. diff --git a/companion-app/src/api/types.ts b/companion-app/src/api/types.ts index 2aacebc..fced60c 100644 --- a/companion-app/src/api/types.ts +++ b/companion-app/src/api/types.ts @@ -85,6 +85,9 @@ export interface Tankvorgang { /** Immer aus fuel_total_eur/liters berechnet, nie aus dem Beleg gelesen. */ price_per_l: number | null; station_name?: string | null; + /** Kopfmarke des Belegs (shell_beleg_parser). Fehlt bei Vorgaengen ohne + Beleg und bei Belegen, deren Datei nicht mehr liegt. */ + station_brand?: string | null; station_id?: string | null; station_address?: string | null; fuel_type?: string | null; diff --git a/companion-app/src/daten/belegentwurf.test.ts b/companion-app/src/daten/belegentwurf.test.ts new file mode 100644 index 0000000..282bab6 --- /dev/null +++ b/companion-app/src/daten/belegentwurf.test.ts @@ -0,0 +1,93 @@ +/** + * Tests zur Uebernahme eines eingelesenen Belegs ins Tankformular. + * + * Die Nutzlast unten ist KEIN erfundenes Beispiel: sie ist die Ausgabe von + * shell_beleg_parser._parsen() fuer den Beleg, den der Eigentuemer am + * 04.09.2026 ueber das Teilen-Blatt hereingereicht hat und der in der App nur + * mit Datum und Uhrzeit ankam (Shell, Richard-Wagner-Str. 9, Ingolstadt, + * 43,87 l fuer 95,60 EUR). Genau dieser Fall gehoert in einen Test. + */ + +import { describe, expect, it } from "vitest" + +import { belegFormularwerte } from "./belegentwurf" + +/* Wortgleich mit dem, was belege._beleg_felder() fuer diesen Beleg + veroeffentlicht - die Feldnamen sind der Punkt dieser Suite. */ +const GETEILT = { + receipt_key: "0000000459_3368-082-00082_2026-04-03T00:55", + receipt_no: "3368/082/00082", + tse_beleg_nr: "73912", + ts: "2026-04-03T00:55:00", + ts_payment: "2026-04-03T00:52:43", + ts_tse: "2026-04-03T00:55:01", + station_id: "0000000459", + station_brand: "Shell", + station_name: "Shell, Richard-Wagner-Str. 9, Ingolstadt", + station_address: "Richard-Wagner-Str. 9, 85057 Ingolstadt", + article_no: "000013", + product_name: "V-Power Racing", + fuel_type: "V-Power Racing", + liters: 43.87, + fuel_total_eur: 95.6, + price_per_l: 2.1792, + discount: 11.84, + discount_per_l: 0.27, + list_price_per_l: 2.449, + receipt_total_eur: 95.6, + net_eur: 80.34, + vat_eur: 15.26, + receipt_file: "/config/audi_dashboard/belege/e-receipt (7).pdf", +} + +describe("belegFormularwerte", () => { + it("fuellt das ganze Formular, nicht nur Datum und Uhrzeit", () => { + // Der gemeldete Fehler: alles ausser zeitpunkt blieb leer, weil die App + // liter/kosten/station/ersparnis/kraftstoff abfragte statt der + // Belegfelder. + expect(belegFormularwerte(GETEILT)).toEqual({ + zeitpunkt: "2026-04-03T00:55", + liter: "43.87", + kosten: "95.6", + ersparnis: "11.84", + station: "Shell, Richard-Wagner-Str. 9, Ingolstadt", + kraftstoff: "V-Power Racing", + }) + }) + + it("nimmt den realen Betrag, nicht den Listenpreis", () => { + // fuel_total_eur ist bereits rabattiert; receipt_total_eur waere hier + // zufaellig gleich, list_price_per_l dagegen ein Preis je Liter. + expect(belegFormularwerte(GETEILT).kosten).toBe(String(GETEILT.fuel_total_eur)) + }) + + it("laesst Felder leer, die der Beleg nicht nennt", () => { + // Ein Beleg ohne Rabatt ist normal (BelegOhneRabatt im Parsertest) - das + // Formularfeld soll dann nicht auf "null" stehen. + expect(belegFormularwerte({ ...GETEILT, discount: null })).toMatchObject({ ersparnis: "" }) + expect(belegFormularwerte({ ts: "2026-04-03T00:55:00" })).toEqual({ + zeitpunkt: "2026-04-03T00:55", + liter: "", + kosten: "", + ersparnis: "", + station: "", + kraftstoff: "", + }) + }) + + it("laesst eine echte Null durch", () => { + // 0 ist ein Wert, kein fehlendes Feld - ein Beleg mit 0,00 EUR Rabatt + // darf nicht wie "kein Rabatt angegeben" aussehen. + expect(belegFormularwerte({ ...GETEILT, discount: 0 }).ersparnis).toBe("0") + }) + + it("kommt mit gar keinem Beleg zurecht", () => { + expect(belegFormularwerte(null).zeitpunkt).toBe("") + expect(belegFormularwerte(undefined).liter).toBe("") + }) + + it("uebernimmt keinen Kilometerstand", () => { + // SPECIFICATION.md §7.7 Regel 2: der kommt immer vom Fahrzeug. + expect(Object.keys(belegFormularwerte(GETEILT))).not.toContain("km") + }) +}) diff --git a/companion-app/src/daten/belegentwurf.ts b/companion-app/src/daten/belegentwurf.ts new file mode 100644 index 0000000..988664b --- /dev/null +++ b/companion-app/src/daten/belegentwurf.ts @@ -0,0 +1,68 @@ +/** + * Was ein eingelesener Beleg ins Tankformular traegt. + * + * WARUM DAS HIER STEHT UND NICHT IM BILDSCHIRM + * ------------------------------------------- + * Bis zum 04.09.2026 stand die Uebernahme als Folge von `if`-Zeilen in + * `Tanken.tsx` - und fragte `liter`/`kosten`/`station`/`ersparnis`/ + * `kraftstoff` ab, also die Namen der FORMULARFELDER. Veroeffentlicht werden + * aber die Namen des BELEGS (`_beleg_felder` in belege.py, dieselben, die auch + * im Tankvorgang landen). Uebereingestimmt hat allein `ts`. + * + * Sichtbar war das als: ein eingelesener Beleg fuellte Datum und Uhrzeit, + * sonst nichts (Befund des Eigentuemers, 04.09.2026 - gemeldet fuer einen + * ueber das Teilen-Blatt hereingereichten Beleg, betraf aber jeden Weg + * gleichermassen, weil beide durch `belegLesen()` laufen). Als eigene + * Funktion laesst sich die Zuordnung gegen eine echte Nutzlast pruefen; in + * den `if`-Zeilen des Bildschirms konnte sie es nicht. + * + * KEIN KILOMETERSTAND + * ------------------- + * Er kommt immer vom Fahrzeug, nie vom Beleg (SPECIFICATION.md §7.7 Regel 2). + * Das Panel haelt es in `tankFelder()` genauso. + */ + +/** Die Felder, die belege.py nach dem Lesen eines Belegs veroeffentlicht. */ +export interface BelegEntwurf { + ts?: string + liters?: number | null + /** Der reale, bereits rabattierte Betrag. */ + fuel_total_eur?: number | null + /** SmartDeal-Ersparnis. */ + discount?: number | null + station_name?: string | null + fuel_type?: string | null + receipt_key?: string | null + receipt_file?: string | null +} + +/** Was davon in welches Formularfeld geht - leere Zeichenkette heisst: nicht + setzen, das Feld behaelt seinen bisherigen Wert. */ +export interface Formularwerte { + zeitpunkt: string + liter: string + kosten: string + ersparnis: string + station: string + kraftstoff: string +} + +/** + * Zahl in die Zeichenkette eines Zahlenfelds. `0` ist ein gueltiger Wert und + * muss durchkommen - nur `null`/`undefined` bedeuten "steht nicht im Beleg". + */ +function zahl(wert: number | null | undefined): string { + return wert == null ? "" : String(wert) +} + +export function belegFormularwerte(e: BelegEntwurf | null | undefined): Formularwerte { + return { + // Das Eingabefeld will "JJJJ-MM-TTThh:mm", der Beleg liefert Sekunden mit. + zeitpunkt: e?.ts ? e.ts.slice(0, 16) : "", + liter: zahl(e?.liters), + kosten: zahl(e?.fuel_total_eur), + ersparnis: zahl(e?.discount), + station: e?.station_name ?? "", + kraftstoff: e?.fuel_type ?? "", + } +} diff --git a/companion-app/src/daten/tankstelle.test.ts b/companion-app/src/daten/tankstelle.test.ts index 1391c37..c304861 100644 --- a/companion-app/src/daten/tankstelle.test.ts +++ b/companion-app/src/daten/tankstelle.test.ts @@ -249,3 +249,60 @@ describe("tankstelleKurz und tankstelleZeilen an echten Belegen", () => { }) }) }) + +describe("gespeicherte Marke (station_brand)", () => { + /* Der Beleg vom 10.11.2025, wie er wirklich gespeichert ist: der Name traegt + den BETREIBER, die Marke steht nur auf dem PDF. Seit dem 04.09.2026 liest + sie shell_beleg_parser._marke aus dem Belegkopf, belege.marken_nachtragen + traegt sie fuer Altbelege nach - und hier kommt sie an. */ + const NAME = "TC Sengül GmbH" + const ADRESSE = "Nürnberger Str.74, 91522 Ansbach" + + it("nimmt die gespeicherte Marke, wo der Name keine hergibt", () => { + // Ohne sie stand der Einzelbeleg ohne Marke da, obwohl "Shell Station" + // auf dem Beleg steht (Befund des Eigentuemers, 04.09.2026). + expect(tankstelleZeilen(NAME, ADRESSE)).toEqual({ + oben: "Nürnberger Str. 74", + unten: "91522 Ansbach", + }) + expect(tankstelleZeilen(NAME, ADRESSE, "Shell")).toEqual({ + oben: "Shell, Nürnberger Str. 74", + unten: "91522 Ansbach", + }) + }) + + it("nimmt sie auch in der Uebersicht", () => { + expect(tankstelleKurz(NAME, ADRESSE, "Shell")).toBe("Shell, Nürnberger Str., Ansbach") + }) + + it("geht der Erkennung aus dem Namen vor", () => { + // Die gespeicherte Angabe stammt vom Belegkopf, die andere ist geraten. + expect(tankstelleTeile("Aral, Musterweg 1, Musterort", null, "Shell").marke).toBe("Shell") + }) + + it("faellt auf die Erkennung zurueck, wo nichts gespeichert ist", () => { + // Vorgaenge ohne Beleg und von Hand eingetragene Tankstellen haben das + // Feld nie - dort bleibt es beim bisherigen Weg. + expect(tankstelleKurz("Shell, Pascalstr. 8, Ingolstadt", null, null)).toBe( + "Shell, Pascalstr., Ingolstadt", + ) + expect(tankstelleKurz("Shell, SÖLDEN", null, undefined).startsWith("Shell")).toBe(true) + }) + + it("behandelt eine leere gespeicherte Marke wie gar keine", () => { + // marken_nachtragen schreibt null, wenn der Beleg keine Marke nennt. + expect(tankstelleTeile("Shell, SÖLDEN", null, "").marke).toBe("Shell") + expect(tankstelleTeile("Shell, SÖLDEN", null, " ").marke).toBe("Shell") + }) + + it("gibt sie dem Kartendienst mit", () => { + // Apple Karten und Google Maps treffen mit der Marke die Zapfsaeule statt + // des Nachbargebaeudes - die Firmierung hilft dort nirgends. + expect(tankstelleZiel(NAME, ADRESSE, "Shell")).toBe( + "Shell, Nürnberger Str.74, 91522 Ansbach", + ) + expect(kartendienstUrl(NAME, ADRESSE, null, "zeigen", "Shell")).toContain( + encodeURIComponent("Shell, Nürnberger Str.74, 91522 Ansbach"), + ) + }) +}) diff --git a/companion-app/src/daten/tankstelle.ts b/companion-app/src/daten/tankstelle.ts index 7d37403..b2e1573 100644 --- a/companion-app/src/daten/tankstelle.ts +++ b/companion-app/src/daten/tankstelle.ts @@ -61,6 +61,13 @@ const MARKEN = [ * sichtbar ist die Kopfmarke. * * Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen. + * + * NUR DER ZWEITE WEG: seit dem 04.09.2026 speichert die Integration die Marke + * als eigenes Feld `station_brand` (shell_beleg_parser._marke, aus dem + * Belegkopf), und fuer Altbelege traegt sie belege.marken_nachtragen() aus der + * abgelegten Belegdatei nach. Wo dieses Feld etwas hergibt, gilt es - hier + * geraten wird nur, wo es fehlt: bei Vorgaengen ohne Beleg, bei von Hand + * eingetragenen Tankstellen und solange das Nachtragen nicht gelaufen ist. */ export function markeErkennen(name: string | null | undefined): string | null { const roh = (name ?? "").trim() @@ -132,13 +139,19 @@ export interface Tankstellenteile { * waehrend die Anschrift daneben ungenutzt blieb. Gelesen werden deshalb * beide Felder: die Anschrift zuerst (sie ist die strukturierte Quelle), der * Name danach fuer Marke und alles, was die Anschrift nicht hergibt. + * + * `marke` ist die gespeicherte Angabe des Belegs (`station_brand`). Sie geht + * vor, weil sie vom Belegkopf stammt und nicht aus dem Anzeigenamen geraten + * ist - genau die Luecke, durch die der Beleg vom 10.11.2025 ohne Marke + * dastand, obwohl "Shell Station" auf ihm steht. */ export function tankstelleTeile( name: string | null | undefined, adresse: string | null | undefined, + marke?: string | null | undefined, ): Tankstellenteile { const teile: Tankstellenteile = { - marke: markeErkennen(name) ?? "", + marke: (marke ?? "").trim() || markeErkennen(name) || "", strasse: "", hausnummer: "", plz: "", @@ -150,8 +163,11 @@ export function tankstelleTeile( for (const stueck of (adresse ?? "").split(",")) quellen.push(stueck) for (const stueck of (name ?? "").split(",")) { const t = stueck.trim() - // Die Marke steht schon fest; als Ort taugt sie nicht. - if (teile.marke && t.toLowerCase() === teile.marke.toLowerCase()) continue + // Ein Teil, der fuer sich genommen eine Kopfmarke IST, taugt nie als Ort. + // Frueher stand hier nur der Vergleich mit der gewaehlten Marke; seit die + // gespeicherte Angabe vorgeht, koennen beide auseinanderfallen, und dann + // rutschte die Marke aus dem Namen in den Ort. + if (t && markeErkennen(t)?.toLowerCase() === t.toLowerCase()) continue quellen.push(t) } @@ -195,8 +211,9 @@ export function tankstelleTeile( export function tankstelleKurz( name: string | null | undefined, adresse: string | null | undefined, + marke?: string | null | undefined, ): string { - const t = tankstelleTeile(name, adresse) + const t = tankstelleTeile(name, adresse, marke) const zeile = [t.marke, t.strasse, t.ort].filter(Boolean).join(", ") return zeile || t.roh || "unbekannt" } @@ -215,8 +232,9 @@ export function tankstelleKurz( export function tankstelleZeilen( name: string | null | undefined, adresse: string | null | undefined, + marke?: string | null | undefined, ): { oben: string; unten: string } { - const t = tankstelleTeile(name, adresse) + const t = tankstelleTeile(name, adresse, marke) const strasse = [t.strasse, t.hausnummer].filter(Boolean).join(" ") let oben = [t.marke, strasse].filter(Boolean).join(", ") let unten = [t.plz, t.ort].filter(Boolean).join(" ") @@ -283,10 +301,11 @@ export function tankstelleSuchtext( export function tankstelleZiel( name: string | null | undefined, adresse: string | null | undefined, + marke?: string | null | undefined, ): string { const a = (adresse ?? "").trim() if (!a) return (name ?? "").trim() - return [markeErkennen(name) ?? "", a].filter(Boolean).join(", ") + return [(marke ?? "").trim() || markeErkennen(name) || "", a].filter(Boolean).join(", ") } /** Apple-Gerät? Dort ist Karten der Standarddienst. */ @@ -315,9 +334,10 @@ export function kartendienstUrl( adresse: string | null | undefined, pos?: Koordinate | null, modus: Kartenmodus = "route", + marke?: string | null | undefined, ): string | null { const beschriftung = (name ?? "").trim() - const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse) + const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse, marke) if (!ziel) return null if (apfelgeraet()) { if (modus === "zeigen") { diff --git a/companion-app/src/screens/TankDetail.tsx b/companion-app/src/screens/TankDetail.tsx index e89f458..3538867 100644 --- a/companion-app/src/screens/TankDetail.tsx +++ b/companion-app/src/screens/TankDetail.tsx @@ -72,7 +72,11 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na return ( <> - + @@ -125,7 +129,11 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na wert={`${eur(tank.discount)} €`} /> )} - + } /> @@ -153,12 +161,14 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na function Tankstellenzeile({ name, adresse, + marke, }: { name: string | null | undefined adresse: string | null | undefined + marke?: string | null | undefined }) { - const { oben, unten } = tankstelleZeilen(name, adresse) - const url = kartendienstUrl(name, adresse, null, "zeigen") + const { oben, unten } = tankstelleZeilen(name, adresse, marke) + const url = kartendienstUrl(name, adresse, null, "zeigen", marke) const inhalt = ( <> diff --git a/companion-app/src/screens/Tanken.tsx b/companion-app/src/screens/Tanken.tsx index de55a5b..9688fcb 100644 --- a/companion-app/src/screens/Tanken.tsx +++ b/companion-app/src/screens/Tanken.tsx @@ -24,6 +24,7 @@ import { import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine" import { BelegPopup } from "./BelegPopup" import { markenlogoUrl } from "./bilder" +import { belegFormularwerte, type BelegEntwurf } from "../daten/belegentwurf" import { tankstelleKurz } from "../daten/tankstelle" import { LOESCH_HINWEIS } from "./bestaetigung" import { Zeilenmenue } from "./Zeilenmenue" @@ -125,7 +126,11 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => kinder={ geheZu("fill", tank.tank_id)} @@ -157,19 +162,6 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => ) } -/** Felder, die belege.py nach dem Lesen eines Belegs veröffentlicht. */ -interface BelegEntwurf { - ts?: string - liter?: number | null - kosten?: number | null - km?: number | null - ersparnis?: number | null - station?: string | null - kraftstoff?: string | null - receipt_key?: string | null - receipt_file?: string | null -} - /** Monatsname allein, wie im Panel. */ function monatName(jahr: number, monat: number): string { return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" }) @@ -275,13 +267,13 @@ function TankFormular({ setzeFehler(e.fehler) } else { setzeBeleg(e) - if (e.liter != null) setzeLiter(String(e.liter)) - if (e.kosten != null) setzeKosten(String(e.kosten)) - if (e.station) setzeStation(e.station) - if (e.km != null) setzeKm(String(e.km)) - if (e.ersparnis != null) setzeErsparnis(String(e.ersparnis)) - if (e.kraftstoff) setzeKraftstoff(e.kraftstoff) - if (e.ts) setzeZeitpunkt(e.ts.slice(0, 16)) + const w = belegFormularwerte(e) + if (w.zeitpunkt) setzeZeitpunkt(w.zeitpunkt) + if (w.liter) setzeLiter(w.liter) + if (w.kosten) setzeKosten(w.kosten) + if (w.ersparnis) setzeErsparnis(w.ersparnis) + if (w.station) setzeStation(w.station) + if (w.kraftstoff) setzeKraftstoff(w.kraftstoff) } return } diff --git a/companion-app/src/screens/TankstellenKarte.tsx b/companion-app/src/screens/TankstellenKarte.tsx index f06b054..59e44f2 100644 --- a/companion-app/src/screens/TankstellenKarte.tsx +++ b/companion-app/src/screens/TankstellenKarte.tsx @@ -32,9 +32,11 @@ import { Karte } from "./Karte" export function TankstellenKarte({ name, adresse, + marke, }: { name?: string | null | undefined adresse: string | null | undefined + marke?: string | null | undefined }) { const [position, setzePosition] = useState(null) const [geprueft, setzeGeprueft] = useState(false) @@ -68,7 +70,7 @@ export function TankstellenKarte({ // Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und // Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert. - if (position) return + if (position) return // Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die // Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die // Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da". diff --git a/custom_components/audi_dashboard/belege.py b/custom_components/audi_dashboard/belege.py index 651ac20..fdb322e 100644 --- a/custom_components/audi_dashboard/belege.py +++ b/custom_components/audi_dashboard/belege.py @@ -137,6 +137,7 @@ def _beleg_felder(geparst: dict, pdf_pfad: str) -> dict: "ts_payment": geparst.get("ts_payment"), "ts_tse": geparst.get("ts_tse"), "station_id": geparst.get("station_id"), + "station_brand": geparst.get("station_brand"), "station_name": geparst.get("station_name"), "station_address": geparst.get("station_address"), "article_no": geparst.get("article_no"), @@ -370,3 +371,60 @@ async def tankvorgang_loeschen(k: Koordinator, tank_id: str | None) -> None: _LOGGER.info("Tankvorgang %s gelöscht", tank_id) else: _LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id) + + +async def marken_nachtragen(k: Koordinator) -> None: + """Traegt die Tankstellenmarke bei Belegen nach, die vor dem 16.08.2026 + eingelesen wurden. + + WARUM ES DAS BRAUCHT + -------------------- + Bis zum 16.08.2026 kannte der Parser keine Marken; `station_name` trug + deshalb die Firmierung des Betreibers ("TC Sengül GmbH"), und die Marke + war fuer diese Datensaetze nirgends gespeichert. Auf dem BELEG steht sie + aber weiterhin - jeder der geprueften Altbelege beginnt mit "Shell-Station" + bzw. "SHELL STATION". Ohne dieses Nachtragen bliebe die Einzelbeleg-Zeile + dieser Vorgaenge dauerhaft ohne Marke (Befund des Eigentuemers, + 04.09.2026). + + VORSICHTIG GEHALTEN + ------------------- + * Angefasst wird nur, wo `station_brand` noch gar nicht vorkommt und die + Belegdatei tatsaechlich liegt. Ein einmal eingetragenes Feld - auch mit + Wert None - wird nie wieder ueberschrieben; damit ueberschreibt das + Nachtragen keine Korrektur von Hand und laeuft nicht bei jedem Start + erneut ueber dieselben Belege. + * Gelesen wird nur der Belegkopf (marke_aus_pdf), nicht der ganze Beleg. + Ein Beleg, an dem beide Parser scheitern wuerden, gibt seine Marke + trotzdem her - und ein Lesefehler bleibt folgenlos. + * Geschrieben wird nur, wenn sich wirklich etwas geaendert hat. + """ + vorgaenge = await k.ablage.tankvorgaenge_lesen() + offen = [ + v + for v in vorgaenge + if "station_brand" not in v and v.get("receipt_file") + ] + if not offen: + return + + def _lesen() -> dict[str, str | None]: + gefunden: dict[str, str | None] = {} + for vorgang in offen: + pfad = vorgang.get("receipt_file") + if not pfad or not os.path.exists(pfad): + continue + gefunden[vorgang["tank_id"]] = shell_beleg_parser.marke_aus_pdf(pfad) + return gefunden + + marken = await k.hass.async_add_executor_job(_lesen) + if not marken: + return + + for vorgang in vorgaenge: + if vorgang.get("tank_id") in marken: + vorgang["station_brand"] = marken[vorgang["tank_id"]] + await k.ablage.tankvorgaenge_schreiben(vorgaenge) + benannt = sorted({m for m in marken.values() if m}) + zusatz = ", ".join(benannt) if benannt else "keine erkennbar" + _LOGGER.info("Tankstellenmarke bei %d Beleg(en) nachgetragen (%s)", len(marken), zusatz) diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 14e6d47..2222338 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.4.20","sha256":"95213bb0998806d4b59b4d520d2dd93a4f1c7ce48991e6f77b38e9dbca8247a8","bytes":322945,"gebaut":"2026-09-04T14:28:33Z"} \ No newline at end of file +{"version":"2026.9.4.21","sha256":"3601a55ee4120c8508dd8f5018dcfbe806f10d87fd706763a0bbd97377d217d1","bytes":323037,"gebaut":"2026-09-04T14:55:41Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index e22428c..c3430de 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index 31f733b..6531ef8 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -1331,6 +1331,13 @@ function markeErkennen(name) { return null; } +/* NUR DER ZWEITE WEG: seit dem 04.09.2026 speichert die Integration die Marke + als eigenes Feld `station_brand` (shell_beleg_parser._marke, aus dem + Belegkopf), und fuer Altbelege traegt sie belege.marken_nachtragen() aus der + abgelegten Belegdatei nach. Wo dieses Feld etwas hergibt, gilt es - hier + geraten wird nur, wo es fehlt: bei Vorgaengen ohne Beleg, bei von Hand + eingetragenen Tankstellen und solange das Nachtragen nicht gelaufen ist. */ + /* Kennzeichen einer Firmierung. Ein Teil, der so etwas enthaelt, ist der BETREIBER und gehoert nach Vorgabe des Eigentuemers nicht in die Anzeige. */ const FIRMA_MUSTER = /\b(gmbh|mbh|ohg|kg|ag|e\.\s?k\.|inh\.|& co)\b/i; @@ -1357,13 +1364,23 @@ function strasseKuerzen(strasse) { Die Markenerkennung lief bis hierher nur ueber den Namen - bei den aelteren Belegen steht die Marke dort gar nicht, deshalb blieb in der Uebersicht die Firmierung stehen ("...GmbH", Nutzerbefund 04.09.2026), waehrend die - Anschrift daneben ungenutzt blieb. Gelesen werden jetzt beide Felder. */ -function tankstelleTeile(name, adresse) { - const teile = { marke: markeErkennen(name) || "", strasse: "", hausnummer: "", plz: "", ort: "", roh: (name || "").trim() }; + Anschrift daneben ungenutzt blieb. Gelesen werden jetzt beide Felder. + + `marke` ist die gespeicherte Angabe des Belegs (station_brand). Sie geht + vor, weil sie vom Belegkopf stammt und nicht aus dem Anzeigenamen geraten + ist - genau die Luecke, durch die der Beleg vom 10.11.2025 ohne Marke + dastand, obwohl "Shell Station" auf ihm steht. */ +function tankstelleTeile(name, adresse, marke) { + const teile = { marke: (marke || "").trim() || markeErkennen(name) || "", strasse: "", hausnummer: "", plz: "", ort: "", roh: (name || "").trim() }; const quellen = (adresse || "").split(","); for (const stueck of (name || "").split(",")) { const t = stueck.trim(); - if (teile.marke && t.toLowerCase() === teile.marke.toLowerCase()) continue; + // Ein Teil, der fuer sich genommen eine Kopfmarke IST, taugt nie als Ort. + // Frueher stand hier nur der Vergleich mit der gewaehlten Marke; seit die + // gespeicherte Angabe vorgeht, koennen beide auseinanderfallen, und dann + // rutschte die Marke aus dem Namen in den Ort. + const eigen = t ? markeErkennen(t) : null; + if (eigen && eigen.toLowerCase() === t.toLowerCase()) continue; quellen.push(t); } for (const stueck of quellen) { @@ -1395,16 +1412,16 @@ function tankstelleTeile(name, adresse) { (Vorgabe des Eigentuemers, 04.09.2026). Ergibt die Zerlegung nichts, bleibt der rohe Name stehen - ihn wegzulassen hiesse, eine bekannte Tankstelle "unbekannt" zu nennen. */ -function tankstelleKurz(name, adresse) { - const t = tankstelleTeile(name, adresse); +function tankstelleKurz(name, adresse, marke) { + const t = tankstelleTeile(name, adresse, marke); return [t.marke, t.strasse, t.ort].filter(Boolean).join(", ") || t.roh || "unbekannt"; } /* Die beiden Zeilen im Einzelbeleg: oben Marke und Strasse mit Hausnummer, unten Postleitzahl und Ort - geschrieben wie eine Anschrift, das Komma trennt nur die Marke ab. `oben` ist nie leer. */ -function tankstelleZeilen(name, adresse) { - const t = tankstelleTeile(name, adresse); +function tankstelleZeilen(name, adresse, marke) { + const t = tankstelleTeile(name, adresse, marke); const strasse = [t.strasse, t.hausnummer].filter(Boolean).join(" "); let oben = [t.marke, strasse].filter(Boolean).join(", "); let unten = [t.plz, t.ort].filter(Boolean).join(" "); @@ -1442,10 +1459,10 @@ function tankstelleSuchtext(name, adresse) { /* Ziel fuer den Kartendienst. Hier gehoeren Name UND Anschrift zusammen: Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die Zapfsaeule statt des Nachbargebaeudes. */ -function tankstelleZiel(name, adresse) { +function tankstelleZiel(name, adresse, marke) { const a = (adresse || "").trim(); if (!a) return (name || "").trim(); - return [markeErkennen(name) || "", a].filter(Boolean).join(", "); + return [(marke || "").trim() || markeErkennen(name) || "", a].filter(Boolean).join(", "); } /* Die Kandidaten der Reihe nach nachschlagen, erster Treffer gewinnt. */ @@ -1467,9 +1484,9 @@ function apfelgeraet() { Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des Eigentuemers, 04.09.2026): erst sehen, wo sie liegt - eine Route erwartet dort niemand, und sie ist im Kartendienst einen Tipp entfernt. */ -function kartendienstUrl(name, adresse, pos, modus = "route") { +function kartendienstUrl(name, adresse, pos, modus = "route", marke) { const beschriftung = (name || "").trim(); - const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse); + const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse, marke); if (!ziel) return null; if (apfelgeraet()) { if (modus === "zeigen") { @@ -1499,7 +1516,7 @@ function tankstelleAufKarteZeigen(karte, pos, f) { // Dieselben zwei Zeilen wie in der Wertezeile darunter (tankstelleZeilen) - // die Nadel soll nicht die Firmierung des Betreibers nennen, wenn die Zeile // die Kopfmarke zeigt. - const zeilen = tankstelleZeilen(f.station_name, f.station_address); + const zeilen = tankstelleZeilen(f.station_name, f.station_address, f.station_brand); window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte) .bindPopup(esc(zeilen.oben) + (zeilen.unten ? "
" + esc(zeilen.unten) : "")); // Position merken und den Knopf scharf schalten - er kann erst jetzt etwas @@ -4752,7 +4769,7 @@ function vFuel() { Ø ${de(schnittpreis(fm), 2)} €/l
`; fm.forEach((f) => { const preis = literpreis(f); h += swipeZeile(`fill:${f.tank_id}`, ``); }); h += `
`; @@ -4793,8 +4810,8 @@ function belegKarte(f) { Geraets; ohne Name und ohne Anschrift bleibt es eine stumme Zeile, denn ein Verweis, der nirgendwohin fuehrt, waere schlechter als keiner. */ function tankstellenZeile(f) { - const { oben, unten } = tankstelleZeilen(f.station_name, f.station_address); - const url = kartendienstUrl(f.station_name, f.station_address, null, "zeigen"); + const { oben, unten } = tankstelleZeilen(f.station_name, f.station_address, f.station_brand); + const url = kartendienstUrl(f.station_name, f.station_address, null, "zeigen", f.station_brand); const inhalt = `${esc(oben)}${unten ? `${esc(unten)}` : ""}` + (url ? `` : ""); diff --git a/custom_components/audi_dashboard/koordinator.py b/custom_components/audi_dashboard/koordinator.py index 37ba2db..96c3682 100644 --- a/custom_components/audi_dashboard/koordinator.py +++ b/custom_components/audi_dashboard/koordinator.py @@ -49,6 +49,7 @@ from homeassistant.helpers.storage import Store from . import ( batterie, + belege, bilder, fahrterkennung, flespi, @@ -216,6 +217,11 @@ class Koordinator: # Fahrt warten. Reicht das Ortsbudget eines Laufs nicht, plant das # Screening selbst einen zweiten ein (screening._nachfassen_planen). await screening.durchfuehren(self) + # Aus demselben Grund hier: die Tankstellenmarke ist so ein neues Feld. + # Sie steht auf jedem Beleg, wurde aber vor dem 16.08.2026 nicht + # gespeichert - nachgetragen wird sie aus der abgelegten Belegdatei, + # einmal je Vorgang (siehe belege.marken_nachtragen). + await belege.marken_nachtragen(self) # Weicht die Reglerstellung vom zuletzt gelesenen Geraetewert ab, # wird die Aenderung hier vorgemerkt - auch wenn sie waehrend eines # Ausfalls gesetzt wurde oder ein frueherer Auftrag nie zum Zug kam. diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 44a4852..2532266 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.4.20", + "version": "2026.9.4.21", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": [ diff --git a/custom_components/audi_dashboard/shell_beleg_parser.py b/custom_components/audi_dashboard/shell_beleg_parser.py index d1569f5..4ae46f9 100644 --- a/custom_components/audi_dashboard/shell_beleg_parser.py +++ b/custom_components/audi_dashboard/shell_beleg_parser.py @@ -123,6 +123,25 @@ def _marke(zeilen): return None +def marke_aus_text(text): + """Die Kopfmarke eines Belegtextes - der oeffentliche Weg zu _marke(). + + Getrennt vom vollstaendigen Einlesen, weil die Marke nur die ersten Zeilen + braucht: sie laesst sich auch aus einem Beleg holen, an dem beide Parser + scheitern wuerden. Genau das braucht das Nachtragen in belege.py fuer + Belege, die vor dem 16.08.2026 eingelesen wurden.""" + return _marke((text or "").splitlines()) + + +def marke_aus_pdf(pdf_pfad): + """Wie marke_aus_text(), aber liest das PDF selbst. None, wenn die Datei + sich nicht lesen laesst - eine fehlende Marke ist kein Fehlerfall.""" + try: + return marke_aus_text(_text_aus_pdf(pdf_pfad)) + except Exception: + return None + + def _ist_strasse(zeile): """Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht.""" @@ -234,6 +253,12 @@ def _parsen(text): # Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht # auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die # ausführliche station_address darunter, nicht in den Anzeigenamen. + # Die Marke steht seit dem 04.09.2026 ZUSAETZLICH als eigenes Feld da. + # Sie nur in den Anzeigenamen zu falten hat sie fuer jeden Beleg + # verloren, den ein aelterer Parser eingelesen hat - dort blieb der + # Betreibername stehen ("TC Sengül GmbH"), und keine Oberflaeche + # konnte die Marke nachtraeglich noch finden. + "station_brand": _marke(text.splitlines()), "station_name": _tankstelle( _marke(text.splitlines()), station.group("strasse").strip(), @@ -325,7 +350,8 @@ def _parsen_generisch(text): break if station_address and strasse and strasse not in station_address: station_address = f"{strasse}, {station_address}" - station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber) + marke = _marke(zeilen) + station_name = _tankstelle(marke, strasse, ort, ersatzname=betreiber) # ---- Gesamtbetrag ------------------------------------------------------ # Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an @@ -408,6 +434,7 @@ def _parsen_generisch(text): "ts_payment": None, "ts_tse": None, "station_id": None, + "station_brand": marke, "station_name": station_name, "station_address": station_address, "article_no": None, diff --git a/tests/belegparser/test_shell_beleg_parser.py b/tests/belegparser/test_shell_beleg_parser.py index deab94b..2e6795c 100644 --- a/tests/belegparser/test_shell_beleg_parser.py +++ b/tests/belegparser/test_shell_beleg_parser.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Regressionstest für shell_beleg_parser.py gegen zehn echte Shell-eReceipts. +"""Regressionstest für shell_beleg_parser.py gegen elf echte Shell-eReceipts. Die PDFs in belege/ sind reale, vom Nutzer bereitgestellte Tankbelege aus -vier verschiedenen Stationen (Ingolstadt/Zrenner, Rain am Lech/Bauch, -Ansbach/Sengül, Königsbronn/Mogler) und decken damit die tatsächlich +fünf verschiedenen Stationen (Ingolstadt/Zrenner an zwei Adressen, Rain am +Lech/Bauch, Ansbach/Sengül, Königsbronn/Mogler) und decken damit die tatsächlich beobachtete Formatvielfalt ab, nicht nur eine angenommene Struktur: - Markenzeile: "SHELL STATION" / "Shell Station" / "Shell-Station" - Rabattbezeichnung: "V-Power Smart Deal" / "ClubSmartRabatt" @@ -42,12 +42,13 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase): cls.dateien = sorted(f for f in os.listdir(_BELEGE) if f.endswith(".pdf")) cls.ergebnisse = {f: _parsen_datei(f) for f in cls.dateien} - def test_zehn_belege_gefunden(self): - self.assertEqual(len(self.dateien), 10, self.dateien) + def test_elf_belege_gefunden(self): + self.assertEqual(len(self.dateien), 11, self.dateien) def test_pflichtfelder_gesetzt(self): pflichtfelder = [ - "receipt_key", "receipt_no", "ts", "station_id", "station_name", + "receipt_key", "receipt_no", "ts", "station_id", "station_brand", + "station_name", "station_address", "article_no", "product_name", "fuel_type", "liters", "list_price_per_l", "fuel_total_eur", "receipt_total_eur", "net_eur", "vat_eur", @@ -72,12 +73,35 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase): # Rabatt darf den Bruttobetrag nicht übersteigen self.assertLess(d["discount"], d["fuel_total_eur"]) + def test_marke_als_eigenes_feld(self): + # Seit dem 04.09.2026 steht die Kopfmarke ZUSAETZLICH als eigenes Feld + # da, nicht nur in station_name eingefaltet. Genau daran fehlte sie + # jedem Beleg, den ein aelterer Parser eingelesen hatte: dort blieb der + # Betreibername stehen, und nachtraeglich war die Marke nirgends mehr + # zu holen (Befund des Eigentuemers, 04.09.2026). + for datei, d in self.ergebnisse.items(): + with self.subTest(datei=datei): + self.assertEqual(d["station_brand"], "Shell") + + def test_marke_ohne_vollstaendiges_einlesen(self): + # marke_aus_pdf() liest nur den Belegkopf. Das ist der Weg, ueber den + # belege.marken_nachtragen() die Marke bei Altbelegen nachtraegt - er + # darf nicht vom vollstaendigen Parsen abhaengen. + for datei in self.dateien: + with self.subTest(datei=datei): + self.assertEqual(p.marke_aus_pdf(os.path.join(_BELEGE, datei)), "Shell") + + def test_marke_aus_pdf_ohne_datei(self): + # Eine fehlende Belegdatei ist kein Fehlerfall - marken_nachtragen() + # laeuft ueber Vorgaenge, deren PDF laengst geloescht sein kann. + self.assertIsNone(p.marke_aus_pdf(os.path.join(_BELEGE, "gibtesnicht.pdf"))) + def test_receipt_key_eindeutig(self): schluessel = [d["receipt_key"] for d in self.ergebnisse.values()] self.assertEqual(len(schluessel), len(set(schluessel)), schluessel) def test_bekannte_stationen(self): - # Vier verschiedene Standorte in den zehn Belegen - stellt sicher, dass + # Fünf verschiedene Standorte in den elf Belegen - stellt sicher, dass # die Stationserkennung nicht zufällig nur für einen Beleg passt. # Anzeigeformat seit 2026-08-16: "Marke, Straße, Ort" statt des reinen # Betreibernamens ("A. Zrenner GmbH" o. ä.) - der Betreiber sagt beim @@ -88,6 +112,7 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase): "Shell, Münchener Str.15a, Ingolstadt", "Shell, Nürnberger Str.74, Ansbach", "Shell, Heidenheimerstr.6, Königsbronn", + "Shell, Richard-Wagner-Str. 9, Ingolstadt", } self.assertEqual(namen, erwartet) @@ -107,6 +132,11 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase): "e-receipt3.pdf": dict(liters=21.73, fuel_total_eur=44.53, discount=6.08, station_id="0000000456"), "e-receipt5.pdf": dict(liters=47.76, fuel_total_eur=79.23, discount=12.90, station_id="0000002227"), "e-receipt9.pdf": dict(liters=51.59, fuel_total_eur=85.59, discount=13.41, station_id="0000002103"), + # Der Beleg, der in der App nur mit Datum und Uhrzeit ankam + # (Eigentuemer, 04.09.2026). Der Parser hat ihn immer vollstaendig + # gelesen - der Fehler sass in Tanken.tsx, das andere Feldnamen + # abfragte, als belege.py veroeffentlicht. + "geteilt-2026-09-04.pdf": dict(liters=43.87, fuel_total_eur=95.60, discount=11.84, station_id="0000000459"), } for datei, erwartet in stichproben.items(): d = self.ergebnisse[datei] @@ -120,7 +150,7 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase): class BelegOhneRabatt(unittest.TestCase): """SmartDeal/ClubSmart ist optional - nicht jeder Tankvorgang hat einen Rabatt. Simuliert durch Entfernen der Rabattzeilen aus einem echten - Beleg, da keiner der zehn realen Belege rabattfrei war.""" + Beleg, da keiner der elf realen Belege rabattfrei war.""" def test_ohne_rabatt_bleibt_alles_andere_korrekt(self): text = p._text_aus_pdf(os.path.join(_BELEGE, "e-receipt.pdf"))