Phase 2: Destinatär-Timeline, Nachweis-Board, Zahlungs-Pipeline & Pächter-Workflow

2a. Destinatär-Timeline (/destinataere/<pk>/timeline/)
    - Chronologische Ansicht aller Events (Zahlungen, Nachweise, E-Mails, Notizen)
    - Filter nach Typ via GET-Parameter

2b. Nachweis-Board (/nachweis-board/)
    - Quartals-Übersicht aller aktiver Destinatäre (Q1–Q4) in einer Tabellenansicht
    - Batch-Erinnerung: erzeugt Audit-Log-Einträge für säumige Destinatäre
    - Semester-Logik erhalten (15.03 / 15.09 Fristen)

2c. Zahlungs-Pipeline (/zahlungs-pipeline/)
    - 5-Stufen-Kanban: Offen → Nachweis eingereicht → Freigegeben → Überwiesen → Abgeschlossen
    - Vier-Augen-Prinzip: can_be_freigegeben() prüft anderen Nutzer als Ersteller
    - SEPA pain.001 XML-Export (/sepa-export/) für freigegebene Zahlungen
    - Neue Status-Werte: nachweis_eingereicht, freigegeben, abgeschlossen
    - Neue Felder: freigegeben_von, freigegeben_am, erstellt_von

2d. Pächter-Workflow (/paechter/workflow/)
    - Pipeline nach Restlaufzeit: abgelaufen / <6M / 6–24M / >24M / unbefristet
    - Ausstehende Jahresabrechnungen (Vorjahr ohne Abrechnung)
    - Pachtanpassungen fällig (Verträge > 5 Jahre laufend)
    - Top-Pächter nach Gesamtfläche

Sidebar-Navigation um Pipeline, Nachweis-Board und Pacht-Workflow erweitert.
Migration 0047 erzeugt und angewendet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-11 10:40:43 +00:00
parent bf47ba11c9
commit ee2c827d85
11 changed files with 1327 additions and 3 deletions

View File

@@ -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

View File

@@ -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 = [] # 624 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 (624 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)

View File

@@ -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