feat: ein Schlagwort darf mehrere Beträge haben
Ein Wort war je Art nur einmal erlaubt, also ließ sich genau ein Preis hinterlegen. Im Personalrestaurant zahlt man aber mal 5.00 und mal 7.50 – der zweite Preis ließ sich nicht erfassen. Der Betrag gehört jetzt in den Unique-Schlüssel: „sv gruppe = 5.00" und „sv gruppe = 7.50" sind zwei Regeln derselben Kategorie, dasselbe Wort OHNE Betrag bleibt der Auffang für alle übrigen Beträge (und darf in einer anderen Kategorie liegen). Betrags-Regeln greifen vor dem Auffang. - Migration 0011 (Unique-Schlüssel um amount_cents erweitert) - Konflikt nur noch bei gleichem Wort UND gleichem Betrag; gilt auch beim Umbenennen und für Transfer-Schlagwörter - Test: zwei Preise treffen die Kategorie, ein dritter Betrag den Auffang; 88 grün Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -570,5 +570,8 @@ class CategorizationRule(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "match_field", "pattern", "kind", name="uq_rule_owner_field_pattern_kind"),
|
||||
# Der Betrag gehört dazu: dasselbe Wort darf mehrere Preise haben
|
||||
# („sv = 5.00" und „sv = 7.50"), ohne Betrag ist es der Auffang.
|
||||
UniqueConstraint("owner_id", "match_field", "pattern", "kind", "amount_cents",
|
||||
name="uq_rule_owner_field_pattern_kind_amount"),
|
||||
)
|
||||
|
||||
@@ -220,15 +220,26 @@ def _conflict(db: Session, clash: CategorizationRule) -> HTTPException:
|
||||
)
|
||||
|
||||
|
||||
def _find_any_rule(db: Session, user: User, pattern: str, kind: str, exclude_id: int | None = None):
|
||||
"""Sucht ein manuelles Schlagwort gleicher Art (kind). Dasselbe Wort darf je
|
||||
einmal für Ausgabe, Einnahme und Transfer existieren – Konflikt nur je Art."""
|
||||
def _find_any_rule(
|
||||
db: Session, user: User, pattern: str, kind: str,
|
||||
amount_cents: int | None = None, exclude_id: int | None = None,
|
||||
):
|
||||
"""Sucht ein manuelles Schlagwort gleicher Art (kind) UND gleichen Betrags.
|
||||
|
||||
Dasselbe Wort darf je einmal für Ausgabe, Einnahme und Transfer existieren –
|
||||
und innerhalb einer Art mehrfach mit verschiedenen Beträgen: Im Personal-
|
||||
restaurant zahlt man mal 5.00, mal 7.50. Ohne Betrag ist es der Auffang für
|
||||
alle übrigen Beträge."""
|
||||
q = db.query(CategorizationRule).filter(
|
||||
CategorizationRule.owner_id == user.id,
|
||||
CategorizationRule.match_field == MatchField.any,
|
||||
CategorizationRule.pattern == pattern,
|
||||
CategorizationRule.kind == kind,
|
||||
)
|
||||
q = q.filter(
|
||||
CategorizationRule.amount_cents.is_(None) if amount_cents is None
|
||||
else CategorizationRule.amount_cents == amount_cents
|
||||
)
|
||||
if exclude_id is not None:
|
||||
q = q.filter(CategorizationRule.id != exclude_id)
|
||||
return q.first()
|
||||
@@ -281,10 +292,14 @@ def update_keyword(
|
||||
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 = _find_any_rule(db, user, pattern, rule.kind, exclude_id=rule.id)
|
||||
neuer_betrag = (
|
||||
payload.amount_cents if "amount_cents" in payload.model_fields_set else rule.amount_cents
|
||||
)
|
||||
if pattern != rule.pattern or neuer_betrag != rule.amount_cents:
|
||||
clash = _find_any_rule(db, user, pattern, rule.kind, neuer_betrag, exclude_id=rule.id)
|
||||
if clash is not None:
|
||||
raise _conflict(db, clash)
|
||||
if pattern != rule.pattern:
|
||||
rule.pattern = pattern
|
||||
rule.applied_count = 0
|
||||
rule.overridden_count = 0
|
||||
@@ -333,9 +348,11 @@ def add_keyword(
|
||||
if not pattern:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
|
||||
kind = cat.type.value # "expense" | "income" – Konflikt nur innerhalb derselben Art
|
||||
existing = _find_any_rule(db, user, pattern, kind)
|
||||
# Konflikt nur bei gleichem Wort UND gleichem Betrag: „sv = 5.00" und
|
||||
# „sv = 7.50" sind zwei gültige Regeln derselben Kategorie.
|
||||
existing = _find_any_rule(db, user, pattern, kind, payload.amount_cents)
|
||||
if existing is not None:
|
||||
if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents:
|
||||
if not existing.is_transfer and existing.category_id == cat.id:
|
||||
return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
|
||||
raise _conflict(db, existing)
|
||||
rule = CategorizationRule(
|
||||
@@ -385,7 +402,7 @@ def add_transfer_keyword(
|
||||
if payload.target_account_id is not None:
|
||||
target_id = get_owned(db, Account, payload.target_account_id, user).id
|
||||
# Gleiches Wort existiert schon als Transfer-Regel -> aktualisieren (Betrag/Zielkonto).
|
||||
existing = _find_any_rule(db, user, pattern, "transfer")
|
||||
existing = _find_any_rule(db, user, pattern, "transfer", payload.amount_cents)
|
||||
if existing is not None:
|
||||
existing.amount_cents = payload.amount_cents
|
||||
existing.target_account_id = target_id
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Schlagwörter: mehrere Beträge je Wort erlauben.
|
||||
|
||||
Bisher durfte ein Wort je Art nur einmal existieren – damit ließ sich genau ein
|
||||
Betrag hinterlegen. Wer im Personalrestaurant mal 5.00 und mal 7.50 zahlt, konnte
|
||||
den zweiten Preis nicht erfassen. Der Betrag gehört deshalb in den Unique-Schlüssel:
|
||||
„sv = 5.00" und „sv = 7.50" sind zwei Regeln, „sv" ohne Betrag bleibt der Auffang.
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("categorization_rules") as batch:
|
||||
batch.drop_constraint("uq_rule_owner_field_pattern_kind", type_="unique")
|
||||
batch.create_unique_constraint(
|
||||
"uq_rule_owner_field_pattern_kind_amount",
|
||||
["owner_id", "match_field", "pattern", "kind", "amount_cents"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Mehrfach-Beträge je Wort auf einen zusammenführen, sonst greift der alte
|
||||
# Schlüssel nicht mehr.
|
||||
op.execute(
|
||||
"DELETE FROM categorization_rules WHERE id NOT IN ("
|
||||
" SELECT MIN(id) FROM categorization_rules"
|
||||
" GROUP BY owner_id, match_field, pattern, kind)"
|
||||
)
|
||||
with op.batch_alter_table("categorization_rules") as batch:
|
||||
batch.drop_constraint("uq_rule_owner_field_pattern_kind_amount", type_="unique")
|
||||
batch.create_unique_constraint(
|
||||
"uq_rule_owner_field_pattern_kind",
|
||||
["owner_id", "match_field", "pattern", "kind"],
|
||||
)
|
||||
@@ -326,3 +326,40 @@ def test_no_retroactive_apply(admin):
|
||||
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
|
||||
|
||||
|
||||
def test_same_word_multiple_amounts(admin):
|
||||
"""Ein Wort darf mehrere Preise haben – im Personalrestaurant zahlt man mal
|
||||
5.00, mal 7.50. Ohne Betrag bleibt es der Auffang für alle übrigen."""
|
||||
giro = _account(admin, name="Giro Preise")
|
||||
mensa, buero = _expense_cats(admin, 2)
|
||||
|
||||
# Zwei Preise fürs Restaurant …
|
||||
for cents in (500, 750):
|
||||
r = admin.post(f"/api/categories/{mensa['id']}/keywords",
|
||||
json={"word": "sv gruppe", "amount_cents": cents})
|
||||
assert r.status_code == 201, r.text
|
||||
# … und derselbe Text ohne Betrag als Auffang in einer anderen Kategorie.
|
||||
r = admin.post(f"/api/categories/{buero['id']}/keywords", json={"word": "sv gruppe"})
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
worte = admin.get(f"/api/categories/{mensa['id']}/keywords").json()
|
||||
betraege = sorted(w["amount_cents"] for w in worte if w["word"] == "sv gruppe")
|
||||
assert betraege == [500, 750]
|
||||
|
||||
csv = (
|
||||
"Datum;Betrag;Empfaenger;Zweck\n"
|
||||
"01.03.2026;-5,00;SV GRUPPE;Mittag\n"
|
||||
"02.03.2026;-7,50;SV GRUPPE;Mittag gross\n"
|
||||
"03.03.2026;-24,00;SV GRUPPE;Material\n"
|
||||
)
|
||||
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
||||
assert rows[0]["category_id"] == mensa["id"], "5.00 -> Restaurant"
|
||||
assert rows[1]["category_id"] == mensa["id"], "7.50 -> Restaurant"
|
||||
assert rows[2]["category_id"] == buero["id"], "anderer Betrag -> Auffang"
|
||||
|
||||
# Derselbe Preis zweimal bleibt ein Konflikt bzw. liefert dieselbe Regel.
|
||||
doppelt = admin.post(f"/api/categories/{mensa['id']}/keywords",
|
||||
json={"word": "sv gruppe", "amount_cents": 500})
|
||||
assert doppelt.status_code == 201
|
||||
assert len(admin.get(f"/api/categories/{mensa['id']}/keywords").json()) == len(worte)
|
||||
|
||||
Reference in New Issue
Block a user