Regeln greifen beim Import. Alles, was vorher hereinkam – oder hereinkam, bevor es die Regel gab –, bleibt liegen. Bei einer Bankanbindung passiert genau das dauernd: Die Umsaetze kommen von selbst, die Regel entsteht spaeter. Die Revolut-Aufrundungen aufs Sparen-Pocket sind der Fall, an dem es aufgefallen ist. Der Knopf steht bei der Transfer-Erkennung und zeigt erst, was passieren wuerde. Uebertrags-Regeln laufen durch denselben Dienst wie jeder andere Uebertrag – er lehnt ab, wo die Gegenseite schon gebucht ist, und solche Buchungen bleiben unangetastet und werden mit Grund gemeldet. Ohne festes Zielkonto wird es aus dem Buchungstext erraten, aber nur bei GENAU einem Treffer und nur an Wortgrenzen: "Euro" darf nicht "PayPal Europe" treffen. Zwei Moeglichkeiten heisst raten, und dann passiert nichts. Angefasst werden nur Buchungen OHNE Kategorie – sonst ueberschriebe der Knopf stillschweigend, was von Hand zugeordnet wurde. Ein Uebertrag ist kein Kandidat mehr, ein zweiter Klick verschiebt das Geld also nicht erneut. Zwei Dinge fielen beim Testen auf und sind mit behoben: - Ein fehlgeschlagener Uebertrag verwarf per rollback() auch alles, was im selben Durchlauf schon gelungen war. Jetzt haelt ein Rettungspunkt je Uebertrag den Schaden bei dem einen. - test_neutral_category nahm VORHANDENE Kategorien und stellte eine davon auf "nicht einrechnen". Benutzten andere Buchungen sie, fielen die mit heraus und die Rechnung ging um deren Summe daneben – ohne Bezug zum Gepruefften. Der Test legt sich seine Kategorien jetzt selbst an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
441 lines
16 KiB
Python
441 lines
16 KiB
Python
"""Kategorie-Gruppen und Kategorien (zweistufig). Alles owner-gescoped.
|
||
|
||
Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe.
|
||
"""
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy import func
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user, get_owned
|
||
from ..models import Account, CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User
|
||
from ..schemas import (
|
||
CategoryCreate,
|
||
CategoryGroupCreate,
|
||
CategoryGroupOut,
|
||
CategoryGroupUpdate,
|
||
CategoryOut,
|
||
CategoryUpdate,
|
||
FlaggedKeywordOut,
|
||
KeywordCreate,
|
||
KeywordOut,
|
||
KeywordUpdate,
|
||
TransferKeywordCreate,
|
||
TransferKeywordOut,
|
||
)
|
||
from ..services.csv_import import normalize_text
|
||
|
||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||
|
||
# Ab wann ein manuelles Schlagwort als „stimmt vielleicht nicht" markiert wird:
|
||
# mindestens so viele Anwendungen UND mindestens dieser Anteil wegkorrigiert.
|
||
FLAG_MIN_APPLIED = 5
|
||
FLAG_RATIO_DEN = 3 # >= 1/3 (jede dritte)
|
||
|
||
|
||
def group_out(g: CategoryGroup, keyword_counts: dict[int, int] | None = None) -> CategoryGroupOut:
|
||
cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower()))
|
||
out = []
|
||
for c in cats:
|
||
eintrag = CategoryOut.model_validate(c)
|
||
if keyword_counts is not None:
|
||
eintrag.keyword_count = keyword_counts.get(c.id, 0)
|
||
out.append(eintrag)
|
||
return CategoryGroupOut(
|
||
id=g.id,
|
||
name=g.name,
|
||
kind=g.kind,
|
||
icon=g.icon,
|
||
color=g.color,
|
||
position=g.position,
|
||
is_builtin=g.is_builtin,
|
||
categories=out,
|
||
)
|
||
|
||
|
||
def _keyword_counts(db: Session, user: User) -> dict[int, int]:
|
||
"""Schlagwörter je Kategorie – eine Abfrage für die ganze Liste.
|
||
|
||
Die Übersicht markiert damit die Kategorien, für die schon etwas hinterlegt
|
||
ist; ein Abruf je Kategorie waere dafuer unverhaeltnismaessig."""
|
||
zeilen = (
|
||
db.query(CategorizationRule.category_id, func.count(CategorizationRule.id))
|
||
.filter(
|
||
CategorizationRule.owner_id == user.id,
|
||
CategorizationRule.source == RuleSource.manual,
|
||
CategorizationRule.is_transfer.is_(False),
|
||
CategorizationRule.category_id.isnot(None),
|
||
)
|
||
.group_by(CategorizationRule.category_id)
|
||
.all()
|
||
)
|
||
return {cat_id: anzahl for cat_id, anzahl in zeilen}
|
||
|
||
|
||
def _next_position(db: Session, model, **filters) -> int:
|
||
q = db.query(model.position)
|
||
for k, v in filters.items():
|
||
q = q.filter(getattr(model, k) == v)
|
||
row = q.order_by(model.position.desc()).first()
|
||
return (row[0] + 1) if row else 0
|
||
|
||
|
||
@router.get("", response_model=list[CategoryGroupOut])
|
||
def list_categories(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
groups = (
|
||
db.query(CategoryGroup)
|
||
.filter(CategoryGroup.owner_id == user.id)
|
||
.order_by(CategoryGroup.position, CategoryGroup.name)
|
||
.all()
|
||
)
|
||
counts = _keyword_counts(db, user)
|
||
return [group_out(g, counts) for g in groups]
|
||
|
||
|
||
# --- Gruppen ---------------------------------------------------------------
|
||
|
||
|
||
@router.post("/groups", response_model=CategoryGroupOut, status_code=status.HTTP_201_CREATED)
|
||
def create_group(
|
||
payload: CategoryGroupCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
g = CategoryGroup(
|
||
owner_id=user.id,
|
||
name=payload.name.strip(),
|
||
kind=payload.kind,
|
||
icon=payload.icon,
|
||
color=payload.color,
|
||
position=_next_position(db, CategoryGroup, owner_id=user.id),
|
||
)
|
||
db.add(g)
|
||
db.commit()
|
||
db.refresh(g)
|
||
return group_out(g)
|
||
|
||
|
||
@router.patch("/groups/{group_id}", response_model=CategoryGroupOut)
|
||
def update_group(
|
||
group_id: int,
|
||
payload: CategoryGroupUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
g = get_owned(db, CategoryGroup, group_id, user)
|
||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||
if value is not None:
|
||
setattr(g, key, value)
|
||
db.commit()
|
||
db.refresh(g)
|
||
return group_out(g)
|
||
|
||
|
||
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_group(
|
||
group_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
g = get_owned(db, CategoryGroup, group_id, user)
|
||
db.delete(g)
|
||
db.commit()
|
||
|
||
|
||
# --- Kategorien ------------------------------------------------------------
|
||
|
||
|
||
@router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED)
|
||
def create_category(
|
||
payload: CategoryCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
group = get_owned(db, CategoryGroup, payload.group_id, user)
|
||
cat = Category(
|
||
owner_id=user.id,
|
||
group_id=group.id,
|
||
name=payload.name.strip(),
|
||
type=group.kind, # erbt die Art der Gruppe
|
||
icon=payload.icon,
|
||
color=payload.color,
|
||
excluded_from_stats=payload.excluded_from_stats,
|
||
position=_next_position(db, Category, owner_id=user.id, group_id=group.id),
|
||
)
|
||
db.add(cat)
|
||
db.commit()
|
||
db.refresh(cat)
|
||
return CategoryOut.model_validate(cat)
|
||
|
||
|
||
@router.patch("/{category_id}", response_model=CategoryOut)
|
||
def update_category(
|
||
category_id: int,
|
||
payload: CategoryUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
cat = get_owned(db, Category, category_id, user)
|
||
data = payload.model_dump(exclude_unset=True)
|
||
new_group_id = data.pop("group_id", None)
|
||
if new_group_id is not None:
|
||
group = get_owned(db, CategoryGroup, new_group_id, user)
|
||
cat.group_id = group.id
|
||
cat.type = group.kind # Art an die neue Gruppe angleichen
|
||
for key, value in data.items():
|
||
# Bei den Schaltern zählt auch False als gesetzter Wert – sonst ließe sich
|
||
# ein einmal gesetztes Häkchen nie wieder entfernen.
|
||
if value is not None or key in ("is_archived", "excluded_from_stats"):
|
||
setattr(cat, key, value)
|
||
db.commit()
|
||
db.refresh(cat)
|
||
return CategoryOut.model_validate(cat)
|
||
|
||
|
||
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_category(
|
||
category_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
cat = get_owned(db, Category, category_id, user)
|
||
db.delete(cat)
|
||
db.commit()
|
||
|
||
|
||
# --- Schlagwörter je Kategorie (manuelle Regeln, Feld = Empfänger ODER Zweck) ---
|
||
|
||
|
||
def _keyword_out(rule: CategorizationRule) -> KeywordOut:
|
||
return KeywordOut(id=rule.id, category_id=rule.category_id, word=rule.pattern, amount_cents=rule.amount_cents)
|
||
|
||
|
||
def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationRule:
|
||
rule = db.get(CategorizationRule, rule_id)
|
||
if rule is None or rule.owner_id != user.id or rule.source != RuleSource.manual:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Nicht gefunden")
|
||
return rule
|
||
|
||
|
||
def _conflict(db: Session, clash: CategorizationRule) -> HTTPException:
|
||
"""Klare Meldung, wenn ein Schlagwort schon woanders (Kategorie/Transfer) hängt."""
|
||
if clash.is_transfer:
|
||
wo = "als Transfer-Regel"
|
||
else:
|
||
cat = db.get(Category, clash.category_id)
|
||
wo = f"der Kategorie „{cat.name if cat else '?'}“"
|
||
return HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail=f"Schlagwort ist bereits {wo} zugeordnet.",
|
||
)
|
||
|
||
|
||
def _find_any_rule(
|
||
db: Session, user: User, pattern: str, kind: str,
|
||
amount_cents: int | None = None, exclude_id: int | None = None,
|
||
):
|
||
"""Sucht ein manuelles Schlagwort gleicher Art (kind) UND gleichen Betrags.
|
||
|
||
Dasselbe Wort darf je einmal für Ausgabe, Einnahme und Transfer existieren –
|
||
und innerhalb einer Art mehrfach mit verschiedenen Beträgen: Im Personal-
|
||
restaurant zahlt man mal 5.00, mal 7.50. Ohne Betrag ist es der Auffang für
|
||
alle übrigen Beträge."""
|
||
q = db.query(CategorizationRule).filter(
|
||
CategorizationRule.owner_id == user.id,
|
||
CategorizationRule.match_field == MatchField.any,
|
||
CategorizationRule.pattern == pattern,
|
||
CategorizationRule.kind == kind,
|
||
)
|
||
q = q.filter(
|
||
CategorizationRule.amount_cents.is_(None) if amount_cents is None
|
||
else CategorizationRule.amount_cents == amount_cents
|
||
)
|
||
if exclude_id is not None:
|
||
q = q.filter(CategorizationRule.id != exclude_id)
|
||
return q.first()
|
||
|
||
|
||
@router.get("/keywords/flagged", response_model=list[FlaggedKeywordOut])
|
||
def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""Manuelle Schlagwörter, die auffällig oft wegkorrigiert werden."""
|
||
rules = (
|
||
db.query(CategorizationRule)
|
||
.filter(
|
||
CategorizationRule.owner_id == user.id,
|
||
CategorizationRule.source == RuleSource.manual,
|
||
CategorizationRule.confirmed.is_(False),
|
||
CategorizationRule.applied_count >= FLAG_MIN_APPLIED,
|
||
CategorizationRule.overridden_count * FLAG_RATIO_DEN >= CategorizationRule.applied_count,
|
||
)
|
||
.order_by(CategorizationRule.overridden_count.desc())
|
||
.all()
|
||
)
|
||
names = {c.id: c.name for c in db.query(Category).filter(Category.owner_id == user.id)}
|
||
return [
|
||
FlaggedKeywordOut(
|
||
id=r.id, category_id=r.category_id,
|
||
category_name="→ Transfer" if r.is_transfer else names.get(r.category_id, "?"),
|
||
word=r.pattern, applied_count=r.applied_count, overridden_count=r.overridden_count,
|
||
)
|
||
for r in rules
|
||
]
|
||
|
||
|
||
@router.post("/keywords/{rule_id}/confirm", status_code=status.HTTP_204_NO_CONTENT)
|
||
def confirm_keyword(rule_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
"""„Behalten" – nicht mehr nachfragen, obwohl korrigiert wird."""
|
||
rule = _owned_manual_rule(db, rule_id, user)
|
||
rule.confirmed = True
|
||
db.commit()
|
||
|
||
|
||
@router.patch("/keywords/{rule_id}", response_model=KeywordOut)
|
||
def update_keyword(
|
||
rule_id: int,
|
||
payload: KeywordUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
"""Schlagwort ändern – Wort und optional Betrag. Ändert sich das Muster,
|
||
werden die Korrektur-Zähler zurückgesetzt."""
|
||
rule = _owned_manual_rule(db, rule_id, user)
|
||
pattern = normalize_text(payload.word)[:255]
|
||
if not pattern:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||
neuer_betrag = (
|
||
payload.amount_cents if "amount_cents" in payload.model_fields_set else rule.amount_cents
|
||
)
|
||
if pattern != rule.pattern or neuer_betrag != rule.amount_cents:
|
||
clash = _find_any_rule(db, user, pattern, rule.kind, neuer_betrag, exclude_id=rule.id)
|
||
if clash is not None:
|
||
raise _conflict(db, clash)
|
||
if pattern != rule.pattern:
|
||
rule.pattern = pattern
|
||
rule.applied_count = 0
|
||
rule.overridden_count = 0
|
||
rule.confirmed = False
|
||
# Betrag nur anfassen, wenn er mitgeschickt wurde. Ausdrücklich null löscht
|
||
# ihn (das leere Betragsfeld im Popup), ein Weglassen lässt ihn stehen.
|
||
if "amount_cents" in payload.model_fields_set:
|
||
rule.amount_cents = payload.amount_cents
|
||
db.commit()
|
||
db.refresh(rule)
|
||
return _keyword_out(rule)
|
||
|
||
|
||
@router.delete("/keywords/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_keyword(rule_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
rule = _owned_manual_rule(db, rule_id, user)
|
||
db.delete(rule)
|
||
db.commit()
|
||
|
||
|
||
@router.get("/{category_id}/keywords", response_model=list[KeywordOut])
|
||
def list_keywords(category_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
cat = get_owned(db, Category, category_id, user)
|
||
rules = (
|
||
db.query(CategorizationRule)
|
||
.filter(
|
||
CategorizationRule.owner_id == user.id,
|
||
CategorizationRule.source == RuleSource.manual,
|
||
CategorizationRule.category_id == cat.id,
|
||
)
|
||
.order_by(CategorizationRule.pattern)
|
||
.all()
|
||
)
|
||
return [_keyword_out(r) for r in rules]
|
||
|
||
|
||
@router.post("/{category_id}/keywords", response_model=KeywordOut, status_code=status.HTTP_201_CREATED)
|
||
def add_keyword(
|
||
category_id: int,
|
||
payload: KeywordCreate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
cat = get_owned(db, Category, category_id, user)
|
||
pattern = normalize_text(payload.word)[:255]
|
||
if not pattern:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||
kind = cat.type.value # "expense" | "income" – Konflikt nur innerhalb derselben Art
|
||
# Konflikt nur bei gleichem Wort UND gleichem Betrag: „sv = 5.00" und
|
||
# „sv = 7.50" sind zwei gültige Regeln derselben Kategorie.
|
||
existing = _find_any_rule(db, user, pattern, kind, payload.amount_cents)
|
||
if existing is not None:
|
||
if not existing.is_transfer and existing.category_id == cat.id:
|
||
return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
|
||
raise _conflict(db, existing)
|
||
rule = CategorizationRule(
|
||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||
category_id=cat.id, amount_cents=payload.amount_cents, kind=kind,
|
||
source=RuleSource.manual, priority=10,
|
||
)
|
||
db.add(rule)
|
||
db.commit()
|
||
db.refresh(rule)
|
||
return _keyword_out(rule)
|
||
|
||
|
||
# --- Transfer-Schlagwörter (Text -> Transfer, Zielkonto aus Konto-Texten) ------
|
||
|
||
|
||
def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut:
|
||
return TransferKeywordOut(
|
||
id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents,
|
||
target_account_id=rule.target_account_id,
|
||
)
|
||
|
||
|
||
@router.post("/keywords/anwenden")
|
||
def apply_rules(vorschau: bool = True, db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user)) -> dict:
|
||
"""Regeln nachträglich auf Buchungen ohne Kategorie anwenden.
|
||
|
||
Regeln greifen sonst nur beim Import. Alles, was vorher hereinkam – oder
|
||
hereinkam, bevor es die Regel gab –, bleibt liegen; bei einer Bankanbindung
|
||
passiert genau das dauernd.
|
||
|
||
Mit ``vorschau`` wird nichts geändert. Erst zeigen, dann tun: Ein Übertrag
|
||
verschiebt Geld, und das gehört nicht hinter einen unbeschrifteten Knopf.
|
||
"""
|
||
from ..services import regeln
|
||
|
||
return regeln.wende_an(db, user, nur_vorschau=vorschau)
|
||
|
||
|
||
@router.get("/transfer-keywords", response_model=list[TransferKeywordOut])
|
||
def list_transfer_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||
rules = (
|
||
db.query(CategorizationRule)
|
||
.filter(
|
||
CategorizationRule.owner_id == user.id,
|
||
CategorizationRule.source == RuleSource.manual,
|
||
CategorizationRule.is_transfer.is_(True),
|
||
)
|
||
.order_by(CategorizationRule.pattern)
|
||
.all()
|
||
)
|
||
return [_transfer_keyword_out(r) for r in rules]
|
||
|
||
|
||
@router.post("/transfer-keywords", response_model=TransferKeywordOut, status_code=status.HTTP_201_CREATED)
|
||
def add_transfer_keyword(
|
||
payload: TransferKeywordCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
pattern = normalize_text(payload.word)[:255]
|
||
if not pattern:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||
target_id = None
|
||
if payload.target_account_id is not None:
|
||
target_id = get_owned(db, Account, payload.target_account_id, user).id
|
||
# Gleiches Wort existiert schon als Transfer-Regel -> aktualisieren (Betrag/Zielkonto).
|
||
existing = _find_any_rule(db, user, pattern, "transfer", payload.amount_cents)
|
||
if existing is not None:
|
||
existing.amount_cents = payload.amount_cents
|
||
existing.target_account_id = target_id
|
||
db.commit()
|
||
db.refresh(existing)
|
||
return _transfer_keyword_out(existing)
|
||
rule = CategorizationRule(
|
||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||
category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
|
||
target_account_id=target_id, kind="transfer", source=RuleSource.manual, priority=10,
|
||
)
|
||
db.add(rule)
|
||
db.commit()
|
||
db.refresh(rule)
|
||
return _transfer_keyword_out(rule)
|