Files
provisionsmanager95/provtool/gui/app.py
T
adminandClaude Opus 5.5 aae28fa7f2 Absturz beim Schließen mit offenem Dialog behoben; GUI-Tests und Selbsttest der .exe im Build
Fehler: Programm schließen, während der Einrichtungs-Assistent offen war → Zugriff auf
geschlossene Datenbank, danach Absturz des Fehler-Handlers.

- beenden(): schließt zuerst offene Dialoge/Rückfragen/Rundgang, lässt wartende Abläufe zu Ende
  laufen und schließt erst dann Datenbank und Fenster; mehrfacher Aufruf unschädlich
- Fehler-Handler protokolliert immer, zeigt aber keine Meldung mehr ohne Fenster
- Alt+F4 schließt nur das aktive bzw. modale Fenster (nicht mehr das ganze Programm)
- Betrag ändern: Ersatz-Dialog, wenn die Zelle nicht sichtbar ist (vorher passierte still nichts)
- tests/test_gui.py: Oberflächen-Tests inkl. Schließen bei jedem Dialog, Watchdog gegen Hänger
- Build: ein Xvfb für den ganzen Build (Wine bindet sich an das erste DISPLAY), GUI-Tests unter
  Windows-Python, danach Selbsttest der fertigen .exe (--selbsttest)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:55:00 +01:00

