Phase 4: SEPA-Validierung (schwifty), Globale Suche (Cmd+K) & Jahresbericht-Modul

- SEPA-Export: IBAN/BIC-Validierung via schwifty, Schuldner-Konto aus StiftungsKonto
- Globale Suche: Cmd+K Modal über Destinatäre, Pächter, Ländereien, Förderungen, Dokumente
- Jahresbericht: Vollständige Jahresbilanz mit Einnahmen/Ausgaben/Netto, Unterstützungen,
  Landabrechnungen, Verwaltungskosten nach Kategorie, PDF-Export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-11 12:57:36 +00:00
parent a79a0989d6
commit 2be72c3990
8 changed files with 695 additions and 160 deletions

View File

@@ -131,6 +131,7 @@ from .system import ( # noqa: F401
GrampsClient,
get_gramps_client,
gramps_debug_api,
globale_suche_api,
csv_import_list,
csv_import_create,
process_personen_csv,

View File

@@ -86,29 +86,86 @@ def bericht_list(request):
return render(request, "stiftung/bericht_list.html", context)
@login_required
def jahresbericht_generate(request, jahr):
"""Generate annual report for a specific year"""
# Get data for the year
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person")
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr
).select_related("land", "paechter")
# Calculate statistics
total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
total_pachtzins = (
verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
def _jahresbericht_context(jahr):
"""Phase 4: Aggregiert alle Daten für den Jahresbericht."""
from stiftung.models import (
DestinataerUnterstuetzung, LandAbrechnung, Verwaltungskosten,
)
context = {
# Förderungen (legacy)
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("destinataer", "person")
total_foerderungen_legacy = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
# Unterstützungen (Phase 2 neue Pipeline)
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
faellig_am__year=jahr
).exclude(status="storniert").select_related("destinataer", "konto")
unterstuetzungen_ausgezahlt = unterstuetzungen.filter(
status__in=["ausgezahlt", "abgeschlossen"]
)
total_unterstuetzungen = unterstuetzungen_ausgezahlt.aggregate(total=Sum("betrag"))["total"] or 0
# Gesamtausgaben Förderung
total_ausgaben_foerderung = total_foerderungen_legacy + total_unterstuetzungen
# Verpachtungen
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr
).filter(
Q(pachtende__isnull=True) | Q(pachtende__year__gte=jahr)
).select_related("land", "paechter")
total_pachtzins = verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
# Landabrechnungen für das Jahr
landabrechnungen = LandAbrechnung.objects.filter(
abrechnungsjahr=jahr
).select_related("land")
pacht_vereinnahmt = landabrechnungen.aggregate(total=Sum("pacht_vereinnahmt"))["total"] or 0
grundsteuer_gesamt = landabrechnungen.aggregate(total=Sum("grundsteuer_betrag"))["total"] or 0
# Verwaltungskosten
verwaltungskosten_qs = Verwaltungskosten.objects.filter(datum__year=jahr)
total_verwaltungskosten = verwaltungskosten_qs.aggregate(total=Sum("betrag"))["total"] or 0
verwaltungskosten_nach_kategorie = (
verwaltungskosten_qs
.values("kategorie")
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
.order_by("-summe")
)
# Gesamtbilanz
total_einnahmen = pacht_vereinnahmt if pacht_vereinnahmt else total_pachtzins
total_ausgaben = total_ausgaben_foerderung + total_verwaltungskosten
netto = total_einnahmen - total_ausgaben
return {
"jahr": jahr,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
"total_foerderungen": total_foerderungen,
"total_pachtzins": total_pachtzins,
"title": f"Jahresbericht {jahr}",
"foerderungen": foerderungen,
"total_foerderungen_legacy": total_foerderungen_legacy,
"unterstuetzungen": unterstuetzungen,
"unterstuetzungen_ausgezahlt": unterstuetzungen_ausgezahlt,
"total_unterstuetzungen": total_unterstuetzungen,
"total_ausgaben_foerderung": total_ausgaben_foerderung,
"verpachtungen": verpachtungen,
"total_pachtzins": total_pachtzins,
"landabrechnungen": landabrechnungen,
"pacht_vereinnahmt": pacht_vereinnahmt,
"grundsteuer_gesamt": grundsteuer_gesamt,
"verwaltungskosten_nach_kategorie": verwaltungskosten_nach_kategorie,
"total_verwaltungskosten": total_verwaltungskosten,
"total_einnahmen": total_einnahmen,
"total_ausgaben": total_ausgaben,
"netto": netto,
# Rückwärtskompatibilität
"total_foerderungen": total_ausgaben_foerderung,
}
@login_required
def jahresbericht_generate(request, jahr):
"""Phase 4: Jahresbericht mit aggregierten Finanzdaten."""
context = _jahresbericht_context(jahr)
return render(request, "stiftung/jahresbericht.html", context)
@@ -124,30 +181,12 @@ def jahresbericht_generate_redirect(request):
@login_required
def jahresbericht_pdf(request, jahr):
"""Generate PDF version of annual report"""
"""Phase 4: PDF-Export des Jahresberichts."""
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML
# Get data for the year
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person")
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr
).select_related("land", "paechter")
# Calculate statistics
total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
total_pachtzins = (
verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
)
context = {
"jahr": jahr,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
"total_foerderungen": total_foerderungen,
"total_pachtzins": total_pachtzins,
}
context = _jahresbericht_context(jahr)
# Render HTML
html_string = render_to_string("stiftung/jahresbericht.html", context)

