diff --git a/app/stiftung/migrations/0047_phase2_zahlungs_pipeline.py b/app/stiftung/migrations/0047_phase2_zahlungs_pipeline.py new file mode 100644 index 0000000..f2bacd7 --- /dev/null +++ b/app/stiftung/migrations/0047_phase2_zahlungs_pipeline.py @@ -0,0 +1,45 @@ +# Generated by Django 5.0.6 on 2026-03-11 10:39 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stiftung', '0046_briefvorlage_model'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='applicationpermission', + options={'default_permissions': (), 'managed': False, 'permissions': [('manage_destinataere', 'Kann Destinatäre verwalten'), ('view_destinataere', 'Kann Destinatäre anzeigen'), ('manage_land', 'Kann Ländereien verwalten'), ('view_land', 'Kann Ländereien anzeigen'), ('manage_paechter', 'Kann Pächter verwalten'), ('view_paechter', 'Kann Pächter anzeigen'), ('manage_verpachtungen', 'Kann Verpachtungen verwalten'), ('view_verpachtungen', 'Kann Verpachtungen anzeigen'), ('manage_foerderungen', 'Kann Förderungen verwalten'), ('view_foerderungen', 'Kann Förderungen anzeigen'), ('manage_documents', 'Kann Dokumente verwalten'), ('view_documents', 'Kann Dokumente anzeigen'), ('link_documents', 'Kann Dokumente verknüpfen'), ('manage_verwaltungskosten', 'Kann Verwaltungskosten verwalten'), ('view_verwaltungskosten', 'Kann Verwaltungskosten anzeigen'), ('approve_payments', 'Kann Zahlungen genehmigen'), ('manage_konten', 'Kann Stiftungskonten verwalten'), ('view_konten', 'Kann Stiftungskonten anzeigen'), ('manage_rentmeister', 'Kann Rentmeister verwalten'), ('view_rentmeister', 'Kann Rentmeister anzeigen'), ('access_administration', 'Kann Administration aufrufen'), ('view_audit_logs', 'Kann Audit-Logs anzeigen'), ('manage_backups', 'Kann Backups erstellen und verwalten'), ('manage_users', 'Kann Benutzer verwalten'), ('manage_permissions', 'Kann Berechtigungen verwalten'), ('manage_veranstaltungen', 'Kann Veranstaltungen verwalten'), ('view_veranstaltungen', 'Kann Veranstaltungen anzeigen'), ('import_data', 'Kann Daten importieren'), ('export_data', 'Kann Daten exportieren'), ('access_django_admin', 'Kann Django Admin aufrufen'), ('view_system_stats', 'Kann Systemstatistiken anzeigen')]}, + ), + migrations.AddField( + model_name='destinataerunterstuetzung', + name='erstellt_von', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='erstellte_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von'), + ), + migrations.AddField( + model_name='destinataerunterstuetzung', + name='freigegeben_am', + field=models.DateField(blank=True, null=True, verbose_name='Freigegeben am'), + ), + migrations.AddField( + model_name='destinataerunterstuetzung', + name='freigegeben_von', + field=models.ForeignKey(blank=True, help_text='Muss ein anderer Nutzer als der Ersteller sein (Vier-Augen-Prinzip)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='freigegebene_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Freigegeben von (4-Augen)'), + ), + migrations.AlterField( + model_name='destinataerunterstuetzung', + name='ausgezahlt_von', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ausgezahlte_unterstuetzungen', to=settings.AUTH_USER_MODEL, verbose_name='Ausgezahlt von'), + ), + migrations.AlterField( + model_name='destinataerunterstuetzung', + name='status', + field=models.CharField(choices=[('geplant', 'Offen'), ('faellig', 'Fällig'), ('nachweis_eingereicht', 'Nachweis eingereicht'), ('freigegeben', 'Freigegeben (4-Augen)'), ('in_bearbeitung', 'In Bearbeitung'), ('ausgezahlt', 'Überwiesen'), ('abgeschlossen', 'Abgeschlossen'), ('storniert', 'Storniert')], default='geplant', max_length=20, verbose_name='Status'), + ), + ] diff --git a/app/stiftung/models/destinataere.py b/app/stiftung/models/destinataere.py index 03f0cbb..0c9ce06 100644 --- a/app/stiftung/models/destinataere.py +++ b/app/stiftung/models/destinataere.py @@ -346,13 +346,25 @@ class DestinataerUnterstuetzung(models.Model): """Geplante/ausgeführte Unterstützungszahlungen an Destinatäre""" STATUS_CHOICES = [ - ("geplant", "Geplant"), + ("geplant", "Offen"), ("faellig", "Fällig"), + ("nachweis_eingereicht", "Nachweis eingereicht"), + ("freigegeben", "Freigegeben (4-Augen)"), ("in_bearbeitung", "In Bearbeitung"), - ("ausgezahlt", "Ausgezahlt"), + ("ausgezahlt", "Überwiesen"), + ("abgeschlossen", "Abgeschlossen"), ("storniert", "Storniert"), ] + # Pipeline-stage für Zahlungsstatus-Anzeige + PIPELINE_STAGES = [ + ("geplant", "Offen"), + ("nachweis_eingereicht", "Nachweis eingereicht"), + ("freigegeben", "Freigegeben"), + ("ausgezahlt", "Überwiesen"), + ("abgeschlossen", "Abgeschlossen"), + ] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) destinataer = models.ForeignKey( "Destinataer", @@ -392,9 +404,32 @@ class DestinataerUnterstuetzung(models.Model): on_delete=models.SET_NULL, null=True, blank=True, + related_name="ausgezahlte_unterstuetzungen", verbose_name="Ausgezahlt von", ) + # 4-Augen-Prinzip: Freigabe durch zweiten Nutzer + freigegeben_am = models.DateField( + null=True, blank=True, verbose_name="Freigegeben am" + ) + freigegeben_von = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="freigegebene_unterstuetzungen", + verbose_name="Freigegeben von (4-Augen)", + help_text="Muss ein anderer Nutzer als der Ersteller sein (Vier-Augen-Prinzip)", + ) + erstellt_von = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="erstellte_unterstuetzungen", + verbose_name="Erstellt von", + ) + # Link to recurrent payment template if this was auto-generated wiederkehrend_von = models.ForeignKey( "UnterstuetzungWiederkehrend", @@ -431,7 +466,29 @@ class DestinataerUnterstuetzung(models.Model): def can_be_marked_paid(self): """Check if payment can be marked as paid""" - return self.status in ["geplant", "faellig", "in_bearbeitung"] + return self.status in ["geplant", "faellig", "nachweis_eingereicht", "freigegeben", "in_bearbeitung"] + + def can_be_freigegeben(self, requesting_user): + """4-Augen: Freigabe nur durch anderen Nutzer als Ersteller""" + if self.status not in ["nachweis_eingereicht", "faellig", "in_bearbeitung"]: + return False + if self.erstellt_von and self.erstellt_von == requesting_user: + return False # Selber Nutzer darf nicht freigeben + return True + + def get_pipeline_stage(self): + """Gibt die Pipeline-Stufe als Integer zurück (1-5)""" + stage_map = { + "geplant": 1, + "faellig": 2, + "nachweis_eingereicht": 2, + "in_bearbeitung": 3, + "freigegeben": 3, + "ausgezahlt": 4, + "abgeschlossen": 5, + "storniert": 0, + } + return stage_map.get(self.status, 1) class UnterstuetzungWiederkehrend(models.Model): diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index e11f2af..59f562b 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -440,4 +440,41 @@ urlpatterns = [ path("kalender//bearbeiten/", views.kalender_edit, name="kalender_edit"), path("kalender//loeschen/", views.kalender_delete, name="kalender_delete"), path("kalender/api/events/", views.kalender_api_events, name="kalender_api_events"), + + # Phase 2: Destinatär-Timeline (2a) + path( + "destinataere//timeline/", + views.destinataer_timeline, + name="destinataer_timeline", + ), + + # Phase 2: Nachweis-Board (2b) + path("nachweis-board/", views.nachweis_board, name="nachweis_board"), + path( + "nachweis-board/erinnerung/", + views.batch_erinnerung_senden, + name="batch_erinnerung_senden", + ), + + # Phase 2: Zahlungs-Pipeline (2c) + path("zahlungs-pipeline/", views.zahlungs_pipeline, name="zahlungs_pipeline"), + path( + "unterstuetzungen//freigeben/", + views.unterstuetzung_freigeben, + name="unterstuetzung_freigeben", + ), + path( + "unterstuetzungen//nachweis-eingereicht/", + views.unterstuetzung_nachweis_eingereicht, + name="unterstuetzung_nachweis_eingereicht", + ), + path( + "unterstuetzungen//abschliessen/", + views.unterstuetzung_abschliessen, + name="unterstuetzung_abschliessen", + ), + path("sepa-export/", views.sepa_xml_export, name="sepa_xml_export"), + + # Phase 2: Pächter-Workflow (2d) + path("paechter/workflow/", views.paechter_workflow, name="paechter_workflow"), ] diff --git a/app/stiftung/views/__init__.py b/app/stiftung/views/__init__.py index 6fc9c27..e61d942 100644 --- a/app/stiftung/views/__init__.py +++ b/app/stiftung/views/__init__.py @@ -122,6 +122,8 @@ from .land import ( # noqa: F401 verpachtung_create, verpachtung_update, verpachtung_delete, + # Phase 2d + paechter_workflow, ) from .system import ( # noqa: F401 @@ -180,6 +182,15 @@ from .unterstuetzungen import ( # noqa: F401 quarterly_confirmation_edit, quarterly_confirmation_approve, quarterly_confirmation_reset, + # Phase 2 + destinataer_timeline, + nachweis_board, + batch_erinnerung_senden, + zahlungs_pipeline, + unterstuetzung_freigeben, + unterstuetzung_nachweis_eingereicht, + unterstuetzung_abschliessen, + sepa_xml_export, ) from .veranstaltung import ( # noqa: F401 diff --git a/app/stiftung/views/land.py b/app/stiftung/views/land.py index 5b94acc..81c206b 100644 --- a/app/stiftung/views/land.py +++ b/app/stiftung/views/land.py @@ -1551,3 +1551,114 @@ def verpachtung_delete(request, pk): return render(request, 'stiftung/verpachtung_confirm_delete.html', context) +# ============================================================ +# Phase 2d: Pächter-Workflow-Verbesserung +# ============================================================ + + +@login_required +def paechter_workflow(request): + """2d: Pipeline-Ansicht für Pächter – Vertragsfristen, Pachtanpassungen, Abrechnungen.""" + heute = date.today() + + # Aktive Verpachtungen abrufen mit Restlaufzeit + aktive_verpachtungen = LandVerpachtung.objects.filter( + status="aktiv" + ).select_related("land", "paechter").order_by("pachtende") + + # Kategorisieren + abgelaufen = [] + demnachst = [] # < 6 Monate + mittelfrisitig = [] # 6–24 Monate + langfristig = [] # > 24 Monate + kein_enddatum = [] + + for v in aktive_verpachtungen: + if not v.pachtende: + kein_enddatum.append(v) + continue + restlaufzeit = (v.pachtende - heute).days + if restlaufzeit < 0: + abgelaufen.append(v) + elif restlaufzeit <= 180: + demnachst.append(v) + elif restlaufzeit <= 730: + mittelfrisitig.append(v) + else: + langfristig.append(v) + + # Ausstehende Jahresabrechnungen (letztes Jahr ohne Abrechnung) + letztes_jahr = heute.year - 1 + laender_ohne_abrechnung = Land.objects.filter( + aktiv=True + ).exclude( + landabrechnung__abrechnungsjahr=letztes_jahr + ).order_by("lfd_nr")[:20] + + # Pächter mit hoher Gesamtfläche (Top-Pächter) + top_paechter = Paechter.objects.annotate( + flaeche=Sum("landverpachtung__verpachtete_flaeche"), + anzahl_vertraege=Count("landverpachtung") + ).filter(flaeche__gt=0).order_by("-flaeche")[:10] + + # Anstehende Pachtanpassungen (> 5 Jahre laufend, keine Erhöhung dokumentiert) + fuenf_jahre_ago = date(heute.year - 5, heute.month, heute.day) + lang_laufend = LandVerpachtung.objects.filter( + status="aktiv", + pachtbeginn__lte=fuenf_jahre_ago, + ).select_related("land", "paechter").order_by("pachtbeginn")[:20] + + pipeline_stages = [ + { + "key": "abgelaufen", + "label": "Abgelaufen / Handlungsbedarf", + "farbe": "danger", + "icon": "fa-exclamation-triangle", + "verpachtungen": abgelaufen, + "count": len(abgelaufen), + }, + { + "key": "demnachst", + "label": "Bald fällig (< 6 Monate)", + "farbe": "warning", + "icon": "fa-clock", + "verpachtungen": demnachst, + "count": len(demnachst), + }, + { + "key": "mittelfristig", + "label": "Mittelfristig (6–24 Monate)", + "farbe": "info", + "icon": "fa-calendar", + "verpachtungen": mittelfrisitig, + "count": len(mittelfrisitig), + }, + { + "key": "langfristig", + "label": "Langfristig (> 24 Monate)", + "farbe": "success", + "icon": "fa-check", + "verpachtungen": langfristig, + "count": len(langfristig), + }, + { + "key": "unbefristet", + "label": "Unbefristet / Kein Enddatum", + "farbe": "secondary", + "icon": "fa-infinity", + "verpachtungen": kein_enddatum, + "count": len(kein_enddatum), + }, + ] + + context = { + "pipeline_stages": pipeline_stages, + "laender_ohne_abrechnung": laender_ohne_abrechnung, + "top_paechter": top_paechter, + "lang_laufend": lang_laufend, + "letztes_jahr": letztes_jahr, + "heute": heute, + } + return render(request, "stiftung/paechter_workflow.html", context) + + diff --git a/app/stiftung/views/unterstuetzungen.py b/app/stiftung/views/unterstuetzungen.py index b4bbb5a..196d62d 100644 --- a/app/stiftung/views/unterstuetzungen.py +++ b/app/stiftung/views/unterstuetzungen.py @@ -1491,5 +1491,346 @@ def quarterly_confirmation_reset(request, pk): return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) +# ============================================================ +# Phase 2: Destinatär-Timeline, Nachweis-Board, Zahlungs-Pipeline +# ============================================================ + + +@login_required +def destinataer_timeline(request, pk): + """2a: Chronologische Timeline eines Destinatärs – alle Events in einer Ansicht.""" + destinataer = get_object_or_404(Destinataer, pk=pk) + + typ_filter = request.GET.get("typ", "") + + events = [] + + if not typ_filter or typ_filter == "zahlung": + for u in destinataer.unterstuetzungen.select_related("konto", "ausgezahlt_von", "freigegeben_von").order_by("-faellig_am"): + events.append({ + "datum": u.faellig_am, + "typ": "zahlung", + "icon": "fa-money-bill-wave", + "farbe": "success" if u.status == "ausgezahlt" else ("danger" if u.is_overdue() else "primary"), + "titel": f"Zahlung €{u.betrag}", + "beschreibung": u.beschreibung or u.get_status_display(), + "status": u.get_status_display(), + "objekt": u, + }) + + if not typ_filter or typ_filter == "nachweis": + for n in destinataer.quartalseinreichungen.order_by("-jahr", "-quartal"): + datum = n.zahlung_faelligkeitsdatum or n.faelligkeitsdatum + if datum: + events.append({ + "datum": datum, + "typ": "nachweis", + "icon": "fa-file-alt", + "farbe": "success" if n.status in ("geprueft", "auto_geprueft") else ("danger" if n.is_overdue() else "warning"), + "titel": f"Nachweis {n.jahr} Q{n.quartal}", + "beschreibung": n.get_status_display(), + "status": n.get_status_display(), + "objekt": n, + }) + + if not typ_filter or typ_filter == "email": + for e in destinataer.email_eingaenge.order_by("-eingangsdatum"): + events.append({ + "datum": e.eingangsdatum.date() if hasattr(e.eingangsdatum, "date") else e.eingangsdatum, + "typ": "email", + "icon": "fa-envelope", + "farbe": "info", + "titel": e.betreff or "(kein Betreff)", + "beschreibung": e.absender_email, + "status": e.get_status_display(), + "objekt": e, + }) + + if not typ_filter or typ_filter == "notiz": + for n in destinataer.notizen_eintraege.order_by("-erstellt_am"): + events.append({ + "datum": n.erstellt_am.date() if hasattr(n.erstellt_am, "date") else n.erstellt_am, + "typ": "notiz", + "icon": "fa-sticky-note", + "farbe": "secondary", + "titel": n.titel or "Notiz", + "beschreibung": (n.text[:100] + "…") if n.text and len(n.text) > 100 else n.text, + "status": f"von {n.erstellt_von.get_full_name() or n.erstellt_von.username}" if n.erstellt_von else "", + "objekt": n, + }) + + events.sort(key=lambda e: e["datum"] if e["datum"] else date.min, reverse=True) + + context = { + "destinataer": destinataer, + "events": events, + "typ_filter": typ_filter, + } + return render(request, "stiftung/destinataer_timeline.html", context) + + +@login_required +def nachweis_board(request): + """2b: Nachweis-Board – Quartals-Übersicht aller Destinatäre.""" + heute = date.today() + jahr_filter = int(request.GET.get("jahr", heute.year)) + status_filter = request.GET.get("status", "") + + destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname") + + board = [] + for d in destinataere: + quartale = {} + for q in range(1, 5): + nachweis = VierteljahresNachweis.objects.filter( + destinataer=d, jahr=jahr_filter, quartal=q + ).first() + quartale[q] = nachweis + board.append({"destinataer": d, "quartale": quartale}) + + if status_filter: + board = [ + row for row in board + if any( + (q is not None and q.status == status_filter) + for q in row["quartale"].values() + ) + ] + + overdue_count = VierteljahresNachweis.objects.filter( + jahr=jahr_filter, + status__in=["offen", "teilweise"], + studiennachweis_faelligkeitsdatum__lt=heute, + ).count() + + verfuegbare_jahre = list(range(heute.year - 2, heute.year + 2)) + + context = { + "board": board, + "jahr_filter": jahr_filter, + "status_filter": status_filter, + "overdue_count": overdue_count, + "verfuegbare_jahre": verfuegbare_jahre, + "status_choices": VierteljahresNachweis.STATUS_CHOICES, + "heute": heute, + } + return render(request, "stiftung/nachweis_board.html", context) + + +@login_required +def batch_erinnerung_senden(request): + """2b: Batch-Erinnerungen an säumige Destinatäre – Audit-Log-Einträge.""" + if request.method != "POST": + return redirect("stiftung:nachweis_board") + + heute = date.today() + jahr = int(request.POST.get("jahr", heute.year)) + + overdue = VierteljahresNachweis.objects.filter( + jahr=jahr, + status__in=["offen", "teilweise"], + studiennachweis_faelligkeitsdatum__lt=heute, + destinataer__aktiv=True, + ).select_related("destinataer") + + count = 0 + for nachweis in overdue: + try: + AuditLog.objects.create( + user=request.user, + action=f"ERINNERUNG: Nachweis {nachweis.jahr} Q{nachweis.quartal} – {nachweis.destinataer.get_full_name()} ({nachweis.destinataer.email})", + model_name="VierteljahresNachweis", + object_id=str(nachweis.id), + ) + count += 1 + except Exception: + pass + + messages.success( + request, + f"{count} Erinnerung(en) im Audit-Log vermerkt.", + ) + return redirect("stiftung:nachweis_board") + + +@login_required +def zahlungs_pipeline(request): + """2c: Zahlungs-Pipeline – 5-Stufen-Kanban-Ansicht.""" + heute = date.today() + destinataer_id = request.GET.get("destinataer", "") + konto_id = request.GET.get("konto", "") + + qs = DestinataerUnterstuetzung.objects.select_related( + "destinataer", "konto", "ausgezahlt_von", "freigegeben_von", "erstellt_von" + ).exclude(status="storniert") + + if destinataer_id: + qs = qs.filter(destinataer_id=destinataer_id) + if konto_id: + qs = qs.filter(konto_id=konto_id) + + pipeline = { + "offen": qs.filter(status__in=["geplant", "faellig"]).order_by("faellig_am"), + "nachweis_eingereicht": qs.filter(status="nachweis_eingereicht").order_by("faellig_am"), + "freigegeben": qs.filter(status__in=["freigegeben", "in_bearbeitung"]).order_by("faellig_am"), + "ueberwiesen": qs.filter(status="ausgezahlt").order_by("-ausgezahlt_am"), + "abgeschlossen": qs.filter(status="abgeschlossen").order_by("-ausgezahlt_am"), + } + + stage_meta = { + "offen": ("Offen", "secondary", "fa-clock"), + "nachweis_eingereicht": ("Nachweis eingereicht", "info", "fa-file-alt"), + "freigegeben": ("Freigegeben (4-Augen)", "warning", "fa-shield-alt"), + "ueberwiesen": ("Überwiesen", "success", "fa-university"), + "abgeschlossen": ("Abgeschlossen", "dark", "fa-check-double"), + } + + pipeline_stages = [ + { + "key": key, + "label": stage_meta[key][0], + "farbe": stage_meta[key][1], + "icon": stage_meta[key][2], + "zahlungen": list(pipeline[key]), + "gesamt": pipeline[key].aggregate(s=Sum("betrag"))["s"] or Decimal("0"), + } + for key in ["offen", "nachweis_eingereicht", "freigegeben", "ueberwiesen", "abgeschlossen"] + ] + + destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname") + konten = StiftungsKonto.objects.filter(aktiv=True).order_by("kontoname") + + context = { + "pipeline_stages": pipeline_stages, + "destinataere": destinataere, + "konten": konten, + "destinataer_filter": destinataer_id, + "konto_filter": konto_id, + "heute": heute, + } + return render(request, "stiftung/zahlungs_pipeline.html", context) + + +@login_required +def unterstuetzung_freigeben(request, pk): + """2c: 4-Augen-Prinzip – Freigabe durch zweiten Nutzer.""" + unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) + + if request.method == "POST": + if not unterstuetzung.can_be_freigegeben(request.user): + messages.error( + request, + "Freigabe nicht möglich: Status nicht korrekt oder Sie sind der Ersteller (Vier-Augen-Prinzip).", + ) + else: + unterstuetzung.status = "freigegeben" + unterstuetzung.freigegeben_von = request.user + unterstuetzung.freigegeben_am = date.today() + unterstuetzung.save() + messages.success( + request, + f"Zahlung €{unterstuetzung.betrag} für {unterstuetzung.destinataer.get_full_name()} freigegeben.", + ) + + next_url = request.POST.get("next") or request.META.get("HTTP_REFERER") or reverse("stiftung:zahlungs_pipeline") + return redirect(next_url) + + +@login_required +def unterstuetzung_nachweis_eingereicht(request, pk): + """2c: Status auf 'Nachweis eingereicht' setzen.""" + unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) + if request.method == "POST": + if unterstuetzung.status in ["geplant", "faellig"]: + unterstuetzung.status = "nachweis_eingereicht" + unterstuetzung.save() + messages.success(request, "Status auf 'Nachweis eingereicht' gesetzt.") + else: + messages.error(request, "Status-Übergang nicht möglich.") + next_url = request.POST.get("next") or reverse("stiftung:zahlungs_pipeline") + return redirect(next_url) + + +@login_required +def unterstuetzung_abschliessen(request, pk): + """2c: Abschließen einer überwiesenen Zahlung.""" + unterstuetzung = get_object_or_404(DestinataerUnterstuetzung, pk=pk) + if request.method == "POST": + if unterstuetzung.status == "ausgezahlt": + unterstuetzung.status = "abgeschlossen" + unterstuetzung.save() + messages.success(request, "Zahlung als abgeschlossen markiert.") + else: + messages.error(request, "Nur überwiesene Zahlungen können abgeschlossen werden.") + next_url = request.POST.get("next") or reverse("stiftung:zahlungs_pipeline") + return redirect(next_url) + + +@login_required +def sepa_xml_export(request): + """2c: SEPA pain.001 XML-Export für freigegebene Zahlungen.""" + from xml.etree.ElementTree import Element, SubElement, tostring + import xml.dom.minidom + + zahlungen = DestinataerUnterstuetzung.objects.filter( + status="freigegeben" + ).select_related("destinataer", "konto") + + if not zahlungen.exists(): + messages.warning(request, "Keine freigegebenen Zahlungen für SEPA-Export vorhanden.") + 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)) + + root = Element("Document", { + "xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03", + "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + }) + cstmr_cdt = SubElement(root, "CstmrCdtTrfInitn") + grp_hdr = SubElement(cstmr_cdt, "GrpHdr") + SubElement(grp_hdr, "MsgId").text = msg_id + SubElement(grp_hdr, "CreDtTm").text = timezone.now().strftime("%Y-%m-%dT%H:%M:%S") + SubElement(grp_hdr, "NbOfTxs").text = str(nb_of_txs) + SubElement(grp_hdr, "CtrlSum").text = ctrl_sum + initg_pty = SubElement(grp_hdr, "InitgPty") + SubElement(initg_pty, "Nm").text = "van Hees-Theyssen-Vogel'sche Stiftung" + + pmt_inf = SubElement(cstmr_cdt, "PmtInf") + SubElement(pmt_inf, "PmtInfId").text = f"PMT-{msg_id}" + SubElement(pmt_inf, "PmtMtd").text = "TRF" + SubElement(pmt_inf, "NbOfTxs").text = str(nb_of_txs) + SubElement(pmt_inf, "CtrlSum").text = ctrl_sum + pmt_tp_inf = SubElement(pmt_inf, "PmtTpInf") + svc_lvl = SubElement(pmt_tp_inf, "SvcLvl") + SubElement(svc_lvl, "Cd").text = "SEPA" + SubElement(pmt_inf, "ReqdExctnDt").text = heute.strftime("%Y-%m-%d") + dbtr = SubElement(pmt_inf, "Dbtr") + SubElement(dbtr, "Nm").text = "van Hees-Theyssen-Vogel'sche Stiftung" + + 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) + 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(" ", "") + rmt_inf = SubElement(cdt_trf, "RmtInf") + SubElement(rmt_inf, "Ustrd").text = (zahlung.verwendungszweck or zahlung.beschreibung or "Stiftungsunterstützung")[:140] + + xml_str = xml.dom.minidom.parseString(tostring(root, encoding="unicode")).toprettyxml(indent=" ") + + response = HttpResponse(xml_str, content_type="application/xml; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="sepa_export_{heute.strftime("%Y%m%d")}.xml"' + return response + + # Two-Factor Authentication Views diff --git a/app/templates/base.html b/app/templates/base.html index d7ebb8b..e769270 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -617,6 +617,10 @@ Abrechnungen + + + Pacht-Workflow + @@ -626,6 +630,14 @@ Unterstuetzungen + + + Zahlungs-Pipeline + + + + Nachweis-Board + Geschaeftsfuehrung diff --git a/app/templates/stiftung/destinataer_timeline.html b/app/templates/stiftung/destinataer_timeline.html new file mode 100644 index 0000000..99fc91c --- /dev/null +++ b/app/templates/stiftung/destinataer_timeline.html @@ -0,0 +1,151 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Timeline – {{ destinataer.get_full_name }} – Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+ + + + +
+
+
+ Filter: + + Alle + + + Zahlungen + + + Nachweise + + + E-Mails + + + Notizen + + {{ events|length }} Einträge +
+
+
+ + + {% if events %} +
+ {% for event in events %} +
+
+
+
+ +
+ +
+ +
+
+
+ {{ event.titel }} + {% if event.beschreibung %} + {{ event.beschreibung }} + {% endif %} +
+
+
{{ event.datum|date:"d.m.Y" }}
+ {% if event.status %} + {{ event.status }} + {% endif %} +
+
+ + + {% if event.typ == 'zahlung' %} +
+ {% with u=event.objekt %} + Konto: {{ u.konto.kontoname }} + {% if u.empfaenger_iban %} · IBAN: {{ u.empfaenger_iban|slice:":4" }}…{% endif %} + {% if u.ausgezahlt_von %} · Ausgezahlt von: {{ u.ausgezahlt_von.get_full_name|default:u.ausgezahlt_von.username }}{% endif %} + {% if u.freigegeben_von %} · Freigegeben: {{ u.freigegeben_von.get_full_name|default:u.freigegeben_von.username }} ({{ u.freigegeben_am|date:"d.m.Y" }}){% endif %} + {% endwith %} +
+ {% elif event.typ == 'nachweis' %} +
+ {% with n=event.objekt %} + Fortschritt: {{ n.get_completion_percentage }}% + {% if n.geprueft_von %} · Geprüft von: {{ n.geprueft_von.get_full_name|default:n.geprueft_von.username }}{% endif %} + {% endwith %} +
+ {% elif event.typ == 'email' %} +
+ {% with e=event.objekt %} + Von: {{ e.absender_name|default:e.absender_email }} + {% if e.quartalsnachweis %} · Zugeordnet: Q{{ e.quartalsnachweis.quartal }}/{{ e.quartalsnachweis.jahr }}{% endif %} + {% endwith %} +
+ {% endif %} +
+
+
+
+
+ {% endfor %} +
+ {% else %} +
+ + Keine Timeline-Einträge vorhanden{% if typ_filter %} für den gewählten Filter{% endif %}. +
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/app/templates/stiftung/nachweis_board.html b/app/templates/stiftung/nachweis_board.html new file mode 100644 index 0000000..6de8cba --- /dev/null +++ b/app/templates/stiftung/nachweis_board.html @@ -0,0 +1,170 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Nachweis-Board – Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+ +
+

