Tankstellenmarke als eigenes Feld, Belegfelder in der App richtig zugeordnet

Die Marke steht auf jedem Beleg, war aber nirgends gespeichert: der Parser
erkennt sie seit dem 16.08.2026, faltete sie aber nur in station_name.
Fuer jeden Beleg, den eine aeltere Fassung eingelesen hat, blieb dort der
Betreibername stehen und die Marke war danach nicht mehr zu holen.

- shell_beleg_parser: station_brand als eigenes Feld aus beiden Parserwegen,
  dazu marke_aus_text()/marke_aus_pdf() - sie lesen nur den Belegkopf und
  haengen nicht am vollstaendigen Einlesen.
- belege.marken_nachtragen(): traegt die Marke einmal beim Start aus der
  abgelegten Belegdatei nach. Nur wo das Feld gar nicht vorkommt und die
  Datei liegt; ein einmal eingetragenes Feld wird nie ueberschrieben.
- Panel und App: die gespeicherte Marke geht vor, geraten wird nur, wo keine
  da ist. Ein Teil, der fuer sich genommen eine Kopfmarke ist, taugt zudem
  nie als Ort.

Dazu Punkt 5 der Meldung vom 04.09.2026: ein eingelesener Beleg fuellte nur
Datum und Uhrzeit. Der Parser las ihn immer vollstaendig - die App fragte
liter/kosten/station/ersparnis/kraftstoff ab, veroeffentlicht werden aber die
Namen des Belegs (liters/fuel_total_eur/station_name/discount/fuel_type).
Uebereingestimmt hat allein ts. Die Zuordnung steht jetzt als
belegFormularwerte() in daten/belegentwurf.ts und wird gegen die echte
Nutzlast dieses Belegs geprueft.

tsc sauber, 258 Tests (die drei neuen Marken-Tests gegen den alten Stand als
scheiternd nachgewiesen), vite build sauber, Panel als Modul geparst,
Parser-Suite 11 Tests gegen elf echte Belege. Panel und App liefern fuer elf
Faelle byteweise dasselbe. Live in audi_ha_test auf 2026.9.4.21 ohne
Traceback: Uebersicht "Shell, Nuernberger Str., Ansbach", Einzelbeleg
zweizeilig mit Marke, und der echte geteilte Beleg fuellt durchs Backend das
ganze Formular.
This commit is contained in:
2026-09-04 17:00:22 +02:00
parent a99ae432fc
commit bdbdb4a010
17 changed files with 572 additions and 60 deletions
+129
View File
@@ -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.
+3
View File
@@ -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;
@@ -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")
})
})
+68
View File
@@ -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 ?? "",
}
}
@@ -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"),
)
})
})
+27 -7
View File
@@ -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") {
+14 -4
View File
@@ -72,7 +72,11 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na
return (
<>
<Tile className="dm-kartenkachel">
<TankstellenKarte name={tank.station_name} adresse={tank.station_address} />
<TankstellenKarte
name={tank.station_name}
adresse={tank.station_address}
marke={tank.station_brand}
/>
</Tile>
<Tile variant="flat" className="dm-distanzkachel">
@@ -125,7 +129,11 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na
wert={`${eur(tank.discount)}`}
/>
)}
<Tankstellenzeile name={tank.station_name} adresse={tank.station_address} />
<Tankstellenzeile
name={tank.station_name}
adresse={tank.station_address}
marke={tank.station_brand}
/>
</>
}
/>
@@ -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 = (
<>
<span className="dm-tankstellenzeile__ort">
+13 -21
View File
@@ -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={
<Blattzeile
label={datum(tank.ts)}
labelZusatz={tankstelleKurz(tank.station_name, tank.station_address)}
labelZusatz={tankstelleKurz(
tank.station_name,
tank.station_address,
tank.station_brand,
)}
wert={tank.liters != null ? `${de(tank.liters, 2)} l` : ""}
wertZusatz={preis != null ? `${de(preis, 2)} €/l` : ""}
beiKlick={() => 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
}
@@ -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<Koordinate | null>(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 <Karte start={position} nadel={tankstelleZeilen(name, adresse)} />
if (position) return <Karte start={position} nadel={tankstelleZeilen(name, adresse, marke)} />
// 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".
@@ -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)
@@ -1 +1 @@
{"version":"2026.9.4.20","sha256":"95213bb0998806d4b59b4d520d2dd93a4f1c7ce48991e6f77b38e9dbca8247a8","bytes":322945,"gebaut":"2026-09-04T14:28:33Z"}
{"version":"2026.9.4.21","sha256":"3601a55ee4120c8508dd8f5018dcfbe806f10d87fd706763a0bbd97377d217d1","bytes":323037,"gebaut":"2026-09-04T14:55:41Z"}
@@ -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 ? "<br>" + esc(zeilen.unten) : ""));
// Position merken und den Knopf scharf schalten - er kann erst jetzt etwas
@@ -4752,7 +4769,7 @@ function vFuel() {
<span>Ø ${de(schnittpreis(fm), 2)} /l</span></span></button>
<div class="acc-b ${mi === 0 ? "on" : ""}" id="fm${j}${m}">`;
fm.forEach((f) => { const preis = literpreis(f); h += swipeZeile(`fill:${f.tank_id}`, `<button class="leaf" data-go="fill:${f.tank_id}">
<span class="k">${dedat(new Date(f.ts))}<small>${esc(tankstelleKurz(f.station_name, f.station_address))}</small></span>
<span class="k">${dedat(new Date(f.ts))}<small>${esc(tankstelleKurz(f.station_name, f.station_address, f.station_brand))}</small></span>
<span class="v">${f.liters != null ? de(f.liters, 2) + " l" : ""}<small>${preis != null ? de(preis, 2) + " €/l" : ""}</small></span>
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>`); });
h += `</div></div>`;
@@ -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 =
`<span class="tankstellenzeile-ort">${esc(oben)}${unten ? `<small>${esc(unten)}</small>` : ""}</span>` +
(url ? `<svg class="chev" viewBox="0 0 6 10" aria-hidden="true"><polyline points="1,1 5,5 1,9"/></svg>` : "");
@@ -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.
@@ -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": [
@@ -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,
+38 -8
View File
@@ -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"))