Files
audi-app/custom_components/audi_dashboard/belege.py
T
tobias bfb7dec1e9 TANK_DISTANZ_SENSOR + Datensatz sichern/laden mit echtem CSV-Import (Panel)
- Neuer optionaler Sensor TANK_DISTANZ_SENSOR ersetzt die eigene
  Kilometerstand-Subtraktion fuer die Tankvorgang-Strecke, wo zugeordnet
  (Live-Erkennung, manuelle Erfassung, historischer Import)
- "Fahrzeugprofil" und "Daten ausgeben" zu einer Kachel zusammengelegt:
  "Datensatz sichern" (4 Exporte wie bisher) / "Datensatz laden" (neu:
  echter CSV-Import fuer Fahrten/Tankvorgaenge/Wartungsplan)
- Import gleicht per ID ab (Fahrten/Tankvorgaenge) bzw. Datum+Art
  (Wartungsplan, hat keine eigene ID) und aktualisiert nur die in der CSV
  enthaltenen Spalten - alles andere am Datensatz bleibt unangetastet
- Nebenbei gefunden und behoben: belege.tankvorgang_aktualisieren()
  ueberschrieb bisher immer alle Felder, auch mit None, wenn irgendeins
  geaendert wurde - fuer den CSV-Import gefaehrlich, jetzt nur noch
  tatsaechlich uebergebene Felder
- Ebenfalls gefunden und behoben: Panel las das Import-Ergebnis per
  HASS.states direkt nach dem Dienstaufruf - ein Wettlauf mit dem
  state_changed-Push. Nutzt jetzt denselben HASS.callWS(get_states)-Weg
  wie der bestehende historie_importieren-Ablauf.

companion-app-Portierung von "Datensatz sichern/laden" steht noch aus.

Version 2026.8.28.3, live im Testcontainer verifiziert (alle drei
CSV-Datensatztypen: anlegen + aktualisieren per ID/Datum+Art getestet,
Testdaten danach geloescht).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 10:19:06 +02:00

296 lines
12 KiB
Python

