"""Belegverarbeitung für Shell-Tankbelege (§7.7, §10 Punkt 7). Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff. shell_beleg_parser.py, im Lastenheft (§2) als mitgeliefert genannt, lag beim Bau nicht im Projektordner vor (siehe Ersteinschätzung) und wurde nachträglich unter audi_dashboard/shell_beleg_parser.py gegen einen echten Shell-eReceipt neu geschrieben (siehe dessen Kopfkommentar). Aufruf über eine Kommandozeilen-Schnittstelle: `python3 shell_beleg_parser.py `, Ausgabe als JSON auf stdout mit den Feldern aus §6.4 (receipt_key, ts, station_name, liters, fuel_total_eur, discount, ...). Muss unter /config/audi_dashboard/shell_beleg_parser.py liegen (siehe INSTALL.md) - eigener Prozess mit dem System-python3 des Containers, nicht die pyscript-Sandbox, deshalb dort einmalig `pip install pypdf` nötig. ABWEICHUNG VOM URSPRÜNGLICHEN LASTENHEFT (Frontend-Anpassung, nach dem ursprünglichen Bau nachträglich verlangt): - Volltankung (§7.4) ist entfernt - weder Berechnung noch Anzeige. Damit entfallen auch level_before_pct/level_after_pct, die nur dafür gebraucht wurden. - fuel_total_eur ist immer der reale, bereits rabattierte Betrag. Die SmartDeal-Ersparnis steht separat in discount. price_per_l wird grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie aus einem vom Beleg übernommenen Wert übernommen - siehe Kopfkommentar in audi-dashboard-app.js (tankFelder()) für dieselbe Regel im Frontend. Entity-IDs stehen zentral in pyscript/modules/einstellungen.py. """ import base64 import datetime import io import json import os import subprocess import einstellungen import frontend_veroeffentlichung import profil PARSER_SKRIPT = "/config/audi_dashboard/shell_beleg_parser.py" # --------------------------------------------------------------- Hilfsfunktionen def _pdf_speichern(inhalt_bytes, dateiname): # io.open über task.executor, kein with, kein bloßes open() - siehe # Kopfkommentar in profil.py für alle drei Gründe. os.makedirs(profil.BELEGE_ORDNER, exist_ok=True) pfad = f"{profil.BELEGE_ORDNER}/{dateiname}" f = task.executor(io.open, pfad, "wb") f.write(inhalt_bytes) f.close() return pfad def _parser_aufrufen(pdf_pfad): ergebnis = subprocess.run( ["python3", PARSER_SKRIPT, pdf_pfad], capture_output=True, text=True, timeout=30, ) if ergebnis.returncode != 0: raise RuntimeError(f"shell_beleg_parser.py fehlgeschlagen: {ergebnis.stderr}") return json.loads(ergebnis.stdout) def _preis_je_liter(fuel_total_eur, liters): if not liters: return None return round(fuel_total_eur / liters, 3) # -------------------------------------------------------------------- Service @service def audi_dashboard_beleg_hochladen(pdf_base64=None, dateiname=None, tank_id=None): """Nimmt einen per Base64 codierten PDF-Beleg entgegen (Upload aus der Oberfläche über hass.callService). Mit tank_id: trägt die Belegdaten direkt in einen bereits bestehenden Tankvorgang nach - egal ob der ursprünglich manuell oder automatisch angelegt wurde. Der Kilometerstand/die gefahrene Distanz dieses Tankvorgangs bleiben dabei unangetastet (die kommen weiterhin vom Fahrzeug, nie vom Beleg, §7.7 Regel 2), nur die Beleg-Felder (Liter, Kosten, Ersparnis, Station, Zeitstempel, ...) werden ergänzt beziehungsweise überschrieben. Passt das Belegdatum nicht zum bereits erfassten Tankvorgang (unterschiedlicher Kalendertag), wird nichts übernommen - stattdessen ein Fehler veröffentlicht, damit in der Oberfläche nicht versehentlich ein falscher Beleg einem bestehenden Eintrag zugeordnet wird. Ohne tank_id: legt KEINEN eigenständigen Tankvorgang mehr an (frühere Abweichung vom Lastenheft, auf Wunsch entfernt), sondern parst den Beleg nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte in den gerade offenen Entwurf (neuer oder bearbeiteter Tankvorgang) und speichert erst auf einen expliziten Klick auf "Speichern". Aufruf als pyscript.audi_dashboard_beleg_hochladen.""" if not pdf_base64: log.warning("audi_dashboard: kein pdf_base64 übergeben") return # task.executor entfällt hier bewusst: _pdf_speichern/_parser_aufrufen sind # in dieser pyscript-Datei definiert, task.executor akzeptiert aber nur # echte externe Python-Funktionen (siehe Kopfkommentar in profil.py). inhalt_bytes = base64.b64decode(pdf_base64) dateiname = dateiname or f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf" pdf_pfad = _pdf_speichern(inhalt_bytes, dateiname) try: geparst = _parser_aufrufen(pdf_pfad) except Exception as exc: log.error(f"audi_dashboard: Beleg konnte nicht geparst werden: {exc}") frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}) return receipt_key = geparst.get("receipt_key") if not receipt_key: log.error("audi_dashboard: Parser lieferte keinen receipt_key") frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}) return # §7.7 Regel 3: derselbe Beleg (receipt_key, minutengenau) erzeugt # keinen zweiten Datensatz vorhandener_beleg = profil.tankvorgang_nach_receipt_key(receipt_key) if vorhandener_beleg is not None and vorhandener_beleg.get("tank_id") != tank_id: log.info(f"audi_dashboard: Beleg {receipt_key} bereits vorhanden, übersprungen") frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Beleg bereits erfasst", "tank_id": tank_id}) return liters = geparst.get("liters") fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert beleg_felder = { "receipt_key": receipt_key, "receipt_no": geparst.get("receipt_no"), "tse_beleg_nr": geparst.get("tse_beleg_nr"), "ts": geparst.get("ts"), "ts_payment": geparst.get("ts_payment"), "ts_tse": geparst.get("ts_tse"), "station_id": geparst.get("station_id"), "station_name": geparst.get("station_name"), "station_address": geparst.get("station_address"), "article_no": geparst.get("article_no"), "product_name": geparst.get("product_name"), "fuel_type": geparst.get("fuel_type"), # Liter, Kosten und Ersparnis kommen vom Beleg (§7.7 Regel 2). # price_per_l wird nie vom Beleg übernommen, sondern immer berechnet. "liters": liters, "fuel_total_eur": fuel_total_eur, "price_per_l": _preis_je_liter(fuel_total_eur, liters), "discount": geparst.get("discount"), # SmartDeal-Ersparnis "discount_per_l": geparst.get("discount_per_l"), "list_price_per_l": geparst.get("list_price_per_l"), "receipt_total_eur": geparst.get("receipt_total_eur"), "net_eur": geparst.get("net_eur"), "vat_eur": geparst.get("vat_eur"), "receipt_file": pdf_pfad, } if tank_id: vorhanden = profil.tankvorgang_nach_id(tank_id) if not vorhanden: log.warning(f"audi_dashboard: Tankvorgang {tank_id} für Beleg-Nachtrag nicht gefunden") frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}) return vorhandenes_datum = (vorhanden.get("ts") or "")[:10] belegdatum = (geparst.get("ts") or "")[:10] if vorhandenes_datum and belegdatum and vorhandenes_datum != belegdatum: log.warning(f"audi_dashboard: Belegdatum {belegdatum} passt nicht zu Tankvorgang {tank_id} (Datum {vorhandenes_datum})") frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Falsches Belegdatum", "tank_id": tank_id}) return aenderungen = dict(beleg_felder) aenderungen["status"] = "vollständig" if profil.tankvorgang_aktualisieren(tank_id, aenderungen): frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id}) log.info(f"audi_dashboard: Beleg {receipt_key} in Tankvorgang {tank_id} nachgetragen") else: frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}) return # Kein tank_id: nur parsen und dem gerade offenen Entwurf in der # Oberfläche zum Ausfüllen bereitstellen - legt selbst KEINEN # Tankvorgang an (siehe Kopfkommentar oben). frontend_veroeffentlichung.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None}) log.info(f"audi_dashboard: Beleg {receipt_key} geparst, wartet auf Speichern in der Oberfläche") @service def audi_dashboard_tankvorgang_manuell(ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None): """Erfassung eines Tankvorgangs aus dem Formular "Neuer Tankvorgang" - Fallback aus §7.7 / §10 Punkt 7, unter „Tanken" vorgesehen (§5.5). Dieselben Felder wie beim Bearbeiten eines bestehenden Vorgangs (siehe audi_dashboard_tankvorgang_aktualisieren). receipt_key/receipt_file sind optional: kommen sie mit (weil zuvor ein Beleg über audi_dashboard_beleg_hochladen ohne tank_id in den Entwurf übernommen wurde), zählt der Tankvorgang als per Beleg erfasst, sonst als manuell. Aufruf als pyscript.audi_dashboard_tankvorgang_manuell.""" if liter is None or kosten is None: log.warning("audi_dashboard: liter und kosten sind Pflichtfelder") return odometer_km = km if km is not None else frontend_veroeffentlichung.zustand_oder_none(einstellungen.KM_SENSOR) tankvorgang = { "tank_id": profil.neue_id("f"), "receipt_key": receipt_key, "ts": ts or datetime.datetime.now(datetime.timezone.utc).isoformat(), "liters": liter, "fuel_total_eur": kosten, "price_per_l": _preis_je_liter(kosten, liter), "discount": ersparnis, "station_name": station, "odometer_km": odometer_km, # Vorschlag aus der Differenz zum letzten Tankvorgang, aber wie # odometer_km selbst frei überschreibbar - deshalb nimmt ein manuell # übergebener Wert immer Vorrang vor der Berechnung. "distance_km": distanz if distanz is not None else profil.distanz_seit_letzter_tankung(odometer_km), "fuel_type": kraftstoff, "source": "beleg" if receipt_key else "manual", "status": "vollständig", "receipt_file": receipt_file, "edited_fields": [], } profil.tankvorgang_anhaengen(tankvorgang) frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() log.info(f"audi_dashboard: Tankvorgang {tankvorgang['tank_id']} angelegt") @service def audi_dashboard_tankvorgang_aktualisieren(tank_id=None, ts=None, liter=None, kosten=None, km=None, ersparnis=None, station=None, distanz=None, kraftstoff=None, receipt_key=None, receipt_file=None): """Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob ursprünglich per Beleg oder manuell angelegt). receipt_key/receipt_file sind optional: kommen sie mit (ein im Bearbeiten-Formular hochgeladener Beleg wurde in den Entwurf übernommen), wird der Tankvorgang zusätzlich als per Beleg erfasst markiert. Aufruf als pyscript.audi_dashboard_tankvorgang_aktualisieren.""" if not tank_id: log.warning("audi_dashboard: tankvorgang_aktualisieren ohne tank_id aufgerufen") return aenderungen = { "ts": ts, "liters": liter, "fuel_total_eur": kosten, "price_per_l": _preis_je_liter(kosten, liter), "odometer_km": km, "discount": ersparnis, "station_name": station, "distance_km": distanz, "fuel_type": kraftstoff, } if receipt_key: aenderungen["receipt_key"] = receipt_key aenderungen["receipt_file"] = receipt_file aenderungen["source"] = "beleg" if profil.tankvorgang_aktualisieren(tank_id, aenderungen): frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() log.info(f"audi_dashboard: Tankvorgang {tank_id} aktualisiert") else: log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden") @service def audi_dashboard_tankvorgang_loeschen(tank_id=None): """Löscht einen Tankvorgang unwiderruflich (Löschfunktion in der Oberfläche). Aufruf als pyscript.audi_dashboard_tankvorgang_loeschen.""" if not tank_id: log.warning("audi_dashboard: tankvorgang_loeschen ohne tank_id aufgerufen") return if profil.tankvorgang_loeschen(tank_id): frontend_veroeffentlichung.tankvorgaenge_veroeffentlichen() log.info(f"audi_dashboard: Tankvorgang {tank_id} gelöscht") else: log.warning(f"audi_dashboard: Tankvorgang {tank_id} nicht gefunden")