feat: Zeilenfilter sichtbar, mit auswaehlbaren Werten

Der Filter steckte in einem zugeklappten Bereich und der Wert musste
blind getippt werden – entsprechend importierte man den Revolut-Auszug
mit allen Produkten in ein Konto, was den Saldo-Abgleich unmoeglich
macht.

Die Rohvorschau liefert jetzt je Spalte die wiederkehrenden Werte samt
Zeilenzahl. Hat eine Datei so eine Spalte, steht der Filter offen sichtbar
mit der Ueberschrift "Diese Datei enthaelt mehrere Konten", und der Wert
wird aus einer Liste gewaehlt ("Giro (530 Zeilen)").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 18:38:05 +02:00
co-authored by Claude Opus 5
parent d05f4915ad
commit bc5349f193
5 changed files with 111 additions and 23 deletions
+32 -2
View File
@@ -172,8 +172,21 @@ def read_rows(data: bytes, template: ImportTemplate) -> list[list[str]]:
return rows
# Eine Spalte taugt als Zeilenfilter, wenn sie die Datei in wenige, klar
# benannte Gruppen teilt – etwa „Produkt" bei Revolut (Giro, Erspartes,
# Anlagekonto). Mehr als eine Handvoll Werte ist keine Gruppe mehr, sondern
# ein Datenfeld wie Empfänger oder Betrag.
MAX_FILTERWERTE = 12
def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
"""Rohe erste Zeilen (zum Spalten-Zuordnen im Template-Editor)."""
"""Rohe erste Zeilen (zum Spalten-Zuordnen im Template-Editor).
Liefert zusätzlich je Spalte die wählbaren Filterwerte mit Zeilenzahl.
Revolut legt alle Produkte in EINE Datei, jedes mit eigener Kontostand-
Spalte; ohne Filter zählen die Bewegungen doppelt und der Saldo-Abgleich
kann gar nicht aufgehen. Die Werte hier zu kennen erspart das Raten.
"""
text = decode_bytes(data, template.encoding)
delim = (template.delimiter or ";")[0]
reader = csv.reader(io.StringIO(text), delimiter=delim)
@@ -183,7 +196,24 @@ def preview_raw(data: bytes, template: ImportTemplate, limit: int = 8) -> dict:
if template.has_header and len(rows) > body_start:
header = rows[body_start]
body_start += 1
return {"header": header, "rows": rows[body_start: body_start + limit]}
body = rows[body_start:]
filterwerte: dict[int, list[dict]] = {}
breite = max((len(r) for r in body), default=0)
for i in range(breite):
zaehler = Counter((r[i] or "").strip() for r in body if i < len(r))
zaehler.pop("", None)
# Wiederholen sich die Werte nicht, ist es keine Gruppe, sondern ein
# Datenfeld wie Empfänger oder Betrag.
if 2 <= len(zaehler) <= MAX_FILTERWERTE and len(zaehler) < len(body):
filterwerte[i] = [{"value": v, "count": n} for v, n in zaehler.most_common()]
return {
"header": header,
"rows": body[:limit],
"row_count": len(body),
"filter_values": filterwerte,
}
def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee: str | None) -> str:
+29
View File
@@ -576,3 +576,32 @@ def test_kurzer_erkennungstext_trifft_nicht_mitten_im_wort(admin):
assert typ["TWINT EUROBUS AG"] == "expense"
# Auch als eigenständiges Wort verbindet der Erkennungs-Text keine Konten mehr.
assert typ["Umbuchung"] == "expense"
MEHRERE_PRODUKTE = (
"Produkt;Datum;Betrag;Empfaenger;Zweck\n"
"Giro;01.03.2026;-10,00;Coop;\n"
"Erspartes;01.03.2026;0,50;Aufrundung;\n"
"Giro;02.03.2026;-20,00;Migros;\n"
"Anlagekonto;02.03.2026;-100,00;Depot;\n"
"Giro;03.03.2026;-5,00;Kiosk;\n"
)
def test_rohvorschau_nennt_die_waehlbaren_filterwerte(admin):
"""Revolut legt alle Produkte in EINE Datei. Damit man den Zeilenfilter
setzen kann, ohne den Wert zu erraten, liefert die Vorschau die Werte je
Spalte samt Zeilenzahl mit."""
r = admin.post(
"/api/imports/raw",
files={"file": ("m.csv", io.BytesIO(MEHRERE_PRODUKTE.encode("utf-8")), "text/csv")},
data={"delimiter": ";", "encoding": "utf-8", "has_header": "true", "skip_rows": "0"},
)
assert r.status_code == 200, r.text
daten = r.json()
assert daten["row_count"] == 5
produkte = daten["filter_values"]["0"]
assert [p["value"] for p in produkte] == ["Giro", "Erspartes", "Anlagekonto"]
assert produkte[0]["count"] == 3
# Spalten mit lauter verschiedenen Werten sind keine Gruppen.
assert "3" not in daten["filter_values"]