diff --git a/backend/app/models.py b/backend/app/models.py index dbed401..e6d4e1a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -94,6 +94,12 @@ class AmountMode(str, enum.Enum): class MatchField(str, enum.Enum): payee = "payee" purpose = "purpose" + any = "any" # trifft, wenn das Muster im Empfänger ODER im Zweck vorkommt + + +class RuleSource(str, enum.Enum): + learned = "learned" # automatisch aus bestätigten Zuordnungen gelernt + manual = "manual" # vom Nutzer als Schlagwort angelegt class ImportStatus(str, enum.Enum): @@ -466,7 +472,13 @@ class ImportBatch(Base): class CategorizationRule(Base): - """Lernende Regel: Empfänger/Zweck enthält Muster -> Kategorie (oder Transfer).""" + """Regel: Empfänger/Zweck enthält Muster -> Kategorie. + + Zwei Quellen (``source``): ``learned`` wird beim Import automatisch aus + bestätigten Zuordnungen gelernt; ``manual`` legt der Nutzer als Schlagwort je + Kategorie an. ``applied_count``/``overridden_count`` zählen, wie oft die Regel + zutraf und wie oft ihr Vorschlag wegkorrigiert wurde – daraus wird ein + manuelles Schlagwort ggf. als „stimmt vielleicht nicht" markiert.""" __tablename__ = "categorization_rules" @@ -479,8 +491,13 @@ class CategorizationRule(Base): category_id: Mapped[int | None] = mapped_column( ForeignKey("categories.id", ondelete="CASCADE"), nullable=True ) + source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned) priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0) hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + applied_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + overridden_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # „Behalten, nicht mehr fragen" – unterdrückt das Flaggen trotz Korrekturen. + confirmed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) __table_args__ = ( diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index c33c45d..b985224 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 6145db5..df93c13 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -345,3 +345,29 @@ class ImportCommitIn(BaseModel): template_id: int | None = None filename: str = "" rows: list[dict] = Field(default_factory=list) + + +# --- Schlagwörter (manuelle Kategorisierungs-Regeln) ----------------------- + + +class KeywordCreate(BaseModel): + word: str = Field(min_length=1, max_length=255) + + +class KeywordUpdate(BaseModel): + word: str = Field(min_length=1, max_length=255) + + +class KeywordOut(BaseModel): + id: int + category_id: int + word: str + + +class FlaggedKeywordOut(BaseModel): + id: int + category_id: int + category_name: str + word: str + applied_count: int + overridden_count: int diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 48fffb5..58ccfbc 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -18,10 +18,12 @@ from ..models import ( BudgetTreatment, CategorizationRule, Category, + CategoryKind, ImportBatch, ImportStatus, ImportTemplate, MatchField, + RuleSource, SAVINGS_ACCOUNT_TYPES, Transaction, TxnType, @@ -143,14 +145,43 @@ def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee # --- Vorschau -------------------------------------------------------------- -def _apply_rules(rules: list[CategorizationRule], payee: str, purpose: str) -> int | None: +def _rule_sort_key(rule: CategorizationRule) -> tuple: + """Reihenfolge der Regelanwendung: längstes Schlagwort zuerst, dann manuelle + vor gelernten, dann höhere Priorität/mehr Treffer.""" + return ( + len(rule.pattern or ""), + 1 if rule.source == RuleSource.manual else 0, + rule.priority, + rule.hits, + ) + + +def _apply_rules( + rules: list[CategorizationRule], + payee: str, + purpose: str, + allowed_kind: CategoryKind | None, + cat_kind: dict[int, CategoryKind], +) -> tuple[int | None, int | None]: + """Erste passende Regel -> (category_id, rule_id). Nur Kategorien, deren Art + (Einnahme/Ausgabe) zur Buchung passt, damit ein Ausgabe-Schlagwort keine + Einnahme falsch einordnet.""" p = normalize_text(payee) z = normalize_text(purpose) - for rule in rules: # bereits nach Priorität sortiert - hay = p if rule.match_field == MatchField.payee else z - if rule.pattern and rule.pattern in hay and rule.category_id: - return rule.category_id - return None + for rule in rules: # bereits sortiert (längstes/​manuell zuerst) + if not rule.pattern or not rule.category_id: + continue + if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind: + continue + if rule.match_field == MatchField.payee: + hit = rule.pattern in p + elif rule.match_field == MatchField.purpose: + hit = rule.pattern in z + else: # any: Empfänger ODER Zweck + hit = rule.pattern in p or rule.pattern in z + if hit: + return rule.category_id, rule.id + return None, None def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]: @@ -200,12 +231,12 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict ): idents[value_norm] = acc_id - rules = ( - db.query(CategorizationRule) - .filter(CategorizationRule.owner_id == user.id) - .order_by(CategorizationRule.priority.desc(), CategorizationRule.hits.desc()) - .all() - ) + rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all() + rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst + cat_kind: dict[int, CategoryKind] = { + cid: kind + for cid, kind in db.query(Category.id, Category.type).filter(Category.owner_id == user.id) + } existing = { h[0] for h in db.query(Transaction.import_hash).filter( @@ -226,6 +257,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict valid = d is not None and amount is not None and amount != 0 ttype = "expense" if (amount or 0) < 0 else "income" category_id: int | None = None + matched_rule_id: int | None = None to_account_id: int | None = None other = idents.get(iban) if iban else None @@ -233,7 +265,10 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict ttype = "transfer" to_account_id = other else: - category_id = _apply_rules(rules, payee or "", purpose or "") + kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income + category_id, matched_rule_id = _apply_rules( + rules, payee or "", purpose or "", kind, cat_kind + ) h = import_hash(account.id, d, amount, payee) is_dup = h in existing or h in seen @@ -249,6 +284,7 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict "valid": valid, "type": ttype, "category_id": category_id, + "matched_rule_id": matched_rule_id, "to_account_id": to_account_id, "import_hash": h, "is_duplicate": is_dup, @@ -347,13 +383,34 @@ def _learn_rule(db: Session, user: User, row: dict) -> None: if existing is None: db.add(CategorizationRule( owner_id=user.id, match_field=MatchField.payee, pattern=pattern, - category_id=cat, priority=1, hits=1, + category_id=cat, source=RuleSource.learned, priority=1, hits=1, )) + # Sichtbar machen (Session ist autoflush=False), damit dasselbe Muster ein + # zweites Mal im selben Import die vorhandene Regel findet statt zu doppeln. + db.flush() else: existing.category_id = cat existing.hits += 1 +def _track_and_learn(db: Session, user: User, row: dict) -> None: + """Zählt für die zutreffende Regel Anwendung/Korrektur (Basis fürs Flaggen von + Schlagwörtern) und lernt anschließend Empfänger -> Kategorie weiter.""" + rid = row.get("matched_rule_id") + final_cat = row.get("category_id") + matched_manual_kept = False + if rid: + rule = db.get(CategorizationRule, rid) + if rule is not None and rule.owner_id == user.id: + rule.applied_count = (rule.applied_count or 0) + 1 + if final_cat != rule.category_id: + rule.overridden_count = (rule.overridden_count or 0) + 1 + elif rule.source == RuleSource.manual: + matched_manual_kept = True # Schlagwort greift schon – nicht doppelt lernen + if not matched_manual_kept: + _learn_rule(db, user, row) + + def commit_import(db: Session, user: User, account: Account, template_id: int | None, filename: str, rows: list[dict]) -> ImportBatch: accounts = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)} batch = ImportBatch( @@ -372,7 +429,7 @@ def commit_import(db: Session, user: User, account: Account, template_id: int | if txn is None: continue db.add(txn) - _learn_rule(db, user, row) + _track_and_learn(db, user, row) imported += 1 batch.imported_count = imported diff --git a/backend/migrations/versions/0006_keyword_rules.py b/backend/migrations/versions/0006_keyword_rules.py new file mode 100644 index 0000000..536dd0b --- /dev/null +++ b/backend/migrations/versions/0006_keyword_rules.py @@ -0,0 +1,32 @@ +"""Schlagwörter/Regeln erweitern: Quelle + Korrektur-Zähler + „behalten". + +Manuelle Schlagwörter je Kategorie und die lernende Korrektur-Erkennung nutzen +dieselbe Tabelle ``categorization_rules`` wie die automatisch gelernten Regeln. + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-08-19 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("categorization_rules") as batch: + batch.add_column(sa.Column("source", sa.String(length=32), nullable=False, server_default="learned")) + batch.add_column(sa.Column("applied_count", sa.Integer(), nullable=False, server_default=sa.text("0"))) + batch.add_column(sa.Column("overridden_count", sa.Integer(), nullable=False, server_default=sa.text("0"))) + batch.add_column(sa.Column("confirmed", sa.Boolean(), nullable=False, server_default=sa.text("0"))) + + +def downgrade() -> None: + with op.batch_alter_table("categorization_rules") as batch: + batch.drop_column("confirmed") + batch.drop_column("overridden_count") + batch.drop_column("applied_count") + batch.drop_column("source") diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py new file mode 100644 index 0000000..e399df3 --- /dev/null +++ b/backend/tests/test_keywords.py @@ -0,0 +1,162 @@ +"""Schlagwörter je Kategorie: Anlegen/Konflikt, Treffer (Empfänger ODER Zweck), +längstes gewinnt, Kind-Filter, Korrektur-Erkennung/Flagging, keine Rückwirkung.""" + +import io +import json + + +def _account(admin, **kw): + body = {"name": "Giro", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0} + body.update(kw) + r = admin.post("/api/accounts", json=body) + assert r.status_code == 201, r.text + return r.json() + + +def _expense_cats(admin, n=2): + out = [] + for g in admin.get("/api/categories").json(): + if g["kind"] == "expense": + out += g["categories"] + assert len(out) >= n, "zu wenige Ausgabe-Kategorien im Seed" + return out[:n] + + +def _income_cat(admin): + for g in admin.get("/api/categories").json(): + if g["kind"] == "income" and g["categories"]: + return g["categories"][0] + raise AssertionError("keine Einnahme-Kategorie im Seed") + + +def _preview_csv(admin, account_id, csv, colmap): + r = admin.post( + "/api/imports/preview", + files={"file": ("k.csv", io.BytesIO(csv.encode("utf-8")), "text/csv")}, + data={ + "account_id": str(account_id), "delimiter": ";", "decimal_sep": ",", + "date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true", + "skip_rows": "0", "amount_mode": "signed", "column_map": colmap, + }, + ) + assert r.status_code == 200, r.text + return r.json()["rows"] + + +COLMAP = json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3}) + + +def test_keyword_crud_and_conflict(admin): + a, b = _expense_cats(admin, 2) + r = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": " REWE "}) + assert r.status_code == 201, r.text + assert r.json()["word"] == "rewe" # getrimmt + kleingeschrieben + kid = r.json()["id"] + + assert [k["word"] for k in admin.get(f"/api/categories/{a['id']}/keywords").json()] == ["rewe"] + + # Gleiches Wort, gleiche Kategorie -> idempotent, keine Dublette. + assert admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "rewe"}).status_code == 201 + assert len(admin.get(f"/api/categories/{a['id']}/keywords").json()) == 1 + + # Gleiches Wort, andere Kategorie -> Konflikt. + assert admin.post(f"/api/categories/{b['id']}/keywords", json={"word": "rewe"}).status_code == 409 + + assert admin.delete(f"/api/categories/keywords/{kid}").status_code == 204 + assert admin.get(f"/api/categories/{a['id']}/keywords").json() == [] + + +def test_matching_payee_purpose_and_kind_filter(admin): + giro = _account(admin, name="Giro KW") + a, _ = _expense_cats(admin, 2) + admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "netflix"}) + + csv = ( + "Datum;Betrag;Empfaenger;Zweck\n" + "01.03.2026;-9,99;NETFLIX;Abo\n" # Empfänger-Treffer + "02.03.2026;-9,99;Kartenzahlung;NETFLIX Monatsabo\n" # Zweck-Treffer (any) + "03.03.2026;9,99;NETFLIX;Erstattung\n" # Einnahme -> darf NICHT greifen + ) + rows = _preview_csv(admin, giro["id"], csv, COLMAP) + assert rows[0]["category_id"] == a["id"] + assert rows[0]["matched_rule_id"] is not None + assert rows[1]["category_id"] == a["id"] # über den Zweck getroffen + assert rows[2]["category_id"] is None # Ausgabe-Schlagwort, Einnahme -> gefiltert + + +def test_longest_keyword_wins(admin): + giro = _account(admin, name="Giro LW") + a, b = _expense_cats(admin, 2) + admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "rewe"}) + admin.post(f"/api/categories/{b['id']}/keywords", json={"word": "rewe getraenke"}) + + csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-20,00;REWE Getraenke Markt;Wasser\n" + rows = _preview_csv(admin, giro["id"], csv, COLMAP) + assert rows[0]["category_id"] == b["id"] # spezifischeres (längeres) Schlagwort gewinnt + + +def test_correction_flagging_confirm(admin): + giro = _account(admin, name="Giro Flag") + a, b = _expense_cats(admin, 2) + kw = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "spotify"}).json() + + lines = "".join(f"0{i}.03.2026;-9,99;SPOTIFY;Abo\n" for i in range(1, 7)) # 6 Zeilen + rows = _preview_csv(admin, giro["id"], "Datum;Betrag;Empfaenger;Zweck\n" + lines, COLMAP) + assert len(rows) == 6 and all(r["category_id"] == a["id"] for r in rows) + + for r in rows[:2]: # zwei wegkorrigieren + r["category_id"] = b["id"] + res = admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "s.csv", "rows": rows}) + assert res.status_code == 200, res.text + + flagged = admin.get("/api/categories/keywords/flagged").json() + hit = next((f for f in flagged if f["id"] == kw["id"]), None) + assert hit is not None, flagged + assert hit["applied_count"] == 6 and hit["overridden_count"] == 2 + + # „Behalten" unterdrückt die Meldung künftig. + assert admin.post(f"/api/categories/keywords/{kw['id']}/confirm").status_code == 204 + assert kw["id"] not in [f["id"] for f in admin.get("/api/categories/keywords/flagged").json()] + + +def test_keyword_update_and_conflict(admin): + a, b = _expense_cats(admin, 2) + kw = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "lidil"}).json() + + r = admin.patch(f"/api/categories/keywords/{kw['id']}", json={"word": " LIDL "}) + assert r.status_code == 200, r.text + assert r.json()["word"] == "lidl" # getrimmt + kleingeschrieben + words = admin.get(f"/api/categories/{a['id']}/keywords").json() + assert next(k for k in words if k["id"] == kw["id"])["word"] == "lidl" + assert "lidil" not in [k["word"] for k in words] # altes Wort ist weg + + # Umbenennen auf ein Wort, das schon einer anderen Kategorie gehört -> Konflikt. + admin.post(f"/api/categories/{b['id']}/keywords", json={"word": "coop"}) + assert admin.patch(f"/api/categories/keywords/{kw['id']}", json={"word": "coop"}).status_code == 409 + + +def test_below_threshold_not_flagged(admin): + giro = _account(admin, name="Giro NoFlag") + a, b = _expense_cats(admin, 2) + kw = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "denner"}).json() + + lines = "".join(f"0{i}.03.2026;-4,00;DENNER;Einkauf\n" for i in range(1, 7)) # 6 Zeilen + rows = _preview_csv(admin, giro["id"], "Datum;Betrag;Empfaenger;Zweck\n" + lines, COLMAP) + rows[0]["category_id"] = b["id"] # nur 1 von 6 korrigiert (< 1/3) + admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "d.csv", "rows": rows}) + + assert kw["id"] not in [f["id"] for f in admin.get("/api/categories/keywords/flagged").json()] + + +def test_no_retroactive_apply(admin): + giro = _account(admin, name="Giro Retro") + a, _ = _expense_cats(admin, 2) + rows = _preview_csv(admin, giro["id"], "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-5,00;ALDI;Einkauf\n", COLMAP) + assert rows[0]["category_id"] is None + admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "aldi.csv", "rows": rows}) + + admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "aldi"}) # erst danach angelegt + + txns = admin.get("/api/transactions").json() + aldi = [t for t in txns if (t.get("payee") or "").upper() == "ALDI"] + assert aldi and aldi[0]["category_id"] is None # bestehende Buchung bleibt unkategorisiert diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2f45111..9bfcaee 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -35,3 +35,11 @@ Zeilen-Parser (Datum + Betrag), speist dieselbe Prüfansicht. Best-Effort. ## Querschnitt – Backup/Export ✅ DB-Backup-Download (Admin) + Export je Entität (CSV/JSON); dokumentierte LXC-Sicherung (docs/BACKUP.md). + +## Querschnitt – Schlagwörter & Kategorie-Auswahl ✅ +Manuelle Schlagwörter je Kategorie (Popup, Doppelklick zum Bearbeiten); Treffer +über Empfänger ODER Verwendungszweck, längstes Schlagwort gewinnt, nur bei +künftigen Importen. Korrektur-Erkennung: ab 5 Anwendungen und ≥ 1/3 Korrekturen +Prüf-Meldung in den Einstellungen (Behalten/Entfernen). Auf-/zuklappbares +Kategorie-Dropdown mit Suche (ersetzt das native optgroup-Select) in Buchungen +und Import-Prüfansicht. diff --git a/web/src/api.js b/web/src/api.js index 0c2de4a..e8f37a9 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -141,6 +141,14 @@ export const api = { updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), + // Schlagwörter je Kategorie (manuelle Auto-Zuordnung) + Prüfmeldungen + listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`), + addKeyword: (categoryId, word) => request(`/categories/${categoryId}/keywords`, { method: "POST", body: { word } }), + updateKeyword: (ruleId, word) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body: { word } }), + deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }), + flaggedKeywords: () => request("/categories/keywords/flagged"), + confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }), + // Transaktionen listTransactions: (params = {}) => { const qs = new URLSearchParams(); diff --git a/web/src/components/CategorySelect.jsx b/web/src/components/CategorySelect.jsx new file mode 100644 index 0000000..470f59b --- /dev/null +++ b/web/src/components/CategorySelect.jsx @@ -0,0 +1,198 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import Icon from "./Icon"; +import IconChip from "./IconChip"; + +// Auf-/zuklappbares Kategorie-Dropdown (Gruppe -> Kategorien als Baum) mit +// Suchfeld. Ersetzt das native setQ(e.target.value)} + placeholder={t("categories.searchPlaceholder")} + /> + +
+ {allowNone && ( + + )} + {groups.map((g) => { + const cats = ql + ? g.categories.filter((c) => c.name.toLowerCase().includes(ql)) + : g.categories; + if (ql && cats.length === 0) return null; + const isCollapsed = !ql && collapsed.has(g.id); + return ( +
+ + {!isCollapsed && + cats.map((c) => ( + + ))} +
+ ); + })} + {groups.length === 0 &&
{t("categories.emptyGroup")}
} +
+ , + document.body + )} + + ); +} diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index 9663bf0..790fc49 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -71,6 +71,14 @@ const PATHS = { ), "chevron-right": , + "chevron-down": , + tags: ( + <> + + + + + ), "refresh-ccw": ( <> diff --git a/web/src/finance.css b/web/src/finance.css index ff74fcd..0287584 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -571,3 +571,219 @@ tr.row-dup { border-radius: var(--radius-sm); margin-top: var(--sp-2); } + +/* ---------- Kategorie-Auswahl (auf-/zuklappbarer Baum, Ersatz für setInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} + /> + + + {words.length === 0 ? ( +
{t("categories.keywordEmpty")}
+ ) : ( + <> +
+ {words.map((w) => + editId === w.id ? ( + + {/* eslint-disable-next-line jsx-a11y/no-autofocus */} + e.target.select()} + onChange={(e) => setEditVal(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); speichernBearbeiten(w.id); } + else if (e.key === "Escape") { e.preventDefault(); abbrechenBearbeiten(); } + }} + onBlur={() => speichernBearbeiten(w.id)} + /> + + ) : ( + starteBearbeiten(w)} + > + {w.word} + + + ) + )} +
+

