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:
commit
da2e3c9ba1
25 files changed
+3366
No files matched your search
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
from provtool.gui.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user