From af865a5c7bbe9eecbbd472c5600d48037b6d5a8d Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 14:09:54 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20neutrale=20Kategorien=20gelten=20f=C3=BC?= =?UTF-8?q?r=20Ausgabe=20UND=20Einnahme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kategorien erben ihre Art von der Gruppe, und die ist entweder Ausgabe oder Einnahme. Für einen Kontoübertrag passt beides nicht: Auf dem einen Konto geht Geld raus, auf dem anderen kommt es rein – man musste also zwei Kategorien in zwei Gruppen anlegen. Als „nicht in Auswertungen einrechnen" markierte Kategorien werden jetzt in beiden Richtungen angeboten (Buchungsformular und Import-Prüfansicht), und ihre Schlagwörter greifen unabhängig vom Vorzeichen. Eine Kategorie „Kontoübertrag" in einer Ausgabe-Gruppe genügt damit für beide Seiten. Test für beide Richtungen; 94 grün. Co-Authored-By: Claude Opus 4.8 --- backend/app/services/csv_import.py | 16 +++++++++---- backend/tests/test_neutral_category.py | 32 ++++++++++++++++++++++++++ web/src/kategorien.js | 20 ++++++++++++++++ web/src/pages/Import.jsx | 5 ++-- web/src/pages/Transactions.jsx | 5 ++-- 5 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 web/src/kategorien.js diff --git a/backend/app/services/csv_import.py b/backend/app/services/csv_import.py index 9dd51c0..3150597 100644 --- a/backend/app/services/csv_import.py +++ b/backend/app/services/csv_import.py @@ -222,7 +222,7 @@ def _match_rule( purpose: str, amount: int | None, allowed_kind: CategoryKind | None, - cat_kind: dict[int, CategoryKind], + cat_kind: dict[int, CategoryKind | None], ) -> CategorizationRule | None: """Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt; @@ -241,7 +241,10 @@ def _match_rule( continue if not rule.category_id: continue - if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind: + # Neutrale Kategorien („Kontoübertrag") passen in beide Richtungen: Geld + # geht auf dem einen Konto raus und kommt auf dem anderen rein. + art = cat_kind.get(rule.category_id) + if allowed_kind is not None and art is not None and art != allowed_kind: continue if _pattern_hit(rule, p, z): return rule @@ -410,9 +413,12 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict 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) + # None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule). + cat_kind: dict[int, CategoryKind | None] = { + cid: (None if neutral else kind) + for cid, kind, neutral in db.query( + Category.id, Category.type, Category.excluded_from_stats + ).filter(Category.owner_id == user.id) } # Duplikate als Multiset zählen: eine Zeile gilt nur dann als Duplikat, wenn # bereits GENAU SO VIELE identische Buchungen in der DB stehen. So werden echte, diff --git a/backend/tests/test_neutral_category.py b/backend/tests/test_neutral_category.py index 42334ba..e3aec8b 100644 --- a/backend/tests/test_neutral_category.py +++ b/backend/tests/test_neutral_category.py @@ -67,3 +67,35 @@ def test_neutral_category_out_of_stats_but_in_balance(admin): # Häkchen entfernen -> zählt sofort wieder mit. admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": False}) assert _ausgaben(admin) == vorher_ausgaben + 35000 + + +def test_neutral_keyword_matches_both_directions(admin): + """Ein Kontoübertrag geht auf dem einen Konto raus und kommt auf dem anderen + rein. Die neutrale Kategorie muss deshalb für BEIDE Richtungen greifen – + obwohl sie in einer Ausgabe-Gruppe liegt.""" + import io, json + acc = admin.post("/api/accounts", json={ + "name": "Giro Beide", "type": "girokonto", "currency": "EUR", + }).json() + uebertrag = _expense_cats(admin, 2)[1] + admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": True}) + admin.post(f"/api/categories/{uebertrag['id']}/keywords", json={"word": "kontouebertrag"}) + + csv_text = ( + "Datum;Betrag;Empfaenger;Zweck\n" + "05.03.2026;-250,00;Kontouebertrag raus;;\n" + "06.03.2026;250,00;Kontouebertrag rein;;\n" + ) + rows = admin.post( + "/api/imports/preview", + files={"file": ("k.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")}, + data={ + "account_id": str(acc["id"]), "delimiter": ";", "decimal_sep": ",", + "date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true", + "skip_rows": "0", "amount_mode": "signed", + "column_map": json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3}), + }, + ).json()["rows"] + + assert rows[0]["category_id"] == uebertrag["id"], "Ausgabe-Richtung" + assert rows[1]["category_id"] == uebertrag["id"], "Einnahme-Richtung ebenso" diff --git a/web/src/kategorien.js b/web/src/kategorien.js new file mode 100644 index 0000000..348a5b6 --- /dev/null +++ b/web/src/kategorien.js @@ -0,0 +1,20 @@ +// Auswahl-Listen für Kategorien. +// +// Kategorien hängen an Gruppen, und eine Gruppe ist entweder Ausgabe oder +// Einnahme. Für einen Kontoübertrag passt beides nicht: Auf dem einen Konto geht +// Geld raus, auf dem anderen kommt es rein. Solche Kategorien sind als „nicht in +// Auswertungen einrechnen" markiert – und werden deshalb in BEIDEN Richtungen +// angeboten, egal in welcher Gruppe sie liegen. +export function gruppenFuer(groups, kind) { + const passend = []; + for (const g of groups) { + if (g.kind === kind) { + passend.push(g); + continue; + } + // Aus einer Gruppe der anderen Art nur die neutralen Kategorien übernehmen. + const neutrale = (g.categories || []).filter((c) => c.excluded_from_stats); + if (neutrale.length) passend.push({ ...g, categories: neutrale }); + } + return passend; +} diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index c11e45e..92a0d17 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -7,6 +7,7 @@ import { useToast } from "../toast"; import Icon from "../components/Icon"; import CategorySelect from "../components/CategorySelect"; import DataTable from "../components/DataTable"; +import { gruppenFuer } from "../kategorien"; import { formatMoney } from "../format"; // Eine Uhrzeit im Wert ist unkritisch – parse_date im Backend schneidet sie ab. @@ -217,8 +218,8 @@ export default function Import() { })); }, [raw]); - const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]); - const incomeGroups = useMemo(() => groups.filter((g) => g.kind === "income"), [groups]); + const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); + const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); const includeCount = rows ? rows.filter((r) => r.include).length : 0; diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index c37ed3d..6b64d78 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -10,6 +10,7 @@ import Money from "../components/Money"; import Modal from "../components/Modal"; import CategorySelect from "../components/CategorySelect"; import DataTable from "../components/DataTable"; +import { gruppenFuer } from "../kategorien"; import { centsToInput, parseAmountToCents, todayIso } from "../format"; const TYPES = ["expense", "income", "transfer"]; @@ -78,7 +79,7 @@ export default function Transactions() { const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type); // Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden). - const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]); + const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const sparenCatId = useMemo(() => { for (const g of expenseGroups) for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id); @@ -132,7 +133,7 @@ export default function Transactions() { const catOptions = useMemo(() => { if (!form) return []; const kind = form.type === "income" ? "income" : "expense"; - return groups.filter((g) => g.kind === kind); + return gruppenFuer(groups, kind); }, [form, groups]); async function speichern(e) {