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 %} + + + + + + + + + \ No newline at end of file diff --git a/app/templates/stiftung/jahresbericht.html b/app/templates/stiftung/jahresbericht.html index 0ad793e..54450bf 100644 --- a/app/templates/stiftung/jahresbericht.html +++ b/app/templates/stiftung/jahresbericht.html @@ -4,12 +4,13 @@ Stiftung – Jahresbericht {{ jahr }} -
-

Stiftung – Jahresbericht {{ jahr }}

-
Jahresübersicht über Förderungen und Verpachtungen
-
Erstellt am {{ "now"|date:"d.m.Y" }}
+ +
+ ← Berichte + | + + PDF herunterladen + +
- + +
+

Jahresbericht {{ jahr }}

+
van Hees-Theyssen-Vogel'sche Familienstiftung
+
Erstellt am {% now "d.m.Y" %}
+
+ +
-

Zusammenfassung

+

1. Jahresbilanz {{ jahr }}

+
+
+
€{{ total_einnahmen|floatformat:2 }}
+
Einnahmen (Pacht)
+
+
+
€{{ total_ausgaben|floatformat:2 }}
+
Ausgaben gesamt
+
+
+
{% if netto >= 0 %}+{% endif %}€{{ netto|floatformat:2 }}
+
Nettosaldo
+
+
-
€{{ total_foerderungen|floatformat:2 }}
-
Gesamtförderungen
+
€{{ total_ausgaben_foerderung|floatformat:2 }}
+
Förderausgaben
-
€{{ total_pachtzins|floatformat:2 }}
-
Gesamtpachtzins
+
€{{ total_verwaltungskosten|floatformat:2 }}
+
Verwaltungskosten
-
{{ foerderungen.count }}
-
Förderungen
+
€{{ pacht_vereinnahmt|floatformat:2 }}
+
Pacht vereinnahmt
-
{{ verpachtungen.count }}
-
Aktive Verpachtungen
+
€{{ grundsteuer_gesamt|floatformat:2 }}
+
Grundsteuer
- + + {% if unterstuetzungen %} +
+

2. Unterstützungszahlungen {{ jahr }}

+

+ {{ unterstuetzungen.count }} Unterstützung(en) geplant/ausgezahlt · + {{ unterstuetzungen_ausgezahlt.count }} überwiesen (€{{ total_unterstuetzungen|floatformat:2 }}) +

+ + + + + + + + + + + + {% for u in unterstuetzungen %} + + + + + + + + {% endfor %} + + + + + + + + +
DestinatärBetragFällig amStatusVerwendungszweck
{{ u.destinataer.get_full_name }}€{{ u.betrag|floatformat:2 }}{{ u.faellig_am|date:"d.m.Y" }} + {{ u.get_status_display }} + {{ u.beschreibung|default:"-" }}
Summe ausgezahlt€{{ total_unterstuetzungen|floatformat:2 }}
+
+ {% endif %} + + {% if foerderungen %}
-

Förderungen im Jahr {{ jahr }}

+

3. Förderungen {{ jahr }}

@@ -151,77 +223,144 @@ - {% for foerderung in foerderungen %} + {% for f in foerderungen %} - - - - + + + + {% endfor %} + + + + + + +
{{ foerderung.person.get_full_name }}{{ foerderung.get_kategorie_display }}€{{ foerderung.betrag|floatformat:2 }} - - {{ foerderung.get_status_display }} - + {% if f.destinataer %}{{ f.destinataer.get_full_name }} + {% elif f.person %}{{ f.person.get_full_name }} + {% else %}–{% endif %} {{ foerderung.antragsdatum|date:"d.m.Y" }}{{ f.get_kategorie_display }}€{{ f.betrag|floatformat:2 }} + {{ f.get_status_display }} + {{ f.antragsdatum|date:"d.m.Y" }}
Summe€{{ total_foerderungen_legacy|floatformat:2 }}
{% endif %} - - {% if verpachtungen %} +
-

Aktive Verpachtungen im Jahr {{ jahr }}

+

4. Grundstücksverwaltung

+ + {% if verpachtungen %} +

Aktive Verpachtungen

- - + - {% for verpachtung in verpachtungen %} + {% for v in verpachtungen %} - - - - - - + + + + + {% endfor %} + + + + + + + +
Länderei PächterVertragsnummer Verpachtete FlächeJährlicher PachtzinsJahrespachtzins Pachtende
{{ verpachtung.land }}{{ verpachtung.paechter.get_full_name }}{{ verpachtung.vertragsnummer }}{{ verpachtung.verpachtete_flaeche|floatformat:2 }} qm€{{ verpachtung.pachtzins_jaehrlich|floatformat:2 }}{{ verpachtung.pachtende|date:"d.m.Y" }}{{ v.land }}{{ v.paechter.get_full_name }}{{ v.verpachtete_flaeche|floatformat:0 }} qm€{{ v.pachtzins_pauschal|floatformat:2 }}{% if v.pachtende %}{{ v.pachtende|date:"d.m.Y" }}{% else %}unbefristet{% endif %}
Gesamtpachtzins (kalkuliert)€{{ total_pachtzins|floatformat:2 }}
+ {% endif %} + + {% if landabrechnungen %} +

Landabrechnungen {{ jahr }}

+ + + + + + + + + + + + {% for a in landabrechnungen %} + + + + + + + + {% endfor %} + + + + + + + + + + +
LändereiPacht vereinnahmtUmlagenGrundsteuerSonstige Einnahmen
{{ a.land }}€{{ a.pacht_vereinnahmt|floatformat:2 }}€{{ a.umlagen_vereinnahmt|floatformat:2 }}€{{ a.grundsteuer_betrag|floatformat:2 }}€{{ a.sonstige_einnahmen|floatformat:2 }}
Summe€{{ pacht_vereinnahmt|floatformat:2 }}€{{ grundsteuer_gesamt|floatformat:2 }}
+ {% endif %} + + {% if not verpachtungen and not landabrechnungen %} +

Keine Verpachtungs- oder Abrechnungsdaten für {{ jahr }} vorhanden.

+ {% endif %} +
+ + + {% if verwaltungskosten_nach_kategorie %} +
+

5. Verwaltungskosten {{ jahr }}

+ + + + + + + + + + {% for k in verwaltungskosten_nach_kategorie %} + + + + + + {% endfor %} + + + + + + +
KategorieAnzahlBetrag
{{ k.kategorie|capfirst }}{{ k.anzahl }}€{{ k.summe|floatformat:2 }}
Gesamt€{{ total_verwaltungskosten|floatformat:2 }}
{% endif %} - -
-

Finanzielle Übersicht

-
-
-
€{{ total_foerderungen|floatformat:2 }}
-
Ausgaben (Förderungen)
-
-
-
€{{ total_pachtzins|floatformat:2 }}
-
Einnahmen (Pachtzins)
-
-
-
€{{ total_pachtzins|add:total_foerderungen|floatformat:2 }}
-
Netto-Position
-
-
-
- - +