feat: einstellbarer Monatsbeginn zum Gehaltstag
Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats, in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint. Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die Kategorienauswertung, der Monatsexport und die Benachrichtigung über überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten. Die Einstellung liegt in einer einzeiligen Tabelle hinter GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich period_start und period_end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
This commit is contained in:
co-authored by
Claude Opus 5
parent
998d5867df
commit
0a6261fc55
@@ -6,8 +6,8 @@ from decimal import Decimal
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.clock import month_end, month_start, today
|
||||
from app.api.deps import DbSession, MonthStartDay
|
||||
from app.core.clock import today
|
||||
from app.core.errors import ValidationError
|
||||
from app.models import SavingsGoal
|
||||
from app.models.enums import EntryKind
|
||||
@@ -43,10 +43,12 @@ from app.services.reports import (
|
||||
budget_status,
|
||||
calendar_month,
|
||||
category_breakdown,
|
||||
current_period,
|
||||
flows,
|
||||
forecast,
|
||||
month_report,
|
||||
months_between,
|
||||
period_of,
|
||||
required_monthly_rate,
|
||||
subscriptions,
|
||||
year_comparison,
|
||||
@@ -67,6 +69,8 @@ def _totals(value: Totals) -> TotalsOut:
|
||||
def _month(report: MonthReport) -> MonthReportOut:
|
||||
return MonthReportOut(
|
||||
month=report.month,
|
||||
period_start=report.period.start,
|
||||
period_end=report.period.end,
|
||||
planned=_totals(report.planned),
|
||||
actual=_totals(report.actual),
|
||||
previous_planned=_totals(report.previous_planned),
|
||||
@@ -89,6 +93,8 @@ def _month(report: MonthReport) -> MonthReportOut:
|
||||
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
|
||||
return ForecastMonthOut(
|
||||
month=entry.month,
|
||||
period_start=entry.period_start,
|
||||
period_end=entry.period_end,
|
||||
income=entry.income,
|
||||
expenses=entry.expenses,
|
||||
balance=entry.balance,
|
||||
@@ -216,11 +222,15 @@ async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut
|
||||
)
|
||||
async def read_month_report(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(
|
||||
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
|
||||
default=None,
|
||||
description="Beliebiger Tag im gewünschten Monat; ausschlaggebend ist dessen "
|
||||
"Monatserster. Vorgabe ist der laufende Abrechnungsmonat.",
|
||||
),
|
||||
) -> MonthReportOut:
|
||||
return _month(await month_report(session, month or today()))
|
||||
monat = month or current_period(start_day).key
|
||||
return _month(await month_report(session, monat, start_day))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -232,10 +242,13 @@ async def read_month_report(
|
||||
)
|
||||
async def read_forecast(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
|
||||
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
|
||||
start: date | None = Query(
|
||||
default=None, description="Erster Monat; Vorgabe ist der laufende Abrechnungsmonat."
|
||||
),
|
||||
) -> ForecastOut:
|
||||
monate = await forecast(session, months, start)
|
||||
monate = await forecast(session, months, start, start_day)
|
||||
return ForecastOut(
|
||||
months=[_forecast_month(monat) for monat in monate],
|
||||
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
|
||||
@@ -251,13 +264,14 @@ async def read_forecast(
|
||||
)
|
||||
async def read_categories(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
date_from: date | None = Query(default=None, alias="from"),
|
||||
date_to: date | None = Query(default=None, alias="to"),
|
||||
kind: EntryKind = Query(default=EntryKind.EXPENSE),
|
||||
) -> CategoryReportOut:
|
||||
heute = today()
|
||||
start = date_from or month_start(heute)
|
||||
ende = date_to or month_end(heute)
|
||||
laufend = current_period(start_day)
|
||||
start = date_from or laufend.start
|
||||
ende = date_to or laufend.end
|
||||
if ende < start:
|
||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||
|
||||
@@ -327,11 +341,15 @@ async def read_year_comparison(
|
||||
)
|
||||
async def read_calendar(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||
) -> CalendarMonthOut:
|
||||
raster = await calendar_month(session, month or today())
|
||||
monat = month or current_period(start_day).key
|
||||
raster = await calendar_month(session, monat, start_day=start_day)
|
||||
return CalendarMonthOut(
|
||||
month=raster.month,
|
||||
period_start=raster.period_start,
|
||||
period_end=raster.period_end,
|
||||
days=[
|
||||
CalendarDayOut(
|
||||
date=tag.on,
|
||||
@@ -358,9 +376,11 @@ async def read_calendar(
|
||||
)
|
||||
async def read_budget_status(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None),
|
||||
) -> list[BudgetStatusOut]:
|
||||
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
|
||||
monat = month or current_period(start_day).key
|
||||
return [_budget(eintrag) for eintrag in await budget_status(session, monat, start_day)]
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -382,25 +402,29 @@ async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]
|
||||
)
|
||||
async def read_dashboard(
|
||||
session: DbSession,
|
||||
start_day: MonthStartDay,
|
||||
month: date | None = Query(default=None),
|
||||
) -> DashboardOut:
|
||||
heute = today()
|
||||
monat = month_start(month or heute)
|
||||
zeitraum = period_of(month, start_day) if month else current_period(start_day, heute)
|
||||
|
||||
bericht = await month_report(session, monat)
|
||||
vorschau = await forecast(session, 12, monat)
|
||||
gruppen = await category_breakdown(session, monat, month_end(monat))
|
||||
bericht = await month_report(session, zeitraum.key, start_day)
|
||||
vorschau = await forecast(session, 12, zeitraum.key, start_day)
|
||||
gruppen = await category_breakdown(session, zeitraum.start, zeitraum.end)
|
||||
abos = await subscriptions(session)
|
||||
|
||||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
|
||||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Zeitraum.
|
||||
naechste = await flows(session, heute, heute + timedelta(days=14))
|
||||
|
||||
return DashboardOut(
|
||||
month_start_day=start_day,
|
||||
month=_month(bericht),
|
||||
total_balance=await total_balance(session, heute),
|
||||
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
|
||||
categories=[_slice(gruppe) for gruppe in gruppen],
|
||||
budgets=[_budget(eintrag) for eintrag in await budget_status(session, monat)],
|
||||
budgets=[
|
||||
_budget(eintrag) for eintrag in await budget_status(session, zeitraum.key, start_day)
|
||||
],
|
||||
goals=await _goals(session, heute),
|
||||
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
|
||||
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
|
||||
|
||||
Reference in New Issue
Block a user