fix: Duplikat-Erkennung als Multiset – echte gleich aussehende Buchungen bleiben

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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 05:40:03 +02:00
co-authored by Claude Opus 4.8
parent 338c21935b
commit 14b3e53685
2 changed files with 36 additions and 14 deletions
+10 -5
View File
@@ -6,6 +6,7 @@ import csv
import hashlib import hashlib
import io import io
import json import json
from collections import Counter
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP 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 cid: kind
for cid, kind in db.query(Category.id, Category.type).filter(Category.owner_id == user.id) 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] h[0]
for h in db.query(Transaction.import_hash).filter( for h in db.query(Transaction.import_hash).filter(
Transaction.owner_id == user.id, Transaction.import_hash.isnot(None) Transaction.owner_id == user.id, Transaction.import_hash.isnot(None)
) )
} )
# Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide # Bestehende Transfers je Konto-Paar: erkennt die Gegenbuchung, wenn beide
# Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt). # Auszüge eines Übertrags importiert werden (sonst zählt die Bewegung doppelt).
transfer_index: dict[frozenset, list[tuple]] = {} 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( 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) (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] = [] out: list[dict] = []
for i, cand in enumerate(candidates): 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 category_id = rule.category_id
h = import_hash(account.id, d, amount, payee) h = import_hash(account.id, d, amount, payee)
is_dup = h in existing or h in seen is_dup = seen[h] < existing[h] # nur so viele Duplikate wie schon in der DB
seen.add(h) seen[h] += 1
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen. # Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen.
is_counterpart = ( is_counterpart = (
+26 -9
View File
@@ -22,7 +22,7 @@ CSV = (
"Datum;Betrag;Empfaenger;Zweck;GegenIBAN\n" "Datum;Betrag;Empfaenger;Zweck;GegenIBAN\n"
"15.03.2026;-50,00;REWE;Einkauf;\n" "15.03.2026;-50,00;REWE;Einkauf;\n"
"25.03.2026;3.000,00;Arbeitgeber;Gehalt;\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 "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) # Zeile 1: Einnahme 3000 (Tausenderpunkt korrekt)
assert rows[1]["type"] == "income" assert rows[1]["type"] == "income"
assert rows[1]["amount_cents"] == 300000 assert rows[1]["amount_cents"] == 300000
# Zeile 2: Duplikat der Zeile 0 # Zeile 2: gleich aussehend wie Zeile 0, aber im selben Auszug KEIN Duplikat.
assert rows[2]["is_duplicate"] is True assert rows[2]["is_duplicate"] is False
assert rows[2]["include"] is False assert rows[2]["include"] is True
# Zeile 3: interner Transfer (IBAN passt zu 'spar') # Zeile 3: interner Transfer (IBAN passt zu 'spar')
assert rows[3]["type"] == "transfer" assert rows[3]["type"] == "transfer"
assert rows[3]["to_account_id"] == spar["id"] 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 assert r.status_code == 200, r.text
batch = r.json() batch = r.json()
# Duplikat wird übersprungen -> 3 gebucht (Ausgabe, Einnahme, Transfer). # Beide REWE + Einnahme + Transfer = 4 gebucht (nichts fälschlich als Duplikat verworfen).
assert batch["imported_count"] == 3 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"] 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. # Regel gelernt: erneute Vorschau schlägt für REWE Lebensmittel vor.
rows2 = _preview(admin, giro["id"]).json()["rows"] 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. # Undo entfernt die Buchungen dieses Batches wieder.
r = admin.delete(f"/api/imports/batches/{batch['id']}") r = admin.delete(f"/api/imports/batches/{batch['id']}")
assert r.status_code == 200 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"] bal2 = admin.get(f"/api/accounts/{giro['id']}").json()["balance_cents"]
assert bal2 == 0, bal2 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