feat: Phase 2 – Budget-Engine + Direktzugriff (http://IP:Port)
Budget: - Modelle + Migration 0003: budget_template_lines, planned_income_lines, budget_periods, budget_period_lines. - services/budget: Perioden-Grenzen (einstellbarer Starttag, Label nach Endmonat), Standard-Vorlage vs. Monats-Override (Snapshot-on-Customize), Soll/Ist inkl. budgetierter Sparen-Transfers, geplante Einnahmen (Soll/Ist). - routers/budgets: Vorlage, geplante Einnahmen (CRUD), Monats-Soll/Ist, Monats-Override, Zurücksetzen. pytest: Perioden, Soll/Ist, Override/Reset, Einnahmen. - Frontend: Budget-Seite (Monat + Standard-Budget), Fortschrittsbalken grün/rot, Monats-Navigation, editierbare Soll-Beträge; Sparen-Option im Transfer-Formular. Deployment-Fix (Nutzer-Anforderung: http://IP:Port muss funktionieren): - Dienst bindet an APP_BIND_HOST=0.0.0.0 (direkt im LAN erreichbar). - APP_COOKIE_SECURE=false als Standard (sonst kein Login über reines HTTP); für reinen HTTPS-Betrieb umstellbar. Doku + install.sh entsprechend angepasst. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""Budget-Engine: Perioden-Grenzen, Standard-Vorlage vs. Monats-Override, Soll/Ist.
|
||||
|
||||
Der Budget-Monat hat einen einstellbaren Starttag D. Ein Zeitraum ist nach seinem
|
||||
End-/Hauptmonat benannt (YYYY-MM):
|
||||
- D <= 1: der Kalendermonat.
|
||||
- D >= 2: [Tag D des Vormonats … Tag D-1 des Hauptmonats].
|
||||
Planned-Beträge liegen in der Hauptwährung des Nutzers; Ist-Werte werden über den
|
||||
eingefrorenen EUR-Wert je Buchung in die Hauptwährung umgerechnet.
|
||||
"""
|
||||
|
||||
import calendar
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import (
|
||||
Account,
|
||||
BudgetPeriod,
|
||||
BudgetPeriodLine,
|
||||
BudgetTemplateLine,
|
||||
BudgetTreatment,
|
||||
CategoryKind,
|
||||
SAVINGS_ACCOUNT_TYPES,
|
||||
Transaction,
|
||||
TxnType,
|
||||
User,
|
||||
)
|
||||
from . import currency
|
||||
|
||||
|
||||
# --- Perioden --------------------------------------------------------------
|
||||
|
||||
|
||||
def _clamp_day(year: int, month: int, day: int) -> date:
|
||||
last = calendar.monthrange(year, month)[1]
|
||||
return date(year, month, min(day, last))
|
||||
|
||||
|
||||
def parse_label(label: str) -> tuple[int, int]:
|
||||
y, m = label.split("-")
|
||||
return int(y), int(m)
|
||||
|
||||
|
||||
def period_bounds(label: str, start_day: int) -> tuple[date, date]:
|
||||
"""(start, end) inklusive für einen Budget-Monat."""
|
||||
y, m = parse_label(label)
|
||||
if start_day <= 1:
|
||||
return date(y, m, 1), date(y, m, calendar.monthrange(y, m)[1])
|
||||
prev = date(y, m, 1) - relativedelta(months=1)
|
||||
start = _clamp_day(prev.year, prev.month, start_day)
|
||||
end = _clamp_day(y, m, start_day) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def period_of(d: date, start_day: int) -> str:
|
||||
"""Label des Budget-Monats, in den ein Datum fällt."""
|
||||
if start_day <= 1:
|
||||
return f"{d.year:04d}-{d.month:02d}"
|
||||
if d.day >= start_day:
|
||||
nxt = date(d.year, d.month, 1) + relativedelta(months=1)
|
||||
return f"{nxt.year:04d}-{nxt.month:02d}"
|
||||
return f"{d.year:04d}-{d.month:02d}"
|
||||
|
||||
|
||||
def shift_label(label: str, months: int) -> str:
|
||||
y, m = parse_label(label)
|
||||
d = date(y, m, 1) + relativedelta(months=months)
|
||||
return f"{d.year:04d}-{d.month:02d}"
|
||||
|
||||
|
||||
def current_label(start_day: int) -> str:
|
||||
return period_of(date.today(), start_day)
|
||||
|
||||
|
||||
# --- Vorlage vs. Monats-Override ------------------------------------------
|
||||
|
||||
|
||||
def _period(db: Session, user: User, label: str) -> BudgetPeriod | None:
|
||||
return (
|
||||
db.query(BudgetPeriod)
|
||||
.filter(BudgetPeriod.owner_id == user.id, BudgetPeriod.label == label)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
|
||||
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
|
||||
period = _period(db, user, label)
|
||||
if period is not None:
|
||||
return {ln.category_id: ln.planned_cents for ln in period.lines}
|
||||
return {
|
||||
tl.category_id: tl.planned_cents
|
||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id)
|
||||
}
|
||||
|
||||
|
||||
def is_customized(db: Session, user: User, label: str) -> bool:
|
||||
return _period(db, user, label) is not None
|
||||
|
||||
|
||||
def ensure_customized(db: Session, user: User, label: str) -> BudgetPeriod:
|
||||
"""Legt (falls nötig) die Monats-Periode an und übernimmt einmalig die Vorlage."""
|
||||
period = _period(db, user, label)
|
||||
if period is None:
|
||||
period = BudgetPeriod(owner_id=user.id, label=label, customized=True)
|
||||
db.add(period)
|
||||
db.flush()
|
||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id):
|
||||
period.lines.append(
|
||||
BudgetPeriodLine(category_id=tl.category_id, planned_cents=tl.planned_cents)
|
||||
)
|
||||
db.flush()
|
||||
return period
|
||||
|
||||
|
||||
def set_month_category(db: Session, user: User, label: str, category_id: int, planned_cents: int) -> None:
|
||||
period = ensure_customized(db, user, label)
|
||||
line = next((ln for ln in period.lines if ln.category_id == category_id), None)
|
||||
if line is None:
|
||||
period.lines.append(BudgetPeriodLine(category_id=category_id, planned_cents=planned_cents))
|
||||
else:
|
||||
line.planned_cents = planned_cents
|
||||
db.commit()
|
||||
|
||||
|
||||
def reset_month(db: Session, user: User, label: str) -> None:
|
||||
period = _period(db, user, label)
|
||||
if period is not None:
|
||||
db.delete(period)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- Ist-Werte -------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_base(db: Session, eur_cents: int, base: str, on: date) -> int:
|
||||
if base == "EUR":
|
||||
return eur_cents
|
||||
conv = currency.from_eur_cents(db, eur_cents, base, on)
|
||||
return conv if conv is not None else eur_cents
|
||||
|
||||
|
||||
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)
|
||||
expenses = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
Transaction.owner_id == user.id,
|
||||
Transaction.type == TxnType.expense,
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
Transaction.category_id.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for t in expenses:
|
||||
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
|
||||
# budgetierte Ausgabe gegen budget_category_id.
|
||||
transfers = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
Transaction.owner_id == user.id,
|
||||
Transaction.type == TxnType.transfer,
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
Transaction.budget_category_id.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for t in transfers:
|
||||
if t.budget_treatment == BudgetTreatment.neutral:
|
||||
continue
|
||||
to_acc = db.get(Account, t.to_account_id) if t.to_account_id else None
|
||||
if to_acc is not None and to_acc.type in SAVINGS_ACCOUNT_TYPES:
|
||||
result[t.budget_category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
||||
return result
|
||||
|
||||
|
||||
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)
|
||||
incomes = (
|
||||
db.query(Transaction)
|
||||
.filter(
|
||||
Transaction.owner_id == user.id,
|
||||
Transaction.type == TxnType.income,
|
||||
Transaction.date >= start,
|
||||
Transaction.date <= end,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for t in incomes:
|
||||
result[t.category_id] += _to_base(db, t.amount_eur_cents, base, t.date)
|
||||
return result
|
||||
Reference in New Issue
Block a user