pyscript-Backend zur echten HA-Integration umgebaut (HACS-fähig)
Das Backend liegt jetzt als custom_components/audi_dashboard/ vor - eine normale Home-Assistant-Integration mit Config-Flow, einer sensor-Plattform und 18 Diensten. Damit ist die App über HACS installierbar; bis das Repo auf GitHub gespiegelt ist (HACS spricht ausschließlich mit GitHub), installiert homeassistant/installationspaket/install.ps1 denselben Ordner ohne HACS. Fünf Installationsschritte entfallen ersatzlos: der pyscript:-Block, der panel_custom:-Block, das Kopieren der Oberfläche nach www/, das langlebige Zugriffstoken (der Verlauf wird direkt über die recorder-API gelesen) und "pip install pypdf" (steht in manifest.json). Das Fahrzeugprofil legt die Integration beim ersten Start aus ihrer Vorlage an. Drei alte Schwächen sind dabei mit erledigt: - Die Nutzlast landet nicht mehr in der Recorder-Datenbank (_unrecorded_attributes - das kann nur eine echte Entität). - Eine laufende Fahrt überlebt einen Neustart (Store statt Arbeitsspeicher); fiel sie während eines Ausfalls ins Ende, schließt nach_neustart_fortsetzen() sie beim letzten aufgezeichneten Zeitpunkt. - Sensor-Zuordnungen wirken sofort - die Zustandsbeobachter werden neu gebunden, der Neustart-Hinweis und der Neustart-Dienst sind weg. Namensvertrag geändert, beide Oberflächen mitgezogen: pyscript.audi_dashboard_x -> sensor.audi_dashboard_x, pyscript.audi_dashboard_y -> audi_dashboard.y. Eine Companion-App vom alten Stand findet nach dem Umstieg nichts mehr und muss neu gebaut werden; das Panel liegt in der Integration und kann nicht driften. Der selbstgebaute Updater entfällt - HACS ist die Update-Mechanik, die Home Assistant kennt. Die Versionierung schrumpft auf eine Quelle: manifest.json. Geprüft am laufenden Testcontainer (Container byteweise identisch mit dem Repo): alle 18 Dienste, Panel, Config-Entry neu laden, Historienimport, echter Shell-Beleg in-process, Neuinstallation im Wegwerf-Container blank mit automatisch nachinstalliertem pypdf. Companion-App: tsc sauber, 112/112 Tests, beide Rauchtests gegen das laufende Backend grün. Belegparser 8/8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py.
|
||||
|
||||
Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in
|
||||
pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken").
|
||||
Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren
|
||||
tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine
|
||||
angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier
|
||||
Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3
|
||||
tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege
|
||||
unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs.
|
||||
"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung
|
||||
("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die
|
||||
Stations- und Rabatterkennung unten bewusst nicht an deren genauen
|
||||
Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr.,
|
||||
Betragsmuster vor "EUR #A").
|
||||
|
||||
Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales
|
||||
Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über
|
||||
`python3 shell_beleg_parser.py <pfad>` (system-python3 im Container, nicht
|
||||
die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py),
|
||||
deshalb reicht `pip install pypdf` einmalig im selben Python, das
|
||||
`python3` in configuration.yaml/Docker auch sonst benutzt.
|
||||
|
||||
Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen:
|
||||
Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout
|
||||
bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg
|
||||
(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht).
|
||||
|
||||
Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
|
||||
SHELL STATION
|
||||
<Betreibername, z.B. "A. Zrenner GmbH">
|
||||
<Straße>
|
||||
<PLZ Ort>
|
||||
Obj.-Nr.: <Stations-ID> Tel. ...
|
||||
Beleg-Nr. <receipt_no> <TT.MM.JJ> <HH:MM>
|
||||
*<article_no> <Produktname> <Listenbetrag> EUR #A*
|
||||
*Zp <Zapfsäule> <Liter> l <Listenpreis/l> EUR/l # *
|
||||
[<Rabattname> -<Rabattbetrag> EUR #A -- optional, nur mit SmartDeal
|
||||
<Rabatt/l> EUR/Liter -- optional]
|
||||
Gesamtbetrag <Endbetrag> EUR
|
||||
Typ Netto Mwst Brutto
|
||||
A:<Mwst-Satz>% <Netto> <Mwst> <Brutto>
|
||||
...
|
||||
Mobile Payment
|
||||
<TT.MM.JJJJ> <HH:MM:SS> -- Zahlungszeitstempel
|
||||
...
|
||||
(Seite 2) POS:...-...-... TSE-Beleg-Nr: <tse_beleg_nr>
|
||||
Start/Ende: <TT.MM.JJJJ HH:MM:SS> / ...
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
try:
|
||||
import pypdf
|
||||
except ImportError:
|
||||
print(
|
||||
"pypdf fehlt. Einmalig installieren mit: pip install pypdf "
|
||||
"(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _text_aus_pdf(pdf_pfad):
|
||||
leser = pypdf.PdfReader(pdf_pfad)
|
||||
return "\n".join(seite.extract_text() or "" for seite in leser.pages)
|
||||
|
||||
|
||||
def _de_zahl(text):
|
||||
""""103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei
|
||||
Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte
|
||||
vor dem Umwandeln entfernen, nicht nur das Komma ersetzen."""
|
||||
if text is None:
|
||||
return None
|
||||
return float(text.strip().replace(".", "").replace(",", "."))
|
||||
|
||||
|
||||
def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
|
||||
jahr = int(jahr)
|
||||
if jahr < 100:
|
||||
jahr += 2000
|
||||
return datetime.datetime(
|
||||
jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde)
|
||||
).isoformat()
|
||||
|
||||
|
||||
_ZAHL = r"[\d.,]+"
|
||||
|
||||
# Echte Geldangabe: Nachkommastellen sind Pflicht, damit im stationsunabhängigen
|
||||
# Parser (_parsen_generisch) keine Artikel-/Belegnummer als Betrag durchgeht.
|
||||
_GELD = r"\d{1,3}(?:[ .]\d{3})*,\d{2}"
|
||||
_GESAMT_WOERTER = ("GESAMT", "ABSOLUT", "ENDBETRAG", "ZUZAHLEN", "TOTAL", "SUMME")
|
||||
# Steuer- und Nettozeilen tragen dieselben Schlüsselwörter ("MWST GESAMT"),
|
||||
# nennen aber nie den bezahlten Rechnungsbetrag.
|
||||
_STEUER_WOERTER = ("MWST", "UST", "VAT", "STEUER", "NETTO")
|
||||
|
||||
# Kraftstoffmarken, wie sie im Belegkopf stehen. Bewusst nur dort gesucht (die
|
||||
# ersten Zeilen), nicht im ganzen Text: "Total" wäre sonst z. B. auch als
|
||||
# Summenzeile ein Treffer. Längere Namen zuerst, damit "TotalEnergies" nicht
|
||||
# als "Total" endet. Die Schreibweise hier ist die, die angezeigt wird - auf
|
||||
# Bons steht die Marke meist in Großbuchstaben ("SHELL STATION").
|
||||
_MARKEN = (
|
||||
"TotalEnergies", "Total", "Aral", "Shell", "Eni", "Agip", "Esso", "OMV",
|
||||
"Avia", "Turmöl", "Turmoel", "Orlen", "Tamoil", "Westfalen", "Allguth",
|
||||
"Classic", "Sprint", "Raiffeisen", "BayWa", "Elan", "HEM", "Star", "JET",
|
||||
"bft", "BP", "Q1",
|
||||
)
|
||||
_STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)"
|
||||
|
||||
|
||||
def _marke(zeilen):
|
||||
"""Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn
|
||||
keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""
|
||||
kopf = "\n".join(zeilen[:8])
|
||||
for marke in _MARKEN:
|
||||
if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):
|
||||
return marke
|
||||
return None
|
||||
|
||||
|
||||
def _ist_strasse(zeile):
|
||||
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
|
||||
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
|
||||
if not zeile:
|
||||
return False
|
||||
return bool(re.search(r"\d", zeile) or re.search(_STRASSE_WOERTER, zeile, re.IGNORECASE))
|
||||
|
||||
|
||||
def _tankstelle(marke, strasse, ort, ersatzname=None):
|
||||
"""Anzeigename der Tankstelle im Format "Marke, Straße, Ort" (§ Anzeige im
|
||||
Frontend, Feld station_name). Fehlende Teile fallen weg statt als leere
|
||||
Kommastelle stehen zu bleiben - nicht jeder Beleg nennt eine Straße. Ohne
|
||||
erkennbare Marke tritt der Betreibername an ihre Stelle."""
|
||||
teile = [t for t in (marke or ersatzname, strasse, ort) if t]
|
||||
return ", ".join(teile) if teile else None
|
||||
|
||||
|
||||
def _parsen(text):
|
||||
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
|
||||
treffer = re.search(muster, text, flags)
|
||||
if treffer is None and pflicht:
|
||||
raise ValueError(f"Muster nicht gefunden: {muster!r}")
|
||||
return treffer
|
||||
|
||||
# ---- Station ---------------------------------------------------------
|
||||
# Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL
|
||||
# STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen,
|
||||
# offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben).
|
||||
# Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die
|
||||
# Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten
|
||||
# Belegen gleich.
|
||||
station = suchen(
|
||||
r"(?P<name>.+?)\s*\n\s*(?P<strasse>.+?)\s*\n\s*(?P<ort>.+?)\s*\n\s*Obj\.-Nr\."
|
||||
)
|
||||
station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1)
|
||||
|
||||
# ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ----------
|
||||
kopf = suchen(
|
||||
r"Beleg-Nr\.\s*(?P<receipt_no>[\d/]+)\s+(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{2})\s+(?P<stunde>\d{2}):(?P<minute>\d{2})"
|
||||
)
|
||||
receipt_no = kopf.group("receipt_no")
|
||||
ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"),
|
||||
kopf.group("stunde"), kopf.group("minute"))
|
||||
|
||||
# ---- Kraftstoff-Position --------------------------------------------
|
||||
position = suchen(
|
||||
r"\*(?P<article_no>\d+)\s+(?P<produkt>.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*"
|
||||
)
|
||||
article_no = position.group("article_no")
|
||||
product_name = position.group("produkt").strip()
|
||||
|
||||
zapfsaeule = suchen(
|
||||
r"\*Zp\s*\d+\s+(?P<liter>" + _ZAHL + r")\s*l\s+(?P<preis>" + _ZAHL + r")\s*EUR/l"
|
||||
)
|
||||
liters = _de_zahl(zapfsaeule.group("liter"))
|
||||
list_price_per_l = _de_zahl(zapfsaeule.group("preis"))
|
||||
|
||||
# ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) --
|
||||
rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False)
|
||||
discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None
|
||||
rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False)
|
||||
discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None
|
||||
|
||||
# ---- Beträge ----------------------------------------------------------
|
||||
fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1))
|
||||
steuerzeile = suchen(
|
||||
r"A:" + _ZAHL + r"%\s+(?P<netto>" + _ZAHL + r")\s+(?P<mwst>" + _ZAHL + r")\s+(?P<brutto>" + _ZAHL + r")"
|
||||
)
|
||||
net_eur = _de_zahl(steuerzeile.group("netto"))
|
||||
vat_eur = _de_zahl(steuerzeile.group("mwst"))
|
||||
receipt_total_eur = _de_zahl(steuerzeile.group("brutto"))
|
||||
|
||||
# ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes
|
||||
# Zahlungsverfahren auf dem Beleg steht ---------------------------------
|
||||
zahlung = suchen(
|
||||
r"Mobile Payment\s*\n+\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
|
||||
pflicht=False,
|
||||
)
|
||||
ts_payment = (
|
||||
_iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"),
|
||||
zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde"))
|
||||
if zahlung else None
|
||||
)
|
||||
|
||||
# ---- TSE-Signatur (Seite 2) - optional -------------------------------
|
||||
tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False)
|
||||
tse_beleg_nr = tse_nr.group(1) if tse_nr else None
|
||||
tse_zeit = suchen(
|
||||
r"Start/Ende:\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
|
||||
pflicht=False,
|
||||
)
|
||||
ts_tse = (
|
||||
_iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"),
|
||||
tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde"))
|
||||
if tse_zeit else None
|
||||
)
|
||||
|
||||
# receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3)
|
||||
receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}"
|
||||
|
||||
return {
|
||||
"receipt_key": receipt_key,
|
||||
"receipt_no": receipt_no,
|
||||
"tse_beleg_nr": tse_beleg_nr,
|
||||
"ts": ts,
|
||||
"ts_payment": ts_payment,
|
||||
"ts_tse": ts_tse,
|
||||
"station_id": station_id,
|
||||
# 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.
|
||||
"station_name": _tankstelle(
|
||||
_marke(text.splitlines()),
|
||||
station.group("strasse").strip(),
|
||||
re.sub(r"^\d{4,5}\s+", "", station.group("ort").strip()),
|
||||
ersatzname=station.group("name").strip(),
|
||||
),
|
||||
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
|
||||
"article_no": article_no,
|
||||
"product_name": product_name,
|
||||
"fuel_type": product_name,
|
||||
"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": receipt_total_eur,
|
||||
"net_eur": net_eur,
|
||||
"vat_eur": vat_eur,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
zeilen = [z.rstrip() for z in text.splitlines()]
|
||||
|
||||
# ---- Adresse -----------------------------------------------------------
|
||||
# Die PLZ-Ort-Angabe steht nicht zuverlässig auf einer eigenen Zeile (ein
|
||||
# österreichischer Beleg führt sie als "SHELL TANKSTELLE, 6450 SÖLDEN"
|
||||
# zusammen mit der Filialbezeichnung) und PLZ sind vier- *oder*
|
||||
# fünfstellig (AT/DE). Deshalb nicht mehr drei feste Zeilen hintereinander
|
||||
# verlangen, sondern im Belegkopf die erste Zeile mit PLZ+Ort suchen; der
|
||||
# Name ist die erste Textzeile darüber.
|
||||
plz_treffer = None
|
||||
plz_zeile = None
|
||||
for i, zeile in enumerate(zeilen[:12]):
|
||||
treffer = re.search(r"\b\d{4,5}\s+([A-Za-zÄÖÜäöüß][^\n]*)$", zeile)
|
||||
if treffer:
|
||||
plz_treffer, plz_zeile = treffer, i
|
||||
break
|
||||
|
||||
ort = None
|
||||
strasse = None
|
||||
betreiber = None
|
||||
station_address = None
|
||||
if plz_treffer is not None:
|
||||
ort = plz_treffer.group(1).strip(" ,.-")
|
||||
station_address = zeilen[plz_zeile].strip()
|
||||
# Die Straße steht entweder vor der PLZ auf derselben Zeile
|
||||
# ("Musterstr. 5, 80331 München") oder auf der Zeile darüber.
|
||||
vor_plz = zeilen[plz_zeile][: plz_treffer.start()].strip(" ,.-")
|
||||
if _ist_strasse(vor_plz):
|
||||
strasse = vor_plz
|
||||
for zeile in reversed(zeilen[:plz_zeile]):
|
||||
if not zeile.strip():
|
||||
continue
|
||||
if strasse is None and _ist_strasse(zeile.strip()):
|
||||
strasse = zeile.strip()
|
||||
continue
|
||||
betreiber = zeile.strip()
|
||||
break
|
||||
if betreiber is None:
|
||||
for zeile in zeilen[:5]:
|
||||
if zeile.strip():
|
||||
betreiber = zeile.strip()
|
||||
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)
|
||||
|
||||
# ---- Gesamtbetrag ------------------------------------------------------
|
||||
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
|
||||
# einem echten Beleg aufgefallen sind: Bondrucker sperren Überschriften
|
||||
# gern buchstabenweise ("G E S A M T BETRAG EUR: 92,60"), und ein frei
|
||||
# laufendes Muster fand vorher die Spaltenüberschrift "SUMME-EUR" und
|
||||
# las die Artikelnummer der Folgezeile als Betrag. Deshalb: Schlüsselwort
|
||||
# auf der leerzeichenfreien Zeile prüfen, Betrag nur als echte Geldangabe
|
||||
# (mit Nachkommastellen) akzeptieren und Steuerzeilen ausschließen.
|
||||
kandidaten = []
|
||||
for zeile in zeilen:
|
||||
kompakt = re.sub(r"\s+", "", zeile).upper()
|
||||
if not any(wort in kompakt for wort in _GESAMT_WOERTER):
|
||||
continue
|
||||
if any(wort in kompakt for wort in _STEUER_WOERTER):
|
||||
continue
|
||||
betraege = re.findall(_GELD, zeile)
|
||||
if betraege:
|
||||
kandidaten.append(_de_zahl(betraege[-1]))
|
||||
if not kandidaten:
|
||||
raise ValueError("Kein Gesamtbetrag gefunden (weder 'Gesamt' noch 'Absolut')")
|
||||
# Mehrere Treffer (z. B. Summenzeile und Kartenzahlungszeile): der
|
||||
# Rechnungsbetrag ist der größte - Teilbeträge sind nie größer.
|
||||
fuel_total_eur = max(kandidaten)
|
||||
|
||||
# ---- 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 -------
|
||||
# "Preis/L" (ohne ausgeschriebenes "Liter") kommt auf echten Belegen vor,
|
||||
# deshalb ist alles nach dem L optional.
|
||||
aufgedruckt = suchen(
|
||||
r"(?:Preis\s*/?\s*(?:je\s*)?L(?:iter|tr\.?)?|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 beleg_lesen(pdf_pfad):
|
||||
"""Liest einen Tankbeleg und gibt die Felder als dict zurück.
|
||||
|
||||
Der eine Einstiegspunkt für Aufrufer im selben Prozess. Die Integration
|
||||
(belege.py) ruft genau diese Funktion im Executor auf - früher lief der
|
||||
Parser als eigener Unterprozess über main() unten, weil pyscript keine
|
||||
fremden Pakete laden konnte und pypdf deshalb von Hand ins Container-
|
||||
Python installiert werden musste. Als echte Integration steht pypdf über
|
||||
manifest.json bereit; der Unterprozess und der Handinstallations-Schritt
|
||||
entfallen damit ersatzlos.
|
||||
|
||||
Wirft eine Exception, wenn weder das Shell-Format noch der
|
||||
stationsunabhängige Rückfall greifen - der Aufrufer entscheidet, was er
|
||||
dem Nutzer davon zeigt."""
|
||||
text = _text_aus_pdf(pdf_pfad)
|
||||
try:
|
||||
return _parsen(text)
|
||||
except Exception:
|
||||
# Kein Shell-Beleg im bekannten Format - Versuch mit dem
|
||||
# stationsunabhängigen Fallback (_parsen_generisch() oben), bevor
|
||||
# endgültig aufgegeben wird.
|
||||
return _parsen_generisch(text)
|
||||
|
||||
|
||||
def main():
|
||||
"""Kommandozeilen-Aufruf, weiterhin nutzbar zum Nachschauen von Hand:
|
||||
`python3 shell_beleg_parser.py <pfad-zur-pdf>`."""
|
||||
if len(sys.argv) != 2:
|
||||
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
ergebnis = beleg_lesen(sys.argv[1])
|
||||
except Exception as exc:
|
||||
print(f"Beleg konnte nicht gelesen werden ({type(exc).__name__}): {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(json.dumps(ergebnis, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user