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 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 = (