Files
tobias 2af5c469d6 Ortsnamen aus dem Backend, Verbrauchsfaktor aus dem Tankbeleg, Radzaehler ueber Fahrten
Ortsauflösung serverseitig (geokodierung.py): der Ort steht jetzt in der Fahrt
statt im Zwischenspeicher jedes Geräts. Beide Oberflächen fragen nichts mehr ab.

Verbrauchskorrektur (verbrauchskorrektur.py): Faktor = Beleg-Liter / Summe der
Einzelfahrten, voll zu voll. Statt einer Schranke am Faktor wird der Nenner
geprüft - decken die erkannten Fahrten 90-110 % der Tacho-Spanne ab? Ausgelöst
beim Schreiben eines Tankvorgangs, nicht erst bei der nächsten Fahrt.

Radzähler: summiert die Fahrten des montierten Satzes statt Tacho-Deltas. Der
alte Weg hatte Fahrzeugwechsel als gefahrene Strecke verbucht (375.962 km bei
einem Tacho von 61.823).

Null heißt unbekannt: ein Kilometerstand von 0 oder ein leeres Feld werden zu
None normalisiert, statt an sechs Lesestellen als "Tachostand null" zu gelten.
Momentanwerte der Sensoren gelten nur für einen Tankvorgang von jetzt.

Oberfläche, beide Codebasen: Wertespalte der Fahrtenliste ausgerichtet, Ort ->
Ort in "Zuletzt", "Räder" statt "Reifen", "Termin vereinbart" entfernt,
Markenlogo 20 % größer, Übersicht-Symbol auf volle Rasterbreite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:28:37 +02:00

361 lines
15 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 als_kilometerstand, 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.tankvorgang_nachbereiten()
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
ts_roh = daten.get("ts")
ts = zeit_normalisiert(ts_roh) if ts_roh else datetime.datetime.now(datetime.UTC)
# Eine 0 oder ein geleertes Feld heissen "kenne ich nicht" - siehe
# als_kilometerstand().
odometer_km = als_kilometerstand(daten.get("km"))
if odometer_km is None:
odometer_km = _km_aus_sensor_wenn_jetzt(k, ts)
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, jetzt=_ist_jetzt(ts)
)
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.tankvorgang_nachbereiten()
_LOGGER.info("Tankvorgang %s angelegt", tankvorgang["tank_id"])
# Wie nah der eingetragene Zeitpunkt an "jetzt" liegen muss, damit der
# aktuelle Tachostand als Ersatz taugt.
SENSOR_ERSATZ_FENSTER = datetime.timedelta(hours=2)
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
def _ist_jetzt(ts: datetime.datetime | None) -> bool:
"""Liegt der eingetragene Zeitpunkt nah genug an jetzt, dass die
Momentanwerte der Sensoren zu ihm passen?"""
if ts is None:
return False
jetzt = datetime.datetime.now(datetime.UTC)
return abs((jetzt - ts).total_seconds()) <= SENSOR_ERSATZ_FENSTER.total_seconds()
def _km_aus_sensor_wenn_jetzt(
k: Koordinator, ts: datetime.datetime | None
) -> float | None:
"""Der aktuelle Tachostand - aber nur fuer einen Tankvorgang von jetzt.
Der Sensor kennt genau einen Wert: den von diesem Augenblick. Fuer eine
Tankung, die gerade passiert ist, ist das der richtige Ersatz und der
Normalfall - der Nutzer muss nichts eintippen.
Fuer eine nachgetragene Tankung von vorgestern ist es der falsche Wert,
und er richtet zweifachen Schaden an: die gefahrene Distanz wird gegen
einen zu hohen Stand gerechnet, und die Datumspruefung der
Verbrauchskorrektur verwirft das Intervall, weil der gespeicherte Stand
nicht zum Tacho des eingetragenen Zeitpunkts passt. Dann lieber gar kein
Kilometerstand - genau das, was eine 0 im Formular ausdruecken soll."""
if not _ist_jetzt(ts):
_LOGGER.info(
"Tankvorgang auf %s datiert - der aktuelle Tachostand passt dazu "
"nicht und wird nicht eingesetzt", ts.isoformat(),
)
return None
return _km_aus_sensor(k)
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
# edited_fields fortschreiben statt ersetzen: was frueher von Hand gesetzt
# wurde, bleibt geschuetzt.
vorhanden = await k.ablage.tankvorgaenge_lesen()
alt = next((t for t in vorhanden if t.get("tank_id") == tank_id), None)
hand = set((alt or {}).get("edited_fields") or [])
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 liter is not None:
# Von Hand eingetippte Liter sind der Beleg, nur ohne PDF - und
# damit dieselbe Wahrheit wie ein geparster Beleg. Ohne diesen
# Vermerk haelt verbrauchskorrektur.beleg_liter() sie fuer den
# Anstieg des Gebers und verwirft sie als Zirkelschluss; der
# Verbrauchsfaktor entstuende nie.
#
# Nebenwirkung, bewusst in Kauf genommen: edited_fields schuetzt
# das Feld vor der Automatik. Wird spaeter doch noch ein PDF zu
# demselben Tankvorgang hochgeladen, bleibt die getippte Zahl
# stehen - der Mensch hatte den Beleg in der Hand.
hand.add("liters")
if "km" in daten:
aenderungen["odometer_km"] = als_kilometerstand(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 hand:
aenderungen["edited_fields"] = sorted(hand)
if await k.ablage.tankvorgang_aktualisieren(tank_id, aenderungen):
await k.tankvorgang_nachbereiten()
_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.tankvorgang_nachbereiten()
_LOGGER.info("Tankvorgang %s gelöscht", tank_id)
else:
_LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id)