diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 6372ff0..e6812b6 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -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: diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 0780524..620b944 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -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"] diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 37446c7..d26d3f0 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -355,10 +355,12 @@ "filterValue": "mit dem Wert", "filterValuePlaceholder": "z. B. Giro", "filterNone": "— kein Filter —", - "filterHint": "Revolut legt alle Pockets (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Filtere hier auf ein Produkt und lege für die anderen eigene Konten an – sonst zählen die Pocket-Bewegungen doppelt.", + "filterHint": "Revolut legt alle Produkte (Giro, Erspartes, Anlagekonto …) in EINE Datei, jedes mit eigenem Kontostand. Wähle hier eines aus und lies die übrigen mit einem eigenen Konto nochmal ein – sonst zählen die Bewegungen doppelt.", "removeEntry": "Eintrag entfernen", "removeEntryConfirm": "Den Verlaufs-Eintrag „{{file}}“ entfernen? Die Buchungen sind bereits zurückgenommen.", - "reconMixedHint": "Enthält die Datei mehrere Konten oder Produkte? Revolut legt Giro, Erspartes und Anlagekonto in EINE Datei, jedes mit eigener Kontostand-Spalte. Dann greift der Abgleich erst, wenn du oben den Zeilenfilter setzt (z. B. Produkt = Giro) und die übrigen Produkte einzeln einliest." + "reconMixedHint": "Enthält die Datei mehrere Konten oder Produkte? Revolut legt Giro, Erspartes und Anlagekonto in EINE Datei, jedes mit eigener Kontostand-Spalte. Dann greift der Abgleich erst, wenn du oben den Zeilenfilter setzt (z. B. Produkt = Giro) und die übrigen Produkte einzeln einliest.", + "filterTitle": "Diese Datei enthält mehrere Konten", + "filterValueOption": "{{value}} ({{count}} Zeilen)" }, "password": { "show": "Passwort anzeigen", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 1926bce..c653268 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -355,10 +355,12 @@ "filterValue": "with value", "filterValuePlaceholder": "e.g. Current", "filterNone": "— no filter —", - "filterHint": "Revolut puts every pocket (Current, Savings, Investments …) into ONE file, each with its own balance. Filter to one product here and create separate accounts for the others – otherwise pocket movements are counted twice.", + "filterHint": "Revolut puts every product (current, savings, investment …) into ONE file, each with its own balance. Pick one here and import the others separately into their own accounts – otherwise the movements count twice.", "removeEntry": "Remove entry", "removeEntryConfirm": "Remove the history entry “{{file}}”? Its transactions have already been undone.", - "reconMixedHint": "Does the file hold several accounts or products? Revolut puts current, savings and investment into ONE file, each with its own balance column. The check only works once you set the row filter above (e.g. Product = Current) and import the other products separately." + "reconMixedHint": "Does the file hold several accounts or products? Revolut puts current, savings and investment into ONE file, each with its own balance column. The check only works once you set the row filter above (e.g. Product = Current) and import the other products separately.", + "filterTitle": "This file holds several accounts", + "filterValueOption": "{{value}} ({{count}} rows)" }, "password": { "show": "Show password", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index ba3609a..20e4ae9 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -218,6 +218,12 @@ export default function Import() { })); }, [raw]); + // Spalten, die die Datei in wenige Gruppen teilen (Revolut: „Produkt"). + const filterSpalten = useMemo( + () => colOptions.filter((c) => raw?.filter_values?.[String(c.i)]), + [colOptions, raw], + ); + const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); @@ -521,6 +527,42 @@ export default function Import() { ))} + {/* Revolut legt alle Produkte in EINE Datei. Ohne Filter zaehlen die + Bewegungen doppelt – deshalb steht der Filter offen, sobald die + Datei eine Spalte mit wenigen wiederkehrenden Werten hat. */} + {filterSpalten.length > 0 && ( +
+ {t("import.filterTitle")} +

{t("import.filterHint")}

+
+ + +
+
+ )} +
{t("import.moreColumns")}

{t("import.ibanHint")}

@@ -542,23 +584,6 @@ export default function Import() { ))}

{t("import.extraColsHint")}

-
- - -
-

{t("import.filterHint")}