feat: Kategorien aus Auswertungen ausnehmen (z. B. „Kontoübertrag")
Umbuchungen zwischen eigenen Konten sind keine Ausgabe – das Geld ist nicht weg, es liegt woanders. Bisher blieb nur die Wahl zwischen einer echten Transfer- Buchung (bildet die Wirklichkeit ab, braucht aber auf beiden Seiten passende Regeln und schätzt bei Fremdwährung den Gegenwert) oder einer gewöhnlichen Ausgabe, die Budget und Statistik verfälscht. Kategorien haben jetzt den Schalter „Nicht in Auswertungen einrechnen": Die Buchung wirkt weiter auf den Kontostand, bleibt aber aus Budget, Dashboard und Auswertungen heraus. Gefiltert wird zentral in actual_expense_by_category und income_by_category – damit greift es zugleich für Budget-Donut, Budget- Fortschritt, Einnahmen/Ausgaben, Ausgaben-Anteile und die Budget-Seite. Das ersetzt die Transfer-Paarung nicht, es ist der einfachere Weg: kein Gegenkonto, keine Kursschätzung, dafür ohne Verbindung zwischen den beiden Seiten. Migration 0013, Schalter im Kategorie-Dialog, Kennzeichnung in der Liste, Test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -307,6 +307,10 @@ class Category(Base):
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# Umbuchungen zwischen eigenen Konten sind keine Ausgabe: Das Geld ist nicht
|
||||
# weg, es liegt woanders. Solche Kategorien bleiben aus Budget, Dashboard und
|
||||
# Auswertungen heraus – die Buchung selbst zählt aber weiter auf den Saldo.
|
||||
excluded_from_stats: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
group: Mapped[CategoryGroup] = relationship(back_populates="categories")
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ def create_category(
|
||||
type=group.kind, # erbt die Art der Gruppe
|
||||
icon=payload.icon,
|
||||
color=payload.color,
|
||||
excluded_from_stats=payload.excluded_from_stats,
|
||||
position=_next_position(db, Category, owner_id=user.id, group_id=group.id),
|
||||
)
|
||||
db.add(cat)
|
||||
@@ -177,7 +178,9 @@ def update_category(
|
||||
cat.group_id = group.id
|
||||
cat.type = group.kind # Art an die neue Gruppe angleichen
|
||||
for key, value in data.items():
|
||||
if value is not None or key == "is_archived":
|
||||
# Bei den Schaltern zählt auch False als gesetzter Wert – sonst ließe sich
|
||||
# ein einmal gesetztes Häkchen nie wieder entfernen.
|
||||
if value is not None or key in ("is_archived", "excluded_from_stats"):
|
||||
setattr(cat, key, value)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
|
||||
@@ -209,6 +209,7 @@ class CategoryOut(BaseModel):
|
||||
position: int
|
||||
is_builtin: bool
|
||||
is_archived: bool
|
||||
excluded_from_stats: bool = False
|
||||
# Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die
|
||||
# Übersicht ohne einen Abruf je Kategorie auskommt.
|
||||
keyword_count: int = 0
|
||||
@@ -244,6 +245,7 @@ class CategoryCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
icon: str = "more-horizontal"
|
||||
color: str = "#6b7480"
|
||||
excluded_from_stats: bool = False
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
@@ -253,6 +255,7 @@ class CategoryUpdate(BaseModel):
|
||||
color: str | None = None
|
||||
position: int | None = None
|
||||
is_archived: bool | None = None
|
||||
excluded_from_stats: bool | None = None
|
||||
|
||||
|
||||
# --- Transaktionen ---------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,7 @@ from ..models import (
|
||||
BudgetPeriodLine,
|
||||
BudgetTemplateLine,
|
||||
BudgetTreatment,
|
||||
Category,
|
||||
CategoryKind,
|
||||
SAVINGS_ACCOUNT_TYPES,
|
||||
Transaction,
|
||||
@@ -172,9 +173,24 @@ def _to_base(db: Session, eur_cents: int, base: str, on: date) -> int:
|
||||
return conv if conv is not None else eur_cents
|
||||
|
||||
|
||||
def _neutrale_kategorien(db: Session, user: User) -> set[int]:
|
||||
"""Kategorien, die nicht in Auswertungen zählen (z. B. „Kontoübertrag").
|
||||
|
||||
Eine Umbuchung zwischen eigenen Konten ist keine Ausgabe – das Geld liegt nur
|
||||
woanders. Auf den Kontostand wirkt sie weiterhin, nur Budget und Statistik
|
||||
lassen sie außen vor."""
|
||||
return {
|
||||
cid
|
||||
for (cid,) in db.query(Category.id).filter(
|
||||
Category.owner_id == user.id, Category.excluded_from_stats.is_(True)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def actual_expense_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int, int]:
|
||||
"""Ist-Ausgaben je Kategorie in Hauptwährung (inkl. budgetierter Sparen-Transfers)."""
|
||||
result: dict[int, int] = defaultdict(int)
|
||||
neutral = _neutrale_kategorien(db, user)
|
||||
expenses = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
@@ -187,6 +203,8 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
|
||||
.all()
|
||||
)
|
||||
for t in expenses:
|
||||
if t.category_id in neutral:
|
||||
continue
|
||||
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
||||
|
||||
# Transfers auf Sparen/Depot zählen (sofern nicht auf "neutral" gesetzt) als
|
||||
@@ -213,6 +231,7 @@ def actual_expense_by_category(db: Session, user: User, start: date, end: date,
|
||||
|
||||
def income_by_category(db: Session, user: User, start: date, end: date, base: str) -> dict[int | None, int]:
|
||||
result: dict[int | None, int] = defaultdict(int)
|
||||
neutral = _neutrale_kategorien(db, user)
|
||||
incomes = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
@@ -224,5 +243,7 @@ def income_by_category(db: Session, user: User, start: date, end: date, base: st
|
||||
.all()
|
||||
)
|
||||
for t in incomes:
|
||||
if t.category_id in neutral:
|
||||
continue
|
||||
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user