"""Ü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.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}