mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 14:35:19 +02:00
Replace the GnuCash import checkbox column with native multi-select
Selecting bookings to import now uses the Treeview's own multi-selection (click, Ctrl+click, Shift+click for ranges) instead of a dedicated checkbox column that had to be clicked precisely -- more standard and much faster for marking many rows at once. Bookings matching an existing payment's date+amount are no longer blocked from selection; they're still flagged (red row, "Bereits vorhanden"). If any selected booking is such a duplicate, importing now asks whether to skip those or instead adopt the booking's description onto the already-recorded payment. That relabeling is handled by a new repository.update_payment_reference, which only touches the reference and gnucash_transaction_id fields, leaving date/amount/allocations untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e25accd35b
commit
195ae0e228
@@ -1426,6 +1426,44 @@ class MemberRepository:
|
||||
)
|
||||
return payment
|
||||
|
||||
def update_payment_reference(
|
||||
self,
|
||||
member_id: str,
|
||||
payment_id: str,
|
||||
*,
|
||||
reference: str,
|
||||
gnucash_transaction_id: str = "",
|
||||
actor_name: str = "Vorstand",
|
||||
) -> dict:
|
||||
"""Relabel an existing payment without touching its date, amount, or
|
||||
allocations -- used e.g. when a GnuCash import recognizes a booking as
|
||||
matching an already-recorded payment and the board wants to adopt the
|
||||
(better) description from the statement instead of re-importing it."""
|
||||
data = self.get_contributions(member_id)
|
||||
payment = next(
|
||||
(item for item in data.payments if str(item.get("payment_id", "")) == payment_id),
|
||||
None,
|
||||
)
|
||||
if payment is None:
|
||||
raise RepositoryError("Zahlung nicht gefunden.")
|
||||
gnucash_id = gnucash_transaction_id.strip()
|
||||
if gnucash_id and gnucash_id != str(payment.get("gnucash_transaction_id", "")):
|
||||
self._assert_gnucash_id_available(gnucash_id, exclude_payment_id=payment_id)
|
||||
payment["reference"] = reference.strip()
|
||||
if gnucash_id:
|
||||
payment["gnucash_transaction_id"] = gnucash_id
|
||||
payment["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
self.save_contributions(member_id, data)
|
||||
self.append_event(
|
||||
member_id,
|
||||
event_type="payment_changed",
|
||||
summary=f"Zahlung geändert: Referenz aktualisiert ({payment['reference']})",
|
||||
actor_type="user",
|
||||
actor_name=actor_name,
|
||||
references={"payment_id": payment_id},
|
||||
)
|
||||
return payment
|
||||
|
||||
def delete_payment(self, member_id: str, payment_id: str) -> None:
|
||||
data = self.get_contributions(member_id)
|
||||
payment = next(
|
||||
|
||||
@@ -27,12 +27,14 @@ from ccma.ui.monitors import centered_geometry, preferred_monitor
|
||||
|
||||
class GnuCashImportDialog(tk.Toplevel):
|
||||
"""Lets the board point at a GnuCash file, pick one account, narrow its bookings
|
||||
down with filters, and tick which ones to import as payments for this member.
|
||||
down with filters, and select (multi-select: click, Ctrl+click, Shift+click for
|
||||
ranges) which ones to import as payments for this member.
|
||||
|
||||
Only bookings with a positive amount on the selected account are offered (money
|
||||
coming in), and any booking whose date + amount already match an existing payment
|
||||
for this member is flagged and cannot be checked -- avoiding accidental double
|
||||
imports if the same statement is imported twice."""
|
||||
coming in). A booking whose date + amount already match an existing payment for
|
||||
this member is flagged, but can still be selected -- at import time, the board is
|
||||
asked whether to skip those or instead adopt the booking's description onto the
|
||||
already-recorded payment, rather than silently blocking them."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,8 +53,7 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
self.accounts: list[GnuCashAccount] = []
|
||||
self.account_by_label: dict[str, GnuCashAccount] = {}
|
||||
self.transactions: list[GnuCashTransaction] = []
|
||||
self.selected_guids: set[str] = set()
|
||||
self.existing_payment_keys: set[tuple[str, str]] = set()
|
||||
self.existing_payment_by_key: dict[tuple[str, str], str] = {}
|
||||
|
||||
self.title("Zahlungen aus GnuCash importieren")
|
||||
self.transient(master.winfo_toplevel())
|
||||
@@ -123,12 +124,11 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
tree_frame.rowconfigure(0, weight=1)
|
||||
self.tree = ttk.Treeview(
|
||||
tree_frame,
|
||||
columns=("selected", "date", "description", "memo", "amount", "status"),
|
||||
columns=("date", "description", "memo", "amount", "status"),
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
selectmode="extended",
|
||||
)
|
||||
for key, title, width in (
|
||||
("selected", "Import", 60),
|
||||
("date", "Datum", 100),
|
||||
("description", "Beschreibung", 280),
|
||||
("memo", "Memo", 180),
|
||||
@@ -137,20 +137,21 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
):
|
||||
self.tree.heading(key, text=title)
|
||||
self.tree.column(key, width=width, anchor="w", stretch=key in {"description", "memo"})
|
||||
self.tree.column("selected", anchor="center")
|
||||
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
self.tree.configure(yscrollcommand=scrollbar.set)
|
||||
self.tree.tag_configure("duplicate", background="#7a2323", foreground="#ffffff")
|
||||
self.tree.bind("<Button-1>", self._on_tree_click)
|
||||
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
|
||||
|
||||
ttk.Label(
|
||||
self,
|
||||
text=(
|
||||
"Es werden nur Buchungen mit positivem Betrag auf dem gewählten Konto angezeigt "
|
||||
"(eingehende Zahlungen). Rot markierte Buchungen haben Datum und Betrag einer bereits "
|
||||
"vorhandenen Zahlung dieses Mitglieds und können nicht erneut importiert werden."
|
||||
"(eingehende Zahlungen). Mehrfachauswahl per Klick, Strg+Klick oder Umschalt+Klick. "
|
||||
"Rot markierte Buchungen haben Datum und Betrag einer bereits vorhandenen Zahlung "
|
||||
"dieses Mitglieds; beim Import kann gewählt werden, ob diese übersprungen werden "
|
||||
"oder ob die Beschreibung der vorhandenen Zahlung ersetzt wird."
|
||||
),
|
||||
style="Mono.TLabel",
|
||||
wraplength=900,
|
||||
@@ -174,10 +175,10 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
|
||||
def _load_existing_payment_keys(self) -> None:
|
||||
data = self.repository.get_contributions(self.member_id)
|
||||
self.existing_payment_keys = {
|
||||
(str(payment.get("date", "")), money_text(payment.get("amount", "0")))
|
||||
for payment in data.payments
|
||||
}
|
||||
self.existing_payment_by_key = {}
|
||||
for payment in data.payments:
|
||||
key = (str(payment.get("date", "")), money_text(payment.get("amount", "0")))
|
||||
self.existing_payment_by_key.setdefault(key, str(payment.get("payment_id", "")))
|
||||
|
||||
def _browse_file(self) -> None:
|
||||
current_text = self.file_var.get().strip()
|
||||
@@ -213,7 +214,6 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
self.account_combo.configure(values=list(self.account_by_label))
|
||||
if self.account_by_label and self.account_var.get() not in self.account_by_label:
|
||||
self.account_var.set(self._preferred_account_label() or next(iter(self.account_by_label)))
|
||||
self.selected_guids.clear()
|
||||
if self.account_var.get():
|
||||
self._account_selected()
|
||||
else:
|
||||
@@ -257,7 +257,6 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
self._render_transactions()
|
||||
return
|
||||
self.transactions = [item for item in all_transactions if item.amount > 0]
|
||||
self.selected_guids.clear()
|
||||
self._render_transactions()
|
||||
file_key = self._current_file_key()
|
||||
if file_key is not None and self.config.gnucash_last_accounts.get(file_key) != account.guid:
|
||||
@@ -286,22 +285,22 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
def _duplicate_payment_id(self, item: GnuCashTransaction) -> str | None:
|
||||
return self.existing_payment_by_key.get((item.date.isoformat(), money_text(item.amount)))
|
||||
|
||||
def _is_duplicate(self, item: GnuCashTransaction) -> bool:
|
||||
return (item.date.isoformat(), money_text(item.amount)) in self.existing_payment_keys
|
||||
return self._duplicate_payment_id(item) is not None
|
||||
|
||||
def _render_transactions(self) -> None:
|
||||
previously_selected = set(self.tree.selection())
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
for item in self._filtered_transactions():
|
||||
duplicate = self._is_duplicate(item)
|
||||
if duplicate:
|
||||
self.selected_guids.discard(item.guid)
|
||||
checked = item.guid in self.selected_guids
|
||||
self.tree.insert(
|
||||
"",
|
||||
"end",
|
||||
iid=item.guid,
|
||||
values=(
|
||||
"☑" if checked else "☐",
|
||||
format_date_for_display(item.date.isoformat()),
|
||||
item.description,
|
||||
item.memo,
|
||||
@@ -310,25 +309,15 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
),
|
||||
tags=("duplicate",) if duplicate else (),
|
||||
)
|
||||
still_present = [guid for guid in previously_selected if self.tree.exists(guid)]
|
||||
if still_present:
|
||||
self.tree.selection_set(still_present)
|
||||
self._update_summary()
|
||||
|
||||
def _on_tree_click(self, event: tk.Event) -> None:
|
||||
row_id = self.tree.identify_row(event.y)
|
||||
column = self.tree.identify_column(event.x)
|
||||
if not row_id or column != "#1":
|
||||
return
|
||||
item = next((entry for entry in self.transactions if entry.guid == row_id), None)
|
||||
if item is None or self._is_duplicate(item):
|
||||
return
|
||||
if row_id in self.selected_guids:
|
||||
self.selected_guids.discard(row_id)
|
||||
else:
|
||||
self.selected_guids.add(row_id)
|
||||
self._render_transactions()
|
||||
|
||||
def _update_summary(self) -> None:
|
||||
filtered = self._filtered_transactions()
|
||||
selected_items = [item for item in filtered if item.guid in self.selected_guids]
|
||||
selected_guids = set(self.tree.selection())
|
||||
selected_items = [item for item in filtered if item.guid in selected_guids]
|
||||
total = sum((item.amount for item in selected_items), Decimal("0"))
|
||||
self.summary_var.set(
|
||||
f"{len(filtered)} Buchungen gefunden · {len(selected_items)} ausgewählt · "
|
||||
@@ -336,17 +325,38 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
)
|
||||
|
||||
def _import_selected(self) -> None:
|
||||
selected_items = [
|
||||
item
|
||||
for item in self.transactions
|
||||
if item.guid in self.selected_guids and not self._is_duplicate(item)
|
||||
]
|
||||
selected_guids = set(self.tree.selection())
|
||||
selected_items = [item for item in self.transactions if item.guid in selected_guids]
|
||||
if not selected_items:
|
||||
messagebox.showinfo("Import", "Bitte mindestens eine Buchung auswählen.", parent=self)
|
||||
return
|
||||
|
||||
new_items = [item for item in selected_items if not self._is_duplicate(item)]
|
||||
duplicate_items = [item for item in selected_items if self._is_duplicate(item)]
|
||||
|
||||
replace_description = False
|
||||
if duplicate_items:
|
||||
response = messagebox.askyesnocancel(
|
||||
"Bereits vorhandene Buchungen ausgewählt",
|
||||
(
|
||||
f"{len(duplicate_items)} der ausgewählten Buchungen stimmen in Datum und Betrag "
|
||||
"mit bereits vorhandenen Zahlungen dieses Mitglieds überein.\n\n"
|
||||
"Ja: Diese werden beim Import übersprungen.\n"
|
||||
"Nein: Bei diesen wird stattdessen die Beschreibung der vorhandenen Zahlung "
|
||||
"durch die Beschreibung aus GnuCash ersetzt.\n"
|
||||
"Abbrechen: Es wird nichts importiert."
|
||||
),
|
||||
parent=self,
|
||||
)
|
||||
if response is None:
|
||||
return
|
||||
replace_description = not response
|
||||
|
||||
imported = 0
|
||||
updated = 0
|
||||
processed_guids: set[str] = set()
|
||||
errors: list[str] = []
|
||||
for item in selected_items:
|
||||
for item in new_items:
|
||||
try:
|
||||
self.repository.create_payment(
|
||||
self.member_id,
|
||||
@@ -356,21 +366,54 @@ class GnuCashImportDialog(tk.Toplevel):
|
||||
gnucash_transaction_id=item.guid,
|
||||
)
|
||||
imported += 1
|
||||
self.selected_guids.discard(item.guid)
|
||||
processed_guids.add(item.guid)
|
||||
except RepositoryError as exc:
|
||||
errors.append(
|
||||
f"{format_date_for_display(item.date.isoformat())} · {money_text(item.amount)} EUR: {exc}"
|
||||
)
|
||||
if imported:
|
||||
if replace_description:
|
||||
for item in duplicate_items:
|
||||
payment_id = self._duplicate_payment_id(item)
|
||||
if not payment_id:
|
||||
continue
|
||||
try:
|
||||
self.repository.update_payment_reference(
|
||||
self.member_id,
|
||||
payment_id,
|
||||
reference=item.description,
|
||||
gnucash_transaction_id=item.guid,
|
||||
)
|
||||
updated += 1
|
||||
processed_guids.add(item.guid)
|
||||
except RepositoryError as exc:
|
||||
errors.append(
|
||||
f"{format_date_for_display(item.date.isoformat())} · "
|
||||
f"{money_text(item.amount)} EUR: {exc}"
|
||||
)
|
||||
|
||||
if imported or updated:
|
||||
self._load_existing_payment_keys()
|
||||
self._render_transactions()
|
||||
still_selected = set(self.tree.selection()) - processed_guids
|
||||
self.tree.selection_set(list(still_selected))
|
||||
|
||||
summary_parts = []
|
||||
if imported:
|
||||
summary_parts.append(f"{imported} Zahlung(en) importiert")
|
||||
if updated:
|
||||
summary_parts.append(f"{updated} Beschreibung(en) aktualisiert")
|
||||
skipped = len(duplicate_items) if not replace_description else 0
|
||||
if skipped:
|
||||
summary_parts.append(f"{skipped} übersprungen (bereits vorhanden)")
|
||||
summary = ", ".join(summary_parts) or "Keine Buchung verarbeitet."
|
||||
|
||||
if errors:
|
||||
messagebox.showwarning(
|
||||
"Import teilweise fehlgeschlagen",
|
||||
f"{imported} Zahlung(en) importiert.\n\nNicht importiert:\n" + "\n".join(errors),
|
||||
f"{summary}.\n\nFehler:\n" + "\n".join(errors),
|
||||
parent=self,
|
||||
)
|
||||
else:
|
||||
messagebox.showinfo("Import abgeschlossen", f"{imported} Zahlung(en) importiert.", parent=self)
|
||||
if imported:
|
||||
messagebox.showinfo("Import abgeschlossen", f"{summary}.", parent=self)
|
||||
if imported or updated:
|
||||
self.on_imported()
|
||||
|
||||
@@ -251,6 +251,71 @@ def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted"
|
||||
|
||||
|
||||
def test_payment_reference_can_be_replaced_without_touching_amount_or_allocations(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="10.00",
|
||||
allocation_amount="10.00",
|
||||
reference="Alte Referenz",
|
||||
)
|
||||
|
||||
updated = repository.update_payment_reference(
|
||||
member.member_id,
|
||||
payment["payment_id"],
|
||||
reference="Neue Referenz aus GnuCash",
|
||||
gnucash_transaction_id="TX-99",
|
||||
)
|
||||
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert updated["reference"] == "Neue Referenz aus GnuCash"
|
||||
assert updated["gnucash_transaction_id"] == "TX-99"
|
||||
assert data.payments[0]["amount"] == "10.00"
|
||||
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("10.00")
|
||||
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
|
||||
|
||||
|
||||
def test_payment_reference_update_rejects_gnucash_id_already_used_elsewhere(tmp_path) -> None:
|
||||
repository, member = _repository_with_claim(tmp_path)
|
||||
repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
payment_date="2026-06-21",
|
||||
amount="10.00",
|
||||
allocation_amount="10.00",
|
||||
gnucash_transaction_id="TX-1",
|
||||
)
|
||||
data = repository.get_contributions(member.member_id)
|
||||
data.claims.append(
|
||||
{
|
||||
"claim_id": "claim-2",
|
||||
"claim_key": "second-claim",
|
||||
"title": "Zweite Forderung",
|
||||
"amount": "20.00",
|
||||
"due_date": "2026-12-31",
|
||||
"status": "open",
|
||||
}
|
||||
)
|
||||
repository.save_contributions(member.member_id, data)
|
||||
second_payment = repository.record_payment(
|
||||
member.member_id,
|
||||
"claim-2",
|
||||
payment_date="2026-06-22",
|
||||
amount="20.00",
|
||||
allocation_amount="20.00",
|
||||
)
|
||||
|
||||
with pytest.raises(RepositoryError, match="GnuCash-ID bereits verwendet"):
|
||||
repository.update_payment_reference(
|
||||
member.member_id,
|
||||
second_payment["payment_id"],
|
||||
reference="Duplikatversuch",
|
||||
gnucash_transaction_id="TX-1",
|
||||
)
|
||||
|
||||
|
||||
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
|
||||
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
|
||||
data = ContributionData(
|
||||
|
||||
Reference in New Issue
Block a user