feat: Schlagwörter je Kategorie + auf-/zuklappbares Kategorie-Dropdown

- Manuelle Schlagwörter je Kategorie (Popup mit Chips, Doppelklick zum Bearbeiten)
- Treffer über Empfänger ODER Verwendungszweck; längstes Schlagwort gewinnt;
  Kind-Filter (Ausgabe/Einnahme); wirken nur bei künftigen Importen
- Lernende Korrektur-Erkennung: ab 5 Anwendungen und >= 1/3 Korrekturen
  erscheint eine Prüf-Meldung in den Einstellungen (Behalten/Entfernen)
- Neue CategorySelect-Komponente (Baum + Suche, per Portal) ersetzt die
  nativen optgroup-Selects in Buchungen und Import-Prüfansicht
- Migration 0006 (source/applied_count/overridden_count/confirmed, match_field any)
- Endpunkte, i18n DE/EN, Tests (test_keywords: 7 neu, gesamt 27 grün)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 21:31:29 +02:00
co-authored by Claude Opus 4.8
parent f375fc5eaa
commit 37f64999cd
17 changed files with 1137 additions and 45 deletions
+161 -2
View File
@@ -3,12 +3,12 @@
Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe.
"""
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, get_owned
from ..models import Category, CategoryGroup, User
from ..models import CategorizationRule, Category, CategoryGroup, MatchField, RuleSource, User
from ..schemas import (
CategoryCreate,
CategoryGroupCreate,
@@ -16,10 +16,20 @@ from ..schemas import (
CategoryGroupUpdate,
CategoryOut,
CategoryUpdate,
FlaggedKeywordOut,
KeywordCreate,
KeywordOut,
KeywordUpdate,
)
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) -> CategoryGroupOut:
cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower()))
@@ -152,3 +162,152 @@ def delete_category(
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)
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
@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=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),
):
"""Wort eines Schlagworts ändern (Inline-Bearbeitung im Popup). Da sich das
Muster ändert, 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 = (
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
CategorizationRule.id != rule.id,
)
.first()
)
if clash is not None:
other = db.get(Category, clash.category_id)
other_name = other.name if other else "?"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schlagwort ist bereits der Kategorie „{other_name}“ zugeordnet.",
)
rule.pattern = pattern
rule.applied_count = 0
rule.overridden_count = 0
rule.confirmed = False
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")
existing = (
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
)
.first()
)
if existing is not None:
if existing.category_id == cat.id:
return _keyword_out(existing) # schon vorhanden – unverändert zurück
other = db.get(Category, existing.category_id)
other_name = other.name if other else "?"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schlagwort ist bereits der Kategorie „{other_name}“ zugeordnet.",
)
rule = CategorizationRule(
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
category_id=cat.id, source=RuleSource.manual, priority=10,
)
db.add(rule)
db.commit()
db.refresh(rule)
return _keyword_out(rule)