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:
@@ -343,6 +343,9 @@ urlpatterns = [
|
||||
# Hilfsbox URLs
|
||||
path("help-box/edit/", views.edit_help_box, name="edit_help_box"),
|
||||
path("help-box/admin/", views.edit_help_box, name="help_boxes_admin"),
|
||||
# Phase 4: Globale Suche (Cmd+K)
|
||||
path("api/suche/", views.globale_suche_api, name="globale_suche_api"),
|
||||
|
||||
# API URLs
|
||||
path("api/land-stats/", views.land_stats_api, name="land_stats_api"),
|
||||
path("api/health/", views.health_check, name="health_check"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user