+ {t("categories.keywordEditHint")} +

+ + )} + + ); +} + export default function Categories() { const { t } = useTranslation(); const confirm = useConfirm(); const toast = useToast(); const [groups, setGroups] = useState([]); const [modal, setModal] = useState(null); // {type:'group'|'category', editing, groupId, form} + const [keywordCat, setKeywordCat] = useState(null); // Kategorie fürs Schlagwort-Popup async function laden() { try { @@ -103,6 +232,9 @@ export default function Categories() {
{c.name} + @@ -164,6 +296,8 @@ export default function Categories() { )} + + {keywordCat && setKeywordCat(null)} />}
); } diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index a991633..fad296f 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -5,6 +5,7 @@ import { useSettings } from "../settings"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "../components/Icon"; +import CategorySelect from "../components/CategorySelect"; import { formatMoney } from "../format"; const DATE_FORMATS = [ @@ -421,14 +422,15 @@ export default function Import() { {accounts.filter((a) => a.id !== Number(accountId)).map((a) => )} ) : ( - + updateRow(idx, { category_id: id != null ? Number(id) : null })} + allowNone + noneLabel={t("transactions.uncategorized")} + placeholder={t("transactions.uncategorized")} + minWidth={180} + /> )} diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index d99555b..77e2f4e 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSettings } from "../settings"; import { useAuth } from "../auth"; @@ -10,6 +11,22 @@ export default function Settings() { const s = useSettings(); const { isAdmin } = useAuth(); const toast = useToast(); + const [flagged, setFlagged] = useState([]); + + async function ladenFlagged() { + try { setFlagged(await api.flaggedKeywords()); } + catch { /* still ignorieren – nicht kritisch für die Einstellungen */ } + } + useEffect(() => { ladenFlagged(); }, []); + + async function keywordBehalten(id) { + try { await api.confirmKeyword(id); setFlagged((f) => f.filter((x) => x.id !== id)); } + catch (err) { toast(err.message, "error"); } + } + async function keywordEntfernen(id) { + try { await api.deleteKeyword(id); setFlagged((f) => f.filter((x) => x.id !== id)); } + catch (err) { toast(err.message, "error"); } + } async function set(key, value) { try { @@ -32,6 +49,30 @@ export default function Settings() {

{t("settings.title")}

+ {flagged.length > 0 && ( +
+

+ {t("settings.keywordReview")} +

+

{t("settings.keywordReviewHint")}

+ {flagged.map((f) => ( +
+
+
+ {f.word} + {f.category_name} +
+
+ {t("settings.keywordCorrected", { overridden: f.overridden_count, applied: f.applied_count })} +
+
+ + +
+ ))} +
+ )} +