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>
This commit is contained in:
2026-08-28 10:19:06 +02:00
parent edefdd66d8
commit bfb7dec1e9
11 changed files with 521 additions and 76 deletions
@@ -2622,11 +2622,12 @@ function vEinst() {
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
Alles Fahrzeugspezifische liegt in einer einzigen Datei: Stammdaten, technische Daten,
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Für ein anderes Fahrzeug wird nur
diese Datei getauscht die App selbst bleibt unverändert.</span>
<button class="aktion" data-profil="export">Profil sichern</button>
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
Profil laden<input type="file" accept="application/json" data-profil="import" hidden></label>
Ausstattung, Reifen, Versicherung, Steuer und Werkstatt. Fahrten, Tankvorgänge und der
Wartungsplan lassen sich einzeln als CSV sichern und bearbeitet wieder einspielen eine
Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt
unangetastet.</span>
<button class="aktion" data-datensatz="sichern">Datensatz sichern</button>
<button class="aktion" data-datensatz="laden">Datensatz laden</button>
</div>
<div class="tile"><span class="label">Backup</span>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
@@ -2646,14 +2647,6 @@ function vEinst() {
<label class="aktion" style="display:block;text-align:center;cursor:pointer">
Backup importieren<input type="file" accept="application/json" data-backup="import" hidden></label>
</div>
<div class="tile"><span class="label">Daten ausgeben</span>
<span style="display:block;margin-top:10px;color:var(--fg2);font-size:13.5px;line-height:1.6">
Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in
Tabellenprogrammen aufgehen.</span>
<button class="aktion" data-csv="fahrten">Fahrten als CSV</button>
<button class="aktion" data-csv="tanken">Tankvorgänge als CSV</button>
<button class="aktion" data-csv="service">Wartungsplan als CSV</button>
</div>
<div class="tile"><span class="label">Version</span>
<dl class="rows">
<div class="row" style="border-bottom:0"><dt>Installiert</dt><dd>${esc(APP_VERSION || "unbekannt")}<small>Home-Assistant-Integration</small></dd></div>
@@ -3904,6 +3897,132 @@ function vImportPopup() {
</div>`;
}
/* ------------------------------------------------- Datensatz-Popup
"Fahrzeugprofil"-Kachel in den Einstellungen: "Datensatz sichern" öffnet
vier sofortige, rein clientseitige Exporte (Fahrzeugprofil als JSON,
Fahrten/Tankvorgänge/Wartungsplan als CSV - identisch zum bisherigen
Verhalten der jetzt zusammengelegten "Fahrzeugprofil"/"Daten
ausgeben"-Kacheln). "Datensatz laden" spiegelt dieselben vier Dateien als
Datei-Auswahl - Fahrzeugprofil läuft wie zuvor über den bestehenden
profil_schreiben-Dienst, die drei CSV-Datensätze über den neuen
csv_importieren-Dienst (csv_import.py): eine Zeile mit bekannter ID
aktualisiert nur ihre eigenen Spalten, alles andere am Datensatz bleibt
unangetastet - siehe dessen Moduldocstring für die Begründung. */
let datensatzPopup = null; // { modus: "sichern" | "laden" } | null
let datensatzLaeuft = null; // welcher Datensatz-Import gerade läuft, oder null
let datensatzErgebnis = null; // { art, ... } des zuletzt gelaufenen Imports, oder null
let datensatzFehler = null;
const DATENSATZ_ARTEN = [
["fahrzeugprofil", "Fahrzeugprofil"],
["fahrten", "Fahrten"],
["tanken", "Tankvorgänge"],
["service", "Wartungsplan"],
];
function datensatzExportieren(art) {
if (art === "fahrzeugprofil") {
const a = document.createElement("a");
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2));
a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove();
} else if (art === "fahrten") {
csvDatei("fahrten.csv", ["ID", "Start", "Ende", "km", "Art", "Status"],
TRIPS.map((t) => [t.trip_id, t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status]));
} else if (art === "tanken") {
csvDatei("tankvorgaenge.csv", ["ID", "Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
FILLS.map((f) => [f.tank_id, f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""]));
} else if (art === "service") {
csvDatei("wartungsplan.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"],
CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""]));
}
}
function datensatzErgebnisText(e) {
if (e.art === "fahrzeugprofil") return "Fahrzeugprofil geladen.";
const teile = [];
if (e.angelegt) teile.push(`${de(e.angelegt)} neu angelegt`);
if (e.aktualisiert) teile.push(`${de(e.aktualisiert)} aktualisiert`);
if (e.uebersprungen) teile.push(`${de(e.uebersprungen)} übersprungen`);
return teile.length ? teile.join(" · ") : "Keine gültigen Zeilen in der Datei gefunden.";
}
function datensatzImportieren(art, datei) {
const leser = new FileReader();
leser.onerror = () => {
datensatzLaeuft = null;
datensatzFehler = "Die Datei ließ sich nicht lesen.";
render();
};
leser.onload = () => {
if (art === "fahrzeugprofil") {
try {
const p = JSON.parse(leser.result);
PROFIL_ROH = p; CONFIG = profilZuConfig(p);
CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) });
bild = 0;
datensatzLaeuft = null; datensatzErgebnis = { art }; datensatzFehler = null;
} catch (err) {
datensatzLaeuft = null;
datensatzFehler = "Die Datei ließ sich nicht lesen: " + err.message;
}
render();
return;
}
HASS.callService(DOMAIN, "csv_importieren", { art, inhalt: leser.result })
.then(async () => {
// importStatusLesen() fragt den Zustand frisch per get_states ab,
// statt aus dem lokal zwischengespeicherten HASS.states zu lesen -
// dessen state_changed-Update kommt über eine eigene
// Websocket-Nachricht rein und muss dem Rückgabewert dieses
// Dienstaufrufs nicht unbedingt schon vorausgegangen sein (dieselbe
// Begründung wie bei importAusloesen() oben, nur ohne dessen
// Warteschleife - der CSV-Import läuft synchron im Backend, bis
// hierhin ist der neue Stand schon veröffentlicht).
const s = await importStatusLesen();
datensatzLaeuft = null;
datensatzErgebnis = { art, ...((s && s.attributes && s.attributes.daten) || {}) };
datensatzFehler = null;
render();
datenAktualisieren();
})
.catch((err) => {
datensatzLaeuft = null;
datensatzFehler = "Der Import ist fehlgeschlagen: " + ((err && err.message) || "unbekannter Fehler");
render();
});
};
datensatzLaeuft = art;
datensatzErgebnis = null; datensatzFehler = null;
render();
leser.readAsText(datei);
}
function vDatensatzPopup() {
if (!datensatzPopup) return "";
const sichern = datensatzPopup.modus === "sichern";
return `<div class="beleg-catcher" data-datensatzzu></div>
<div class="beleg-popup" role="dialog" aria-modal="true" aria-label="${sichern ? "Datensatz sichern" : "Datensatz laden"}">
<span class="label">${sichern ? "Datensatz sichern" : "Datensatz laden"}</span>
<span class="label" style="margin-top:10px;color:var(--fg2);letter-spacing:0;
text-transform:none;font-size:12.5px;line-height:1.6">
${sichern
? "Semikolon als Trennzeichen und deutsches Zahlenformat, damit die Dateien ohne Umweg in Tabellenprogrammen aufgehen."
: "Eine zuvor gesicherte, ggf. bearbeitete Datei wieder einspielen - eine Zeile mit bekannter ID aktualisiert nur ihre eigenen Spalten, alles andere bleibt unangetastet."}
</span>
${DATENSATZ_ARTEN.map(([art, label]) => sichern
? `<button class="aktion" style="margin-top:12px" data-datensatzsichern="${art}">${label}${art === "fahrzeugprofil" ? " sichern" : " als CSV"}</button>`
: `<label class="aktion" style="display:block;text-align:center;cursor:pointer;margin-top:12px">
${datensatzLaeuft === art ? "Lädt …" : label + (art === "fahrzeugprofil" ? " laden" : " (CSV) laden")}
<input type="file" accept="${art === "fahrzeugprofil" ? "application/json" : "text/csv"}"
data-datensatzladen="${art}" hidden ${datensatzLaeuft ? "disabled" : ""}></label>`
).join("")}
${datensatzErgebnis ? `<div style="margin-top:14px;font-size:13.5px;line-height:1.6;color:var(--fg)">${esc(datensatzErgebnisText(datensatzErgebnis))}</div>` : ""}
${datensatzFehler ? `<span class="label" style="margin-top:10px;color:var(--red)">${esc(datensatzFehler)}</span>` : ""}
<button class="aktion primaer" style="margin-top:16px" data-datensatzzu>Fertig</button>
</div>`;
}
function vBelegPopup() {
if (!belegPopup) return "";
// Am Telefon ist Einfügen der Hauptweg (PDF aus der Mail kopiert), am
@@ -4090,7 +4209,7 @@ function render() {
// faellt sonst bei jedem render(), z. B. beim Umschalten des Filters,
// auf 0 zurueck).
const setupKoerperScroll = setupOffen ? (ROOT.querySelector(".setup-koerper") || {}).scrollTop : 0;
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup();
ROOT.getElementById("overlay").innerHTML = sheetMarkup() + vSetupPopup() + vBelegPopup() + vImportPopup() + vDatensatzPopup();
if (setupOffen) {
const koerper = ROOT.querySelector(".setup-koerper");
if (koerper) koerper.scrollTop = setupKoerperScroll || 0;
@@ -4824,6 +4943,18 @@ function ereignisseVerdrahten() {
importPopup = null; render(); return;
}
if (e.target.closest("[data-import-start]")) { importAusloesen(); return; }
const dsOeffnen = e.target.closest("[data-datensatz]");
if (dsOeffnen) {
datensatzPopup = { modus: dsOeffnen.dataset.datensatz };
datensatzLaeuft = null; datensatzErgebnis = null; datensatzFehler = null;
render(); return;
}
if (e.target.closest("[data-datensatzzu]")) {
if (datensatzLaeuft) return;
datensatzPopup = null; render(); return;
}
const dsExport = e.target.closest("[data-datensatzsichern]");
if (dsExport) { datensatzExportieren(dsExport.dataset.datensatzsichern); return; }
const del = e.target.closest("[data-loeschen]");
if (del) { delete CAR.service.vereinbart[del.dataset.loeschen]; profilSpeichern(); render(); return; }
const vseldel = e.target.closest("[data-vselbstdel]");
@@ -4833,13 +4964,6 @@ function ereignisseVerdrahten() {
if (oel) { CAR.oel.modus = oel.dataset.oel; profilSpeichern(); render(); return; }
const mode = e.target.closest("[data-mode]");
if (mode) { themeSetzen(mode.dataset.mode); themeIcon(); render(); return; }
const pr = e.target.closest("[data-profil]");
if (pr && pr.dataset.profil === "export") {
const a = document.createElement("a");
a.href = "data:application/json;charset=utf-8," + encodeURIComponent(JSON.stringify(PROFIL_ROH, null, 2));
a.download = "fahrzeugprofil.json"; document.body.appendChild(a); a.click(); a.remove();
return;
}
const bk = e.target.closest("[data-backup]");
if (bk && bk.dataset.backup === "jetzt") { serviceRufen("backup_jetzt", {}); return; }
if (bk && bk.dataset.backup === "export") {
@@ -4895,17 +5019,6 @@ function ereignisseVerdrahten() {
}
return;
}
const cs = e.target.closest("[data-csv]");
if (cs) {
const w = cs.dataset.csv;
if (w === "fahrten") csvDatei("fahrten.csv", ["Start", "Ende", "km", "Art", "Status"],
TRIPS.map((t) => [t.ts_start, t.ts_end, t.distance_km != null ? de(t.distance_km) : "", t.art, t.status]));
else if (w === "tanken") csvDatei("tankvorgaenge.csv", ["Zeitpunkt", "Station", "Liter", "€/l", "Kosten €"],
FILLS.map((f) => [f.ts, f.station_name || "", f.liters != null ? de(f.liters, 2) : "", f.liters ? de(f.fuel_total_eur / f.liters, 2) : "", f.fuel_total_eur != null ? eur(f.fuel_total_eur) : ""]));
else csvDatei("servicebuch.csv", ["Datum", "km", "Art", "Werkstatt", "Kosten €"],
CAR.service.buch.map((e2) => [e2.datum, de(e2.km), e2.art, e2.werkstatt, e2.kosten != null ? eur(e2.kosten) : ""]));
return;
}
const satz = e.target.closest("[data-satz]");
if (satz) { serviceRufen("reifen_wechseln", { satz: satz.dataset.satz.toLowerCase() }); CAR.reifen.aktiv = satz.dataset.satz; render(); return; }
const rfArch = e.target.closest("[data-reifenarchivieren]");
@@ -5007,18 +5120,9 @@ function ereignisseVerdrahten() {
});
ROOT.addEventListener("change", (e) => {
if (e.target.dataset.profil === "import") {
if (e.target.dataset.datensatzladen) {
const datei = e.target.files[0]; if (!datei) return;
const leser = new FileReader();
leser.onload = () => {
try {
const p = JSON.parse(leser.result);
PROFIL_ROH = p; CONFIG = profilZuConfig(p); CAR = profilZuCar(p, { km: CAR.odo, tankprozent: CAR.tankPct, reichweite_km: CAR.reichweite, gesichert: CAR.gesichert });
serviceRufen("profil_schreiben", { profil_json: JSON.stringify(p) });
bild = 0; render();
} catch (err) { hinweis("Die Datei ließ sich nicht lesen", err.message); }
};
leser.readAsText(datei);
datensatzImportieren(e.target.dataset.datensatzladen, datei);
return;
}
if (e.target.dataset.backup === "import") {