diff --git a/backend/app/models.py b/backend/app/models.py index fc7b6cd..d07b85e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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"), ) diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index ab7f67f..a47a0d5 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -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 diff --git a/backend/migrations/versions/0011_rule_amount_in_unique.py b/backend/migrations/versions/0011_rule_amount_in_unique.py new file mode 100644 index 0000000..47cbcfe --- /dev/null +++ b/backend/migrations/versions/0011_rule_amount_in_unique.py @@ -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"], + ) diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index a2ac6ce..d53e73f 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -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)