feat: Transfer-Erkennung symmetrisch (Eingänge) + Gegenbuchungs-Erkennung

- Enthält der Buchungstext den Erkennungs-Text (Art „Text") eines ANDEREN
  Kontos, wird auch ein Eingang als Transfer VON diesem Konto gesetzt – nicht
  nur die Ausgabe. Richtung ergibt sich aus dem Vorzeichen.
- Gegenbuchungs-Erkennung: existiert schon ein Transfer zwischen denselben
  Konten mit passendem Betrag (±5 Tage), wird die Gegenseite als „Gegenbuchung"
  markiert und nicht angehakt – so zählt dieselbe Bewegung beim Import beider
  Auszüge nicht doppelt (überschreibbar in der Prüfansicht).
- Tests: Eingang übers Konto-Tag, Gegenbuchung übersprungen (78 grün)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-20 20:52:31 +02:00
co-authored by Claude Opus 4.8
parent 1f03483e5d
commit 338c21935b
5 changed files with 74 additions and 2 deletions
+41 -1
View File
@@ -209,6 +209,22 @@ def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpos
return best[1] if best else None
# Fenster, in dem eine Buchung als Gegenseite eines bestehenden Transfers gilt
# (Buchungs-/Valutadatum können ein paar Tage auseinanderliegen).
COUNTERPART_DAYS = 5
def _is_counterpart(transfer_index: dict, account_id: int, other_id: int, d: date, amount: int) -> bool:
"""True, wenn schon ein Transfer zwischen denselben Konten existiert, dessen
Betrag (Soll- oder Haben-Seite) zu dieser Buchung passt – dann ist es die
bereits gebuchte Gegenseite und darf nicht erneut zählen."""
absamt = abs(amount)
for t_date, amt, to_amt in transfer_index.get(frozenset((account_id, other_id)), []):
if abs((t_date - d).days) <= COUNTERPART_DAYS and absamt in (amt, to_amt):
return True
return False
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
"""Zerlegt die Datei anhand des Templates in rohe Kandidaten-Zeilen."""
cmap = json.loads(template.column_map or "{}")
@@ -272,6 +288,18 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
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]] = {}
for t_acc, t_to, t_date, t_amt, t_to_amt in (
db.query(Transaction.account_id, Transaction.to_account_id, Transaction.date,
Transaction.amount_cents, Transaction.to_amount_cents)
.filter(Transaction.owner_id == user.id, Transaction.type == TxnType.transfer,
Transaction.to_account_id.isnot(None))
):
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()
out: list[dict] = []
@@ -290,6 +318,10 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
to_account_id: int | None = None
other = idents.get(iban) if iban else None
if other is None:
# Symmetrisch: enthält der Text den Erkennungs-Text eines ANDEREN
# Kontos, ist es ein Transfer von/zu diesem Konto (auch Eingänge).
other = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
if other is not None and other != account.id:
ttype = "transfer"
to_account_id = other
@@ -312,6 +344,13 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
is_dup = h in existing or h in seen
seen.add(h)
# Gegenbuchung eines bereits gebuchten Transfers? Dann nicht erneut buchen.
is_counterpart = (
ttype == "transfer" and to_account_id is not None
and d is not None and amount is not None
and _is_counterpart(transfer_index, account.id, to_account_id, d, amount)
)
out.append({
"index": i,
"date": d.isoformat() if d else None,
@@ -326,7 +365,8 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"to_account_id": to_account_id,
"import_hash": h,
"is_duplicate": is_dup,
"include": valid and not is_dup,
"is_counterpart": is_counterpart,
"include": valid and not is_dup and not is_counterpart,
})
return out