feat: Der Soll-Betrag kann sich aus den Vertraegen rechnen
Die Vertraege standen bisher nur auf ihrer eigenen Seite. Im Budget sah man
nichts davon – man musste selbst nachrechnen, was fest gebunden ist, und beim
naechsten Preisaufschlag noch einmal.
Jetzt hat jede Kategorie zwei Betriebsarten, umschaltbar ueber ein Sigma neben
dem Eingabefeld:
fest eine Zahl, die man hinschreibt. Wie bisher.
berechnet Summe der Vertraege dieser Kategorie plus einem Zuschlag.
Der Zuschlag ist der Punkt. Wer fuer Streaming 62 plant, weil 42 gebunden sind
und 20 Luft sein sollen, muss beim naechsten Preisaufschlag nicht nachrechnen
– und verliert seine 20 dabei auch nicht. Unter der Zeile steht die Rechnung
offen, sonst stuende im Feld eine Zahl, die nicht der Soll-Betrag ist.
Das gilt fuer JEDE Kategorie, nicht nur fuer eine Abo-Kategorie: Ein
Handyvertrag liegt in "Telefon & Internet", und die rechnet sich genauso.
Auch auf "fest" steht der Hinweis da ("davon 16.84 fest gebunden") – sonst
sieht man nie, was einen schon bindet.
Zwei Dinge, die sonst still danebengingen, melden sich jetzt oben auf der
Seite: Vertraege OHNE Kategorie (ihr Geld fehlt im Budget, ohne dass irgendwo
steht, dass es fehlt) und Waehrungen ohne Kurs.
Gerechnet wird in effective_planned – der einen Stelle, aus der Budget-Seite
UND Dashboard ihr Soll beziehen. Damit koennen die beiden gar nicht erst
auseinanderlaufen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
26718b1301
commit
ab5d18e0d0
@@ -472,6 +472,19 @@ class BudgetTemplateLine(Base):
|
||||
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# Wie der Soll-Betrag zustande kommt.
|
||||
#
|
||||
# "fest" – die Zahl in ``planned_cents``, wie eh und je.
|
||||
# "berechnet" – Summe der Vertraege dieser Kategorie PLUS ``zuschlag_cents``.
|
||||
#
|
||||
# Der Zuschlag ist der Punkt: Wer fuer Streaming 80 plant, weil 58 fest
|
||||
# gebunden sind und 22 Luft sein sollen, will beim naechsten Preisaufschlag
|
||||
# nicht nachrechnen muessen – und seine 22 auch nicht verlieren.
|
||||
#
|
||||
# ``planned_cents`` bleibt bei "berechnet" stehen, wird aber nicht gelesen.
|
||||
# So findet man seinen alten Wert wieder, wenn man zurueckschaltet.
|
||||
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
|
||||
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "category_id", name="uq_tpl_owner_category"),
|
||||
@@ -533,6 +546,10 @@ class BudgetPeriodLine(Base):
|
||||
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
planned_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# Wie in der Vorlage – der angepasste Monat erbt die Betriebsart, sonst
|
||||
# friert er beim Anpassen heimlich auf einer Zahl ein.
|
||||
modus: Mapped[str] = mapped_column(String(16), nullable=False, default="fest")
|
||||
zuschlag_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
period: Mapped[BudgetPeriod] = relationship(back_populates="lines")
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from ..models import (
|
||||
User,
|
||||
)
|
||||
from ..schemas import PlannedIncomeCreate, PlannedIncomeOut, PlannedIncomeUpdate
|
||||
from ..services import budget
|
||||
from ..services import abos, budget
|
||||
from .accounts import base_currency
|
||||
from .settings import BUDGET_START_DAY_KEY, get_user_setting
|
||||
|
||||
@@ -51,31 +51,51 @@ def get_template(db: Session = Depends(get_db), user: User = Depends(get_current
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
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()
|
||||
)
|
||||
if planned_cents <= 0:
|
||||
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}
|
||||
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=planned_cents)
|
||||
line = BudgetTemplateLine(owner_id=user.id, category_id=category_id, planned_cents=0)
|
||||
db.add(line)
|
||||
else:
|
||||
line.planned_cents = planned_cents
|
||||
budget.uebernimm(line, planned_cents, modus, zuschlag_cents)
|
||||
db.commit()
|
||||
return {"category_id": category_id, "planned_cents": planned_cents}
|
||||
return {"category_id": category_id, "planned_cents": line.planned_cents,
|
||||
"modus": line.modus, "zuschlag_cents": line.zuschlag_cents}
|
||||
|
||||
|
||||
# --- Geplante Einnahmen ----------------------------------------------------
|
||||
@@ -148,6 +168,10 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
||||
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
|
||||
@@ -167,9 +191,16 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
||||
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.
|
||||
@@ -190,6 +221,7 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
||||
"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,
|
||||
})
|
||||
|
||||
@@ -221,6 +253,13 @@ def _build_view(db: Session, user: User, label: str) -> dict:
|
||||
"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,
|
||||
@@ -248,13 +287,17 @@ def view(label: str, db: Session = Depends(get_db), user: User = Depends(get_cur
|
||||
def set_month_category(
|
||||
label: str,
|
||||
category_id: int,
|
||||
planned_cents: 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)
|
||||
budget.set_month_category(db, user, label, category_id, max(0, planned_cents))
|
||||
_pruefe_modus(modus)
|
||||
budget.set_month_category(db, user, label, category_id, planned_cents, modus,
|
||||
zuschlag_cents)
|
||||
return _build_view(db, user, label)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Was die Verträge je Kategorie im Monat kosten.
|
||||
|
||||
Die eine Stelle, an der aus Verträgen eine Budget-Zahl wird. Budget-Seite und
|
||||
Dashboard fragen beide hier – sonst könnten sie unterschiedliche Antworten
|
||||
geben, und man müsste raten, welche stimmt.
|
||||
|
||||
Gerechnet wird in der Hauptwährung: Ein Vertrag in Euro und einer in Franken
|
||||
lassen sich sonst nicht addieren. Was sich mangels Kurs nicht umrechnen lässt,
|
||||
wird GEMELDET und nicht stillschweigend als Null verbucht – eine zu kleine
|
||||
Summe im Budget ist schlimmer als gar keine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Subscription, User
|
||||
from . import currency
|
||||
|
||||
|
||||
def _monatlich_in(db: Session, s: Subscription, base: str, heute: date) -> int | None:
|
||||
"""Monatswert eines Vertrags in der Hauptwährung, oder None ohne Kurs."""
|
||||
monatlich = s.monatlich_cents
|
||||
if s.currency == base:
|
||||
return monatlich
|
||||
return currency.convert(db, monatlich, s.currency, base, heute)
|
||||
|
||||
|
||||
def uebersicht(db: Session, user: User, base: str) -> dict:
|
||||
"""Alles, was die Budget-Seite über die Verträge wissen muss.
|
||||
|
||||
``je_kategorie`` Monatswert je Kategorie, in Hauptwährung
|
||||
``anzahl`` wie viele Verträge je Kategorie – „aus 2 Verträgen"
|
||||
``ohne_kategorie`` Summe und Anzahl der Verträge, die keiner Kategorie
|
||||
zugeordnet sind. Ohne diese Angabe fehlt ihr Geld im
|
||||
Budget, ohne dass irgendwo steht, dass es fehlt.
|
||||
``ohne_kurs`` Währungen, die sich nicht umrechnen liessen.
|
||||
"""
|
||||
heute = date.today()
|
||||
je_kategorie: dict[int, int] = defaultdict(int)
|
||||
anzahl: dict[int, int] = defaultdict(int)
|
||||
ohne_kategorie_cents = 0
|
||||
ohne_kategorie_anzahl = 0
|
||||
ohne_kurs: set[str] = set()
|
||||
|
||||
for s in db.query(Subscription).filter(
|
||||
Subscription.owner_id == user.id,
|
||||
Subscription.is_active.is_(True),
|
||||
):
|
||||
cents = _monatlich_in(db, s, base, heute)
|
||||
if cents is None:
|
||||
ohne_kurs.add(s.currency)
|
||||
continue
|
||||
if s.category_id is None:
|
||||
ohne_kategorie_cents += cents
|
||||
ohne_kategorie_anzahl += 1
|
||||
continue
|
||||
je_kategorie[s.category_id] += cents
|
||||
anzahl[s.category_id] += 1
|
||||
|
||||
return {
|
||||
"je_kategorie": dict(je_kategorie),
|
||||
"anzahl": dict(anzahl),
|
||||
"ohne_kategorie_cents": ohne_kategorie_cents,
|
||||
"ohne_kategorie_anzahl": ohne_kategorie_anzahl,
|
||||
"ohne_kurs": sorted(ohne_kurs),
|
||||
}
|
||||
|
||||
|
||||
def monatlich_je_kategorie(db: Session, user: User, base: str) -> dict[int, int]:
|
||||
"""Nur die Beträge – für die Soll-Berechnung."""
|
||||
return uebersicht(db, user, base)["je_kategorie"]
|
||||
@@ -122,14 +122,41 @@ def _period(db: Session, user: User, label: str) -> BudgetPeriod | None:
|
||||
)
|
||||
|
||||
|
||||
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
|
||||
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
|
||||
def hauptwaehrung(db: Session, user: User) -> str:
|
||||
"""Waehrung, in der Soll-Betraege gefuehrt werden."""
|
||||
from ..routers.settings import BASE_CURRENCY_KEY, get_user_setting
|
||||
|
||||
return get_user_setting(db, user, BASE_CURRENCY_KEY) or "EUR"
|
||||
|
||||
|
||||
def soll_einer_zeile(zeile, aus_vertraegen: dict[int, int]) -> int:
|
||||
"""Was eine Budget-Zeile als Soll bedeutet.
|
||||
|
||||
Bei "berechnet" ist die hingeschriebene Zahl nicht der Soll-Betrag, sondern
|
||||
nur der Zuschlag – dazu kommt, was die Vertraege dieser Kategorie kosten.
|
||||
"""
|
||||
if zeile.modus == "berechnet":
|
||||
return aus_vertraegen.get(zeile.category_id, 0) + zeile.zuschlag_cents
|
||||
return zeile.planned_cents
|
||||
|
||||
|
||||
def _zeilen(db: Session, user: User, label: str):
|
||||
"""Die wirksamen Budget-Zeilen: 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 period.lines
|
||||
return db.query(BudgetTemplateLine).filter(
|
||||
BudgetTemplateLine.owner_id == user.id).all()
|
||||
|
||||
|
||||
def effective_planned(db: Session, user: User, label: str) -> dict[int, int]:
|
||||
"""Wirksame Soll-Beträge je Kategorie: Monats-Override falls vorhanden, sonst Vorlage."""
|
||||
from . import abos
|
||||
|
||||
aus_vertraegen = abos.monatlich_je_kategorie(db, user, hauptwaehrung(db, user))
|
||||
return {
|
||||
tl.category_id: tl.planned_cents
|
||||
for tl in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id)
|
||||
ln.category_id: soll_einer_zeile(ln, aus_vertraegen)
|
||||
for ln in _zeilen(db, user, label)
|
||||
}
|
||||
|
||||
|
||||
@@ -145,23 +172,43 @@ def ensure_customized(db: Session, user: User, label: str) -> BudgetPeriod:
|
||||
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)
|
||||
)
|
||||
period.lines.append(BudgetPeriodLine(
|
||||
category_id=tl.category_id, planned_cents=tl.planned_cents,
|
||||
# Ohne diese beiden fiele ein angepasster Monat auf die Zahl
|
||||
# zurueck, die zufaellig zuletzt in ``planned_cents`` stand.
|
||||
modus=tl.modus, zuschlag_cents=tl.zuschlag_cents,
|
||||
))
|
||||
db.flush()
|
||||
return period
|
||||
|
||||
|
||||
def set_month_category(db: Session, user: User, label: str, category_id: int, planned_cents: int) -> None:
|
||||
def set_month_category(db: Session, user: User, label: str, category_id: int,
|
||||
planned_cents: int | None = None, modus: str | None = None,
|
||||
zuschlag_cents: int | None = None) -> 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
|
||||
line = BudgetPeriodLine(category_id=category_id, planned_cents=0)
|
||||
period.lines.append(line)
|
||||
uebernimm(line, planned_cents, modus, zuschlag_cents)
|
||||
db.commit()
|
||||
|
||||
|
||||
def uebernimm(zeile, planned_cents: int | None, modus: str | None,
|
||||
zuschlag_cents: int | None) -> None:
|
||||
"""Setzt an einer Budget-Zeile, was mitgegeben wurde – und nur das.
|
||||
|
||||
Getrennt gehalten, weil Vorlage und Monat dieselbe Regel brauchen und zwei
|
||||
Kopien davon genau die Stelle waeren, an der sie auseinanderlaufen.
|
||||
"""
|
||||
if planned_cents is not None:
|
||||
zeile.planned_cents = max(0, planned_cents)
|
||||
if zuschlag_cents is not None:
|
||||
zeile.zuschlag_cents = max(0, zuschlag_cents)
|
||||
if modus is not None:
|
||||
zeile.modus = modus
|
||||
|
||||
|
||||
def reset_month(db: Session, user: User, label: str) -> None:
|
||||
period = _period(db, user, label)
|
||||
if period is not None:
|
||||
|
||||
Reference in New Issue
Block a user