- Umschalter 1·2·3: Monate nebeneinander (responsives Raster, auf schmalem
Schirm gestapelt); prev/next verschiebt das ganze Fenster
- Je Monat: „Als Standard festlegen" (Monat -> Vorlage) und „Standard
übernehmen" (Vorlage -> Monat, überschreibt), plus Zurücksetzen
- Backend: copy_month_to_template / copy_template_to_month + Endpunkte
POST /budget/template/from-month/{label} und /budget/{label}/from-template
- Test fürs Kopieren; globale .grow-Utility ergänzt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
280 lines
9.6 KiB
Python
280 lines
9.6 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 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:
|
|
try:
|
|
return max(1, min(31, int(get_user_setting(db, user, BUDGET_START_DAY_KEY) or "1")))
|
|
except ValueError:
|
|
return 1
|
|
|
|
|
|
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 {
|
|
"lines": [{"category_id": ln.category_id, "planned_cents": ln.planned_cents} for ln in lines],
|
|
"income": [PlannedIncomeOut.model_validate(i).model_dump() for i in income],
|
|
}
|
|
|
|
|
|
@router.put("/template/category/{category_id}")
|
|
def set_template_category(
|
|
category_id: int,
|
|
planned_cents: int,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
_require_expense_category(db, user, category_id)
|
|
line = (
|
|
db.query(BudgetTemplateLine)
|
|
.filter(BudgetTemplateLine.owner_id == user.id, BudgetTemplateLine.category_id == category_id)
|
|
.first()
|
|
)
|
|
if planned_cents <= 0:
|
|
if line is not None:
|
|
db.delete(line)
|
|
db.commit()
|
|
return {"category_id": category_id, "planned_cents": 0}
|
|
if line is None:
|
|
line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=planned_cents)
|
|
db.add(line)
|
|
else:
|
|
line.planned_cents = planned_cents
|
|
db.commit()
|
|
return {"category_id": category_id, "planned_cents": planned_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, start, end, base)
|
|
income_actual = budget.income_by_category(db, user, start, end, base)
|
|
|
|
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
|
|
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,
|
|
})
|
|
total_planned += g_planned
|
|
total_actual += g_actual
|
|
groups_out.append({
|
|
"id": g.id, "name": g.name, "icon": g.icon, "color": g.color,
|
|
"planned_cents": g_planned, "actual_cents": g_actual, "over": g_actual > g_planned,
|
|
"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,
|
|
"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,
|
|
db: Session = Depends(get_db),
|
|
user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
budget.parse_label(label)
|
|
_require_expense_category(db, user, category_id)
|
|
budget.set_month_category(db, user, label, category_id, max(0, planned_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)
|