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
+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".