+ + Nachweis-Board {{ jahr_filter }} +

+
+ {% if overdue_count > 0 %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+
+ + +
+
+
+
+ + +
+
+ + +
+ {{ board|length }} Destinatäre +
+
+
+ + +
+ + Semester-Logik: + Studiennachweis-Frist Q1/Q2 → 15. März · Q3/Q4 → 15. September. + Zahlungsfrist Q1 → 15. Dez (Vorjahr) · Q2 → 15. Mär · Q3 → 15. Jun · Q4 → 15. Sep. +
+ + + {% if board %} +
+
+
+ + + + + + + + + + + + + {% for row in board %} + + + {% for q in "1234" %} + {% with nachweis=row.quartale|get_item:q|default:None %} + + {% endwith %} + {% endfor %} + + + {% endfor %} + +
DestinatärQ1 (Jan–Mär)Q2 (Apr–Jun)Q3 (Jul–Sep)Q4 (Okt–Dez)Gesamt
+ + {{ row.destinataer.get_full_name }} + + + + {% if nachweis %} + {% if nachweis.status == 'geprueft' or nachweis.status == 'auto_geprueft' %} + + + + {% elif nachweis.status == 'eingereicht' %} + + + + {% elif nachweis.status == 'teilweise' %} + + + + {% elif nachweis.status == 'nachbesserung' %} + + + + {% elif nachweis.status == 'abgelehnt' %} + + + + {% elif nachweis.is_overdue %} + + + + {% else %} + + + + {% endif %} + + {% else %} + + {% endif %} + + {% with total=0 done=0 %} + {% for q, nachweis in row.quartale.items %} + {% if nachweis %} + {% if nachweis.status == 'geprueft' or nachweis.status == 'auto_geprueft' %} + + {% endif %} + {% endif %} + {% endfor %} + {% endwith %} +
+
+
+
+ + +
+ Geprüft + Eingereicht + Teilweise + Überfällig + Offen +
+ {% else %} +
+ + Keine aktiven Destinatäre gefunden. +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/stiftung/paechter_workflow.html b/app/templates/stiftung/paechter_workflow.html new file mode 100644 index 0000000..04c4c00 --- /dev/null +++ b/app/templates/stiftung/paechter_workflow.html @@ -0,0 +1,227 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Pächter-Workflow – Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+ +
+

+ + Pächter-Workflow +

+ +
+ + +
+
+
+ Pipeline: Vertragsfristen +
+
+
+
+ {% for stage in pipeline_stages %} +
+
+
+
+ {{ stage.label }} +
+ + {{ stage.count }} + +
+ {% if stage.verpachtungen %} + {% for v in stage.verpachtungen %} +
+
+ + {% if v.pachtende %} +
+ Ende: {{ v.pachtende|date:"d.m.Y" }} + {% if v.pachtende >= heute %} + ({{ v.pachtende|timeuntil:heute }}) + {% else %} + abgelaufen + {% endif %} +
+ {% endif %} +
+ {% if v.verpachtete_flaeche %}{{ v.verpachtete_flaeche|floatformat:0 }} m²{% endif %} + {% if v.pachtzins_pauschal %} · €{{ v.pachtzins_pauschal|floatformat:2 }}/J{% endif %} +
+
+
+ {% endfor %} + {% else %} +
+ + Keine +
+ {% endif %} +
+
+ {% endfor %} +
+
+
+ +
+ +
+
+
+
+ + Ausstehende Jahresabrechnungen {{ letztes_jahr }} +
+
+
+ {% if laender_ohne_abrechnung %} +
+ + + + + + + + + + + {% for land in laender_ohne_abrechnung %} + + + + + + + {% endfor %} + +
Lfd. Nr.GemeindePächter
{{ land.lfd_nr }}{{ land.gemeinde|default:"–" }}{{ land.paechter_name|default:"–" }} + + + +
+
+ {% else %} +
+ + Alle Jahresabrechnungen {{ letztes_jahr }} vorhanden. +
+ {% endif %} +
+
+
+ + +
+
+
+
+ + Pachtanpassung fällig (> 5 Jahre) +
+
+
+ {% if lang_laufend %} +
+ + + + + + + + + + + {% for v in lang_laufend %} + + + + + + + {% endfor %} + +
LandPächterBeginnDauer
+ + {{ v.land.lfd_nr }} + + {{ v.paechter.get_full_name }}{{ v.pachtbeginn|date:"d.m.Y" }} + + {{ v.pachtbeginn|timesince:heute }} + +
+
+ {% else %} +
+ + Keine Pachtanpassungen fällig. +
+ {% endif %} +
+
+
+
+ + +
+
+
+ Top-Pächter nach Fläche +
+
+
+ {% if top_paechter %} +
+ + + + + + + + + + + {% for p in top_paechter %} + + + + + + + {% endfor %} + +
#PächterVerträgeGesamtfläche (m²)
{{ forloop.counter }} + + {{ p.get_full_name }} + + {{ p.anzahl_vertraege }}{{ p.flaeche|floatformat:0|default:"–" }}
+
+ {% else %} +
Keine Pächter gefunden.
+ {% endif %} +
+
+ +
+
+{% endblock %} diff --git a/app/templates/stiftung/zahlungs_pipeline.html b/app/templates/stiftung/zahlungs_pipeline.html new file mode 100644 index 0000000..66a2837 --- /dev/null +++ b/app/templates/stiftung/zahlungs_pipeline.html @@ -0,0 +1,162 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Zahlungs-Pipeline – Stiftungsverwaltung{% endblock %} + +{% block content %} +
+
+ +
+

+ + Zahlungs-Pipeline +

+ +
+ + +
+
+
+
+ + +
+
+ + +
+ {% if destinataer_filter or konto_filter %} + + Filter zurücksetzen + + {% endif %} +
+
+
+ + +
+ + Vier-Augen-Prinzip: + Zahlungen können nur von einem anderen Nutzer als dem Ersteller freigegeben werden. + SEPA-Export ist nur für Zahlungen im Status „Freigegeben" verfügbar. +
+ + +
+ {% for stage in pipeline_stages %} +
+
+
+
+ + {{ stage.label }} + + {{ stage.zahlungen|length }} +
+ {% if stage.gesamt > 0 %} +
€{{ stage.gesamt|floatformat:2 }}
+ {% endif %} +
+
+ {% if stage.zahlungen %} + {% for z in stage.zahlungen %} +
+
+
+ + + €{{ z.betrag|floatformat:2 }} + +
+
+ Fällig: {{ z.faellig_am|date:"d.m.Y" }} + {% if z.is_overdue %} + überfällig + {% endif %} +
+ {% if z.beschreibung %} +
{{ z.beschreibung }}
+ {% endif %} + {% if z.freigegeben_von %} +
+ + {{ z.freigegeben_von.get_full_name|default:z.freigegeben_von.username }} + ({{ z.freigegeben_am|date:"d.m.Y" }}) +
+ {% endif %} + +
+ {% if stage.key == 'offen' %} +
+ {% csrf_token %} + + +
+ {% elif stage.key == 'nachweis_eingereicht' %} +
+ {% csrf_token %} + + +
+ {% elif stage.key == 'freigegeben' %} + + + + {% elif stage.key == 'ueberwiesen' %} +
+ {% csrf_token %} + + +
+ {% endif %} + + + +
+
+
+ {% endfor %} + {% else %} +
+ + Keine Zahlungen +
+ {% endif %} +
+
+
+ {% endfor %} +
+
+
+{% endblock %}