diff --git a/app/requirements.txt b/app/requirements.txt index 7b44ac5..9357343 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -13,3 +13,4 @@ markdown==3.6 django-otp==1.2.4 django-htmx==1.19.0 qrcode[pil]==7.4.2 +schwifty==2026.3.0 diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index f8272f4..5b16440 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -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"), diff --git a/app/stiftung/views/__init__.py b/app/stiftung/views/__init__.py index 044f431..1d81ee7 100644 --- a/app/stiftung/views/__init__.py +++ b/app/stiftung/views/__init__.py @@ -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, diff --git a/app/stiftung/views/finanzen.py b/app/stiftung/views/finanzen.py index 6d60671..57b6481 100644 --- a/app/stiftung/views/finanzen.py +++ b/app/stiftung/views/finanzen.py @@ -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) diff --git a/app/stiftung/views/system.py b/app/stiftung/views/system.py index bb7044d..23a2032 100644 --- a/app/stiftung/views/system.py +++ b/app/stiftung/views/system.py @@ -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}) + + diff --git a/app/stiftung/views/unterstuetzungen.py b/app/stiftung/views/unterstuetzungen.py index 196d62d..7824d45 100644 --- a/app/stiftung/views/unterstuetzungen.py +++ b/app/stiftung/views/unterstuetzungen.py @@ -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] diff --git a/app/templates/base.html b/app/templates/base.html index 9749a84..90302c1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -777,5 +777,223 @@ {% block javascript %}{% endblock %} + + +
+ + + + + +