diff --git a/backend/routes/admin.py b/backend/routes/admin.py index a8fa045..d12f9eb 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -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 1–4 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 1–4 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"
{body_stb}
", + 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"
{body_stb}
", + 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 " 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"
{body_rene}
", + 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), + }