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

+5
View File
@@ -0,0 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.0.0"
APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach"
+4
View File
@@ -0,0 +1,4 @@
from provtool.gui.app import main
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
"""Speicherorte und Einstellungen.
Die Einstellungen liegen als JSON in %APPDATA%\\Provisionsmanager95\\einstellungen.json.
Darin steht u. a. der Pfad zur Datenbank – der kann bewusst auf ein Netzlaufwerk
zeigen, wenn mehrere PCs (nacheinander) mit denselben Händlerdaten arbeiten sollen.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
APP_DIR_NAME = "Provisionsmanager95"
def app_dir() -> Path:
base = os.environ.get("PROVTOOL_HOME")
if base:
p = Path(base)
elif os.name == "nt" and os.environ.get("APPDATA"):
p = Path(os.environ["APPDATA"]) / APP_DIR_NAME
else:
p = Path.home() / ".config" / APP_DIR_NAME
p.mkdir(parents=True, exist_ok=True)
return p
def _settings_file() -> Path:
return app_dir() / "einstellungen.json"
DEFAULTS = {
"db_pfad": None, # None = Standard im App-Verzeichnis
"export_ordner": None,
"import_ordner": None,
"export_kopfzeile": False,
"export_summe": True,
"export_pro_anbieter": True,
}
def load() -> dict:
data = dict(DEFAULTS)
f = _settings_file()
if f.exists():
try:
data.update(json.loads(f.read_text(encoding="utf-8")))
except (OSError, ValueError):
pass
return data
def save(data: dict) -> None:
_settings_file().write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
def db_path(settings: dict | None = None) -> Path:
settings = settings or load()
if settings.get("db_pfad"):
return Path(settings["db_pfad"])
return app_dir() / "provisionen.db"
+309
View File
@@ -0,0 +1,309 @@
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
from __future__ import annotations
import json
import shutil
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from provtool.importer import ImportErgebnis
from provtool.money import apply_percent
SCHEMA_VERSION = 1
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
CREATE TABLE IF NOT EXISTS haendler (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
ansprechpartner TEXT DEFAULT '',
email TEXT DEFAULT '',
notiz TEXT DEFAULT '',
standard_prozent REAL NOT NULL DEFAULT 100,
aktiv INTEGER NOT NULL DEFAULT 1
);
-- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu
-- (freenet: Unterhändlernummer, Vodafone: VO-Nummer).
CREATE TABLE IF NOT EXISTS kennung (
id INTEGER PRIMARY KEY,
haendler_id INTEGER NOT NULL REFERENCES haendler(id) ON DELETE CASCADE,
anbieter TEXT NOT NULL,
wert TEXT NOT NULL,
UNIQUE (anbieter, wert)
);
-- Abweichender Auszahlungs-Prozentsatz je Händler und Provisionsart.
CREATE TABLE IF NOT EXISTS regel (
id INTEGER PRIMARY KEY,
haendler_id INTEGER NOT NULL REFERENCES haendler(id) ON DELETE CASCADE,
provisionsart TEXT NOT NULL,
prozent REAL NOT NULL,
UNIQUE (haendler_id, provisionsart)
);
CREATE TABLE IF NOT EXISTS abrechnung (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL,
erstellt TEXT NOT NULL,
geaendert TEXT NOT NULL,
notiz TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS abrechnung_datei (
id INTEGER PRIMARY KEY,
abrechnung_id INTEGER NOT NULL REFERENCES abrechnung(id) ON DELETE CASCADE,
dateiname TEXT NOT NULL,
sha256 TEXT NOT NULL,
format TEXT NOT NULL,
importiert TEXT NOT NULL,
anzahl INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS position (
id INTEGER PRIMARY KEY,
abrechnung_id INTEGER NOT NULL REFERENCES abrechnung(id) ON DELETE CASCADE,
datei_id INTEGER REFERENCES abrechnung_datei(id) ON DELETE CASCADE,
reihenfolge INTEGER NOT NULL,
anbieter TEXT NOT NULL,
zeitraum TEXT, provisionsart TEXT, kennung TEXT, datum TEXT,
rufnummer TEXT, imei TEXT, geraet TEXT, vorname TEXT, name TEXT, tarif TEXT,
netz TEXT, dienst TEXT, aktionscode TEXT, quelle TEXT,
betrag_orig INTEGER NOT NULL, -- Cent, laut Abrechnung des Anbieters
betrag INTEGER NOT NULL, -- Cent, Auszahlung an den Händler
prozent REAL, -- zuletzt angewendeter Prozentsatz (NULL = manuell)
manuell INTEGER NOT NULL DEFAULT 0,
roh TEXT
);
CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
"""
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
@dataclass
class Haendler:
id: int
name: str
ansprechpartner: str = ""
email: str = ""
notiz: str = ""
standard_prozent: float = 100.0
aktiv: bool = True
def _now() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
class Datenbank:
def __init__(self, path: str | Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.con = sqlite3.connect(str(self.path))
self.con.row_factory = sqlite3.Row
self.con.execute("PRAGMA foreign_keys = ON")
self.con.executescript(SCHEMA)
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
self.con.commit()
def close(self):
self.con.close()
def backup(self, ziel: str | Path) -> None:
self.con.commit()
dst = sqlite3.connect(str(ziel))
with dst:
self.con.backup(dst)
dst.close()
@staticmethod
def restore(quelle: str | Path, ziel: str | Path) -> None:
test = sqlite3.connect(str(quelle))
try:
test.execute("SELECT count(*) FROM haendler").fetchone()
finally:
test.close()
shutil.copyfile(quelle, ziel)
# ------------------------------------------------------------------ Händler
def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]:
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name"
return [Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
r["standard_prozent"], bool(r["aktiv"])) for r in self.con.execute(sql)]
def haendler(self, hid: int) -> Haendler | None:
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
if not r:
return None
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
r["standard_prozent"], bool(r["aktiv"]))
def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
"""Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]"""
with self.con:
if h.id:
self.con.execute(
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=? WHERE id=?",
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv), h.id))
hid = h.id
else:
cur = self.con.execute(
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv) VALUES (?,?,?,?,?,?)",
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv)))
hid = cur.lastrowid
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
for anbieter, wert in kennungen:
wert = wert.strip()
if not wert:
continue
besitzer = self.con.execute("SELECT h.name FROM kennung k JOIN haendler h ON h.id=k.haendler_id "
"WHERE k.anbieter=? AND k.wert=?", (anbieter, wert)).fetchone()
if besitzer:
raise ValueError(f"Die Kennung {wert} ({anbieter}) ist bereits dem Händler „{besitzer[0]}“ zugeordnet.")
self.con.execute("INSERT INTO kennung (haendler_id, anbieter, wert) VALUES (?,?,?)", (hid, anbieter, wert))
return hid
def haendler_loeschen(self, hid: int) -> None:
with self.con:
self.con.execute("DELETE FROM haendler WHERE id=?", (hid,))
def kennungen(self, hid: int) -> list[tuple[str, str]]:
return [(r[0], r[1]) for r in self.con.execute(
"SELECT anbieter, wert FROM kennung WHERE haendler_id=? ORDER BY anbieter, wert", (hid,))]
def kennung_map(self) -> dict[tuple[str, str], int]:
return {(r[0], r[1]): r[2] for r in self.con.execute(
"SELECT k.anbieter, k.wert, k.haendler_id FROM kennung k JOIN haendler h ON h.id=k.haendler_id WHERE h.aktiv=1")}
# ------------------------------------------------------------------ Regeln
def regeln(self, hid: int) -> dict[str, float]:
return {r[0]: r[1] for r in self.con.execute(
"SELECT provisionsart, prozent FROM regel WHERE haendler_id=?", (hid,))}
def regel_setzen(self, hid: int, provisionsart: str, prozent: float | None) -> None:
with self.con:
if prozent is None:
self.con.execute("DELETE FROM regel WHERE haendler_id=? AND provisionsart=?", (hid, provisionsart))
else:
self.con.execute("INSERT INTO regel (haendler_id, provisionsart, prozent) VALUES (?,?,?) "
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent",
(hid, provisionsart, prozent))
def prozent_fuer(self, hid: int, provisionsart: str) -> float:
if hid == UNZUGEORDNET:
return 100.0
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
(hid, provisionsart)).fetchone()
if r:
return r[0]
h = self.haendler(hid)
return h.standard_prozent if h else 100.0
# ------------------------------------------------------------------ Abrechnungen
def abrechnung_neu(self, titel: str) -> int:
with self.con:
cur = self.con.execute("INSERT INTO abrechnung (titel, erstellt, geaendert) VALUES (?,?,?)",
(titel, _now(), _now()))
return cur.lastrowid
def abrechnungen(self) -> list[sqlite3.Row]:
return list(self.con.execute(
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
"(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien "
"FROM abrechnung a ORDER BY a.geaendert DESC"))
def abrechnung(self, aid: int) -> sqlite3.Row | None:
return self.con.execute("SELECT * FROM abrechnung WHERE id=?", (aid,)).fetchone()
def abrechnung_umbenennen(self, aid: int, titel: str) -> None:
with self.con:
self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid))
def abrechnung_loeschen(self, aid: int) -> None:
with self.con:
self.con.execute("DELETE FROM abrechnung WHERE id=?", (aid,))
def dateien(self, aid: int) -> list[sqlite3.Row]:
return list(self.con.execute("SELECT * FROM abrechnung_datei WHERE abrechnung_id=? ORDER BY id", (aid,)))
def datei_schon_importiert(self, sha256: str) -> list[str]:
return [r[0] for r in self.con.execute(
"SELECT a.titel FROM abrechnung_datei d JOIN abrechnung a ON a.id=d.abrechnung_id WHERE d.sha256=?",
(sha256,))]
def datei_entfernen(self, datei_id: int) -> None:
with self.con:
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
kmap = self.kennung_map()
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
(aid,)).fetchone()[0]
with self.con:
cur = self.con.execute(
"INSERT INTO abrechnung_datei (abrechnung_id, dateiname, sha256, format, importiert, anzahl) VALUES (?,?,?,?,?,?)",
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
did = cur.lastrowid
for i, p in enumerate(erg.positionen, start=start + 1):
hid = kmap.get((p.anbieter, p.kennung), UNZUGEORDNET)
proz = self.prozent_fuer(hid, p.provisionsart)
self.con.execute(
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)",
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung,
p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name,
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent,
apply_percent(p.betrag_cent, proz), proz, json.dumps(p.roh, ensure_ascii=False, default=str)))
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
return len(erg.positionen)
def positionen(self, aid: int) -> list[dict]:
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen)."""
kmap = self.kennung_map()
out = []
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
d = dict(r)
d["haendler_id"] = kmap.get((d["anbieter"], d["kennung"]), UNZUGEORDNET)
out.append(d)
return out
def betrag_setzen(self, pid: int, cent: int) -> None:
with self.con:
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
n = 0
with self.con:
for pid in pids:
r = self.con.execute("SELECT betrag_orig, manuell FROM position WHERE id=?", (pid,)).fetchone()
if not r or (r["manuell"] and not auch_manuelle):
continue
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?",
(apply_percent(r["betrag_orig"], prozent), prozent, pid))
n += 1
return n
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an.
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
n = 0
with self.con:
for p in self.positionen(aid):
if p["manuell"] and not auch_manuelle:
continue
proz = self.prozent_fuer(p["haendler_id"], p["provisionsart"])
neu = apply_percent(p["betrag_orig"], proz)
if neu != p["betrag"] or proz != p["prozent"] or p["manuell"]:
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?", (neu, proz, p["id"]))
n += 1
return n
def touch(self, aid: int) -> None:
with self.con:
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
+123
View File
@@ -0,0 +1,123 @@
"""Export der Händler-Abrechnung als Excel – Aufbau wie „so soll es zum schluss ausschauen.xlsx“.
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN |
G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode
Sortierung: Zeitraum → Provisionsart → Datum (sonst Reihenfolge der Abrechnung).
"""
from __future__ import annotations
import re
from datetime import date
from pathlib import Path
from provtool.importer import ANBIETER
from provtool.money import to_euro
SPALTEN = [
("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13),
("IMEI/SN", 16), ("Gerät", 16), ("Vorname", 14), ("Name", 30), ("Tarif", 34), ("Betrag", 12),
("Netz", 7), ("Dienst", 22), ("Aktionscode", 12),
]
EURO_FORMAT = '_-* #,##0.00\\ "€"_-;\\-* #,##0.00\\ "€"_-;_-* "-"??\\ "€"_-;_-@_-'
def sortiert(positionen: list[dict]) -> list[dict]:
return sorted(positionen, key=lambda p: (p.get("zeitraum") or "", p.get("provisionsart") or "",
p.get("datum") or "", p.get("reihenfolge") or 0))
def _zahl_oder_text(s: str):
"""Rufnummern/IMEI als Zahl (Format „0“) wie in der Vorlage – führende Nullen bleiben als Text erhalten."""
s = (s or "").strip()
if s.isdigit() and not s.startswith("0") and len(s) <= 15:
return int(s)
return s or None
def dateiname(text: str) -> str:
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export"
def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True,
blattname: str = "Tabelle1") -> Path:
import openpyxl
from openpyxl.styles import Font
from openpyxl.utils import get_column_letter
ziel = Path(ziel)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = blattname[:31] or "Tabelle1"
font = Font(name="Aptos Narrow", size=10)
bold = Font(name="Aptos Narrow", size=10, bold=True)
zeile = 1
if kopfzeile:
for c, (titel, _) in enumerate(SPALTEN, start=1):
ws.cell(zeile, c, titel).font = bold
ws.freeze_panes = "A2"
zeile += 1
erste = zeile
for p in sortiert(positionen):
werte = [
p.get("zeitraum") or None,
p.get("provisionsart") or None,
_zahl_oder_text(p.get("kennung")),
date.fromisoformat(p["datum"]) if p.get("datum") else None,
_zahl_oder_text(p.get("rufnummer")),
_zahl_oder_text(p.get("imei")),
p.get("geraet") or None,
p.get("vorname") or None,
p.get("name") or None,
p.get("tarif") or None,
to_euro(p["betrag"]),
p.get("netz") or None,
p.get("dienst") or None,
p.get("aktionscode") or None,
]
for c, v in enumerate(werte, start=1):
cell = ws.cell(zeile, c, v)
cell.font = font
ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
ws.cell(zeile, 5).number_format = "0"
ws.cell(zeile, 6).number_format = "0"
ws.cell(zeile, 11).number_format = EURO_FORMAT
zeile += 1
letzte = zeile - 1
if summe and letzte >= erste:
s = ws.cell(zeile + 1, 11, f"=SUM(K{erste}:K{letzte})")
s.number_format = EURO_FORMAT
s.font = bold
ws.cell(zeile + 1, 10, "Summe").font = bold
for c, (_, breite) in enumerate(SPALTEN, start=1):
ws.column_dimensions[get_column_letter(c)].width = breite
ws.page_setup.orientation = "landscape"
ws.page_setup.fitToWidth = 1
ws.page_setup.fitToHeight = 0
ws.sheet_properties.pageSetUpPr.fitToPage = True
ziel.parent.mkdir(parents=True, exist_ok=True)
wb.save(ziel)
return ziel
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
pro_anbieter: bool = True, kopfzeile: bool = False, summe: bool = True) -> list[Path]:
"""Schreibt eine (oder je Anbieter eine) Datei für einen Händler. Gibt die erzeugten Pfade zurück."""
ordner = Path(ordner)
if not positionen:
return []
gruppen: dict[str, list[dict]] = {}
if pro_anbieter:
for p in positionen:
gruppen.setdefault(p["anbieter"], []).append(p)
else:
gruppen[""] = positionen
out = []
for anbieter, pos in sorted(gruppen.items()):
teile = [haendler_name] + ([ANBIETER.get(anbieter, anbieter)] if anbieter else []) + [titel]
name = dateiname(" - ".join(t for t in teile if t)) + ".xlsx"
out.append(schreibe_xlsx(ordner / name, pos, kopfzeile=kopfzeile, summe=summe))
return out
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)
+304
View File
@@ -0,0 +1,304 @@
"""Einlesen der Provisionsabrechnungen (freenet CSV/Excel, Vodafone/Euronics Excel).
Jedes Format wird über seine Kopfzeile erkannt – Dateiname und Blattname sind egal.
Alle Formate werden in dieselbe Struktur `Position` überführt.
"""
from __future__ import annotations
import csv
import hashlib
import io
from dataclasses import asdict, dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
from provtool.money import parse_cent
FREENET = "freenet"
VODAFONE = "vodafone"
ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"}
@dataclass
class Position:
anbieter: str
zeitraum: str = ""
provisionsart: str = ""
kennung: str = "" # Unterhändler-Nr. (freenet) bzw. VO-Nr. (Vodafone)
datum: date | None = None # Aktivierungs-/Anschaltdatum
rufnummer: str = ""
imei: str = ""
geraet: str = ""
vorname: str = ""
name: str = ""
tarif: str = ""
betrag_cent: int = 0
netz: str = ""
dienst: str = ""
aktionscode: str = ""
quelle: str = ""
roh: dict = field(default_factory=dict)
def as_dict(self) -> dict:
d = asdict(self)
d["datum"] = self.datum.isoformat() if self.datum else None
return d
@dataclass
class ImportErgebnis:
datei: str
sha256: str
format: str
positionen: list[Position]
warnungen: list[str] = field(default_factory=list)
class ImportFehler(Exception):
pass
# --------------------------------------------------------------------------- Hilfen
def _norm(s) -> str:
"""Kopfzeilen-Vergleich: klein, ohne Leer-/Sonderzeichen, Umlaute vereinheitlicht."""
s = str(s or "").strip().lower()
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss"), ("�", "")):
s = s.replace(a, b)
return "".join(ch for ch in s if ch.isalnum())
def _text(v) -> str:
if v is None:
return ""
if isinstance(v, float) and v.is_integer():
v = int(v)
return str(v).strip()
def _datum(v) -> date | None:
if v is None or v == "":
return None
if isinstance(v, datetime):
return v.date()
if isinstance(v, date):
return v
if isinstance(v, (int, float)): # Excel-Seriennummer
return (datetime(1899, 12, 30) + timedelta(days=float(v))).date()
s = str(v).strip()
for f in ("%d.%m.%Y", "%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%d.%m.%y", "%Y.%m.%d", "%d/%m/%Y"):
try:
return datetime.strptime(s, f).date()
except ValueError:
pass
return None
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
# --------------------------------------------------------------------------- Formate
class Format:
key = ""
anbieter = ""
pflicht: tuple[str, ...] = ()
def passt(self, header_norm: list[str]) -> bool:
return all(_norm(p) in header_norm for p in self.pflicht)
def zeile(self, row: dict, quelle: str) -> Position | None:
raise NotImplementedError
class FreenetFormat(Format):
key = "freenet"
anbieter = FREENET
pflicht = ("Zeitraum", "Verguetungsart", "Unterhaendler", "Betrag")
def zeile(self, r: dict, quelle: str) -> Position | None:
g = lambda *k: next((_text(r.get(_norm(x))) for x in k if _text(r.get(_norm(x)))), "")
if not g("Verguetungsart") and not g("Betrag"):
return None
return Position(
anbieter=FREENET,
zeitraum=g("Zeitraum"),
provisionsart=g("Verguetungsart"),
kennung=g("Unterhaendler") or g("Haendlernummer"),
datum=_datum(r.get(_norm("Aktiv"))),
rufnummer=g("Rufnummer"),
imei=g("IMEI_SN"),
geraet=g("Geraetename"),
vorname=g("Kundenvorname"),
name=g("Kundename"),
tarif=g("Tarif"),
betrag_cent=parse_cent(r.get(_norm("Betrag"))),
netz=g("Netz"),
dienst=g("Dienst"),
aktionscode=g("Aktionscode", "Couponcode"),
quelle=quelle,
)
class VodafoneFormat(Format):
"""Euronics-Provisionsclearing (Vodafone/otelo/Cable), Blatt 'All' bzw. Abrechnungslauf-Blätter."""
key = "vodafone"
anbieter = VODAFONE
pflicht = ("Anschaltdatum", "Artikelbezeichnung", "Vo-Nr")
def zeile(self, r: dict, quelle: str) -> Position | None:
g = lambda *k: next((_text(r.get(_norm(x))) for x in k if _text(r.get(_norm(x)))), "")
praemie = r.get(_norm("Prämie in EUR"), r.get(_norm("Praemie in EUR")))
if not g("Artikelbezeichnung") and praemie in (None, ""):
return None
art = g("Artikelbezeichnung")
netz = "otelo" if "otelo" in art.lower() else ("Cable" if "cable" in art.lower() else "VF")
return Position(
anbieter=VODAFONE,
zeitraum=g("Abrechnungslauf"),
provisionsart=art,
kennung=g("Vo-Nr"),
datum=_datum(r.get(_norm("Anschaltdatum"))),
rufnummer=g("Rufnummer"),
imei=g("IMEI", "Kartennummer"),
geraet="",
vorname="",
name=g("Kunde"),
tarif=g("Tarif"),
betrag_cent=parse_cent(praemie),
netz=netz,
dienst=g("TarifKey02", "Tarif Key02"),
aktionscode=g("Text"),
quelle=quelle,
)
FORMATE: list[Format] = [FreenetFormat(), VodafoneFormat()]
# --------------------------------------------------------------------------- Tabellen lesen
def _tabellen_aus_rows(rows: list[list], quelle: str, blatt: str = "") -> tuple[Format, list[Position]] | None:
"""Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter."""
for idx, row in enumerate(rows[:40]):
header = [_norm(c) for c in row]
for fmt in FORMATE:
if fmt.passt(header):
pos: list[Position] = []
leer = 0
for data in rows[idx + 1:]:
if not any(_text(c) for c in data):
leer += 1
if leer > 20:
break
continue
leer = 0
rec = {}
for h, v in zip(header, data):
if h and h not in rec:
rec[h] = v
p = fmt.zeile(rec, quelle)
if p:
p.roh = {str(k): (v.isoformat() if isinstance(v, (date, datetime)) else v)
for k, v in zip(row, data) if k not in (None, "")}
if blatt:
p.roh["_blatt"] = blatt
pos.append(p)
return fmt, pos
return None
def _decode(raw: bytes) -> str:
for enc in ("utf-8-sig", "cp1252", "latin-1"):
try:
return raw.decode(enc)
except UnicodeDecodeError:
continue
return raw.decode("latin-1", errors="replace")
def _lies_csv(path: Path) -> list[tuple[str, list[list]]]:
text = _decode(path.read_bytes())
sample = text[:5000]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=";,\t|")
delim = dialect.delimiter
except csv.Error:
delim = ";"
rows = list(csv.reader(io.StringIO(text), delimiter=delim))
return [("", rows)]
def _lies_xlsx(path: Path) -> list[tuple[str, list[list]]]:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
try:
return [(ws.title.strip(), [list(r) for r in ws.iter_rows(values_only=True)]) for ws in wb.worksheets]
finally:
wb.close()
def _lies_xls(path: Path) -> list[tuple[str, list[list]]]:
import xlrd
book = xlrd.open_workbook(str(path))
out = []
for sh in book.sheets():
rows = []
for r in range(sh.nrows):
row = []
for c in range(sh.ncols):
cell = sh.cell(r, c)
if cell.ctype == xlrd.XL_CELL_DATE:
row.append(xlrd.xldate.xldate_as_datetime(cell.value, book.datemode))
else:
row.append(cell.value)
rows.append(row)
out.append((sh.name.strip(), rows))
return out
def lies_datei(path: str | Path) -> ImportErgebnis:
path = Path(path)
ext = path.suffix.lower()
if ext in (".csv", ".txt"):
blaetter = _lies_csv(path)
elif ext in (".xlsx", ".xlsm"):
blaetter = _lies_xlsx(path)
elif ext == ".xls":
blaetter = _lies_xls(path)
else:
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.")
gefunden: list[tuple[str, Format, list[Position]]] = []
for name, rows in blaetter:
res = _tabellen_aus_rows(rows, path.name, name)
if res and res[1]:
gefunden.append((name, res[0], res[1]))
if not gefunden:
raise ImportFehler(
f"In „{path.name}“ wurde keine bekannte Provisionsliste gefunden.\n"
"Erwartet wird eine freenet-Abrechnung (Spalten Verguetungsart/Unterhaendler/Betrag) "
"oder eine Vodafone/Euronics-Abrechnung (Spalten Anschaltdatum/Artikelbezeichnung/Vo-Nr)."
)
warnungen: list[str] = []
# Vodafone/Euronics: Das Blatt "All" enthält alle Abrechnungsläufe. Sind zusätzlich
# Einzelblätter befüllt, würden Positionen doppelt gezählt – daher nur "All" verwenden.
alle = [g for g in gefunden if g[0].lower() == "all"]
if alle and len(gefunden) > 1:
uebrig = [g[0] for g in gefunden if g is not alle[0]]
warnungen.append(f"Nur Blatt „All“ übernommen, Einzelblätter ignoriert: {', '.join(uebrig)}")
gefunden = alle
positionen = [p for _, _, ps in gefunden for p in ps]
formate = sorted({f.key for _, f, _ in gefunden})
ohne_kennung = sum(1 for p in positionen if not p.kennung)
if ohne_kennung:
warnungen.append(f"{ohne_kennung} Position(en) ohne Unterhändler-/VO-Nummer.")
return ImportErgebnis(path.name, _sha256(path), "+".join(formate), positionen, warnungen)
+71
View File
@@ -0,0 +1,71 @@
"""Geldbeträge werden intern immer als ganze Cent (int) geführt – keine Float-Rundungsfehler."""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
def parse_cent(value) -> int:
"""Wandelt '1.234,56', '-8', '30', 12.5, '12.50 €' usw. in Cent um."""
if value is None:
return 0
if isinstance(value, bool):
raise ValueError("Ungültiger Betrag")
if isinstance(value, int):
return value * 100
if isinstance(value, float):
return int(Decimal(str(value)).scaleb(2).quantize(Decimal(1), ROUND_HALF_UP))
s = str(value).strip().replace("€", "").replace("EUR", "").replace(" ", "").replace("\xa0", "")
if not s:
return 0
neg = False
if s.endswith("-"): # SAP-Stil: '30,00-'
neg, s = True, s[:-1]
if "," in s and "." in s:
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".") # 1.234,56
else:
s = s.replace(",", "") # 1,234.56
elif "," in s:
s = s.replace(",", ".")
try:
d = Decimal(s)
except InvalidOperation as exc:
raise ValueError(f"Ungültiger Betrag: {value!r}") from exc
cent = int(d.scaleb(2).quantize(Decimal(1), ROUND_HALF_UP))
return -cent if neg else cent
def apply_percent(cent: int, prozent: float) -> int:
d = Decimal(cent) * Decimal(str(prozent)) / Decimal(100)
return int(d.quantize(Decimal(1), ROUND_HALF_UP))
def fmt(cent: int | None, symbol: bool = True) -> str:
if cent is None:
return ""
neg = cent < 0
cent = abs(cent)
euro, rest = divmod(cent, 100)
s = f"{euro:,}".replace(",", ".") + f",{rest:02d}"
if neg:
s = "-" + s
return s + (" €" if symbol else "")
def to_euro(cent: int) -> float:
return float(Decimal(cent) / Decimal(100))
def fmt_percent(p: float | None) -> str:
if p is None:
return ""
s = f"{p:.2f}".rstrip("0").rstrip(".")
return s.replace(".", ",") + " %"
def parse_percent(text: str) -> float:
s = str(text).strip().replace("%", "").replace(" ", "").replace(",", ".")
p = float(s)
if p < 0 or p > 1000:
raise ValueError("Prozentwert außerhalb des gültigen Bereichs")
return p