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:
@@ -7,16 +7,21 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, get_owned
|
||||
from ..models import Account, AccountIdentifier, User
|
||||
from ..models import Account, AccountIdentifier, IdentifierKind, User
|
||||
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate
|
||||
from ..services import balances
|
||||
from ..services.csv_import import normalize_text
|
||||
from .settings import BASE_CURRENCY_KEY, get_user_setting
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
def normalize_identifier(value: str) -> str:
|
||||
"""Vereinheitlicht IBAN/Nummer für den Abgleich: Großbuchstaben, nur alphanumerisch."""
|
||||
def normalize_identifier(value: str, kind: IdentifierKind) -> str:
|
||||
"""Vereinheitlicht einen Identifikator für den Abgleich. IBAN/Nummer:
|
||||
Großbuchstaben, nur alphanumerisch. Freier Text: klein, getrimmt (für die
|
||||
Zielkonto-Erkennung im Buchungstext)."""
|
||||
if kind == IdentifierKind.text:
|
||||
return normalize_text(value)
|
||||
return "".join(ch for ch in value.upper() if ch.isalnum())
|
||||
|
||||
|
||||
@@ -49,7 +54,7 @@ def _set_identifiers(acc: Account, items) -> None:
|
||||
acc.identifiers.clear()
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for it in items:
|
||||
norm = normalize_identifier(it.value)
|
||||
norm = normalize_identifier(it.value, it.kind)
|
||||
key = (it.kind.value, norm)
|
||||
if not norm or key in seen:
|
||||
continue
|
||||
|
||||
@@ -20,6 +20,8 @@ from ..schemas import (
|
||||
KeywordCreate,
|
||||
KeywordOut,
|
||||
KeywordUpdate,
|
||||
TransferKeywordCreate,
|
||||
TransferKeywordOut,
|
||||
)
|
||||
from ..services.csv_import import normalize_text
|
||||
|
||||
@@ -168,7 +170,7 @@ def delete_category(
|
||||
|
||||
|
||||
def _keyword_out(rule: CategorizationRule) -> KeywordOut:
|
||||
return KeywordOut(id=rule.id, category_id=rule.category_id, word=rule.pattern)
|
||||
return KeywordOut(id=rule.id, category_id=rule.category_id, word=rule.pattern, amount_cents=rule.amount_cents)
|
||||
|
||||
|
||||
def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationRule:
|
||||
@@ -178,6 +180,30 @@ def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationR
|
||||
return rule
|
||||
|
||||
|
||||
def _conflict(db: Session, clash: CategorizationRule) -> HTTPException:
|
||||
"""Klare Meldung, wenn ein Schlagwort schon woanders (Kategorie/Transfer) hängt."""
|
||||
if clash.is_transfer:
|
||||
wo = "als Transfer-Regel"
|
||||
else:
|
||||
cat = db.get(Category, clash.category_id)
|
||||
wo = f"der Kategorie „{cat.name if cat else '?'}“"
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Schlagwort ist bereits {wo} zugeordnet.",
|
||||
)
|
||||
|
||||
|
||||
def _find_any_rule(db: Session, user: User, pattern: str, exclude_id: int | None = None):
|
||||
q = db.query(CategorizationRule).filter(
|
||||
CategorizationRule.owner_id == user.id,
|
||||
CategorizationRule.match_field == MatchField.any,
|
||||
CategorizationRule.pattern == pattern,
|
||||
)
|
||||
if exclude_id is not None:
|
||||
q = q.filter(CategorizationRule.id != exclude_id)
|
||||
return q.first()
|
||||
|
||||
|
||||
@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."""
|
||||
@@ -196,7 +222,8 @@ def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(ge
|
||||
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, "?"),
|
||||
id=r.id, category_id=r.category_id,
|
||||
category_name="→ Transfer" if r.is_transfer else names.get(r.category_id, "?"),
|
||||
word=r.pattern, applied_count=r.applied_count, overridden_count=r.overridden_count,
|
||||
)
|
||||
for r in rules
|
||||
@@ -225,27 +252,16 @@ def update_keyword(
|
||||
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()
|
||||
)
|
||||
clash = _find_any_rule(db, user, pattern, exclude_id=rule.id)
|
||||
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.",
|
||||
)
|
||||
raise _conflict(db, clash)
|
||||
rule.pattern = pattern
|
||||
rule.applied_count = 0
|
||||
rule.overridden_count = 0
|
||||
rule.confirmed = False
|
||||
# Betrag nur ändern, wenn mitgeschickt (leere Inline-Wortänderung darf ihn nicht löschen).
|
||||
if "amount_cents" in payload.model_fields_set:
|
||||
rule.amount_cents = payload.amount_cents
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return _keyword_out(rule)
|
||||
@@ -285,29 +301,62 @@ def add_keyword(
|
||||
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()
|
||||
)
|
||||
existing = _find_any_rule(db, user, pattern)
|
||||
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.",
|
||||
)
|
||||
if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents:
|
||||
return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
|
||||
raise _conflict(db, existing)
|
||||
rule = CategorizationRule(
|
||||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||||
category_id=cat.id, source=RuleSource.manual, priority=10,
|
||||
category_id=cat.id, amount_cents=payload.amount_cents,
|
||||
source=RuleSource.manual, priority=10,
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return _keyword_out(rule)
|
||||
|
||||
|
||||
# --- Transfer-Schlagwörter (Text -> Transfer, Zielkonto aus Konto-Texten) ------
|
||||
|
||||
|
||||
def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut:
|
||||
return TransferKeywordOut(id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents)
|
||||
|
||||
|
||||
@router.get("/transfer-keywords", response_model=list[TransferKeywordOut])
|
||||
def list_transfer_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||
rules = (
|
||||
db.query(CategorizationRule)
|
||||
.filter(
|
||||
CategorizationRule.owner_id == user.id,
|
||||
CategorizationRule.source == RuleSource.manual,
|
||||
CategorizationRule.is_transfer.is_(True),
|
||||
)
|
||||
.order_by(CategorizationRule.pattern)
|
||||
.all()
|
||||
)
|
||||
return [_transfer_keyword_out(r) for r in rules]
|
||||
|
||||
|
||||
@router.post("/transfer-keywords", response_model=TransferKeywordOut, status_code=status.HTTP_201_CREATED)
|
||||
def add_transfer_keyword(
|
||||
payload: TransferKeywordCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
):
|
||||
pattern = normalize_text(payload.word)[:255]
|
||||
if not pattern:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||||
existing = _find_any_rule(db, user, pattern)
|
||||
if existing is not None:
|
||||
if existing.is_transfer and existing.amount_cents == payload.amount_cents:
|
||||
return _transfer_keyword_out(existing)
|
||||
raise _conflict(db, existing)
|
||||
rule = CategorizationRule(
|
||||
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
|
||||
category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
|
||||
source=RuleSource.manual, priority=10,
|
||||
)
|
||||
db.add(rule)
|
||||
db.commit()
|
||||
db.refresh(rule)
|
||||
return _transfer_keyword_out(rule)
|
||||
|
||||
Reference in New Issue
Block a user