"""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("<>", self._haendler_gewaehlt) self.htree.bind("", 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("", self._doppelklick) self.ptree.bind("", self._kontextmenue) self.ptree.bind("<>", 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("", lambda e: self.neu()) r.bind_all("", lambda e: self.abrechnung_waehlen()) r.bind_all("", lambda e: self.importieren()) r.bind_all("", lambda e: self.exportieren()) r.bind_all("", lambda e: self.haendler()) r.bind_all("", lambda e: self._suchfeld.focus_set()) r.bind_all("", lambda e: self.hilfe()) self.ptree.bind("", lambda e: self.betrag_bearbeiten()) self.ptree.bind("", lambda e: self.betrag_bearbeiten()) self.ptree.bind("", 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("", speichern) e.bind("", speichern) e.bind("", lambda ev: speichern(ev, 1)) e.bind("", lambda ev: speichern(ev, 1)) e.bind("", lambda ev: speichern(ev, -1)) e.bind("", abbruch) e.bind("", 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()