825 lines
37 KiB
Python
825 lines
37 KiB
Python
"""Hauptfenster des Provisions-Managers 95."""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import tkinter as tk
|
||
import traceback
|
||
from collections import defaultdict
|
||
from datetime import date
|
||
from pathlib import Path
|
||
from tkinter import filedialog
|
||
|
||
from provtool import 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)
|
||
h.add_command(label="Auswahl fest einem Händler zuweisen…", command=self.haendler_zuweisen)
|
||
h.add_command(label="Auswahl automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
|
||
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"] + (" (fest)" if p["haendler_fest"] is not None else ""),
|
||
fmt(p["betrag_orig"]),
|
||
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
|
||
tags=tuple(tags))
|
||
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
|
||
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
|
||
self.f_p.set_titel(f"Positionen – {name}")
|
||
so = sum(p["betrag_orig"] for p in pos)
|
||
sa = sum(p["betrag"] for p in pos)
|
||
self.status.set("pos", f" {len(pos)} Positionen")
|
||
self.status.set("orig", f" Provision: {fmt(so)}")
|
||
self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
|
||
|
||
def _status_auswahl(self):
|
||
pids = self._ausgewaehlte_pids()
|
||
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())):
|
||
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
||
self.status.set("pos", f" {len(ps)} ausgewählt")
|
||
self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}")
|
||
self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
|
||
|
||
def _klappen(self, auf: bool):
|
||
for iid in self.ptree.get_children():
|
||
self.ptree.item(iid, open=auf)
|
||
|
||
def _ausgewaehlte_pids(self) -> list[int]:
|
||
pids: list[int] = []
|
||
for iid in self.ptree.selection():
|
||
if iid.startswith("g"):
|
||
pids.extend(p["id"] for p in self._gruppen[iid][1])
|
||
elif iid.startswith("p"):
|
||
pids.append(int(iid[1:]))
|
||
return list(dict.fromkeys(pids))
|
||
|
||
# ================================================================= Aktionen
|
||
def _brauche_abrechnung(self, frage_neu=True) -> bool:
|
||
if self.aid:
|
||
return True
|
||
if frage_neu:
|
||
return self.neu()
|
||
return False
|
||
|
||
def _vorschlag_titel(self) -> str:
|
||
heute = date.today()
|
||
m = heute.month - 1 or 12
|
||
y = heute.year if heute.month > 1 else heute.year - 1
|
||
return f"{MONATE[m - 1]} {y}"
|
||
|
||
def neu(self) -> bool:
|
||
t = w.eingabe(self.root, "Neue Abrechnung", "Bezeichnung der Abrechnung (z. B. Abrechnungsmonat):",
|
||
self._vorschlag_titel())
|
||
if not t or not t.strip():
|
||
return False
|
||
self.aid = self.db.abrechnung_neu(t.strip())
|
||
self.ansicht = ALLE
|
||
self._aktualisieren()
|
||
return True
|
||
|
||
def abrechnung_waehlen(self):
|
||
if not self.db.abrechnungen():
|
||
w.info(self.root, "Abrechnung öffnen", "Es gibt noch keine gespeicherten Abrechnungen.")
|
||
return
|
||
d = dlg.AbrechnungenDialog(self.root, self.db, self.aid)
|
||
res = d.zeigen()
|
||
if res and res[0] == "oeffnen":
|
||
self.ansicht = ALLE
|
||
self.oeffnen(res[1])
|
||
else:
|
||
self._aktualisieren()
|
||
|
||
def importieren(self, dateien: list[str] | None = None):
|
||
if not dateien:
|
||
dateien = list(filedialog.askopenfilenames(
|
||
parent=self.root, title="Provisionsabrechnungen importieren",
|
||
initialdir=self.settings.get("import_ordner") or None,
|
||
filetypes=[("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
|
||
("CSV-Dateien", "*.csv"), ("Excel-Dateien", "*.xlsx *.xlsm *.xls"),
|
||
("Alle Dateien", "*.*")]))
|
||
if not dateien:
|
||
return
|
||
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
|
||
if not self._brauche_abrechnung():
|
||
return
|
||
bericht = []
|
||
self.root.configure(cursor="watch")
|
||
self.root.update()
|
||
try:
|
||
for f in dateien:
|
||
name = Path(f).name
|
||
try:
|
||
erg = lies_datei(f)
|
||
except ImportFehler as exc:
|
||
bericht.append((name, "–", 0, 0, str(exc).split("\n")[0]))
|
||
continue
|
||
except Exception as exc:
|
||
bericht.append((name, "–", 0, 0, f"Fehler: {exc}"))
|
||
continue
|
||
schon = self.db.datei_schon_importiert(erg.sha256)
|
||
if schon and not w.frage(self.root, "Bereits importiert",
|
||
f"„{name}“ wurde bereits importiert (in: {', '.join(schon)}).\n\n"
|
||
"Trotzdem noch einmal importieren? Die Positionen wären dann doppelt.",
|
||
default_ja=False):
|
||
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
|
||
continue
|
||
n = self.db.importieren(self.aid, erg)
|
||
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
|
||
"; ".join(erg.warnungen) or "OK"))
|
||
finally:
|
||
self.root.configure(cursor="")
|
||
self._aktualisieren()
|
||
unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
||
for p in self.positionen:
|
||
if p["haendler_id"] == UNZUGEORDNET:
|
||
u = unbekannt[(p["anbieter"], p["kennung"])]
|
||
u[0] += 1
|
||
u[1] += p["betrag_orig"]
|
||
res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen()
|
||
if res:
|
||
self.kennung_zuordnen(*res)
|
||
|
||
def dateien(self):
|
||
if not self.aid:
|
||
return
|
||
if dlg.DateienDialog(self.root, self.db, self.aid).zeigen():
|
||
self._aktualisieren()
|
||
|
||
def haendler(self, hid: int | None = None, neue_kennung: tuple[str, str] | None = None):
|
||
geaendert = dlg.HaendlerDialog(self.root, self.db, hid, neue_kennung).zeigen()
|
||
if geaendert:
|
||
self._nach_haendler_aenderung()
|
||
|
||
def _nach_haendler_aenderung(self):
|
||
if self.aid:
|
||
n = self.db.neu_berechnen(self.aid)
|
||
if n:
|
||
self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet")
|
||
self._aktualisieren()
|
||
|
||
def kennung_zuordnen(self, anbieter: str, kennung: str):
|
||
res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen()
|
||
if not res:
|
||
return
|
||
if res[0] == "neu":
|
||
self.haendler(neue_kennung=(anbieter, kennung))
|
||
else:
|
||
self._nach_haendler_aenderung()
|
||
|
||
def kennung_zuordnen_auswahl(self):
|
||
pids = self._ausgewaehlte_pids()
|
||
p = next((x for x in self.positionen if pids and x["id"] == pids[0]), None)
|
||
if not p:
|
||
w.info(self.root, "Kennung zuordnen", "Bitte zuerst eine Position oder Gruppe auswählen.")
|
||
return
|
||
if p["haendler_id"] != UNZUGEORDNET:
|
||
w.info(self.root, "Kennung zuordnen",
|
||
f"Die Kennung {p['kennung']} gehört bereits zu „{self.haendler_namen.get(p['haendler_id'])}“.")
|
||
return
|
||
self.kennung_zuordnen(p["anbieter"], p["kennung"])
|
||
|
||
def _auswahl_info(self):
|
||
sel = self.ptree.selection()
|
||
pids = self._ausgewaehlte_pids()
|
||
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
||
return sel, pids, ps
|
||
|
||
def prozent(self):
|
||
sel, pids, ps = self._auswahl_info()
|
||
if not ps:
|
||
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
|
||
"in der Liste auswählen.")
|
||
return
|
||
hids = {p["haendler_id"] for p in ps}
|
||
arten = {p["provisionsart"] for p in ps}
|
||
hid = next(iter(hids)) if len(hids) == 1 else None
|
||
art = next(iter(arten)) if len(arten) == 1 else None
|
||
regel_moeglich = bool(hid and hid > 0 and art is not None)
|
||
hname = self.haendler_namen.get(hid, "(Nicht zugeordnet)") if hid is not None else "(mehrere)"
|
||
akt = {p["prozent"] for p in ps if p["prozent"] is not None}
|
||
vorschlag = next(iter(akt)) if len(akt) == 1 else 100.0
|
||
nur_gruppe = len(sel) == 1 and sel[0].startswith("g")
|
||
res = dlg.ProzentDialog(self.root, hname, art if nur_gruppe or len(ps) > 1 else None, len(ps),
|
||
sum(p["betrag_orig"] for p in ps), vorschlag, regel_moeglich).zeigen()
|
||
if not res:
|
||
return
|
||
proz, als_regel, manuelle = res
|
||
if als_regel and regel_moeglich:
|
||
self.db.regel_setzen(hid, art, proz)
|
||
self.db.prozent_anwenden(pids, proz, auch_manuelle=manuelle)
|
||
self.db.touch(self.aid)
|
||
self._aktualisieren()
|
||
self._auswahl_wiederherstellen(sel)
|
||
|
||
def _auswahl_wiederherstellen(self, sel):
|
||
vorhanden = [i for i in sel if self.ptree.exists(i)]
|
||
if vorhanden:
|
||
self.ptree.selection_set(vorhanden)
|
||
self.ptree.see(vorhanden[0])
|
||
|
||
def zuruecksetzen(self):
|
||
sel, pids, ps = self._auswahl_info()
|
||
if not ps:
|
||
return
|
||
for p in ps:
|
||
self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"]))
|
||
self.db.touch(self.aid)
|
||
self._aktualisieren()
|
||
self._auswahl_wiederherstellen(sel)
|
||
|
||
def neu_berechnen(self):
|
||
if not self.aid:
|
||
return
|
||
manuell = sum(1 for p in self.positionen if p["manuell"])
|
||
auch = False
|
||
if manuell:
|
||
auch = w.frage(self.root, "Neu berechnen",
|
||
f"{manuell} Position(en) wurden von Hand geändert.\n\nSollen diese ebenfalls nach den "
|
||
"Händler-Regeln neu berechnet werden?", ja="Ja, alle", nein="Nein, behalten",
|
||
default_ja=False)
|
||
n = self.db.neu_berechnen(self.aid, auch_manuelle=auch)
|
||
self._aktualisieren()
|
||
w.info(self.root, "Neu berechnen", f"{n} Position(en) wurden neu berechnet.")
|
||
|
||
# ----- Inline-Bearbeitung des Auszahlungsbetrags
|
||
def _doppelklick(self, e):
|
||
iid = self.ptree.identify_row(e.y)
|
||
if not iid:
|
||
return
|
||
if iid.startswith("g"):
|
||
self.ptree.selection_set(iid)
|
||
if self.ptree.identify_column(e.x) in ("#7", "#8"):
|
||
self.prozent()
|
||
return "break"
|
||
return
|
||
self.ptree.selection_set(iid)
|
||
self.betrag_bearbeiten(iid)
|
||
|
||
def betrag_bearbeiten(self, iid: str | None = None):
|
||
if iid is None:
|
||
sel = [i for i in self.ptree.selection() if i.startswith("p")]
|
||
if len(sel) != 1:
|
||
if any(i.startswith("g") for i in self.ptree.selection()) or len(sel) > 1:
|
||
self.prozent()
|
||
else:
|
||
w.info(self.root, "Betrag ändern", "Bitte eine einzelne Position auswählen.")
|
||
return
|
||
iid = sel[0]
|
||
self.ptree.see(iid)
|
||
self.ptree.update_idletasks()
|
||
bbox = self.ptree.bbox(iid, "ausz")
|
||
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())
|
||
m.add_separator()
|
||
m.add_command(label="Händler zuweisen…", command=self.haendler_zuweisen)
|
||
m.add_command(label="Automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
|
||
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
|
||
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||
m.tk_popup(e.x_root, e.y_root)
|
||
|
||
def haendler_zuweisen(self, automatisch: bool = False):
|
||
sel, pids, _ = self._auswahl_info()
|
||
if not pids:
|
||
w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.")
|
||
return
|
||
if automatisch:
|
||
hid = None
|
||
else:
|
||
hid = dlg.HaendlerWaehlen(self.root, self.db, len(pids)).zeigen()
|
||
if hid is None:
|
||
return
|
||
self.db.haendler_zuweisen(pids, hid)
|
||
self.db.touch(self.aid)
|
||
self._aktualisieren()
|
||
|
||
def _null(self):
|
||
sel, pids, _ = self._auswahl_info()
|
||
self.db.prozent_anwenden(pids, 0.0)
|
||
self.db.touch(self.aid)
|
||
self._aktualisieren()
|
||
self._auswahl_wiederherstellen(sel)
|
||
|
||
# ----- Export
|
||
def exportieren(self):
|
||
if not self.aid or not self.positionen:
|
||
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
|
||
"Bitte zuerst Dateien importieren.")
|
||
return
|
||
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
||
for p in self.positionen:
|
||
if p["haendler_id"] != UNZUGEORDNET:
|
||
stat[p["haendler_id"]][0] += 1
|
||
stat[p["haendler_id"]][1] += p["betrag"]
|
||
if not stat:
|
||
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
|
||
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
|
||
return
|
||
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in
|
||
sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
|
||
vor = {self.ansicht} if self.ansicht in stat else set(stat)
|
||
titel = self.db.abrechnung(self.aid)["titel"]
|
||
res = dlg.ExportDialog(self.root, liste, vor, self.settings, titel).zeigen()
|
||
if not res:
|
||
return
|
||
config.save(self.settings)
|
||
erzeugt = []
|
||
try:
|
||
for hid in res["ids"]:
|
||
pos = [p for p in self.positionen if p["haendler_id"] == hid]
|
||
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
|
||
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
|
||
summe=res["summe"])
|
||
except PermissionError as exc:
|
||
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
|
||
"Ist sie vielleicht noch in Excel geöffnet?")
|
||
return
|
||
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
|
||
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n…" if len(erzeugt) > 15 else ""))
|
||
if res["oeffnen"]:
|
||
_oeffne_ordner(res["ordner"])
|
||
|
||
# ----- Datei-Menü
|
||
def sicherung(self):
|
||
p = filedialog.asksaveasfilename(parent=self.root, title="Datensicherung speichern", defaultextension=".db",
|
||
initialfile=f"Provisionen-Sicherung-{date.today().isoformat()}.db",
|
||
filetypes=[("Datenbank", "*.db")])
|
||
if p:
|
||
self.db.backup(p)
|
||
w.info(self.root, "Datensicherung", f"Sicherung gespeichert:\n{p}")
|
||
|
||
def wiederherstellen(self):
|
||
p = filedialog.askopenfilename(parent=self.root, title="Datensicherung einspielen",
|
||
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
|
||
if not p:
|
||
return
|
||
if not w.frage(self.root, "Datensicherung einspielen",
|
||
"Die aktuelle Datenbank wird durch die Sicherung ERSETZT.\n"
|
||
"Vorher wird automatisch eine Kopie (*.vor-wiederherstellung.db) angelegt.\n\nFortfahren?",
|
||
default_ja=False):
|
||
return
|
||
ziel = self.db.path
|
||
self.db.backup(ziel.with_suffix(".vor-wiederherstellung.db"))
|
||
self.db.close()
|
||
try:
|
||
Datenbank.restore(p, ziel)
|
||
except Exception as exc:
|
||
w.fehler(self.root, "Datensicherung", f"Die Datei ist keine gültige Sicherung:\n{exc}")
|
||
self.db = Datenbank(ziel)
|
||
self.aid = None
|
||
abr = self.db.abrechnungen()
|
||
if abr:
|
||
self.aid = abr[0]["id"]
|
||
self.ansicht = ALLE
|
||
self._aktualisieren()
|
||
|
||
def 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. oder die Regel\n"
|
||
" „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick ▸ Händler zuweisen.\n\n"
|
||
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
|
||
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
|
||
" auch alle Wochenlisten eines Monats.\n\n"
|
||
"4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n"
|
||
" • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n"
|
||
" optional als Standard für diesen Händler merken.\n"
|
||
" • Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
|
||
" (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n"
|
||
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
|
||
"Alles wird automatisch in der Datenbank gespeichert.")
|
||
|
||
def _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()
|