Initialer Import: HA-Panel, Design-System, Companion-App

Drei zusammengehörige Teile in einem Repository:

- homeassistant/  Das fertige, im Einsatz befindliche Home-Assistant-Panel
  (panel_custom Custom Element + pyscript-Backend). Echte Fahrzeug- und
  Personendaten (fahrzeugprofil.json, fahrten.jsonl, tankvorgaenge.jsonl,
  Tankbelege) bleiben per .gitignore außen vor; die anonymisierte Vorlage
  fahrzeugprofil.example.json ist mit dabei.

- design-system/  Eigenständige React-Komponentenbibliothek (@audi-dash/ui),
  die die visuelle Sprache des Panels nachbildet - ohne Audi-Markenzeichen
  und ohne die lizenzierte Hausschrift. Dient als Grundlage für Claude
  Design. War bis hierher ein eigenes Repository und ist in dieses
  eingeschmolzen worden.

- companion-app/  Datenschicht der neuen App DataMetric360 (iOS/Android via
  Capacitor, zusätzlich als Iframe im HA-Dashboard). Noch ohne Oberfläche:
  REST- und WebSocket-Zugriff auf Home Assistant plus Warteschlange für
  Änderungen ohne Netz. Ersetzt das eingespritzte hass-Objekt, das nur
  innerhalb des HA-Frontends existiert.

