"""Überträge zwischen eigenen Konten: suchen, verbinden, lösen. Alle Wege hier ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung wird angefasst – das ist die tragende Eigenschaft des Entwurfs, nicht eine Bequemlichkeit. """ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned from ..models import Account, LinkIgnored, Transaction, TransferLink, TxnType, User from ..services import uebertraege router = APIRouter(prefix="/uebertraege", tags=["uebertraege"]) class PaarIn(BaseModel): a_id: int b_id: int def _paar(db: Session, user: User, payload: PaarIn) -> tuple[Transaction, Transaction]: a = get_owned(db, Transaction, payload.a_id, user) b = get_owned(db, Transaction, payload.b_id, user) if a.id == b.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Eine Buchung kann nicht mit sich selbst verknüpft werden") return a, b @router.get("") def list_links(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]: return [ {"id": l.id, "from_txn_id": l.from_txn_id, "to_txn_id": l.to_txn_id, "automatisch": l.automatisch} for l in db.query(TransferLink).filter(TransferLink.owner_id == user.id) .order_by(TransferLink.id) ] @router.get("/vorschlaege") def vorschlaege(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Nur fragen, nichts tun. Getrennt von ``/suchen``: Eine Ansicht, die bloss „ist das ein Übertrag?" anzeigt, darf beim Hinsehen nichts verknüpfen. """ return uebertraege.finde(db, user, nur_vorschlaege=True) @router.post("/suchen") def suchen(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict: """Paare suchen. Eindeutige mit Übertrags-Hinweis werden verknüpft, der Rest kommt als Vorschlag zurück.""" return uebertraege.finde(db, user) @router.post("/verbinden") def verbinden( payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> dict: a, b = _paar(db, user, payload) if a.account_id == b.account_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Beide Buchungen gehören zum selben Konto") if {a.type, b.type} != {TxnType.expense, TxnType.income}: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ein Übertrag braucht einen Abgang und einen Zugang") schon = ( db.query(TransferLink) .filter(TransferLink.owner_id == user.id) .filter((TransferLink.from_txn_id.in_((a.id, b.id))) | (TransferLink.to_txn_id.in_((a.id, b.id)))) .first() ) if schon is not None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Eine der Buchungen ist bereits verknüpft") link = uebertraege.verbinde(db, user, a, b, automatisch=False) db.commit() return {"id": link.id, "from_txn_id": link.from_txn_id, "to_txn_id": link.to_txn_id, "automatisch": False} @router.get("/ignoriert") def ignorierte(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict]: """Welche Vorschlaege wurden abgelehnt? Ohne diese Liste war „Ignorieren" eine Einbahnstrasse: Das Paar blieb bei jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen Vorschlag gab. Wer einmal falsch klickte, suchte danach einen Fehler in der Erkennung, den es nicht gab. """ eintraege = ( db.query(LinkIgnored) .filter(LinkIgnored.owner_id == user.id) .order_by(LinkIgnored.id.desc()) .all() ) ids = {i for e in eintraege for i in (e.a_txn_id, e.b_txn_id)} buchungen = { t.id: t for t in db.query(Transaction).filter(Transaction.id.in_(ids or [0])) } namen = dict(db.query(Account.id, Account.name).filter(Account.owner_id == user.id)) def kurz(txn_id: int) -> dict | None: t = buchungen.get(txn_id) if t is None: return None return { "id": t.id, "date": t.date.isoformat(), "amount_cents": t.amount_cents, "payee": t.payee or t.description, "account_name": namen.get(t.account_id, "?"), } ergebnis = [] for e in eintraege: a, b = kurz(e.a_txn_id), kurz(e.b_txn_id) # Ist eine der beiden Buchungen geloescht, gibt es nichts mehr # zurueckzunehmen – der Eintrag waere nur noch Rauschen. if a and b: ergebnis.append({"a": a, "b": b}) return ergebnis @router.delete("/ignoriert", status_code=status.HTTP_204_NO_CONTENT) def ignorieren_aufheben( payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> None: """Die Ablehnung zuruecknehmen – das Paar wird wieder vorgeschlagen.""" a, b = _paar(db, user, payload) klein, gross = sorted((a.id, b.id)) ( db.query(LinkIgnored) .filter(LinkIgnored.owner_id == user.id, LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross) .delete() ) db.commit() # Vor der Route mit dem Platzhalter, sonst verschluckt "/{link_id}" den # wortwoertlichen Pfad: FastAPI nimmt die erste passende Route, und # "ignoriert" laesst sich nicht als Zahl lesen (422 statt 204). @router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT) def loesen(link_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Verknüpfung entfernen. Die beiden Buchungen bleiben, wie sie sind – inklusive ihrer Kategorien, die damit sofort wieder in der Auswertung zählen.""" db.delete(get_owned(db, TransferLink, link_id, user)) db.commit() @router.post("/ignorieren", status_code=status.HTTP_201_CREATED) def ignorieren( payload: PaarIn, db: Session = Depends(get_db), user: User = Depends(get_current_user) ) -> dict: """Vorschlag ablehnen, damit er bei jedem weiteren Suchlauf wegbleibt.""" a, b = _paar(db, user, payload) klein, gross = sorted((a.id, b.id)) vorhanden = ( db.query(LinkIgnored) .filter(LinkIgnored.owner_id == user.id, LinkIgnored.a_txn_id == klein, LinkIgnored.b_txn_id == gross) .first() ) if vorhanden is None: db.add(LinkIgnored(owner_id=user.id, a_txn_id=klein, b_txn_id=gross)) db.commit() return {"ok": True}