mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
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>
420 lines
18 KiB
Python
420 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
from collections.abc import Callable
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
from tkinter import filedialog, messagebox, ttk
|
|
|
|
from ccma.config import AppConfig
|
|
from ccma.domain.contributions import money_text
|
|
from ccma.domain.dates import (
|
|
DateValidationError,
|
|
date_input_hint,
|
|
format_date_for_display,
|
|
normalize_date_input,
|
|
)
|
|
from ccma.services.gnucash_import import (
|
|
GnuCashAccount,
|
|
GnuCashImportError,
|
|
GnuCashTransaction,
|
|
list_accounts,
|
|
list_transactions,
|
|
)
|
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
|
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 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). 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,
|
|
master: tk.Misc,
|
|
repository: MemberRepository,
|
|
member_id: str,
|
|
config: AppConfig,
|
|
on_imported: Callable[[], None],
|
|
):
|
|
super().__init__(master)
|
|
self.repository = repository
|
|
self.member_id = member_id
|
|
self.config = config
|
|
self.on_imported = on_imported
|
|
|
|
self.accounts: list[GnuCashAccount] = []
|
|
self.account_by_label: dict[str, GnuCashAccount] = {}
|
|
self.transactions: list[GnuCashTransaction] = []
|
|
self.existing_payment_by_key: dict[tuple[str, str], str] = {}
|
|
|
|
self.title("Zahlungen aus GnuCash importieren")
|
|
self.transient(master.winfo_toplevel())
|
|
monitor = preferred_monitor(master.winfo_toplevel())
|
|
self.geometry(centered_geometry(round(monitor.width * 0.8), round(monitor.height * 0.8), monitor))
|
|
self.minsize(780, 480)
|
|
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
|
self.bind("<Escape>", lambda _event: self.destroy())
|
|
self._build_ui()
|
|
self._load_existing_payment_keys()
|
|
self.after_idle(self._activate)
|
|
if self.file_var.get().strip():
|
|
self._load_file()
|
|
|
|
def _activate(self) -> None:
|
|
try:
|
|
self.deiconify()
|
|
self.lift()
|
|
self.focus_force()
|
|
self.grab_set()
|
|
except tk.TclError:
|
|
return
|
|
|
|
def _build_ui(self) -> None:
|
|
self.columnconfigure(0, weight=1)
|
|
self.rowconfigure(2, weight=1)
|
|
|
|
file_frame = ttk.Frame(self, padding=(16, 16, 16, 8))
|
|
file_frame.grid(row=0, column=0, sticky="ew")
|
|
file_frame.columnconfigure(1, weight=1)
|
|
self.file_var = tk.StringVar(value=self.config.gnucash_path)
|
|
ttk.Label(file_frame, text="GnuCash-Datei").grid(row=0, column=0, sticky="w", padx=(0, 12))
|
|
ttk.Entry(file_frame, textvariable=self.file_var).grid(row=0, column=1, sticky="ew")
|
|
ttk.Button(file_frame, text="Durchsuchen…", command=self._browse_file).grid(
|
|
row=0, column=2, padx=(8, 0)
|
|
)
|
|
ttk.Label(file_frame, text="Konto").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=(8, 0))
|
|
self.account_var = tk.StringVar()
|
|
self.account_combo = ttk.Combobox(
|
|
file_frame, textvariable=self.account_var, state="readonly"
|
|
)
|
|
self.account_combo.grid(row=1, column=1, sticky="ew", pady=(8, 0))
|
|
self.account_combo.bind("<<ComboboxSelected>>", lambda _event: self._account_selected())
|
|
ttk.Button(file_frame, text="Datei laden", command=self._load_file).grid(
|
|
row=1, column=2, padx=(8, 0), pady=(8, 0)
|
|
)
|
|
|
|
filter_frame = ttk.Frame(self, padding=(16, 0, 16, 8))
|
|
filter_frame.grid(row=1, column=0, sticky="ew")
|
|
ttk.Label(filter_frame, text="Beschreibung enthält").pack(side="left")
|
|
self.description_filter_var = tk.StringVar()
|
|
ttk.Entry(filter_frame, textvariable=self.description_filter_var, width=26).pack(
|
|
side="left", padx=(8, 16)
|
|
)
|
|
self.description_filter_var.trace_add("write", lambda *_args: self._render_transactions())
|
|
ttk.Label(filter_frame, text=f"Von ({date_input_hint()})").pack(side="left")
|
|
self.date_from_var = tk.StringVar()
|
|
ttk.Entry(filter_frame, textvariable=self.date_from_var, width=12).pack(side="left", padx=(8, 16))
|
|
self.date_from_var.trace_add("write", lambda *_args: self._render_transactions())
|
|
ttk.Label(filter_frame, text=f"Bis ({date_input_hint()})").pack(side="left")
|
|
self.date_to_var = tk.StringVar()
|
|
ttk.Entry(filter_frame, textvariable=self.date_to_var, width=12).pack(side="left", padx=(8, 0))
|
|
self.date_to_var.trace_add("write", lambda *_args: self._render_transactions())
|
|
|
|
tree_frame = ttk.Frame(self, padding=(16, 0, 16, 8))
|
|
tree_frame.grid(row=2, column=0, sticky="nsew")
|
|
tree_frame.columnconfigure(0, weight=1)
|
|
tree_frame.rowconfigure(0, weight=1)
|
|
self.tree = ttk.Treeview(
|
|
tree_frame,
|
|
columns=("date", "description", "memo", "amount", "status"),
|
|
show="headings",
|
|
selectmode="extended",
|
|
)
|
|
for key, title, width in (
|
|
("date", "Datum", 100),
|
|
("description", "Beschreibung", 280),
|
|
("memo", "Memo", 180),
|
|
("amount", "Betrag", 100),
|
|
("status", "Status", 220),
|
|
):
|
|
self.tree.heading(key, text=title)
|
|
self.tree.column(key, width=width, anchor="w", stretch=key in {"description", "memo"})
|
|
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("<<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). 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,
|
|
).grid(row=3, column=0, sticky="w", padx=16)
|
|
|
|
summary_frame = ttk.Frame(self, padding=(16, 8, 16, 8))
|
|
summary_frame.grid(row=4, column=0, sticky="ew")
|
|
self.summary_var = tk.StringVar(value="Keine Datei geladen.")
|
|
ttk.Label(summary_frame, textvariable=self.summary_var, style="Mono.TLabel").pack(side="left")
|
|
|
|
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
|
|
buttons.grid(row=5, column=0, sticky="e")
|
|
ttk.Button(buttons, text="Schließen", command=self.destroy).pack(side="left", padx=(0, 8))
|
|
self.import_button = ttk.Button(
|
|
buttons,
|
|
text="Ausgewählte importieren",
|
|
style="Accent.TButton",
|
|
command=self._import_selected,
|
|
)
|
|
self.import_button.pack(side="left")
|
|
|
|
def _load_existing_payment_keys(self) -> None:
|
|
data = self.repository.get_contributions(self.member_id)
|
|
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()
|
|
current = Path(current_text).expanduser() if current_text else Path.home()
|
|
initial_dir = current.parent if current.is_file() else current
|
|
selected = filedialog.askopenfilename(
|
|
parent=self,
|
|
title="GnuCash-Datei auswählen",
|
|
initialdir=str(initial_dir) if initial_dir.is_dir() else str(Path.home()),
|
|
filetypes=[("GnuCash-Dateien", "*.gnucash *.xac"), ("Alle Dateien", "*.*")],
|
|
)
|
|
if selected:
|
|
self.file_var.set(selected)
|
|
self._load_file()
|
|
|
|
def _load_file(self) -> None:
|
|
path_text = self.file_var.get().strip()
|
|
if not path_text:
|
|
messagebox.showinfo("GnuCash-Datei", "Bitte zuerst eine GnuCash-Datei auswählen.", parent=self)
|
|
return
|
|
path = Path(path_text).expanduser()
|
|
if not path.is_file():
|
|
messagebox.showerror("GnuCash-Datei", "Die ausgewählte Datei existiert nicht.", parent=self)
|
|
return
|
|
try:
|
|
self.accounts = list_accounts(path)
|
|
except GnuCashImportError as exc:
|
|
messagebox.showerror("GnuCash-Datei konnte nicht gelesen werden", str(exc), parent=self)
|
|
return
|
|
self.account_by_label = {
|
|
f"{account.full_name} ({account.account_type})": account for account in self.accounts
|
|
}
|
|
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)))
|
|
if self.account_var.get():
|
|
self._account_selected()
|
|
else:
|
|
self.transactions = []
|
|
self._render_transactions()
|
|
if path_text != self.config.gnucash_path:
|
|
self.config.gnucash_path = path_text
|
|
self.config.save()
|
|
|
|
def _current_file_key(self) -> str | None:
|
|
path_text = self.file_var.get().strip()
|
|
if not path_text:
|
|
return None
|
|
path = Path(path_text).expanduser()
|
|
if not path.is_file():
|
|
return None
|
|
return str(path.resolve())
|
|
|
|
def _preferred_account_label(self) -> str | None:
|
|
file_key = self._current_file_key()
|
|
if file_key is None:
|
|
return None
|
|
last_guid = self.config.gnucash_last_accounts.get(file_key)
|
|
if not last_guid:
|
|
return None
|
|
return next(
|
|
(label for label, account in self.account_by_label.items() if account.guid == last_guid),
|
|
None,
|
|
)
|
|
|
|
def _account_selected(self) -> None:
|
|
account = self.account_by_label.get(self.account_var.get())
|
|
if account is None:
|
|
return
|
|
path = Path(self.file_var.get().strip()).expanduser()
|
|
try:
|
|
all_transactions = list_transactions(path, account.guid)
|
|
except GnuCashImportError as exc:
|
|
messagebox.showerror("GnuCash-Datei konnte nicht gelesen werden", str(exc), parent=self)
|
|
self.transactions = []
|
|
self._render_transactions()
|
|
return
|
|
self.transactions = [item for item in all_transactions if item.amount > 0]
|
|
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:
|
|
self.config.gnucash_last_accounts[file_key] = account.guid
|
|
self.config.save()
|
|
|
|
def _filtered_transactions(self) -> list[GnuCashTransaction]:
|
|
needle = self.description_filter_var.get().strip().casefold()
|
|
try:
|
|
date_from = normalize_date_input(self.date_from_var.get(), "Von") or None
|
|
except DateValidationError:
|
|
date_from = None
|
|
try:
|
|
date_to = normalize_date_input(self.date_to_var.get(), "Bis") or None
|
|
except DateValidationError:
|
|
date_to = None
|
|
result = []
|
|
for item in self.transactions:
|
|
if needle and needle not in item.description.casefold() and needle not in item.memo.casefold():
|
|
continue
|
|
iso = item.date.isoformat()
|
|
if date_from and iso < date_from:
|
|
continue
|
|
if date_to and iso > date_to:
|
|
continue
|
|
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 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)
|
|
self.tree.insert(
|
|
"",
|
|
"end",
|
|
iid=item.guid,
|
|
values=(
|
|
format_date_for_display(item.date.isoformat()),
|
|
item.description,
|
|
item.memo,
|
|
f"{money_text(item.amount)} EUR",
|
|
"Bereits vorhanden" if duplicate else "",
|
|
),
|
|
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 _update_summary(self) -> None:
|
|
filtered = self._filtered_transactions()
|
|
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 · "
|
|
f"{money_text(total)} EUR"
|
|
)
|
|
|
|
def _import_selected(self) -> None:
|
|
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 new_items:
|
|
try:
|
|
self.repository.create_payment(
|
|
self.member_id,
|
|
payment_date=item.date.isoformat(),
|
|
amount=money_text(item.amount),
|
|
reference=item.description,
|
|
gnucash_transaction_id=item.guid,
|
|
)
|
|
imported += 1
|
|
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 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"{summary}.\n\nFehler:\n" + "\n".join(errors),
|
|
parent=self,
|
|
)
|
|
else:
|
|
messagebox.showinfo("Import abgeschlossen", f"{summary}.", parent=self)
|
|
if imported or updated:
|
|
self.on_imported()
|