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:
@@ -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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user