GET /api/budget/template gab je Zeile nur planned_cents heraus, obwohl das Modell modus und zuschlag_cents fuehrt und PUT sie zurueckgibt. Wer die Vorlage so liest, haelt eine berechnete Zeile fuer eine feste - und schreibt sie beim naechsten Speichern auch als feste zurueck. Die Weboberflaeche traf es nicht, weil sie die Betriebsart aus der Monatsansicht nimmt. Die iOS-App bietet beide Richtungen an und haette den Fehler sichtbar gemacht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
346 lines
13 KiB
Python
346 lines
13 KiB
Python
"""Budget: Standard-Vorlage, geplante Einnahmen und die Monats-Soll/Ist-Ansicht."""
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user, get_owned
|
||
from ..models import (
|
||
Account,
|
||
BudgetTemplateLine,
|
||
Category,
|
||
CategoryGroup,
|
||
CategoryKind,
|
||
PlannedIncomeLine,
|
||
User,
|
||
)
|
||
from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate
|
||
from ..services import abos, budget
|
||
from .accounts import base_currency
|
||
from .settings import BUDGET_START_DAY_KEY, get_user_setting
|
||
|
||
router = APIRouter(prefix="/budget", tags=["budget"])
|
||
|
||
|
||
def _start_day(db: Session, user: User) -> int:
|
||
return budget.start_tag(db, user)
|
||
|
||
|
||
def _require_expense_category(db: Session, user: User, category_id: int) -> Category:
|
||
cat = get_owned(db, Category, category_id, user)
|
||
if cat.type != CategoryKind.expense:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Nur Ausgabe-Kategorien sind budgetierbar")
|
||
return cat
|
||
|
||
|
||
# --- Standard-Vorlage ------------------------------------------------------
|
||
|
||
|
||
@router.get("/template")
|
||
def get_template(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
lines = db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id).all()
|
||
income = (
|
||
db.query(PlannedIncomeLine)
|
||
.filter(PlannedIncomeLine.owner_id == user.id)
|
||
.order_by(PlannedIncomeLine.position, PlannedIncomeLine.id)
|
||
.all()
|
||
)
|
||
return {
|
||
# Betriebsart und Zuschlag gehören dazu: PUT gibt sie zurück und die
|
||
# Monatsansicht liefert sie ohnehin. Ohne sie zeigt ein Client eine
|
||
# berechnete Zeile als feste an und schreibt sie beim nächsten
|
||
# Speichern auch als feste zurück.
|
||
"lines": [
|
||
{
|
||
"category_id": ln.category_id,
|
||
"planned_cents": ln.planned_cents,
|
||
"modus": ln.modus,
|
||
"zuschlag_cents": ln.zuschlag_cents,
|
||
}
|
||
for ln in lines
|
||
],
|
||
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
|
||
}
|
||
|
||
|
||
MODI = ("fest", "berechnet")
|
||
|
||
|
||
def _pruefe_modus(modus: str | None) -> None:
|
||
if modus is not None and modus not in MODI:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail=f"Unbekannte Betriebsart '{modus}'. Moeglich: {', '.join(MODI)}.",
|
||
)
|
||
|
||
|
||
@router.put("/template/category/{category_id}")
|
||
def set_template_category(
|
||
category_id: int,
|
||
planned_cents: int | None = None,
|
||
modus: str | None = None,
|
||
zuschlag_cents: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
_require_expense_category(db, user, category_id)
|
||
_pruefe_modus(modus)
|
||
line = (
|
||
db.query(BudgetTemplateLine)
|
||
.filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id)
|
||
.first()
|
||
)
|
||
ziel_modus = modus or (line.modus if line is not None else "fest")
|
||
# Eine Zeile ohne Inhalt wird geloescht – sie ist dasselbe wie "kein
|
||
# Budget". Bei "berechnet" gilt das NICHT: Dort ist die Zeile selbst die
|
||
# Aussage ("rechne aus den Vertraegen"), auch wenn der Zuschlag null ist.
|
||
leer = ziel_modus == "fest" and (planned_cents is not None and planned_cents <= 0)
|
||
if leer:
|
||
if line is not None:
|
||
db.delete(line)
|
||
db.commit()
|
||
return {"category_id": category_id, "planned_cents": 0, "modus": "fest",
|
||
"zuschlag_cents": 0}
|
||
if line is None:
|
||
line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=0)
|
||
db.add(line)
|
||
budget.uebernimm(line, planned_cents, modus, zuschlag_cents)
|
||
db.commit()
|
||
return {"category_id": category_id, "planned_cents": line.planned_cents,
|
||
"modus": line.modus, "zuschlag_cents": line.zuschlag_cents}
|
||
|
||
|
||
# --- Geplante Einnahmen ----------------------------------------------------
|
||
|
||
|
||
@router.post("/income", response_model=PlannedIncomeOut, status_code=status.HTTP_201_CREATED)
|
||
def create_income(
|
||
payload: PlannedIncomeCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
):
|
||
if payload.category_id is not None:
|
||
get_owned(db, Category, payload.category_id, user)
|
||
if payload.target_account_id is not None:
|
||
get_owned(db, Account, payload.target_account_id, user)
|
||
max_pos = (
|
||
db.query(PlannedIncomeLine.position)
|
||
.filter(PlannedIncomeLine.owner_id == user.id)
|
||
.order_by(PlannedIncomeLine.position.desc())
|
||
.first()
|
||
)
|
||
line = PlannedIncomeLine(
|
||
owner_id=user.id,
|
||
label=payload.label.strip(),
|
||
category_id=payload.category_id,
|
||
target_account_id=payload.target_account_id,
|
||
planned_cents=payload.planned_cents,
|
||
position=(max_pos[0] + 1) if max_pos else 0,
|
||
)
|
||
db.add(line)
|
||
db.commit()
|
||
db.refresh(line)
|
||
return line
|
||
|
||
|
||
@router.patch("/income/{income_id}", response_model=PlannedIncomeOut)
|
||
def update_income(
|
||
income_id: int,
|
||
payload: PlannedIncomeUpdate,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
):
|
||
line = get_owned(db, PlannedIncomeLine, income_id, user)
|
||
data = payload.model_dump(exclude_unset=True)
|
||
if "category_id" in data and data["category_id"] is not None:
|
||
get_owned(db, Category, data["category_id"], user)
|
||
if "target_account_id" in data and data["target_account_id"] is not None:
|
||
get_owned(db, Account, data["target_account_id"], user)
|
||
for key, value in data.items():
|
||
setattr(line, key, value)
|
||
db.commit()
|
||
db.refresh(line)
|
||
return line
|
||
|
||
|
||
@router.delete("/income/{income_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
def delete_income(
|
||
income_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> None:
|
||
line = get_owned(db, PlannedIncomeLine, income_id, user)
|
||
db.delete(line)
|
||
db.commit()
|
||
|
||
|
||
# --- Soll/Ist-Ansicht ------------------------------------------------------
|
||
|
||
|
||
def _build_view(db: Session, user: User, label: str) -> dict:
|
||
base = base_currency(db, user)
|
||
start_day = _start_day(db, user)
|
||
start, end = budget.period_bounds(label, start_day)
|
||
planned = budget.effective_planned(db, user, label)
|
||
actual = budget.actual_expense_by_category(db, user, label, start, end, base)
|
||
income_actual = budget.income_by_category(db, user, label, start, end, base)
|
||
# Was aus Vertraegen kommt – auch fuer Kategorien auf "fest". Dort ist es
|
||
# kein Rechenwert, sondern ein Hinweis: "hier sind 43.89 gebunden".
|
||
vertraege = abos.uebersicht(db, user, base)
|
||
zeilen = {ln.category_id: ln for ln in budget._zeilen(db, user, label)}
|
||
|
||
groups_out = []
|
||
total_planned = total_actual = 0
|
||
groups = (
|
||
db.query(CategoryGroup)
|
||
.filter(CategoryGroup.owner_id == user.id, CategoryGroup.kind == CategoryKind.expense)
|
||
.order_by(CategoryGroup.position, CategoryGroup.name)
|
||
.all()
|
||
)
|
||
for g in groups:
|
||
cats = []
|
||
g_planned = g_actual = 0
|
||
for c in sorted(g.categories, key=lambda x: (x.position, x.name.lower())):
|
||
if c.is_archived:
|
||
continue
|
||
p = planned.get(c.id, 0)
|
||
a = actual.get(c.id, 0)
|
||
g_planned += p
|
||
g_actual += a
|
||
zeile = zeilen.get(c.id)
|
||
cats.append({
|
||
"category_id": c.id, "name": c.name, "icon": c.icon, "color": c.color,
|
||
"planned_cents": p, "actual_cents": a, "delta_cents": p - a, "over": a > p,
|
||
# Woraus sich der Soll-Betrag ergibt. "aus_vertraegen_cents"
|
||
# steht auch bei "fest" – dann als Hinweis, nicht als Summand.
|
||
"modus": zeile.modus if zeile is not None else "fest",
|
||
"zuschlag_cents": zeile.zuschlag_cents if zeile is not None else 0,
|
||
"aus_vertraegen_cents": vertraege["je_kategorie"].get(c.id, 0),
|
||
"vertraege_anzahl": vertraege["anzahl"].get(c.id, 0),
|
||
# "over" bleibt die nackte Tatsache; ob sie eine Warnung IST,
|
||
# entscheidet die Kategorie. Wer mehr zuruecklegt als geplant,
|
||
# hat nichts ueberzogen.
|
||
"good": c.spending_is_good,
|
||
})
|
||
total_planned += g_planned
|
||
total_actual += g_actual
|
||
# Ueberschritten ist eine Gruppe an dem, was WIRKLICH zu viel war. Das
|
||
# Sparen bleibt draussen: Sonst faerbt sich "Finanzen" rot, weil jemand
|
||
# mehr zurueckgelegt hat als geplant – eine Warnung fuer etwas Gutes,
|
||
# und daneben stuende die echte Ueberschreitung ununterscheidbar.
|
||
# Angezeigt werden weiter die vollen Summen; nur das Urteil laesst das
|
||
# Sparen aus.
|
||
echt_geplant = sum(c["planned_cents"] for c in cats if not c["good"])
|
||
echt_ist = sum(c["actual_cents"] for c in cats if not c["good"])
|
||
groups_out.append({
|
||
"id": g.id, "name": g.name, "icon": g.icon, "color": g.color,
|
||
"planned_cents": g_planned, "actual_cents": g_actual,
|
||
"over": echt_ist > echt_geplant,
|
||
"good": bool(cats) and all(c["good"] for c in cats),
|
||
"aus_vertraegen_cents": sum(c["aus_vertraegen_cents"] for c in cats),
|
||
"categories": cats,
|
||
})
|
||
|
||
income_lines = (
|
||
db.query(PlannedIncomeLine)
|
||
.filter(PlannedIncomeLine.owner_id == user.id)
|
||
.order_by(PlannedIncomeLine.position, PlannedIncomeLine.id)
|
||
.all()
|
||
)
|
||
income_out = []
|
||
total_planned_income = total_received_income = 0
|
||
for ln in income_lines:
|
||
received = income_actual.get(ln.category_id, 0) if ln.category_id else 0
|
||
total_planned_income += ln.planned_cents
|
||
total_received_income += received
|
||
income_out.append({
|
||
"id": ln.id, "label": ln.label, "category_id": ln.category_id,
|
||
"target_account_id": ln.target_account_id,
|
||
"planned_cents": ln.planned_cents, "received_cents": received,
|
||
})
|
||
|
||
return {
|
||
"label": label,
|
||
"start": start.isoformat(),
|
||
"end": end.isoformat(),
|
||
"customized": budget.is_customized(db, user, label),
|
||
"base_currency": base,
|
||
"prev": budget.shift_label(label, -1),
|
||
"next": budget.shift_label(label, 1),
|
||
"groups": groups_out,
|
||
"income": income_out,
|
||
# Was sich NICHT zuordnen liess. Ohne diese zwei Angaben fehlt das Geld
|
||
# im Budget, ohne dass irgendwo steht, dass es fehlt.
|
||
"vertraege": {
|
||
"ohne_kategorie_cents": vertraege["ohne_kategorie_cents"],
|
||
"ohne_kategorie_anzahl": vertraege["ohne_kategorie_anzahl"],
|
||
"ohne_kurs": vertraege["ohne_kurs"],
|
||
},
|
||
"totals": {
|
||
"planned_expense": total_planned,
|
||
"actual_expense": total_actual,
|
||
"planned_income": total_planned_income,
|
||
"received_income": total_received_income,
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/current")
|
||
def current(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
return _build_view(db, user, budget.current_label(_start_day(db, user)))
|
||
|
||
|
||
@router.get("/{label}")
|
||
def view(label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
|
||
try:
|
||
budget.parse_label(label)
|
||
except (ValueError, IndexError):
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Ungültiges Monats-Label (YYYY-MM)")
|
||
return _build_view(db, user, label)
|
||
|
||
|
||
@router.put("/{label}/category/{category_id}")
|
||
def set_month_category(
|
||
label: str,
|
||
category_id: int,
|
||
planned_cents: int | None = None,
|
||
modus: str | None = None,
|
||
zuschlag_cents: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(get_current_user),
|
||
) -> dict:
|
||
budget.parse_label(label)
|
||
_require_expense_category(db, user, category_id)
|
||
_pruefe_modus(modus)
|
||
budget.set_month_category(db, user, label, category_id, planned_cents, modus,
|
||
zuschlag_cents)
|
||
return _build_view(db, user, label)
|
||
|
||
|
||
@router.delete("/{label}")
|
||
def reset_month(
|
||
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> dict:
|
||
budget.parse_label(label)
|
||
budget.reset_month(db, user, label)
|
||
return _build_view(db, user, label)
|
||
|
||
|
||
# --- Kopieren Monat <-> Vorlage --------------------------------------------
|
||
|
||
|
||
@router.post("/template/from-month/{label}")
|
||
def template_from_month(
|
||
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> dict:
|
||
"""„Diesen Monat als Standard festlegen": Monat -> Vorlage."""
|
||
budget.parse_label(label)
|
||
budget.copy_month_to_template(db, user, label)
|
||
return get_template(db, user)
|
||
|
||
|
||
@router.post("/{label}/from-template")
|
||
def month_from_template(
|
||
label: str, db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> dict:
|
||
"""„Standard in diesen Monat übernehmen": Vorlage -> Monat (überschreibt)."""
|
||
budget.parse_label(label)
|
||
budget.copy_template_to_month(db, user, label)
|
||
return _build_view(db, user, label)
|