Der Stichtag eines Kontos wurde gespeichert und angezeigt, von der Saldo-Berechnung aber ignoriert: sie addierte jede Buchung des Kontos, auch die aus der Zeit vor dem Stichtag. Damit lief jeder Saldo um die gesamte Vorgeschichte daneben. Der Anfangssaldo beschreibt jetzt den Stand am ENDE des Stichtags – so steht er auf dem Auszug. Buchungen bis einschliesslich dieses Tages zaehlen nicht mehr zum Kontostand, bleiben aber in Liste und Auswertungen. Beim Uebertrag wird jede Seite an ihrem eigenen Stichtag gemessen; der Vermoegensverlauf folgt derselben Regel. Ohne Stichtag zaehlt weiterhin alles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
334 lines
12 KiB
Python
334 lines
12 KiB
Python
"""Dashboard: pro Nutzer gespeichertes Karten-Layout und die Datenendpunkte je Karte."""
|
||
|
||
import json
|
||
from collections import defaultdict
|
||
from datetime import date, timedelta
|
||
|
||
from fastapi import APIRouter, Body, Depends, Query
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user
|
||
from ..models import (
|
||
Account,
|
||
Category,
|
||
CategoryGroup,
|
||
CategoryKind,
|
||
DashboardLayout,
|
||
Transaction,
|
||
TxnType,
|
||
User,
|
||
)
|
||
from ..services import balances, budget, currency
|
||
from .accounts import base_currency
|
||
from .settings import BUDGET_START_DAY_KEY, get_user_setting
|
||
|
||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||
|
||
# Sinnvolles Standard-Layout (12-Spalten-Raster). Eine Karte je Typ.
|
||
DEFAULT_LAYOUT = [
|
||
{"i": "account-balances", "type": "account-balances", "x": 0, "y": 0, "w": 4, "h": 5},
|
||
{"i": "budget-donut", "type": "budget-donut", "x": 4, "y": 0, "w": 4, "h": 5},
|
||
{"i": "income-expense", "type": "income-expense", "x": 8, "y": 0, "w": 4, "h": 2},
|
||
{"i": "budget-progress", "type": "budget-progress", "x": 8, "y": 2, "w": 4, "h": 3},
|
||
{"i": "net-worth", "type": "net-worth", "x": 0, "y": 5, "w": 8, "h": 4},
|
||
{"i": "recent-transactions", "type": "recent-transactions", "x": 8, "y": 5, "w": 4, "h": 4},
|
||
]
|
||
|
||
|
||
def _start_day(db: Session, user: User) -> int:
|
||
try:
|
||
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
||
except ValueError:
|
||
return 1
|
||
|
||
|
||
def _label(db: Session, user: User) -> str:
|
||
return budget.current_label(_start_day(db, user))
|
||
|
||
|
||
# --- Layout ----------------------------------------------------------------
|
||
|
||
|
||
@router.get("/layout")
|
||
def get_layout(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
||
if row is None:
|
||
return {"layout": DEFAULT_LAYOUT, "default": True}
|
||
try:
|
||
layout = json.loads(row.layout)
|
||
except ValueError:
|
||
layout = DEFAULT_LAYOUT
|
||
return {"layout": layout, "default": False}
|
||
|
||
|
||
@router.put("/layout")
|
||
def save_layout(
|
||
layout: list = Body(...),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
||
if row is None:
|
||
row = DashboardLayout(owner_id=user.id, layout=json.dumps(layout))
|
||
db.add(row)
|
||
else:
|
||
row.layout = json.dumps(layout)
|
||
db.commit()
|
||
return {"layout": layout, "default": False}
|
||
|
||
|
||
@router.delete("/layout")
|
||
def reset_layout(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
row = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
||
if row is not None:
|
||
db.delete(row)
|
||
db.commit()
|
||
return {"layout": DEFAULT_LAYOUT, "default": True}
|
||
|
||
|
||
# --- Kartendaten -----------------------------------------------------------
|
||
|
||
|
||
def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
|
||
return (
|
||
db.query(CategoryGroup)
|
||
.filter(CategoryGroup.owner_id == user.id, CategoryGroup.kind == CategoryKind.expense)
|
||
.order_by(CategoryGroup.position, CategoryGroup.name)
|
||
.all()
|
||
)
|
||
|
||
|
||
def _units(groups: list[CategoryGroup], by_category: bool):
|
||
"""Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
|
||
ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
|
||
for g in groups:
|
||
if by_category:
|
||
for c in g.categories:
|
||
if not c.is_archived:
|
||
yield (c.name, c.color, [c.id])
|
||
else:
|
||
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
|
||
|
||
|
||
@router.get("/budget-donut")
|
||
def budget_donut(
|
||
group_by: str = Query(default="group"),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
base = base_currency(db, user)
|
||
label = _label(db, user)
|
||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||
planned = budget.effective_planned(db, user, label)
|
||
groups = _expense_groups(db, user)
|
||
by_category = group_by == "category"
|
||
|
||
def collect(values: dict[int, int]):
|
||
segs, tot = [], 0
|
||
for name, color, ids in _units(groups, by_category):
|
||
val = sum(values.get(i, 0) for i in ids)
|
||
if val > 0:
|
||
segs.append({"name": name, "value": val, "color": color})
|
||
tot += val
|
||
return segs, tot
|
||
|
||
segments, total = collect(planned)
|
||
mode = "planned"
|
||
if total == 0:
|
||
segments, total = collect(budget.actual_expense_by_category(db, user, start, end, base))
|
||
mode = "actual"
|
||
return {"segments": segments, "total": total, "currency": base, "mode": mode,
|
||
"label": label, "group_by": "category" if by_category else "group"}
|
||
|
||
|
||
@router.get("/expense-share")
|
||
def expense_share(
|
||
group_by: str = Query(default="category"),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
"""Tatsächliche Ausgaben des laufenden Budget-Monats je Kategorie/Gruppe.
|
||
|
||
Anders als der Budget-Donut zeigt das rein die Ist-Ausgaben – unabhängig
|
||
davon, ob für den Monat überhaupt geplant wurde. Die Anteile rechnet die
|
||
Oberfläche aus `amount` und `total`, damit sich gerundete Prozente nicht zu
|
||
99,9 % addieren."""
|
||
base = base_currency(db, user)
|
||
label = _label(db, user)
|
||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
||
by_category = group_by != "group"
|
||
|
||
rows = []
|
||
for name, color, ids in _units(_expense_groups(db, user), by_category):
|
||
val = sum(actual.get(i, 0) for i in ids)
|
||
if val > 0:
|
||
rows.append({"name": name, "color": color, "amount": val})
|
||
rows.sort(key=lambda r: r["amount"], reverse=True)
|
||
return {
|
||
"rows": rows,
|
||
"total": sum(r["amount"] for r in rows),
|
||
"currency": base,
|
||
"label": label,
|
||
"group_by": "category" if by_category else "group",
|
||
}
|
||
|
||
|
||
@router.get("/budget-progress")
|
||
def budget_progress(
|
||
group_by: str = Query(default="group"),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
base = base_currency(db, user)
|
||
label = _label(db, user)
|
||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||
planned = budget.effective_planned(db, user, label)
|
||
actual = budget.actual_expense_by_category(db, user, start, end, base)
|
||
by_category = group_by == "category"
|
||
rows = []
|
||
for name, color, ids in _units(_expense_groups(db, user), by_category):
|
||
gp = sum(planned.get(i, 0) for i in ids)
|
||
ga = sum(actual.get(i, 0) for i in ids)
|
||
if gp > 0 or ga > 0:
|
||
rows.append({"name": name, "color": color, "planned": gp, "actual": ga, "over": ga > gp})
|
||
return {"rows": rows, "currency": base, "label": label,
|
||
"group_by": "category" if by_category else "group"}
|
||
|
||
|
||
@router.get("/income-expense")
|
||
def income_expense(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
base = base_currency(db, user)
|
||
label = _label(db, user)
|
||
start, end = budget.period_bounds(label, _start_day(db, user))
|
||
expense = sum(budget.actual_expense_by_category(db, user, start, end, base).values())
|
||
income = sum(budget.income_by_category(db, user, start, end, base).values())
|
||
return {"income": income, "expense": expense, "net": income - expense, "currency": base, "label": label}
|
||
|
||
|
||
@router.get("/balances")
|
||
def dashboard_balances(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
base = base_currency(db, user)
|
||
today = date.today()
|
||
accounts = (
|
||
db.query(Account)
|
||
.filter(Account.owner_id == user.id)
|
||
.order_by(Account.position, Account.name)
|
||
.all()
|
||
)
|
||
rows = []
|
||
total = 0
|
||
for a in accounts:
|
||
bb = balances.account_balance_base_cents(db, a, base, today)
|
||
rows.append({
|
||
"id": a.id, "name": a.name, "icon": a.icon, "color": a.color,
|
||
"currency": a.currency, "balance_cents": balances.account_balance_cents(db, a),
|
||
"balance_base_cents": bb, "archived": a.is_archived,
|
||
})
|
||
if not a.is_archived:
|
||
total += bb
|
||
return {"accounts": rows, "total": total, "currency": base}
|
||
|
||
|
||
@router.get("/net-worth")
|
||
def net_worth(
|
||
days: int = Query(default=90, ge=7, le=365),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
base = base_currency(db, user)
|
||
end = date.today()
|
||
start = end - timedelta(days=days - 1)
|
||
|
||
accounts = db.query(Account).filter(Account.owner_id == user.id, Account.is_archived == False).all() # noqa: E712
|
||
acc_by_id = {a.id: a for a in accounts}
|
||
|
||
def to_eur(cents: int, ccy: str, on: date) -> int:
|
||
v = currency.to_eur_cents(db, cents, ccy, on)
|
||
return v if v is not None else cents
|
||
|
||
opening_eur = sum(to_eur(a.opening_balance_cents, a.currency, a.opening_date or start) for a in accounts)
|
||
|
||
def zaehlt(acc: Account | None, on: date) -> bool:
|
||
"""Buchungen bis zum Stichtag stecken schon im Anfangssaldo."""
|
||
return acc is not None and (acc.opening_date is None or on > acc.opening_date)
|
||
|
||
def eur_effect(t: Transaction) -> int:
|
||
# Jede Seite wird für sich geprüft: Der Stichtag des Quellkontos kann
|
||
# anders liegen als der des Zielkontos.
|
||
effekt = 0
|
||
if zaehlt(acc_by_id.get(t.account_id), t.date):
|
||
effekt += t.amount_eur_cents if t.type == TxnType.income else -t.amount_eur_cents
|
||
if t.type == TxnType.transfer:
|
||
to_acc = acc_by_id.get(t.to_account_id) if t.to_account_id else None
|
||
if zaehlt(to_acc, t.date):
|
||
credited = t.to_amount_cents if t.to_amount_cents is not None else t.amount_cents
|
||
effekt += to_eur(credited, to_acc.currency, t.date)
|
||
return effekt
|
||
|
||
txns = db.query(Transaction).filter(
|
||
Transaction.owner_id == user.id, Transaction.account_id.in_(list(acc_by_id.keys()) or [0])
|
||
).all() if acc_by_id else []
|
||
|
||
before = sum(eur_effect(t) for t in txns if t.date < start)
|
||
deltas: dict[date, int] = defaultdict(int)
|
||
for t in txns:
|
||
if start <= t.date <= end:
|
||
deltas[t.date] += eur_effect(t)
|
||
|
||
running = opening_eur + before
|
||
labels: list[str] = []
|
||
values_eur: list[int] = []
|
||
d = start
|
||
while d <= end:
|
||
running += deltas.get(d, 0)
|
||
labels.append(d.isoformat())
|
||
values_eur.append(running)
|
||
d += timedelta(days=1)
|
||
|
||
if base != "EUR":
|
||
rate = currency.rate_on(db, base, end)
|
||
if rate is not None:
|
||
values_eur = [int(round(v * float(rate))) for v in values_eur]
|
||
return {"labels": labels, "values": values_eur, "currency": base}
|
||
|
||
|
||
@router.get("/recent")
|
||
def recent(
|
||
limit: int = Query(default=8, le=50),
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
base = base_currency(db, user)
|
||
accs = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||
cats = {c.id: c for c in db.query(Category).filter(Category.owner_id == user.id)}
|
||
rows = (
|
||
db.query(Transaction)
|
||
.filter(Transaction.owner_id == user.id)
|
||
.order_by(Transaction.date.desc(), Transaction.id.desc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
out = []
|
||
for t in rows:
|
||
acc = accs.get(t.account_id)
|
||
if t.type == TxnType.transfer:
|
||
to = accs.get(t.to_account_id)
|
||
title = f"{acc.name if acc else '?'} → {to.name if to else '?'}"
|
||
icon, color, sign = "arrow-left-right", "#6b7480", None
|
||
else:
|
||
cat = cats.get(t.category_id) if t.category_id else None
|
||
title = cat.name if cat else (t.payee or "—")
|
||
icon = cat.icon if cat else "more-horizontal"
|
||
color = cat.color if cat else "#6b7480"
|
||
sign = "pos" if t.type == TxnType.income else "neg"
|
||
out.append({
|
||
"id": t.id, "date": t.date.isoformat(), "type": t.type.value,
|
||
"title": title, "icon": icon, "color": color, "sign": sign,
|
||
"amount_cents": t.amount_cents, "currency": acc.currency if acc else "EUR",
|
||
"amount_base_cents": t.amount_eur_cents if base == "EUR"
|
||
else (currency.from_eur_cents(db, t.amount_eur_cents, base, t.date) or t.amount_eur_cents),
|
||
})
|
||
return {"transactions": out, "currency": base}
|