From bfb65b25831cd38f488558ab21d9b50351933acb Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:51:32 +0200 Subject: [PATCH 1/2] Let individual SEPA debits be excluded from an export, not just all-or-nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ccma/ui/sepa_dialog.py | 72 +++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src/ccma/ui/sepa_dialog.py b/src/ccma/ui/sepa_dialog.py index d13c54c..704d470 100644 --- a/src/ccma/ui/sepa_dialog.py +++ b/src/ccma/ui/sepa_dialog.py @@ -40,7 +40,7 @@ class SepaExportDialog(tk.Toplevel): def _build_ui(self) -> None: self.columnconfigure(0, weight=1) - self.rowconfigure(2, 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) @@ -57,10 +57,23 @@ class SepaExportDialog(tk.Toplevel): ) 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, 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, 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 ( ("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"), @@ -69,9 +82,10 @@ class SepaExportDialog(tk.Toplevel): ): self.table.heading(key, text=label) 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("<>", lambda _event: self._update_summary()) 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) 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( @@ -107,6 +121,14 @@ class SepaExportDialog(tk.Toplevel): 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( @@ -116,18 +138,39 @@ class SepaExportDialog(tk.Toplevel): messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self) return self.table.delete(*self.table.get_children()) - for debit in self.debits: + for index, debit in enumerate(self.debits): self.table.insert( - "", "end", + "", "end", iid=str(index), values=(debit.member_number, debit.account_holder, debit.iban, debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"), ) - total = sum((item.amount for item in self.debits), Decimal("0")) - self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.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: @@ -135,6 +178,11 @@ class SepaExportDialog(tk.Toplevel): "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?", @@ -157,7 +205,7 @@ class SepaExportDialog(tk.Toplevel): return target = self._target(".csv", "CSV-Datei", dates[2]) 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) def _save_xml(self) -> None: @@ -169,7 +217,7 @@ class SepaExportDialog(tk.Toplevel): return try: 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 {}, ) except RepositoryError as exc: @@ -180,7 +228,7 @@ class SepaExportDialog(tk.Toplevel): def _open_mail_export(self) -> None: dates = self._prepare() if dates: - SepaMailExportDialog(self, self.repository, self.debits, dates[2]) + SepaMailExportDialog(self, self.repository, self._selected_debits(), dates[2]) class SepaMailExportDialog(tk.Toplevel): From 2ce764b0074033f14aad998ae611a91546d2be03 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Sat, 15 Aug 2026 03:51:52 +0200 Subject: [PATCH 2/2] Document the SEPA export multi-select feature Co-Authored-By: Claude Sonnet 5 --- src/ccma/assets/CHANGELOG.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index 5ddae9e..30eac7d 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -35,7 +35,8 @@ "Mahnungsentwürfe können nachträglich bearbeitet werden; auch eine bereits als versandt markierte Mahnung lässt sich dafür sicher zurücksetzen (die gebuchte Mahngebühr wird wieder entfernt), sofern noch keine höhere Mahnstufe existiert oder Zahlungen bereits darauf verbucht wurden.", "Eine überfällige Forderung, für die bereits eine Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr fälschlich als unbearbeiteter Fehler gemeldet, sondern als niedrig priorisierter Hinweis, dass die laufende Frist noch nicht abgelaufen ist.", "Geburtstags- und Mitgliedsjubiläums-Hinweise des Hausmeisters werden für Mitglieder mit beendeter Mitgliedschaft nicht mehr angezeigt.", - "Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft." + "Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft.", + "Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt." ] }, {