Files
CCMA/src/ccma/ui/sepa_dialog.py
T
Marcel PeterkauandClaude Sonnet 5 bfb65b2583 Let individual SEPA debits be excluded from an export, not just all-or-nothing
The SEPA export dialog always exported every pending direct debit in the
filtered date range with no way to hold one back -- e.g. a member whose
Rücklastschrift is still being clarified had to either be collected again
regardless or the whole run skipped. The table now supports multi-select
(everything selected by default) and the CSV/XML/mail exports only include
the currently selected rows. Deselection survives the refresh() that
_prepare() runs right before exporting (tracked by member_id, not row index,
so a debit newly appearing after a requery still defaults to selected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:51:32 +02:00

315 lines
13 KiB
Python

from __future__ import annotations
import tkinter as tk
from datetime import date, timedelta
from decimal import Decimal
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from ccma.domain.dates import DateValidationError, normalize_date_input
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
from ccma.services.sepa_mail import generate_debit_mails
from ccma.storage.repository import MemberRepository, RepositoryError
def _next_weekday(selected: date) -> date:
while selected.weekday() >= 5:
selected += timedelta(days=1)
return selected
class SepaExportDialog(tk.Toplevel):
def __init__(self, master: tk.Misc, repository: MemberRepository):
super().__init__(master)
self.repository = repository
self.debits = []
self.warnings: list[str] = []
self.title("SEPA-Lastschriften")
self.transient(master.winfo_toplevel())
self.geometry("1000x560")
self.minsize(760, 420)
self.from_var = tk.StringVar(value=date(date.today().year, 1, 1).isoformat())
self.due_var = tk.StringVar(value=date.today().isoformat())
self.collection_var = tk.StringVar(
value=_next_weekday(date.today() + timedelta(days=2)).isoformat()
)
self.summary_var = tk.StringVar()
self.warning_var = tk.StringVar()
self._build_ui()
self.refresh()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(3, weight=1)
filters = ttk.Frame(self, padding=12)
filters.grid(row=0, column=0, sticky="ew")
ttk.Label(filters, text="Fällig ab").grid(row=0, column=0)
ttk.Entry(filters, textvariable=self.from_var, width=14).grid(
row=0, column=1, padx=(8, 20)
)
ttk.Label(filters, text="Fällig bis").grid(row=0, column=2)
ttk.Entry(filters, textvariable=self.due_var, width=14).grid(
row=0, column=3, padx=(8, 20)
)
ttk.Label(filters, text="Einzugsdatum").grid(row=0, column=4)
ttk.Entry(filters, textvariable=self.collection_var, width=14).grid(
row=0, column=5, padx=(8, 20)
)
ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6)
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", padx=12, pady=(0, 2)
)
ttk.Label(
self,
text=(
"Standardmäßig sind alle Lastschriften ausgewählt -- einzelne mit Strg+Klick "
"abwählen (z. B. während eine Rücklastschrift geklärt wird), um sie erst bei "
"einem späteren Einzug zu berücksichtigen."
),
style="Muted.TLabel",
wraplength=960,
).grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8))
self.table = ttk.Treeview(
self,
columns=("number", "name", "iban", "mandate", "purpose", "amount"),
show="headings",
selectmode="extended",
)
for key, label, width, anchor in (
("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"),
("iban", "IBAN", 190, "w"), ("mandate", "Mandat", 130, "w"),
("purpose", "Verwendungszweck", 280, "w"), ("amount", "Betrag", 90, "e"),
):
self.table.heading(key, text=label)
self.table.column(key, width=width, anchor=anchor)
self.table.grid(row=3, column=0, sticky="nsew", padx=12)
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
footer = ttk.Frame(self, padding=12)
footer.grid(row=4, column=0, sticky="ew")
footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w")
ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid(
row=0, column=1, padx=(8, 0)
)
ttk.Button(footer, text="Info-Mails erzeugen", command=self._open_mail_export).grid(
row=0, column=2, padx=(8, 0)
)
ttk.Button(
footer, text="SEPA-XML speichern", style="Accent.TButton", command=self._save_xml
).grid(row=0, column=3, padx=(8, 0))
ttk.Button(footer, text="Schließen", command=self.destroy).grid(
row=0, column=4, padx=(8, 0)
)
def _dates(self) -> tuple[date, date, date]:
try:
due_from = date.fromisoformat(
normalize_date_input(self.from_var.get(), "Fällig ab")
)
due = date.fromisoformat(normalize_date_input(self.due_var.get(), "Fällig bis"))
collection = date.fromisoformat(
normalize_date_input(self.collection_var.get(), "Einzugsdatum")
)
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if due_from > due:
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
if collection.weekday() >= 5:
raise RepositoryError(
"Das Einzugsdatum muss ein Bankarbeitstag von Montag bis Freitag sein."
)
return due_from, due, collection
def refresh(self) -> None:
# Preserve which members were deliberately deselected across a refresh (e.g.
# _prepare() re-querying right before an export) by identity, not by row index
# -- a debit newly appearing in the requeried list still defaults to selected.
previously_deselected = {
self.debits[int(iid)].member_id
for iid in self.table.get_children()
if iid not in self.table.selection()
}
try:
due_from, due, _collection = self._dates()
self.debits, self.warnings = pending_direct_debits(
self.repository, due_from=due_from, due_until=due
)
except RepositoryError as exc:
messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self)
return
self.table.delete(*self.table.get_children())
for index, debit in enumerate(self.debits):
self.table.insert(
"", "end", iid=str(index),
values=(debit.member_number, debit.account_holder, debit.iban,
debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"),
)
self.table.selection_set(
[
str(index)
for index, debit in enumerate(self.debits)
if debit.member_id not in previously_deselected
]
)
self._update_summary()
self.warning_var.set(
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else ""
)
def _selected_debits(self) -> list:
return [self.debits[int(iid)] for iid in sorted(self.table.selection(), key=int)]
def _update_summary(self) -> None:
selected = self._selected_debits()
total = sum((item.amount for item in self.debits), Decimal("0"))
if len(selected) == len(self.debits):
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
return
selected_total = sum((item.amount for item in selected), Decimal("0"))
self.summary_var.set(
f"{len(selected)} von {len(self.debits)} Lastschrift(en) ausgewählt · "
f"Summe {selected_total:.2f} EUR (gesamt {total:.2f} EUR)"
)
def _prepare(self) -> tuple[date, date, date] | None:
self.refresh()
if not self.debits:
messagebox.showinfo(
"Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self
)
return None
if not self._selected_debits():
messagebox.showinfo(
"Keine Auswahl", "Bitte mindestens eine Lastschrift auswählen.", parent=self
)
return None
if self.warnings and not messagebox.askyesno(
"Unvollständige Mandate",
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
parent=self,
):
return None
return self._dates()
def _target(self, extension: str, title: str, collection: date) -> Path | None:
selected = filedialog.asksaveasfilename(
parent=self, title=title, defaultextension=extension,
initialfile=f"SEPA-Lastschriften-{collection.isoformat()}{extension}",
filetypes=[(title, f"*{extension}"), ("Alle Dateien", "*.*")],
)
return Path(selected) if selected else None
def _save_csv(self) -> None:
dates = self._prepare()
if not dates:
return
target = self._target(".csv", "CSV-Datei", dates[2])
if target:
write_csv(target, self._selected_debits(), collection_date=dates[2])
messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self)
def _save_xml(self) -> None:
dates = self._prepare()
if not dates:
return
target = self._target(".xml", "SEPA-XML", dates[2])
if not target:
return
try:
write_pain008(
target, self._selected_debits(), collection_date=dates[2],
organization=self.repository.get_configuration().get("organization") or {},
)
except RepositoryError as exc:
messagebox.showerror("SEPA-XML konnte nicht erstellt werden", str(exc), parent=self)
return
messagebox.showinfo("Export gespeichert", f"SEPA-XML gespeichert:\n{target}", parent=self)
def _open_mail_export(self) -> None:
dates = self._prepare()
if dates:
SepaMailExportDialog(self, self.repository, self._selected_debits(), dates[2])
class SepaMailExportDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
debits: list,
collection_date: date,
):
super().__init__(master)
self.repository = repository
self.debits = debits
self.collection_date = collection_date
organization = repository.get_configuration().get("organization") or {}
self.sender_name_var = tk.StringVar(
value=str(organization.get("name") or "Verwaltung C3MA")
)
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
self.title("SEPA-Info-Mails erzeugen")
self.transient(master)
self.resizable(True, False)
self.columnconfigure(1, weight=1)
ttk.Label(
self,
text=(
f"Für {len(debits)} Lastschrift(en) werden Thunderbird-kompatible "
".eml-Entwürfe erzeugt."
),
wraplength=620,
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
self.signature = tk.Text(self, width=55, height=5, wrap="word")
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(
buttons,
text="Ordner wählen und Entwürfe erzeugen",
style="Accent.TButton",
command=self._generate,
).pack(side="left", padx=(8, 0))
self.grab_set()
def _generate(self) -> None:
selected = filedialog.askdirectory(
parent=self,
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
mustexist=True,
)
if not selected:
return
try:
generated, warnings = generate_debit_mails(
self.repository,
self.debits,
collection_date=self.collection_date,
output_directory=selected,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
return
detail = (
f"{len(generated)} Entwurf/Entwürfe wurden in\n{selected}\nerzeugt und in den "
"Mitgliederakten archiviert."
)
if warnings:
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)
self.destroy()