Tankstellenmarke als eigenes Feld, Belegfelder in der App richtig zugeordnet
Die Marke steht auf jedem Beleg, war aber nirgends gespeichert: der Parser erkennt sie seit dem 16.08.2026, faltete sie aber nur in station_name. Fuer jeden Beleg, den eine aeltere Fassung eingelesen hat, blieb dort der Betreibername stehen und die Marke war danach nicht mehr zu holen. - shell_beleg_parser: station_brand als eigenes Feld aus beiden Parserwegen, dazu marke_aus_text()/marke_aus_pdf() - sie lesen nur den Belegkopf und haengen nicht am vollstaendigen Einlesen. - belege.marken_nachtragen(): traegt die Marke einmal beim Start aus der abgelegten Belegdatei nach. Nur wo das Feld gar nicht vorkommt und die Datei liegt; ein einmal eingetragenes Feld wird nie ueberschrieben. - Panel und App: die gespeicherte Marke geht vor, geraten wird nur, wo keine da ist. Ein Teil, der fuer sich genommen eine Kopfmarke ist, taugt zudem nie als Ort. Dazu Punkt 5 der Meldung vom 04.09.2026: ein eingelesener Beleg fuellte nur Datum und Uhrzeit. Der Parser las ihn immer vollstaendig - die App fragte liter/kosten/station/ersparnis/kraftstoff ab, veroeffentlicht werden aber die Namen des Belegs (liters/fuel_total_eur/station_name/discount/fuel_type). Uebereingestimmt hat allein ts. Die Zuordnung steht jetzt als belegFormularwerte() in daten/belegentwurf.ts und wird gegen die echte Nutzlast dieses Belegs geprueft. tsc sauber, 258 Tests (die drei neuen Marken-Tests gegen den alten Stand als scheiternd nachgewiesen), vite build sauber, Panel als Modul geparst, Parser-Suite 11 Tests gegen elf echte Belege. Panel und App liefern fuer elf Faelle byteweise dasselbe. Live in audi_ha_test auf 2026.9.4.21 ohne Traceback: Uebersicht "Shell, Nuernberger Str., Ansbach", Einzelbeleg zweizeilig mit Marke, und der echte geteilte Beleg fuellt durchs Backend das ganze Formular.
This commit is contained in:
@@ -10726,3 +10726,132 @@ Beleg liest sich `Nürnberger Str., Ansbach`, und der Einzelbeleg zeigt
|
||||
**Nicht geprueft, weil es ohne Mac nicht geht:** dass die Registrierung auf dem
|
||||
Geraet greift. Das zeigt erst der naechste Xcode-Lauf - und der bricht jetzt ab,
|
||||
wenn sie fehlt.
|
||||
|
||||
## CN. Die Marke stand auf jedem Beleg - und war nirgends gespeichert. Dazu: warum ein eingelesener Beleg nur Datum und Uhrzeit fuellte (2026.9.4.21)
|
||||
|
||||
### 1. "In einem Beleg soll die Marke, falls vorhanden, erkannt werden"
|
||||
|
||||
Der Einzelbeleg vom 10.11.2025 las sich nach Abschnitt CM
|
||||
`Nürnberger Str. 74` / `91522 Ansbach` - richtig zerlegt, aber ohne Marke. Die
|
||||
Frage war also nicht, ob die Zerlegung stimmt, sondern woher die Marke kommen
|
||||
soll.
|
||||
|
||||
**Sie steht auf dem Beleg.** Nachgesehen statt vermutet - die erste Zeile jedes
|
||||
abgelegten PDFs:
|
||||
|
||||
| Beleg | Zeile 0 | gespeicherter `station_name` |
|
||||
|---|---|---|
|
||||
| e-receipt9 | `Shell-Station` | `Hermann Mogler Mineralölg. GmbH` |
|
||||
| e-receipt5 | `Shell Station` | `TC Sengül GmbH` |
|
||||
| e-receipt | `SHELL STATION` | `A. Zrenner GmbH` |
|
||||
| receipt_2026… | `SHELL TANKSTELLE, 6450 SÖLDEN` | `Shell, SÖLDEN` |
|
||||
|
||||
Der Parser **erkennt** die Marke seit dem 16.08.2026 (`_marke()`, nur im
|
||||
Belegkopf gesucht) - er hat sie aber nur in den Anzeigenamen gefaltet
|
||||
(`_tankstelle()`). Fuer jeden Beleg, den eine aeltere Fassung eingelesen hat,
|
||||
blieb dort der Betreibername stehen, und die Marke war danach **nirgends mehr
|
||||
zu holen**. Ein Feld, das nur als Nebenprodukt einer Zeichenkette existiert,
|
||||
ist kein Feld.
|
||||
|
||||
**Jetzt eigenes Feld** `station_brand`, aus beiden Parser-Wegen. Dazu zwei
|
||||
oeffentliche Funktionen: `marke_aus_text()` und `marke_aus_pdf()` - sie lesen
|
||||
nur den Kopf und haengen damit **nicht** am vollstaendigen Einlesen. Ein Beleg,
|
||||
an dem beide Parser scheitern wuerden, gibt seine Marke trotzdem her.
|
||||
|
||||
**Das Nachtragen** (`belege.marken_nachtragen`) laeuft einmal beim Start, an
|
||||
genau der Stelle, die dafuer schon da war (`_nach_start`, direkt hinter
|
||||
`screening.durchfuehren` - deren Kommentar beschreibt woertlich diesen Fall:
|
||||
"was ihre neuen Felder nachtragen koennen, soll nicht bis zur naechsten Fahrt
|
||||
warten"). Vorsichtig gehalten:
|
||||
|
||||
* angefasst wird nur, wo `station_brand` **gar nicht vorkommt** und die
|
||||
Belegdatei tatsaechlich liegt;
|
||||
* ein einmal eingetragenes Feld - auch mit Wert `None` - wird nie
|
||||
ueberschrieben. Damit ueberschreibt es keine Korrektur von Hand und laeuft
|
||||
nicht bei jedem Start erneut ueber dieselben Belege;
|
||||
* geschrieben wird nur, wenn sich wirklich etwas geaendert hat.
|
||||
|
||||
Am Testcontainer geprueft: erster Start
|
||||
`Tankstellenmarke bei 5 Beleg(en) nachgetragen (Shell)`, zweiter Start **keine
|
||||
Zeile mehr**. Genau die fuenf Vorgaenge mit Belegdatei tragen das Feld.
|
||||
|
||||
**Gespeichert schlaegt geraten.** In beiden Codebasen nimmt
|
||||
`tankstelleTeile(name, adresse, marke)` die gespeicherte Angabe vorweg und
|
||||
faellt nur dort auf `markeErkennen(name)` zurueck, wo keine da ist: bei
|
||||
Vorgaengen ohne Beleg, bei von Hand eingetragenen Tankstellen, und solange das
|
||||
Nachtragen nicht gelaufen ist.
|
||||
|
||||
Beim Nebeneinanderlegen von Panel und App fiel noch etwas auf, das vorher nicht
|
||||
auffallen konnte: steht im Namen eine ANDERE Marke als die gespeicherte, rutschte
|
||||
die aus dem Namen in den **Ort** ("Aral" als Ortsname). Die alte Regel verglich
|
||||
nur mit der gewaehlten Marke. Jetzt gilt: ein Teil, der fuer sich genommen eine
|
||||
Kopfmarke IST, taugt nie als Ort.
|
||||
|
||||
### 2. Punkt 5 aus Abschnitt CM: der Beleg kam mit Datum und Uhrzeit an
|
||||
|
||||
Der Eigentuemer hat den Beleg geschickt (Shell, Richard-Wagner-Str. 9,
|
||||
Ingolstadt, 43,87 l fuer 95,60 EUR) - liegt als
|
||||
`tests/belegparser/belege/geteilt-2026-09-04.pdf`, gitignored.
|
||||
|
||||
**Der Parser liest ihn vollstaendig.** `_parsen()` greift, alle Werte stimmen.
|
||||
Der Fehler sass in der App:
|
||||
|
||||
| App fragte ab | belege.py veroeffentlicht |
|
||||
|---|---|
|
||||
| `liter` | `liters` |
|
||||
| `kosten` | `fuel_total_eur` |
|
||||
| `ersparnis` | `discount` |
|
||||
| `station` | `station_name` |
|
||||
| `kraftstoff` | `fuel_type` |
|
||||
| `km` | *(gar nicht - §7.7 Regel 2)* |
|
||||
| `ts` | `ts` |
|
||||
|
||||
Uebereingestimmt hat **allein `ts`** - deshalb Datum und Uhrzeit und sonst
|
||||
nichts. Der Kraftstoff im Bildschirmfoto war nicht vom Beleg, sondern
|
||||
`KRAFTSTOFFSORTEN[0]`. Das Panel hat die Namen in `tankFelder()` immer richtig
|
||||
benutzt; die App hatte sich ihre eigenen ausgedacht.
|
||||
|
||||
**Meine Einschaetzung in Abschnitt CM war zu kurz gegriffen.** Dass der
|
||||
Teilen-Weg und die Dateiauswahl denselben `belegLesen()` benutzen, stimmte -
|
||||
daraus folgte aber nicht "also nicht die App". Beide Wege waren gleich kaputt.
|
||||
Richtig war nur, es nicht ohne den Beleg zu entscheiden.
|
||||
|
||||
Die Zuordnung steht jetzt als `belegFormularwerte()` in
|
||||
`daten/belegentwurf.ts`, nicht mehr als `if`-Zeilen im Bildschirm - **als
|
||||
Funktion laesst sie sich gegen eine echte Nutzlast pruefen, in den `if`-Zeilen
|
||||
konnte sie es nicht**. Die Nutzlast im Test ist die echte Ausgabe fuer diesen
|
||||
Beleg, kein erfundenes Beispiel.
|
||||
|
||||
Nebenbei mitgenommen: `0` ist ein Wert, kein fehlendes Feld. Ein Beleg mit
|
||||
0,00 EUR Rabatt darf nicht wie "kein Rabatt angegeben" aussehen - deshalb
|
||||
`== null` und nicht `!wert`.
|
||||
|
||||
### Verifiziert
|
||||
|
||||
`tsc --noEmit` sauber, **258 Tests** (von 246: 6 fuer die gespeicherte Marke,
|
||||
6 fuer die Formularwerte), `vite build` sauber, Panel als **Modul** geparst,
|
||||
Parser-Suite **11 Tests** gegen jetzt elf echte Belege.
|
||||
|
||||
**Die drei Marken-Tests gegen den alten Stand laufen lassen** (`marke`-Parameter
|
||||
ignoriert): 3 von 36 scheitern, danach wieder 36 gruen. Der Test misst also,
|
||||
was er behauptet.
|
||||
|
||||
**Panel und App byteweise gleich** fuer elf Faelle mit und ohne gespeicherte
|
||||
Marke - die Panel-Funktionen aus dem Buendel geschnitten und in Node gegen
|
||||
dieselben Eingaben laufen lassen, nicht nachgebaut.
|
||||
|
||||
**Live in `audi_ha_test` (2026.9.4.21, 0 Tracebacks):**
|
||||
* Uebersicht `Shell, Nürnberger Str., Ansbach`, Einzelbeleg
|
||||
`Shell, Nürnberger Str. 74` / `91522 Ansbach`.
|
||||
* Den echten geteilten Beleg ueber `beleg_hochladen` durchs Backend geschickt
|
||||
und die veroeffentlichte Entitaet gelesen: `station_brand: "Shell"`, und die
|
||||
Formularzuordnung ergibt daraus `43.87` / `95.6` / `11.84` /
|
||||
`Shell, Richard-Wagner-Str. 9, Ingolstadt` / `V-Power Racing` /
|
||||
`2026-04-03T00:55` - also das ganze Formular, nicht nur die Uhrzeit.
|
||||
* Die Prueflast danach wieder entfernt (`/config/www/pruefbeleg.pdf` und die
|
||||
verwaiste `belege/geteilt-pruefung.pdf`; kein Tankvorgang zeigte darauf).
|
||||
|
||||
**Nicht geprueft:** die iOS-Huelle - die Aenderungen der App liegen im
|
||||
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.
|
||||
|
||||
@@ -85,6 +85,9 @@ export interface Tankvorgang {
|
||||
/** Immer aus fuel_total_eur/liters berechnet, nie aus dem Beleg gelesen. */
|
||||
price_per_l: number | null;
|
||||
station_name?: string | null;
|
||||
/** Kopfmarke des Belegs (shell_beleg_parser). Fehlt bei Vorgaengen ohne
|
||||
Beleg und bei Belegen, deren Datei nicht mehr liegt. */
|
||||
station_brand?: string | null;
|
||||
station_id?: string | null;
|
||||
station_address?: string | null;
|
||||
fuel_type?: string | null;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests zur Uebernahme eines eingelesenen Belegs ins Tankformular.
|
||||
*
|
||||
* Die Nutzlast unten ist KEIN erfundenes Beispiel: sie ist die Ausgabe von
|
||||
* shell_beleg_parser._parsen() fuer den Beleg, den der Eigentuemer am
|
||||
* 04.09.2026 ueber das Teilen-Blatt hereingereicht hat und der in der App nur
|
||||
* mit Datum und Uhrzeit ankam (Shell, Richard-Wagner-Str. 9, Ingolstadt,
|
||||
* 43,87 l fuer 95,60 EUR). Genau dieser Fall gehoert in einen Test.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { belegFormularwerte } from "./belegentwurf"
|
||||
|
||||
/* Wortgleich mit dem, was belege._beleg_felder() fuer diesen Beleg
|
||||
veroeffentlicht - die Feldnamen sind der Punkt dieser Suite. */
|
||||
const GETEILT = {
|
||||
receipt_key: "0000000459_3368-082-00082_2026-04-03T00:55",
|
||||
receipt_no: "3368/082/00082",
|
||||
tse_beleg_nr: "73912",
|
||||
ts: "2026-04-03T00:55:00",
|
||||
ts_payment: "2026-04-03T00:52:43",
|
||||
ts_tse: "2026-04-03T00:55:01",
|
||||
station_id: "0000000459",
|
||||
station_brand: "Shell",
|
||||
station_name: "Shell, Richard-Wagner-Str. 9, Ingolstadt",
|
||||
station_address: "Richard-Wagner-Str. 9, 85057 Ingolstadt",
|
||||
article_no: "000013",
|
||||
product_name: "V-Power Racing",
|
||||
fuel_type: "V-Power Racing",
|
||||
liters: 43.87,
|
||||
fuel_total_eur: 95.6,
|
||||
price_per_l: 2.1792,
|
||||
discount: 11.84,
|
||||
discount_per_l: 0.27,
|
||||
list_price_per_l: 2.449,
|
||||
receipt_total_eur: 95.6,
|
||||
net_eur: 80.34,
|
||||
vat_eur: 15.26,
|
||||
receipt_file: "/config/audi_dashboard/belege/e-receipt (7).pdf",
|
||||
}
|
||||
|
||||
describe("belegFormularwerte", () => {
|
||||
it("fuellt das ganze Formular, nicht nur Datum und Uhrzeit", () => {
|
||||
// Der gemeldete Fehler: alles ausser zeitpunkt blieb leer, weil die App
|
||||
// liter/kosten/station/ersparnis/kraftstoff abfragte statt der
|
||||
// Belegfelder.
|
||||
expect(belegFormularwerte(GETEILT)).toEqual({
|
||||
zeitpunkt: "2026-04-03T00:55",
|
||||
liter: "43.87",
|
||||
kosten: "95.6",
|
||||
ersparnis: "11.84",
|
||||
station: "Shell, Richard-Wagner-Str. 9, Ingolstadt",
|
||||
kraftstoff: "V-Power Racing",
|
||||
})
|
||||
})
|
||||
|
||||
it("nimmt den realen Betrag, nicht den Listenpreis", () => {
|
||||
// fuel_total_eur ist bereits rabattiert; receipt_total_eur waere hier
|
||||
// zufaellig gleich, list_price_per_l dagegen ein Preis je Liter.
|
||||
expect(belegFormularwerte(GETEILT).kosten).toBe(String(GETEILT.fuel_total_eur))
|
||||
})
|
||||
|
||||
it("laesst Felder leer, die der Beleg nicht nennt", () => {
|
||||
// Ein Beleg ohne Rabatt ist normal (BelegOhneRabatt im Parsertest) - das
|
||||
// Formularfeld soll dann nicht auf "null" stehen.
|
||||
expect(belegFormularwerte({ ...GETEILT, discount: null })).toMatchObject({ ersparnis: "" })
|
||||
expect(belegFormularwerte({ ts: "2026-04-03T00:55:00" })).toEqual({
|
||||
zeitpunkt: "2026-04-03T00:55",
|
||||
liter: "",
|
||||
kosten: "",
|
||||
ersparnis: "",
|
||||
station: "",
|
||||
kraftstoff: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("laesst eine echte Null durch", () => {
|
||||
// 0 ist ein Wert, kein fehlendes Feld - ein Beleg mit 0,00 EUR Rabatt
|
||||
// darf nicht wie "kein Rabatt angegeben" aussehen.
|
||||
expect(belegFormularwerte({ ...GETEILT, discount: 0 }).ersparnis).toBe("0")
|
||||
})
|
||||
|
||||
it("kommt mit gar keinem Beleg zurecht", () => {
|
||||
expect(belegFormularwerte(null).zeitpunkt).toBe("")
|
||||
expect(belegFormularwerte(undefined).liter).toBe("")
|
||||
})
|
||||
|
||||
it("uebernimmt keinen Kilometerstand", () => {
|
||||
// SPECIFICATION.md §7.7 Regel 2: der kommt immer vom Fahrzeug.
|
||||
expect(Object.keys(belegFormularwerte(GETEILT))).not.toContain("km")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Was ein eingelesener Beleg ins Tankformular traegt.
|
||||
*
|
||||
* WARUM DAS HIER STEHT UND NICHT IM BILDSCHIRM
|
||||
* -------------------------------------------
|
||||
* Bis zum 04.09.2026 stand die Uebernahme als Folge von `if`-Zeilen in
|
||||
* `Tanken.tsx` - und fragte `liter`/`kosten`/`station`/`ersparnis`/
|
||||
* `kraftstoff` ab, also die Namen der FORMULARFELDER. Veroeffentlicht werden
|
||||
* aber die Namen des BELEGS (`_beleg_felder` in belege.py, dieselben, die auch
|
||||
* im Tankvorgang landen). Uebereingestimmt hat allein `ts`.
|
||||
*
|
||||
* Sichtbar war das als: ein eingelesener Beleg fuellte Datum und Uhrzeit,
|
||||
* sonst nichts (Befund des Eigentuemers, 04.09.2026 - gemeldet fuer einen
|
||||
* ueber das Teilen-Blatt hereingereichten Beleg, betraf aber jeden Weg
|
||||
* gleichermassen, weil beide durch `belegLesen()` laufen). Als eigene
|
||||
* Funktion laesst sich die Zuordnung gegen eine echte Nutzlast pruefen; in
|
||||
* den `if`-Zeilen des Bildschirms konnte sie es nicht.
|
||||
*
|
||||
* KEIN KILOMETERSTAND
|
||||
* -------------------
|
||||
* Er kommt immer vom Fahrzeug, nie vom Beleg (SPECIFICATION.md §7.7 Regel 2).
|
||||
* Das Panel haelt es in `tankFelder()` genauso.
|
||||
*/
|
||||
|
||||
/** Die Felder, die belege.py nach dem Lesen eines Belegs veroeffentlicht. */
|
||||
export interface BelegEntwurf {
|
||||
ts?: string
|
||||
liters?: number | null
|
||||
/** Der reale, bereits rabattierte Betrag. */
|
||||
fuel_total_eur?: number | null
|
||||
/** SmartDeal-Ersparnis. */
|
||||
discount?: number | null
|
||||
station_name?: string | null
|
||||
fuel_type?: string | null
|
||||
receipt_key?: string | null
|
||||
receipt_file?: string | null
|
||||
}
|
||||
|
||||
/** Was davon in welches Formularfeld geht - leere Zeichenkette heisst: nicht
|
||||
setzen, das Feld behaelt seinen bisherigen Wert. */
|
||||
export interface Formularwerte {
|
||||
zeitpunkt: string
|
||||
liter: string
|
||||
kosten: string
|
||||
ersparnis: string
|
||||
station: string
|
||||
kraftstoff: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahl in die Zeichenkette eines Zahlenfelds. `0` ist ein gueltiger Wert und
|
||||
* muss durchkommen - nur `null`/`undefined` bedeuten "steht nicht im Beleg".
|
||||
*/
|
||||
function zahl(wert: number | null | undefined): string {
|
||||
return wert == null ? "" : String(wert)
|
||||
}
|
||||
|
||||
export function belegFormularwerte(e: BelegEntwurf | null | undefined): Formularwerte {
|
||||
return {
|
||||
// Das Eingabefeld will "JJJJ-MM-TTThh:mm", der Beleg liefert Sekunden mit.
|
||||
zeitpunkt: e?.ts ? e.ts.slice(0, 16) : "",
|
||||
liter: zahl(e?.liters),
|
||||
kosten: zahl(e?.fuel_total_eur),
|
||||
ersparnis: zahl(e?.discount),
|
||||
station: e?.station_name ?? "",
|
||||
kraftstoff: e?.fuel_type ?? "",
|
||||
}
|
||||
}
|
||||
@@ -249,3 +249,60 @@ describe("tankstelleKurz und tankstelleZeilen an echten Belegen", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("gespeicherte Marke (station_brand)", () => {
|
||||
/* Der Beleg vom 10.11.2025, wie er wirklich gespeichert ist: der Name traegt
|
||||
den BETREIBER, die Marke steht nur auf dem PDF. Seit dem 04.09.2026 liest
|
||||
sie shell_beleg_parser._marke aus dem Belegkopf, belege.marken_nachtragen
|
||||
traegt sie fuer Altbelege nach - und hier kommt sie an. */
|
||||
const NAME = "TC Sengül GmbH"
|
||||
const ADRESSE = "Nürnberger Str.74, 91522 Ansbach"
|
||||
|
||||
it("nimmt die gespeicherte Marke, wo der Name keine hergibt", () => {
|
||||
// Ohne sie stand der Einzelbeleg ohne Marke da, obwohl "Shell Station"
|
||||
// auf dem Beleg steht (Befund des Eigentuemers, 04.09.2026).
|
||||
expect(tankstelleZeilen(NAME, ADRESSE)).toEqual({
|
||||
oben: "Nürnberger Str. 74",
|
||||
unten: "91522 Ansbach",
|
||||
})
|
||||
expect(tankstelleZeilen(NAME, ADRESSE, "Shell")).toEqual({
|
||||
oben: "Shell, Nürnberger Str. 74",
|
||||
unten: "91522 Ansbach",
|
||||
})
|
||||
})
|
||||
|
||||
it("nimmt sie auch in der Uebersicht", () => {
|
||||
expect(tankstelleKurz(NAME, ADRESSE, "Shell")).toBe("Shell, Nürnberger Str., Ansbach")
|
||||
})
|
||||
|
||||
it("geht der Erkennung aus dem Namen vor", () => {
|
||||
// Die gespeicherte Angabe stammt vom Belegkopf, die andere ist geraten.
|
||||
expect(tankstelleTeile("Aral, Musterweg 1, Musterort", null, "Shell").marke).toBe("Shell")
|
||||
})
|
||||
|
||||
it("faellt auf die Erkennung zurueck, wo nichts gespeichert ist", () => {
|
||||
// Vorgaenge ohne Beleg und von Hand eingetragene Tankstellen haben das
|
||||
// Feld nie - dort bleibt es beim bisherigen Weg.
|
||||
expect(tankstelleKurz("Shell, Pascalstr. 8, Ingolstadt", null, null)).toBe(
|
||||
"Shell, Pascalstr., Ingolstadt",
|
||||
)
|
||||
expect(tankstelleKurz("Shell, SÖLDEN", null, undefined).startsWith("Shell")).toBe(true)
|
||||
})
|
||||
|
||||
it("behandelt eine leere gespeicherte Marke wie gar keine", () => {
|
||||
// marken_nachtragen schreibt null, wenn der Beleg keine Marke nennt.
|
||||
expect(tankstelleTeile("Shell, SÖLDEN", null, "").marke).toBe("Shell")
|
||||
expect(tankstelleTeile("Shell, SÖLDEN", null, " ").marke).toBe("Shell")
|
||||
})
|
||||
|
||||
it("gibt sie dem Kartendienst mit", () => {
|
||||
// Apple Karten und Google Maps treffen mit der Marke die Zapfsaeule statt
|
||||
// des Nachbargebaeudes - die Firmierung hilft dort nirgends.
|
||||
expect(tankstelleZiel(NAME, ADRESSE, "Shell")).toBe(
|
||||
"Shell, Nürnberger Str.74, 91522 Ansbach",
|
||||
)
|
||||
expect(kartendienstUrl(NAME, ADRESSE, null, "zeigen", "Shell")).toContain(
|
||||
encodeURIComponent("Shell, Nürnberger Str.74, 91522 Ansbach"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,13 @@ const MARKEN = [
|
||||
* sichtbar ist die Kopfmarke.
|
||||
*
|
||||
* Wortgrenzen sind Absicht: "Star" darf nicht in "Starnberg" treffen.
|
||||
*
|
||||
* NUR DER ZWEITE WEG: seit dem 04.09.2026 speichert die Integration die Marke
|
||||
* als eigenes Feld `station_brand` (shell_beleg_parser._marke, aus dem
|
||||
* Belegkopf), und fuer Altbelege traegt sie belege.marken_nachtragen() aus der
|
||||
* abgelegten Belegdatei nach. Wo dieses Feld etwas hergibt, gilt es - hier
|
||||
* geraten wird nur, wo es fehlt: bei Vorgaengen ohne Beleg, bei von Hand
|
||||
* eingetragenen Tankstellen und solange das Nachtragen nicht gelaufen ist.
|
||||
*/
|
||||
export function markeErkennen(name: string | null | undefined): string | null {
|
||||
const roh = (name ?? "").trim()
|
||||
@@ -132,13 +139,19 @@ export interface Tankstellenteile {
|
||||
* waehrend die Anschrift daneben ungenutzt blieb. Gelesen werden deshalb
|
||||
* beide Felder: die Anschrift zuerst (sie ist die strukturierte Quelle), der
|
||||
* Name danach fuer Marke und alles, was die Anschrift nicht hergibt.
|
||||
*
|
||||
* `marke` ist die gespeicherte Angabe des Belegs (`station_brand`). Sie geht
|
||||
* vor, weil sie vom Belegkopf stammt und nicht aus dem Anzeigenamen geraten
|
||||
* ist - genau die Luecke, durch die der Beleg vom 10.11.2025 ohne Marke
|
||||
* dastand, obwohl "Shell Station" auf ihm steht.
|
||||
*/
|
||||
export function tankstelleTeile(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
marke?: string | null | undefined,
|
||||
): Tankstellenteile {
|
||||
const teile: Tankstellenteile = {
|
||||
marke: markeErkennen(name) ?? "",
|
||||
marke: (marke ?? "").trim() || markeErkennen(name) || "",
|
||||
strasse: "",
|
||||
hausnummer: "",
|
||||
plz: "",
|
||||
@@ -150,8 +163,11 @@ export function tankstelleTeile(
|
||||
for (const stueck of (adresse ?? "").split(",")) quellen.push(stueck)
|
||||
for (const stueck of (name ?? "").split(",")) {
|
||||
const t = stueck.trim()
|
||||
// Die Marke steht schon fest; als Ort taugt sie nicht.
|
||||
if (teile.marke && t.toLowerCase() === teile.marke.toLowerCase()) continue
|
||||
// Ein Teil, der fuer sich genommen eine Kopfmarke IST, taugt nie als Ort.
|
||||
// Frueher stand hier nur der Vergleich mit der gewaehlten Marke; seit die
|
||||
// gespeicherte Angabe vorgeht, koennen beide auseinanderfallen, und dann
|
||||
// rutschte die Marke aus dem Namen in den Ort.
|
||||
if (t && markeErkennen(t)?.toLowerCase() === t.toLowerCase()) continue
|
||||
quellen.push(t)
|
||||
}
|
||||
|
||||
@@ -195,8 +211,9 @@ export function tankstelleTeile(
|
||||
export function tankstelleKurz(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
marke?: string | null | undefined,
|
||||
): string {
|
||||
const t = tankstelleTeile(name, adresse)
|
||||
const t = tankstelleTeile(name, adresse, marke)
|
||||
const zeile = [t.marke, t.strasse, t.ort].filter(Boolean).join(", ")
|
||||
return zeile || t.roh || "unbekannt"
|
||||
}
|
||||
@@ -215,8 +232,9 @@ export function tankstelleKurz(
|
||||
export function tankstelleZeilen(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
marke?: string | null | undefined,
|
||||
): { oben: string; unten: string } {
|
||||
const t = tankstelleTeile(name, adresse)
|
||||
const t = tankstelleTeile(name, adresse, marke)
|
||||
const strasse = [t.strasse, t.hausnummer].filter(Boolean).join(" ")
|
||||
let oben = [t.marke, strasse].filter(Boolean).join(", ")
|
||||
let unten = [t.plz, t.ort].filter(Boolean).join(" ")
|
||||
@@ -283,10 +301,11 @@ export function tankstelleSuchtext(
|
||||
export function tankstelleZiel(
|
||||
name: string | null | undefined,
|
||||
adresse: string | null | undefined,
|
||||
marke?: string | null | undefined,
|
||||
): string {
|
||||
const a = (adresse ?? "").trim()
|
||||
if (!a) return (name ?? "").trim()
|
||||
return [markeErkennen(name) ?? "", a].filter(Boolean).join(", ")
|
||||
return [(marke ?? "").trim() || markeErkennen(name) || "", a].filter(Boolean).join(", ")
|
||||
}
|
||||
|
||||
/** Apple-Gerät? Dort ist Karten der Standarddienst. */
|
||||
@@ -315,9 +334,10 @@ export function kartendienstUrl(
|
||||
adresse: string | null | undefined,
|
||||
pos?: Koordinate | null,
|
||||
modus: Kartenmodus = "route",
|
||||
marke?: string | null | undefined,
|
||||
): string | null {
|
||||
const beschriftung = (name ?? "").trim()
|
||||
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse)
|
||||
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse, marke)
|
||||
if (!ziel) return null
|
||||
if (apfelgeraet()) {
|
||||
if (modus === "zeigen") {
|
||||
|
||||
@@ -72,7 +72,11 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na
|
||||
return (
|
||||
<>
|
||||
<Tile className="dm-kartenkachel">
|
||||
<TankstellenKarte name={tank.station_name} adresse={tank.station_address} />
|
||||
<TankstellenKarte
|
||||
name={tank.station_name}
|
||||
adresse={tank.station_address}
|
||||
marke={tank.station_brand}
|
||||
/>
|
||||
</Tile>
|
||||
|
||||
<Tile variant="flat" className="dm-distanzkachel">
|
||||
@@ -125,7 +129,11 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na
|
||||
wert={`${eur(tank.discount)} €`}
|
||||
/>
|
||||
)}
|
||||
<Tankstellenzeile name={tank.station_name} adresse={tank.station_address} />
|
||||
<Tankstellenzeile
|
||||
name={tank.station_name}
|
||||
adresse={tank.station_address}
|
||||
marke={tank.station_brand}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -153,12 +161,14 @@ export function TankDetail({ id, geheZu }: { id: string | undefined; geheZu: (na
|
||||
function Tankstellenzeile({
|
||||
name,
|
||||
adresse,
|
||||
marke,
|
||||
}: {
|
||||
name: string | null | undefined
|
||||
adresse: string | null | undefined
|
||||
marke?: string | null | undefined
|
||||
}) {
|
||||
const { oben, unten } = tankstelleZeilen(name, adresse)
|
||||
const url = kartendienstUrl(name, adresse, null, "zeigen")
|
||||
const { oben, unten } = tankstelleZeilen(name, adresse, marke)
|
||||
const url = kartendienstUrl(name, adresse, null, "zeigen", marke)
|
||||
const inhalt = (
|
||||
<>
|
||||
<span className="dm-tankstellenzeile__ort">
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Blattzeile, KRAFTSTOFFSORTEN, Leerzustand } from "./bausteine"
|
||||
import { BelegPopup } from "./BelegPopup"
|
||||
import { markenlogoUrl } from "./bilder"
|
||||
import { belegFormularwerte, type BelegEntwurf } from "../daten/belegentwurf"
|
||||
import { tankstelleKurz } from "../daten/tankstelle"
|
||||
import { LOESCH_HINWEIS } from "./bestaetigung"
|
||||
import { Zeilenmenue } from "./Zeilenmenue"
|
||||
@@ -125,7 +126,11 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
kinder={
|
||||
<Blattzeile
|
||||
label={datum(tank.ts)}
|
||||
labelZusatz={tankstelleKurz(tank.station_name, tank.station_address)}
|
||||
labelZusatz={tankstelleKurz(
|
||||
tank.station_name,
|
||||
tank.station_address,
|
||||
tank.station_brand,
|
||||
)}
|
||||
wert={tank.liters != null ? `${de(tank.liters, 2)} l` : ""}
|
||||
wertZusatz={preis != null ? `${de(preis, 2)} €/l` : ""}
|
||||
beiKlick={() => geheZu("fill", tank.tank_id)}
|
||||
@@ -157,19 +162,6 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) =>
|
||||
)
|
||||
}
|
||||
|
||||
/** Felder, die belege.py nach dem Lesen eines Belegs veröffentlicht. */
|
||||
interface BelegEntwurf {
|
||||
ts?: string
|
||||
liter?: number | null
|
||||
kosten?: number | null
|
||||
km?: number | null
|
||||
ersparnis?: number | null
|
||||
station?: string | null
|
||||
kraftstoff?: string | null
|
||||
receipt_key?: string | null
|
||||
receipt_file?: string | null
|
||||
}
|
||||
|
||||
/** Monatsname allein, wie im Panel. */
|
||||
function monatName(jahr: number, monat: number): string {
|
||||
return new Date(jahr, monat, 1).toLocaleDateString("de-DE", { month: "long" })
|
||||
@@ -275,13 +267,13 @@ function TankFormular({
|
||||
setzeFehler(e.fehler)
|
||||
} else {
|
||||
setzeBeleg(e)
|
||||
if (e.liter != null) setzeLiter(String(e.liter))
|
||||
if (e.kosten != null) setzeKosten(String(e.kosten))
|
||||
if (e.station) setzeStation(e.station)
|
||||
if (e.km != null) setzeKm(String(e.km))
|
||||
if (e.ersparnis != null) setzeErsparnis(String(e.ersparnis))
|
||||
if (e.kraftstoff) setzeKraftstoff(e.kraftstoff)
|
||||
if (e.ts) setzeZeitpunkt(e.ts.slice(0, 16))
|
||||
const w = belegFormularwerte(e)
|
||||
if (w.zeitpunkt) setzeZeitpunkt(w.zeitpunkt)
|
||||
if (w.liter) setzeLiter(w.liter)
|
||||
if (w.kosten) setzeKosten(w.kosten)
|
||||
if (w.ersparnis) setzeErsparnis(w.ersparnis)
|
||||
if (w.station) setzeStation(w.station)
|
||||
if (w.kraftstoff) setzeKraftstoff(w.kraftstoff)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@ import { Karte } from "./Karte"
|
||||
export function TankstellenKarte({
|
||||
name,
|
||||
adresse,
|
||||
marke,
|
||||
}: {
|
||||
name?: string | null | undefined
|
||||
adresse: string | null | undefined
|
||||
marke?: string | null | undefined
|
||||
}) {
|
||||
const [position, setzePosition] = useState<Koordinate | null>(null)
|
||||
const [geprueft, setzeGeprueft] = useState(false)
|
||||
@@ -68,7 +70,7 @@ export function TankstellenKarte({
|
||||
|
||||
// Wie im Panel (tankstelleAufKarteZeigen): die CI-Nadel mit Name und
|
||||
// Anschrift im Popup, dazu der Knopf, der wieder darauf zentriert.
|
||||
if (position) return <Karte start={position} nadel={tankstelleZeilen(name, adresse)} />
|
||||
if (position) return <Karte start={position} nadel={tankstelleZeilen(name, adresse, marke)} />
|
||||
// Solange die Auflösung läuft: eine leere Kartenfläche, wie im Panel (die
|
||||
// Karte steht dort schon, die Nadel kommt nach). Bewusst nicht die
|
||||
// Ladeanzeige der Seite - die meldet „der Bildschirm ist noch nicht da".
|
||||
|
||||
@@ -137,6 +137,7 @@ def _beleg_felder(geparst: dict, pdf_pfad: str) -> dict:
|
||||
"ts_payment": geparst.get("ts_payment"),
|
||||
"ts_tse": geparst.get("ts_tse"),
|
||||
"station_id": geparst.get("station_id"),
|
||||
"station_brand": geparst.get("station_brand"),
|
||||
"station_name": geparst.get("station_name"),
|
||||
"station_address": geparst.get("station_address"),
|
||||
"article_no": geparst.get("article_no"),
|
||||
@@ -370,3 +371,60 @@ async def tankvorgang_loeschen(k: Koordinator, tank_id: str | None) -> None:
|
||||
_LOGGER.info("Tankvorgang %s gelöscht", tank_id)
|
||||
else:
|
||||
_LOGGER.warning("Tankvorgang %s nicht gefunden", tank_id)
|
||||
|
||||
|
||||
async def marken_nachtragen(k: Koordinator) -> None:
|
||||
"""Traegt die Tankstellenmarke bei Belegen nach, die vor dem 16.08.2026
|
||||
eingelesen wurden.
|
||||
|
||||
WARUM ES DAS BRAUCHT
|
||||
--------------------
|
||||
Bis zum 16.08.2026 kannte der Parser keine Marken; `station_name` trug
|
||||
deshalb die Firmierung des Betreibers ("TC Sengül GmbH"), und die Marke
|
||||
war fuer diese Datensaetze nirgends gespeichert. Auf dem BELEG steht sie
|
||||
aber weiterhin - jeder der geprueften Altbelege beginnt mit "Shell-Station"
|
||||
bzw. "SHELL STATION". Ohne dieses Nachtragen bliebe die Einzelbeleg-Zeile
|
||||
dieser Vorgaenge dauerhaft ohne Marke (Befund des Eigentuemers,
|
||||
04.09.2026).
|
||||
|
||||
VORSICHTIG GEHALTEN
|
||||
-------------------
|
||||
* Angefasst wird nur, wo `station_brand` noch gar nicht vorkommt und die
|
||||
Belegdatei tatsaechlich liegt. Ein einmal eingetragenes Feld - auch mit
|
||||
Wert None - wird nie wieder ueberschrieben; damit ueberschreibt das
|
||||
Nachtragen keine Korrektur von Hand und laeuft nicht bei jedem Start
|
||||
erneut ueber dieselben Belege.
|
||||
* Gelesen wird nur der Belegkopf (marke_aus_pdf), nicht der ganze Beleg.
|
||||
Ein Beleg, an dem beide Parser scheitern wuerden, gibt seine Marke
|
||||
trotzdem her - und ein Lesefehler bleibt folgenlos.
|
||||
* Geschrieben wird nur, wenn sich wirklich etwas geaendert hat.
|
||||
"""
|
||||
vorgaenge = await k.ablage.tankvorgaenge_lesen()
|
||||
offen = [
|
||||
v
|
||||
for v in vorgaenge
|
||||
if "station_brand" not in v and v.get("receipt_file")
|
||||
]
|
||||
if not offen:
|
||||
return
|
||||
|
||||
def _lesen() -> dict[str, str | None]:
|
||||
gefunden: dict[str, str | None] = {}
|
||||
for vorgang in offen:
|
||||
pfad = vorgang.get("receipt_file")
|
||||
if not pfad or not os.path.exists(pfad):
|
||||
continue
|
||||
gefunden[vorgang["tank_id"]] = shell_beleg_parser.marke_aus_pdf(pfad)
|
||||
return gefunden
|
||||
|
||||
marken = await k.hass.async_add_executor_job(_lesen)
|
||||
if not marken:
|
||||
return
|
||||
|
||||
for vorgang in vorgaenge:
|
||||
if vorgang.get("tank_id") in marken:
|
||||
vorgang["station_brand"] = marken[vorgang["tank_id"]]
|
||||
await k.ablage.tankvorgaenge_schreiben(vorgaenge)
|
||||
benannt = sorted({m for m in marken.values() if m})
|
||||
zusatz = ", ".join(benannt) if benannt else "keine erkennbar"
|
||||
_LOGGER.info("Tankstellenmarke bei %d Beleg(en) nachgetragen (%s)", len(marken), zusatz)
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":"2026.9.4.20","sha256":"95213bb0998806d4b59b4d520d2dd93a4f1c7ce48991e6f77b38e9dbca8247a8","bytes":322945,"gebaut":"2026-09-04T14:28:33Z"}
|
||||
{"version":"2026.9.4.21","sha256":"3601a55ee4120c8508dd8f5018dcfbe806f10d87fd706763a0bbd97377d217d1","bytes":323037,"gebaut":"2026-09-04T14:55:41Z"}
|
||||
Binary file not shown.
@@ -1331,6 +1331,13 @@ function markeErkennen(name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* NUR DER ZWEITE WEG: seit dem 04.09.2026 speichert die Integration die Marke
|
||||
als eigenes Feld `station_brand` (shell_beleg_parser._marke, aus dem
|
||||
Belegkopf), und fuer Altbelege traegt sie belege.marken_nachtragen() aus der
|
||||
abgelegten Belegdatei nach. Wo dieses Feld etwas hergibt, gilt es - hier
|
||||
geraten wird nur, wo es fehlt: bei Vorgaengen ohne Beleg, bei von Hand
|
||||
eingetragenen Tankstellen und solange das Nachtragen nicht gelaufen ist. */
|
||||
|
||||
/* Kennzeichen einer Firmierung. Ein Teil, der so etwas enthaelt, ist der
|
||||
BETREIBER und gehoert nach Vorgabe des Eigentuemers nicht in die Anzeige. */
|
||||
const FIRMA_MUSTER = /\b(gmbh|mbh|ohg|kg|ag|e\.\s?k\.|inh\.|& co)\b/i;
|
||||
@@ -1357,13 +1364,23 @@ function strasseKuerzen(strasse) {
|
||||
Die Markenerkennung lief bis hierher nur ueber den Namen - bei den aelteren
|
||||
Belegen steht die Marke dort gar nicht, deshalb blieb in der Uebersicht die
|
||||
Firmierung stehen ("...GmbH", Nutzerbefund 04.09.2026), waehrend die
|
||||
Anschrift daneben ungenutzt blieb. Gelesen werden jetzt beide Felder. */
|
||||
function tankstelleTeile(name, adresse) {
|
||||
const teile = { marke: markeErkennen(name) || "", strasse: "", hausnummer: "", plz: "", ort: "", roh: (name || "").trim() };
|
||||
Anschrift daneben ungenutzt blieb. Gelesen werden jetzt beide Felder.
|
||||
|
||||
`marke` ist die gespeicherte Angabe des Belegs (station_brand). Sie geht
|
||||
vor, weil sie vom Belegkopf stammt und nicht aus dem Anzeigenamen geraten
|
||||
ist - genau die Luecke, durch die der Beleg vom 10.11.2025 ohne Marke
|
||||
dastand, obwohl "Shell Station" auf ihm steht. */
|
||||
function tankstelleTeile(name, adresse, marke) {
|
||||
const teile = { marke: (marke || "").trim() || markeErkennen(name) || "", strasse: "", hausnummer: "", plz: "", ort: "", roh: (name || "").trim() };
|
||||
const quellen = (adresse || "").split(",");
|
||||
for (const stueck of (name || "").split(",")) {
|
||||
const t = stueck.trim();
|
||||
if (teile.marke && t.toLowerCase() === teile.marke.toLowerCase()) continue;
|
||||
// Ein Teil, der fuer sich genommen eine Kopfmarke IST, taugt nie als Ort.
|
||||
// Frueher stand hier nur der Vergleich mit der gewaehlten Marke; seit die
|
||||
// gespeicherte Angabe vorgeht, koennen beide auseinanderfallen, und dann
|
||||
// rutschte die Marke aus dem Namen in den Ort.
|
||||
const eigen = t ? markeErkennen(t) : null;
|
||||
if (eigen && eigen.toLowerCase() === t.toLowerCase()) continue;
|
||||
quellen.push(t);
|
||||
}
|
||||
for (const stueck of quellen) {
|
||||
@@ -1395,16 +1412,16 @@ function tankstelleTeile(name, adresse) {
|
||||
(Vorgabe des Eigentuemers, 04.09.2026). Ergibt die Zerlegung nichts, bleibt
|
||||
der rohe Name stehen - ihn wegzulassen hiesse, eine bekannte Tankstelle
|
||||
"unbekannt" zu nennen. */
|
||||
function tankstelleKurz(name, adresse) {
|
||||
const t = tankstelleTeile(name, adresse);
|
||||
function tankstelleKurz(name, adresse, marke) {
|
||||
const t = tankstelleTeile(name, adresse, marke);
|
||||
return [t.marke, t.strasse, t.ort].filter(Boolean).join(", ") || t.roh || "unbekannt";
|
||||
}
|
||||
|
||||
/* Die beiden Zeilen im Einzelbeleg: oben Marke und Strasse mit Hausnummer,
|
||||
unten Postleitzahl und Ort - geschrieben wie eine Anschrift, das Komma
|
||||
trennt nur die Marke ab. `oben` ist nie leer. */
|
||||
function tankstelleZeilen(name, adresse) {
|
||||
const t = tankstelleTeile(name, adresse);
|
||||
function tankstelleZeilen(name, adresse, marke) {
|
||||
const t = tankstelleTeile(name, adresse, marke);
|
||||
const strasse = [t.strasse, t.hausnummer].filter(Boolean).join(" ");
|
||||
let oben = [t.marke, strasse].filter(Boolean).join(", ");
|
||||
let unten = [t.plz, t.ort].filter(Boolean).join(" ");
|
||||
@@ -1442,10 +1459,10 @@ function tankstelleSuchtext(name, adresse) {
|
||||
/* Ziel fuer den Kartendienst. Hier gehoeren Name UND Anschrift zusammen:
|
||||
Apple Karten und Google Maps kennen Betriebsnamen und treffen damit die
|
||||
Zapfsaeule statt des Nachbargebaeudes. */
|
||||
function tankstelleZiel(name, adresse) {
|
||||
function tankstelleZiel(name, adresse, marke) {
|
||||
const a = (adresse || "").trim();
|
||||
if (!a) return (name || "").trim();
|
||||
return [markeErkennen(name) || "", a].filter(Boolean).join(", ");
|
||||
return [(marke || "").trim() || markeErkennen(name) || "", a].filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
/* Die Kandidaten der Reihe nach nachschlagen, erster Treffer gewinnt. */
|
||||
@@ -1467,9 +1484,9 @@ function apfelgeraet() {
|
||||
Tankstellen-Verweis im Einzelbeleg will das Zweite (Vorgabe des
|
||||
Eigentuemers, 04.09.2026): erst sehen, wo sie liegt - eine Route erwartet
|
||||
dort niemand, und sie ist im Kartendienst einen Tipp entfernt. */
|
||||
function kartendienstUrl(name, adresse, pos, modus = "route") {
|
||||
function kartendienstUrl(name, adresse, pos, modus = "route", marke) {
|
||||
const beschriftung = (name || "").trim();
|
||||
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse);
|
||||
const ziel = pos ? `${pos.lat},${pos.lon}` : tankstelleZiel(name, adresse, marke);
|
||||
if (!ziel) return null;
|
||||
if (apfelgeraet()) {
|
||||
if (modus === "zeigen") {
|
||||
@@ -1499,7 +1516,7 @@ function tankstelleAufKarteZeigen(karte, pos, f) {
|
||||
// Dieselben zwei Zeilen wie in der Wertezeile darunter (tankstelleZeilen) -
|
||||
// die Nadel soll nicht die Firmierung des Betreibers nennen, wenn die Zeile
|
||||
// die Kopfmarke zeigt.
|
||||
const zeilen = tankstelleZeilen(f.station_name, f.station_address);
|
||||
const zeilen = tankstelleZeilen(f.station_name, f.station_address, f.station_brand);
|
||||
window.L.marker([pos.lat, pos.lon], { icon }).addTo(karte)
|
||||
.bindPopup(esc(zeilen.oben) + (zeilen.unten ? "<br>" + esc(zeilen.unten) : ""));
|
||||
// Position merken und den Knopf scharf schalten - er kann erst jetzt etwas
|
||||
@@ -4752,7 +4769,7 @@ function vFuel() {
|
||||
<span>Ø ${de(schnittpreis(fm), 2)} €/l</span></span></button>
|
||||
<div class="acc-b ${mi === 0 ? "on" : ""}" id="fm${j}${m}">`;
|
||||
fm.forEach((f) => { const preis = literpreis(f); h += swipeZeile(`fill:${f.tank_id}`, `<button class="leaf" data-go="fill:${f.tank_id}">
|
||||
<span class="k">${dedat(new Date(f.ts))}<small>${esc(tankstelleKurz(f.station_name, f.station_address))}</small></span>
|
||||
<span class="k">${dedat(new Date(f.ts))}<small>${esc(tankstelleKurz(f.station_name, f.station_address, f.station_brand))}</small></span>
|
||||
<span class="v">${f.liters != null ? de(f.liters, 2) + " l" : ""}<small>${preis != null ? de(preis, 2) + " €/l" : ""}</small></span>
|
||||
<svg class="chev" viewBox="0 0 6 10"><polyline points="1,1 5,5 1,9"/></svg></button>`); });
|
||||
h += `</div></div>`;
|
||||
@@ -4793,8 +4810,8 @@ function belegKarte(f) {
|
||||
Geraets; ohne Name und ohne Anschrift bleibt es eine stumme Zeile, denn
|
||||
ein Verweis, der nirgendwohin fuehrt, waere schlechter als keiner. */
|
||||
function tankstellenZeile(f) {
|
||||
const { oben, unten } = tankstelleZeilen(f.station_name, f.station_address);
|
||||
const url = kartendienstUrl(f.station_name, f.station_address, null, "zeigen");
|
||||
const { oben, unten } = tankstelleZeilen(f.station_name, f.station_address, f.station_brand);
|
||||
const url = kartendienstUrl(f.station_name, f.station_address, null, "zeigen", f.station_brand);
|
||||
const inhalt =
|
||||
`<span class="tankstellenzeile-ort">${esc(oben)}${unten ? `<small>${esc(unten)}</small>` : ""}</span>` +
|
||||
(url ? `<svg class="chev" viewBox="0 0 6 10" aria-hidden="true"><polyline points="1,1 5,5 1,9"/></svg>` : "");
|
||||
|
||||
@@ -49,6 +49,7 @@ from homeassistant.helpers.storage import Store
|
||||
|
||||
from . import (
|
||||
batterie,
|
||||
belege,
|
||||
bilder,
|
||||
fahrterkennung,
|
||||
flespi,
|
||||
@@ -216,6 +217,11 @@ class Koordinator:
|
||||
# Fahrt warten. Reicht das Ortsbudget eines Laufs nicht, plant das
|
||||
# Screening selbst einen zweiten ein (screening._nachfassen_planen).
|
||||
await screening.durchfuehren(self)
|
||||
# Aus demselben Grund hier: die Tankstellenmarke ist so ein neues Feld.
|
||||
# Sie steht auf jedem Beleg, wurde aber vor dem 16.08.2026 nicht
|
||||
# gespeichert - nachgetragen wird sie aus der abgelegten Belegdatei,
|
||||
# einmal je Vorgang (siehe belege.marken_nachtragen).
|
||||
await belege.marken_nachtragen(self)
|
||||
# Weicht die Reglerstellung vom zuletzt gelesenen Geraetewert ab,
|
||||
# wird die Aenderung hier vorgemerkt - auch wenn sie waehrend eines
|
||||
# Ausfalls gesetzt wurde oder ein frueherer Auftrag nie zum Zug kam.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "audi_dashboard",
|
||||
"name": "Audi Dashboard",
|
||||
"version": "2026.9.4.20",
|
||||
"version": "2026.9.4.21",
|
||||
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
|
||||
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
|
||||
"codeowners": [
|
||||
|
||||
@@ -123,6 +123,25 @@ def _marke(zeilen):
|
||||
return None
|
||||
|
||||
|
||||
def marke_aus_text(text):
|
||||
"""Die Kopfmarke eines Belegtextes - der oeffentliche Weg zu _marke().
|
||||
|
||||
Getrennt vom vollstaendigen Einlesen, weil die Marke nur die ersten Zeilen
|
||||
braucht: sie laesst sich auch aus einem Beleg holen, an dem beide Parser
|
||||
scheitern wuerden. Genau das braucht das Nachtragen in belege.py fuer
|
||||
Belege, die vor dem 16.08.2026 eingelesen wurden."""
|
||||
return _marke((text or "").splitlines())
|
||||
|
||||
|
||||
def marke_aus_pdf(pdf_pfad):
|
||||
"""Wie marke_aus_text(), aber liest das PDF selbst. None, wenn die Datei
|
||||
sich nicht lesen laesst - eine fehlende Marke ist kein Fehlerfall."""
|
||||
try:
|
||||
return marke_aus_text(_text_aus_pdf(pdf_pfad))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ist_strasse(zeile):
|
||||
"""Straßenzeilen tragen eine Hausnummer oder ein Straßenwort - der
|
||||
Betreibername ("AUTO B. FRISCHMANN GMBH") tut beides nicht."""
|
||||
@@ -234,6 +253,12 @@ def _parsen(text):
|
||||
# Anzeigename "Marke, Straße, Ort" (siehe _tankstelle). Der Ort steht
|
||||
# auf dem Bon als "85057 Ingolstadt" - die PLZ gehört nur in die
|
||||
# ausführliche station_address darunter, nicht in den Anzeigenamen.
|
||||
# Die Marke steht seit dem 04.09.2026 ZUSAETZLICH als eigenes Feld da.
|
||||
# Sie nur in den Anzeigenamen zu falten hat sie fuer jeden Beleg
|
||||
# verloren, den ein aelterer Parser eingelesen hat - dort blieb der
|
||||
# Betreibername stehen ("TC Sengül GmbH"), und keine Oberflaeche
|
||||
# konnte die Marke nachtraeglich noch finden.
|
||||
"station_brand": _marke(text.splitlines()),
|
||||
"station_name": _tankstelle(
|
||||
_marke(text.splitlines()),
|
||||
station.group("strasse").strip(),
|
||||
@@ -325,7 +350,8 @@ def _parsen_generisch(text):
|
||||
break
|
||||
if station_address and strasse and strasse not in station_address:
|
||||
station_address = f"{strasse}, {station_address}"
|
||||
station_name = _tankstelle(_marke(zeilen), strasse, ort, ersatzname=betreiber)
|
||||
marke = _marke(zeilen)
|
||||
station_name = _tankstelle(marke, strasse, ort, ersatzname=betreiber)
|
||||
|
||||
# ---- Gesamtbetrag ------------------------------------------------------
|
||||
# Zeilenweise statt über den ganzen Text, aus zwei Gründen, die beide an
|
||||
@@ -408,6 +434,7 @@ def _parsen_generisch(text):
|
||||
"ts_payment": None,
|
||||
"ts_tse": None,
|
||||
"station_id": None,
|
||||
"station_brand": marke,
|
||||
"station_name": station_name,
|
||||
"station_address": station_address,
|
||||
"article_no": None,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regressionstest für shell_beleg_parser.py gegen zehn echte Shell-eReceipts.
|
||||
"""Regressionstest für shell_beleg_parser.py gegen elf 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
|
||||
fünf verschiedenen Stationen (Ingolstadt/Zrenner an zwei Adressen, 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"
|
||||
@@ -42,12 +42,13 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase):
|
||||
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_elf_belege_gefunden(self):
|
||||
self.assertEqual(len(self.dateien), 11, self.dateien)
|
||||
|
||||
def test_pflichtfelder_gesetzt(self):
|
||||
pflichtfelder = [
|
||||
"receipt_key", "receipt_no", "ts", "station_id", "station_name",
|
||||
"receipt_key", "receipt_no", "ts", "station_id", "station_brand",
|
||||
"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",
|
||||
@@ -72,12 +73,35 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase):
|
||||
# Rabatt darf den Bruttobetrag nicht übersteigen
|
||||
self.assertLess(d["discount"], d["fuel_total_eur"])
|
||||
|
||||
def test_marke_als_eigenes_feld(self):
|
||||
# Seit dem 04.09.2026 steht die Kopfmarke ZUSAETZLICH als eigenes Feld
|
||||
# da, nicht nur in station_name eingefaltet. Genau daran fehlte sie
|
||||
# jedem Beleg, den ein aelterer Parser eingelesen hatte: dort blieb der
|
||||
# Betreibername stehen, und nachtraeglich war die Marke nirgends mehr
|
||||
# zu holen (Befund des Eigentuemers, 04.09.2026).
|
||||
for datei, d in self.ergebnisse.items():
|
||||
with self.subTest(datei=datei):
|
||||
self.assertEqual(d["station_brand"], "Shell")
|
||||
|
||||
def test_marke_ohne_vollstaendiges_einlesen(self):
|
||||
# marke_aus_pdf() liest nur den Belegkopf. Das ist der Weg, ueber den
|
||||
# belege.marken_nachtragen() die Marke bei Altbelegen nachtraegt - er
|
||||
# darf nicht vom vollstaendigen Parsen abhaengen.
|
||||
for datei in self.dateien:
|
||||
with self.subTest(datei=datei):
|
||||
self.assertEqual(p.marke_aus_pdf(os.path.join(_BELEGE, datei)), "Shell")
|
||||
|
||||
def test_marke_aus_pdf_ohne_datei(self):
|
||||
# Eine fehlende Belegdatei ist kein Fehlerfall - marken_nachtragen()
|
||||
# laeuft ueber Vorgaenge, deren PDF laengst geloescht sein kann.
|
||||
self.assertIsNone(p.marke_aus_pdf(os.path.join(_BELEGE, "gibtesnicht.pdf")))
|
||||
|
||||
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 Standorte in den zehn Belegen - stellt sicher, dass
|
||||
# Fünf verschiedene Standorte in den elf Belegen - stellt sicher, dass
|
||||
# die Stationserkennung nicht zufällig nur für einen Beleg passt.
|
||||
# Anzeigeformat seit 2026-08-16: "Marke, Straße, Ort" statt des reinen
|
||||
# Betreibernamens ("A. Zrenner GmbH" o. ä.) - der Betreiber sagt beim
|
||||
@@ -88,6 +112,7 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase):
|
||||
"Shell, Münchener Str.15a, Ingolstadt",
|
||||
"Shell, Nürnberger Str.74, Ansbach",
|
||||
"Shell, Heidenheimerstr.6, Königsbronn",
|
||||
"Shell, Richard-Wagner-Str. 9, Ingolstadt",
|
||||
}
|
||||
self.assertEqual(namen, erwartet)
|
||||
|
||||
@@ -107,6 +132,11 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase):
|
||||
"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"),
|
||||
# Der Beleg, der in der App nur mit Datum und Uhrzeit ankam
|
||||
# (Eigentuemer, 04.09.2026). Der Parser hat ihn immer vollstaendig
|
||||
# gelesen - der Fehler sass in Tanken.tsx, das andere Feldnamen
|
||||
# abfragte, als belege.py veroeffentlicht.
|
||||
"geteilt-2026-09-04.pdf": dict(liters=43.87, fuel_total_eur=95.60, discount=11.84, station_id="0000000459"),
|
||||
}
|
||||
for datei, erwartet in stichproben.items():
|
||||
d = self.ergebnisse[datei]
|
||||
@@ -120,7 +150,7 @@ class AlleBelegeParsenOhneFehler(unittest.TestCase):
|
||||
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."""
|
||||
Beleg, da keiner der elf 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"))
|
||||
|
||||
Reference in New Issue
Block a user