"""Belegverarbeitung für Tankbelege (SPECIFICATION.md §7.7).
Beleg-Upload direkt in der App statt Postfachanbindung - kein IMAP-Zugriff.
Das eigentliche Lesen der PDF macht shell_beleg_parser.py daneben; hier steht,
was mit dem Ergebnis passiert.
ZWEI REGELN, DIE DEN AUFBAU ERKLÄREN
------------------------------------
1. Liter, Kosten und Ersparnis kommen vom Beleg, Kilometerstand und gefahrene
Distanz kommen immer vom Fahrzeug (§7.7 Regel 2). Ein Beleg-Nachtrag rührt
odometer_km/distance_km deshalb nicht an.
2. price_per_l wird grundsätzlich aus fuel_total_eur ÷ liters berechnet, nie
vom Beleg übernommen. fuel_total_eur ist dabei immer der reale, bereits
rabattierte Betrag; die SmartDeal-Ersparnis steht separat in discount.
Mit tank_id trägt der Upload die Belegdaten in einen bestehenden Tankvorgang
nach - egal ob der ursprünglich manuell oder automatisch angelegt wurde. Passt
das Belegdatum nicht zum erfassten Tankvorgang (anderer Kalendertag), wird
nichts übernommen, sondern ein Fehler veröffentlicht: sonst landet in der
Oberfläche versehentlich ein falscher Beleg an einem bestehenden Eintrag.
Ohne tank_id legt der Upload KEINEN eigenständigen Tankvorgang an, sondern
parst nur und veröffentlicht das Ergebnis - die Oberfläche übernimmt die Werte
in den gerade offenen Entwurf und speichert erst auf einen expliziten Klick.
"""
from __future__ import annotations
import base64
import datetime
import logging
import os
from typing import TYPE_CHECKING
from . import shell_beleg_parser
from . import tankerkennung
from .tankerkennung import leerer_tankvorgang
from .veroeffentlichung import zustand_oder_none
from .verlauf import zeit_normalisiert
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
def preis_je_liter(fuel_total_eur: float | None, liters: float | None) -> float | None:
if not liters or fuel_total_eur is None:
return None
return round(fuel_total_eur / liters, 3)
def _speichern_und_lesen(ordner: str, dateiname: str, rohdaten: bytes) -> tuple[str, dict]:
"""Läuft komplett im Executor: Datei schreiben und PDF lesen sind beides
blockierende Arbeit."""
os.makedirs(ordner, exist_ok=True)
pfad = os.path.join(ordner, dateiname)
with open(pfad, "wb") as datei:
datei.write(rohdaten)
return pfad, shell_beleg_parser.beleg_lesen(pfad)
async def hochladen(k: Koordinator, daten: dict) -> None:
pdf_base64 = daten.get("pdf_base64")
tank_id = daten.get("tank_id")
if not pdf_base64:
_LOGGER.warning("Beleg-Upload ohne pdf_base64 aufgerufen")
return
dateiname = daten.get("dateiname") or (
f"beleg_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
)
# Alles bis zum geparsten Ergebnis in einem try: eine unbehandelte
# Ausnahme hier (kaputtes Base64, volle Festplatte, unlesbares PDF) wäre
# für die Oberfläche unsichtbar - ein abgelehnter Dienstaufruf landet dort
# nur in der Browser-Konsole, angezeigt wird nichts.
try:
rohdaten = base64.b64decode(pdf_base64)
pdf_pfad, geparst = await k.hass.async_add_executor_job(
_speichern_und_lesen, k.ablage.belege_ordner, dateiname, rohdaten
)
except Exception as fehler: # noqa: BLE001 - jede Ursache endet hier gleich
_LOGGER.error("Beleg konnte nicht verarbeitet werden: %s", fehler)
await k.beleg_ergebnis_veroeffentlichen(
{"fehler": "Beleg konnte nicht gelesen werden", "tank_id": tank_id}
)
return
receipt_key = geparst.get("receipt_key")
if not receipt_key:
_LOGGER.error("Parser lieferte keinen receipt_key")
await k.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 = await k.ablage.tankvorgang_nach_receipt_key(receipt_key)
if vorhandener is not None and vorhandener.get("tank_id") != tank_id:
_LOGGER.info("Beleg %s bereits vorhanden, übersprungen", receipt_key)
await k.beleg_ergebnis_veroeffentlichen(
{"fehler": "Beleg bereits erfasst", "tank_id": tank_id}
)
return
beleg_felder = _beleg_felder(geparst, pdf_pfad)
if not tank_id:
await k.beleg_ergebnis_veroeffentlichen({**beleg_felder, "tank_id": None})
_LOGGER.info("Beleg %s geparst, wartet auf Speichern in der Oberfläche", receipt_key)
return
await _nachtragen(k, tank_id, geparst, beleg_felder, receipt_key)
def _beleg_felder(geparst: dict, pdf_pfad: str) -> dict:
liters = geparst.get("liters")
fuel_total_eur = geparst.get("fuel_total_eur") # real, bereits rabattiert
return {
"receipt_key": geparst.get("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"),
"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,
}
async def _nachtragen(
k: Koordinator, tank_id: str, geparst: dict, beleg_felder: dict, receipt_key: str
) -> None:
vorhanden = await k.ablage.tankvorgang_nach_id(tank_id)
if not vorhanden:
_LOGGER.warning("Tankvorgang %s für Beleg-Nachtrag nicht gefunden", tank_id)
await k.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:
_LOGGER.warning(
"Belegdatum %s passt nicht zu Tankvorgang %s (Datum %s)",
belegdatum, tank_id, vorhandenes_datum,
)
await k.beleg_ergebnis_veroeffentlichen(
{"fehler": "Falsches Belegdatum", "tank_id": tank_id}
)
return
aenderungen = dict(beleg_felder)
aenderungen["status"] = "vollständig"
if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen):
await k.tankvorgaenge_veroeffentlichen()
await k.beleg_ergebnis_veroeffentlichen({"erfolg": True, "tank_id": tank_id})
_LOGGER.info("Beleg %s in Tankvorgang %s nachgetragen", receipt_key, tank_id)
else:
await k.beleg_ergebnis_veroeffentlichen(
{"fehler": "Tankvorgang nicht gefunden", "tank_id": tank_id}
)
# ------------------------------------------------ Tankvorgänge aus dem Formular
async def tankvorgang_manuell(k: Koordinator, daten: dict) -> None:
"""Erfassung aus dem Formular "Neuer Tankvorgang".
receipt_key/receipt_file sind optional: kommen sie mit (weil zuvor ein
Beleg ohne tank_id in den Entwurf übernommen wurde), zählt der Tankvorgang
als per Beleg erfasst, sonst als manuell."""
liter = daten.get("liter")
kosten = daten.get("kosten")
if liter is None or kosten is None:
_LOGGER.warning("liter und kosten sind Pflichtfelder")
return
km = daten.get("km")
odometer_km = km if km is not None else _km_aus_sensor(k)
distanz = daten.get("distanz")
if distanz is None:
# Vorschlag aus TANK_DISTANZ_SENSOR oder, ohne ihn, der Differenz zum
# letzten Tankvorgang - aber wie odometer_km selbst frei
# überschreibbar, ein übergebener Wert hat deshalb immer Vorrang.
distanz = await tankerkennung.distanz_seit_tankung(k, odometer_km)
ts_roh = daten.get("ts")
ts = zeit_normalisiert(ts_roh) if ts_roh else datetime.datetime.now(datetime.UTC)
receipt_key = daten.get("receipt_key")
tankvorgang = leerer_tankvorgang(
ts.isoformat(),
"beleg" if receipt_key else "manual",
)
tankvorgang.update({
"receipt_key": receipt_key,
"liters": liter,
"fuel_total_eur": kosten,
"price_per_l": preis_je_liter(kosten, liter),
"discount": daten.get("ersparnis"),
"station_name": daten.get("station"),
"odometer_km": odometer_km,
"distance_km": distanz,
"fuel_type": daten.get("kraftstoff"),
"status": "vollständig",
"receipt_file": daten.get("receipt_file"),
})
await k.ablage.tankvorgang_anhaengen(tankvorgang)
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info("Tankvorgang %s angelegt", tankvorgang["tank_id"])
def _km_aus_sensor(k: Koordinator) -> float | None:
wert = zustand_oder_none(k.hass, k.zuordnung.werte.KM_SENSOR)
try:
return float(wert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
async def tankvorgang_aktualisieren(k: Koordinator, daten: dict) -> None:
"""Bearbeitet einen bestehenden Tankvorgang (Einzelbeleg-Seite, egal ob
ursprünglich per Beleg oder manuell angelegt).
Nur tatsächlich in `daten` enthaltene Schlüssel werden geändert - fehlt
ein Feld ganz (statt nur leer/None zu sein), bleibt der bisherige Wert
unangetastet. Das bestehende Bearbeiten-Formular (Einzelbeleg-Seite)
schickt ohnehin immer alle Felder mit, verhält sich also unverändert;
ein schlankerer Aufrufer wie der CSV-Import (csv_import.py) kann so
gezielt nur einzelne Spalten aktualisieren, ohne den Rest des
Datensatzes (odometer_km, discount, fuel_type, receipt_key, ...)
stillschweigend auf None zu setzen."""
tank_id = daten.get("tank_id")
if not tank_id:
_LOGGER.warning("tankvorgang_aktualisieren ohne tank_id aufgerufen")
return
aenderungen: dict = {}
if "ts" in daten:
ts_normalisiert = zeit_normalisiert(daten.get("ts"))
aenderungen["ts"] = ts_normalisiert.isoformat() if ts_normalisiert else daten.get("ts")
if "liter" in daten or "kosten" in daten:
liter = daten.get("liter")
kosten = daten.get("kosten")
aenderungen["liters"] = liter
aenderungen["fuel_total_eur"] = kosten
aenderungen["price_per_l"] = preis_je_liter(kosten, liter)
if "km" in daten:
aenderungen["odometer_km"] = daten.get("km")
if "ersparnis" in daten:
aenderungen["discount"] = daten.get("ersparnis")
if "station" in daten:
aenderungen["station_name"] = daten.get("station")
if "distanz" in daten:
aenderungen["distance_km"] = daten.get("distanz")
if "kraftstoff" in daten:
aenderungen["fuel_type"] = daten.get("kraftstoff")
if daten.get("receipt_key"):
aenderungen["receipt_key"] = daten["receipt_key"]
aenderungen["receipt_file"] = daten.get("receipt_file")
aenderungen["source"] = "beleg"
if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen):
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info("Tankvorgang %s aktualisiert", tank_id)
else:
_LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id)
async def tankvorgang_loeschen(k: Koordinator, tank_id: str | None) -> None:
if not tank_id:
_LOGGER.warning("tankvorgang_loeschen ohne tank_id aufgerufen")
return
if await k.ablage.tankvorgang_loeschen(tank_id):
await k.tankvorgaenge_veroeffentlichen()
_LOGGER.info("Tankvorgang %s gelöscht", tank_id)
else:
_LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id)