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:
@@ -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)
|
||||
Reference in New Issue
Block a user