Hauptuntersuchung: Ableitung aus Erstzulassung und Wartungsplan, drittes Feld entfernt

Gemeldet: Wartungsplan leer, Erstzulassung 01.03.2025, erwartet 01.03.2028 -
gezeigt wurden drei verschiedene Antworten (Uebersicht 01.08.2026, Mein Audi
"August 2026", Service "kein Eintrag im Wartungsplan").

Ursache war das Profilfeld hauptuntersuchung_faellig ("08/2026"), das die
Ableitung ueberstimmte, in keiner Oberflaeche ein Eingabefeld hatte und im
Backend kein Schema. Drei Bildschirme verarbeiteten denselben Wert
verschieden. Das Feld ist samt Lese- und Schreibweg entfernt; es gibt jetzt
zwei Quellen: Wartungsplan + 24 Monate, sonst Erstzulassung + 36 Monate.

Dabei mitbehoben:

* Die Service-Kachel verschluckte abgeleitete Termine, sobald kein
  Wartungsplan-Eintrag dahinterstand.
* monatePlus() in der App verlor einen Tag ueber die Zeitumstellung (drei von
  fuenf gemessenen Faellen) - betraf Oelwechsel- und Inspektionsprognose
  genauso. Das Panel rechnete dort seit jeher richtig.
* Die App verlangte fuer jeden Wartungsplan-Eintrag eine Kilometerangabe. Eine
  Hauptuntersuchung ist rein datumsbasiert; ohne km waere der erste HU-Eintrag
  ignoriert worden. Ausserdem nahm das Panel den ersten Treffer der Liste, die
  App den juengsten - beide nehmen jetzt den juengsten.
* Monatsgenauigkeit ("03/2025") bleibt auf beiden Seiten erhalten.

