Fuenf gemeldete Bugs behoben: Haubenschloss/Tuerschloesser entfernt, Bestaetigungsdialog-Transparenz, geteilte FMM003-Koordinaten, Laedt-Haenger, generischer Beleg-Parser

Entfernt Haubenschloss-/Tuerschloss-Erkennung dauerhaft aus Setup-Katalog und
Sicherheitscheck (auf ausdruecklichen Wunsch, nicht nur leer/unzuordenbar wie
der Rest der abgeloesten VAG-Integration). Behebt eine durchscheinende
Bestaetigungs-Sheet ("Doppelt zugeordnet" u.a.) - derselbe --tile-2-
Transparenz-Fehler, der fuer das Setup-Popup schon behoben war, hier
nachgezogen. Ergaenzt einen zweiten GPS-Pfad (STANDORT_LAT_SENSOR/
STANDORT_LON_SENSOR) fuer Integrationen wie flespi, die Breiten-/Laengengrad
als zwei eigene Sensoren statt als device_tracker-Attribute liefern. Haertet
den bekannten "Laedt ..."-Haenger beim App-Start zusaetzlich ab: Tab-Klicks
pruefen jetzt aktiv nach, ob Daten inzwischen da sind. Ergaenzt
shell_beleg_parser.py um einen stationsunabhaengigen Fallback fuer
Tankbelege unbekannter Formate (Adresse/Gesamtbetrag/Menge/Rabatt-Herleitung
wie vom Nutzer vorgegeben) - die bestehende Shell-Erkennung bleibt
unveraendert und weiterhin durch die zehn echten Testbelege abgedeckt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:32:15 +02:00
parent 440ff22bda
commit 25d1cebd12
10 changed files with 287 additions and 78 deletions
+125 -2
View File
@@ -50,6 +50,7 @@ Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
"""
import datetime
import hashlib
import json
import re
import sys
@@ -198,6 +199,118 @@ def _parsen(text):
}
def _parsen_generisch(text):
"""Stationsunabhängiger Fallback für Belege, die keinem der oben fest an
Shells Beleg-Struktur verankerten Muster entsprechen (_parsen() wirft in
dem Fall ValueError - siehe main()). Arbeitet nach demselben Verfahren,
das an einem echten Beleg einer bislang unbekannten Tankstelle von Hand
nachvollzogen wurde: Adresse suchen, Gesamtbetrag suchen ("Gesamt.../
Absolut..."), Literangabe suchen ("Menge"/"Amount" oder eine an "l"/
"Liter" hängende Zahl), daraus den tatsächlich bezahlten Preis/Liter
errechnen (Gesamtbetrag / Liter). Steht daneben auch ein aufgedruckter
Preis/Liter auf dem Beleg und weicht er vom errechneten ab, wurde ein
Rabatt gewährt - auf dem Beleg meist als eigener Betrag mit einem Minus
davor erkennbar, danach wird zuerst gesucht, sonst aus der Preisdifferenz
hergeleitet. Ob ein so gefundener Rabatt als "SmartDeal" behandelt wird,
entscheidet einzig station_name (Shell-Logo/-Label im Frontend, siehe
frontend_veroeffentlichung.py) - kein Sonderfall hier nötig, die Frontend-
Anzeige hängt nur an einem gesetzten discount, nicht an einem eigenen
Kennzeichen.
Liefert deutlich weniger Felder als _parsen() (kein Bon-Kleingedrucktes
wie TSE-Nummer/Zahlungszeitstempel) - unproblematisch, denn
belegverarbeitung.py liest nur receipt_key als echtes Pflichtfeld,
alles andere per .get()."""
def suchen(muster, flags=re.IGNORECASE | re.MULTILINE):
return re.search(muster, text, flags)
# ---- Adresse: Name-/Straßen-/PLZ-Ort-Zeile hintereinander - wie bei
# Shell (_parsen() oben), aber ohne Ankerzeile "Obj.-Nr." danach, also
# direkt an der PLZ-Ort-Form (5 Ziffern + Text) erkannt. Nicht Pflicht.
adresse = suchen(r"([^\n]{2,60})\n([^\n]{2,60})\n(\d{5}\s+[^\n]{2,40})")
station_name = adresse.group(1).strip() if adresse else None
station_address = (
f"{adresse.group(2).strip()}, {adresse.group(3).strip()}" if adresse else None
)
# ---- Gesamtbetrag ------------------------------------------------------
gesamt = suchen(r"(?:Gesamt\w*|Absolut\w*|Endbetrag|Summe|Total)\D{0,20}?(" + _ZAHL + r")\s*(?:€|EUR)?")
if not gesamt:
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
fuel_total_eur = _de_zahl(gesamt.group(1))
# ---- Menge in Litern ----------------------------------------------------
menge = suchen(r"(?:Menge|Amount)\D{0,15}?(" + _ZAHL + r")\s*(?:l\b|L\b|Liter|Ltr\.?)")
if not menge:
menge = suchen(r"(" + _ZAHL + r")\s*(?:Liter|Ltr\.?|l)\b")
if not menge:
raise ValueError("Keine Literangabe gefunden (weder 'Menge'/'Amount' noch 'X l')")
liters = _de_zahl(menge.group(1))
if not liters:
raise ValueError("Literangabe ist 0 oder unlesbar")
berechneter_preis_l = round(fuel_total_eur / liters, 3)
# ---- Aufgedruckter Preis/Liter - Abweichung verrät einen Rabatt -------
aufgedruckt = suchen(
r"(?:Preis\s*/?\s*(?:je\s*)?Liter|Price\s*/?\s*L(?:iter)?|€\s*/\s*l|EUR\s*/\s*l)\D{0,10}?(" + _ZAHL + r")"
)
list_price_per_l = _de_zahl(aufgedruckt.group(1)) if aufgedruckt else berechneter_preis_l
discount = None
discount_per_l = None
if aufgedruckt and abs(list_price_per_l - berechneter_preis_l) > 0.005:
minus = suchen(r"-\s*(" + _ZAHL + r")\s*(?:€|EUR)")
if minus:
discount = _de_zahl(minus.group(1))
discount_per_l = round(discount / liters, 3)
else:
discount_per_l = round(list_price_per_l - berechneter_preis_l, 3)
discount = round(discount_per_l * liters, 2)
# ---- Zeitstempel: irgendein Datum+Uhrzeit auf dem Beleg ----------------
zeit = suchen(r"(\d{2})\.(\d{2})\.(\d{2,4})\D{0,10}?(\d{2}):(\d{2})")
ts = _iso(zeit.group(1), zeit.group(2), zeit.group(3), zeit.group(4), zeit.group(5)) if zeit else None
# ---- Belegnummer, sonst deterministischer Ersatzschlüssel --------------
# Wichtig: kein eingebautes hash() (pro Prozess zufällig gesalzen, siehe
# PYTHONHASHSEED) - der Parser läuft für jeden Beleg als eigener Prozess
# (Kopfkommentar oben), ein instabiler Schlüssel würde den §7.7-Regel-3-
# Dedup-Vergleich in belegverarbeitung.py unbrauchbar machen.
beleg_nr = suchen(
r"(?:Beleg-?Nr\.?|Bon-?Nr\.?|Rechnungs-?Nr\.?|Receipt\s*No\.?|Trans(?:aktions)?-?Nr\.?)\D{0,5}?([\dA-Za-z/\-]+)"
)
receipt_no = beleg_nr.group(1) if beleg_nr else None
schluessel_basis = "|".join(
str(teil) for teil in (receipt_no, ts, station_address, fuel_total_eur, liters) if teil
) or text[:80]
receipt_key = "generisch_" + hashlib.sha1(schluessel_basis.encode("utf-8")).hexdigest()[:16]
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": None,
"ts": ts,
"ts_payment": None,
"ts_tse": None,
"station_id": None,
"station_name": station_name,
"station_address": station_address,
"article_no": None,
"product_name": None,
"fuel_type": None,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": fuel_total_eur,
"net_eur": None,
"vat_eur": None,
}
def main():
if len(sys.argv) != 2:
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
@@ -205,10 +318,20 @@ def main():
pdf_pfad = sys.argv[1]
try:
text = _text_aus_pdf(pdf_pfad)
ergebnis = _parsen(text)
except Exception as exc:
print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr)
print(f"PDF konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
try:
ergebnis = _parsen(text)
except Exception:
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
# endgültig aufgegeben wird.
try:
ergebnis = _parsen_generisch(text)
except Exception as exc:
print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
print(json.dumps(ergebnis, ensure_ascii=False))
@@ -65,14 +65,10 @@ TUER_SENSOREN = []
# Fenster (§4.1) - keine Quelle mehr vorhanden.
FENSTER_SENSOREN = []
# Verriegelung - keine Quelle mehr vorhanden.
TUERSCHLOSS_SENSOREN = []
# Heckklappe und Motorhaube - keine Quelle mehr vorhanden.
HECKKLAPPE_SENSOR = ""
HECKKLAPPENSCHLOSS_SENSOR = ""
HAUBE_SENSOR = ""
HAUBENSCHLOSS_SENSOR = ""
# Vom Fahrzeug selbst gemeldete Service-Fälligkeit (ergänzt die App-eigene,
# aus dem Servicebuch berechnete Prognose) - keine Quelle mehr vorhanden.
@@ -87,6 +83,13 @@ INSPEKTION_STRECKE_SENSOR = ""
# vom FMM003 geliefert.
STANDORT_TRACKER = "device_tracker.testzone_fmm003"
# Alternative zu STANDORT_TRACKER: manche Integrationen (z. B. flespi) liefern
# Breiten-/Längengrad nicht als Attribute einer einzelnen device_tracker-
# Entity, sondern als zwei eigene sensor-Entities. _standort() nutzt diese
# beiden nur, wenn STANDORT_TRACKER leer ist oder keine Koordinaten liefert.
STANDORT_LAT_SENSOR = ""
STANDORT_LON_SENSOR = ""
# Update-Funktion (Einstellungen -> "Update suchen", siehe
# updateverwaltung.py): Git-Repository, in das dieses Projekt gepflegt wird -
# z. B. ein privates GitHub-Repo, genau wie für die HACS-Integration bereits
+8 -8
View File
@@ -81,10 +81,6 @@ FELDER = [
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["window"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["fenster", "window"]},
{"key": "TUERSCHLOSS_SENSOREN", "label": "Türschlösser", "gruppe": "sicherheit",
"hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt (device_class \"lock\"-Konvention).",
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": True, "positionen": POSITIONEN,
"pflicht": False, "stichworte": ["schloss", "lock", "verriegel", "entriegel"]},
{"key": "HECKKLAPPE_SENSOR", "label": "Heckklappe", "gruppe": "sicherheit",
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
@@ -97,10 +93,6 @@ FELDER = [
"hinweis": "\"aus\"/off = zu.",
"domains": ["binary_sensor"], "device_classes": ["door", "opening"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["haube", "hood", "bonnet", "motorhaube"]},
{"key": "HAUBENSCHLOSS_SENSOR", "label": "Haubenschloss", "gruppe": "sicherheit",
"hinweis": "\"on\" = entriegelt, \"aus\"/off = verriegelt.",
"domains": ["binary_sensor"], "device_classes": ["lock"], "units": [], "liste": False, "pflicht": False,
"stichworte": ["haube", "hood", "schloss", "lock"]},
{"key": "NAECHSTER_OELWECHSEL_SENSOR", "label": "Nächster Ölwechsel (Datum)", "gruppe": "uebersicht",
"hinweis": "Vom Fahrzeug selbst gemeldete Fälligkeit, ergänzt die App-eigene Servicebuch-Prognose.",
"domains": ["sensor"], "device_classes": ["date", "timestamp"], "units": [], "liste": False, "pflicht": False,
@@ -121,6 +113,14 @@ FELDER = [
"hinweis": "Braucht latitude/longitude-Attribute (HA-Konvention für device_tracker).",
"domains": ["device_tracker"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["standort", "gps", "tracker", "position", "ortung"]},
{"key": "STANDORT_LAT_SENSOR", "label": "GPS-Standort: Breitengrad", "gruppe": "standort",
"hinweis": "Nur nötig, falls die Integration Breiten-/Längengrad als zwei eigene Sensoren statt als Attribute einer GPS-Standort-Entity liefert (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["breitengrad", "latitude", "lat"]},
{"key": "STANDORT_LON_SENSOR", "label": "GPS-Standort: Längengrad", "gruppe": "standort",
"hinweis": "Nur nötig, falls die Integration Breiten-/Längengrad als zwei eigene Sensoren statt als Attribute einer GPS-Standort-Entity liefert (z. B. flespi).",
"domains": ["sensor"], "device_classes": [], "units": [], "liste": False, "pflicht": False,
"stichworte": ["längengrad", "longitude", "lon", "lng"]},
]
# Mehrere @state_trigger(f"...")-Dekoratoren (fahrterkennung.py,
@@ -88,37 +88,59 @@ def _abs_zahl(wert):
def _standort():
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel) - liest
latitude/longitude/gps_accuracy von der in STANDORT_TRACKER hinterlegten
device_tracker-Entity (HA-Konvention). Fehlt die Entity-ID oder ist sie
(noch) nicht verfügbar, liefert diese Funktion durchgehend None statt
eines geratenen Werts - genau das zustand_oder_none()-Muster von oben,
hier als eigene Funktion, weil zusätzlich zum Zustand noch Attribute und
der Aktualisierungszeitpunkt gebraucht werden: state.get() liefert dafür
ein StateVal (str-Unterklasse) mit .last_updated als datetime-Attribut -
kein eigenständiges state.last_updated(), das gibt es in pyscript nicht."""
entity_id = einstellungen.STANDORT_TRACKER
"""Live-GPS-Position des Fahrzeugs (Übersicht -> Standort-Kachel). Zwei
mögliche Quellen, in dieser Reihenfolge versucht: (1) STANDORT_TRACKER,
eine device_tracker-Entity mit latitude/longitude/gps_accuracy-Attributen
(HA-Konvention) - der Normalfall; (2) STANDORT_LAT_SENSOR/
STANDORT_LON_SENSOR, zwei eigene sensor-Entities für Breiten-/Längengrad -
manche Integrationen (z. B. flespi) liefern Koordinaten so, statt als
Attribute einer einzelnen Tracker-Entity. Fehlt beides oder ist (noch)
nicht verfügbar, liefert diese Funktion durchgehend None statt eines
geratenen Werts - genau das zustand_oder_none()-Muster von oben, hier als
eigene Funktion, weil zusätzlich zum Zustand noch Attribute und der
Aktualisierungszeitpunkt gebraucht werden: state.get() liefert dafür ein
StateVal (str-Unterklasse) mit .last_updated als datetime-Attribut - kein
eigenständiges state.last_updated(), das gibt es in pyscript nicht."""
leer = {"lat": None, "lon": None, "genauigkeit_m": None, "zeit": None}
if not entity_id:
return leer
try:
wert = state.get(entity_id)
attribute = state.getattr(entity_id)
except NameError:
return leer
if not attribute or attribute.get("latitude") is None:
return leer
zeit = None
try:
zeit = wert.last_updated.isoformat()
except AttributeError:
pass
return {
"lat": _zu_zahl(attribute.get("latitude")),
"lon": _zu_zahl(attribute.get("longitude")),
"genauigkeit_m": _zu_zahl(attribute.get("gps_accuracy")),
"zeit": zeit,
}
tracker_id = einstellungen.STANDORT_TRACKER
if tracker_id:
try:
wert = state.get(tracker_id)
attribute = state.getattr(tracker_id)
except NameError:
attribute = None
if attribute and attribute.get("latitude") is not None:
zeit = None
try:
zeit = wert.last_updated.isoformat()
except AttributeError:
pass
return {
"lat": _zu_zahl(attribute.get("latitude")),
"lon": _zu_zahl(attribute.get("longitude")),
"genauigkeit_m": _zu_zahl(attribute.get("gps_accuracy")),
"zeit": zeit,
}
lat_id, lon_id = einstellungen.STANDORT_LAT_SENSOR, einstellungen.STANDORT_LON_SENSOR
if lat_id and lon_id:
try:
lat_wert = state.get(lat_id)
lon_wert = state.get(lon_id)
except NameError:
return leer
lat, lon = _zu_zahl(lat_wert), _zu_zahl(lon_wert)
if lat is None or lon is None:
return leer
zeit = None
try:
zeit = lat_wert.last_updated.isoformat()
except AttributeError:
pass
return {"lat": lat, "lon": lon, "genauigkeit_m": None, "zeit": zeit}
return leer
POSITIONEN = ["vorne links", "vorne rechts", "hinten links", "hinten rechts"]
@@ -137,14 +159,10 @@ def _sicherheitscheck():
for pos, sensor in zip(POSITIONEN, einstellungen.FENSTER_SENSOREN):
w = zustand_oder_none(sensor)
eintraege.append({"label": f"Fenster {pos}", "ok": None if w is None else w == "off"})
for pos, sensor in zip(POSITIONEN, einstellungen.TUERSCHLOSS_SENSOREN):
w = zustand_oder_none(sensor)
eintraege.append({"label": f"Türschloss {pos}", "ok": None if w is None else w == "off"})
for label, sensor in [
("Heckklappe", einstellungen.HECKKLAPPE_SENSOR),
("Heckklappenschloss", einstellungen.HECKKLAPPENSCHLOSS_SENSOR),
("Motorhaube", einstellungen.HAUBE_SENSOR),
("Haubenschloss", einstellungen.HAUBENSCHLOSS_SENSOR),
]:
w = zustand_oder_none(sensor)
eintraege.append({"label": label, "ok": None if w is None else w == "off"})
+9
View File
@@ -2992,6 +2992,15 @@ function render() {
});
}
const go = (name, id) => {
// Bekanntes Race (siehe Kopfkommentar bei nachladeAnstossen): auf einem
// langsam startenden Backend kann die App auf "Lädt …" hängen bleiben,
// obwohl die Daten inzwischen längst da wären - der bislang einzige
// beobachtete Ausweg war ein Tabwechsel, der zufällig einen frischen
// datenLaden()-Durchlauf auslöste. Hier deshalb bei jedem Tab-/Menüklick
// aktiv nachschauen, statt nur auf den nächsten hass-Push zu warten -
// no-op, falls die Daten längst da sind (datenLaden() rendert dann
// ohnehin nichts doppelt Sichtbares).
if (!DATEN_GELADEN) datenLaden(false);
// Ein unbestätigter Servicebuch-Entwurf (siehe data-sbneu) verlässt die
// Anwendung nie ungespeichert - wird die Eintragsseite ohne Klick auf
// "Speichern" verlassen, verwerfen wir ihn hier statt ihn liegen zu lassen
@@ -1 +1 @@
{"version": 1786744000}
{"version": 1786752000}
+10 -2
View File
@@ -853,7 +853,15 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
@keyframes sheet-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes sheet-rein { from { transform: translateY(18px); opacity: 0 } to { transform: none; opacity: 1 } }
.sheet-gruppe {
background: var(--tile-2);
/* --tile-2 is translucent in the iOS overlay's night theme (rgba white,
meant for tile-on-tile layering) - a confirm sheet sits directly over
whatever page content triggered it, so a see-through card made titles
like "Doppelt zugeordnet" hard to read against the busy background
bleeding through. Same fix already applied to .setup-popup/.standortmenu
for the same reason - opaque surface token, with the --canvas fallback
this file already uses elsewhere for when the (purely additive, in
principle removable) overlay isn't loaded. */
background: var(--tile-deckend, var(--canvas));
border-radius: 16px;
overflow: hidden;
display: flex; flex-direction: column;
@@ -877,7 +885,7 @@ button.tile, .tilebtn { transition: background .15s, transform .1s; }
.sheet-gruppe button:active { background: var(--shade); }
.sheet-gruppe button.loeschen { color: var(--bad); }
.sheet-abbrechen {
background: var(--tile-2); border: none; border-radius: 16px;
background: var(--tile-deckend, var(--canvas)); border: none; border-radius: 16px;
font-family: inherit; font-size: 16px; color: var(--fg);
padding: 16px; min-height: 52px; cursor: pointer;
}