Dazu die Projektdokumentation: SPECIFICATION.md (Ist-Stand des Panels),
COMPANION_APP_ARCHITECTURE.md (Architekturentscheidungen der neuen App),
AUDIT_2026-08-10.md, DESIGN_BRIEF_DATAMETRIC360.md und der ursprüngliche
Bauauftrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 00:10:53 +02:00
commit e1d570992e
151 changed files with 18162 additions and 0 deletions
@@ -0,0 +1,185 @@
{
"_hinweis": "Vorlage ohne echte Fahrzeug-/Personendaten. Vor der Installation nach fahrzeugprofil.json kopieren und die Platzhalter unten ersetzen (VIN, Kennzeichen, Erstzulassung, HU-Termin, WLAN-Name, Versicherung, Werkstatt, Servicebuch) - am einfachsten über den Button 'Einrichten' in den Einstellungen der App, sobald sie einmal läuft. Diese Datei selbst bleibt bewusst im Repo (siehe .gitignore), die echte fahrzeugprofil.json nicht.",
"fahrzeug": {
"fin": "",
"kennzeichen": "",
"titel": "RS 4 Avant competition",
"zusatz": "Avant competition",
"untertitel": "quattro · 331 kW",
"details": "2.9 TFSI quattro competition · 331 kW · 450 PS",
"erstzulassung": "",
"hauptuntersuchung_faellig": "",
"wlan_name": "",
"tankvolumen_liter": 58,
"ausfuehrung": "competition"
},
"einstellungen": {
"nacht_von": "19:00",
"nacht_bis": "06:00",
"fahrten_pausenzeit_min": 15,
"uebersichtsbild": "Frontansicht",
"kraftstoffanbieter": "Shell",
"smartdeal": { "aktiv": false, "laeuft_ab": "" },
"oelwechsel_intervall": {
"modus": "hersteller",
"km": 15000,
"monate": 12,
"hersteller_km": 30000,
"hersteller_monate": 24
},
"backup_intervall": "aus",
"letztes_backup": null
},
"versicherung": {
"gesellschaft": "",
"vertrag": "",
"nummer": "",
"service": "",
"notfall": "",
"beitrag": 0,
"steueranteil": 0,
"steuersatz": "19 %",
"gueltigAb": "",
"zahlweise": "jährlich",
"teile": [
{ "bez": "Haftpflicht", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Vollkasko inklusive Teilkasko", "betrag": 0, "sf": "", "sfAlt": "", "satz": "", "satzAlt": "" },
{ "bez": "Schutzbrief Mobilität", "betrag": 0, "sf": null, "sfAlt": null, "satz": null, "satzAlt": null }
],
"selbst": [["Vollkasko", "0 €"], ["Teilkasko", "0 €"]],
"schutzbrief": {
"stand": "",
"gilt": "",
"versichert": "",
"ausloeser": [],
"entweder": [],
"gruppen": [],
"nicht": [],
"pflichten": [],
"rang": ""
},
"leistungen": []
},
"steuer": {
"betrag": 0,
"zeitraum": "jährlich",
"faellig": null,
"grundlage": "Hubraum und CO₂-Ausstoß",
"lastschrift": true,
"finanzamt": ""
},
"reifen": {
"aktiv": "sommer",
"anzugsmoment_nm": 120,
"wechsel": { "datum": "", "vereinbart": false },
"referenz_odo_km": null,
"saetze": {
"sommer": {
"km": 0,
"bild": "/local/bilder/rad-sommer.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "275/30 R20 97Y XL",
"druck_vorne": "2,5",
"druck_hinten": "2,4",
"kommentar": ""
},
"winter": {
"km": 0,
"bild": "/local/bilder/rad-winter.webp",
"marke": "",
"modell": "",
"dot": "",
"mass": "265/35 R19",
"druck_vorne": "2,6",
"druck_hinten": "2,5",
"kommentar": ""
}
}
},
"service": {
"vereinbart": {},
"autohaus": {
"name": "",
"adresse": "",
"tel": "",
"mail": "",
"pos": null
},
"buch": []
},
"technik": [
{ "gruppe": "Motor", "posten": [
["Bauart", "V6 Ottomotor, Direkteinspritzung, Biturbo"],
["Hubraum", "2 894 cm³"],
["Leistung", "331 kW / 450 PS bei 5 7006 700 min⁻¹"],
["Maximales Drehmoment", "600 Nm bei 2 0005 000 min⁻¹"],
["Abgasreinigung", "Katalysator, Lambdasonde, Ottopartikelfilter"],
["Abgasnorm", "EU6 AP"],
["Motormanagement", "vollelektronisch"]
]},
{ "gruppe": "Kraftübertragung", "posten": [
["Getriebe", "8-Gang tiptronic"],
["Antrieb", "quattro, permanent, selbstsperrendes Mittendifferenzial"],
["Kupplung", "hydraulischer Drehmomentwandler mit Überbrückung"]
]},
{ "gruppe": "Fahrleistungen", "posten": [
["0 auf 100 km/h", "3,9 s"],
["Höchstgeschwindigkeit", "290 km/h"],
["Effizienzklasse", "E"],
["Fahrgeräusch", "70 dB(A)"]
]},
{ "gruppe": "Fahrwerk, Lenkung, Bremsen", "posten": [
["Fahrwerk", "RS-Sportfahrwerk"],
["Vorderachse", "5-Lenker, Rohrstabilisator"],
["Hinterachse", "5-Lenker, Rohrstabilisator"],
["Lenkung", "elektromechanisch, geschwindigkeitsabhängig"],
["Wendekreis", "ca. 11,7 m"],
["Bremsanlage", "RS-Stahlbremse, Sättel rot glänzend"],
["Vorn", "Faustsattel, Scheibe innenbelüftet"],
["Hinten", "Faustsattel mit elektrischer Parkbremse, innenbelüftet"]
]},
{ "gruppe": "Räder und Reifen", "posten": [
["Räder", "Leichtmetall 9J × 20, Audi Sport"],
["Reifen", "275/30 R20 97Y XL"],
["Pannenhilfe", "Reifenreparaturset, kein Ersatzrad"],
["Radschrauben", "Standard"],
["Anzugsmoment", "120 Nm"]
]},
{ "gruppe": "Maße, Gewichte, Volumen", "posten": [
["Leergewicht", "1 820 kg"],
["Zulässiges Gesamtgewicht", "2 350 kg"],
["Dachlast / Stützlast", "90 / 80 kg"],
["Anhängelast gebremst", "2 100 kg bei 8 %, 1 900 kg bei 12 %"],
["Anhängelast ungebremst", "750 kg"],
["Gepäckraum", "495 / 1 495 l"],
["Tankinhalt", "58 l, erhöhtes Füllvolumen für quattro"]
]},
{ "gruppe": "Bordnetz", "posten": [
["Batterie", "420 A, 75 Ah"],
["Generator", "100150 A"],
["Kältemittel", "R1234yf"]
]},
{ "gruppe": "Wartung", "posten": [
["Serviceanzeige", "30 000 km oder 2 Jahre, variabel"],
["Garantie", "Anschlussgarantie 3 Jahre, max. 100 000 km"]
]}
],
"ausstattung": [
{ "gruppe": "RS-Paket", "sonder": ["RS competition", "RS-Designpaket rot", "RS-Komfortpaket", "RS-Sportabgasanlage plus", "RS-Sportfahrwerk", "RS-Stahlbremsanlage, Sättel rot glänzend", "Bremssättel rot lackiert", "Anhebung der Höchstgeschwindigkeit auf 290 km/h", "Sicherheitsgurte schwarz mit rotem Rand", "RS-Einstiegsleisten"] },
{ "gruppe": "Außen", "sonder": ["Optikpaket schwarz plus", "Optikpaket schwarz glänzend", "Panorama-Glasdach", "Dachreling schwarz", "Anhängevorrichtung", "Gepäckraumklappe elektrisch öffnend und schließend", "Matrix-LED-Scheinwerfer mit dynamischer Lichtinszenierung", "LED-Heckleuchten mit dynamischer Lichtinszenierung", "Außenspiegel elektrisch, beheiz- und anklappbar, abblendend, mit Memory", "Außenspiegelgehäuse in Wagenfarbe", "Sonnenschutzverglasung abgedunkelt"] },
{ "gruppe": "Innen", "sonder": ["Mikrofaser Dinamica/Leder mit Wabensteppung und RS-Stickung", "Sportsitze plus vorn", "Vordersitze elektrisch mit Memory für den Fahrersitz", "Sitzheizung vorn und hinten", "Lendenwirbelstütze mit Massagefunktion", "Dekoreinlagen Carbon Köper matt", "Ambiente-Lichtpaket plus", "3-Zonen-Komfortklimaautomatik", "Lederlenkrad 3-Speichen mit Lenkradheizung", "Innenspiegel automatisch abblendend, rahmenlos", "USB-Ladeschnittstellen im Fond", "Sonnenschutzrollo hinten", "Fußmatten RS-Designpaket"] },
{ "gruppe": "Infotainment", "sonder": ["MMI Navigation plus mit MMI touch", "Audi virtual cockpit plus", "Head-up-Display", "Bang & Olufsen Premium Sound System mit 3D-Klang", "Audi phone box", "LTE-Unterstützung für Audi phone box", "Audi connect Navigation & Infotainment"] },
{ "gruppe": "Assistenz", "sonder": ["Assistenzpaket Tour", "Assistenzpaket Stadt mit Parken", "Audi adaptive cruise control", "Audi active lane assist", "Halteassistent", "Parkassistent mit Einparkhilfe plus", "Umgebungskameras", "Kamerabasierte Verkehrszeichenerkennung", "Audi pre sense rear und basic", "Garagentoröffner", "Komfortschlüssel mit sensorgesteuerter Heckklappe"] },
{ "gruppe": "Serie, ausgewählt", "sonder": ["Audi drive select", "Audi pre sense city", "Spurwechselwarnung", "Reifendruck-Kontrollanzeige", "Start-Stop-System", "Audi connect Notruf & Service mit Remote & Control", "Smartphone-Interface", "Kraftstoffbehälter mit erhöhtem Füllvolumen", "Pedalerie und Fußstütze aus Edelstahl"] }
]
}
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Parser für Shell-Tankbelege (PDF), aufgerufen von belegverarbeitung.py.
Diese Datei fehlte beim Bau im Projektordner (siehe Kopfkommentar in
pyscript/belegverarbeitung.py und README.md, Abschnitt "Bekannte Lücken").
Sie ist hier gegen echte Shell-eReceipts neu geschrieben und gegen deren
tatsächlich von pypdf extrahierten Text geprüft - nicht gegen eine
angenommene Struktur. Regressionstest mit zehn realen Belegen aus vier
Stationen liegt unter tests/test_shell_beleg_parser.py (Aufruf: python3
tests/test_shell_beleg_parser.py aus diesem Ordner). Die Belege
unterscheiden sich u. a. in der Markenzeile ("SHELL STATION" vs.
"Shell Station" vs. "Shell-Station") und der Rabattbezeichnung
("V-Power Smart Deal" vs. "ClubSmartRabatt") - deshalb ist die
Stations- und Rabatterkennung unten bewusst nicht an deren genauen
Wortlaut gebunden, sondern an feste Beleg-Struktur-Marker (Obj.-Nr.,
Betragsmuster vor "EUR #A").
Abhängigkeit: pypdf (reine Textextraktion, der Beleg ist ein normales
Text-PDF, kein Scan/Bild - kein OCR nötig). Läuft als eigener Prozess über
`python3 shell_beleg_parser.py <pfad>` (system-python3 im Container, nicht
die pyscript-Sandbox - siehe _parser_aufrufen() in belegverarbeitung.py),
deshalb reicht `pip install pypdf` einmalig im selben Python, das
`python3` in configuration.yaml/Docker auch sonst benutzt.
Kommandozeilen-Schnittstelle wie von belegverarbeitung.py angenommen:
Ein Pfad zu einer PDF-Datei als einziges Argument, JSON-Objekt auf stdout
bei Erfolg, Exit-Code ungleich 0 und Fehlertext auf stderr bei Misserfolg
(z. B. wenn der Beleg keiner der erkannten Vorlagen entspricht).
Aufbau eines Shell-eReceipts (aus dem echten Beispiel, Reihenfolge fix):
SHELL STATION
<Betreibername, z.B. "A. Zrenner GmbH">
<Straße>
<PLZ Ort>
Obj.-Nr.: <Stations-ID> Tel. ...
Beleg-Nr. <receipt_no> <TT.MM.JJ> <HH:MM>
*<article_no> <Produktname> <Listenbetrag> EUR #A*
*Zp <Zapfsäule> <Liter> l <Listenpreis/l> EUR/l # *
[<Rabattname> -<Rabattbetrag> EUR #A -- optional, nur mit SmartDeal
<Rabatt/l> EUR/Liter -- optional]
Gesamtbetrag <Endbetrag> EUR
Typ Netto Mwst Brutto
A:<Mwst-Satz>% <Netto> <Mwst> <Brutto>
...
Mobile Payment
<TT.MM.JJJJ> <HH:MM:SS> -- Zahlungszeitstempel
...
(Seite 2) POS:...-...-... TSE-Beleg-Nr: <tse_beleg_nr>
Start/Ende: <TT.MM.JJJJ HH:MM:SS> / ...
"""
import datetime
import json
import re
import sys
try:
import pypdf
except ImportError:
print(
"pypdf fehlt. Einmalig installieren mit: pip install pypdf "
"(im selben Python, das diesen Parser ausführt, siehe Kopfkommentar).",
file=sys.stderr,
)
sys.exit(1)
def _text_aus_pdf(pdf_pfad):
leser = pypdf.PdfReader(pdf_pfad)
return "\n".join(seite.extract_text() or "" for seite in leser.pages)
def _de_zahl(text):
""""103,06" -> 103.06 / "2,399" -> 2.399. Kein Tausenderpunkt bei
Tankbeträgen dieser Größenordnung zu erwarten, trotzdem robust: Punkte
vor dem Umwandeln entfernen, nicht nur das Komma ersetzen."""
if text is None:
return None
return float(text.strip().replace(".", "").replace(",", "."))
def _iso(tag, monat, jahr, stunde, minute, sekunde="0"):
jahr = int(jahr)
if jahr < 100:
jahr += 2000
return datetime.datetime(
jahr, int(monat), int(tag), int(stunde), int(minute), int(sekunde)
).isoformat()
_ZAHL = r"[\d.,]+"
def _parsen(text):
def suchen(muster, *, pflicht=True, flags=re.MULTILINE):
treffer = re.search(muster, text, flags)
if treffer is None and pflicht:
raise ValueError(f"Muster nicht gefunden: {muster!r}")
return treffer
# ---- Station ---------------------------------------------------------
# Nicht auf den Wortlaut der Markenzeile selbst verankert ("SHELL
# STATION" vs. "Shell-STATION" - beide an echten Belegen gesehen,
# offenbar je nach Pächter/Kassensystem unterschiedlich geschrieben).
# Stattdessen einfach die drei Zeilen direkt vor "Obj.-Nr." nehmen - die
# Reihenfolge Betreiber/Straße/Ort direkt davor ist auf beiden echten
# Belegen gleich.
station = suchen(
r"(?P<name>.+?)\s*\n\s*(?P<strasse>.+?)\s*\n\s*(?P<ort>.+?)\s*\n\s*Obj\.-Nr\."
)
station_id = suchen(r"Obj\.-Nr\.:\s*(\d+)").group(1)
# ---- Beleg-Kopf (Beleg-Nr., Datum/Zeit auf dem Kassenbon) ----------
kopf = suchen(
r"Beleg-Nr\.\s*(?P<receipt_no>[\d/]+)\s+(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{2})\s+(?P<stunde>\d{2}):(?P<minute>\d{2})"
)
receipt_no = kopf.group("receipt_no")
ts = _iso(kopf.group("tag"), kopf.group("monat"), kopf.group("jahr"),
kopf.group("stunde"), kopf.group("minute"))
# ---- Kraftstoff-Position --------------------------------------------
position = suchen(
r"\*(?P<article_no>\d+)\s+(?P<produkt>.+?)\s+" + _ZAHL + r"\s*EUR\s*#A\*"
)
article_no = position.group("article_no")
product_name = position.group("produkt").strip()
zapfsaeule = suchen(
r"\*Zp\s*\d+\s+(?P<liter>" + _ZAHL + r")\s*l\s+(?P<preis>" + _ZAHL + r")\s*EUR/l"
)
liters = _de_zahl(zapfsaeule.group("liter"))
list_price_per_l = _de_zahl(zapfsaeule.group("preis"))
# ---- SmartDeal-Rabatt (optional - nicht jeder Tankvorgang hat einen) --
rabatt_treffer = suchen(r"-(" + _ZAHL + r")\s*EUR\s*#A\s*$", pflicht=False)
discount = _de_zahl(rabatt_treffer.group(1)) if rabatt_treffer else None
rabatt_l_treffer = suchen(r"(" + _ZAHL + r")\s*EUR/Liter", pflicht=False)
discount_per_l = _de_zahl(rabatt_l_treffer.group(1)) if rabatt_l_treffer else None
# ---- Beträge ----------------------------------------------------------
fuel_total_eur = _de_zahl(suchen(r"Gesamtbetrag\s+(" + _ZAHL + r")\s*EUR").group(1))
steuerzeile = suchen(
r"A:" + _ZAHL + r"%\s+(?P<netto>" + _ZAHL + r")\s+(?P<mwst>" + _ZAHL + r")\s+(?P<brutto>" + _ZAHL + r")"
)
net_eur = _de_zahl(steuerzeile.group("netto"))
vat_eur = _de_zahl(steuerzeile.group("mwst"))
receipt_total_eur = _de_zahl(steuerzeile.group("brutto"))
# ---- Zahlungszeitstempel (Mobile Payment) - optional, falls anderes
# Zahlungsverfahren auf dem Beleg steht ---------------------------------
zahlung = suchen(
r"Mobile Payment\s*\n+\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_payment = (
_iso(zahlung.group("tag"), zahlung.group("monat"), zahlung.group("jahr"),
zahlung.group("stunde"), zahlung.group("minute"), zahlung.group("sekunde"))
if zahlung else None
)
# ---- TSE-Signatur (Seite 2) - optional -------------------------------
tse_nr = suchen(r"TSE-Beleg-Nr:\s*(\d+)", pflicht=False)
tse_beleg_nr = tse_nr.group(1) if tse_nr else None
tse_zeit = suchen(
r"Start/Ende:\s*(?P<tag>\d{2})\.(?P<monat>\d{2})\.(?P<jahr>\d{4})\s+(?P<stunde>\d{2}):(?P<minute>\d{2}):(?P<sekunde>\d{2})",
pflicht=False,
)
ts_tse = (
_iso(tse_zeit.group("tag"), tse_zeit.group("monat"), tse_zeit.group("jahr"),
tse_zeit.group("stunde"), tse_zeit.group("minute"), tse_zeit.group("sekunde"))
if tse_zeit else None
)
# receipt_key: minutengenauer Deduplizierungs-Schlüssel (§6.4/§7.7 Regel 3)
receipt_key = f"{station_id}_{receipt_no.replace('/', '-')}_{ts[:16]}"
return {
"receipt_key": receipt_key,
"receipt_no": receipt_no,
"tse_beleg_nr": tse_beleg_nr,
"ts": ts,
"ts_payment": ts_payment,
"ts_tse": ts_tse,
"station_id": station_id,
"station_name": station.group("name").strip(),
"station_address": f"{station.group('strasse').strip()}, {station.group('ort').strip()}",
"article_no": article_no,
"product_name": product_name,
"fuel_type": product_name,
"liters": liters,
"list_price_per_l": list_price_per_l,
"discount": discount,
"discount_per_l": discount_per_l,
"fuel_total_eur": fuel_total_eur,
"receipt_total_eur": receipt_total_eur,
"net_eur": net_eur,
"vat_eur": vat_eur,
}
def main():
if len(sys.argv) != 2:
print("Aufruf: python3 shell_beleg_parser.py <pfad-zur-pdf>", file=sys.stderr)
sys.exit(1)
pdf_pfad = sys.argv[1]
try:
text = _text_aus_pdf(pdf_pfad)
ergebnis = _parsen(text)
except Exception as exc:
print(f"Beleg konnte nicht geparst werden ({type(exc).__name__}): {exc}", file=sys.stderr)
sys.exit(1)
print(json.dumps(ergebnis, ensure_ascii=False))
if __name__ == "__main__":
main()
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Regressionstest für shell_beleg_parser.py gegen zehn echte Shell-eReceipts.
Die PDFs in belege/ sind reale, vom Nutzer bereitgestellte Tankbelege aus
vier verschiedenen Stationen (Ingolstadt/Zrenner, Rain am Lech/Bauch,
Ansbach/Sengül, Königsbronn/Mogler) und decken damit die tatsächlich
beobachtete Formatvielfalt ab, nicht nur eine angenommene Struktur:
- Markenzeile: "SHELL STATION" / "Shell Station" / "Shell-Station"
- Rabattbezeichnung: "V-Power Smart Deal" / "ClubSmartRabatt"
- POS-Zeile: mit und ohne "68002-"-Präfix vor der Stations-ID
- unterschiedliche Fußzeilen-Länge/-Inhalte
Aufruf: python3 data/tests/test_shell_beleg_parser.py
(braucht pypdf im selben Python wie shell_beleg_parser.py selbst)
"""
import os
import sys
import unittest
_HIER = os.path.dirname(os.path.abspath(__file__))
_BELEGE = os.path.join(_HIER, "belege")
sys.path.insert(0, os.path.dirname(_HIER)) # .. /data, wo shell_beleg_parser.py liegt
import shell_beleg_parser as p # noqa: E402
def _parsen_datei(dateiname):
pfad = os.path.join(_BELEGE, dateiname)
return p._parsen(p._text_aus_pdf(pfad))
class AlleBelegeParsenOhneFehler(unittest.TestCase):
"""Jeder der zehn echten Belege muss ohne Exception durchlaufen und
strukturell plausible Werte liefern - das ist der eigentliche Zweck
dieser Suite: künftige Regex-Änderungen dürfen keinen der bekannten
realen Belegtypen mehr brechen."""
@classmethod
def setUpClass(cls):
cls.dateien = sorted(f for f in os.listdir(_BELEGE) if f.endswith(".pdf"))
cls.ergebnisse = {f: _parsen_datei(f) for f in cls.dateien}
def test_zehn_belege_gefunden(self):
self.assertEqual(len(self.dateien), 10, self.dateien)
def test_pflichtfelder_gesetzt(self):
pflichtfelder = [
"receipt_key", "receipt_no", "ts", "station_id", "station_name",
"station_address", "article_no", "product_name", "fuel_type",
"liters", "list_price_per_l", "fuel_total_eur",
"receipt_total_eur", "net_eur", "vat_eur",
]
for datei, d in self.ergebnisse.items():
for feld in pflichtfelder:
with self.subTest(datei=datei, feld=feld):
self.assertIsNotNone(d[feld], f"{feld} fehlt in {datei}")
def test_betraege_plausibel(self):
for datei, d in self.ergebnisse.items():
with self.subTest(datei=datei):
self.assertGreater(d["liters"], 0)
self.assertGreater(d["list_price_per_l"], 0)
self.assertGreater(d["fuel_total_eur"], 0)
# Netto + MwSt muss dem Bruttobetrag entsprechen (Rundung: 1 Cent)
self.assertAlmostEqual(d["net_eur"] + d["vat_eur"], d["receipt_total_eur"], delta=0.01)
# Gesamtbetrag der Position == Bruttobetrag der Steuerzeile
self.assertAlmostEqual(d["fuel_total_eur"], d["receipt_total_eur"], delta=0.01)
if d["discount"] is not None:
self.assertGreater(d["discount"], 0)
# Rabatt darf den Bruttobetrag nicht übersteigen
self.assertLess(d["discount"], d["fuel_total_eur"])
def test_receipt_key_eindeutig(self):
schluessel = [d["receipt_key"] for d in self.ergebnisse.values()]
self.assertEqual(len(schluessel), len(set(schluessel)), schluessel)
def test_bekannte_stationen(self):
# Vier verschiedene Betreiber/Städte in den zehn Belegen - stellt sicher,
# dass die Stationserkennung nicht zufällig nur für einen Betreiber passt.
namen = {d["station_name"] for d in self.ergebnisse.values()}
erwartet = {
"A. Zrenner GmbH", "Thomas Bauch GmbH",
"TC Sengül GmbH", "Hermann Mogler Mineralölg. GmbH",
}
self.assertEqual(namen, erwartet)
def test_rabatt_label_unabhaengig(self):
# e-receipt7.pdf nutzt "ClubSmartRabatt" statt "V-Power Smart Deal" -
# die Rabatterkennung darf nicht am Label hängen, nur am Betragsmuster.
d = self.ergebnisse["e-receipt7.pdf"]
self.assertAlmostEqual(d["discount"], 6.95, delta=0.001)
self.assertAlmostEqual(d["discount_per_l"], 0.18, delta=0.001)
def test_stichproben_werte(self):
# Punktuelle Kontrolle einzelner, von Hand aus dem Beleg abgelesener
# Werte - fängt stille Verschiebungen (z.B. falsche Regex-Gruppe) ab,
# die die reinen Plausibilitätschecks oben nicht erkennen würden.
stichproben = {
"e-receipt.pdf": dict(liters=42.15, fuel_total_eur=78.78, discount=11.80, station_id="0000007770"),
"e-receipt3.pdf": dict(liters=21.73, fuel_total_eur=44.53, discount=6.08, station_id="0000000456"),
"e-receipt5.pdf": dict(liters=47.76, fuel_total_eur=79.23, discount=12.90, station_id="0000002227"),
"e-receipt9.pdf": dict(liters=51.59, fuel_total_eur=85.59, discount=13.41, station_id="0000002103"),
}
for datei, erwartet in stichproben.items():
d = self.ergebnisse[datei]
with self.subTest(datei=datei):
self.assertAlmostEqual(d["liters"], erwartet["liters"], delta=0.001)
self.assertAlmostEqual(d["fuel_total_eur"], erwartet["fuel_total_eur"], delta=0.001)
self.assertAlmostEqual(d["discount"], erwartet["discount"], delta=0.001)
self.assertEqual(d["station_id"], erwartet["station_id"])
class BelegOhneRabatt(unittest.TestCase):
"""SmartDeal/ClubSmart ist optional - nicht jeder Tankvorgang hat einen
Rabatt. Simuliert durch Entfernen der Rabattzeilen aus einem echten
Beleg, da keiner der zehn realen Belege rabattfrei war."""
def test_ohne_rabatt_bleibt_alles_andere_korrekt(self):
text = p._text_aus_pdf(os.path.join(_BELEGE, "e-receipt.pdf"))
ohne_rabatt = "\n".join(
z for z in text.split("\n")
if "Smart Deal" not in z and "EUR/Liter" not in z
)
d = p._parsen(ohne_rabatt)
self.assertIsNone(d["discount"])
self.assertIsNone(d["discount_per_l"])
self.assertAlmostEqual(d["fuel_total_eur"], 78.78, delta=0.001)
self.assertAlmostEqual(d["liters"], 42.15, delta=0.001)
if __name__ == "__main__":
unittest.main(verbosity=2)