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"))