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>
This commit is contained in:
Marcel Peterkau
2026-08-15 03:51:32 +02:00
co-authored by Claude Sonnet 5
parent d87a57775e
commit bfb65b2583
+60 -12
View File
@@ -40,7 +40,7 @@ class SepaExportDialog(tk.Toplevel):
def _build_ui(self) -> None: def _build_ui(self) -> None:
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1) self.rowconfigure(3, weight=1)
filters = ttk.Frame(self, padding=12) filters = ttk.Frame(self, padding=12)
filters.grid(row=0, column=0, sticky="ew") filters.grid(row=0, column=0, sticky="ew")
ttk.Label(filters, text="Fällig ab").grid(row=0, column=0) ttk.Label(filters, text="Fällig ab").grid(row=0, column=0)
@@ -57,10 +57,23 @@ class SepaExportDialog(tk.Toplevel):
) )
ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6) 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( ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", padx=12, pady=(0, 8) 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.table = ttk.Treeview(
self, columns=("number", "name", "iban", "mandate", "purpose", "amount"), show="headings" self,
columns=("number", "name", "iban", "mandate", "purpose", "amount"),
show="headings",
selectmode="extended",
) )
for key, label, width, anchor in ( for key, label, width, anchor in (
("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"), ("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"),
@@ -69,9 +82,10 @@ class SepaExportDialog(tk.Toplevel):
): ):
self.table.heading(key, text=label) self.table.heading(key, text=label)
self.table.column(key, width=width, anchor=anchor) self.table.column(key, width=width, anchor=anchor)
self.table.grid(row=2, column=0, sticky="nsew", padx=12) 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 = ttk.Frame(self, padding=12)
footer.grid(row=3, column=0, sticky="ew") footer.grid(row=4, column=0, sticky="ew")
footer.columnconfigure(0, weight=1) footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w") 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( ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid(
@@ -107,6 +121,14 @@ class SepaExportDialog(tk.Toplevel):
return due_from, due, collection return due_from, due, collection
def refresh(self) -> None: 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: try:
due_from, due, _collection = self._dates() due_from, due, _collection = self._dates()
self.debits, self.warnings = pending_direct_debits( self.debits, self.warnings = pending_direct_debits(
@@ -116,18 +138,39 @@ class SepaExportDialog(tk.Toplevel):
messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self) messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self)
return return
self.table.delete(*self.table.get_children()) self.table.delete(*self.table.get_children())
for debit in self.debits: for index, debit in enumerate(self.debits):
self.table.insert( self.table.insert(
"", "end", "", "end", iid=str(index),
values=(debit.member_number, debit.account_holder, debit.iban, values=(debit.member_number, debit.account_holder, debit.iban,
debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"), debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"),
) )
total = sum((item.amount for item in self.debits), Decimal("0")) self.table.selection_set(
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR") [
str(index)
for index, debit in enumerate(self.debits)
if debit.member_id not in previously_deselected
]
)
self._update_summary()
self.warning_var.set( self.warning_var.set(
f"{len(self.warnings)} unvollständige(s) Mandat(e) übersprungen." if self.warnings else "" 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: def _prepare(self) -> tuple[date, date, date] | None:
self.refresh() self.refresh()
if not self.debits: if not self.debits:
@@ -135,6 +178,11 @@ class SepaExportDialog(tk.Toplevel):
"Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self "Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self
) )
return None 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( if self.warnings and not messagebox.askyesno(
"Unvollständige Mandate", "Unvollständige Mandate",
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?", "\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
@@ -157,7 +205,7 @@ class SepaExportDialog(tk.Toplevel):
return return
target = self._target(".csv", "CSV-Datei", dates[2]) target = self._target(".csv", "CSV-Datei", dates[2])
if target: if target:
write_csv(target, self.debits, collection_date=dates[2]) write_csv(target, self._selected_debits(), collection_date=dates[2])
messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self) messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self)
def _save_xml(self) -> None: def _save_xml(self) -> None:
@@ -169,7 +217,7 @@ class SepaExportDialog(tk.Toplevel):
return return
try: try:
write_pain008( write_pain008(
target, self.debits, collection_date=dates[2], target, self._selected_debits(), collection_date=dates[2],
organization=self.repository.get_configuration().get("organization") or {}, organization=self.repository.get_configuration().get("organization") or {},
) )
except RepositoryError as exc: except RepositoryError as exc:
@@ -180,7 +228,7 @@ class SepaExportDialog(tk.Toplevel):
def _open_mail_export(self) -> None: def _open_mail_export(self) -> None:
dates = self._prepare() dates = self._prepare()
if dates: if dates:
SepaMailExportDialog(self, self.repository, self.debits, dates[2]) SepaMailExportDialog(self, self.repository, self._selected_debits(), dates[2])
class SepaMailExportDialog(tk.Toplevel): class SepaMailExportDialog(tk.Toplevel):