View File

@@ -2137,3 +2137,94 @@ from stiftung.models import GeschichteSeite, GeschichteBild
from stiftung.forms import GeschichteSeiteForm, GeschichteBildForm
# =============================================================================
# Phase 4: GLOBALE SUCHE (Cmd+K)
# =============================================================================
@login_required
def globale_suche_api(request):
"""Phase 4: AJAX-Endpunkt für globale Suche über alle Bereiche."""
from django.http import JsonResponse
from stiftung.models import (
Destinataer, Paechter, Land, LandVerpachtung, Foerderung,
)
from stiftung.models.dokumente import DokumentDatei
q = request.GET.get("q", "").strip()
if len(q) < 2:
return JsonResponse({"results": []})
results = []
# Destinatäre
for d in Destinataer.objects.filter(
Q(vorname__icontains=q) | Q(nachname__icontains=q) | Q(email__icontains=q)
)[:5]:
results.append({
"typ": "Destinatär",
"titel": d.get_full_name(),
"untertitel": d.email or "",
"url": f"/destinataere/{d.pk}/",
"icon": "fas fa-user",
})
# Pächter
for p in Paechter.objects.filter(
Q(vorname__icontains=q) | Q(nachname__icontains=q) | Q(email__icontains=q)
)[:5]:
results.append({
"typ": "Pächter",
"titel": p.get_full_name(),
"untertitel": p.email or "",
"url": f"/paechter/{p.pk}/",
"icon": "fas fa-user-tie",
})
# Ländereien
for l in Land.objects.filter(
Q(gemeinde__icontains=q) | Q(gemarkung__icontains=q) | Q(lfd_nr__icontains=q)
)[:5]:
results.append({
"typ": "Länderei",
"titel": str(l),
"untertitel": l.gemeinde or "",
"url": f"/laendereien/{l.pk}/",
"icon": "fas fa-map",
})
# Förderungen suche über Destinatär-Name oder Bemerkungen
for f in Foerderung.objects.filter(
Q(destinataer__vorname__icontains=q)
| Q(destinataer__nachname__icontains=q)
| Q(bemerkungen__icontains=q)
).select_related("destinataer", "person")[:5]:
empfaenger = (
f.destinataer.get_full_name() if f.destinataer
else (f.person.get_full_name() if f.person else "Unbekannt")
)
results.append({
"typ": "Förderung",
"titel": f"{empfaenger} ({f.jahr})",
"untertitel": f"{f.betrag} · {f.get_status_display()}",
"url": f"/foerderungen/{f.pk}/",
"icon": "fas fa-gift",
})
# Dokumente (DMS)
try:
for dok in DokumentDatei.objects.filter(
Q(titel__icontains=q) | Q(beschreibung__icontains=q)
)[:5]:
results.append({
"typ": "Dokument",
"titel": dok.titel,
"untertitel": dok.get_kontext_display(),
"url": f"/dms/{dok.pk}/",
"icon": "fas fa-file-alt",
})
except Exception:
pass
return JsonResponse({"results": results, "query": q})

View File

