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
+18 -1
View File
@@ -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__ = (