992 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Hauptfenster des Provisions-Managers 95."""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tkinter as tk
import traceback
from collections import defaultdict
from datetime import date
from pathlib import Path
from tkinter import filedialog
from provtool import APP_NAME, config
from provtool import export as exp
from provtool.db import UNZUGEORDNET, Datenbank
from provtool.gui import assistent as ass
from provtool.gui import dialogs as dlg
from provtool.gui import rahmen as rh
from provtool.gui import win95 as w
from provtool.importer import ANBIETER, ImportFehler, lies_datei
from provtool.money import fmt, fmt_percent, parse_cent
ALLE = -1 # Ansicht „Alle Positionen“
MONATE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober",
"November", "Dezember"]
def _datum_de(iso: str | None) -> str:
if not iso:
return ""
y, m, d = iso.split("-")
return f"{d}.{m}.{y}"
def _zeitraum_kurz(z: str | None) -> str:
"""'2026.10.01-2026.10.07' → '01.10.–07.10.'"""
m = re.fullmatch(r"(\d{4})\.(\d{2})\.(\d{2})\s*-\s*(\d{4})\.(\d{2})\.(\d{2})", (z or "").strip())
if m:
return f"{m[3]}.{m[2]}.–{m[6]}.{m[5]}."
return z or ""
def _oeffne_ordner(pfad: str):
try:
if sys.platform.startswith("win"):
os.startfile(pfad) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", pfad])
else:
subprocess.Popen(["xdg-open", pfad])
except Exception:
pass
class App:
def __init__(self, root: tk.Tk):
self.root = root
self.settings = config.load()
self.db = Datenbank(config.db_path(self.settings))
self.aid: int | None = None
self.positionen: list[dict] = []
self.haendler_namen: dict[int, str] = {}
self.ansicht: int = ALLE
self._edit: tk.Entry | None = None
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu
w.apply(root)
self.icons = w.Icons(root)
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
root.protocol("WM_DELETE_WINDOW", self.beenden)
root.report_callback_exception = self._fehler_handler
klassisch = bool(self.settings.get("klassischer_rahmen", True))
w.KLASSISCH = klassisch
self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
self.rahmen.set_titel(APP_NAME)
self.inhalt = self.rahmen.inhalt
self._menue()
self._toolbar()
self._body()
self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
griff = self.rahmen.griff(self.status)
if griff:
griff.pack(side="right", anchor="se")
self.status.pack(side="bottom", fill="x")
self._tasten()
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False))
abr = self.db.abrechnungen()
if abr:
self.oeffnen(abr[0]["id"])
else:
self._aktualisieren()
if not self.settings.get("assistent_erledigt") and not self.db.haendler_liste():
root.after(400, self.assistent)
elif self.settings.get("tipps_beim_start", True):
root.after(500, self.tipp_des_tages)
# ================================================================= Aufbau
def _menue(self):
self.menueleiste = rh.Menueleiste(self.inhalt, self.root)
self.menueleiste.pack(side="top", fill="x")
d = self.menueleiste.menue("Datei")
d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu)
d.add_command(label="Abrechnung öffnen…", accelerator="Strg+O", command=self.abrechnung_waehlen)
d.add_separator()
d.add_command(label="Dateien importieren…", accelerator="Strg+I", command=self.importieren)
d.add_command(label="Importierte Dateien…", command=self.dateien)
d.add_command(label="Exportieren…", accelerator="Strg+E", command=self.exportieren)
d.add_separator()
d.add_command(label="Datensicherung erstellen…", command=self.sicherung)
d.add_command(label="Datensicherung einspielen…", command=self.wiederherstellen)
d.add_command(label="Einstellungen…", command=self.einstellungen)
d.add_separator()
d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden)
b = self.menueleiste.menue("Bearbeiten")
b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_bearbeiten)
b.add_command(label="Auszahlung in Prozent…", accelerator="F4", command=self.prozent)
b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
b.add_separator()
b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen)
h = self.menueleiste.menue("Händler")
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler)
h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl)
h.add_command(label="Auswahl fest einem Händler zuweisen…", command=self.haendler_zuweisen)
h.add_command(label="Auswahl automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
hi = self.menueleiste.menue("?", underline=None)
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
hi.add_command(label="Rundgang durch das Programm", command=self.rundgang)
hi.add_command(label="Tipp des Tages…", command=self.tipp_des_tages)
hi.add_separator()
hi.add_command(label="Einrichtungs-Assistent…", command=self.assistent)
hi.add_separator()
hi.add_command(label=f"Info über {APP_NAME}…", command=lambda: dlg.InfoDialog(self.root).zeigen())
def _toolbar(self):
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised")
self.tb: dict[str, w.ToolbarButton] = {}
tb.pack(side="top", fill="x")
for text, icon, cmd, tip in (
("Neu", "neu", self.neu, "Neue Abrechnung anlegen (Strg+N)"),
("Öffnen", "oeffnen", self.abrechnung_waehlen, "Gespeicherte Abrechnung öffnen (Strg+O)"),
(None, None, None, None),
("Import", "import", self.importieren, "CSV-/Excel-Dateien einlesen (Strg+I)"),
("Export", "export", self.exportieren, "Händler-Abrechnungen als Excel speichern (Strg+E)"),
(None, None, None, None),
("Händler", "haendler", self.haendler, "Händler und Kennungen verwalten (Strg+H)"),
("Prozent", "prozent", self.prozent, "Auszahlung für Auswahl in Prozent festlegen (F4)"),
("Betrag", "rechner", self.betrag_bearbeiten, "Betrag der Position ändern (F2)"),
):
if text is None:
w.trenner(tb).pack(side="left", fill="y", padx=4, pady=3)
continue
self.tb[text] = w.ToolbarButton(tb, text, self.icons(icon), cmd, tip)
self.tb[text].pack(side="left", padx=1, pady=1)
self.v_suche = tk.StringVar()
sf = tk.Frame(tb, bg=w.FACE)
sf.pack(side="right", padx=8)
tk.Label(sf, text="Suchen:").pack(side="left")
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
e.pack(side="left", padx=4)
self.v_suche.trace_add("write", lambda *_: self._tabelle())
self._suchfeld = e
self._suchrahmen = sf
def _body(self):
desk = tk.Frame(self.inhalt, bg=w.TEAL)
desk.pack(side="top", fill="both", expand=True)
pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0)
pw.pack(fill="both", expand=True, padx=6, pady=6)
# --- Händler-Fenster
self.f_h = w.Fenster(pw, "Händler", self.icons("haendler"))
r, self.htree = w.scroll_tree(self.f_h.inhalt, columns=("anzahl", "summe"), show="tree headings",
selectmode="browse")
self.htree.heading("#0", text="Händler", anchor="w")
self.htree.heading("anzahl", text="Pos.")
self.htree.heading("summe", text="Auszahlung")
self.htree.column("#0", width=150, minwidth=80, stretch=False)
self.htree.column("anzahl", width=38, anchor="e", stretch=False)
self.htree.column("summe", width=84, anchor="e", stretch=False)
w.auto_breite(self.htree, {"#0": 1})
self.htree.tag_configure("grau", foreground=w.SHADOW)
self.htree.tag_configure("fett", font=w.FONT_BOLD)
r.pack(fill="both", expand=True)
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
pw.add(self.f_h, minsize=240, width=310)
# --- Positionen-Fenster
self.f_p = w.Fenster(pw, "Positionen", self.icons("neu"))
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz")
r2, self.ptree = w.scroll_tree(self.f_p.inhalt, columns=cols, show="tree headings", selectmode="extended")
self.ptree.heading("#0", text="Provisionsart / Kunde", anchor="w")
self.ptree.column("#0", width=200, minwidth=140, stretch=False)
for c, t, b, a in (("datum", "Datum", 76, "w"), ("rufnummer", "Rufnummer", 100, "w"),
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 84, "w"),
("kennung", "Kennung", 70, "w"),
("orig", "Provision", 78, "e"), ("proz", "%", 52, "e"), ("ausz", "Auszahlung", 84, "e")):
self.ptree.heading(c, text=t, anchor=a)
self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False)
w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4})
self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD)
self.ptree.tag_configure("manuell", foreground="#0000c0")
self.ptree.tag_configure("abw", foreground="#a00000")
self.ptree.tag_configure("negativ", foreground="#a00000")
r2.pack(fill="both", expand=True)
self.ptree.bind("<Double-1>", self._doppelklick)
self.ptree.bind("<Button-3>", self._kontextmenue)
self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl())
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
leiste.pack(fill="x", pady=(4, 0))
self.b_prozent = w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent"))
self.b_prozent.pack(side="left")
w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
self.v_alle_auf = tk.BooleanVar(value=True)
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack(
side="left", padx=8)
pw.add(self.f_p, minsize=500)
def _tasten(self):
r = self.root
r.bind_all("<Control-n>", lambda e: self.neu())
r.bind_all("<Control-o>", lambda e: self.abrechnung_waehlen())
r.bind_all("<Control-i>", lambda e: self.importieren())
r.bind_all("<Control-e>", lambda e: self.exportieren())
r.bind_all("<Control-h>", lambda e: self.haendler())
r.bind_all("<Control-f>", lambda e: self._suchfeld.focus_set())
r.bind_all("<F1>", lambda e: self.hilfe())
self.ptree.bind("<F2>", lambda e: self.betrag_bearbeiten())
self.ptree.bind("<Return>", lambda e: self.betrag_bearbeiten())
self.ptree.bind("<F4>", lambda e: self.prozent())
# ================================================================= Daten & Anzeige
def oeffnen(self, aid: int):
self.aid = aid
self._aktualisieren()
def _aktualisieren(self, behalte_auswahl: bool = True):
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
if self.aid and self.db.abrechnung(self.aid):
self.positionen = self.db.positionen(self.aid)
titel = self.db.abrechnung(self.aid)["titel"]
self.rahmen.set_titel(f"{APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}")
else:
self.aid = None
self.positionen = []
self.rahmen.set_titel(APP_NAME)
self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import")
self._haendlerliste(behalte_auswahl)
self._tabelle()
def _gefiltert(self) -> list[dict]:
if self.ansicht == ALLE:
return self.positionen
return [p for p in self.positionen if p["haendler_id"] == self.ansicht]
def _haendlerliste(self, behalte_auswahl=True):
t = self.htree
t.delete(*t.get_children())
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0, 0])
for p in self.positionen:
s = stat[p["haendler_id"]]
s[0] += 1
s[1] += p["betrag"]
s[2] += p["betrag_orig"]
ges = [sum(s[0] for s in stat.values()), sum(s[1] for s in stat.values())]
t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",))
for h in self.db.haendler_liste():
s = stat.get(h.id, [0, 0, 0])
tags = () if s[0] else ("grau",)
t.insert("", "end", iid=str(h.id), text=h.name, values=(s[0] or "", fmt(s[1]) if s[0] else ""), tags=tags)
s = stat.get(UNZUGEORDNET)
if s:
t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])),
tags=("grau",))
ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE)
self.ansicht = int(ziel)
t.selection_set(ziel)
t.see(ziel)
def _haendler_gewaehlt(self, _e=None):
sel = self.htree.selection()
if sel and int(sel[0]) != self.ansicht:
self.ansicht = int(sel[0])
self._tabelle()
def _haendler_doppelklick(self):
if self.ansicht > 0:
self.haendler(self.ansicht)
elif self.ansicht == UNZUGEORDNET:
self.kennung_zuordnen_auswahl()
def _gruppe_von(self, p: dict) -> str:
if self.ansicht == UNZUGEORDNET:
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}"
if self.ansicht == ALLE:
name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)")
return f"{name} · {p['provisionsart'] or '(ohne)'}"
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}"
def _tabelle(self):
if self._edit:
self._edit.destroy()
self._edit = None
t = self.ptree
offen = {iid for iid in t.get_children() if t.item(iid, "open")}
war_leer = not t.get_children()
t.delete(*t.get_children())
such = self.v_suche.get().strip().lower()
pos = self._gefiltert()
if such:
pos = [p for p in pos if such in " ".join(str(p.get(k) or "") for k in (
"rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
"imei")).lower()]
gruppen: dict[str, list[dict]] = defaultdict(list)
for p in exp.sortiert(pos):
gruppen[self._gruppe_von(p)].append(p)
self._gruppen = {}
for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
giid = f"g{gi}"
self._gruppen[giid] = (gname, plist)
so = sum(p["betrag_orig"] for p in plist)
sa = sum(p["betrag"] for p in plist)
proz = {p["prozent"] for p in plist}
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
t.insert("", "end", iid=giid, text=f"{gname} ({len(plist)})", open=(war_leer or giid in offen or bool(such)),
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
for p in plist:
tags = []
if p["manuell"]:
tags.append("manuell")
elif p["betrag"] != p["betrag_orig"]:
tags.append("abw")
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
fmt(p["betrag_orig"]),
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
tags=tuple(tags))
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
self.f_p.set_titel(f"Positionen – {name}")
so = sum(p["betrag_orig"] for p in pos)
sa = sum(p["betrag"] for p in pos)
self.status.set("pos", f" {len(pos)} Positionen")
self.status.set("orig", f" Provision: {fmt(so)}")
self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
def _status_auswahl(self):
pids = self._ausgewaehlte_pids()
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())):
ps = [p for p in self.positionen if p["id"] in set(pids)]
self.status.set("pos", f" {len(ps)} ausgewählt")
self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}")
self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
def _klappen(self, auf: bool):
for iid in self.ptree.get_children():
self.ptree.item(iid, open=auf)
def _ausgewaehlte_pids(self) -> list[int]:
pids: list[int] = []
for iid in self.ptree.selection():
if iid.startswith("g"):
pids.extend(p["id"] for p in self._gruppen[iid][1])
elif iid.startswith("p"):
pids.append(int(iid[1:]))
return list(dict.fromkeys(pids))
# ================================================================= Aktionen
def _brauche_abrechnung(self, frage_neu=True) -> bool:
if self.aid:
return True
if frage_neu:
return self.neu()
return False
def _vorschlag_titel(self) -> str:
heute = date.today()
m = heute.month - 1 or 12
y = heute.year if heute.month > 1 else heute.year - 1
return f"{MONATE[m - 1]} {y}"
def neu(self) -> bool:
t = w.eingabe(self.root, "Neue Abrechnung", "Bezeichnung der Abrechnung (z. B. Abrechnungsmonat):",
self._vorschlag_titel())
if not t or not t.strip():
return False
self.aid = self.db.abrechnung_neu(t.strip())
self.ansicht = ALLE
self._aktualisieren()
return True
def abrechnung_waehlen(self):
if not self.db.abrechnungen():
w.info(self.root, "Abrechnung öffnen", "Es gibt noch keine gespeicherten Abrechnungen.")
return
d = dlg.AbrechnungenDialog(self.root, self.db, self.aid)
res = d.zeigen()
if res and res[0] == "oeffnen":
self.ansicht = ALLE
self.oeffnen(res[1])
else:
self._aktualisieren()
def importieren(self, dateien: list[str] | None = None):
if not dateien:
dateien = list(filedialog.askopenfilenames(
parent=self.root, title="Provisionsabrechnungen importieren",
initialdir=self.settings.get("import_ordner") or None,
filetypes=[("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
("CSV-Dateien", "*.csv"), ("Excel-Dateien", "*.xlsx *.xlsm *.xls"),
("Alle Dateien", "*.*")]))
if not dateien:
return
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
if not self._brauche_abrechnung():
return
bericht = []
self.root.configure(cursor="watch")
self.root.update()
try:
for f in dateien:
name = Path(f).name
try:
erg = lies_datei(f)
except ImportFehler as exc:
bericht.append((name, "–", 0, 0, str(exc).split("\n")[0]))
continue
except Exception as exc:
bericht.append((name, "–", 0, 0, f"Fehler: {exc}"))
continue
schon = self.db.datei_schon_importiert(erg.sha256)
if schon and not w.frage(self.root, "Bereits importiert",
f"„{name}“ wurde bereits importiert (in: {', '.join(schon)}).\n\n"
"Trotzdem noch einmal importieren? Die Positionen wären dann doppelt.",
default_ja=False):
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
continue
n = self.db.importieren(self.aid, erg)
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
"; ".join(erg.warnungen) or "OK"))
finally:
self.root.configure(cursor="")
self._aktualisieren()
unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen:
if p["haendler_id"] == UNZUGEORDNET:
u = unbekannt[(p["anbieter"], p["kennung"])]
u[0] += 1
u[1] += p["betrag_orig"]
res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen()
if res:
self.kennung_zuordnen(*res)
def dateien(self):
if not self.aid:
return
if dlg.DateienDialog(self.root, self.db, self.aid).zeigen():
self._aktualisieren()
def haendler(self, hid: int | None = None, neue_kennung: tuple[str, str] | None = None):
geaendert = dlg.HaendlerDialog(self.root, self.db, hid, neue_kennung).zeigen()
if geaendert:
self._nach_haendler_aenderung()
def _nach_haendler_aenderung(self):
if self.aid:
n = self.db.neu_berechnen(self.aid)
if n:
self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet")
self._aktualisieren()
def kennung_zuordnen(self, anbieter: str, kennung: str):
res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen()
if not res:
return
if res[0] == "neu":
self.haendler(neue_kennung=(anbieter, kennung))
else:
self._nach_haendler_aenderung()
def kennung_zuordnen_auswahl(self):
pids = self._ausgewaehlte_pids()
p = next((x for x in self.positionen if pids and x["id"] == pids[0]), None)
if not p:
w.info(self.root, "Kennung zuordnen", "Bitte zuerst eine Position oder Gruppe auswählen.")
return
if p["haendler_id"] != UNZUGEORDNET:
w.info(self.root, "Kennung zuordnen",
f"Die Kennung {p['kennung']} gehört bereits zu „{self.haendler_namen.get(p['haendler_id'])}“.")
return
self.kennung_zuordnen(p["anbieter"], p["kennung"])
def _auswahl_info(self):
sel = self.ptree.selection()
pids = self._ausgewaehlte_pids()
ps = [p for p in self.positionen if p["id"] in set(pids)]
return sel, pids, ps
def prozent(self):
sel, pids, ps = self._auswahl_info()
if not ps:
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
"in der Liste auswählen.")
return
hids = {p["haendler_id"] for p in ps}
arten = {p["provisionsart"] for p in ps}
hid = next(iter(hids)) if len(hids) == 1 else None
art = next(iter(arten)) if len(arten) == 1 else None
regel_moeglich = bool(hid and hid > 0 and art is not None)
hname = self.haendler_namen.get(hid, "(Nicht zugeordnet)") if hid is not None else "(mehrere)"
akt = {p["prozent"] for p in ps if p["prozent"] is not None}
vorschlag = next(iter(akt)) if len(akt) == 1 else 100.0
nur_gruppe = len(sel) == 1 and sel[0].startswith("g")
res = dlg.ProzentDialog(self.root, hname, art if nur_gruppe or len(ps) > 1 else None, len(ps),
sum(p["betrag_orig"] for p in ps), vorschlag, regel_moeglich).zeigen()
if not res:
return
proz, als_regel, manuelle = res
if als_regel and regel_moeglich:
self.db.regel_setzen(hid, art, proz)
self.db.prozent_anwenden(pids, proz, auch_manuelle=manuelle)
self.db.touch(self.aid)
self._aktualisieren()
self._auswahl_wiederherstellen(sel)
def _auswahl_wiederherstellen(self, sel):
vorhanden = [i for i in sel if self.ptree.exists(i)]
if vorhanden:
self.ptree.selection_set(vorhanden)
self.ptree.see(vorhanden[0])
def zuruecksetzen(self):
sel, pids, ps = self._auswahl_info()
if not ps:
return
for p in ps:
self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"]))
self.db.touch(self.aid)
self._aktualisieren()
self._auswahl_wiederherstellen(sel)
def neu_berechnen(self):
if not self.aid:
return
manuell = sum(1 for p in self.positionen if p["manuell"])
auch = False
if manuell:
auch = w.frage(self.root, "Neu berechnen",
f"{manuell} Position(en) wurden von Hand geändert.\n\nSollen diese ebenfalls nach den "
"Händler-Regeln neu berechnet werden?", ja="Ja, alle", nein="Nein, behalten",
default_ja=False)
n = self.db.neu_berechnen(self.aid, auch_manuelle=auch)
self._aktualisieren()
w.info(self.root, "Neu berechnen", f"{n} Position(en) wurden neu berechnet.")
# ----- Inline-Bearbeitung des Auszahlungsbetrags
def _doppelklick(self, e):
iid = self.ptree.identify_row(e.y)
if not iid:
return
if iid.startswith("g"):
self.ptree.selection_set(iid)
if self.ptree.identify_column(e.x) in ("#7", "#8"):
self.prozent()
return "break"
return
self.ptree.selection_set(iid)
self.betrag_bearbeiten(iid)
def betrag_bearbeiten(self, iid: str | None = None):
if iid is None:
sel = [i for i in self.ptree.selection() if i.startswith("p")]
if len(sel) != 1:
if any(i.startswith("g") for i in self.ptree.selection()) or len(sel) > 1:
self.prozent()
else:
w.info(self.root, "Betrag ändern", "Bitte eine einzelne Position auswählen.")
return
iid = sel[0]
self.ptree.see(iid)
self.ptree.update_idletasks()
bbox = self.ptree.bbox(iid, "ausz")
pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid)
if not bbox:
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) → kleiner Dialog statt Inline-Feld
self._betrag_dialog(p)
return
x, y, bw, bh = bbox
var = tk.StringVar(value=fmt(p["betrag"], symbol=False))
e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT)
e.place(x=x, y=y, width=bw, height=bh)
e.select_range(0, "end")
e.focus_set()
self._edit = e
def speichern(_ev=None, weiter: int = 0):
if self._edit is not e:
return
try:
cent = parse_cent(var.get())
except ValueError:
e.configure(bg="#ffc0c0")
return "break"
self._edit = None
e.destroy()
if cent != p["betrag"]:
self.db.betrag_setzen(pid, cent)
self.db.touch(self.aid)
self._aktualisieren()
nxt = self.ptree.next(iid) if weiter > 0 else (self.ptree.prev(iid) if weiter < 0 else None)
if self.ptree.exists(iid):
self.ptree.selection_set(iid)
self.ptree.focus(iid)
if nxt and self.ptree.exists(nxt):
self.ptree.selection_set(nxt)
self.ptree.focus(nxt)
self.betrag_bearbeiten(nxt)
else:
self.ptree.focus_set()
return "break"
def abbruch(_ev=None):
self._edit = None
e.destroy()
self.ptree.focus_set()
return "break"
e.bind("<Return>", speichern)
e.bind("<KP_Enter>", speichern)
e.bind("<Tab>", lambda ev: speichern(ev, 1))
e.bind("<Down>", lambda ev: speichern(ev, 1))
e.bind("<Up>", lambda ev: speichern(ev, -1))
e.bind("<Escape>", abbruch)
e.bind("<FocusOut>", speichern)
def _betrag_dialog(self, p: dict):
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
while True:
text = w.eingabe(self.root, "Betrag ändern",
f"Auszahlung für {kunde}\n{p['provisionsart']} – Provision {fmt(p['betrag_orig'])}:",
fmt(p["betrag"], symbol=False))
if text is None:
return
try:
cent = parse_cent(text)
except ValueError:
w.warnung(self.root, "Betrag ändern", "Bitte einen Betrag eingeben, z. B. 25 oder 12,50.")
continue
break
if cent != p["betrag"]:
self.db.betrag_setzen(p["id"], cent)
self.db.touch(self.aid)
self._aktualisieren()
def _kontextmenue(self, e):
iid = self.ptree.identify_row(e.y)
if not iid:
return
if iid not in self.ptree.selection():
self.ptree.selection_set(iid)
m = tk.Menu(self.root, tearoff=0)
if iid.startswith("p"):
m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid))
m.add_command(label="Auszahlung in Prozent…", command=self.prozent)
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
m.add_separator()
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
m.add_separator()
m.add_command(label="Händler zuweisen…", command=self.haendler_zuweisen)
m.add_command(label="Automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
m.tk_popup(e.x_root, e.y_root)
def haendler_zuweisen(self, automatisch: bool = False):
sel, pids, _ = self._auswahl_info()
if not pids:
w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.")
return
if automatisch:
hid = None
else:
hid = dlg.HaendlerWaehlen(self.root, self.db, len(pids)).zeigen()
if hid is None:
return
self.db.haendler_zuweisen(pids, hid)
self.db.touch(self.aid)
self._aktualisieren()
def _null(self):
sel, pids, _ = self._auswahl_info()
self.db.prozent_anwenden(pids, 0.0)
self.db.touch(self.aid)
self._aktualisieren()
self._auswahl_wiederherstellen(sel)
# ----- Export
def exportieren(self):
if not self.aid or not self.positionen:
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
"Bitte zuerst Dateien importieren.")
return
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen:
if p["haendler_id"] != UNZUGEORDNET:
stat[p["haendler_id"]][0] += 1
stat[p["haendler_id"]][1] += p["betrag"]
if not stat:
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
return
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in
sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
vor = {self.ansicht} if self.ansicht in stat else set(stat)
titel = self.db.abrechnung(self.aid)["titel"]
res = dlg.ExportDialog(self.root, liste, vor, self.settings, titel).zeigen()
if not res:
return
config.save(self.settings)
erzeugt = []
try:
for hid in res["ids"]:
pos = [p for p in self.positionen if p["haendler_id"] == hid]
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
summe=res["summe"])
except PermissionError as exc:
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
"Ist sie vielleicht noch in Excel geöffnet?")
return
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n…" if len(erzeugt) > 15 else ""))
if res["oeffnen"]:
_oeffne_ordner(res["ordner"])
# ----- Datei-Menü
def sicherung(self):
p = filedialog.asksaveasfilename(parent=self.root, title="Datensicherung speichern", defaultextension=".db",
initialfile=f"Provisionen-Sicherung-{date.today().isoformat()}.db",
filetypes=[("Datenbank", "*.db")])
if p:
self.db.backup(p)
w.info(self.root, "Datensicherung", f"Sicherung gespeichert:\n{p}")
def wiederherstellen(self):
p = filedialog.askopenfilename(parent=self.root, title="Datensicherung einspielen",
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
if not p:
return
if not w.frage(self.root, "Datensicherung einspielen",
"Die aktuelle Datenbank wird durch die Sicherung ERSETZT.\n"
"Vorher wird automatisch eine Kopie (*.vor-wiederherstellung.db) angelegt.\n\nFortfahren?",
default_ja=False):
return
ziel = self.db.path
self.db.backup(ziel.with_suffix(".vor-wiederherstellung.db"))
self.db.close()
try:
Datenbank.restore(p, ziel)
except Exception as exc:
w.fehler(self.root, "Datensicherung", f"Die Datei ist keine gültige Sicherung:\n{exc}")
self.db = Datenbank(ziel)
self.aid = None
abr = self.db.abrechnungen()
if abr:
self.aid = abr[0]["id"]
self.ansicht = ALLE
self._aktualisieren()
def db_wechseln(self, pfad: str | None):
"""Öffnet (oder legt an) eine andere Datenbank. pfad=None: Standard-Speicherort."""
ziel = Path(pfad) if pfad else config.app_dir() / "provisionen.db"
db = Datenbank(ziel) # wirft bei Fehler – alte DB bleibt dann offen
self.db.close()
self.db = db
self.settings["db_pfad"] = str(ziel) if pfad else None
config.save(self.settings)
abr = self.db.abrechnungen()
self.aid = abr[0]["id"] if abr else None
self.ansicht = ALLE
self._aktualisieren()
def einstellungen(self):
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
if not res:
return
if res["klassisch"] != bool(self.settings.get("klassischer_rahmen", True)):
self.settings["klassischer_rahmen"] = res["klassisch"]
config.save(self.settings)
w.info(self.root, "Einstellungen", "Die Änderung der Titelleiste wird beim nächsten Programmstart wirksam.")
if res["pfad"] and Path(res["pfad"]) != self.db.path:
try:
self.db_wechseln(res["pfad"])
except Exception as exc:
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
def hilfe(self):
w.info(self.root, "Kurzanleitung",
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr. oder die Regel\n"
" „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick ▸ Händler zuweisen.\n\n"
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
" auch alle Wochenlisten eines Monats.\n\n"
"4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n"
" • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n"
" optional als Standard für diesen Händler merken.\n"
" • Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
" (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n"
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
"Alles wird automatisch in der Datenbank gespeichert.")
def assistent(self):
res = ass.Einrichtungsassistent(self).zeigen()
if self._schliesst:
return # Programm wird gerade beendet – beim nächsten Start erscheint der Assistent erneut
self.settings["assistent_erledigt"] = True
config.save(self.settings)
self._aktualisieren()
if not res:
return
if res["titel"]:
self.aid = self.db.abrechnung_neu(res["titel"])
self.ansicht = ALLE
self._aktualisieren()
if res["dateien"]:
self.importieren(res["dateien"])
if res["rundgang"]:
self.root.after(200, self.rundgang)
def rundgang(self):
if self._schliesst:
return
tb = self.tb
schritte = [
(self.rahmen.titelleiste or self.menueleiste, "Willkommen zum Rundgang!",
"In ein paar Schritten sehen Sie, wo Sie was finden.\n\nWeiter geht es mit „Weiter >“ oder der "
"Eingabetaste, beenden können Sie jederzeit mit Esc."),
(tb["Händler"], "Händler",
"Hier legen Sie Ihre Unterhändler an: Name, Auszahlung in % und woran ihre Positionen erkannt werden "
"(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."),
(tb["Neu"], "Neue Abrechnung",
"Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. "
"Mit „Öffnen“ holen Sie frühere Abrechnungen wieder hervor."),
(tb["Import"], "Dateien importieren",
"Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. "
"Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."),
(self.f_h, "Händlerliste",
"Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n"
"„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
"Das wird nicht exportiert."),
(self.f_p, "Positionen prüfen",
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
"• Doppelklick auf eine Position: Betrag frei ändern\n"
"• Rechtsklick: Auszahlung 0 €, Händler zuweisen, …\n"
"• Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
(self.b_prozent, "Auszahlung in Prozent",
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. "
"Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
(tb["Export"], "Exportieren",
"Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat."),
(self.status, "Summen",
"Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: "
"Provision laut Anbieter und Ihre Auszahlung."),
(self._suchrahmen, "Suchen",
"Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."),
(self.menueleiste.knoepfe["?"], "Das war's!",
"Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü „?“.\n\n"
"Viel Erfolg mit dem Provisions-Manager 95!"),
]
self._rundgang = ass.Rundgang(self, schritte)
def tipp_des_tages(self):
ass.TippDesTages(self).zeigen()
config.save(self.settings)
def _fehler_handler(self, exc, val, tb):
text = "".join(traceback.format_exception(exc, val, tb))
try:
with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f:
f.write(f"--- {date.today().isoformat()} {APP_NAME}\n{text}\n")
except OSError:
pass
# Beim/nach dem Beenden keine Meldung mehr zeigen – das Fenster gibt es dann nicht mehr
if self._beendet or self._schliesst or not self._lebt():
return
try:
w.fehler(self.root, "Allgemeine Schutzverletzung",
f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n"
f"Details stehen in {config.app_dir() / 'fehler.log'}")
except tk.TclError:
pass
def _lebt(self) -> bool:
try:
return bool(self.root.winfo_exists())
except tk.TclError:
return False
def _offene_fenster(self) -> list[tk.Toplevel]:
"""Alle offenen Dialoge (auch verschachtelte), innerste zuletzt."""
out: list[tk.Toplevel] = []
def suche(widget):
for kind in widget.winfo_children():
if isinstance(kind, tk.Toplevel):
out.append(kind)
suche(kind)
if self._lebt():
suche(self.root)
return out
def beenden(self):
"""Programm schließen – auch wenn gerade Dialoge, Rückfragen oder der Rundgang offen sind.
Offene Dialoge werden zuerst geschlossen; der Code, der auf sie gewartet hat, läuft dann mit
„abgebrochen“ zu Ende (Datenbank noch offen). Neue Dialoge öffnen sich in dieser Phase nicht
mehr. Erst danach werden Einstellungen gespeichert, Datenbank und Fenster geschlossen."""
if self._beendet or not self._lebt():
return
self._schliesst = True
self.root._provtool_schliesst = True # type: ignore[attr-defined] # Signal an win95.Dialog
rg = getattr(self, "_rundgang", None)
if rg is not None:
rg.beenden()
if self._edit is not None:
self._edit.destroy()
self._edit = None
offen = self._offene_fenster()
if offen:
for t in reversed(offen):
try:
if t.winfo_exists():
t.destroy()
except tk.TclError:
pass
self.root.after(30, self.beenden) # wenn alle wartenden Abläufe zurückgekehrt sind
return
self._beendet = True
try:
self.settings["fenster"] = self.rahmen.normale_geometrie()
self.settings["maximiert"] = self.rahmen.maximiert
config.save(self.settings)
except (tk.TclError, OSError):
pass
finally:
try:
self.db.close()
finally:
try:
self.root.destroy()
except tk.TclError:
pass
def main():
if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest":
from provtool.selbsttest import ausfuehren
sys.exit(ausfuehren(sys.argv[2] if len(sys.argv) > 2 else "selbsttest.txt"))
if sys.platform.startswith("win"):
try: # scharfe Darstellung auf HiDPI-Bildschirmen
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
root = tk.Tk(className="Provisionsmanager95")
app = App(root)
if len(sys.argv) > 1:
root.after(200, lambda: app.importieren([a for a in sys.argv[1:] if Path(a).exists()]))
root.mainloop()
if __name__ == "__main__":
main()