diff --git a/.tmp-patch.mjs b/.tmp-patch.mjs new file mode 100644 index 0000000..67596a9 --- /dev/null +++ b/.tmp-patch.mjs @@ -0,0 +1,49 @@ +import { readFileSync, writeFileSync } from "node:fs" +const p = "custom_components/audi_dashboard/shell_beleg_parser.py" +let s = readFileSync(p, "utf8") +const alt = [ + "def _marke(zeilen):", + ' """Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn', + ' keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen."""', + ' kopf = "\n".join(zeilen[:8])', + " for marke in _MARKEN:", + ' if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE):', + " return marke", + " return None", +].join("\n") +const neu = [ + "def _betragszeile(zeile):", + ' """Eine Summenzeile ("GESAMT 78,78 EUR")? Sie darf im Kopf nicht nach', + " Marken durchsucht werden.", + "", + ' Grund: "Total" ist beides - eine Kopfmarke und ein Summenwort. Auf einem', + " Beleg mit knappem Kopf, der den Betrag schon in den ersten acht Zeilen", + ' druckt, wuerde daraus faelschlich die Marke "Total" (am 04.09.2026 an', + " einem echten Beleg mit vorangestellter Summenzeile nachgestellt und", + " reproduziert). Verlangt werden beide Kennzeichen zugleich - Summenwort", + " UND echte Geldangabe -, damit eine Anschrift mit Hausnummer oder eine", + ' Postleitzahl ("SHELL TANKSTELLE, 6450 SÖLDEN") nicht mitfaellt."""', + ' kompakt = re.sub(r"\s+", "", zeile).upper()', + " if not any(wort in kompakt for wort in _GESAMT_WOERTER):", + " return False", + " return bool(re.search(_GELD, zeile))", + "", + "", + "def _marke(zeilen):", + ' """Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn', + " keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen.", + "", + " Acht Zeilen, weil die Marke in den bekannten Belegen in Zeile 0 steht und", + " im oesterreichischen Format in Zeile 1 (am 04.09.2026 ueber alle zwoelf", + ' abgelegten Belege gemessen) - das Fenster ist der Puffer, nicht die",', + ' Erwartung."""', + " kopf = [z for z in zeilen[:8] if not _betragszeile(z)]", + ' text = "\n".join(kopf)', + " for marke in _MARKEN:", + ' if re.search(r"\b" + re.escape(marke) + r"\b", text, re.IGNORECASE):', + " return marke", + " return None", +].join("\n") +if (s.split(alt).length - 1 !== 1) throw new Error("Anker nicht eindeutig") +writeFileSync(p, s.replace(alt, neu)) +console.log("ok") diff --git a/AGENTS.md b/AGENTS.md index 3e64517..c2e1b2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10855,3 +10855,62 @@ dieselben Eingaben laufen lassen, nicht nachgebaut. OTA-Buendel (2026.9.4.21) und brauchen keinen Xcode-Lauf. Der offene Punkt aus Abschnitt CM bleibt: dass die `packageClassList`-Registrierung auf dem Geraet greift, zeigt erst der naechste Xcode-Lauf. + +## CO. "Gibt es eine Garantie, dass alle Tankstellen ihre Belege gleich gestalten?" - Nein. Was gemessen wurde (2026.9.4.22) + +Die Frage des Eigentuemers zur Markenerkennung. Die ehrliche Antwort ist nein, +und sie ist gemessen statt behauptet. + +### Wovon die Erkennung abhaengt + +Drei Annahmen, jede eine mit Bruchstelle: + +1. **Die Marke steht in den ersten acht Zeilen.** Gemessen ueber alle zwoelf + abgelegten Belege: elfmal Zeile 0, beim oesterreichischen Format Zeile 1. + Das Fenster ist der Puffer, nicht die Erwartung - ab Zeile 8 findet sie + niemand mehr (nachgestellt, liefert `None`). +2. **Die Marke steht in `_MARKEN`.** Eine feste Liste von 26 Namen, von Hand + gepflegt. Was nicht drinsteht, wird nicht erkannt. +3. **Sie steht ueberhaupt drauf.** Freie Tankstellen fuehren keine. + +### Der Ausfall beschaedigt nichts - nachgestellt + +Kopfzeile eines echten Belegs durch `OIL! Tankstelle`, `Hoyer Tankstelle`, +`Freie Tankstelle Mueller` ersetzt: + +* `station_brand` wird `None`, +* `station_name` faellt auf den Betreibernamen zurueck, +* Liter, Betrag, Rabatt, Anschrift bleiben **unveraendert richtig**. + +In der Anzeige heisst das `Pascalstr., Ingolstadt` statt +`Shell, Pascalstr., Ingolstadt` - die Marke fehlt, sonst nichts. Genau so soll +es sein: **ein falscher Name waere schlimmer als keiner.** + +### Ein echter Defekt, dabei gefunden + +`"Total"` ist **beides**: eine Kopfmarke und ein Summenwort. Ein Beleg, der den +Betrag schon in den ersten acht Zeilen druckt, lieferte faelschlich die Marke +`Total` - an einem echten Beleg mit vorangestellter Summenzeile reproduziert. +Der Kommentar an `_GELD` warnt seit jeher vor genau diesem Wort; das Fenster +allein war die einzige Absicherung. + +Jetzt filtert `_betragszeile()` Summenzeilen aus dem Kopf, bevor gesucht wird. +Verlangt werden **beide** Kennzeichen zugleich - Summenwort UND echte +Geldangabe -, damit eine Kopfzeile wie `SHELL TANKSTELLE, 6450 SÖLDEN` nicht +mitfaellt. Gegenprobe geprueft: ein echter `TOTAL TANKSTELLE`-Kopf ergibt +weiterhin `Total`. + +### Was daraus NICHT folgt + +Die Erkennung ist **an genau einer Marke belegt**: alle zwoelf abgelegten +Belege sind Shell. Ueber die anderen 25 Eintraege der Liste sagt das nichts - +sie sind plausibel, aber ungeprueft. Ein Beleg einer anderen Kette waere der +erste echte Test. + +### Verifiziert + +Parser-Suite jetzt **17 Tests** (von 11): die neue Klasse +`MarkenerkennungGrenzen` haelt fest, wo die Erkennung greift, wo sie aufgibt, +und dass ein Aufgeben den Beleg nicht beschaedigt. Alle zwoelf Belege liefern +weiterhin `Shell`. `audi_ha_test` auf 2026.9.4.22, 0 Tracebacks, das Nachtragen +lief erwartungsgemaess **nicht** erneut. diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 2222338..a313652 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.4.21","sha256":"3601a55ee4120c8508dd8f5018dcfbe806f10d87fd706763a0bbd97377d217d1","bytes":323037,"gebaut":"2026-09-04T14:55:41Z"} \ No newline at end of file +{"version":"2026.9.4.22","sha256":"66406934fb50aa810ff782ce877bc82d25ec8d338979d5ae9e28e5dc5c2ea521","bytes":323041,"gebaut":"2026-09-04T15:08:22Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index c3430de..567de5c 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 2532266..28b91cb 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.4.21", + "version": "2026.9.4.22", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": [ diff --git a/custom_components/audi_dashboard/shell_beleg_parser.py b/custom_components/audi_dashboard/shell_beleg_parser.py index 4ae46f9..2070b0a 100644 --- a/custom_components/audi_dashboard/shell_beleg_parser.py +++ b/custom_components/audi_dashboard/shell_beleg_parser.py @@ -113,12 +113,40 @@ _MARKEN = ( _STRASSE_WOERTER = r"(?:stra(?:ss|ß)e|str\.|gasse|weg|platz|allee|ring|damm|chaussee)" +def _betragszeile(zeile): + """Eine Summenzeile ("GESAMT 78,78 EUR")? Sie darf im Kopf nicht nach + Marken durchsucht werden. + + Grund: "Total" ist beides - eine Kopfmarke und ein Summenwort. Auf einem + Beleg mit knappem Kopf, der den Betrag schon in den ersten acht Zeilen + druckt, wuerde daraus faelschlich die Marke "Total" (am 04.09.2026 an einem + echten Beleg mit vorangestellter Summenzeile nachgestellt und + reproduziert). Verlangt werden beide Kennzeichen ZUGLEICH - Summenwort UND + echte Geldangabe -, damit eine Anschrift mit Hausnummer oder eine + Postleitzahl ("SHELL TANKSTELLE, 6450 SÖLDEN") nicht mitfaellt.""" + kompakt = re.sub(r"\s+", "", zeile).upper() + if not any(wort in kompakt for wort in _GESAMT_WOERTER): + return False + return bool(re.search(_GELD, zeile)) + + def _marke(zeilen): """Markenname aus dem Belegkopf ("SHELL STATION" -> "Shell"). None, wenn - keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen.""" - kopf = "\n".join(zeilen[:8]) + keine bekannte Marke draufsteht - dann bleibt es beim Betreibernamen. + + Acht Zeilen, weil die Marke in allen zwoelf abgelegten Belegen in Zeile 0 + steht, im oesterreichischen Format in Zeile 1 (am 04.09.2026 gemessen). + Das Fenster ist der Puffer, nicht die Erwartung. + + KEINE GARANTIE, UND DAS IST ABSICHT: Belege sind nicht genormt. Steht die + Marke woanders, heisst sie anders oder fuehrt die Station gar keine (freie + Tankstellen), gibt es hier None - und der Beleg wird trotzdem vollstaendig + eingelesen, nur eben ohne Marke. Ein falscher Name waere schlimmer als + keiner.""" + kopf = [z for z in zeilen[:8] if not _betragszeile(z)] + text = "\n".join(kopf) for marke in _MARKEN: - if re.search(r"\b" + re.escape(marke) + r"\b", kopf, re.IGNORECASE): + if re.search(r"\b" + re.escape(marke) + r"\b", text, re.IGNORECASE): return marke return None diff --git a/tests/belegparser/test_shell_beleg_parser.py b/tests/belegparser/test_shell_beleg_parser.py index 2e6795c..1749ece 100644 --- a/tests/belegparser/test_shell_beleg_parser.py +++ b/tests/belegparser/test_shell_beleg_parser.py @@ -165,5 +165,68 @@ class BelegOhneRabatt(unittest.TestCase): self.assertAlmostEqual(d["liters"], 42.15, delta=0.001) + +class MarkenerkennungGrenzen(unittest.TestCase): + """Was die Markenerkennung leistet - und was ausdrücklich nicht. + + Belege sind nicht genormt. Diese Suite hält fest, wo die Erkennung greift, + wo sie aufgibt, und dass ein Aufgeben den Beleg nicht beschädigt. + """ + + @classmethod + def setUpClass(cls): + cls.zeilen = p._text_aus_pdf(os.path.join(_BELEGE, "e-receipt.pdf")).splitlines() + + def test_marke_steht_ganz_oben(self): + # Gemessen am 04.09.2026: in allen elf Belegen in Zeile 0. Das Fenster + # von acht Zeilen ist der Puffer, nicht die Erwartung - der + # österreichische Beleg (nur in der Instanz) hat sie in Zeile 1. + for datei in sorted(f for f in os.listdir(_BELEGE) if f.endswith(".pdf")): + with self.subTest(datei=datei): + zeilen = p._text_aus_pdf(os.path.join(_BELEGE, datei)).splitlines() + self.assertEqual(p._marke(zeilen[:1]), "Shell") + + def test_ausserhalb_des_fensters_wird_nicht_gefunden(self): + # Ehrlich dokumentiert statt stillschweigend angenommen: steht die + # Marke erst ab Zeile 8, findet sie niemand. + verschoben = [""] * 8 + self.zeilen + self.assertIsNone(p._marke(verschoben)) + + def test_unbekannte_marke_beschaedigt_den_beleg_nicht(self): + # Freie Tankstellen und Marken, die nicht in _MARKEN stehen: der Beleg + # wird vollständig eingelesen, nur eben ohne Marke. Ein falscher Name + # wäre schlimmer als keiner. + for ersatz in ("OIL! Tankstelle", "Hoyer Tankstelle", "Freie Tankstelle Mueller"): + with self.subTest(kopf=ersatz): + d = p._parsen("\n".join([ersatz] + self.zeilen[1:])) + self.assertIsNone(d["station_brand"]) + # Ohne Marke tritt der Betreibername an ihre Stelle. + self.assertEqual(d["station_name"], "A. Zrenner GmbH, Pascalstr. 8, Ingolstadt") + self.assertAlmostEqual(d["liters"], 42.15, delta=0.001) + self.assertAlmostEqual(d["fuel_total_eur"], 78.78, delta=0.001) + + def test_summenzeile_im_kopf_ist_keine_marke(self): + # "Total" ist beides - Kopfmarke und Summenwort. Ein Beleg mit knappem + # Kopf, der den Betrag schon oben druckt, lieferte vorher fälschlich + # die Marke "Total" (nachgestellt am 04.09.2026). + for summe in ("TOTAL: 78,78 EUR", "GESAMT 78,78 EUR", "Summe 78,78 EUR"): + with self.subTest(zeile=summe): + self.assertEqual(p._marke([summe] + self.zeilen), "Shell") + + def test_echte_marke_total_bleibt_erkennbar(self): + # Die Gegenprobe zum Test darüber: verworfen wird nur, wo Summenwort + # UND Geldangabe zusammenkommen - eine Kopfzeile ohne Betrag nicht. + self.assertEqual(p._marke(["TOTAL TANKSTELLE", "Musterweg 1", "80331 München"]), "Total") + # Und eine Postleitzahl in der Kopfzeile faellt nicht mit heraus. + self.assertEqual( + p._marke(["AUTO B. FRISCHMANN GMBH", "SHELL TANKSTELLE, 6450 SÖLDEN"]), "Shell" + ) + + def test_wortgrenze_schuetzt_vor_teiltreffern(self): + # "Star" darf nicht in "Starnberg" treffen, "Eni" nicht in "Enisstr.". + self.assertIsNone(p._marke(["Tankstelle Mueller", "Seestr. 3", "82319 Starnberg"])) + self.assertIsNone(p._marke(["Freie Tankstelle", "Enisweg 4", "12345 Musterort"])) + + if __name__ == "__main__": unittest.main(verbosity=2)