@@ -1768,9 +1768,14 @@ def unterstuetzung_abschliessen(request, pk):
@login_required
def sepa_xml_export(request):
"""2c: SEPA pain.001 XML-Export für freigegebene Zahlungen."""
"""Phase 4: SEPA pain.001 XML-Export mit schwifty IBAN/BIC-Validierung."""
from xml.etree.ElementTree import Element, SubElement, tostring
import xml.dom.minidom
try:
from schwifty import IBAN as SchwiftyIBAN, BIC as SchwiftyBIC
schwifty_available = True
except ImportError:
schwifty_available = False
zahlungen = DestinataerUnterstuetzung.objects.filter(
status="freigegeben"
@@ -1780,10 +1785,32 @@ def sepa_xml_export(request):
messages.warning(request, "Keine freigegebenen Zahlungen für SEPA-Export vorhanden.")
return redirect("stiftung:zahlungs_pipeline")
# IBAN/BIC-Validierung mit schwifty
validierungsfehler = []
if schwifty_available:
for zahlung in zahlungen:
iban_raw = (zahlung.empfaenger_iban or zahlung.destinataer.iban or "").replace(" ", "")
if not iban_raw:
validierungsfehler.append(
f"{zahlung.destinataer.get_full_name()}: Keine IBAN hinterlegt"
)
continue
try:
SchwiftyIBAN(iban_raw)
except Exception:
validierungsfehler.append(
f"{zahlung.destinataer.get_full_name()}: Ungültige IBAN '{iban_raw}'"
)
if validierungsfehler:
for fehler in validierungsfehler:
messages.error(request, f"SEPA-Validierungsfehler: {fehler}")
return redirect("stiftung:zahlungs_pipeline")
heute = date.today()
msg_id = f"STIFTUNG-{heute.strftime('%Y%m%d%H%M%S')}"
nb_of_txs = zahlungen.count()
ctrl_sum = str(sum(z.betrag for z in zahlungen))
ctrl_sum = f"{sum(z.betrag for z in zahlungen):.2f}"
root = Element("Document", {
"xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03",
@@ -1810,18 +1837,34 @@ def sepa_xml_export(request):
dbtr = SubElement(pmt_inf, "Dbtr")
SubElement(dbtr, "Nm").text = "van Hees-Theyssen-Vogel'sche Stiftung"
# Schuldner-IBAN aus aktivem Stiftungskonto
if zahlungen.first() and zahlungen.first().konto and zahlungen.first().konto.iban:
dbtr_acct = SubElement(pmt_inf, "DbtrAcct")
dbtr_acct_id = SubElement(dbtr_acct, "Id")
SubElement(dbtr_acct_id, "IBAN").text = zahlungen.first().konto.iban.replace(" ", "")
dbtr_agt = SubElement(pmt_inf, "DbtrAgt")
fin_instn_id = SubElement(dbtr_agt, "FinInstnId")
bic_val = zahlungen.first().konto.bic.strip()
if schwifty_available and bic_val:
try:
bic_val = str(SchwiftyBIC(bic_val))
except Exception:
pass
SubElement(fin_instn_id, "BIC").text = bic_val or "NOTPROVIDED"
for zahlung in zahlungen:
cdt_trf = SubElement(pmt_inf, "CdtTrfTxInf")
pmt_id_el = SubElement(cdt_trf, "PmtId")
SubElement(pmt_id_el, "EndToEndId").text = str(zahlung.id)[:35]
amt = SubElement(cdt_trf, "Amt")
instd_amt = SubElement(amt, "InstdAmt", {"Ccy": "EUR"})
instd_amt.text = str(zahlung.betrag)
instd_amt.text = f"{zahlung.betrag:.2f}"
cdtr = SubElement(cdt_trf, "Cdtr")
SubElement(cdtr, "Nm").text = (zahlung.empfaenger_name or zahlung.destinataer.get_full_name())[:70]
cdtr_acct = SubElement(cdt_trf, "CdtrAcct")
cdtr_id = SubElement(cdtr_acct, "Id")
SubElement(cdtr_id, "IBAN").text = (zahlung.empfaenger_iban or zahlung.destinataer.iban or "").replace(" ", "")
iban_clean = (zahlung.empfaenger_iban or zahlung.destinataer.iban or "").replace(" ", "")
SubElement(cdtr_id, "IBAN").text = iban_clean
rmt_inf = SubElement(cdt_trf, "RmtInf")
SubElement(rmt_inf, "Ustrd").text = (zahlung.verwendungszweck or zahlung.beschreibung or "Stiftungsunterstützung")[:140]