Markenerkennung: Grenzen gemessen, Summenzeile im Kopf ist keine Marke

Auf die Frage, ob Belege einheitlich gestaltet sind: nein. Die Erkennung
haengt an drei Annahmen - Marke in den ersten acht Zeilen, Marke in der
festen Liste, Marke ueberhaupt vorhanden. Alle drei sind jetzt gemessen und
in Tests festgehalten.

Dabei ein echter Defekt gefunden: "Total" ist Kopfmarke UND Summenwort. Ein
Beleg mit Betrag in den ersten acht Zeilen lieferte faelschlich die Marke
"Total" (an einem echten Beleg reproduziert). _betragszeile() filtert
Summenzeilen jetzt aus dem Kopf - verlangt werden beide Kennzeichen zugleich,
Summenwort und Geldangabe, damit "SHELL TANKSTELLE, 6450 SOELDEN" nicht
mitfaellt. Gegenprobe: ein echter "TOTAL TANKSTELLE"-Kopf ergibt weiter Total.

Der Ausfall beschaedigt nichts: mit unbekannter Marke im Kopf bleiben Liter,
Betrag, Rabatt und Anschrift unveraendert richtig, nur die Marke fehlt.

Parser-Suite 17 Tests (von 11), alle zwoelf abgelegten Belege liefern weiter
Shell, audi_ha_test auf 2026.9.4.22 ohne Traceback.
This commit is contained in:
2026-09-04 17:09:12 +02:00
parent bdbdb4a010
commit 8557bfe230
7 changed files with 204 additions and 5 deletions
+49
View File
@@ -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")
+59
View File
@@ -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.
@@ -1 +1 @@
{"version":"2026.9.4.21","sha256":"3601a55ee4120c8508dd8f5018dcfbe806f10d87fd706763a0bbd97377d217d1","bytes":323037,"gebaut":"2026-09-04T14:55:41Z"}
{"version":"2026.9.4.22","sha256":"66406934fb50aa810ff782ce877bc82d25ec8d338979d5ae9e28e5dc5c2ea521","bytes":323041,"gebaut":"2026-09-04T15:08:22Z"}
@@ -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": [
@@ -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
@@ -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)