39da68cd87
Leaflet's stylesheet was appended to document.head, so it never reached the
panel's shadow root: .leaflet-tile{position:absolute} never applied, tiles laid
out as in-flow images (~1040px inside a 190px box) and the marker pane ended up
far below the visible area. That is the real cause of the long-standing
"fragmented Leaflet rendering" finding and of the invisible vehicle pin - every
tile request had actually succeeded. The stylesheet now goes into the shadow
root and is awaited before the map is built.
Also in this round:
- Map tiles are always light (Google-Maps-style), no dark variant at night.
- Vehicle marker uses the real CI poi-car icons (poi-car-l >=34px, poi-car-s
below), with a white halo so the outline stays readable on tiles.
- Removed the obsolete combined STANDORT_TRACKER field; only the split
lat/lon sensors remain.
- Receipt upload: widened the try block so base64/save failures surface, and
the frontend call site now reports a rejected service call.
- Generic receipt parser: total detection is line-based (letter-spaced
headings, no more matching the SUMME-EUR column header, tax lines excluded),
address heuristic handles 4-digit postcodes and single-line address blocks,
and "Preis/L" matches without a spelled-out "Liter".
- Design backlog: red hairline frame on list rows (delete button bled through
at fractional row heights) and select fields now use the grey background box.
Shell 10-receipt regression suite still passes; all changes verified live in
the audi_ha_test container.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
13 KiB
Python
276 lines
13 KiB
Python
"""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
|
|
<pfad-zur-pdf>`, 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).
|
|
#
|
|
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
|
|
# Exception hier (kaputtes Base64, volle Festplatte, ...) wäre für die
|
|
# Oberfläche unsichtbar geblieben - serviceRufen() im Frontend loggt einen
|
|
# rejizierten Service-Aufruf nur in die Browser-Konsole, zeigt aber nichts
|
|
# an (siehe audi-dashboard-app.js). Bisher war nur der Parser-Aufruf
|
|
# selbst so abgesichert; b64decode/_pdf_speichern fehlten.
|
|
try:
|
|
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)
|
|
geparst = _parser_aufrufen(pdf_pfad)
|
|
except Exception as exc:
|
|
log.error(f"audi_dashboard: Beleg konnte nicht verarbeitet 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")
|