Feat: Admin action-items invoices_unpaid, CSV-Download, Quartalsbericht-Versand

This commit is contained in:
rene 2026-05-15 10:06:04 +02:00
commit 5dddacff96

View file

@ -1,5 +1,8 @@
"""BAN YARO — Admin / Moderator Backend"""
import asyncio
import csv
import io
import logging
import os
import sys
import time
@ -8,11 +11,14 @@ from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel
from typing import Optional
from typing import Optional, List
from database import db, DB_PATH
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
_TZ = ZoneInfo("Europe/Berlin")
@ -83,6 +89,11 @@ def require_admin(user=Depends(get_current_user)):
# ------------------------------------------------------------------
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
class QuarterlyReportBody(BaseModel):
year: int
quarter: int
email: str
class UserPatch(BaseModel):
rolle: Optional[str] = None # user | moderator | admin
is_moderator: Optional[int] = None
@ -130,6 +141,12 @@ async def action_items(user=Depends(require_mod)):
).fetchone()[0]
except Exception:
upgrades_pending = 0
try:
invoices_unpaid = conn.execute(
"SELECT COUNT(*) FROM invoices WHERE status='sent'"
).fetchone()[0]
except Exception:
invoices_unpaid = 0
return {
"jobs_pending": jobs,
"breeder_pending": breeders,
@ -138,6 +155,7 @@ async def action_items(user=Depends(require_mod)):
"poi_edits_pending": poi_edits,
"users_today": users_today,
"upgrades_pending": upgrades_pending,
"invoices_unpaid": invoices_unpaid,
}
@ -1242,3 +1260,189 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
return {"ok": True, "tier": req["tier"], "user": req["name"]}
# ------------------------------------------------------------------
# Helpers: Quartalsdaten
# ------------------------------------------------------------------
def _quarter_bounds(year: int, q: int):
"""Gibt (start_date, end_date) als ISO-Strings zurück (YYYY-MM-DD)."""
if q not in (1, 2, 3, 4):
raise HTTPException(400, "Quartal muss 14 sein.")
month_start = (q - 1) * 3 + 1
month_end = month_start + 2
# Letzter Tag des Endmonats
import calendar
last_day = calendar.monthrange(year, month_end)[1]
return (
f"{year:04d}-{month_start:02d}-01",
f"{year:04d}-{month_end:02d}-{last_day:02d}",
)
def _fetch_quarter_invoices(conn, year: int, q: int):
"""Liest alle bezahlten/gesendeten Rechnungen des Quartals."""
start, end = _quarter_bounds(year, q)
rows = conn.execute("""
SELECT invoice_number, created_at, recipient_name, recipient_email,
amount_net, tax_amount, amount_gross,
status, paid_at, paid_amount
FROM invoices
WHERE status IN ('paid', 'sent')
AND DATE(created_at) BETWEEN ? AND ?
ORDER BY created_at ASC
""", (start, end)).fetchall()
return rows, start, end
def _build_csv(rows) -> bytes:
"""Erstellt CSV-Bytes aus den Rechnungszeilen."""
buf = io.StringIO()
writer = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_MINIMAL)
writer.writerow([
"Rechnungsnummer", "Datum", "Empfänger", "E-Mail",
"Nettobetrag", "Steuer", "Bruttobetrag",
"Status", "Bezahlt-am", "Gezahlter-Betrag",
])
for r in rows:
# Datum auf YYYY-MM-DD kürzen
datum = (r["created_at"] or "")[:10]
paid_at = (r["paid_at"] or "")[:10] if r["paid_at"] else ""
writer.writerow([
r["invoice_number"],
datum,
r["recipient_name"],
r["recipient_email"],
f"{r['amount_net']:.2f}".replace(".", ","),
f"{r['tax_amount']:.2f}".replace(".", ","),
f"{r['amount_gross']:.2f}".replace(".", ","),
r["status"],
paid_at,
f"{r['paid_amount']:.2f}".replace(".", ",") if r["paid_amount"] is not None else "",
])
return buf.getvalue().encode("utf-8-sig") # BOM für Excel-Kompatibilität
# ------------------------------------------------------------------
# GET /api/admin/invoices/quarterly/{year}/{q}/csv
# ------------------------------------------------------------------
@router.get("/invoices/quarterly/{year}/{q}/csv")
async def invoice_quarterly_csv(year: int, q: int, user=Depends(require_admin)):
"""CSV-Download aller Rechnungen eines Quartals (paid + sent)."""
with db() as conn:
rows, start, end = _fetch_quarter_invoices(conn, year, q)
csv_bytes = _build_csv(rows)
filename = f"rechnungen_{year}_Q{q}.csv"
logger.info(f"CSV-Download Q{q}/{year}: {len(rows)} Rechnungen → {filename}")
return Response(
content=csv_bytes,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ------------------------------------------------------------------
# POST /api/admin/invoices/send-quarterly-report
# ------------------------------------------------------------------
@router.post("/invoices/send-quarterly-report")
async def send_quarterly_report(data: QuarterlyReportBody, user=Depends(require_admin)):
"""Sendet Quartalsbericht als CSV-Anhang an Steuerberater + Zusammenfassung an René."""
if data.quarter not in (1, 2, 3, 4):
raise HTTPException(400, "Quartal muss 14 sein.")
with db() as conn:
rows, start, end = _fetch_quarter_invoices(conn, data.year, data.quarter)
csv_bytes = _build_csv(rows)
filename = f"rechnungen_{data.year}_Q{data.quarter}.csv"
# Zusammenfassungs-Zahlen
total_net = sum(r["amount_net"] for r in rows)
total_tax = sum(r["tax_amount"] for r in rows)
total_gross = sum(r["amount_gross"] for r in rows)
count_paid = sum(1 for r in rows if r["status"] == "paid")
count_sent = sum(1 for r in rows if r["status"] == "sent")
subject_stb = (
f"Ban Yaro Rechnungen Q{data.quarter}/{data.year} "
f"({start} bis {end})"
)
body_stb = (
f"Hallo,\n\n"
f"anbei die Rechnungsübersicht für Q{data.quarter}/{data.year} "
f"({start} bis {end}).\n\n"
f"Anzahl Rechnungen: {len(rows)}\n"
f" davon bezahlt: {count_paid}\n"
f" davon ausstehend: {count_sent}\n\n"
f"Summe Netto: {total_net:>10.2f} EUR\n"
f"Summe Steuer: {total_tax:>10.2f} EUR\n"
f"Summe Brutto: {total_gross:>10.2f} EUR\n\n"
f"Die vollständige Liste finden Sie als CSV-Anhang.\n\n"
f"Viele Grüße\nRené Nitzsche / Ban Yaro"
)
from mailer import send_email, SMTP_FROM
# Steuerberater-Mail (mit CSV-Anhang wenn unterstützt)
try:
await send_email(
data.email,
subject_stb,
f"<pre style='font-family:monospace'>{body_stb}</pre>",
body_stb,
attachments=[{
"filename": filename,
"content": csv_bytes,
"content_type": "text/csv",
}],
)
logger.info(f"Quartalsbericht Q{data.quarter}/{data.year}{data.email} (mit Anhang)")
except TypeError:
# send_email unterstützt noch kein attachments-Argument → ohne Anhang senden
await send_email(
data.email,
subject_stb,
f"<pre style='font-family:monospace'>{body_stb}</pre>",
body_stb,
)
logger.warning(f"Quartalsbericht Q{data.quarter}/{data.year}{data.email} (OHNE Anhang, attachments nicht unterstützt)")
# Zusammenfassung an René (SMTP_FROM-Adresse)
# Reine E-Mail-Adresse aus "Name <addr>" extrahieren
from_addr = SMTP_FROM
if "<" in from_addr:
from_addr = from_addr[from_addr.index("<") + 1 : from_addr.index(">")].strip()
subject_rene = f"[Ban Yaro Admin] Quartalsbericht Q{data.quarter}/{data.year} versendet"
body_rene = (
f"Der Quartalsbericht Q{data.quarter}/{data.year} wurde an {data.email} gesendet.\n\n"
f"Zeitraum: {start} bis {end}\n"
f"Rechnungen gesamt: {len(rows)} (bezahlt: {count_paid}, ausstehend: {count_sent})\n\n"
f"Netto: {total_net:>10.2f} EUR\n"
f"Steuer: {total_tax:>10.2f} EUR\n"
f"Brutto: {total_gross:>10.2f} EUR\n"
)
try:
await send_email(
from_addr,
subject_rene,
f"<pre style='font-family:monospace'>{body_rene}</pre>",
body_rene,
)
except Exception as e:
logger.warning(f"Zusammenfassungs-Mail an René fehlgeschlagen: {e}")
return {
"ok": True,
"sent_to": data.email,
"year": data.year,
"quarter": data.quarter,
"period": f"{start} {end}",
"count": len(rows),
"count_paid": count_paid,
"count_sent": count_sent,
"total_net": round(total_net, 2),
"total_tax": round(total_tax, 2),
"total_gross": round(total_gross, 2),
}