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:
@@ -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 = (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user