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
@@ -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,