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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user