Neu: paritaet_hu.test.ts vergleicht die App gegen die aus der Panel-Quelle
herausgeschnittenen Originalfunktionen, 15 Faelle. Gesamt 292 App-Tests, 73
Python-Tests mit 231 Untertests, 0 Tracebacks. Live an der Testinstanz auf
allen drei gemeldeten Bildschirmen geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 01:56:11 +02:00
parent 5621b818cc
commit 863284e540
25 changed files with 505 additions and 243 deletions
@@ -0,0 +1,74 @@
import { readFileSync } from "node:fs"
import { describe, expect, it } from "vitest"
import { hauptuntersuchungFaellig } from "./service"
/* Vergleicht die Ableitung der Hauptuntersuchung Zeile für Zeile gegen die
ECHTEN Panel-Funktionen, aus der Panel-Quelle herausgeschnitten. */
const quelle = readFileSync(
"../custom_components/audi_dashboard/frontend/audi-dashboard-app.js", "utf8")
const block = (kopf: string) => {
const i = quelle.indexOf(kopf)
if (i < 0) throw new Error("fehlt: " + kopf)
let tiefe = 0
for (let k = quelle.indexOf("{", i); k < quelle.length; k++) {
if (quelle[k] === "{") tiefe++
else if (quelle[k] === "}" && !--tiefe) return quelle.slice(i, k + 1)
}
throw new Error("unvollstaendig: " + kopf)
}
const zeile = (muster: RegExp) => {
const m = muster.exec(quelle)
if (!m) throw new Error("fehlt: " + muster)
return m[0]
}
const panel = new Function(`
${zeile(/^const DATUM_ISO = .*$/m)}
${zeile(/^const DATUM_TAG = .*$/m)}
${zeile(/^const DATUM_MONAT = .*$/m)}
${zeile(/^const DATUM_ISO_MONAT = .*$/m)}
${block("function datumBauen")}
${zeile(/^const gueltigesDatum = .*$/m)}
${block("function datumAusText")}
${block("function dat(")}
${zeile(/^function monatePlus.*$/m)}
return { datumAusText, monatePlus };
`)() as {
datumAusText: (w: string) => { zeit: Date; genau: string } | null
monatePlus: (d: string | Date, m: number) => Date
}
const iso = (d: Date | null) => d === null ? null
: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
/* Wie hauptuntersuchungsTermin() im Panel, nur ohne dessen CAR-Zugriff.
Beide Seiten führen die Genauigkeit mit, nur in verschiedener Form: das
Panel als Merkmal `genau` am Termin, die App als ISO-Monat im Text
("2028-03"). Verglichen wird deshalb die normalisierte Form - dieselbe
Aussage, nicht dieselbe Darstellung. */
const panelHU = (erst: string, eintragDe?: string) => {
if (eintragDe) return iso(panel.monatePlus(eintragDe, 24))
const z = panel.datumAusText(erst)
if (!z) return null
const ziel = iso(panel.monatePlus(z.zeit, 36))
return z.genau === "monat" ? ziel!.slice(0, 7) : ziel
}
describe("Hauptuntersuchung: Panel und App rechnen gleich", () => {
for (const erst of ["01.03.2025", "2025-03-01", "15.01.2025", "20.11.2025",
"10.02.2025", "29.02.2024", "31.12.2023", "03/2025",
"31.02.2025", "", "Unsinn"]) {
it(`Erstzulassung ${JSON.stringify(erst)}`, () => {
expect(hauptuntersuchungFaellig([], erst)).toBe(panelHU(erst))
})
}
const paare: [string, string][] = [["15.01.2025", "2025-01-15"], ["20.11.2025", "2025-11-20"],
["10.02.2025", "2025-02-10"], ["29.02.2024", "2024-02-29"]]
for (const [de, isoDatum] of paare) {
it(`Wartungsplan ${de}`, () => {
expect(hauptuntersuchungFaellig([{ art: "Hauptuntersuchung", datum: isoDatum }], "01.03.2025"))
.toBe(panelHU("01.03.2025", de))
})
}
})
-3
View File
@@ -58,7 +58,6 @@ export interface Fahrzeug {
fin: string
details: string
erstzulassung: string
hu: string
odo: number
odoBekannt: boolean
tankPct: number
@@ -174,7 +173,6 @@ export function profilZuFahrzeug(profil: Profil, status: Fahrzeugstatus): Fahrze
fin: text(fahrzeug, "fin"),
details: text(fahrzeug, "details"),
erstzulassung: text(fahrzeug, "erstzulassung"),
hu: text(fahrzeug, "hauptuntersuchung_faellig"),
odo: status.km ?? 0,
odoBekannt: status.km !== null && status.km !== undefined,
tankPct: status.tankprozent ?? 0,
@@ -247,7 +245,6 @@ export function zusammenfuehren(
f["tankvolumen_liter"] = einstellungen.tankvolumen
f["fin"] = fahrzeug.fin
f["erstzulassung"] = fahrzeug.erstzulassung
f["hauptuntersuchung_faellig"] = fahrzeug.hu
e["uebersichtsbild"] = einstellungen.startbild
e["backup_intervall"] = einstellungen.backupIntervall
+52 -4
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"
import {
hauptuntersuchungFaellig,
inspektionPrognose,
kmProTagAusFahrten,
letzterOelwechsel,
@@ -17,7 +18,6 @@ function fahrzeug(odo: number, modus = "hersteller"): Fahrzeug {
fin: "",
details: "",
erstzulassung: "",
hu: "",
odo,
odoBekannt: true,
tankPct: 0,
@@ -147,15 +147,15 @@ describe("naechsterService", () => {
})
it("nimmt die Hauptuntersuchung auf, obwohl sie keine eigene Prognose hat", () => {
const f = { ...fahrzeug(46000), hu: "2026-09-01" }
const f = { ...fahrzeug(46000), erstzulassung: "01.03.2025" }
const s = naechsterService(f, [], [], jetzt)
expect(s).not.toBeNull()
expect(s!.art).toBe("Hauptuntersuchung")
expect(s!.restKm).toBeNull()
expect(s!.datum).toBe("2026-09-01")
expect(s!.datum).toBe("2028-03-01")
})
it("liefert nichts ohne Servicebuch und ohne gepflegte Hauptuntersuchung", () => {
it("liefert nichts ohne Servicebuch und ohne Erstzulassung", () => {
expect(naechsterService(fahrzeug(46000), [], [], jetzt)).toBeNull()
})
@@ -350,3 +350,51 @@ describe("oelMeldungsPrognose", () => {
expect(p!.datum).toBe("2027-08-11")
})
})
/* Gemeldet vom Eigentümer am 05.09.2026: Wartungsplan leer, Erstzulassung
01.03.2025 - erwartet 01.03.2028. Angezeigt wurden stattdessen DREI
verschiedene Antworten: Übersicht "01.08.2026", Mein Audi "August 2026",
Service "kein Eintrag im Wartungsplan". Ursache war ein Profilfeld
`hauptuntersuchung_faellig` ("08/2026"), das die Ableitung überstimmte,
selbst aber in keiner Oberfläche ein Eingabefeld hatte. Vorgabe seither:
"die eingabe reicht fürs erste mal in der Erstzulassung und danach über den
Wartungsplan" - zwei Quellen, keine dritte. */
describe("Hauptuntersuchung", () => {
const buchMit = (datum: string) => [{ art: "Hauptuntersuchung", datum, km: 40000 }]
it("leitet die erste aus der Erstzulassung ab: plus 36 Monate", () => {
expect(hauptuntersuchungFaellig([], "01.03.2025")).toBe("2028-03-01")
})
it("liest die Erstzulassung in jeder Schreibweise gleich", () => {
// Vor dem 05.09.2026 ergab dieselbe Erstzulassung je nach Schreibweise
// 2028-02-28 oder 2028-03-01 - toISOString() rechnete nach UTC.
for (const schreibweise of ["01.03.2025", "2025-03-01"]) {
expect(hauptuntersuchungFaellig([], schreibweise)).toBe("2028-03-01")
}
})
it("rechnet ab dem Wartungsplan-Eintrag mit 24 Monaten statt 36", () => {
expect(hauptuntersuchungFaellig(buchMit("2025-03-01"), "01.03.2025")).toBe("2027-03-01")
})
it("lässt den Wartungsplan die Erstzulassung schlagen", () => {
// Die Erstzulassung zählt nur bis zur ersten eingetragenen Untersuchung.
const spaeter = hauptuntersuchungFaellig(buchMit("2026-06-15"), "01.03.2025")
expect(spaeter).toBe("2028-06-15")
})
it("verliert keinen Tag über die Zeitumstellung", () => {
// monatePlus() rechnete früher über UTC und verlor dabei einen Tag,
// sobald Start und Ziel in verschiedenen Zeitzonen-Halbjahren lagen.
expect(hauptuntersuchungFaellig(buchMit("2025-01-15"), "")).toBe("2027-01-15")
expect(hauptuntersuchungFaellig(buchMit("2025-11-20"), "")).toBe("2027-11-20")
expect(hauptuntersuchungFaellig(buchMit("2025-02-10"), "")).toBe("2027-02-10")
})
it("sagt nichts, wenn es weder Eintrag noch Erstzulassung gibt", () => {
expect(hauptuntersuchungFaellig([], "")).toBeNull()
expect(hauptuntersuchungFaellig([], null)).toBeNull()
expect(hauptuntersuchungFaellig([], "keine Ahnung")).toBeNull()
})
})
+86 -11
View File
@@ -11,6 +11,7 @@
* Datum, gedeckelt auf spätestens das Zeitlimit des Intervalls.
*/
import { alsZeitpunkt, isoTag } from "../format"
import type { Fahrzeug } from "./profilAdapter"
const TAG_MS = 86_400_000
@@ -30,11 +31,28 @@ interface Buchhaltung {
art?: string
}
/* Durchgehend in ORTSZEIT gerechnet, ohne Umweg über UTC.
Vorher stand hier `new Date(isoDatum)` (das liest einen reinen Tag als
UTC-Mitternacht) und am Ende `toISOString()` (das rechnet zurück). Fällt
Start und Ziel in verschiedene Zeitumstellungen, verschiebt das um eine
Stunde - und die reicht über Mitternacht. Am 05.09.2026 gemessen, drei
von fünf Fällen daneben:
2025-01-15 + 6 Monate -> 2025-07-14 statt 2025-07-15
2025-11-20 + 6 Monate -> 2026-05-19 statt 2026-05-20
2025-02-10 + 4 Monate -> 2025-06-09 statt 2025-06-10
Betraf Ölwechsel- und Inspektionsprognose gleichermaßen. Das Panel
rechnet in `monatePlus()` von jeher in Ortszeit und war damit richtig -
eine stille Paritätsabweichung. */
function monatePlus(isoDatum: string, monate: number): string {
const d = new Date(isoDatum)
const teile = /^(\d{4})-(\d{2})-(\d{2})/.exec(isoDatum)
if (!teile) return isoDatum
const d = new Date(Number(teile[1]), Number(teile[2]) - 1 + monate, Number(teile[3]))
if (Number.isNaN(d.getTime())) return isoDatum
d.setMonth(d.getMonth() + monate)
return d.toISOString().slice(0, 10)
const zwei = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
}
/** Der jüngste Servicebucheintrag, dessen Art auf das gesuchte Stichwort
@@ -314,15 +332,72 @@ export function serviceKandidaten(
if (m) kandidaten.push({ art: "Inspektion", restKm: m.restKm, datum: m.datum })
}
// fahrzeug.hu wird von Hand gepflegt und kann leer oder unlesbar sein.
const hu = new Date(fahrzeug.hu)
if (fahrzeug.hu && !Number.isNaN(hu.getTime())) {
kandidaten.push({
art: "Hauptuntersuchung",
restKm: null,
datum: hu.toISOString().slice(0, 10),
})
/* Über dieselbe Ableitung wie Service und Mein Audi. Vorher stand hier
`new Date(fahrzeug.hu)` auf einem Profilfeld, das die Ableitung gar nicht
erst befragt hat - daher zeigte die Übersicht am 05.09.2026 den 01.08.2026,
während Service "kein Eintrag im Wartungsplan" meldete. Drei Bildschirme,
drei Antworten auf dieselbe Frage. */
const hu = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung)
if (hu) {
kandidaten.push({ art: "Hauptuntersuchung", restKm: null, datum: hu })
}
return kandidaten
}
/* Die Hauptuntersuchung hat ZWEI Quellen — wortgleich mit
hauptuntersuchungsTermin() im Panel:
1. der letzte Servicebuch-Eintrag plus **24** Monate.
2. die Erstzulassung plus **36** Monate.
Die 36 gegen 24 sind kein Versehen: die erste Hauptuntersuchung eines
Neuwagens ist nach drei Jahren fällig, jede weitere nach zwei (Vorgabe des
Eigentümers, 05.09.2026).
Ein drittes, überstimmendes Profilfeld gibt es nicht mehr — Begründung im
Panel bei hauptuntersuchungsTermin(). */
export const HU_MONATE_FOLGE = 24
export const HU_MONATE_ERSTE = 36
/** Der jüngste Servicebucheintrag, dessen Art auf eine Hauptuntersuchung
hindeutet.
Verlangt bewusst KEINE Kilometerangabe, anders als letzterEintrag(): eine
Hauptuntersuchung ist rein datumsbasiert, ihr Kilometerstand geht in keine
Rechnung ein. Bis zum 05.09.2026 lief sie über letzterEintrag() und wurde
damit stillschweigend übergangen, sobald der Eintrag kein km-Feld hatte -
die App wäre dann auf die Erstzulassung zurückgefallen und hätte drei
Jahre statt zwei gerechnet, während das Panel den Eintrag benutzte. */
export function letzteHauptuntersuchung(buch: readonly Buchhaltung[]): Buchhaltung | null {
const treffer = buch
.filter((e) => typeof e.art === "string" && /hauptunter/i.test(e.art) && e.datum)
.sort((a, b) => String(b.datum).localeCompare(String(a.datum)))
return treffer[0] ?? null
}
/**
* Die Fälligkeit der Hauptuntersuchung, oder null wenn sich keine bestimmen
* lässt.
*
* `null` heißt wirklich "unbekannt": es gibt weder einen Servicebuch-Eintrag
* noch eine lesbare Erstzulassung. Dann steht in der Oberfläche ein Strich -
* eine erfundene Fälligkeit wäre schlechter.
*/
export function hauptuntersuchungFaellig(
buch: readonly Buchhaltung[],
erstzulassung: string | null | undefined,
): string | null {
const eintrag = letzteHauptuntersuchung(buch)
if (eintrag?.datum) return monatePlus(eintrag.datum, HU_MONATE_FOLGE)
/* alsZeitpunkt() ist derselbe Parser, den auch die Anzeige benutzt, und
liefert die Genauigkeit gleich mit: eine Erstzulassung "03/2025" nennt
keinen Tag, also nennt der abgeleitete Termin auch keinen - "01.03.2028"
wäre eine Erfindung. Das Ergebnis ist dann ein ISO-Monat ("2028-03"),
den datum() als "März 2028" ausgibt. isoTag() rechnet den
Zeitzonenversatz heraus; toISOString() allein verschöbe den Tag. */
const z = alsZeitpunkt(erstzulassung)
if (!z) return null
const ziel = monatePlus(isoTag(z.zeit), HU_MONATE_ERSTE)
return z.genau === "monat" ? ziel.slice(0, 7) : ziel
}
+4 -1
View File
@@ -150,7 +150,10 @@ function bauen(jahr: number, monat: number, tag: number): Date | null {
return d
}
function alsZeitpunkt(wert: string | number | Date | null | undefined): Zeitpunkt | null {
/** Wortgleich mit datumAusText() im Panel. Exportiert, weil die Ableitung
der Hauptuntersuchung dieselbe Genauigkeit braucht wie die Anzeige: aus
einer Erstzulassung "03/2025" darf kein Termin mit Tag entstehen. */
export function alsZeitpunkt(wert: string | number | Date | null | undefined): Zeitpunkt | null {
if (wert === null || wert === undefined || wert === "") return null
if (wert instanceof Date) return Number.isNaN(wert.getTime()) ? null : { zeit: wert, genau: "tag" }
if (typeof wert === "number") {
+9 -4
View File
@@ -18,7 +18,7 @@ import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
import { Tile } from "@audi-dash/ui"
import { useDaten } from "../daten/DatenKontext"
import { inspektionPrognose, oelwechselPrognose } from "../daten/service"
import { hauptuntersuchungFaellig, inspektionPrognose, oelwechselPrognose } from "../daten/service"
import { datum, de, deOderStrich, eur } from "../format"
import type { SeitenName } from "../navigation"
import { Wertzeile, Werteliste } from "./bausteine"
@@ -108,6 +108,11 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
Der Wert ist bewusst die reale Fahrzeugmeldung (Datum plus „in X km"),
nicht die App-Prognose; fehlt sie, bleibt ein Strich. Die eigene Prognose
steht klein unter dem Namen (prognoseZeile() dort). */
/* Nicht mehr nur der eingetragene Wert: fehlt er, wird abgeleitet -
Servicebuch + 24 Monate, sonst Erstzulassung + 36. Wortgleich mit
hauptuntersuchungsTermin() im Panel; bis zum 05.09.2026 leitete nur
das Panel ab, die App zeigte einen Strich. */
const huFaellig = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung)
const oelProg = oelwechselPrognose(fahrzeug, buch)
const inspProg = inspektionPrognose(fahrzeug, buch)
const termine: { art: string; prognose: string | null; haupt: string; neben: string | null }[] = [
@@ -132,11 +137,11 @@ export function MeinAudi({ geheZu }: { geheZu: (name: SeitenName, id?: string) =
: null,
},
{
// Die Hauptuntersuchung meldet das Fahrzeug nie — hier zählt allein das
// im Profil hinterlegte Datum.
// Die Hauptuntersuchung meldet das Fahrzeug nie - sie wird immer
// abgeleitet, aus dem Wartungsplan oder der Erstzulassung.
art: "Hauptuntersuchung",
prognose: null,
haupt: fahrzeug.hu ? datum(fahrzeug.hu) : "",
haupt: huFaellig ? datum(huFaellig) : "",
neben: null,
},
]
+6 -11
View File
@@ -14,15 +14,7 @@ import { ActionButton, Feld, Fig, Tile } from "@audi-dash/ui"
import { kartendienstUrl } from "../daten/tankstelle"
import { useDaten } from "../daten/DatenKontext"
import {
inspektionPrognose,
letzterInspektion,
letzterOelwechsel,
meldungsPrognose,
oelMeldungsPrognose,
oelwechselPrognose,
serviceKandidaten,
} from "../daten/service"
import { hauptuntersuchungFaellig, inspektionPrognose, letzterInspektion, letzterOelwechsel, meldungsPrognose, oelMeldungsPrognose, oelwechselPrognose, serviceKandidaten } from "../daten/service"
import { datum, de, eur, isoTag } from "../format"
import type { SeitenName } from "../navigation"
import { SymbolInspektion, SymbolOelwechsel } from "../symbole"
@@ -129,7 +121,10 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
? `voraussichtlich am ${datum(inspMeldungProg.datum)}`
: null
const huDatum = fahrzeug.hu ? new Date(fahrzeug.hu) : null
/* Siehe MeinAudi.tsx: fehlt die eingetragene Faelligkeit, wird sie
abgeleitet - beide Bildschirme muessen dieselbe zeigen. */
const huFaellig = hauptuntersuchungFaellig(buch, fahrzeug.erstzulassung)
const huDatum = huFaellig ? new Date(huFaellig) : null
const huGueltig = !!huDatum && !Number.isNaN(huDatum.getTime())
return (
@@ -174,7 +169,7 @@ export function Service({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
<span>Hauptuntersuchung</span>
</div>
{huGueltig ? (
<div className="dm-tgwert">{datum(fahrzeug.hu)}</div>
<div className="dm-tgwert">{datum(huFaellig)}</div>
) : (
<div className="dm-tgwert dm-tgwert--leer">kein Eintrag im Wartungsplan</div>
)}
-1
View File
@@ -35,7 +35,6 @@ export const beispielProfil: Profil = {
details: "2.9 TFSI quattro competition",
kennzeichen: "M AB 1234",
erstzulassung: "2023-04-01",
hauptuntersuchung_faellig: "2027-04-01",
tankvolumen_liter: 58,
ausfuehrung: "competition",
},