- Transfer-Regel kann direkt ein Zielkonto tragen (target_account_id); ohne -> weiterhin Auto-Erkennung aus dem Buchungstext (Konto-Texte) - Migration 0010; Endpunkt aktualisiert bestehendes Wort statt Konflikt - Import bevorzugt das feste Zielkonto, sonst _guess_target_account - Konten-Seite: Transfer-Erkennung mit Zielkonto-Auswahl + Anzeige am Chip - Test: festes Zielkonto greift ohne Konto-Texte (75 grün) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
404 lines
15 KiB
Python
404 lines
15 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,
|
||
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():
|
||
if value is not None or key == "is_archived":
|
||
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, exclude_id: int | None = None):
|
||
"""Sucht ein manuelles Schlagwort gleicher Art (kind). Dasselbe Wort darf je
|
||
einmal für Ausgabe, Einnahme und Transfer existieren – Konflikt nur je Art."""
|
||
q = db.query(CategorizationRule).filter(
|
||
CategorizationRule.owner_id == user.id,
|
||
CategorizationRule.match_field == MatchField.any,
|
||
CategorizationRule.pattern == pattern,
|
||
CategorizationRule.kind == kind,
|
||
)
|
||
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")
|
||
if pattern != rule.pattern:
|
||
clash = _find_any_rule(db, user, pattern, rule.kind, exclude_id=rule.id)
|
||
if clash is not None:
|
||
raise _conflict(db, clash)
|
||
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
|
||
existing = _find_any_rule(db, user, pattern, kind)
|
||
if existing is not None:
|
||
if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents:
|
||
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.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")
|
||
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)
|