Feature: Daueraufträge in Ausgaben — monatlich/quartalsweise/jährlich, Scheduler, SW by-v605
This commit is contained in:
parent
a63a9ba197
commit
798289ae5a
9 changed files with 448 additions and 9 deletions
|
|
@ -1,7 +1,8 @@
|
|||
"""BAN YARO — Ausgaben-Tracker Routes"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
|
@ -33,6 +34,43 @@ class ExpenseUpdate(BaseModel):
|
|||
notiz: Optional[str] = None
|
||||
|
||||
|
||||
class RecurringCreate(BaseModel):
|
||||
dog_id: Optional[int] = None
|
||||
kategorie: str
|
||||
betrag: float
|
||||
haeufigkeit: str # monatlich | quartalsweise | jaehrlich
|
||||
startdatum: str # ISO date
|
||||
notiz: Optional[str] = None
|
||||
|
||||
class RecurringUpdate(BaseModel):
|
||||
dog_id: Optional[int] = None
|
||||
kategorie: Optional[str] = None
|
||||
betrag: Optional[float] = None
|
||||
haeufigkeit: Optional[str] = None
|
||||
startdatum: Optional[str] = None
|
||||
notiz: Optional[str] = None
|
||||
aktiv: Optional[bool] = None
|
||||
|
||||
|
||||
HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"}
|
||||
|
||||
|
||||
def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date:
|
||||
"""Berechnet das nächste Fälligkeitsdatum nach `after`."""
|
||||
d = date.fromisoformat(startdatum)
|
||||
if d > after:
|
||||
return d
|
||||
if haeufigkeit == "monatlich":
|
||||
delta = relativedelta(months=1)
|
||||
elif haeufigkeit == "quartalsweise":
|
||||
delta = relativedelta(months=3)
|
||||
else:
|
||||
delta = relativedelta(years=1)
|
||||
while d <= after:
|
||||
d += delta
|
||||
return d
|
||||
|
||||
|
||||
def _serialize(row) -> dict:
|
||||
return dict(row)
|
||||
|
||||
|
|
@ -226,3 +264,133 @@ async def delete_expense(expense_id: int, user=Depends(get_current_user)):
|
|||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Wiederkehrende Ausgaben
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/recurring")
|
||||
async def list_recurring(user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT r.*, d.name AS dog_name
|
||||
FROM recurring_expenses r
|
||||
LEFT JOIN dogs d ON d.id = r.dog_id
|
||||
WHERE r.user_id=? ORDER BY r.startdatum DESC""",
|
||||
(user["id"],),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/recurring", status_code=201)
|
||||
async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)):
|
||||
if data.kategorie not in KATEGORIEN:
|
||||
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
|
||||
if data.haeufigkeit not in HAEUFIGKEITEN:
|
||||
raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}")
|
||||
if data.betrag <= 0:
|
||||
raise HTTPException(400, "Betrag muss größer als 0 sein.")
|
||||
|
||||
today = date.today()
|
||||
naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1))
|
||||
|
||||
with db() as conn:
|
||||
if data.dog_id:
|
||||
if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?",
|
||||
(data.dog_id, user["id"])).fetchone():
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
conn.execute(
|
||||
"""INSERT INTO recurring_expenses
|
||||
(user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz)
|
||||
VALUES (?,?,?,?,?,?,?,?)""",
|
||||
(user["id"], data.dog_id, data.kategorie, data.betrag,
|
||||
data.haeufigkeit, data.startdatum, str(naechste), data.notiz),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.patch("/recurring/{rid}")
|
||||
async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"])
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Dauerauftrag nicht gefunden.")
|
||||
updates: dict = {}
|
||||
if data.kategorie is not None:
|
||||
if data.kategorie not in KATEGORIEN:
|
||||
raise HTTPException(400, f"Ungültige Kategorie.")
|
||||
updates["kategorie"] = data.kategorie
|
||||
if data.betrag is not None:
|
||||
updates["betrag"] = data.betrag
|
||||
if data.haeufigkeit is not None:
|
||||
if data.haeufigkeit not in HAEUFIGKEITEN:
|
||||
raise HTTPException(400, "Ungültige Häufigkeit.")
|
||||
updates["haeufigkeit"] = data.haeufigkeit
|
||||
if data.startdatum is not None:
|
||||
updates["startdatum"] = data.startdatum
|
||||
if data.notiz is not None:
|
||||
updates["notiz"] = data.notiz
|
||||
if data.aktiv is not None:
|
||||
updates["aktiv"] = 1 if data.aktiv else 0
|
||||
if updates:
|
||||
# naechste_faelligkeit neu berechnen wenn relevante Felder geändert
|
||||
startdatum = updates.get("startdatum", row["startdatum"])
|
||||
haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"])
|
||||
today = date.today()
|
||||
updates["naechste_faelligkeit"] = str(
|
||||
_next_due(startdatum, haeufigkeit, today - timedelta(days=1))
|
||||
)
|
||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||
conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?",
|
||||
[*updates.values(), rid])
|
||||
row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/recurring/{rid}", status_code=204)
|
||||
async def delete_recurring(rid: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?",
|
||||
(rid, user["id"])).fetchone():
|
||||
raise HTTPException(404, "Dauerauftrag nicht gefunden.")
|
||||
conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,))
|
||||
return None
|
||||
|
||||
|
||||
def process_due_recurring(user_id: int | None = None):
|
||||
"""Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen."""
|
||||
today = date.today()
|
||||
today_str = str(today)
|
||||
with db() as conn:
|
||||
where = "aktiv=1 AND naechste_faelligkeit <= ?"
|
||||
params: list = [today_str]
|
||||
if user_id:
|
||||
where += " AND user_id=?"
|
||||
params.append(user_id)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM recurring_expenses WHERE {where}", params
|
||||
).fetchall()
|
||||
|
||||
for r in rows:
|
||||
# Eintrag anlegen
|
||||
conn.execute(
|
||||
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(r["user_id"], r["dog_id"], r["kategorie"], r["betrag"],
|
||||
r["naechste_faelligkeit"],
|
||||
f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"),
|
||||
)
|
||||
# Nächste Fälligkeit berechnen
|
||||
naechste = _next_due(r["startdatum"], r["haeufigkeit"],
|
||||
date.fromisoformat(r["naechste_faelligkeit"]))
|
||||
conn.execute(
|
||||
"UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?",
|
||||
(str(naechste), r["id"]),
|
||||
)
|
||||
return len(rows) if rows else 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue