fix: neutrale Kategorien gelten für Ausgabe UND Einnahme
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 <noreply@anthropic.com>
This commit is contained in:
@@ -222,7 +222,7 @@ def _match_rule(
|
|||||||
purpose: str,
|
purpose: str,
|
||||||
amount: int | None,
|
amount: int | None,
|
||||||
allowed_kind: CategoryKind | None,
|
allowed_kind: CategoryKind | None,
|
||||||
cat_kind: dict[int, CategoryKind],
|
cat_kind: dict[int, CategoryKind | None],
|
||||||
) -> CategorizationRule | None:
|
) -> CategorizationRule | None:
|
||||||
"""Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
|
"""Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
|
||||||
greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
|
greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
|
||||||
@@ -241,7 +241,10 @@ def _match_rule(
|
|||||||
continue
|
continue
|
||||||
if not rule.category_id:
|
if not rule.category_id:
|
||||||
continue
|
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
|
continue
|
||||||
if _pattern_hit(rule, p, z):
|
if _pattern_hit(rule, p, z):
|
||||||
return rule
|
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 = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
|
||||||
rules.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst
|
rules.sort(key=_rule_sort_key, reverse=True) # längstes/manuell zuerst
|
||||||
cat_kind: dict[int, CategoryKind] = {
|
# None = neutral: zählt für Ausgabe UND Einnahme (siehe _match_rule).
|
||||||
cid: kind
|
cat_kind: dict[int, CategoryKind | None] = {
|
||||||
for cid, kind in db.query(Category.id, Category.type).filter(Category.owner_id == user.id)
|
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
|
# 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,
|
# bereits GENAU SO VIELE identische Buchungen in der DB stehen. So werden echte,
|
||||||
|
|||||||
@@ -67,3 +67,35 @@ def test_neutral_category_out_of_stats_but_in_balance(admin):
|
|||||||
# Häkchen entfernen -> zählt sofort wieder mit.
|
# Häkchen entfernen -> zählt sofort wieder mit.
|
||||||
admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": False})
|
admin.patch(f"/api/categories/{uebertrag['id']}", json={"excluded_from_stats": False})
|
||||||
assert _ausgaben(admin) == vorher_ausgaben + 35000
|
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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useToast } from "../toast";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import CategorySelect from "../components/CategorySelect";
|
import CategorySelect from "../components/CategorySelect";
|
||||||
import DataTable from "../components/DataTable";
|
import DataTable from "../components/DataTable";
|
||||||
|
import { gruppenFuer } from "../kategorien";
|
||||||
import { formatMoney } from "../format";
|
import { formatMoney } from "../format";
|
||||||
|
|
||||||
// Eine Uhrzeit im Wert ist unkritisch – parse_date im Backend schneidet sie ab.
|
// Eine Uhrzeit im Wert ist unkritisch – parse_date im Backend schneidet sie ab.
|
||||||
@@ -217,8 +218,8 @@ export default function Import() {
|
|||||||
}));
|
}));
|
||||||
}, [raw]);
|
}, [raw]);
|
||||||
|
|
||||||
const expenseGroups = useMemo(() => groups.filter((g) => g.kind === "expense"), [groups]);
|
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
|
||||||
const incomeGroups = useMemo(() => groups.filter((g) => g.kind === "income"), [groups]);
|
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
|
||||||
|
|
||||||
const includeCount = rows ? rows.filter((r) => r.include).length : 0;
|
const includeCount = rows ? rows.filter((r) => r.include).length : 0;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Money from "../components/Money";
|
|||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import CategorySelect from "../components/CategorySelect";
|
import CategorySelect from "../components/CategorySelect";
|
||||||
import DataTable from "../components/DataTable";
|
import DataTable from "../components/DataTable";
|
||||||
|
import { gruppenFuer } from "../kategorien";
|
||||||
import { centsToInput, parseAmountToCents, todayIso } from "../format";
|
import { centsToInput, parseAmountToCents, todayIso } from "../format";
|
||||||
|
|
||||||
const TYPES = ["expense", "income", "transfer"];
|
const TYPES = ["expense", "income", "transfer"];
|
||||||
@@ -78,7 +79,7 @@ export default function Transactions() {
|
|||||||
const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type);
|
const savingsDest = form?.type === "transfer" && toAcc && SAVINGS_TYPES.includes(toAcc.type);
|
||||||
|
|
||||||
// Standard-Kategorie für budgetierte Sparen-Transfers (falls vorhanden).
|
// 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(() => {
|
const sparenCatId = useMemo(() => {
|
||||||
for (const g of expenseGroups)
|
for (const g of expenseGroups)
|
||||||
for (const c of g.categories) if (c.name === "Sparen & Investments") return String(c.id);
|
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(() => {
|
const catOptions = useMemo(() => {
|
||||||
if (!form) return [];
|
if (!form) return [];
|
||||||
const kind = form.type === "income" ? "income" : "expense";
|
const kind = form.type === "income" ? "income" : "expense";
|
||||||
return groups.filter((g) => g.kind === kind);
|
return gruppenFuer(groups, kind);
|
||||||
}, [form, groups]);
|
}, [form, groups]);
|
||||||
|
|
||||||
async function speichern(e) {
|
async function speichern(e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user