Provisions-Manager 95: Import, Händler-DB, Auszahlungsregeln, Excel-Export, Win95-Oberfläche, Wine-Build

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-11-02 09:24:39 +01:00
commit 322d2ed1c3
25 files changed
+3366

No files matched your search

View File
Whitespace-only changes.
+803
View File
@@ -0,0 +1,803 @@
"""Hauptfenster des Provisions-Managers 95."""
from __future__ import annotations
import os
import re
import subprocess
import sys
import tkinter as tk
import traceback
from collections import defaultdict
from datetime import date
from pathlib import Path
from tkinter import filedialog
from provtool import APP_NAME, config
from provtool import export as exp
from provtool.db import UNZUGEORDNET, Datenbank
from provtool.gui import dialogs as dlg
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
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.title(APP_NAME)
root.geometry(self.settings.get("fenster", "1200x720"))
root.minsize(900, 520)
root.protocol("WM_DELETE_WINDOW", self.beenden)
root.report_callback_exception = self._fehler_handler
self._menue()
self._toolbar()
self._body()
self.status = w.Statusleiste(root, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
self.status.pack(side="bottom", fill="x")
self._tasten()
abr = self.db.abrechnungen()
if abr:
self.oeffnen(abr[0]["id"])
else:
self._aktualisieren()
root.after(300, self._willkommen)
# ================================================================= Aufbau
def _menue(self):
m = tk.Menu(self.root, tearoff=0)
d = tk.Menu(m, tearoff=0)
d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu)
d.add_command(label="Abrechnung öffnen…", accelerator="Strg+O", command=self.abrechnung_waehlen)
d.add_separator()
d.add_command(label="Dateien importieren…", accelerator="Strg+I", command=self.importieren)
d.add_command(label="Importierte Dateien…", command=self.dateien)
d.add_command(label="Exportieren…", accelerator="Strg+E", command=self.exportieren)
d.add_separator()
d.add_command(label="Datensicherung erstellen…", command=self.sicherung)
d.add_command(label="Datensicherung einspielen…", command=self.wiederherstellen)
d.add_command(label="Einstellungen…", command=self.einstellungen)
d.add_separator()
d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden)
m.add_cascade(label="Datei", menu=d, underline=0)
b = tk.Menu(m, tearoff=0)
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)
m.add_cascade(label="Bearbeiten", menu=b, underline=0)
h = tk.Menu(m, tearoff=0)
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)
m.add_cascade(label="Händler", menu=h, underline=0)
hi = tk.Menu(m, tearoff=0)
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
hi.add_separator()
hi.add_command(label=f"Info über {APP_NAME}…", command=lambda: dlg.InfoDialog(self.root).zeigen())
m.add_cascade(label="?", menu=hi)
self.root.configure(menu=m)
def _toolbar(self):
tb = tk.Frame(self.root, bg=w.FACE, bd=1, relief="raised")
tb.pack(side="top", fill="x")
for text, icon, cmd, tip in (
("Neu", "neu", self.neu, "Neue Abrechnung anlegen (Strg+N)"),
("Öffnen", "oeffnen", self.abrechnung_waehlen, "Gespeicherte Abrechnung öffnen (Strg+O)"),
(None, None, None, None),
("Import", "import", self.importieren, "CSV-/Excel-Dateien einlesen (Strg+I)"),
("Export", "export", self.exportieren, "Händler-Abrechnungen als Excel speichern (Strg+E)"),
(None, None, None, None),
("Händler", "haendler", self.haendler, "Händler und Kennungen verwalten (Strg+H)"),
("Prozent", "prozent", self.prozent, "Auszahlung für Auswahl in Prozent festlegen (F4)"),
("Betrag", "rechner", self.betrag_bearbeiten, "Betrag der Position ändern (F2)"),
):
if text is None:
w.trenner(tb).pack(side="left", fill="y", padx=4, pady=3)
continue
w.ToolbarButton(tb, text, self.icons(icon), cmd, tip).pack(side="left", padx=1, pady=1)
self.v_suche = tk.StringVar()
sf = tk.Frame(tb, bg=w.FACE)
sf.pack(side="right", padx=8)
tk.Label(sf, text="Suchen:").pack(side="left")
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
e.pack(side="left", padx=4)
self.v_suche.trace_add("write", lambda *_: self._tabelle())
self._suchfeld = e
def _body(self):
desk = tk.Frame(self.root, bg=w.TEAL)
desk.pack(side="top", fill="both", expand=True)
pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0)
pw.pack(fill="both", expand=True, padx=6, pady=6)
# --- Händler-Fenster
self.f_h = w.Fenster(pw, "Händler", self.icons("haendler"))
r, self.htree = w.scroll_tree(self.f_h.inhalt, columns=("anzahl", "summe"), show="tree headings",
selectmode="browse")
self.htree.heading("#0", text="Händler", anchor="w")
self.htree.heading("anzahl", text="Pos.")
self.htree.heading("summe", text="Auszahlung")
self.htree.column("#0", width=150, minwidth=80, stretch=False)
self.htree.column("anzahl", width=38, anchor="e", stretch=False)
self.htree.column("summe", width=84, anchor="e", stretch=False)
w.auto_breite(self.htree, {"#0": 1})
self.htree.tag_configure("grau", foreground=w.SHADOW)
self.htree.tag_configure("fett", font=w.FONT_BOLD)
r.pack(fill="both", expand=True)
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
pw.add(self.f_h, minsize=240, width=310)
# --- Positionen-Fenster
self.f_p = w.Fenster(pw, "Positionen", self.icons("neu"))
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz")
r2, self.ptree = w.scroll_tree(self.f_p.inhalt, columns=cols, show="tree headings", selectmode="extended")
self.ptree.heading("#0", text="Provisionsart / Kunde", anchor="w")
self.ptree.column("#0", width=200, minwidth=140, stretch=False)
for c, t, b, a in (("datum", "Datum", 76, "w"), ("rufnummer", "Rufnummer", 100, "w"),
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 84, "w"),
("kennung", "Kennung", 70, "w"),
("orig", "Provision", 78, "e"), ("proz", "%", 52, "e"), ("ausz", "Auszahlung", 84, "e")):
self.ptree.heading(c, text=t, anchor=a)
self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False)
w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4})
self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD)
self.ptree.tag_configure("manuell", foreground="#0000c0")
self.ptree.tag_configure("abw", foreground="#a00000")
self.ptree.tag_configure("negativ", foreground="#a00000")
r2.pack(fill="both", expand=True)
self.ptree.bind("<Double-1>", self._doppelklick)
self.ptree.bind("<Button-3>", self._kontextmenue)
self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl())
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
leiste.pack(fill="x", pady=(4, 0))
w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent")).pack(side="left")
w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
self.v_alle_auf = tk.BooleanVar(value=True)
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack(
side="left", padx=8)
pw.add(self.f_p, minsize=500)
def _tasten(self):
r = self.root
r.bind_all("<Control-n>", lambda e: self.neu())
r.bind_all("<Control-o>", lambda e: self.abrechnung_waehlen())
r.bind_all("<Control-i>", lambda e: self.importieren())
r.bind_all("<Control-e>", lambda e: self.exportieren())
r.bind_all("<Control-h>", lambda e: self.haendler())
r.bind_all("<Control-f>", lambda e: self._suchfeld.focus_set())
r.bind_all("<F1>", lambda e: self.hilfe())
self.ptree.bind("<F2>", lambda e: self.betrag_bearbeiten())
self.ptree.bind("<Return>", lambda e: self.betrag_bearbeiten())
self.ptree.bind("<F4>", lambda e: self.prozent())
# ================================================================= Daten & Anzeige
def oeffnen(self, aid: int):
self.aid = aid
self._aktualisieren()
def _aktualisieren(self, behalte_auswahl: bool = True):
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
if self.aid and self.db.abrechnung(self.aid):
self.positionen = self.db.positionen(self.aid)
titel = self.db.abrechnung(self.aid)["titel"]
self.root.title(f"{APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}")
else:
self.aid = None
self.positionen = []
self.root.title(APP_NAME)
self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import")
self._haendlerliste(behalte_auswahl)
self._tabelle()
def _gefiltert(self) -> list[dict]:
if self.ansicht == ALLE:
return self.positionen
return [p for p in self.positionen if p["haendler_id"] == self.ansicht]
def _haendlerliste(self, behalte_auswahl=True):
t = self.htree
t.delete(*t.get_children())
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0, 0])
for p in self.positionen:
s = stat[p["haendler_id"]]
s[0] += 1
s[1] += p["betrag"]
s[2] += p["betrag_orig"]
ges = [sum(s[0] for s in stat.values()), sum(s[1] for s in stat.values())]
t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",))
for h in self.db.haendler_liste():
s = stat.get(h.id, [0, 0, 0])
tags = () if s[0] else ("grau",)
t.insert("", "end", iid=str(h.id), text=h.name, values=(s[0] or "", fmt(s[1]) if s[0] else ""), tags=tags)
s = stat.get(UNZUGEORDNET)
if s:
t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])),
tags=("grau",))
ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE)
self.ansicht = int(ziel)
t.selection_set(ziel)
t.see(ziel)
def _haendler_gewaehlt(self, _e=None):
sel = self.htree.selection()
if sel and int(sel[0]) != self.ansicht:
self.ansicht = int(sel[0])
self._tabelle()
def _haendler_doppelklick(self):
if self.ansicht > 0:
self.haendler(self.ansicht)
elif self.ansicht == UNZUGEORDNET:
self.kennung_zuordnen_auswahl()
def _gruppe_von(self, p: dict) -> str:
if self.ansicht == UNZUGEORDNET:
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}"
if self.ansicht == ALLE:
name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)")
return f"{name} · {p['provisionsart'] or '(ohne)'}"
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}"
def _tabelle(self):
if self._edit:
self._edit.destroy()
self._edit = None
t = self.ptree
offen = {iid for iid in t.get_children() if t.item(iid, "open")}
war_leer = not t.get_children()
t.delete(*t.get_children())
such = self.v_suche.get().strip().lower()
pos = self._gefiltert()
if such:
pos = [p for p in pos if such in " ".join(str(p.get(k) or "") for k in (
"rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
"imei")).lower()]
gruppen: dict[str, list[dict]] = defaultdict(list)
for p in exp.sortiert(pos):
gruppen[self._gruppe_von(p)].append(p)
self._gruppen = {}
for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
giid = f"g{gi}"
self._gruppen[giid] = (gname, plist)
so = sum(p["betrag_orig"] for p in plist)
sa = sum(p["betrag"] for p in plist)
proz = {p["prozent"] for p in plist}
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
t.insert("", "end", iid=giid, text=f"{gname} ({len(plist)})", open=(war_leer or giid in offen or bool(such)),
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
for p in plist:
tags = []
if p["manuell"]:
tags.append("manuell")
elif p["betrag"] != p["betrag_orig"]:
tags.append("abw")
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
p["kennung"], fmt(p["betrag_orig"]),
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
tags=tuple(tags))
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
self.f_p.set_titel(f"Positionen – {name}")
so = sum(p["betrag_orig"] for p in pos)
sa = sum(p["betrag"] for p in pos)
self.status.set("pos", f" {len(pos)} Positionen")
self.status.set("orig", f" Provision: {fmt(so)}")
self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
def _status_auswahl(self):
pids = self._ausgewaehlte_pids()
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())):
ps = [p for p in self.positionen if p["id"] in set(pids)]
self.status.set("pos", f" {len(ps)} ausgewählt")
self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}")
self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
def _klappen(self, auf: bool):
for iid in self.ptree.get_children():
self.ptree.item(iid, open=auf)
def _ausgewaehlte_pids(self) -> list[int]:
pids: list[int] = []
for iid in self.ptree.selection():
if iid.startswith("g"):
pids.extend(p["id"] for p in self._gruppen[iid][1])
elif iid.startswith("p"):
pids.append(int(iid[1:]))
return list(dict.fromkeys(pids))
# ================================================================= Aktionen
def _brauche_abrechnung(self, frage_neu=True) -> bool:
if self.aid:
return True
if frage_neu:
return self.neu()
return False
def _vorschlag_titel(self) -> str:
heute = date.today()
m = heute.month - 1 or 12
y = heute.year if heute.month > 1 else heute.year - 1
return f"{MONATE[m - 1]} {y}"
def neu(self) -> bool:
t = w.eingabe(self.root, "Neue Abrechnung", "Bezeichnung der Abrechnung (z. B. Abrechnungsmonat):",
self._vorschlag_titel())
if not t or not t.strip():
return False
self.aid = self.db.abrechnung_neu(t.strip())
self.ansicht = ALLE
self._aktualisieren()
return True
def abrechnung_waehlen(self):
if not self.db.abrechnungen():
w.info(self.root, "Abrechnung öffnen", "Es gibt noch keine gespeicherten Abrechnungen.")
return
d = dlg.AbrechnungenDialog(self.root, self.db, self.aid)
res = d.zeigen()
if res and res[0] == "oeffnen":
self.ansicht = ALLE
self.oeffnen(res[1])
else:
self._aktualisieren()
def importieren(self, dateien: list[str] | None = None):
if not dateien:
dateien = list(filedialog.askopenfilenames(
parent=self.root, title="Provisionsabrechnungen importieren",
initialdir=self.settings.get("import_ordner") or None,
filetypes=[("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
("CSV-Dateien", "*.csv"), ("Excel-Dateien", "*.xlsx *.xlsm *.xls"),
("Alle Dateien", "*.*")]))
if not dateien:
return
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
if not self._brauche_abrechnung():
return
bericht = []
self.root.configure(cursor="watch")
self.root.update()
try:
for f in dateien:
name = Path(f).name
try:
erg = lies_datei(f)
except ImportFehler as exc:
bericht.append((name, "–", 0, 0, str(exc).split("\n")[0]))
continue
except Exception as exc:
bericht.append((name, "–", 0, 0, f"Fehler: {exc}"))
continue
schon = self.db.datei_schon_importiert(erg.sha256)
if schon and not w.frage(self.root, "Bereits importiert",
f"„{name}“ wurde bereits importiert (in: {', '.join(schon)}).\n\n"
"Trotzdem noch einmal importieren? Die Positionen wären dann doppelt.",
default_ja=False):
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
continue
n = self.db.importieren(self.aid, erg)
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
"; ".join(erg.warnungen) or "OK"))
finally:
self.root.configure(cursor="")
self._aktualisieren()
unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen:
if p["haendler_id"] == UNZUGEORDNET:
u = unbekannt[(p["anbieter"], p["kennung"])]
u[0] += 1
u[1] += p["betrag_orig"]
res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen()
if res:
self.kennung_zuordnen(*res)
def dateien(self):
if not self.aid:
return
if dlg.DateienDialog(self.root, self.db, self.aid).zeigen():
self._aktualisieren()
def haendler(self, hid: int | None = None, neue_kennung: tuple[str, str] | None = None):
geaendert = dlg.HaendlerDialog(self.root, self.db, hid, neue_kennung).zeigen()
if geaendert:
self._nach_haendler_aenderung()
def _nach_haendler_aenderung(self):
if self.aid:
n = self.db.neu_berechnen(self.aid)
if n:
self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet")
self._aktualisieren()
def kennung_zuordnen(self, anbieter: str, kennung: str):
res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen()
if not res:
return
if res[0] == "neu":
self.haendler(neue_kennung=(anbieter, kennung))
else:
self._nach_haendler_aenderung()
def kennung_zuordnen_auswahl(self):
pids = self._ausgewaehlte_pids()
p = next((x for x in self.positionen if pids and x["id"] == pids[0]), None)
if not p:
w.info(self.root, "Kennung zuordnen", "Bitte zuerst eine Position oder Gruppe auswählen.")
return
if p["haendler_id"] != UNZUGEORDNET:
w.info(self.root, "Kennung zuordnen",
f"Die Kennung {p['kennung']} gehört bereits zu „{self.haendler_namen.get(p['haendler_id'])}“.")
return
self.kennung_zuordnen(p["anbieter"], p["kennung"])
def _auswahl_info(self):
sel = self.ptree.selection()
pids = self._ausgewaehlte_pids()
ps = [p for p in self.positionen if p["id"] in set(pids)]
return sel, pids, ps
def prozent(self):
sel, pids, ps = self._auswahl_info()
if not ps:
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
"in der Liste auswählen.")
return
hids = {p["haendler_id"] for p in ps}
arten = {p["provisionsart"] for p in ps}
hid = next(iter(hids)) if len(hids) == 1 else None
art = next(iter(arten)) if len(arten) == 1 else None
regel_moeglich = bool(hid and hid > 0 and art is not None)
hname = self.haendler_namen.get(hid, "(Nicht zugeordnet)") if hid is not None else "(mehrere)"
akt = {p["prozent"] for p in ps if p["prozent"] is not None}
vorschlag = next(iter(akt)) if len(akt) == 1 else 100.0
nur_gruppe = len(sel) == 1 and sel[0].startswith("g")
res = dlg.ProzentDialog(self.root, hname, art if nur_gruppe or len(ps) > 1 else None, len(ps),
sum(p["betrag_orig"] for p in ps), vorschlag, regel_moeglich).zeigen()
if not res:
return
proz, als_regel, manuelle = res
if als_regel and regel_moeglich:
self.db.regel_setzen(hid, art, proz)
self.db.prozent_anwenden(pids, proz, auch_manuelle=manuelle)
self.db.touch(self.aid)
self._aktualisieren()
self._auswahl_wiederherstellen(sel)
def _auswahl_wiederherstellen(self, sel):
vorhanden = [i for i in sel if self.ptree.exists(i)]
if vorhanden:
self.ptree.selection_set(vorhanden)
self.ptree.see(vorhanden[0])
def zuruecksetzen(self):
sel, pids, ps = self._auswahl_info()
if not ps:
return
for p in ps:
self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"]))
self.db.touch(self.aid)
self._aktualisieren()
self._auswahl_wiederherstellen(sel)
def neu_berechnen(self):
if not self.aid:
return
manuell = sum(1 for p in self.positionen if p["manuell"])
auch = False
if manuell:
auch = w.frage(self.root, "Neu berechnen",
f"{manuell} Position(en) wurden von Hand geändert.\n\nSollen diese ebenfalls nach den "
"Händler-Regeln neu berechnet werden?", ja="Ja, alle", nein="Nein, behalten",
default_ja=False)
n = self.db.neu_berechnen(self.aid, auch_manuelle=auch)
self._aktualisieren()
w.info(self.root, "Neu berechnen", f"{n} Position(en) wurden neu berechnet.")
# ----- Inline-Bearbeitung des Auszahlungsbetrags
def _doppelklick(self, e):
iid = self.ptree.identify_row(e.y)
if not iid:
return
if iid.startswith("g"):
self.ptree.selection_set(iid)
if self.ptree.identify_column(e.x) in ("#7", "#8"):
self.prozent()
return "break"
return
self.ptree.selection_set(iid)
self.betrag_bearbeiten(iid)
def betrag_bearbeiten(self, iid: str | None = None):
if iid is None:
sel = [i for i in self.ptree.selection() if i.startswith("p")]
if len(sel) != 1:
if any(i.startswith("g") for i in self.ptree.selection()) or len(sel) > 1:
self.prozent()
else:
w.info(self.root, "Betrag ändern", "Bitte eine einzelne Position auswählen.")
return
iid = sel[0]
self.ptree.see(iid)
self.ptree.update_idletasks()
bbox = self.ptree.bbox(iid, "ausz")
if not bbox:
return
pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid)
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 _kontextmenue(self, e):
iid = self.ptree.identify_row(e.y)
if not iid:
return
if iid not in self.ptree.selection():
self.ptree.selection_set(iid)
m = tk.Menu(self.root, tearoff=0)
if iid.startswith("p"):
m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid))
m.add_command(label="Auszahlung in Prozent…", command=self.prozent)
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
m.add_separator()
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
m.add_separator()
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
m.tk_popup(e.x_root, e.y_root)
def _null(self):
sel, pids, _ = self._auswahl_info()
self.db.prozent_anwenden(pids, 0.0)
self.db.touch(self.aid)
self._aktualisieren()
self._auswahl_wiederherstellen(sel)
# ----- Export
def exportieren(self):
if not self.aid or not self.positionen:
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
"Bitte zuerst Dateien importieren.")
return
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen:
if p["haendler_id"] != UNZUGEORDNET:
stat[p["haendler_id"]][0] += 1
stat[p["haendler_id"]][1] += p["betrag"]
if not stat:
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
return
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in
sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
vor = {self.ansicht} if self.ansicht in stat else set(stat)
titel = self.db.abrechnung(self.aid)["titel"]
res = dlg.ExportDialog(self.root, liste, vor, self.settings, titel).zeigen()
if not res:
return
config.save(self.settings)
erzeugt = []
try:
for hid in res["ids"]:
pos = [p for p in self.positionen if p["haendler_id"] == hid]
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
summe=res["summe"])
except PermissionError as exc:
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
"Ist sie vielleicht noch in Excel geöffnet?")
return
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n…" if len(erzeugt) > 15 else ""))
if res["oeffnen"]:
_oeffne_ordner(res["ordner"])
# ----- Datei-Menü
def sicherung(self):
p = filedialog.asksaveasfilename(parent=self.root, title="Datensicherung speichern", defaultextension=".db",
initialfile=f"Provisionen-Sicherung-{date.today().isoformat()}.db",
filetypes=[("Datenbank", "*.db")])
if p:
self.db.backup(p)
w.info(self.root, "Datensicherung", f"Sicherung gespeichert:\n{p}")
def wiederherstellen(self):
p = filedialog.askopenfilename(parent=self.root, title="Datensicherung einspielen",
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
if not p:
return
if not w.frage(self.root, "Datensicherung einspielen",
"Die aktuelle Datenbank wird durch die Sicherung ERSETZT.\n"
"Vorher wird automatisch eine Kopie (*.vor-wiederherstellung.db) angelegt.\n\nFortfahren?",
default_ja=False):
return
ziel = self.db.path
self.db.backup(ziel.with_suffix(".vor-wiederherstellung.db"))
self.db.close()
try:
Datenbank.restore(p, ziel)
except Exception as exc:
w.fehler(self.root, "Datensicherung", f"Die Datei ist keine gültige Sicherung:\n{exc}")
self.db = Datenbank(ziel)
self.aid = None
abr = self.db.abrechnungen()
if abr:
self.aid = abr[0]["id"]
self.ansicht = ALLE
self._aktualisieren()
def einstellungen(self):
neu = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
if neu and Path(neu) != self.db.path:
try:
db = Datenbank(neu)
except Exception as exc:
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
return
self.db.close()
self.db = db
self.settings["db_pfad"] = neu
config.save(self.settings)
abr = self.db.abrechnungen()
self.aid = abr[0]["id"] if abr else None
self.ansicht = ALLE
self._aktualisieren()
def hilfe(self):
w.info(self.root, "Kurzanleitung",
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n\n"
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
" auch alle Wochenlisten eines Monats.\n\n"
"4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n"
" • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n"
" optional als Standard für diesen Händler merken.\n"
" • Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
" (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n"
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
"Alles wird automatisch in der Datenbank gespeichert.")
def _willkommen(self):
if not self.db.haendler_liste():
if w.frage(self.root, "Willkommen",
f"Willkommen beim {APP_NAME}!\n\nEs sind noch keine Händler angelegt.\n"
"Jetzt die Unterhändler mit ihren Kennungen anlegen?"):
self.haendler()
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(text + "\n")
except OSError:
pass
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'}")
def beenden(self):
try:
self.settings["fenster"] = self.root.geometry()
config.save(self.settings)
self.db.close()
finally:
self.root.destroy()
def main():
if sys.platform.startswith("win"):
try: # scharfe Darstellung auf HiDPI-Bildschirmen
import ctypes
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
root = tk.Tk(className="Provisionsmanager95")
app = App(root)
if len(sys.argv) > 1:
root.after(200, lambda: app.importieren([a for a in sys.argv[1:] if Path(a).exists()]))
root.mainloop()
if __name__ == "__main__":
main()
+633
View File
@@ -0,0 +1,633 @@
"""Dialogfenster des Provisions-Managers."""
from __future__ import annotations
import tkinter as tk
from tkinter import filedialog, ttk
from provtool import APP_NAME, APP_ORG, __version__
from provtool.db import Datenbank, Haendler
from provtool.gui import win95 as w
from provtool.importer import ANBIETER
from provtool.money import fmt, fmt_percent, parse_percent
# =========================================================================== Händlerverwaltung
class HaendlerDialog(w.Dialog):
def __init__(self, master, db: Datenbank, auswahl_id: int | None = None,
neue_kennung: tuple[str, str] | None = None):
super().__init__(master, "Händler verwalten", resizable=True)
self.db = db
self.aktuell: Haendler | None = None
self.geaendert = False
haupt = tk.Frame(self, bg=w.FACE)
haupt.pack(fill="both", expand=True, padx=8, pady=8)
# --- links: Liste
links = tk.Frame(haupt, bg=w.FACE)
links.pack(side="left", fill="y")
tk.Label(links, text="Händler:", anchor="w").pack(fill="x")
rahmen, self.liste = w.scroll_tree(links, columns=("name",), show="tree", selectmode="browse", height=16)
self.liste.column("#0", width=0, stretch=False)
self.liste.column("name", width=190)
rahmen.pack(fill="y", expand=True)
self.liste.bind("<<TreeviewSelect>>", self._auswahl)
lk = tk.Frame(links, bg=w.FACE)
lk.pack(fill="x", pady=(6, 0))
w.knopf(lk, "Neu", self._neu, breite=8).pack(side="left")
w.knopf(lk, "Löschen", self._loeschen, breite=8).pack(side="left", padx=4)
# --- rechts: Formular
rechts = tk.Frame(haupt, bg=w.FACE)
rechts.pack(side="left", fill="both", expand=True, padx=(10, 0))
g = w.gruppe(rechts, "Stammdaten")
g.pack(fill="x")
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar()
self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
("E-Mail:", self.v_mail))):
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
tk.Entry(g, textvariable=var, width=36).grid(row=r, column=1, columnspan=2, sticky="we", pady=2)
tk.Label(g, text="Standard-Auszahlung:", anchor="w").grid(row=3, column=0, sticky="w", pady=2)
pf = tk.Frame(g, bg=w.FACE)
pf.grid(row=3, column=1, sticky="w")
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left")
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e")
tk.Label(g, text="Notiz:", anchor="nw").grid(row=4, column=0, sticky="nw", pady=2)
self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT)
self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2)
g.columnconfigure(1, weight=1)
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
g2.pack(fill="both", expand=True, pady=(8, 0))
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=4,
selectmode="browse")
self.kliste.heading("anbieter", text="Anbieter")
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
self.kliste.column("anbieter", width=90)
self.kliste.column("wert", width=200)
r2.pack(fill="both", expand=True)
kf = tk.Frame(g2, bg=w.FACE)
kf.pack(fill="x", pady=(4, 0))
self.v_kanb = tk.StringVar(value="freenet")
cb = ttk.Combobox(kf, textvariable=self.v_kanb, values=list(ANBIETER.values()), width=10, state="readonly")
cb.pack(side="left")
self.v_kwert = tk.StringVar()
ke = tk.Entry(kf, textvariable=self.v_kwert, width=18)
ke.pack(side="left", padx=4)
ke.bind("<Return>", lambda e: self._kennung_dazu())
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4)
g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart")
g3.pack(fill="both", expand=True, pady=(8, 0))
r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=4, selectmode="browse")
self.rliste.heading("art", text="Provisionsart")
self.rliste.heading("proz", text="Auszahlung")
self.rliste.column("art", width=240)
self.rliste.column("proz", width=70, anchor="e")
r3.pack(fill="both", expand=True)
rf = tk.Frame(g3, bg=w.FACE)
rf.pack(fill="x", pady=(4, 0))
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent…“.", fg=w.SHADOW).pack(side="left")
w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
unten = tk.Frame(self, bg=w.FACE)
unten.pack(fill="x", padx=8, pady=(0, 8))
w.knopf(unten, "Schließen", self.abbrechen, breite=10).pack(side="right")
w.knopf(unten, "Speichern", self._speichern, breite=10, default="active").pack(side="right", padx=6)
self._kennungen: list[tuple[str, str]] = []
self._liste_laden(auswahl_id)
if neue_kennung:
self._neu()
self._kennungen = [neue_kennung]
self._kennungen_zeigen()
self.minsize(640, 460)
# ---- intern
@staticmethod
def _anbieter_key(anzeige: str) -> str:
return next((k for k, v in ANBIETER.items() if v == anzeige), anzeige)
def _liste_laden(self, auswahl_id=None):
self.liste.delete(*self.liste.get_children())
for h in self.db.haendler_liste():
self.liste.insert("", "end", iid=str(h.id), values=(h.name + ("" if h.aktiv else " (inaktiv)"),))
if auswahl_id and self.liste.exists(str(auswahl_id)):
self.liste.selection_set(str(auswahl_id))
self.liste.see(str(auswahl_id))
elif self.liste.get_children():
self.liste.selection_set(self.liste.get_children()[0])
else:
self._neu()
def _auswahl(self, _e=None):
sel = self.liste.selection()
if not sel:
return
h = self.db.haendler(int(sel[0]))
if not h:
return
self.aktuell = h
self.v_name.set(h.name)
self.v_ap.set(h.ansprechpartner)
self.v_mail.set(h.email)
self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", ""))
self.v_aktiv.set(h.aktiv)
self.t_notiz.delete("1.0", "end")
self.t_notiz.insert("1.0", h.notiz or "")
self._kennungen = self.db.kennungen(h.id)
self._kennungen_zeigen()
self._regeln_zeigen()
def _kennungen_zeigen(self):
self.kliste.delete(*self.kliste.get_children())
for i, (a, v) in enumerate(self._kennungen):
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
def _regeln_zeigen(self):
self.rliste.delete(*self.rliste.get_children())
if self.aktuell and self.aktuell.id:
for art, p in sorted(self.db.regeln(self.aktuell.id).items()):
self.rliste.insert("", "end", iid=art, values=(art, fmt_percent(p)))
def _neu(self):
self.liste.selection_remove(self.liste.selection())
self.aktuell = Haendler(0, "")
for v in (self.v_name, self.v_ap, self.v_mail):
v.set("")
self.v_proz.set("100")
self.v_aktiv.set(True)
self.t_notiz.delete("1.0", "end")
self._kennungen = []
self._kennungen_zeigen()
self._regeln_zeigen()
def _kennung_dazu(self):
wert = self.v_kwert.get().strip()
if not wert:
return
anb = self._anbieter_key(self.v_kanb.get())
if (anb, wert) not in self._kennungen:
self._kennungen.append((anb, wert))
self.v_kwert.set("")
self._kennungen_zeigen()
def _kennung_weg(self):
sel = self.kliste.selection()
if sel:
del self._kennungen[int(sel[0])]
self._kennungen_zeigen()
def _regel_weg(self):
sel = self.rliste.selection()
if sel and self.aktuell and self.aktuell.id:
self.db.regel_setzen(self.aktuell.id, sel[0], None)
self.geaendert = True
self._regeln_zeigen()
def _speichern(self):
name = self.v_name.get().strip()
if not name:
w.warnung(self, "Händler", "Bitte einen Namen eingeben.")
return
try:
proz = parse_percent(self.v_proz.get())
except ValueError:
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
return
if self.v_kwert.get().strip():
self._kennung_dazu()
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get())
try:
hid = self.db.haendler_speichern(h, self._kennungen)
except Exception as exc: # sqlite3.IntegrityError / ValueError
msg = str(exc)
if "UNIQUE" in msg and "haendler.name" in msg:
msg = f"Es gibt bereits einen Händler „{name}“."
w.fehler(self, "Händler speichern", msg)
return
self.geaendert = True
self._liste_laden(hid)
def _loeschen(self):
if not self.aktuell or not self.aktuell.id:
return
if w.frage(self, "Händler löschen",
f"Händler „{self.aktuell.name}“ mit allen Kennungen und Regeln löschen?\n\n"
"Positionen in Abrechnungen bleiben erhalten, gelten dann aber als „nicht zugeordnet“.",
default_ja=False):
self.db.haendler_loeschen(self.aktuell.id)
self.geaendert = True
self.aktuell = None
self._liste_laden()
def abbrechen(self):
self.result = self.geaendert
self.destroy()
# =========================================================================== Prozent anwenden
class ProzentDialog(w.Dialog):
def __init__(self, master, haendler_name: str, provisionsart: str | None, anzahl: int, summe_orig: int,
vorschlag: float, regel_moeglich: bool):
super().__init__(master, "Auszahlung festlegen")
self.summe_orig = summe_orig
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", padx=12, pady=12)
ic = w.make_icon(self, "prozent", 2)
self._ic = ic
tk.Label(f, image=ic).grid(row=0, column=0, rowspan=4, sticky="n", padx=(0, 12))
was = f"Provisionsart „{provisionsart}“" if provisionsart else f"{anzahl} ausgewählte Position(en)"
tk.Label(f, text=f"Händler: {haendler_name}\n{was}\nProvision laut Abrechnung: {fmt(summe_orig)}",
justify="left", anchor="w").grid(row=0, column=1, columnspan=3, sticky="w")
tk.Label(f, text="Auszahlung:").grid(row=1, column=1, sticky="w", pady=(10, 2))
self.v = tk.StringVar(value=fmt_percent(vorschlag).replace(" %", ""))
ef = tk.Frame(f, bg=w.FACE)
ef.grid(row=1, column=2, columnspan=2, sticky="w", pady=(10, 2))
e = tk.Entry(ef, textvariable=self.v, width=8, justify="right")
e.pack(side="left")
tk.Label(ef, text="% der Provision").pack(side="left", padx=4)
self.vorschau = tk.Label(f, text="", fg=w.NAVY, anchor="w")
self.vorschau.grid(row=2, column=1, columnspan=3, sticky="w")
self.v_regel = tk.BooleanVar(value=bool(provisionsart and regel_moeglich))
self.v_manuell = tk.BooleanVar(value=False)
cbf = tk.Frame(f, bg=w.FACE)
cbf.grid(row=3, column=1, columnspan=3, sticky="w", pady=(8, 0))
if provisionsart and regel_moeglich:
tk.Checkbutton(cbf, text="Als Standard für diesen Händler und diese Provisionsart merken",
variable=self.v_regel).pack(anchor="w")
tk.Checkbutton(cbf, text="Auch von Hand geänderte Beträge überschreiben",
variable=self.v_manuell).pack(anchor="w")
self.v.trace_add("write", lambda *_: self._vorschau())
self._vorschau()
self.bind("<Return>", lambda _e: self._ok())
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 12))
e.select_range(0, "end")
self._e = e
def _vorschau(self):
from provtool.money import apply_percent
try:
p = parse_percent(self.v.get())
self.vorschau.configure(text=f"→ Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
except ValueError:
self.vorschau.configure(text="(ungültiger Wert)")
def _ok(self):
try:
p = parse_percent(self.v.get())
except ValueError:
w.warnung(self, "Auszahlung", "Bitte einen Prozentwert eingeben, z. B. 100, 80 oder 72,5.")
return
self.result = (p, self.v_regel.get(), self.v_manuell.get())
self.destroy()
def zeigen(self, fokus=None):
return super().zeigen(self._e)
# =========================================================================== Abrechnungen
class AbrechnungenDialog(w.Dialog):
def __init__(self, master, db: Datenbank, aktuelle_id: int | None):
super().__init__(master, "Abrechnung öffnen", resizable=True)
self.db = db
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings",
height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120),
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
r.pack(fill="both", expand=True, padx=10)
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
w.knopf(k, "Umbenennen…", self._umbenennen, breite=12).pack(side="left", padx=6)
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
self.bind("<Return>", lambda e: self._oeffnen())
self._laden(aktuelle_id)
def _laden(self, sel=None):
self.tree.delete(*self.tree.get_children())
for a in self.db.abrechnungen():
self.tree.insert("", "end", iid=str(a["id"]),
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
kids = self.tree.get_children()
if sel and self.tree.exists(str(sel)):
self.tree.selection_set(str(sel))
elif kids:
self.tree.selection_set(kids[0])
def _sel(self) -> int | None:
s = self.tree.selection()
return int(s[0]) if s else None
def _oeffnen(self):
if self._sel():
self.result = ("oeffnen", self._sel())
self.destroy()
def _umbenennen(self):
aid = self._sel()
if not aid:
return
t = w.eingabe(self, "Umbenennen", "Neue Bezeichnung:", self.db.abrechnung(aid)["titel"])
if t and t.strip():
self.db.abrechnung_umbenennen(aid, t.strip())
self._laden(aid)
self.result = ("geaendert", aid)
def _loeschen(self):
aid = self._sel()
if not aid:
return
titel = self.db.abrechnung(aid)["titel"]
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
default_ja=False):
self.db.abrechnung_loeschen(aid)
self._laden()
self.result = ("geloescht", aid)
def abbrechen(self):
self.destroy()
class DateienDialog(w.Dialog):
"""Zeigt die importierten Dateien einer Abrechnung; einzelne Dateien können wieder entfernt werden."""
def __init__(self, master, db: Datenbank, aid: int):
super().__init__(master, "Importierte Dateien", resizable=True)
self.db, self.aid, self.geaendert = db, aid, False
r, self.tree = w.scroll_tree(self, columns=("datei", "format", "anzahl", "zeit"), show="headings", height=8,
selectmode="browse")
for c, t, b in (("datei", "Datei", 260), ("format", "Format", 80), ("anzahl", "Positionen", 70),
("zeit", "Importiert", 120)):
self.tree.heading(c, text=t)
self.tree.column(c, width=b)
r.pack(fill="both", expand=True, padx=10, pady=(10, 0))
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
w.knopf(k, "Datei entfernen", self._weg).pack(side="left")
w.knopf(k, "Schließen", self.abbrechen, breite=10).pack(side="right")
self._laden()
def _laden(self):
self.tree.delete(*self.tree.get_children())
for d in self.db.dateien(self.aid):
self.tree.insert("", "end", iid=str(d["id"]),
values=(d["dateiname"], d["format"], d["anzahl"], d["importiert"][:16]))
def _weg(self):
s = self.tree.selection()
if not s:
return
name = self.tree.item(s[0], "values")[0]
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
self.db.datei_entfernen(int(s[0]))
self.geaendert = True
self._laden()
def abbrechen(self):
self.result = self.geaendert
self.destroy()
# =========================================================================== Import-Bericht
class ImportBericht(w.Dialog):
def __init__(self, master, zeilen: list[tuple[str, str, int, int, str]], unbekannt: list[tuple[str, str, int, int]]):
"""zeilen: (datei, format, anzahl, summe_cent, hinweis); unbekannt: (anbieter, kennung, anzahl, summe)."""
super().__init__(master, "Import abgeschlossen", resizable=True)
tk.Label(self, text="Folgende Dateien wurden eingelesen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r, t = w.scroll_tree(self, columns=("datei", "format", "anzahl", "summe", "hinweis"), show="headings",
height=min(6, max(2, len(zeilen))))
for c, h, b, a in (("datei", "Datei", 220, "w"), ("format", "Format", 70, "w"), ("anzahl", "Pos.", 50, "e"),
("summe", "Summe", 90, "e"), ("hinweis", "Hinweis", 260, "w")):
t.heading(c, text=h)
t.column(c, width=b, anchor=a)
for d, f, n, s, hw in zeilen:
t.insert("", "end", values=(d, f, n, fmt(s) if n else "", hw))
r.pack(fill="both", expand=True, padx=10)
self.zuordnen = None
if unbekannt:
tk.Label(self, text="Nicht zugeordnete Kennungen (kein Händler hinterlegt – z. B. eigene Filiale):",
anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings",
height=min(6, len(unbekannt)), selectmode="browse")
for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
self.ut.heading(c, text=h)
self.ut.column(c, width=b, anchor=a)
for a, k, n, s in unbekannt:
self.ut.insert("", "end", iid=f"{a}\t{k}", values=(ANBIETER.get(a, a), k or "(leer)", n, fmt(s)))
r2.pack(fill="both", expand=True, padx=10)
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
if unbekannt:
w.knopf(k, "Kennung einem Händler zuordnen…", self._zuordnen).pack(side="left")
w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
self.bind("<Return>", lambda e: self.abbrechen())
def _zuordnen(self):
s = self.ut.selection()
if s:
self.result = tuple(s[0].split("\t", 1))
self.destroy()
class KennungZuordnen(w.Dialog):
"""Fragt, ob eine Kennung einem bestehenden oder neuen Händler zugeordnet werden soll."""
def __init__(self, master, db: Datenbank, anbieter: str, kennung: str):
super().__init__(master, "Kennung zuordnen")
self.db, self.anbieter, self.kennung = db, anbieter, kennung
tk.Label(self, text=f"{ANBIETER.get(anbieter, anbieter)}-Kennung {kennung} zuordnen zu:",
anchor="w").pack(fill="x", padx=12, pady=(12, 6))
self.v = tk.StringVar(value="__neu__")
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="x", padx=20)
tk.Radiobutton(f, text="Neuem Händler …", variable=self.v, value="__neu__").pack(anchor="w")
haendler = db.haendler_liste()
if haendler:
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w")
self.cb = ttk.Combobox(f, values=[h.name for h in haendler], state="readonly", width=34)
self.cb.pack(anchor="w", padx=22, pady=(0, 4))
self.cb.bind("<<ComboboxSelected>>", lambda e: self.v.set("__alt__"))
self._hmap = {h.name: h.id for h in haendler}
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
def _ok(self):
if self.v.get() == "__neu__":
self.result = ("neu", None)
else:
name = self.cb.get()
if not name:
w.warnung(self, "Kennung zuordnen", "Bitte einen Händler auswählen.")
return
hid = self._hmap[name]
kenn = self.db.kennungen(hid) + [(self.anbieter, self.kennung)]
try:
self.db.haendler_speichern(self.db.haendler(hid), kenn)
except ValueError as exc:
w.fehler(self, "Kennung zuordnen", str(exc))
return
self.result = ("alt", hid)
self.destroy()
# =========================================================================== Export
class ExportDialog(w.Dialog):
def __init__(self, master, haendler: list[tuple[int, str, int, int]], vorauswahl: set[int], settings: dict,
titel: str):
"""haendler: (id, name, anzahl, summe_cent)"""
super().__init__(master, "Abrechnungen exportieren")
self.settings = settings
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=10, pady=10)
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8,
selectmode="extended")
self.tree.heading("#0", text="")
self.tree.column("#0", width=30, stretch=False)
for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")):
self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor=a)
self.haken = {}
for hid, name, n, s in haendler:
self.haken[hid] = hid in vorauswahl
self.tree.insert("", "end", iid=str(hid), text="[x]" if self.haken[hid] else "[ ]",
values=(name, n, fmt(s)))
self.tree.bind("<Button-1>", self._klick)
self.tree.bind("<space>", lambda e: [self._toggle(int(i)) for i in self.tree.selection()])
r.pack(fill="both", expand=True, pady=(2, 4))
kk = tk.Frame(f, bg=w.FACE)
kk.pack(fill="x")
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
g = w.gruppe(f, "Optionen")
g.pack(fill="x", pady=(8, 0))
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
self.v_titel = tk.StringVar(value=titel)
tk.Entry(g, textvariable=self.v_titel, width=30).grid(row=0, column=1, columnspan=2, sticky="we", pady=2)
tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
w.knopf(g, "Durchsuchen…", self._ordner).grid(row=1, column=2, padx=(4, 0))
self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True))
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
self.v_auf = tk.BooleanVar(value=True)
tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei",
variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w")
tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3,
sticky="w")
tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0,
columnspan=3, sticky="w")
tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0,
columnspan=3, sticky="w")
g.columnconfigure(1, weight=1)
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _klick(self, e):
if self.tree.identify_column(e.x) == "#0":
iid = self.tree.identify_row(e.y)
if iid:
self._toggle(int(iid))
def _toggle(self, hid):
self.haken[hid] = not self.haken[hid]
self.tree.item(str(hid), text="[x]" if self.haken[hid] else "[ ]")
def _alle(self, an):
for hid in self.haken:
self.haken[hid] = an
self.tree.item(str(hid), text="[x]" if an else "[ ]")
def _ordner(self):
d = filedialog.askdirectory(parent=self, title="Zielordner wählen",
initialdir=self.v_ordner.get() or None)
if d:
self.v_ordner.set(d)
def _ok(self):
ids = [h for h, an in self.haken.items() if an]
if not ids:
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
return
if not self.v_ordner.get().strip():
self._ordner()
if not self.v_ordner.get().strip():
return
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(),
export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get())
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
pro_anbieter=self.v_anb.get(), kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(),
oeffnen=self.v_auf.get())
self.destroy()
# =========================================================================== Einstellungen
class EinstellungenDialog(w.Dialog):
def __init__(self, master, settings: dict, db_pfad: str):
super().__init__(master, "Einstellungen")
self.settings = settings
g = w.gruppe(self, "Datenbank")
g.pack(fill="x", padx=10, pady=10)
tk.Label(g, text="Speicherort der Datenbank (Händler, Regeln, Abrechnungen):", anchor="w").grid(
row=0, column=0, columnspan=2, sticky="w")
self.v = tk.StringVar(value=db_pfad)
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4)
w.knopf(g, "Ändern…", self._waehlen).grid(row=1, column=1, padx=(4, 0))
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _waehlen(self):
p = filedialog.asksaveasfilename(parent=self, title="Datenbank wählen oder neu anlegen",
defaultextension=".db", confirmoverwrite=False,
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
if p:
self.v.set(p)
def _ok(self):
self.result = self.v.get().strip()
self.destroy()
# =========================================================================== Info
class InfoDialog(w.Dialog):
def __init__(self, master):
super().__init__(master, f"Info über {APP_NAME}")
top = tk.Frame(self, bg=w.FACE)
top.pack(padx=14, pady=14)
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
c.pack(side="left", anchor="n")
self._img = w.make_icon(self, "app", 6)
c.create_image(57, 57, image=self._img)
rechts = tk.Frame(top, bg=w.FACE)
rechts.pack(side="left", padx=(14, 0), anchor="n")
tk.Label(rechts, text=APP_NAME, font=w.FONT_BIG).pack(anchor="w")
tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w")
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
"freenet · Vodafone", justify="left").pack(anchor="w")
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
tk.Label(rechts, text="Arbeitsspeicher für Provisionen: reichlich", fg=w.SHADOW).pack(anchor="w")
self.knopfleiste([("OK", self.abbrechen)]).pack(pady=(0, 12))
self.bind("<Return>", lambda e: self.abbrechen())
+582
View File
@@ -0,0 +1,582 @@
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine."""
from __future__ import annotations
import sys
import tkinter as tk
from tkinter import ttk
FACE = "#c0c0c0"
HILITE = "#ffffff"
LIGHT = "#dfdfdf"
SHADOW = "#808080"
DARK = "#000000"
NAVY = "#000080"
TEAL = "#008080"
WHITE = "#ffffff"
INFO_BG = "#ffffe1"
IS_WIN = sys.platform.startswith("win")
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
FONT_SIZE = 8 if IS_WIN else 9
FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
PALETTE = {
"k": "#000000", "w": "#ffffff", "g": "#c0c0c0", "d": "#808080", "y": "#ffff00", "Y": "#808000",
"b": "#000080", "B": "#0000ff", "r": "#ff0000", "R": "#800000", "G": "#008000", "L": "#00ff00",
"t": "#008080", "c": "#00ffff", "s": "#ffcc99", "o": "#ff8000",
}
ICONS = {
"neu": """
..kkkkkkkkk.....
..kwwwwwwwkk....
..kwwwwwwwkwk...
..kwwwwwwwkkkk..
..kwwwwwwwwwwk..
..kwkkkkkkkwwk..
..kwwwwwwwwwwk..
..kwkkkkkkkkwk..
..kwwwwwwwwwwk..
..kwkkkkkkkkwk..
..kwwwwwwwwwwk..
..kwkkkkkwwwwk..
..kwwwwwwwwwwk..
..kwwwwwwwwwwk..
..kkkkkkkkkkkk..
................""",
"oeffnen": """
................
.........kkk....
........k...k.k.
.............kk.
.kkk........kkk.
kyYyk...........
kYyYykkkkkkk....
kyYyYyYyYyYk....
kYyYykkkkkkkkkk.
kyYykyyyyyyyyyk.
kYykyyyyyyyyyk..
kykyyyyyyyyyk...
kkyyyyyyyyyk....
kkkkkkkkkkk.....
................
................""",
"import": """
......kkkk......
......kGGk......
......kGGk......
......kGGk......
...kkkkGGkkkk...
....kGGGGGGk....
.....kGGGGk.....
......kGGk......
.......kk.......
.kkkkkkkkkkkkkk.
.kwwwwwwwwwwwdk.
.kwkkwkkwkkwwdk.
.kwwwwwwwwwwwdk.
.kddddddddddddk.
.kkkkkkkkkkkkkk.
................""",
"export": """
kkkkkkkkkkkkkkk.
kbbkwwwwwwwkbbk.
kbbkwwwwwwwkbbk.
kbbkwwwwwwwkbbk.
kbbkwwwwwwwkbbk.
kbbkkkkkkkkkbbk.
kbbbbbbbbbbbbbk.
kbbbbbbbbbbbbbk.
kbbkkkkkkkkkkbk.
kbbkgggkkgggkbk.
kbbkgggkkgggkbk.
kbbkgggkkgggkbk.
kbbkgggggggggbk.
.kbkkkkkkkkkkbk.
..kkkkkkkkkkkkk.
................""",
"haendler": """
................
....kkk.........
...kssskk.kkk...
...ksksk.kssk...
...kssskksksk...
....kskkkssk....
...kbbbkkksk....
..kbbbbbkRRRk...
..kbbbbbkRRRRk..
..kbkbkbRRRRRk..
..kbkbkbkRkRRk..
..kbkbkbkRkRRk..
..kbbbbbkRRRRk..
...kbkbkkRkRk...
...kkkkk.kkkk...
................""",
"prozent": """
................
..kkk......kk...
.kwwwk....kk....
.kwkwk...kk.....
.kwwwk..kk......
..kkk..kk.......
......kk........
.....kk.........
....kk..........
...kk...kkk.....
..kk...kwwwk....
.kk....kwkwk....
kk.....kwwwk....
........kkk.....
................
................""",
"rechner": """
.kkkkkkkkkkkkk..
.kddddddddddddk.
.kdkkkkkkkkkkdk.
.kdkLLLLLLLLkdk.
.kdkkkkkkkkkkdk.
.kddddddddddddk.
.kdwwdwwdwwdrrk.
.kdkkdkkdkkdkkk.
.kddddddddddddk.
.kdwwdwwdwwdwwk.
.kdkkdkkdkkdkkk.
.kddddddddddddk.
.kdwwdwwdwwdwwk.
.kdkkdkkdkkdkkk.
.kkkkkkkkkkkkkk.
................""",
"info": """
.....kkkkkk.....
...kkBBBBBBkk...
..kBBBBBBBBBBk..
.kBBBBBwwBBBBBk.
.kBBBBBwwBBBBBk.
kBBBBBBBBBBBBBBk
kBBBBBwwwBBBBBBk
kBBBBBBwwBBBBBBk
kBBBBBBwwBBBBBBk
kBBBBBBwwBBBBBBk
.kBBBBBwwBBBBBk.
.kBBBBwwwwBBBBk.
..kBBBBBBBBBBk..
...kkBBBBBBkk...
.....kkkkkk.....
................""",
"warnung": """
.......kk.......
......kyyk......
......kyyk......
.....kyyyyk.....
.....kykkyk.....
....kyykkyyk....
....kyykkyyk....
...kyyykkyyyk...
...kyyykkyyyk...
..kyyyyyyyyyyk..
..kyyyykkyyyyk..
.kyyyyykkyyyyyk.
.kyyyyyyyyyyyyk.
kkkkkkkkkkkkkkkk
................
................""",
"fehler": """
.....kkkkkk.....
...kkrrrrrrkk...
..krrrrrrrrrrk..
.krrwrrrrrrwrrk.
.krwwwrrrrwwwrk.
krrrwwwrrwwwrrrk
krrrrwwwwwwrrrrk
krrrrrwwwwrrrrrk
krrrrrwwwwrrrrrk
krrrrwwwwwwrrrrk
.krrwwwrrwwwrrk.
.krwwwrrrrwwwrk.
..krrrrrrrrrrk..
...kkrrrrrrkk...
.....kkkkkk.....
................""",
"frage": """
.....kkkkkk.....
...kkwwwwwwkk...
..kwwwwwwwwwwk..
.kwwwwBBBBwwwwk.
.kwwwBBwwBBwwwk.
kwwwwwwwwBBwwwwk
kwwwwwwwBBwwwwwk
kwwwwwwBBwwwwwwk
kwwwwwwBBwwwwwwk
kwwwwwwwwwwwwwwk
.kwwwwwBBwwwwwk.
.kwwwwwBBwwwwwk.
..kwwwwwwwwwwk..
...kkwwwwwwkk...
.....kkkkkkk....
................""",
"app": """
................
.....kkkkkkk....
....kdddddddk...
....kdkkkkkdk...
....kdkLLLkdk...
....kdkLLLkdk...
....kdkkkkkdk...
....kdwdwdwdk...
....kdddddddk...
....kdwdwdwdk...
....kdddddddk...
..ttkdwdwdwdktt.
.tcccckkkkkkccct
tcccccyyyyyyccct
.ttttttttttttttt
................""",
}
def _icon_rows(name: str) -> list[str]:
rows = [r.strip() for r in ICONS[name].strip("\n").splitlines() if r.strip()]
return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows))
def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage:
img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale)
for y, row in enumerate(_icon_rows(name)):
for x, ch in enumerate(row):
if ch == ".":
continue
color = PALETTE[ch]
img.put(color, to=(x * scale, y * scale, (x + 1) * scale, (y + 1) * scale))
return img
class Icons:
"""Lazy-Cache, damit PhotoImages nicht vom Garbage Collector entsorgt werden."""
def __init__(self, master):
self.master = master
self._cache: dict[tuple[str, int], tk.PhotoImage] = {}
def __call__(self, name: str, scale: int = 1) -> tk.PhotoImage:
key = (name, scale)
if key not in self._cache:
self._cache[key] = make_icon(self.master, name, scale)
return self._cache[key]
# --------------------------------------------------------------------------- Theme
def apply(root: tk.Tk) -> None:
root.configure(bg=FACE)
o = root.option_add
o("*Font", FONT)
o("*Background", FACE)
o("*Foreground", DARK)
o("*activeBackground", FACE)
o("*highlightThickness", 0)
o("*selectBackground", NAVY)
o("*selectForeground", WHITE)
o("*Button.relief", "raised")
o("*Button.borderWidth", 2)
o("*Button.padX", 8)
o("*Button.padY", 1)
o("*Entry.background", WHITE)
o("*Entry.relief", "sunken")
o("*Entry.borderWidth", 2)
o("*Entry.disabledBackground", FACE)
o("*Text.background", WHITE)
o("*Text.relief", "sunken")
o("*Text.borderWidth", 2)
o("*Listbox.background", WHITE)
o("*Listbox.relief", "sunken")
o("*Listbox.borderWidth", 2)
o("*Checkbutton.selectColor", WHITE)
o("*Radiobutton.selectColor", WHITE)
o("*Menu.relief", "raised")
o("*Menu.activeBackground", NAVY)
o("*Menu.activeForeground", WHITE)
o("*Menu.activeBorderWidth", 0)
o("*TCombobox*Listbox.background", WHITE)
o("*TCombobox*Listbox.selectBackground", NAVY)
from tkinter import font as tkfont
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
st = ttk.Style(root)
st.theme_use("classic" if "classic" in st.theme_names() else "default")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT,
selectbackground=NAVY, selectforeground=WHITE, fieldbackground=WHITE,
bordercolor=FACE, lightcolor=HILITE, darkcolor=SHADOW, highlightthickness=0)
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile, borderwidth=0, font=FONT)
st.map("Treeview", background=[("selected", NAVY)], foreground=[("selected", WHITE)])
st.configure("Treeview.Heading", background=FACE, relief="raised", borderwidth=2, font=FONT, padding=(3, 1))
st.map("Treeview.Heading", relief=[("pressed", "sunken")], background=[("active", FACE)])
st.configure("TScrollbar", background=FACE, troughcolor=LIGHT, arrowcolor=DARK, relief="raised", borderwidth=2,
arrowsize=16, width=16)
st.map("TScrollbar", background=[("active", FACE)])
st.configure("TCombobox", fieldbackground=WHITE, background=FACE, arrowsize=14, padding=1)
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
selectforeground=[("readonly", DARK)])
st.configure("TPanedwindow", background=FACE)
st.configure("Sash", sashthickness=6, background=FACE)
# --------------------------------------------------------------------------- Bausteine
class Sunken(tk.Frame):
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
def __init__(self, master, **kw):
kw.setdefault("bd", 2)
kw.setdefault("relief", "sunken")
super().__init__(master, **kw)
class Fenster(tk.Frame):
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
self.titelleiste.pack(fill="x", padx=1, pady=1)
if icon is not None:
tk.Label(self.titelleiste, image=icon, bg=NAVY).pack(side="left", padx=(2, 0))
self.titel_var = tk.StringVar(value=titel)
tk.Label(self.titelleiste, textvariable=self.titel_var, bg=NAVY, fg=WHITE, font=FONT_BOLD,
anchor="w").pack(side="left", fill="x", padx=3)
self.inhalt = tk.Frame(self, bg=FACE)
self.inhalt.pack(fill="both", expand=True, padx=2, pady=2)
def set_titel(self, t: str):
self.titel_var.set(t)
class Statusleiste(tk.Frame):
def __init__(self, master, felder: list[tuple[str, int]]):
super().__init__(master, bg=FACE)
self.vars: dict[str, tk.StringVar] = {}
for i, (key, breite) in enumerate(felder):
v = tk.StringVar()
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4)
if breite:
lbl.configure(width=breite)
lbl.pack(side="left", padx=(0, 2), pady=2)
else:
lbl.pack(side="left", fill="x", expand=True, padx=(0, 2), pady=2)
self.vars[key] = v
def set(self, key: str, text: str):
self.vars[key].set(text)
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
activebackground=FACE, **kw)
if icon is not None:
b.configure(image=icon, compound="left", padx=6)
if breite:
b.configure(width=breite)
return b
class ToolbarButton(tk.Label):
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick."""
def __init__(self, master, text: str, icon: tk.PhotoImage, command, tooltip: str = ""):
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
padx=6, pady=2, font=FONT)
self.command = command
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
self.bind("<ButtonRelease-1>", self._release)
if tooltip:
Tooltip(self, tooltip)
def _release(self, e):
self.configure(relief="raised")
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
self.command()
class Tooltip:
def __init__(self, widget, text: str):
self.widget, self.text, self.tip, self._after = widget, text, None, None
widget.bind("<Enter>", self._plan, add="+")
widget.bind("<Leave>", self._weg, add="+")
widget.bind("<ButtonPress>", self._weg, add="+")
def _plan(self, _e):
self._after = self.widget.after(600, self._zeigen)
def _zeigen(self):
x = self.widget.winfo_rootx() + 10
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
self.tip = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack()
def _weg(self, _e=None):
if self._after:
self.widget.after_cancel(self._after)
self._after = None
if self.tip:
self.tip.destroy()
self.tip = None
def trenner(master, vertikal: bool = True):
f = tk.Frame(master, bg=FACE)
if vertikal:
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
else:
tk.Frame(f, height=1, bg=SHADOW).pack(fill="x")
tk.Frame(f, height=1, bg=HILITE).pack(fill="x")
return f
def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]:
rahmen = Sunken(master, bg=WHITE)
tree = ttk.Treeview(rahmen, **kw)
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
tree.grid(row=0, column=0, sticky="nsew")
vs.grid(row=0, column=1, sticky="ns")
hs.grid(row=1, column=0, sticky="ew")
rahmen.rowconfigure(0, weight=1)
rahmen.columnconfigure(0, weight=1)
return rahmen, tree
def auto_breite(tree: ttk.Treeview, flex: dict[str, float], reserve: int = 4):
"""Feste Spalten behalten ihre Breite, die „flex“-Spalten teilen sich den Rest anteilig.
So bleiben z. B. die Betragsspalten immer sichtbar, egal wie breit das Fenster ist."""
def anpassen(_e=None):
verfuegbar = tree.winfo_width() - reserve
if verfuegbar < 50:
return
alle = ["#0"] + list(tree["columns"]) if "tree" in str(tree["show"]) else list(tree["columns"])
fest = sum(int(tree.column(c, "width")) for c in alle if c not in flex)
rest = max(0, verfuegbar - fest)
summe = sum(flex.values())
for c, anteil in flex.items():
minimum = int(tree.column(c, "minwidth"))
tree.column(c, width=max(minimum, int(rest * anteil / summe)))
tree.bind("<Configure>", anpassen, add="+")
return anpassen
# --------------------------------------------------------------------------- Dialoge
class Dialog(tk.Toplevel):
"""Modaler Dialog im Win95-Stil."""
def __init__(self, master, titel: str, resizable: bool = False):
super().__init__(master, bg=FACE)
self.withdraw()
self.title(titel)
self.transient(master.winfo_toplevel())
self.resizable(resizable, resizable)
self.result = None
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
self.bind("<Escape>", lambda e: self.abbrechen())
try:
self.iconphoto(False, master.winfo_toplevel()._app_icon) # type: ignore[attr-defined]
except Exception:
pass
def zeigen(self, fokus=None):
self.update_idletasks()
m = self.master.winfo_toplevel()
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
self.geometry(f"+{x}+{y}")
self.deiconify()
self.lift()
try:
self.grab_set()
except tk.TclError:
pass
(fokus or self).focus_set()
self.wait_window()
return self.result
def abbrechen(self):
self.result = None
self.destroy()
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
f = tk.Frame(self, bg=FACE)
for i, (text, cmd) in enumerate(knoepfe):
b = knopf(f, text, cmd, breite=10)
if i == default:
b.configure(default="active")
b.pack(side="left", padx=4)
return f
def _meldung(master, titel: str, text: str, icon: str, knoepfe: list[tuple[str, object]], default=0):
d = Dialog(master, titel)
body = tk.Frame(d, bg=FACE)
body.pack(fill="both", expand=True, padx=12, pady=(14, 8))
ic = make_icon(d, icon, 2)
d._ic = ic
tk.Label(body, image=ic, bg=FACE).pack(side="left", anchor="n", padx=(0, 14))
tk.Label(body, text=text, bg=FACE, justify="left", wraplength=420, anchor="w").pack(side="left", fill="x")
def mk(val):
def f():
d.result = val
d.destroy()
return f
leiste = d.knopfleiste([(t, mk(v)) for t, v in knoepfe], default)
leiste.pack(pady=(4, 12))
d.bind("<Return>", lambda e: mk(knoepfe[default][1])())
return d.zeigen()
def info(master, titel: str, text: str):
_meldung(master, titel, text, "info", [("OK", True)])
def warnung(master, titel: str, text: str):
_meldung(master, titel, text, "warnung", [("OK", True)])
def fehler(master, titel: str, text: str):
_meldung(master, titel, text, "fehler", [("OK", True)])
def frage(master, titel: str, text: str, ja: str = "Ja", nein: str = "Nein", default_ja: bool = True) -> bool:
return bool(_meldung(master, titel, text, "frage", [(ja, True), (nein, False)], 0 if default_ja else 1))
def eingabe(master, titel: str, text: str, wert: str = "") -> str | None:
d = Dialog(master, titel)
tk.Label(d, text=text, bg=FACE, justify="left", anchor="w").pack(fill="x", padx=12, pady=(12, 4))
var = tk.StringVar(value=wert)
e = tk.Entry(d, textvariable=var, width=46)
e.pack(padx=12, pady=4)
e.select_range(0, "end")
def ok():
d.result = var.get()
d.destroy()
d.bind("<Return>", lambda _e: ok())
d.knopfleiste([("OK", ok), ("Abbrechen", d.abbrechen)]).pack(pady=(6, 12))
return d.zeigen(e)