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,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 700–6 700 min⁻¹"],
|
||||
["Maximales Drehmoment", "600 Nm bei 2 000–5 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", "100–150 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"] }
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user