1392 lines
68 KiB
Python
1392 lines
68 KiB
Python
"""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 config
|
||
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(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.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))
|
||
|
||
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 _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_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._tabelle()
|
||
|
||
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()}
|
||
if self.aid and self.db.abrechnung(self.aid):
|
||
self.positionen = self.db.positionen(self.aid)
|
||
titel = self.db.abrechnung(self.aid)["titel"] + (" – erledigt" if self.db.erledigt(self.aid) 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()
|
||
self._haendlerliste(behalte_auswahl)
|
||
self._tabelle()
|
||
|
||
def _seitenkopf(self):
|
||
"""Titel, Status und Knöpfe über den Kennzahlen."""
|
||
a = self.db.abrechnung(self.aid) if self.aid else 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 = self.db.erledigt(self.aid)
|
||
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._tabelle()
|
||
|
||
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):
|
||
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()]
|
||
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)
|
||
if pruef:
|
||
pos = [p for p in pos if pruef(p)]
|
||
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}
|
||
if all(_ist_staffel(p) for p in plist):
|
||
pz = "Staffel"
|
||
else:
|
||
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 i, p in enumerate(plist):
|
||
tags = ["zebra"] if i % 2 else []
|
||
if p["abgerechnet"] or p["doppelt"]:
|
||
tags.append("abgerechnet")
|
||
elif 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"]),
|
||
"abgerechnet" if p["abgerechnet"] or p["doppelt"] else
|
||
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
|
||
fmt(p["betrag"])),
|
||
tags=tuple(tags))
|
||
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 _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, 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 = []
|
||
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)
|
||
did = self.db.dateien(self.aid)[-1]["id"]
|
||
doppelt = sum(1 for p in self.db.positionen(self.aid) if p["datei_id"] == did and p["doppelt"])
|
||
hinweise = list(erg.warnungen) + ([f"{doppelt} Pos. bereits abgerechnet (werden nicht erneut "
|
||
"exportiert)"] if doppelt else [])
|
||
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
|
||
"; ".join(hinweise) 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 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
|
||
# 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"]
|
||
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"])
|
||
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}")
|
||
if res["design"] != w.DESIGN:
|
||
self.design_wechseln(res["design"])
|
||
|
||
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()
|
||
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 _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()]
|
||
while True:
|
||
root = tk.Tk(className="Provisionsmanager95")
|
||
app = App(root)
|
||
if dateien:
|
||
root.after(200, lambda: app.importieren(dateien))
|
||
dateien = []
|
||
root.mainloop()
|
||
if not app.neustart: # sonst: Design wurde umgeschaltet – Fenster neu aufbauen
|
||
break
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|