Files
provisionsmanager95/provtool/gui/app.py
T
Kerim AtilganandClaude Opus 5.5 b85a75857e
Build / linux (push) Successful in 1m56s
Build / windows-exe (push) Successful in 8m6s
Updater: Prüfung beim Start und unter Hilfe, Setup herunterladen, DB sichern, still installieren, Neustart
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:18:22 +01:00

1560 lines
76 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 tempfile
import threading
import tkinter as tk
import traceback
import webbrowser
from collections import defaultdict
from datetime import date
from pathlib import Path
from tkinter import filedialog
from provtool import __version__, config, updater
from provtool import export as exp
from provtool import regeln
from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank
from provtool.gui import assistent as ass
from provtool.gui import dialogs as dlg
from provtool.gui import menue as mn
from provtool.gui import modal
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 | None = None # wird nach dem Aufbau des Fensters geöffnet (_db_oeffnen)
self.aid: int | None = None
self.positionen: list[dict] = []
self.haendler_namen: dict[int, str] = {}
self.ansicht: int = ALLE
self._alle_gruppen: dict[str, tuple[str, list[dict]]] = {}
self._gruppen: dict[str, tuple[str, list[dict]]] = {}
self._zeilen_tags: dict[str, tuple[str, ...]] = {}
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.design_setzen(self.settings.get("design", "modern"))
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 = w.DESIGN == "win95" # Win95: eigene Titelleiste; modern: normaler Windows-Rahmen
w.KLASSISCH = klassisch
self.rahmen = rh.Hauptrahmen(root, w.APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
self.rahmen.set_titel(w.APP_NAME)
self.inhalt = self.rahmen.inhalt
if w.DUNKEL:
from provtool.gui import win32
win32.dunkle_titelleiste(root)
self.neustart = False # Design umgeschaltet: main() baut das Fenster neu auf
self.filter = "alle"
self._kopf()
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()
# startet maximiert („vollbild“, abschaltbar nur für Tests); „fenster“ = Größe nach dem Wiederherstellen
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("vollbild", True))
if self.settings.get("trenner"):
root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0))
self.db = self._db_oeffnen()
abr = self.db.abrechnungen()
if abr:
self.oeffnen(abr[0]["id"])
else:
self._aktualisieren()
if self.settings.pop("defekt", None):
root.after(300, lambda: w.warnung(
root, "Einstellungen",
"Die Einstellungsdatei war unlesbar und wurde durch Standardwerte ersetzt (Kopie: "
f"{config.app_dir() / 'einstellungen.defekt.json'}).\n\nBitte unter Datei > Einstellungen "
"prüfen, ob der Speicherort der Datenbank noch stimmt."))
elif 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)
def _db_oeffnen(self) -> Datenbank:
"""Datenbank laut Einstellungen öffnen. Ist ein eigener Speicherort eingestellt (Netzlaufwerk) und
nicht erreichbar, wird gefragt – statt abzustürzen oder dort still eine leere Datenbank anzulegen."""
while True:
pfad = config.db_path(self.settings)
try:
if self.settings.get("db_pfad") and not pfad.exists():
raise FileNotFoundError("Die Datei gibt es nicht (Netzlaufwerk nicht verbunden?)")
return Datenbank(pfad)
except Exception as exc:
wahl = w._meldung(self.root, "Datenbank nicht erreichbar",
f"Die Datenbank konnte nicht geöffnet werden:\n{pfad}\n\n{exc}\n\n"
"Standard: die lokale Datenbank dieses PCs verwenden.",
"fehler", [("Standard", "standard"), ("Andere wählen...", "waehlen"),
("Beenden", "ende")], default=0)
if wahl == "standard":
self.settings["db_pfad"] = None
config.save(self.settings)
elif wahl == "waehlen":
p = filedialog.askopenfilename(parent=self.root, title="Datenbank öffnen",
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
if p:
self.settings["db_pfad"] = p
config.save(self.settings)
else:
self.root.destroy()
raise SystemExit(1) from exc
# ================================================================= Aufbau
def _kopf(self):
"""Kopfzeile wie in den Webtools: Logo, Menüs, rechts Suche (modern auch Hell/Dunkel)."""
kopf = tk.Frame(self.inhalt, bg=w.FACE)
kopf.pack(side="top", fill="x")
if w.MODERN:
w.trenner(self.inhalt, vertikal=False).pack(side="top", fill="x")
innen = tk.Frame(kopf, bg=w.FACE)
innen.pack(fill="x", padx=(18, 14) if w.MODERN else 0, pady=10 if w.MODERN else 0)
if w.MODERN:
w.logo(innen).pack(side="left")
namen = tk.Frame(innen, bg=w.FACE)
namen.pack(side="left", padx=(10, 22))
tk.Label(namen, text=w.APP_NAME, font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold")).pack(anchor="w")
tk.Label(namen, text="Handy-Insel", fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(anchor="w")
self._menue(innen)
if w.MODERN:
self.b_design = w.knopf(innen, "Hell" if w.DUNKEL else "Dunkel", self.design_umschalten)
self.b_design.pack(side="right", padx=(8, 0))
w.Tooltip(self.b_design, "Zwischen hellem und dunklem Design wechseln")
self.v_suche = tk.StringVar()
if w.MODERN: # Suchfeld mit Rahmen, Hinweis und Tastenkürzel
sf = tk.Frame(innen, bg=w.WHITE, highlightthickness=1, highlightbackground=w.RAND2,
highlightcolor=w.NAVY)
sf.pack(side="right")
tk.Label(sf, text="Suchen", bg=w.WHITE, fg=w.SHADOW).pack(side="left", padx=(10, 2))
e = tk.Entry(sf, textvariable=self.v_suche, width=28, highlightthickness=0, bd=4)
e.pack(side="left")
tk.Label(sf, text="Strg F", bg=w.WHITE, fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(
side="left", padx=(2, 10))
e.bind("<FocusIn>", lambda _e: sf.configure(highlightbackground=w.NAVY), add="+")
e.bind("<FocusOut>", lambda _e: sf.configure(highlightbackground=w.RAND2), add="+")
else:
sf = tk.Frame(innen, 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._suche_geplant = None
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
self._suchfeld = e
self._suchrahmen = sf
def _menue(self, master):
self.menueleiste = mn.Menueleiste(master, self.root)
self.menueleiste.pack(side="left")
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_command(label="Abrechnung erledigt / wieder öffnen...", command=self.erledigt_umschalten)
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))
h.add_separator()
h.add_command(label="Eigene Kennungen (ausblenden)...", command=self.eigene_verwalten)
h.add_command(label="Kennung der Auswahl als eigene ausblenden", command=self.eigene_ausblenden)
h.add_separator()
h.add_command(label="Provisionsarten (Airtime / Jahresbonus)...", command=self.provisionsarten)
h.add_command(label="Standard-Staffel Jahresbonus...", command=self.standard_staffel)
self.hilfe_menue = "Hilfe" if w.MODERN else "?"
hi = self.menueleiste.menue(self.hilfe_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_command(label="Nach Updates suchen...", command=self.updates_pruefen)
hi.add_separator()
hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent)
hi.add_separator()
hi.add_command(label=f"Info über {w.APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
FILTER = (("alle", "Alle"), ("unzugeordnet", "Nicht zugeordnet"), ("manuell", "Von Hand geändert"),
("doppelt", "Doppelt"), ("abgerechnet", "Bereits abgerechnet"))
def _body(self):
m = w.MODERN
desk = tk.Frame(self.inhalt, bg=w.BG)
desk.pack(side="top", fill="both", expand=True)
# Trennlinie zwischen den Spalten, mit der Maus ziehbar. Beim Ziehen wandert nur eine Linie mit;
# neu aufgebaut wird erst beim Loslassen (flüssig, auch mit vielen Positionen).
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.BG, sashwidth=6 if m else 7,
sashrelief="flat" if m else "raised",
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
pw.pack(fill="both", expand=True, padx=0 if m else 6, pady=0 if m else 6)
# ---------------- Seitenleiste: Abrechnungen und Händler
# Win95: zwei eigene Flächen mit blauer Titelleiste auf dem Desktop
links = tk.Frame(pw, bg=w.FACE if m else w.BG)
innen = tk.Frame(links, bg=w.FACE if m else w.BG)
innen.pack(fill="both", expand=True, padx=12 if m else 0, pady=(16, 10) if m else 0)
seite = dict(style="Seite.Treeview") if m else {}
rand = {} if m else dict(padx=2, pady=(0, 2)) # Abstand der Liste zum Win95-Rahmen
self.f_a = tk.Frame(innen, bg=w.FACE) if m else w.Karte(innen)
self.f_a.pack(fill="x", pady=(0, 16 if m else 6))
zeile = self._abschnitt_zeile(self.f_a, "Abrechnungen")
self.b_neu = w.knopf(zeile, "+ Neu", self.neu, padx=8, pady=0, **self._link())
self.b_neu.pack(side="right", padx=0 if m else 1)
w.Tooltip(self.b_neu, "Neue Abrechnung anlegen (Strg+N)")
r, self.atree = w.scroll_tree(self.f_a, columns=("status",), show="tree", height=5, selectmode="browse",
auto=True, **seite)
self.atree.column("#0", width=150, minwidth=80, stretch=False)
self.atree.column("status", width=110, anchor="e", stretch=False)
w.auto_breite(self.atree, {"#0": 1})
self.atree.tag_configure("leise", foreground=w.SHADOW)
self._punkte = {f: w.punkt(self.root, getattr(w, f)) for f in ("BLAU", "GOLD", "GRUEN", "ROT", "SHADOW")}
r.pack(fill="both", expand=True, **rand)
self.atree.bind("<<TreeviewSelect>>", self._abrechnung_gewaehlt)
self.f_h = tk.Frame(innen, bg=w.FACE) if m else w.Karte(innen)
self.f_h.pack(fill="both", expand=True)
zeile = self._abschnitt_zeile(self.f_h, "Händler")
self.b_haendler = w.knopf(zeile, "Verwalten", self.haendler, padx=8, pady=0, **self._link())
self.b_haendler.pack(side="right", padx=0 if m else 1)
w.Tooltip(self.b_haendler, "Händler und Kennungen verwalten (Strg+H)")
r, self.htree = w.scroll_tree(self.f_h, columns=("anzahl", "summe"), show="tree", selectmode="browse",
auto=True, **seite)
self.htree.column("#0", width=140, minwidth=80, stretch=False)
self.htree.column("anzahl", width=40, anchor="e", stretch=False)
self.htree.column("summe", width=96, 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, **rand)
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
pw.add(links, minsize=240, width=320)
# ---------------- Inhalt: Kopf der Abrechnung, Kennzahlen, Positionen
rechts = tk.Frame(pw, bg=w.BG)
innen = tk.Frame(rechts, bg=w.BG)
innen.pack(fill="both", expand=True, padx=(16, 20) if m else 0, pady=(16, 0) if m else 0)
kopf = tk.Frame(innen, bg=w.BG)
kopf.pack(fill="x", pady=(0, 14 if m else 6))
texte = tk.Frame(kopf, bg=w.BG)
texte.pack(side="left", fill="x", expand=True)
self.l_status = w.abschnitt(texte, "", bg=w.BG)
self.l_status.pack(anchor="w")
self.l_titel = tk.Label(texte, text="", bg=w.BG, font=w.FONT_TITEL, anchor="w")
self.l_titel.pack(anchor="w")
self.l_info = tk.Label(texte, text="", bg=w.BG, fg=w.TEXT2, anchor="w")
self.l_info.pack(anchor="w")
if not m: # Win95: weiße Schrift auf dem Desktop, wie die Symbolbeschriftungen
for lbl in (self.l_status, self.l_titel, self.l_info):
lbl.configure(fg=w.WHITE)
self.b_export = w.knopf(kopf, "Exportieren", self.exportieren, primaer=True)
self.b_export.pack(side="right", anchor="s")
w.Tooltip(self.b_export, "Händler-Abrechnungen als Excel speichern (Strg+E)")
self.b_import = w.knopf(kopf, "Importieren", self.importieren)
self.b_import.pack(side="right", anchor="s", padx=8)
w.Tooltip(self.b_import, "CSV-/Excel-Dateien einlesen (Strg+I)")
self.b_erledigt = w.knopf(kopf, "Als erledigt markieren", self.erledigt_umschalten)
self.b_erledigt.pack(side="right", anchor="s")
self.tb = {"Neu": self.b_neu, "Import": self.b_import, "Export": self.b_export, "Händler": self.b_haendler}
kacheln = tk.Frame(innen, bg=w.BG)
kacheln.pack(fill="x", pady=(0, 14 if m else 6))
self.kpi: dict[str, tuple[tk.Label, tk.Label]] = {}
for i, (key, titel) in enumerate((("pos", "Positionen"), ("orig", "Provision"), ("ausz", "Auszahlung"),
("air", "Airtime"))):
k = w.Karte(kacheln)
k.grid(row=0, column=i, sticky="nsew", padx=(0 if i == 0 else 7, 0 if i == 3 else 7))
kacheln.columnconfigure(i, weight=1, uniform="kpi")
ki = tk.Frame(k, bg=w.FACE)
ki.pack(fill="both", expand=True, padx=16 if m else 8, pady=12 if m else 6)
w.abschnitt(ki, titel).pack(anchor="w")
farbe = {"ausz": w.NAVY, "air": w.GRUEN}.get(key, w.DARK)
wert = tk.Label(ki, text="", font=w.FONT_ZAHL, fg=farbe, anchor="w")
wert.pack(anchor="w", pady=(4, 0))
info = tk.Label(ki, text="", fg=w.TEXT2, anchor="w")
info.pack(anchor="w")
self.kpi[key] = (wert, info)
self.f_p = w.Karte(innen)
self.f_p.pack(fill="both", expand=True, pady=(0, 12 if m else 0))
if not m: # Win95: Titel in der blauen Leiste, Filter darunter wie eine Werkzeugleiste
_, self.l_ptitel = w.titelleiste(self.f_p, "Positionen")
kopfzeile = tk.Frame(self.f_p, bg=w.FACE)
kopfzeile.pack(fill="x", padx=14 if m else 4, pady=10 if m else 4)
if m:
self.l_ptitel = tk.Label(kopfzeile, text="Positionen", font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold"))
self.l_ptitel.pack(side="left", padx=(0, 12))
self.pills: dict[str, w.Pill] = {}
for key, text in self.FILTER:
self.pills[key] = w.Pill(kopfzeile, text, lambda k=key: self._filter_setzen(k), aktiv=key == self.filter)
self.pills[key].pack(side="left", padx=(0, 6))
self.l_auswahl = tk.Label(kopfzeile, text="", fg=w.TEXT2)
self.l_auswahl.pack(side="right")
if m:
w.trenner(self.f_p, vertikal=False).pack(fill="x")
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz")
r2, self.ptree = w.scroll_tree(self.f_p, columns=cols, show="tree headings", selectmode="extended")
if m:
r2.configure(highlightthickness=0)
kopf_text = (lambda t: t.upper()) if m else (lambda t: t)
self.ptree.heading("#0", text=kopf_text("Provisionsart / Kunde"), anchor="w")
self.ptree.column("#0", width=200, minwidth=140, stretch=False)
for c, t, b, a in (("datum", "Datum", 86, "w"), ("rufnummer", "Rufnummer", 112, "w"),
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 94, "w"),
("kennung", "Kennung", 80, "w"),
("orig", "Provision", 100, "e"), ("proz", "%", 64, "e"), ("ausz", "Auszahlung", 112, "e")):
self.ptree.heading(c, text=kopf_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("zebra", background=w.ZEBRA)
self.ptree.tag_configure("manuell", foreground=w.MANUELL)
self.ptree.tag_configure("abw", foreground=w.ROT)
self.ptree.tag_configure("negativ", foreground=w.ROT)
self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW)
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, bg=w.FACE)
leiste.pack(fill="x", padx=14 if m else 2, pady=10 if m else (4, 2))
self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, None if m else self.icons("prozent"))
self.b_prozent.pack(side="left")
w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten,
None if m else self.icons("rechner")).pack(side="left", padx=6)
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
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=6)
pw.add(rechts, minsize=560)
@staticmethod
def _abschnitt_zeile(master, titel: str) -> tk.Frame:
"""Überschrift eines Abschnitts der Seitenleiste; Win95 als blaue Titelleiste. Knöpfe kommen rechts hinein."""
if not w.MODERN:
return w.titelleiste(master, titel)[0]
zeile = tk.Frame(master, bg=w.FACE)
zeile.pack(fill="x", pady=(0, 4))
w.abschnitt(zeile, titel).pack(side="left", padx=(8, 0))
return zeile
@staticmethod
def _link() -> dict:
"""Kleine Textknöpfe in der Seitenleiste (modern ohne Rahmen, in Akzentfarbe)."""
return dict(fg=w.NAVY, highlightthickness=0, font=w.FONT_BOLD) if w.MODERN else {}
def _filter_setzen(self, key: str):
self.filter = key
for k, pill in self.pills.items():
pill.setzen(k == key)
self._filtern()
def _tasten(self):
r = self.root
def frei(befehl):
# Tastenkürzel des Hauptfensters nicht ausführen, solange ein Dialog offen ist
# (sonst öffnete z. B. Strg+N im Dialog einen zweiten Dialog darüber)
return lambda e: None if modal.offen() else befehl()
r.bind_all("<Control-n>", frei(self.neu))
r.bind_all("<Control-o>", frei(self.abrechnung_waehlen))
r.bind_all("<Control-i>", frei(self.importieren))
r.bind_all("<Control-e>", frei(self.exportieren))
r.bind_all("<Control-h>", frei(self.haendler))
r.bind_all("<Control-f>", frei(self._suchfeld.focus_set))
r.bind_all("<F1>", frei(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()}
a = self.db.abrechnung(self.aid) if self.aid else None
if a:
self.positionen = self.db.positionen(self.aid)
titel = a["titel"] + (" – erledigt" if a["erledigt"] else "")
self.rahmen.set_titel(f"{w.APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}")
else:
self.aid = None
self.positionen = []
self.rahmen.set_titel(w.APP_NAME)
self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import")
self._abrechnungsliste()
self._seitenkopf(a)
self._haendlerliste(behalte_auswahl)
self._tabelle()
def _seitenkopf(self, a):
"""Titel, Status und Knöpfe über den Kennzahlen (a: Zeile der Abrechnung oder None)."""
if not a:
self.l_status.configure(text="Keine Abrechnung geöffnet".upper() if w.MODERN else "Keine Abrechnung")
self.l_titel.configure(text="Willkommen")
self.l_info.configure(text="Links „+ Neu“ anlegen oder gleich Dateien importieren.")
self.b_erledigt.configure(text="Als erledigt markieren", state="disabled")
return
erledigt = a["erledigt"]
dateien = self.db.dateien(self.aid)
status = "erledigt" if erledigt else "offen"
self.l_status.configure(text=f"Abrechnung · {status}".upper() if w.MODERN else f"Abrechnung ({status})")
self.l_titel.configure(text=a["titel"])
self.l_info.configure(text=f"{len(dateien)} {'Datei' if len(dateien) == 1 else 'Dateien'} importiert · "
f"{sum(d['anzahl'] for d in dateien)} Zeilen" if dateien else "Noch nichts importiert")
self.b_erledigt.configure(text="Wieder öffnen" if erledigt else "Als erledigt markieren", state="normal")
def _abrechnungsliste(self):
t = self.atree
t.delete(*t.get_children())
for a in self.db.abrechnungen():
status, farbe = (("erledigt", "GRUEN") if a["erledigt"] else
(f"{a['abgerechnet']} abgerechnet", "GOLD") if a["abgerechnet"] else ("offen", "BLAU"))
t.insert("", "end", iid=str(a["id"]), text=" " + a["titel"], values=(status,), image=self._punkte[farbe])
if self.aid and t.exists(str(self.aid)):
t.selection_set(str(self.aid))
t.see(str(self.aid))
def _abrechnung_gewaehlt(self, _e=None):
sel = self.atree.selection()
if sel and int(sel[0]) != self.aid:
self.ansicht = ALLE
self.oeffnen(int(sel[0]))
def _suche_planen(self):
"""Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen)."""
if self._suche_geplant is not None:
self.root.after_cancel(self._suche_geplant)
self._suche_geplant = self.root.after(150, self._suche_ausfuehren)
def _suche_ausfuehren(self):
self._suche_geplant = None
self._filtern()
def _gefiltert(self) -> list[dict]:
if self.ansicht == ALLE:
return [p for p in self.positionen if p["haendler_id"] != EIGEN]
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 k, s in stat.items() if k != EIGEN), sum(s[1] for k, s in stat.items() if k != EIGEN)]
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",))
s = stat.get(EIGEN)
if s:
t.insert("", "end", iid=str(EIGEN), text="(Eigene – ausgeblendet)", values=(s[0], fmt(s[2])),
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()
elif self.ansicht == EIGEN:
self.eigene_verwalten()
def _gruppe_von(self, p: dict) -> str:
if self.ansicht in (UNZUGEORDNET, EIGEN):
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):
"""Baut die Tabelle für die gewählte Ansicht komplett neu auf (nach Datenänderungen).
Suche und Filter blenden danach nur noch Zeilen aus und ein (_filtern) – kein Neuaufbau."""
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())
gruppen: dict[str, list[dict]] = defaultdict(list)
for p in exp.sortiert(self._gefiltert()):
gruppen[self._gruppe_von(p)].append(p)
self._alle_gruppen = {} # giid -> (Name, alle Positionen der Gruppe)
self._zeilen_tags = {}
for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
giid = f"g{gi}"
self._alle_gruppen[giid] = (gname, plist)
t.insert("", "end", iid=giid, text="", open=(war_leer or giid in offen), tags=("gruppe",))
for p in plist:
tags: tuple[str, ...] = ()
if p["abgerechnet"] or p["doppelt"]:
tags = ("abgerechnet",)
elif p["manuell"]:
tags = ("manuell",)
elif p["betrag"] != p["betrag_orig"]:
tags = ("abw",)
self._zeilen_tags[f"p{p['id']}"] = tags
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"]),
"abgerechnet" if p["abgerechnet"] else "doppelt" if p["doppelt"] else
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
fmt(p["betrag"])),
tags=tags)
self._filtern()
def _filtern(self):
"""Suche und Filter anwenden: nicht passende Zeilen werden nur ausgehängt (detach), passende wieder
eingehängt – viel schneller als Löschen und Neuanlegen. Gruppenzeilen und Kennzahlen folgen."""
t = self.ptree
such = self.v_suche.get().strip().lower()
pruef = {"unzugeordnet": lambda p: p["haendler_id"] == UNZUGEORDNET, "manuell": lambda p: p["manuell"],
"doppelt": lambda p: p["doppelt"], "abgerechnet": lambda p: p["abgerechnet"]}.get(self.filter)
def passt(p: dict) -> bool:
if pruef and not pruef(p):
return False
return not such or such in " ".join(str(p.get(k) or "") for k in (
"rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
"imei")).lower()
self._gruppen = {} # giid -> (Name, sichtbare Positionen) – für Auswahl über die Gruppenzeile
pos: list[dict] = []
sichtbar_gi = 0
for giid, (gname, plist) in self._alle_gruppen.items():
treffer = []
for p in plist:
iid = f"p{p['id']}"
if passt(p):
t.move(iid, giid, len(treffer))
t.item(iid, tags=self._zeilen_tags[iid] + (("zebra",) if len(treffer) % 2 else ()))
treffer.append(p)
else:
t.detach(iid)
if not treffer:
t.detach(giid)
continue
t.move(giid, "", sichtbar_gi)
sichtbar_gi += 1
self._gruppen[giid] = (gname, treffer)
pos += treffer
so = sum(p["betrag_orig"] for p in treffer)
sa = sum(p["betrag"] for p in treffer)
proz = {p["prozent"] for p in treffer}
if all(_ist_staffel(p) for p in treffer):
pz = "Staffel"
else:
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
t.item(giid, text=f"{gname} ({len(treffer)})", values=("", "", "", "", "", fmt(so), pz, fmt(sa)))
if such:
t.item(giid, open=True)
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
self.l_ptitel.configure(text=f"Positionen – {name}")
so = sum(p["betrag_orig"] for p in pos)
sa = sum(p["betrag"] for p in pos)
air = [p for p in pos if p.get("typ") == regeln.AIRTIME]
self.status.set("pos", f" {len(pos)} Positionen")
self.status.set("orig", f" Provision: {fmt(so)}")
self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
self._kennzahl("pos", str(len(pos)), name)
self._kennzahl("orig", fmt(so), "laut Anbieter")
self._kennzahl("ausz", fmt(sa), f"{round(sa * 100 / so)} % im Schnitt" if so > 0 else "")
self._kennzahl("air", fmt(sum(p["betrag"] for p in air)),
(f"{len(air)} Posten · eigene Excel-Datei" if any(p["betrag"] for p in air) else f"{len(air)} Posten")
if air else "keine Airtime")
self.l_auswahl.configure(text="")
def _kennzahl(self, key: str, wert: str, info: str):
self.kpi[key][0].configure(text=wert)
self.kpi[key][1].configure(text=info)
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.l_auswahl.configure(text=f"{len(ps)} ausgewählt · {fmt(sum(p['betrag'] for p in ps))}")
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 _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, ziel: int | 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 ziel is None: # jedes Mal fragen, in welche Abrechnung
if not self.db.abrechnungen():
if not self.neu():
return
else:
res = dlg.AbrechnungenDialog(self.root, self.db, self.aid, fuer_import=True).zeigen()
if not res:
return
if res[0] == "neu":
if not self.neu():
return
else:
self.aid, self.ansicht = res[1], ALLE
else:
self.aid = ziel
if self._gesperrt():
return
bericht = []
importiert: list[tuple[int, int, list[str]]] = [] # (Zeile im Bericht, datei_id, Hinweise)
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)
importiert.append((len(bericht), self.db.dateien(self.aid)[-1]["id"], list(erg.warnungen)))
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen), ""))
finally:
self.root.configure(cursor="")
self._aktualisieren()
# Doppelte (schon abgerechnet oder schon offen vorhanden) je Datei zählen – einmal für alle Dateien
doppelt: dict[int, int] = defaultdict(int)
for p in self.positionen:
if p["doppelt"]:
doppelt[p["datei_id"]] += 1
for zeile, did, hinweise in importiert:
if doppelt.get(did):
hinweise.append(f"{doppelt[did]} Pos. doppelt bzw. bereits abgerechnet (werden nicht exportiert)")
bericht[zeile] = bericht[zeile][:4] + ("; ".join(hinweise) or "OK",)
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 and res[0] == "zuordnen":
self.kennung_zuordnen(res[1], res[2])
elif res and res[0] == "eigen":
self.eigene_ausblenden(res[1])
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 provisionsarten(self):
if dlg.ProvisionsartenDialog(self.root, self.db).zeigen():
self._nach_haendler_aenderung()
def standard_staffel(self):
neu = dlg.StaffelDialog(self.root, self.db.standard_staffel(), "Standard-Staffel Jahresbonus Neuaktivierung",
standard=list(regeln.STANDARD_STAFFEL)).zeigen()
if neu is not None:
self.db.standard_staffel_setzen(neu)
self._nach_haendler_aenderung()
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 _gesperrt(self) -> bool:
"""Erledigte Abrechnung: keine Änderungen (Hinweis statt stillem Nichtstun)."""
if self.aid and self.db.erledigt(self.aid):
w.info(self.root, "Abrechnung erledigt", "Diese Abrechnung ist als erledigt gekennzeichnet und kann "
"nicht mehr geändert werden.\n\nZum Ändern: Datei > Abrechnung erledigt / wieder öffnen.")
return True
return False
def erledigt_umschalten(self):
if not self.aid:
return
titel = self.db.abrechnung(self.aid)["titel"]
if self.db.erledigt(self.aid):
if not w.frage(self.root, "Wieder öffnen", f"Abrechnung „{titel}“ wieder öffnen?"):
return
auch = w.frage(self.root, "Wieder öffnen", "Sollen die Positionen auch wieder als NICHT abgerechnet "
"gelten (z. B. um sie neu zu exportieren)?", ja="Ja, freigeben", nein="Nein",
default_ja=False)
self.db.erledigt_setzen(self.aid, False, positionen_aufheben=auch)
else:
if not w.frage(self.root, "Abrechnung erledigt",
f"Abrechnung „{titel}“ als erledigt kennzeichnen?\n\nAlle Händler-Positionen gelten dann "
"als abgerechnet und werden auch in späteren Dateien nicht noch einmal abgerechnet. "
"Die Abrechnung kann danach nicht mehr geändert werden."):
return
self.db.erledigt_setzen(self.aid, True)
self._aktualisieren()
def abgerechnet_aufheben(self):
if self._gesperrt():
return
_, pids, _ = self._auswahl_info()
n = self.db.abrechnung_aufheben(pids)
self._aktualisieren()
self.status.set("info", f" {n} Position(en) wieder offen")
def prozent(self):
if self._gesperrt():
return
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):
if self._gesperrt():
return
sel, pids, ps = self._auswahl_info()
if not ps:
return
self.db.regeln_wiederherstellen([p["id"] for p in ps])
self.db.touch(self.aid)
self._aktualisieren()
self._auswahl_wiederherstellen(sel)
def neu_berechnen(self):
if not self.aid or self._gesperrt():
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 self._gesperrt():
return
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 p["abgerechnet"]:
w.info(self.root, "Betrag ändern", "Diese Position ist bereits abgerechnet.\n\n"
"Zum Ändern: Rechtsklick > Kennzeichnung „abgerechnet“ aufheben.")
return
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 = mn.Popup(self.root)
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)
_, _, ps = self._auswahl_info()
if any(p["haendler_id"] == UNZUGEORDNET for p in ps):
m.add_separator()
m.add_command(label="Kennung als eigene ausblenden", command=self.eigene_ausblenden)
if any(p["haendler_id"] == EIGEN for p in ps):
m.add_separator()
m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden)
if any(p["abgerechnet"] for p in ps):
m.add_separator()
m.add_command(label="Kennzeichnung „abgerechnet“ aufheben", command=self.abgerechnet_aufheben)
m.tk_popup(e.x_root, e.y_root)
# ----- Eigene Kennungen (eigene Filiale) ausblenden
def _kennungen_der_auswahl(self, nur: int) -> list[tuple[str, str]]:
_, _, ps = self._auswahl_info()
return sorted({(p["anbieter"], p["kennung"]) for p in ps if p["haendler_id"] == nur})
def eigene_ausblenden(self, kennungen: list[tuple[str, str]] | None = None):
kennungen = kennungen if kennungen is not None else self._kennungen_der_auswahl(UNZUGEORDNET)
if not kennungen:
w.info(self.root, "Eigene Kennung", "Bitte zuerst nicht zugeordnete Positionen auswählen.")
return
liste = "\n".join(f" {ANBIETER.get(a, a)}: {k or '(leer)'}" for a, k in kennungen)
if not w.frage(self.root, "Eigene Kennung ausblenden",
f"Diese Kennung(en) gehören zur eigenen Filiale und werden künftig ausgeblendet "
f"(nicht angezeigt, nicht mitgerechnet, nicht exportiert):\n\n{liste}\n\n"
"Rückgängig jederzeit unter Händler > Eigene Kennungen."):
return
for a, k in kennungen:
self.db.eigene_setzen(a, k, True)
self._aktualisieren()
def eigene_einblenden(self):
for a, k in self._kennungen_der_auswahl(EIGEN):
self.db.eigene_setzen(a, k, False)
self._aktualisieren()
def eigene_verwalten(self):
if dlg.EigeneKennungenDialog(self.root, self.db).zeigen():
self._aktualisieren()
def haendler_zuweisen(self, automatisch: bool = False):
if self._gesperrt():
return
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):
if self._gesperrt():
return
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
if self._gesperrt(): # erledigt: nichts mehr exportieren (und damit abrechnen)
return
# nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet)
offen = [p for p in self.positionen if p["haendler_id"] > 0 and not p["abgerechnet"] and not p["doppelt"]]
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
for p in offen:
stat[p["haendler_id"]][0] += 1
stat[p["haendler_id"]][1] += p["betrag"]
if not stat:
if any(p["haendler_id"] > 0 for p in self.positionen):
w.info(self.root, "Export", "Alle Positionen dieser Abrechnung sind bereits abgerechnet.\n\n"
"Zum erneuten Export: Datei > Abrechnung erledigt / wieder öffnen (Positionen freigeben).")
else:
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, exportiert = [], []
zeit = res["zeitraum"]
vergeben: set[str] = set() # Dateinamen dieses Exports – gleich lautende Händler überschreiben sich nicht
try:
for hid in res["ids"]:
pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))]
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
summe=res["summe"], null_weglassen=res["null_weglassen"],
vergeben=vergeben)
exportiert += pos
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
hinweis = ""
if res["kennzeichnen"] and exportiert:
n = self.db.abrechnen([p["id"] for p in exportiert])
hinweis = f"\n\n{n} Position(en) als abgerechnet gekennzeichnet."
self._aktualisieren()
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}"
+ (f"\nZeitraum: {zeit.text()}" if zeit else "") + "\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "") + hinweis)
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["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}")
self.settings["update_pruefen"] = res["update"]
config.save(self.settings)
if res["design"] != w.DESIGN:
self.design_wechseln(res["design"])
# ================================================================= Updates
@staticmethod
def _installiert() -> bool:
"""Läuft die per Setup installierte Windows-Version? Nur die kann sich selbst aktualisieren."""
return (bool(getattr(sys, "frozen", False)) and sys.platform.startswith("win")
and Path(sys.executable).name.lower() == "provisionsmanager.exe")
def _im_hintergrund(self, arbeit, fertig):
"""arbeit() im Thread (nur Netzwerk/Dateien, kein Tk), danach fertig(ergebnis) im Tk-Thread."""
erg: dict = {}
def lauf():
try:
erg["wert"] = arbeit()
except Exception as exc:
erg["fehler"] = exc
threading.Thread(target=lauf, daemon=True).start()
def warten():
if self._beendet or self._schliesst:
return
if not erg:
self.root.after(200, warten)
else:
fertig(erg)
self.root.after(200, warten)
def updates_pruefen(self, still: bool = False):
"""Auf Gitea nach einer neueren Version suchen. still = automatisch beim Start: nur melden, wenn es
eine gibt (keine Meldung bei Fehler oder wenn aktuell)."""
if getattr(self, "_update_laeuft", False):
return
self._update_laeuft = True
def fertig(erg):
self._update_laeuft = False
self._update_melden(erg, still)
self._im_hintergrund(updater.neueste, fertig)
def _update_melden(self, erg: dict, still: bool):
if "fehler" in erg:
if not still:
w.warnung(self.root, "Updates", f"Konnte nicht nach Updates suchen:\n{erg['fehler']}")
return
info = erg["wert"]
if not updater.ist_neuer(info["version"], __version__):
if not still:
w.info(self.root, "Updates", f"Sie haben die neueste Version ({__version__}).")
return
if still and modal.offen(): # z. B. Tipp des Tages offen – später noch einmal
self.root.after(3000, lambda: self._update_melden(erg, still))
return
text = f"Version {info['version']} ist verfügbar (installiert: {__version__})."
if self._installiert() and info["setup_url"]:
if w.frage(self.root, "Update verfügbar",
text + "\n\nJetzt herunterladen und installieren? Die Datenbank wird vorher gesichert, das "
"Programm beendet sich kurz und startet danach von selbst wieder.",
ja="Jetzt installieren", nein="Später"):
self._update_installieren(info)
elif w.frage(self.root, "Update verfügbar", text + "\n\nDownload-Seite öffnen?",
ja="Seite öffnen", nein="Später"):
webbrowser.open(info["seite"])
def _update_installieren(self, info: dict):
self.status.set("info", f" Lade Version {info['version']} herunter ...")
ordner = Path(tempfile.gettempdir()) / "Provisionsmanager-Update"
def fertig(erg):
if "fehler" in erg:
self.status.set("info", "")
w.fehler(self.root, "Update", f"Download fehlgeschlagen:\n{erg['fehler']}")
return
try:
self.db.backup(config.app_dir() / f"vor-update-{__version__}.db")
except Exception as exc:
if not w.frage(self.root, "Update", f"Die Datenbank konnte nicht gesichert werden:\n{exc}\n\n"
"Trotzdem installieren?", ja="Installieren", nein="Abbrechen", default_ja=False):
return
# NEUSTART=1: das Setup startet das Programm danach wieder (installer.iss)
subprocess.Popen([str(erg["wert"]), "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART",
"/CLOSEAPPLICATIONS", "/NEUSTART=1"], close_fds=True)
self.beenden()
self._im_hintergrund(lambda: updater.herunterladen(info, ordner), fertig)
def design_umschalten(self):
self.design_wechseln("modern" if w.DUNKEL else "dunkel")
def design_wechseln(self, design: str):
"""Fenster mit dem neuen Design neu aufbauen (main() startet es nach dem Schließen wieder)."""
self.settings["design"] = design
self.neustart = True
self.root.after_idle(self.beenden)
def hilfe(self):
w.info(self.root, "Kurzanleitung",
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in %, Airtime in % (0 % = keine)\n"
" und die Kennungen – bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n"
" oder das Häkchen „Nachnamen in GROSSBUCHSTABEN“. Jahresbonus Neuaktivierung:\n"
" Standard-Staffel oder eigene Staffel je Händler.\n"
" 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, Pfeil runter/Tab = nächste Zeile, Esc = abbrechen).\n\n"
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat (freenet\n"
" und Vodafone zusammen, Airtime als eine Summenzeile) und eine Airtime-Liste.\n"
" Positionen mit 0,00 € werden weggelassen.\n\n"
"Zeitraum: Im Export-Dialog „Nur von … bis …“ (oder „Vormonat“). Exportierte Positionen\n"
"gelten als abgerechnet (grau) und werden nicht noch einmal abgerechnet – auch nicht, wenn\n"
"sie in einer späteren Datei wieder auftauchen. Datei > Abrechnung erledigt sperrt eine\n"
"fertige Abrechnung.\n\n"
"Welche Provisionsart Airtime bzw. Jahresbonus ist, wird automatisch erkannt –\n"
"umstellbar unter Händler > Provisionsarten.\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"], ziel=self.aid)
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. "
"Frühere Abrechnungen öffnen Sie mit einem Klick in der Liste darunter."),
(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.pills["alle"], "Filter",
"Mit diesen Knöpfen zeigen Sie nur bestimmte Positionen: nicht zugeordnete, von Hand geänderte, "
"doppelte oder bereits abgerechnete."),
(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",
"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 „Exportieren“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat "
"(Airtime darin als eine Summenzeile) und – falls vorhanden – die Airtime-Liste."),
(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[self.hilfe_menue], "Das war's!",
"Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü "
f"„{self.hilfe_menue}“.\n\n"
f"Viel Erfolg mit dem {w.APP_NAME}!"),
]
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()} {w.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["trenner"] = self.pw.sash_coord(0)[0]
self.settings["fenster"] = self.rahmen.normale_geometrie()
config.save(self.settings)
except (tk.TclError, OSError):
pass
finally:
try:
if self.db is not None:
self.db.close()
finally:
try:
self.root.destroy()
except tk.TclError:
pass
def _ist_staffel(p: dict) -> bool:
"""Betrag kommt aus der Jahresbonus-Staffel (nicht aus einem Prozentsatz)."""
return p.get("typ") == regeln.JAHRESBONUS and p["prozent"] is None and not p["manuell"] and p["haendler_id"] > 0
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
dateien = [a for a in sys.argv[1:] if Path(a).exists()]
erster = True
while True:
root = tk.Tk(className="Provisionsmanager95")
app = App(root)
if dateien:
root.after(200, lambda: app.importieren(dateien))
dateien = []
if erster and app.settings.get("update_pruefen", True): # nur im echten Start, nicht in Tests
root.after(3000, lambda: app.updates_pruefen(still=True))
erster = False
root.mainloop()
if not app.neustart: # sonst: Design wurde umgeschaltet – Fenster neu aufbauen
break
if __name__ == "__main__":
main()