feat: Gleich aussehende Buchungen stehen in der Arbeitsliste

Bisher fand man Dubletten nur zufaellig beim Durchscrollen der Buchungsliste –
oder ich habe ein Skript dafuer geschrieben, und der Nutzer musste sich auf
einen Server einloggen, um seine eigenen Zahlen zu reparieren. Beides ist
falsch.

Die Arbeitsliste zeigt sie jetzt: gleiches Konto, gleicher Tag, gleicher
Betrag, gleicher Text. Je Zeile steht die Kennung der Bank dabei, und die
entscheidet die Frage – dieselbe Kennung heisst sicher dieselbe Zahlung,
verschiedene heissen, die Bank hat sie als verschieden gemeldet.

Entschieden wird bewusst NICHTS von selbst. Drei Aufrundungen ueber 1.00 am
selben Tag sind echt, drei gleiche Einkaeufe koennen es auch sein; das weiss
nur der Nutzer. Certain meldet und laesst loeschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 14:32:40 +02:00
co-authored by Claude Opus 5
parent 63056ae76a
commit 335a7d55c8
8 changed files with 229 additions and 0 deletions
+48
View File
@@ -159,6 +159,54 @@ def list_transactions(
return [to_out(db, t, base, ccy, woher) for t in rows]
@router.get("/dubletten")
def duplicates(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
"""Buchungen, die gleich aussehen: gleiches Konto, gleicher Tag, gleicher
Betrag, gleicher Text.
Entschieden wird hier NICHTS. Drei Aufrundungen über 1.00 am selben Tag
sind echt, drei gleiche Einkäufe können es auch sein – das weiss nur der
Nutzer. Deshalb steht zu jeder Zeile die Kennung der Bank dabei: Tragen
zwei Zeilen dieselbe, ist es sicher dieselbe Zahlung; tragen sie
verschiedene, hat die Bank sie als verschieden gemeldet.
"""
from ..services.csv_import import normalize_text
gruppen: dict[tuple, list] = {}
for txn in db.query(Transaction).filter(
Transaction.owner_id == user.id,
Transaction.type != TxnType.transfer,
).order_by(Transaction.date.desc(), Transaction.id):
schluessel = (txn.account_id, txn.type.value, txn.date,
txn.amount_cents, normalize_text(txn.payee or txn.description or ""))
gruppen.setdefault(schluessel, []).append(txn)
verdaechtig = {k: v for k, v in gruppen.items() if len(v) > 1}
if not verdaechtig:
return {"gruppen": []}
alle_ids = [t.id for zeilen in verdaechtig.values() for t in zeilen]
herkunft = _herkunft(db, user, [t for zeilen in verdaechtig.values() for t in zeilen])
kennungen = {
txn_id: (wert or "").split(":", 1)[-1]
for txn_id, wert in db.query(InboxItem.transaction_id, InboxItem.external_id)
.filter(InboxItem.owner_id == user.id, InboxItem.transaction_id.in_(alle_ids))
}
return {"gruppen": [
{
"account_id": k[0], "datum": k[2].isoformat(), "amount_cents": k[3],
"typ": k[1],
"zeilen": [
{"id": t.id, "payee": t.payee, "description": t.description,
"category_id": t.category_id, "herkunft": herkunft.get(t.id, "manuell"),
"kennung": kennungen.get(t.id)}
for t in zeilen
],
}
for k, zeilen in sorted(verdaechtig.items(), key=lambda p: p[0][2], reverse=True)
][:100]}
@router.post("", response_model=TransactionOut, status_code=status.HTTP_201_CREATED)
def create_transaction(
payload: TransactionCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
+43
View File
@@ -78,3 +78,46 @@ def test_verknuepfte_buchungen_sind_nicht_mehr_offen(admin):
params={"uncategorized": True}).json()}
assert raus["id"] not in danach and rein["id"] not in danach, \
"verknuepfte Buchungen brauchen keine Kategorie mehr"
def test_gleich_aussehende_buchungen_werden_gemeldet_nicht_entschieden(admin):
"""Drei Aufrundungen ueber 1.00 am selben Tag sind echt, drei gleiche
Einkaeufe koennen es auch sein – das weiss nur der Nutzer. Deshalb wird
gemeldet, nicht entschieden, und die Kennung der Bank steht dabei."""
import secrets
from app.database import SessionLocal
from app.models import InboxItem, InboxSource, InboxStatus, User
konto = admin.post("/api/accounts", json={
"name": f"Dublette {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
text = f"Migrolino {secrets.token_hex(3)}"
ids = [admin.post("/api/transactions", json={
"type": "expense", "date": "2026-08-25", "account_id": konto["id"],
"amount_cents": 390, "payee": text}).json()["id"] for _ in range(3)]
# Zwei davon kamen von der Bank, unter verschiedenen Kennungen.
db = SessionLocal()
try:
besitzer = db.query(User).order_by(User.id).first()
for i, txn_id in enumerate(ids[:2]):
db.add(InboxItem(owner_id=besitzer.id, source=InboxSource.bank,
status=InboxStatus.booked, account_id=konto["id"],
date=__import__("datetime").date(2026, 8, 25),
amount_cents=-390, currency="CHF", payee=text,
external_id=f"acc-x:ref-{i}", transaction_id=txn_id))
db.commit()
finally:
db.close()
gruppen = admin.get("/api/transactions/dubletten").json()["gruppen"]
meine = next(g for g in gruppen if g["account_id"] == konto["id"])
assert len(meine["zeilen"]) == 3
assert meine["amount_cents"] == 390
kennungen = {z["kennung"] for z in meine["zeilen"]}
assert kennungen == {"ref-0", "ref-1", None}, "die Kennung muss dabeistehen"
assert {z["herkunft"] for z in meine["zeilen"]} == {"bank", "manuell"}
# Und nichts wurde geloescht.
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == -1170