bfb7dec1e9
- 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>
180 lines
7.2 KiB
Python
180 lines
7.2 KiB
Python
"""CSV-Import für Fahrten, Tankvorgänge und Wartungsplan (Einstellungen ->
|
|
"Datensatz laden") - das Gegenstück zum bestehenden, rein clientseitigen
|
|
CSV-Export (beide Frontends: der "Datensatz sichern"-Popup baut die Datei
|
|
direkt im Browser aus den schon geladenen Daten, ohne Dienstaufruf).
|
|
|
|
Format identisch zum Export: Semikolon als Trennzeichen, deutsches
|
|
Zahlenformat ("," als Dezimaltrennzeichen, optional "." als
|
|
Tausendertrennzeichen), UTF-8 mit BOM (wird beim Lesen übersprungen).
|
|
|
|
ABGLEICH STATT VOLLSTÄNDIGEM ERSATZ (Nutzerentscheidung): eine Zeile mit
|
|
bekannter ID aktualisiert NUR die in der CSV enthaltenen Spalten am
|
|
bestehenden Datensatz - alles andere (GPS, Route, edited_fields,
|
|
Beleg-Verknüpfung, ...) bleibt unangetastet, siehe
|
|
`belege.tankvorgang_aktualisieren()`s eigene Begründung. Eine Zeile ohne
|
|
oder mit unbekannter ID legt einen neuen Datensatz an - so lässt sich auch
|
|
eine von Hand in der Tabelle ergänzte Zeile importieren.
|
|
|
|
Wartungsplan hat, anders als Fahrten/Tankvorgänge, keine eigene ID - die
|
|
Einträge leben als einfache Liste im Fahrzeugprofil und werden bisher nur
|
|
über ihren Listenindex adressiert (siehe vSbuch()/Servicebuch() in beiden
|
|
Frontends). Der Abgleich läuft dort stattdessen über Datum+Art als
|
|
Ersatzschlüssel - eine Zeile, die eines von beidem ändert, wird deshalb
|
|
nicht wiedererkannt und legt einen neuen Eintrag an, statt den alten zu
|
|
aktualisieren. Für die kleinen, von Hand gepflegten Wartungsplan-Listen
|
|
dieses Projekts ist das ein bewusst akzeptierter Kompromiss gegenüber einer
|
|
eigens für diesen Zweck eingeführten ID.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from . import belege, fahrterkennung
|
|
|
|
if TYPE_CHECKING:
|
|
from .koordinator import Koordinator
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def _de_zahl(text: str | None) -> float | None:
|
|
""""1.234,5" -> 1234.5. None/leer -> None (Spalte nicht gesetzt, nicht
|
|
"auf 0 setzen")."""
|
|
text = (text or "").strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
return float(text.replace(".", "").replace(",", "."))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _zeilen(inhalt: str) -> list[dict[str, str]]:
|
|
"""CSV-Text (inkl. Kopfzeile, optional mit BOM) als Liste von
|
|
Feldname->Text-Wörterbüchern, in der Reihenfolge der Kopfzeile. Ganz
|
|
leere Zeilen (z. B. eine Leerzeile am Dateiende) werden übersprungen."""
|
|
bereinigt = inhalt.lstrip("")
|
|
zeilen = list(csv.reader(io.StringIO(bereinigt), delimiter=";"))
|
|
if not zeilen:
|
|
return []
|
|
kopf = zeilen[0]
|
|
return [dict(zip(kopf, zeile)) for zeile in zeilen[1:] if any(f.strip() for f in zeile)]
|
|
|
|
|
|
async def fahrten_importieren(k: Koordinator, inhalt: str) -> dict:
|
|
bestehende = {f["trip_id"] for f in await k.ablage.fahrten_lesen()}
|
|
aktualisiert = angelegt = uebersprungen = 0
|
|
for zeile in _zeilen(inhalt):
|
|
trip_id = (zeile.get("ID") or "").strip()
|
|
start = zeile.get("Start") or None
|
|
ende = zeile.get("Ende") or None
|
|
art = zeile.get("Art") or None
|
|
km = _de_zahl(zeile.get("km"))
|
|
if trip_id and trip_id in bestehende:
|
|
await fahrterkennung.aktualisieren(k, {
|
|
"trip_id": trip_id, "ts_start": start, "ts_end": ende,
|
|
"art": art, "distanz": km,
|
|
})
|
|
aktualisiert += 1
|
|
elif start and ende:
|
|
await fahrterkennung.manuell_anlegen(k, {
|
|
"ts_start": start, "ts_end": ende, "art": art, "distanz": km,
|
|
})
|
|
angelegt += 1
|
|
else:
|
|
_LOGGER.warning("CSV-Zeile (Fahrten) ohne Start/Ende übersprungen: %s", zeile)
|
|
uebersprungen += 1
|
|
return {"aktualisiert": aktualisiert, "angelegt": angelegt, "uebersprungen": uebersprungen}
|
|
|
|
|
|
async def tankvorgaenge_importieren(k: Koordinator, inhalt: str) -> dict:
|
|
bestehende = {t["tank_id"] for t in await k.ablage.tankvorgaenge_lesen()}
|
|
aktualisiert = angelegt = uebersprungen = 0
|
|
for zeile in _zeilen(inhalt):
|
|
tank_id = (zeile.get("ID") or "").strip()
|
|
ts = zeile.get("Zeitpunkt") or None
|
|
station = zeile.get("Station") or None
|
|
liter = _de_zahl(zeile.get("Liter"))
|
|
kosten = _de_zahl(zeile.get("Kosten €"))
|
|
if tank_id and tank_id in bestehende:
|
|
daten: dict = {"tank_id": tank_id}
|
|
if ts is not None:
|
|
daten["ts"] = ts
|
|
if liter is not None or kosten is not None:
|
|
daten["liter"] = liter
|
|
daten["kosten"] = kosten
|
|
if station is not None:
|
|
daten["station"] = station
|
|
await belege.tankvorgang_aktualisieren(k, daten)
|
|
aktualisiert += 1
|
|
elif ts and liter is not None and kosten is not None:
|
|
await belege.tankvorgang_manuell(k, {
|
|
"ts": ts, "liter": liter, "kosten": kosten, "station": station,
|
|
})
|
|
angelegt += 1
|
|
else:
|
|
_LOGGER.warning(
|
|
"CSV-Zeile (Tankvorgänge) ohne Zeitpunkt/Liter/Kosten übersprungen: %s", zeile
|
|
)
|
|
uebersprungen += 1
|
|
return {"aktualisiert": aktualisiert, "angelegt": angelegt, "uebersprungen": uebersprungen}
|
|
|
|
|
|
async def wartungsplan_importieren(k: Koordinator, inhalt: str) -> dict:
|
|
profil = await k.ablage.profil_lesen()
|
|
if profil is None:
|
|
return {"aktualisiert": 0, "angelegt": 0, "uebersprungen": 0}
|
|
buch = profil.setdefault("service", {}).setdefault("buch", [])
|
|
aktualisiert = angelegt = uebersprungen = 0
|
|
for zeile in _zeilen(inhalt):
|
|
datum = zeile.get("Datum") or None
|
|
art = zeile.get("Art") or None
|
|
if not datum or not art:
|
|
_LOGGER.warning("CSV-Zeile (Wartungsplan) ohne Datum/Art übersprungen: %s", zeile)
|
|
uebersprungen += 1
|
|
continue
|
|
km = _de_zahl(zeile.get("km"))
|
|
werkstatt = zeile.get("Werkstatt") or None
|
|
kosten = _de_zahl(zeile.get("Kosten €"))
|
|
treffer = next(
|
|
(e for e in buch if e.get("datum") == datum and e.get("art") == art), None
|
|
)
|
|
if treffer is not None:
|
|
if km is not None:
|
|
treffer["km"] = km
|
|
if werkstatt is not None:
|
|
treffer["werkstatt"] = werkstatt
|
|
if kosten is not None:
|
|
treffer["kosten"] = kosten
|
|
aktualisiert += 1
|
|
else:
|
|
buch.append({
|
|
"datum": datum, "km": km or 0, "art": art,
|
|
"werkstatt": werkstatt or "", "kosten": kosten, "arbeiten": "",
|
|
})
|
|
angelegt += 1
|
|
await k.ablage.profil_schreiben(profil)
|
|
await k.profil_veroeffentlichen()
|
|
return {"aktualisiert": aktualisiert, "angelegt": angelegt, "uebersprungen": uebersprungen}
|
|
|
|
|
|
IMPORTIERER = {
|
|
"fahrten": fahrten_importieren,
|
|
"tanken": tankvorgaenge_importieren,
|
|
"service": wartungsplan_importieren,
|
|
}
|
|
|
|
|
|
async def importieren(k: Koordinator, art: str, inhalt: str) -> dict:
|
|
funktion = IMPORTIERER.get(art)
|
|
if funktion is None:
|
|
_LOGGER.warning("csv_importieren mit unbekannter Art aufgerufen: %s", art)
|
|
return {"aktualisiert": 0, "angelegt": 0, "uebersprungen": 0}
|
|
ergebnis = await funktion(k, inhalt)
|
|
_LOGGER.info("CSV-Import (%s) abgeschlossen - %s", art, ergebnis)
|
|
return ergebnis
|