feat: Regeln (Betrag/Transfer/Konto-Texte) + Münz-Icon + pastellgrünes Theme
Regeln: - Schlagwörter mit optionaler Betragsbedingung (z. B. „SV = 5.00 → Restaurant"); Betrags-Regeln gewinnen vor betragslosen - Transfer-Schlagwörter (Text → Transfer) auf der Konten-Seite - Konto-Identifikator-Art „Text": erkennt das Zielkonto eines Transfers aus dem Buchungstext (längster passender Text gewinnt) - Migration 0007, Endpunkte, Tests (Betrag/Transfer/Konflikt), gesamt 30 grün Optik: - App-Icon: Münzhaufen (aus Nutzer-SVG) weiß auf grünem Grund, klein gehalten - Farbschema auf pastelliges Grün (Akzent hell/dunkel + Favicon) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ from ..models import (
|
||||
CategorizationRule,
|
||||
Category,
|
||||
CategoryKind,
|
||||
IdentifierKind,
|
||||
ImportBatch,
|
||||
ImportStatus,
|
||||
ImportTemplate,
|
||||
@@ -146,9 +147,10 @@ def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee
|
||||
|
||||
|
||||
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."""
|
||||
"""Reihenfolge der Regelanwendung: Betrags-Regeln (spezifischer) zuerst, dann
|
||||
längstes Schlagwort, dann manuelle vor gelernten, dann Priorität/Treffer."""
|
||||
return (
|
||||
1 if rule.amount_cents is not None else 0,
|
||||
len(rule.pattern or ""),
|
||||
1 if rule.source == RuleSource.manual else 0,
|
||||
rule.priority,
|
||||
@@ -156,32 +158,55 @@ def _rule_sort_key(rule: CategorizationRule) -> tuple:
|
||||
)
|
||||
|
||||
|
||||
def _apply_rules(
|
||||
def _pattern_hit(rule: CategorizationRule, p: str, z: str) -> bool:
|
||||
if rule.match_field == MatchField.payee:
|
||||
return rule.pattern in p
|
||||
if rule.match_field == MatchField.purpose:
|
||||
return rule.pattern in z
|
||||
return rule.pattern in p or rule.pattern in z # any: Empfänger ODER Zweck
|
||||
|
||||
|
||||
def _match_rule(
|
||||
rules: list[CategorizationRule],
|
||||
payee: str,
|
||||
purpose: str,
|
||||
amount: int | None,
|
||||
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."""
|
||||
) -> CategorizationRule | None:
|
||||
"""Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
|
||||
greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
|
||||
Transfer-Regeln kennen keine Art."""
|
||||
p = normalize_text(payee)
|
||||
z = normalize_text(purpose)
|
||||
for rule in rules: # bereits sortiert (längstes/manuell zuerst)
|
||||
if not rule.pattern or not rule.category_id:
|
||||
absamt = abs(amount) if amount is not None else None
|
||||
for rule in rules: # bereits sortiert (Betrag/längstes/manuell zuerst)
|
||||
if not rule.pattern:
|
||||
continue
|
||||
if rule.amount_cents is not None and (absamt is None or absamt != rule.amount_cents):
|
||||
continue
|
||||
if rule.is_transfer:
|
||||
if _pattern_hit(rule, p, z):
|
||||
return rule
|
||||
continue
|
||||
if 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
|
||||
if _pattern_hit(rule, p, z):
|
||||
return rule
|
||||
return None
|
||||
|
||||
|
||||
def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None:
|
||||
"""Zielkonto eines Transfers aus dem Buchungstext raten: längster passender
|
||||
Erkennungs-Text je Konto gewinnt."""
|
||||
hay = f"{normalize_text(payee)} {normalize_text(purpose)}"
|
||||
best: tuple[int, int] | None = None # (Länge, Konto-ID)
|
||||
for val, acc_id in text_idents:
|
||||
if val and acc_id != source_id and val in hay and (best is None or len(val) > best[0]):
|
||||
best = (len(val), acc_id)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
|
||||
@@ -223,13 +248,17 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
||||
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
|
||||
|
||||
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik)."""
|
||||
idents: dict[str, int] = {}
|
||||
for value_norm, acc_id in (
|
||||
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id)
|
||||
idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN)
|
||||
text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto
|
||||
for value_norm, acc_id, kind in (
|
||||
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind)
|
||||
.join(Account, Account.id == AccountIdentifier.account_id)
|
||||
.filter(Account.owner_id == user.id)
|
||||
):
|
||||
idents[value_norm] = acc_id
|
||||
if kind == IdentifierKind.text:
|
||||
text_idents.append((value_norm, acc_id))
|
||||
else:
|
||||
idents[value_norm] = acc_id
|
||||
|
||||
rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
|
||||
rules.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst
|
||||
@@ -266,9 +295,14 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
|
||||
to_account_id = other
|
||||
else:
|
||||
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
|
||||
category_id, matched_rule_id = _apply_rules(
|
||||
rules, payee or "", purpose or "", kind, cat_kind
|
||||
)
|
||||
rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
|
||||
if rule is not None:
|
||||
matched_rule_id = rule.id
|
||||
if rule.is_transfer:
|
||||
ttype = "transfer"
|
||||
to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
|
||||
else:
|
||||
category_id = rule.category_id
|
||||
|
||||
h = import_hash(account.id, d, amount, payee)
|
||||
is_dup = h in existing or h in seen
|
||||
|
||||
Reference in New Issue
Block a user