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"""
|
"""BAN YARO — Admin / Moderator Backend"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
@ -8,11 +11,14 @@ from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from database import db, DB_PATH
|
from database import db, DB_PATH
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_TZ = ZoneInfo("Europe/Berlin")
|
_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"}
|
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
|
||||||
|
|
||||||
|
class QuarterlyReportBody(BaseModel):
|
||||||
|
year: int
|
||||||
|
quarter: int
|
||||||
|
email: str
|
||||||
|
|
||||||
class UserPatch(BaseModel):
|
class UserPatch(BaseModel):
|
||||||
rolle: Optional[str] = None # user | moderator | admin
|
rolle: Optional[str] = None # user | moderator | admin
|
||||||
is_moderator: Optional[int] = None
|
is_moderator: Optional[int] = None
|
||||||
|
|
@ -130,6 +141,12 @@ async def action_items(user=Depends(require_mod)):
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
upgrades_pending = 0
|
upgrades_pending = 0
|
||||||
|
try:
|
||||||
|
invoices_unpaid = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM invoices WHERE status='sent'"
|
||||||
|
).fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
invoices_unpaid = 0
|
||||||
return {
|
return {
|
||||||
"jobs_pending": jobs,
|
"jobs_pending": jobs,
|
||||||
"breeder_pending": breeders,
|
"breeder_pending": breeders,
|
||||||
|
|
@ -138,6 +155,7 @@ async def action_items(user=Depends(require_mod)):
|
||||||
"poi_edits_pending": poi_edits,
|
"poi_edits_pending": poi_edits,
|
||||||
"users_today": users_today,
|
"users_today": users_today,
|
||||||
"upgrades_pending": upgrades_pending,
|
"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}")
|
logging.getLogger(__name__).warning(f"Bestätigungsmail fehlgeschlagen: {e}")
|
||||||
|
|
||||||
return {"ok": True, "tier": req["tier"], "user": req["name"]}
|
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