From 14b3e53685d50412ef9a11745e326e71bdfa29da Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 05:40:03 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Duplikat-Erkennung=20als=20Multiset=20?= =?UTF-8?q?=E2=80=93=20echte=20gleich=20aussehende=20Buchungen=20bleiben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher wurde eine zweite Buchung mit gleichem Tag/Betrag/Text (z. B. zwei Gutschriften à 324 am selben Tag) fälschlich als Duplikat verworfen. Jetzt gilt eine Zeile nur als Duplikat, wenn bereits GENAU SO VIELE identische Buchungen in der DB stehen – ein erneuter Import derselben Datei bleibt weiterhin Duplikat-sicher. - csv_import.detect_rows: existing/seen als Counter (Multiset) statt Set - Tests angepasst + Re-Import-Test ergänzt; an echtem Raiffeisen-Auszug verifiziert (254 Zeilen, 0 fälschliche Duplikate, Saldo == Bank-Endsaldo) Co-Authored-By: Claude Opus 4.8 --- backend/app/services/csv_import.py | 15 ++++++++----- backend/tests/test_import.py | 35 ++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index fac8539..0e3e42f 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -6,6 +6,7 @@ import csv import hashlib import io import json +from collections import Counter from datetime import date, datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP @@ -282,12 +283,16 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict cid: kind for cid, kind in db.query(Category.id, Category.type).filter(Category.owner_id == user.id) } - existing = { + # Duplikate als Multiset zählen: eine Zeile gilt nur dann als Duplikat, wenn + # bereits GENAU SO VIELE identische Buchungen in der DB stehen. So werden echte, + # gleich aussehende Buchungen desselben Tags (z. B. zwei Gutschriften über 324) + # behalten, ein erneuter Import derselben Datei aber weiter erkannt. + existing: Counter = Counter( h[0] for h in db.query(Transaction.import_hash).filter( Transaction.owner_id == user.id, Transaction.import_hash.isnot(None) ) - } + ) # Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide # Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt). transfer_index: dict[frozenset, list[tuple]] = {} @@ -300,7 +305,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict transfer_index.setdefault(frozenset((t_acc, t_to)), []).append( (t_date, t_amt, t_to_amt if t_to_amt is not None else t_amt) ) - seen: set[str] = set() + seen: Counter = Counter() out: list[dict] = [] for i, cand in enumerate(candidates): @@ -341,8 +346,8 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict category_id = rule.category_id h = import_hash(account.id, d, amount, payee) - is_dup = h in existing or h in seen - seen.add(h) + is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB + seen[h] += 1 # Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen. is_counterpart = ( diff --git a/backend/tests/test_import.py b/backend/tests/test_import.py index 226888f..d685910 100644 --- a/backend/tests/test_import.py +++ b/backend/tests/test_import.py @@ -22,7 +22,7 @@ CSV = ( "Datum;Betrag;Empfaenger;Zweck;GegenIBAN\n" "15.03.2026;-50,00;REWE;Einkauf;\n" "25.03.2026;3.000,00;Arbeitgeber;Gehalt;\n" - "15.03.2026;-50,00;REWE;Einkauf;\n" # Duplikat der ersten Zeile + "15.03.2026;-50,00;REWE;Einkauf;\n" # gleich aussehend, aber echte 2. Buchung "20.03.2026;-200,00;Sparen;Ueberweisung;CH9300762011623852957\n" # interner Transfer ) @@ -60,9 +60,9 @@ def test_preview_parsing_and_detection(admin): # Zeile 1: Einnahme 3000 (Tausenderpunkt korrekt) assert rows[1]["type"] == "income" assert rows[1]["amount_cents"] == 300000 - # Zeile 2: Duplikat der Zeile 0 - assert rows[2]["is_duplicate"] is True - assert rows[2]["include"] is False + # Zeile 2: gleich aussehend wie Zeile 0, aber im selben Auszug KEIN Duplikat. + assert rows[2]["is_duplicate"] is False + assert rows[2]["include"] is True # Zeile 3: interner Transfer (IBAN passt zu 'spar') assert rows[3]["type"] == "transfer" assert rows[3]["to_account_id"] == spar["id"] @@ -83,12 +83,12 @@ def test_commit_and_undo_and_learning(admin): }) assert r.status_code == 200, r.text batch = r.json() - # Duplikat wird übersprungen -> 3 gebucht (Ausgabe, Einnahme, Transfer). - assert batch["imported_count"] == 3 + # Beide REWE + Einnahme + Transfer = 4 gebucht (nichts fälschlich als Duplikat verworfen). + assert batch["imported_count"] == 4 - # Saldo: 0 + 3000 - 50 - 200(Transfer raus) = 2750,00 + # Saldo: 0 + 3000 - 50 - 50 - 200(Transfer raus) = 2700,00 bal = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"] - assert bal == 275000, bal + assert bal == 270000, bal # Regel gelernt: erneute Vorschau schlägt für REWE Lebensmittel vor. rows2 = _preview(admin, giro["id"]).json()["rows"] @@ -98,6 +98,23 @@ def test_commit_and_undo_and_learning(admin): # Undo entfernt die Buchungen dieses Batches wieder. r = admin.delete(f"/api/imports/batches/{batch['id']}") assert r.status_code == 200 - assert r.json()["deleted"] == 3 + assert r.json()["deleted"] == 4 bal2 = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"] assert bal2 == 0, bal2 + + +def test_reimport_same_file_is_deduped(admin): + """Erneuter Import derselben Datei erkennt ALLE Zeilen als Duplikat – + auch die zwei gleich aussehenden REWE-Buchungen (Multiset-Zählung).""" + giro = _account(admin, name="Giro Re") + _account(admin, name="Spar Re", type="sparkonto", currency="EUR", + identifiers=[{"kind": "iban", "value": "CH93 0076 2011 6238 5295 7"}]) + + rows = _preview(admin, giro["id"]).json()["rows"] + r = admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "k.csv", "rows": rows}) + assert r.json()["imported_count"] == 4 + + # Zweiter Vorschau-Lauf derselben Datei: nichts mehr buchbar. + again = _preview(admin, giro["id"]).json()["rows"] + assert all(r["is_duplicate"] for r in again) + assert sum(1 for r in again if r["include"]) == 0