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:
@@ -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")
|
||||
@@ -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"}
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user