Die Erkennung gab es laengst – aber nur auf der Seite "Zu erledigen", in einer Karte, die man erst finden muss. Wer in der Buchungsliste steht und die zweite Haelfte eines Uebertrags sieht, greift stattdessen zum ↔-Knopf und legt einen zweiten Uebertrag an. Genau so ist heute eine Zahlung doppelt verschoben worden. Jetzt steht die Frage in der Zeile: "Uebertrag von/nach Revolut CHF?" mit Verbinden und Ignorieren. Ignorieren wird gemerkt, sonst fragte Certain bei jedem Laden erneut. Dafuer gibt es die Suche jetzt auch als reines Nachschlagen: ``finde`` mit ``nur_vorschlaege`` verknuepft nichts, und dahinter liegt GET /uebertraege/vorschlaege. Eine Ansicht, die bloss fragt, darf beim Hinsehen nichts veraendern – sonst entschiede das Anschauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
114 lines
4.3 KiB
Python
114 lines
4.3 KiB
Python
"""Ü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}
|