Feat: Admin-Rechnungs-Endpoints — invoices_unpaid, CSV-Download, Quartalsbericht-Versand
- action_items(): invoices_unpaid (status='sent') zum Return-Dict hinzugefügt
- GET /api/admin/invoices/quarterly/{year}/{q}/csv — CSV-Download (Semikolon-getrennt, UTF-8-BOM für Excel)
- POST /api/admin/invoices/send-quarterly-report — sendet CSV-Anhang an Steuerberater + Zusammenfassung an René (SMTP_FROM); graceful fallback wenn attachments noch nicht von mailer.py unterstützt
This commit is contained in:
parent
ebff9d820d
commit
77093774f9
1 changed files with 205 additions and 1 deletions
|
|
@ -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"<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),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue