From 322d2ed1c3f158775b081adac7e7c91839e9c958 Mon Sep 17 00:00:00 2001 From: Handy-Insel Date: Mon, 2 Nov 2026 09:24:39 +0100 Subject: [PATCH] =?UTF-8?q?Provisions-Manager=2095:=20Import,=20H=C3=A4ndl?= =?UTF-8?q?er-DB,=20Auszahlungsregeln,=20Excel-Export,=20Win95-Oberfl?= =?UTF-8?q?=C3=A4che,=20Wine-Build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/windows-build.yml | 59 +++ .gitignore | 15 + README.md | 91 ++++ assets/app.ico | Bin 0 -> 8459 bytes assets/app.png | Bin 0 -> 980 bytes build/build_windows.sh | 33 ++ build/make_icon.py | 20 + build/provisionsmanager.spec | 29 ++ build/start.py | 3 + build/version_info.py | 26 + provtool/__init__.py | 5 + provtool/__main__.py | 4 + provtool/config.py | 61 +++ provtool/db.py | 309 +++++++++++ provtool/export.py | 123 +++++ provtool/gui/__init__.py | 0 provtool/gui/app.py | 803 +++++++++++++++++++++++++++++ provtool/gui/dialogs.py | 633 +++++++++++++++++++++++ provtool/gui/win95.py | 582 +++++++++++++++++++++ provtool/importer.py | 304 +++++++++++ provtool/money.py | 71 +++ pytest.ini | 3 + requirements-dev.txt | 3 + requirements.txt | 2 + tests/test_kern.py | 187 +++++++ 25 files changed, 3366 insertions(+) create mode 100644 .gitea/workflows/windows-build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/app.ico create mode 100644 assets/app.png create mode 100755 build/build_windows.sh create mode 100644 build/make_icon.py create mode 100644 build/provisionsmanager.spec create mode 100644 build/start.py create mode 100644 build/version_info.py create mode 100644 provtool/__init__.py create mode 100644 provtool/__main__.py create mode 100644 provtool/config.py create mode 100644 provtool/db.py create mode 100644 provtool/export.py create mode 100644 provtool/gui/__init__.py create mode 100644 provtool/gui/app.py create mode 100644 provtool/gui/dialogs.py create mode 100644 provtool/gui/win95.py create mode 100644 provtool/importer.py create mode 100644 provtool/money.py create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/test_kern.py diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml new file mode 100644 index 0000000..10f7e70 --- /dev/null +++ b/.gitea/workflows/windows-build.yml @@ -0,0 +1,59 @@ +# Baut die Windows-Version (Provisionsmanager95.exe) mit Windows-Python unter Wine. +# - jeder Push / PR: Tests + .exe als Build-Artefakt +# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip +name: Windows-Build + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + workflow_dispatch: + +jobs: + windows-exe: + # Label ggf. an euren Runner anpassen (Runner braucht Docker; Wine steckt im Container-Image). + runs-on: ubuntu-latest + container: + image: tobix/pywine:3.12 + env: + WINEPREFIX: /opt/wineprefix + steps: + - name: Werkzeuge für Checkout/Upload installieren + run: | + apt-get update -qq + apt-get install -y -qq --no-install-recommends git nodejs ca-certificates >/dev/null + + - uses: actions/checkout@v4 + + - name: Tests + Build (.exe) + run: ./build/build_windows.sh + + - name: Build-Artefakt hochladen + uses: actions/upload-artifact@v3 + with: + name: Provisionsmanager95 + path: | + dist/Provisionsmanager95.exe + dist/Provisionsmanager95-*.zip + + - name: Release erstellen + if: startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -eu + VERSION=$(cat dist/VERSION | tr -d '\r\n') + if [ "v$VERSION" != "$TAG" ]; then + echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1 + fi + resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}") + id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do + curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \ + -H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null + echo "hochgeladen: $f" + done diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8545d8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Echte Abrechnungen enthalten Kundendaten – niemals einchecken! +beispieldaten/ +*.csv +/*.xlsx +/*.xls + +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +venv/ +dist/ +build/tmp/ +build/version_info.txt +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7e0acf --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# Provisions-Manager 95 + +Windows-Programm der Handy-Insel Miesbach: liest die monatlichen Provisionsabrechnungen +von **freenet** (CSV/Excel) und **Vodafone/Euronics** (Excel) ein, ordnet die Positionen den +Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine +fertige Excel-Abrechnung. Optik: Windows 95. + +## Bedienung in Kürze + +1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*: + - freenet → Spalte **Unterhaendler** (z. B. `27518902`) + - Vodafone → Spalte **Vo-Nr** (z. B. `80211721`, `EU133301`) + Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern. +2. **Neue Abrechnung** (`Strg+N`), z. B. „Oktober 2026“. +3. **Import** (`Strg+I`): beliebig viele Dateien auf einmal, z. B. alle freenet-Wochenlisten + eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt. + Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter + „(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen. +4. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach + Provisionsart gruppiert. + - Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch + als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe). + - Doppelklick / `F2` auf eine Position: Betrag frei überschreiben + (`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert. + - Rechtsklick: u. a. „Auszahlung 0 €“. +5. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage: + + | A | B | C | D | E | F | G | H | I | J | K | L | M | N | + |---|---|---|---|---|---|---|---|---|---|---|---|---|---| + | Zeitraum | Provisionsart | Unterhändler-/VO-Nr | Datum | Rufnummer | IMEI | Gerät | Vorname | Name | Tarif | **Auszahlung** | Netz | Dienst | Aktionscode | + + Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile. + Kopfzeile optional zuschaltbar. + +Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`). +Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*. + +### Zuordnung der Vodafone-Spalten + +| Export | Vodafone/Euronics | +|---|---| +| Zeitraum | Abrechnungslauf (`VF2609`, `VF2609P8`, …) | +| Provisionsart | Artikelbezeichnung | +| Unterhändler | Vo-Nr | +| Datum | Anschaltdatum | +| Name | Kunde | +| Tarif | Tarif | +| Netz | VF / otelo / Cable (aus der Artikelbezeichnung) | +| Dienst | TarifKey02 | +| Aktionscode | Text | + +Bei Vodafone wird nur das Blatt **All** gelesen (die Einzelblätter je Abrechnungslauf würden +sonst doppelt zählen). + +## Entwicklung + +```bash +python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements-dev.txt +pytest # Tests (nur erfundene Testdaten) +python -m provtool # Programm starten (benötigt Tkinter) +``` + +Aufbau: + +| Datei | Inhalt | +|---|---| +| `provtool/importer.py` | Formaterkennung und Einlesen (CSV, xlsx, xls) | +| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen | +| `provtool/export.py` | Excel-Export im Zielformat | +| `provtool/money.py` | Beträge (intern immer in Cent) | +| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) | + +**Datenschutz:** Echte Abrechnungen enthalten Kundendaten und gehören nicht ins Repository +(`.gitignore` schließt `*.csv`, `*.xlsx` im Hauptordner und `beispieldaten/` aus). + +## Windows-Build + +Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container +`tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller: + +- jeder Push: Tests unter Windows-Python + `Provisionsmanager95.exe` als Artefakt +- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip` + +Lokal geht dasselbe mit Docker: + +```bash +docker run --rm --entrypoint bash -v "$PWD":/src -w /src tobix/pywine:3.12 ./build/build_windows.sh +``` + +Neue Version: `__version__` in `provtool/__init__.py` erhöhen, committen, `git tag v1.0.1 && git push --tags`. diff --git a/assets/app.ico b/assets/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..b7a5d91cf63069641570f775795eca06c327f239 GIT binary patch literal 8459 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 literal 0 HcmV?d00001 diff --git a/assets/app.png b/assets/app.png new file mode 100644 index 0000000000000000000000000000000000000000..1192ec962e64900e659da195f61d872faccc47d1 GIT binary patch literal 980 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJFd*`6+rAr*7p-gWF-Vj#e9 z@SW&?wzzX~-&nJkNcp?n5nQ^=^JmiMpFj;L!2WD`RYUxPnQ{%PFUudC|H5!UhOxnb zIJDtQ+>82Y-#*7*|9VNnr{dwy+`oL0j1Dsxh($LHZ5`%*X1Vs`@3jb0^v(FpQ1;!2 zf5QuFTZh@A3@w~AL$a5dLAEehZ)PGkR6v3EoROz`Ui!;lzxPdFXMf(f_P>=j1BWot z=yU1M^XZw+|4L)^dk?mV&%1x^{REP{33T?AxzBf&{H?wJ?tDE-Zr`wWf9}6Y=}#Yt z@6$iOfIPcv)@5hf|Kt8HzxQwDJn!rGzrX!A`Pu*d-p>U|isl=?wx13E?yUcg{rHKe^csIXTzeO5X~S^K|udS?83{1OVeyA6ozb literal 0 HcmV?d00001 diff --git a/build/build_windows.sh b/build/build_windows.sh new file mode 100755 index 0000000..aef41d8 --- /dev/null +++ b/build/build_windows.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12). +# Ergebnis: dist/Provisionsmanager95.exe und dist/Provisionsmanager95-.zip +set -euo pipefail +cd "$(dirname "$0")/.." + +export WINEDEBUG=-all +export LANG=C.UTF-8 LC_ALL=C.UTF-8 # sonst kann Wine keine Umlaute in Dateinamen abbilden +export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}" +PY="wine python" + +$PY --version +$PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt + +echo "== Tests (Windows-Python) ==" +$PY -m pytest -q + +echo "== Version ==" +VERSION=$($PY build/version_info.py | tr -d '\r') +echo "Version $VERSION" + +echo "== PyInstaller ==" +$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec + +test -f dist/Provisionsmanager95.exe +cp README.md dist/LIESMICH.md 2>/dev/null || true +( cd dist && rm -f Provisionsmanager95-*.zip && \ + python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \ + "Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md 2>/dev/null \ + || $PY -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \ + "Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md ) +echo "$VERSION" > dist/VERSION +ls -la dist diff --git a/build/make_icon.py b/build/make_icon.py new file mode 100644 index 0000000..e35d4e7 --- /dev/null +++ b/build/make_icon.py @@ -0,0 +1,20 @@ +"""Erzeugt assets/app.ico aus der Pixel-Grafik in provtool/gui/win95.py (benötigt Pillow, nur zum Entwickeln).""" +import sys +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from provtool.gui.win95 import PALETTE, _icon_rows # noqa: E402 + +img = Image.new("RGBA", (16, 16), (0, 0, 0, 0)) +for y, row in enumerate(_icon_rows("app")): + for x, ch in enumerate(row): + if ch != ".": + img.putpixel((x, y), tuple(int(PALETTE[ch][i:i + 2], 16) for i in (1, 3, 5)) + (255,)) +groessen = [16, 32, 48, 64, 128, 256] +big = img.resize((256, 256), Image.NEAREST) +big.save(ROOT / "assets" / "app.ico", sizes=[(s, s) for s in groessen]) +big.save(ROOT / "assets" / "app.png") +print("assets/app.ico geschrieben") diff --git a/build/provisionsmanager.spec b/build/provisionsmanager.spec new file mode 100644 index 0000000..f2b795f --- /dev/null +++ b/build/provisionsmanager.spec @@ -0,0 +1,29 @@ +# PyInstaller-Spezifikation – erzeugt eine einzelne Provisionsmanager95.exe +# Aufruf (unter Windows oder Wine): pyinstaller --noconfirm build/provisionsmanager.spec +import os + +ROOT = os.path.abspath(os.path.join(SPECPATH, "..")) + +a = Analysis( + [os.path.join(ROOT, "build", "start.py")], + pathex=[ROOT], + hiddenimports=["openpyxl", "xlrd"], + excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="Provisionsmanager95", + icon=os.path.join(ROOT, "assets", "app.ico"), + version=os.path.join(ROOT, "build", "version_info.txt"), + console=False, + upx=False, + debug=False, + strip=False, + runtime_tmpdir=None, +) diff --git a/build/start.py b/build/start.py new file mode 100644 index 0000000..a549f80 --- /dev/null +++ b/build/start.py @@ -0,0 +1,3 @@ +from provtool.gui.app import main + +main() diff --git a/build/version_info.py b/build/version_info.py new file mode 100644 index 0000000..6d57d28 --- /dev/null +++ b/build/version_info.py @@ -0,0 +1,26 @@ +"""Schreibt build/version_info.txt (Windows-Dateieigenschaften der .exe) aus provtool.__version__.""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from provtool import APP_NAME, APP_ORG, __version__ # noqa: E402 + +v = tuple(int(x) for x in (__version__.split(".") + ["0", "0", "0"])[:4]) +text = f"""VSVersionInfo( + ffi=FixedFileInfo(filevers={v}, prodvers={v}, mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0), + kids=[ + StringFileInfo([StringTable('040704b0', [ + StringStruct('CompanyName', '{APP_ORG}'), + StringStruct('FileDescription', '{APP_NAME}'), + StringStruct('FileVersion', '{__version__}'), + StringStruct('InternalName', 'Provisionsmanager95'), + StringStruct('OriginalFilename', 'Provisionsmanager95.exe'), + StringStruct('ProductName', '{APP_NAME}'), + StringStruct('ProductVersion', '{__version__}')])]), + VarFileInfo([VarStruct('Translation', [0x0407, 1200])]) + ] +) +""" +(ROOT / "build" / "version_info.txt").write_text(text, encoding="utf-8") +print(__version__) diff --git a/provtool/__init__.py b/provtool/__init__.py new file mode 100644 index 0000000..45c15b8 --- /dev/null +++ b/provtool/__init__.py @@ -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" diff --git a/provtool/__main__.py b/provtool/__main__.py new file mode 100644 index 0000000..9aadb85 --- /dev/null +++ b/provtool/__main__.py @@ -0,0 +1,4 @@ +from provtool.gui.app import main + +if __name__ == "__main__": + main() diff --git a/provtool/config.py b/provtool/config.py new file mode 100644 index 0000000..c5b505b --- /dev/null +++ b/provtool/config.py @@ -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" diff --git a/provtool/db.py b/provtool/db.py new file mode 100644 index 0000000..b57296d --- /dev/null +++ b/provtool/db.py @@ -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)) diff --git a/provtool/export.py b/provtool/export.py new file mode 100644 index 0000000..110e465 --- /dev/null +++ b/provtool/export.py @@ -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 diff --git a/provtool/gui/__init__.py b/provtool/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/provtool/gui/app.py b/provtool/gui/app.py new file mode 100644 index 0000000..e2298da --- /dev/null +++ b/provtool/gui/app.py @@ -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("<>", self._haendler_gewaehlt) + self.htree.bind("", lambda e: self._haendler_doppelklick()) + pw.add(self.f_h, minsize=240, width=310) + + # --- Positionen-Fenster + self.f_p = w.Fenster(pw, "Positionen", self.icons("neu")) + cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz") + r2, self.ptree = w.scroll_tree(self.f_p.inhalt, columns=cols, show="tree headings", selectmode="extended") + self.ptree.heading("#0", text="Provisionsart / Kunde", anchor="w") + self.ptree.column("#0", width=200, minwidth=140, stretch=False) + for c, t, b, a in (("datum", "Datum", 76, "w"), ("rufnummer", "Rufnummer", 100, "w"), + ("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 84, "w"), + ("kennung", "Kennung", 70, "w"), + ("orig", "Provision", 78, "e"), ("proz", "%", 52, "e"), ("ausz", "Auszahlung", 84, "e")): + self.ptree.heading(c, text=t, anchor=a) + self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False) + w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4}) + self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD) + self.ptree.tag_configure("manuell", foreground="#0000c0") + self.ptree.tag_configure("abw", foreground="#a00000") + self.ptree.tag_configure("negativ", foreground="#a00000") + r2.pack(fill="both", expand=True) + self.ptree.bind("", self._doppelklick) + self.ptree.bind("", self._kontextmenue) + self.ptree.bind("<>", lambda e: self._status_auswahl()) + + leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE) + leiste.pack(fill="x", pady=(4, 0)) + w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent")).pack(side="left") + w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4) + w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left") + self.v_alle_auf = tk.BooleanVar(value=True) + w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right") + w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4) + tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack( + side="left", padx=8) + pw.add(self.f_p, minsize=500) + + def _tasten(self): + r = self.root + r.bind_all("", lambda e: self.neu()) + r.bind_all("", lambda e: self.abrechnung_waehlen()) + r.bind_all("", lambda e: self.importieren()) + r.bind_all("", lambda e: self.exportieren()) + r.bind_all("", lambda e: self.haendler()) + r.bind_all("", lambda e: self._suchfeld.focus_set()) + r.bind_all("", lambda e: self.hilfe()) + self.ptree.bind("", lambda e: self.betrag_bearbeiten()) + self.ptree.bind("", lambda e: self.betrag_bearbeiten()) + self.ptree.bind("", lambda e: self.prozent()) + + # ================================================================= Daten & Anzeige + def oeffnen(self, aid: int): + self.aid = aid + self._aktualisieren() + + def _aktualisieren(self, behalte_auswahl: bool = True): + self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()} + if self.aid and self.db.abrechnung(self.aid): + self.positionen = self.db.positionen(self.aid) + titel = self.db.abrechnung(self.aid)["titel"] + self.root.title(f"{APP_NAME} - [{titel}]") + self.status.set("info", f" Abrechnung: {titel}") + else: + self.aid = None + self.positionen = [] + self.root.title(APP_NAME) + self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import") + self._haendlerliste(behalte_auswahl) + self._tabelle() + + def _gefiltert(self) -> list[dict]: + if self.ansicht == ALLE: + return self.positionen + return [p for p in self.positionen if p["haendler_id"] == self.ansicht] + + def _haendlerliste(self, behalte_auswahl=True): + t = self.htree + t.delete(*t.get_children()) + stat: dict[int, list[int]] = defaultdict(lambda: [0, 0, 0]) + for p in self.positionen: + s = stat[p["haendler_id"]] + s[0] += 1 + s[1] += p["betrag"] + s[2] += p["betrag_orig"] + ges = [sum(s[0] for s in stat.values()), sum(s[1] for s in stat.values())] + t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",)) + for h in self.db.haendler_liste(): + s = stat.get(h.id, [0, 0, 0]) + tags = () if s[0] else ("grau",) + t.insert("", "end", iid=str(h.id), text=h.name, values=(s[0] or "", fmt(s[1]) if s[0] else ""), tags=tags) + s = stat.get(UNZUGEORDNET) + if s: + t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])), + tags=("grau",)) + ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE) + self.ansicht = int(ziel) + t.selection_set(ziel) + t.see(ziel) + + def _haendler_gewaehlt(self, _e=None): + sel = self.htree.selection() + if sel and int(sel[0]) != self.ansicht: + self.ansicht = int(sel[0]) + self._tabelle() + + def _haendler_doppelklick(self): + if self.ansicht > 0: + self.haendler(self.ansicht) + elif self.ansicht == UNZUGEORDNET: + self.kennung_zuordnen_auswahl() + + def _gruppe_von(self, p: dict) -> str: + if self.ansicht == UNZUGEORDNET: + return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}" + if self.ansicht == ALLE: + name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)") + return f"{name} · {p['provisionsart'] or '(ohne)'}" + return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}" + + def _tabelle(self): + if self._edit: + self._edit.destroy() + self._edit = None + t = self.ptree + offen = {iid for iid in t.get_children() if t.item(iid, "open")} + war_leer = not t.get_children() + t.delete(*t.get_children()) + such = self.v_suche.get().strip().lower() + pos = self._gefiltert() + if such: + pos = [p for p in pos if such in " ".join(str(p.get(k) or "") for k in ( + "rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode", + "imei")).lower()] + gruppen: dict[str, list[dict]] = defaultdict(list) + for p in exp.sortiert(pos): + gruppen[self._gruppe_von(p)].append(p) + self._gruppen = {} + for gi, (gname, plist) in enumerate(sorted(gruppen.items())): + giid = f"g{gi}" + self._gruppen[giid] = (gname, plist) + so = sum(p["betrag_orig"] for p in plist) + sa = sum(p["betrag"] for p in plist) + proz = {p["prozent"] for p in plist} + pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "") + t.insert("", "end", iid=giid, text=f"{gname} ({len(plist)})", open=(war_leer or giid in offen or bool(such)), + values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",)) + for p in plist: + tags = [] + if p["manuell"]: + tags.append("manuell") + elif p["betrag"] != p["betrag_orig"]: + tags.append("abw") + kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)" + t.insert(giid, "end", iid=f"p{p['id']}", text=kunde, + values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]), + p["kennung"], fmt(p["betrag_orig"]), + "manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])), + tags=tuple(tags)) + name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get( + self.ansicht, self.haendler_namen.get(self.ansicht, "")) + self.f_p.set_titel(f"Positionen – {name}") + so = sum(p["betrag_orig"] for p in pos) + sa = sum(p["betrag"] for p in pos) + self.status.set("pos", f" {len(pos)} Positionen") + self.status.set("orig", f" Provision: {fmt(so)}") + self.status.set("ausz", f" Auszahlung: {fmt(sa)}") + + def _status_auswahl(self): + pids = self._ausgewaehlte_pids() + if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())): + ps = [p for p in self.positionen if p["id"] in set(pids)] + self.status.set("pos", f" {len(ps)} ausgewählt") + self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}") + self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}") + + def _klappen(self, auf: bool): + for iid in self.ptree.get_children(): + self.ptree.item(iid, open=auf) + + def _ausgewaehlte_pids(self) -> list[int]: + pids: list[int] = [] + for iid in self.ptree.selection(): + if iid.startswith("g"): + pids.extend(p["id"] for p in self._gruppen[iid][1]) + elif iid.startswith("p"): + pids.append(int(iid[1:])) + return list(dict.fromkeys(pids)) + + # ================================================================= Aktionen + def _brauche_abrechnung(self, frage_neu=True) -> bool: + if self.aid: + return True + if frage_neu: + return self.neu() + return False + + def _vorschlag_titel(self) -> str: + heute = date.today() + m = heute.month - 1 or 12 + y = heute.year if heute.month > 1 else heute.year - 1 + return f"{MONATE[m - 1]} {y}" + + def neu(self) -> bool: + t = w.eingabe(self.root, "Neue Abrechnung", "Bezeichnung der Abrechnung (z. B. Abrechnungsmonat):", + self._vorschlag_titel()) + if not t or not t.strip(): + return False + self.aid = self.db.abrechnung_neu(t.strip()) + self.ansicht = ALLE + self._aktualisieren() + return True + + def abrechnung_waehlen(self): + if not self.db.abrechnungen(): + w.info(self.root, "Abrechnung öffnen", "Es gibt noch keine gespeicherten Abrechnungen.") + return + d = dlg.AbrechnungenDialog(self.root, self.db, self.aid) + res = d.zeigen() + if res and res[0] == "oeffnen": + self.ansicht = ALLE + self.oeffnen(res[1]) + else: + self._aktualisieren() + + def importieren(self, dateien: list[str] | None = None): + if not dateien: + dateien = list(filedialog.askopenfilenames( + parent=self.root, title="Provisionsabrechnungen importieren", + initialdir=self.settings.get("import_ordner") or None, + filetypes=[("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"), + ("CSV-Dateien", "*.csv"), ("Excel-Dateien", "*.xlsx *.xlsm *.xls"), + ("Alle Dateien", "*.*")])) + if not dateien: + return + self.settings["import_ordner"] = str(Path(dateien[0]).parent) + if not self._brauche_abrechnung(): + return + bericht = [] + self.root.configure(cursor="watch") + self.root.update() + try: + for f in dateien: + name = Path(f).name + try: + erg = lies_datei(f) + except ImportFehler as exc: + bericht.append((name, "–", 0, 0, str(exc).split("\n")[0])) + continue + except Exception as exc: + bericht.append((name, "–", 0, 0, f"Fehler: {exc}")) + continue + schon = self.db.datei_schon_importiert(erg.sha256) + if schon and not w.frage(self.root, "Bereits importiert", + f"„{name}“ wurde bereits importiert (in: {', '.join(schon)}).\n\n" + "Trotzdem noch einmal importieren? Die Positionen wären dann doppelt.", + default_ja=False): + bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)")) + continue + n = self.db.importieren(self.aid, erg) + bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen), + "; ".join(erg.warnungen) or "OK")) + finally: + self.root.configure(cursor="") + self._aktualisieren() + unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0]) + for p in self.positionen: + if p["haendler_id"] == UNZUGEORDNET: + u = unbekannt[(p["anbieter"], p["kennung"])] + u[0] += 1 + u[1] += p["betrag_orig"] + res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen() + if res: + self.kennung_zuordnen(*res) + + def dateien(self): + if not self.aid: + return + if dlg.DateienDialog(self.root, self.db, self.aid).zeigen(): + self._aktualisieren() + + def haendler(self, hid: int | None = None, neue_kennung: tuple[str, str] | None = None): + geaendert = dlg.HaendlerDialog(self.root, self.db, hid, neue_kennung).zeigen() + if geaendert: + self._nach_haendler_aenderung() + + def _nach_haendler_aenderung(self): + if self.aid: + n = self.db.neu_berechnen(self.aid) + if n: + self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet") + self._aktualisieren() + + def kennung_zuordnen(self, anbieter: str, kennung: str): + res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen() + if not res: + return + if res[0] == "neu": + self.haendler(neue_kennung=(anbieter, kennung)) + else: + self._nach_haendler_aenderung() + + def kennung_zuordnen_auswahl(self): + pids = self._ausgewaehlte_pids() + p = next((x for x in self.positionen if pids and x["id"] == pids[0]), None) + if not p: + w.info(self.root, "Kennung zuordnen", "Bitte zuerst eine Position oder Gruppe auswählen.") + return + if p["haendler_id"] != UNZUGEORDNET: + w.info(self.root, "Kennung zuordnen", + f"Die Kennung {p['kennung']} gehört bereits zu „{self.haendler_namen.get(p['haendler_id'])}“.") + return + self.kennung_zuordnen(p["anbieter"], p["kennung"]) + + def _auswahl_info(self): + sel = self.ptree.selection() + pids = self._ausgewaehlte_pids() + ps = [p for p in self.positionen if p["id"] in set(pids)] + return sel, pids, ps + + def prozent(self): + sel, pids, ps = self._auswahl_info() + if not ps: + w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen " + "in der Liste auswählen.") + return + hids = {p["haendler_id"] for p in ps} + arten = {p["provisionsart"] for p in ps} + hid = next(iter(hids)) if len(hids) == 1 else None + art = next(iter(arten)) if len(arten) == 1 else None + regel_moeglich = bool(hid and hid > 0 and art is not None) + hname = self.haendler_namen.get(hid, "(Nicht zugeordnet)") if hid is not None else "(mehrere)" + akt = {p["prozent"] for p in ps if p["prozent"] is not None} + vorschlag = next(iter(akt)) if len(akt) == 1 else 100.0 + nur_gruppe = len(sel) == 1 and sel[0].startswith("g") + res = dlg.ProzentDialog(self.root, hname, art if nur_gruppe or len(ps) > 1 else None, len(ps), + sum(p["betrag_orig"] for p in ps), vorschlag, regel_moeglich).zeigen() + if not res: + return + proz, als_regel, manuelle = res + if als_regel and regel_moeglich: + self.db.regel_setzen(hid, art, proz) + self.db.prozent_anwenden(pids, proz, auch_manuelle=manuelle) + self.db.touch(self.aid) + self._aktualisieren() + self._auswahl_wiederherstellen(sel) + + def _auswahl_wiederherstellen(self, sel): + vorhanden = [i for i in sel if self.ptree.exists(i)] + if vorhanden: + self.ptree.selection_set(vorhanden) + self.ptree.see(vorhanden[0]) + + def zuruecksetzen(self): + sel, pids, ps = self._auswahl_info() + if not ps: + return + for p in ps: + self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"])) + self.db.touch(self.aid) + self._aktualisieren() + self._auswahl_wiederherstellen(sel) + + def neu_berechnen(self): + if not self.aid: + return + manuell = sum(1 for p in self.positionen if p["manuell"]) + auch = False + if manuell: + auch = w.frage(self.root, "Neu berechnen", + f"{manuell} Position(en) wurden von Hand geändert.\n\nSollen diese ebenfalls nach den " + "Händler-Regeln neu berechnet werden?", ja="Ja, alle", nein="Nein, behalten", + default_ja=False) + n = self.db.neu_berechnen(self.aid, auch_manuelle=auch) + self._aktualisieren() + w.info(self.root, "Neu berechnen", f"{n} Position(en) wurden neu berechnet.") + + # ----- Inline-Bearbeitung des Auszahlungsbetrags + def _doppelklick(self, e): + iid = self.ptree.identify_row(e.y) + if not iid: + return + if iid.startswith("g"): + self.ptree.selection_set(iid) + if self.ptree.identify_column(e.x) in ("#7", "#8"): + self.prozent() + return "break" + return + self.ptree.selection_set(iid) + self.betrag_bearbeiten(iid) + + def betrag_bearbeiten(self, iid: str | None = None): + if iid is None: + sel = [i for i in self.ptree.selection() if i.startswith("p")] + if len(sel) != 1: + if any(i.startswith("g") for i in self.ptree.selection()) or len(sel) > 1: + self.prozent() + else: + w.info(self.root, "Betrag ändern", "Bitte eine einzelne Position auswählen.") + return + iid = sel[0] + self.ptree.see(iid) + self.ptree.update_idletasks() + bbox = self.ptree.bbox(iid, "ausz") + if not bbox: + return + pid = int(iid[1:]) + p = next(x for x in self.positionen if x["id"] == pid) + x, y, bw, bh = bbox + var = tk.StringVar(value=fmt(p["betrag"], symbol=False)) + e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT) + e.place(x=x, y=y, width=bw, height=bh) + e.select_range(0, "end") + e.focus_set() + self._edit = e + + def speichern(_ev=None, weiter: int = 0): + if self._edit is not e: + return + try: + cent = parse_cent(var.get()) + except ValueError: + e.configure(bg="#ffc0c0") + return "break" + self._edit = None + e.destroy() + if cent != p["betrag"]: + self.db.betrag_setzen(pid, cent) + self.db.touch(self.aid) + self._aktualisieren() + nxt = self.ptree.next(iid) if weiter > 0 else (self.ptree.prev(iid) if weiter < 0 else None) + if self.ptree.exists(iid): + self.ptree.selection_set(iid) + self.ptree.focus(iid) + if nxt and self.ptree.exists(nxt): + self.ptree.selection_set(nxt) + self.ptree.focus(nxt) + self.betrag_bearbeiten(nxt) + else: + self.ptree.focus_set() + return "break" + + def abbruch(_ev=None): + self._edit = None + e.destroy() + self.ptree.focus_set() + return "break" + + e.bind("", speichern) + e.bind("", speichern) + e.bind("", lambda ev: speichern(ev, 1)) + e.bind("", lambda ev: speichern(ev, 1)) + e.bind("", lambda ev: speichern(ev, -1)) + e.bind("", abbruch) + e.bind("", speichern) + + def _kontextmenue(self, e): + iid = self.ptree.identify_row(e.y) + if not iid: + return + if iid not in self.ptree.selection(): + self.ptree.selection_set(iid) + m = tk.Menu(self.root, tearoff=0) + if iid.startswith("p"): + m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid)) + m.add_command(label="Auszahlung in Prozent…", command=self.prozent) + m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen) + m.add_separator() + m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null()) + if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE: + m.add_separator() + m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl) + m.tk_popup(e.x_root, e.y_root) + + def _null(self): + sel, pids, _ = self._auswahl_info() + self.db.prozent_anwenden(pids, 0.0) + self.db.touch(self.aid) + self._aktualisieren() + self._auswahl_wiederherstellen(sel) + + # ----- Export + def exportieren(self): + if not self.aid or not self.positionen: + w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n" + "Bitte zuerst Dateien importieren.") + return + stat: dict[int, list[int]] = defaultdict(lambda: [0, 0]) + for p in self.positionen: + if p["haendler_id"] != UNZUGEORDNET: + stat[p["haendler_id"]][0] += 1 + stat[p["haendler_id"]][1] += p["betrag"] + if not stat: + w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n" + "Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.") + return + liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in + sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())] + vor = {self.ansicht} if self.ansicht in stat else set(stat) + titel = self.db.abrechnung(self.aid)["titel"] + res = dlg.ExportDialog(self.root, liste, vor, self.settings, titel).zeigen() + if not res: + return + config.save(self.settings) + erzeugt = [] + try: + for hid in res["ids"]: + pos = [p for p in self.positionen if p["haendler_id"] == hid] + erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"], + pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"], + summe=res["summe"]) + except PermissionError as exc: + w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n" + "Ist sie vielleicht noch in Excel geöffnet?") + return + w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n" + + "\n".join(p.name for p in erzeugt[:15]) + ("\n…" if len(erzeugt) > 15 else "")) + if res["oeffnen"]: + _oeffne_ordner(res["ordner"]) + + # ----- Datei-Menü + def sicherung(self): + p = filedialog.asksaveasfilename(parent=self.root, title="Datensicherung speichern", defaultextension=".db", + initialfile=f"Provisionen-Sicherung-{date.today().isoformat()}.db", + filetypes=[("Datenbank", "*.db")]) + if p: + self.db.backup(p) + w.info(self.root, "Datensicherung", f"Sicherung gespeichert:\n{p}") + + def wiederherstellen(self): + p = filedialog.askopenfilename(parent=self.root, title="Datensicherung einspielen", + filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")]) + if not p: + return + if not w.frage(self.root, "Datensicherung einspielen", + "Die aktuelle Datenbank wird durch die Sicherung ERSETZT.\n" + "Vorher wird automatisch eine Kopie (*.vor-wiederherstellung.db) angelegt.\n\nFortfahren?", + default_ja=False): + return + ziel = self.db.path + self.db.backup(ziel.with_suffix(".vor-wiederherstellung.db")) + self.db.close() + try: + Datenbank.restore(p, ziel) + except Exception as exc: + w.fehler(self.root, "Datensicherung", f"Die Datei ist keine gültige Sicherung:\n{exc}") + self.db = Datenbank(ziel) + self.aid = None + abr = self.db.abrechnungen() + if abr: + self.aid = abr[0]["id"] + self.ansicht = ALLE + self._aktualisieren() + + def einstellungen(self): + neu = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen() + if neu and Path(neu) != self.db.path: + try: + db = Datenbank(neu) + except Exception as exc: + w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}") + return + self.db.close() + self.db = db + self.settings["db_pfad"] = neu + config.save(self.settings) + abr = self.db.abrechnungen() + self.aid = abr[0]["id"] if abr else None + self.ansicht = ALLE + self._aktualisieren() + + def hilfe(self): + w.info(self.root, "Kurzanleitung", + "1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n" + " bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n\n" + "2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n" + "3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n" + " auch alle Wochenlisten eines Monats.\n\n" + "4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n" + " • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n" + " optional als Standard für diesen Händler merken.\n" + " • Doppelklick / F2 auf eine Position: Betrag frei ändern\n" + " (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n" + "5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n" + "Alles wird automatisch in der Datenbank gespeichert.") + + def _willkommen(self): + if not self.db.haendler_liste(): + if w.frage(self.root, "Willkommen", + f"Willkommen beim {APP_NAME}!\n\nEs sind noch keine Händler angelegt.\n" + "Jetzt die Unterhändler mit ihren Kennungen anlegen?"): + self.haendler() + + def _fehler_handler(self, exc, val, tb): + text = "".join(traceback.format_exception(exc, val, tb)) + try: + with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f: + f.write(text + "\n") + except OSError: + pass + w.fehler(self.root, "Allgemeine Schutzverletzung", + f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n" + f"Details stehen in {config.app_dir() / 'fehler.log'}") + + def beenden(self): + try: + self.settings["fenster"] = self.root.geometry() + config.save(self.settings) + self.db.close() + finally: + self.root.destroy() + + +def main(): + if sys.platform.startswith("win"): + try: # scharfe Darstellung auf HiDPI-Bildschirmen + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + root = tk.Tk(className="Provisionsmanager95") + app = App(root) + if len(sys.argv) > 1: + root.after(200, lambda: app.importieren([a for a in sys.argv[1:] if Path(a).exists()])) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/provtool/gui/dialogs.py b/provtool/gui/dialogs.py new file mode 100644 index 0000000..a57a172 --- /dev/null +++ b/provtool/gui/dialogs.py @@ -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("<>", 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("", 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("", 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("", 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("", 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("", 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("<>", 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("", self._klick) + self.tree.bind("", 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("", lambda e: self.abbrechen()) diff --git a/provtool/gui/win95.py b/provtool/gui/win95.py new file mode 100644 index 0000000..73de00f --- /dev/null +++ b/provtool/gui/win95.py @@ -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("", lambda e: self.configure(relief="raised")) + self.bind("", lambda e: self.configure(relief="flat")) + self.bind("", lambda e: self.configure(relief="sunken")) + self.bind("", 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("", self._plan, add="+") + widget.bind("", self._weg, add="+") + widget.bind("", 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("", 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("", 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("", 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("", lambda _e: ok()) + d.knopfleiste([("OK", ok), ("Abbrechen", d.abbrechen)]).pack(pady=(6, 12)) + return d.zeigen(e) diff --git a/provtool/importer.py b/provtool/importer.py new file mode 100644 index 0000000..69ba519 --- /dev/null +++ b/provtool/importer.py @@ -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) diff --git a/provtool/money.py b/provtool/money.py new file mode 100644 index 0000000..7fb5761 --- /dev/null +++ b/provtool/money.py @@ -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 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..5e6d04b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.3 +pyinstaller==6.11.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ab7b18e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +openpyxl==3.1.5 +xlrd==2.0.1 diff --git a/tests/test_kern.py b/tests/test_kern.py new file mode 100644 index 0000000..51db062 --- /dev/null +++ b/tests/test_kern.py @@ -0,0 +1,187 @@ +"""Tests für Import, Datenbank und Export – ausschließlich mit erfundenen Testdaten.""" +from datetime import datetime + +import openpyxl +import pytest + +from provtool import export +from provtool.db import UNZUGEORDNET, Datenbank, Haendler +from provtool.importer import ImportFehler, lies_datei +from provtool.money import apply_percent, fmt, parse_cent + +FREENET_KOPF = ("Zeitraum;Verguetungsart;Haendlernummer;Unterhaendler;Vertragsnummer;Aktiv;Deaktiv;Rufnummer;" + "Kartennummer;IMEI_SN;Geraetename;Kundenvorname;Kundename;Tarif-Kurz;Tarif;Prozent;Betrag;Grund;" + "externe Vertragsnummer;Mitarbeiternummer;Abrechnungsnummer;Netz-Kurz;Netz;Dienst-Kurz;Dienst;" + "Aktionscode;Auftragserfassungsdatum;Detailausweisung;Teilzahlung Nr.;Event-Datum;FAQ;Bonusschreiben;" + "Leistungsdatum;Couponcode") + + +def freenet_zeile(art, unterh, aktiv, rufnr, name, tarif, betrag, netz="VF", aktion="", zeitraum="2026.10.01-2026.10.07"): + f = [""] * 34 + f[0], f[1], f[2], f[3], f[4], f[5] = zeitraum, art, "28000000", unterh, "123", aktiv + f[7], f[12], f[14], f[16], f[22], f[24], f[25] = rufnr, name, tarif, betrag, netz, "Teilnehmerverhaeltnis", aktion + return ";".join(f) + + +@pytest.fixture +def freenet_csv(tmp_path): + zeilen = [ + FREENET_KOPF, + freenet_zeile("Grundvergütung Mobilfunk Credit freenet", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet Flat 5GB", "50"), + freenet_zeile("SIM-Karten", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet Flat 5GB", "8"), + freenet_zeile("Sonderbonus Aktion", "111", "07.10.2026", "1602222222", "Beispiel KG", "Business Classic", "170", "D1", "A123"), + freenet_zeile("Grundvergütung Mobilfunk Credit freenet", "111", "06.10.2026", "1602222222", "Beispiel KG", "Business Classic", "12,50", "D1"), + freenet_zeile("SIM-Karten", "999", "01.10.2026", "1609999999", "Eigener Kunde", "Flex", "-8"), + ] + p = tmp_path / "freenet.csv" + p.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252")) + return p + + +@pytest.fixture +def vodafone_xlsx(tmp_path): + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "All" + ws["A1"] = "Provisionsclearing Euronics e.G." + ws["B3"], ws["C3"] = "Kundennummer:", 1 + ws.append([]) + ws.append([]) + ws["A6"] = None + kopf = ["Anschaltdatum", "Kartennummer", "IMEI", "Rufnummer", "Kunde", "Artikelbezeichnung", "Tarif", + "TarifKey02", "Prämie in EUR", "Abrechnungslauf", "Vo-Nr", "Lieferantennr", "Text"] + for c, h in enumerate(kopf, 1): + ws.cell(6, c, h) + daten = [ + (datetime(2026, 9, 8), "3225601 ", "", "491726400000 ", "Testkunde ", "Freischaltungsvergütung Vodafone", + "Aktivierung Smart L ", "Smart ", 345, "VF2609 ", "80211721 ", "705136", "SMART3L"), + (datetime(2026, 9, 8), "3225601 ", "", "491726400000 ", "Testkunde ", "Freischaltungsvergütung Vodafone", + "Basispreisrabatt", "Promotion", -40, "VF2609 ", "80211721 ", "705136", "SMART3L"), + (datetime(2026, 9, 3), "", "", "182935058", "Kabelkunde", "Freischaltungsvergütung Vodafone Cable", + "VF Cable/GigaZuhause 50", "VF Internet Cable", 120, "VF2609", "EU133301", "705136", "5BV:FPT"), + ] + for r, row in enumerate(daten, 7): + for c, v in enumerate(row, 1): + ws.cell(r, c, v) + leer = wb.create_sheet("VF2609 Fil 0 Ref 1") + leer["A5"], leer["B5"] = "Anschaltdatum", "Kartennummer" + p = tmp_path / "vodafone.xlsx" + wb.save(p) + return p + + +@pytest.fixture +def db(tmp_path): + d = Datenbank(tmp_path / "test.db") + yield d + d.close() + + +# --------------------------------------------------------------------------- Geld + +@pytest.mark.parametrize("ein,cent", [("30", 3000), ("12,50", 1250), ("1.234,56", 123456), ("-8", -800), + (12.5, 1250), (8, 800), ("30,00-", -3000), ("", 0), (None, 0), + ("1,234.56", 123456), ("0,1", 10)]) +def test_parse_cent(ein, cent): + assert parse_cent(ein) == cent + + +def test_prozent_und_format(): + assert apply_percent(17000, 80) == 13600 + assert apply_percent(1250, 33.33) == 417 + assert apply_percent(-800, 50) == -400 + assert fmt(123456) == "1.234,56 €" + assert fmt(-5) == "-0,05 €" + + +# --------------------------------------------------------------------------- Import + +def test_freenet_import(freenet_csv): + erg = lies_datei(freenet_csv) + assert erg.format == "freenet" + assert len(erg.positionen) == 5 + p = erg.positionen[0] + assert p.provisionsart == "Grundvergütung Mobilfunk Credit freenet" + assert p.kennung == "111" + assert p.datum.isoformat() == "2026-10-05" + assert p.betrag_cent == 5000 + assert erg.positionen[3].betrag_cent == 1250 + assert erg.positionen[2].aktionscode == "A123" + + +def test_vodafone_import(vodafone_xlsx): + erg = lies_datei(vodafone_xlsx) + assert erg.format == "vodafone" + assert len(erg.positionen) == 3 + p = erg.positionen[0] + assert p.kennung == "80211721" # Leerzeichen entfernt + assert p.rufnummer == "491726400000" + assert p.betrag_cent == 34500 + assert p.zeitraum == "VF2609" + assert erg.positionen[2].netz == "Cable" + + +def test_unbekanntes_format(tmp_path): + p = tmp_path / "x.csv" + p.write_text("a;b;c\n1;2;3\n") + with pytest.raises(ImportFehler): + lies_datei(p) + + +# --------------------------------------------------------------------------- Datenbank + +def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path): + hid = db.haendler_speichern(Haendler(0, "Händler A", standard_prozent=100), + [("freenet", "111"), ("vodafone", "80211721")]) + aid = db.abrechnung_neu("Oktober 2026") + db.importieren(aid, lies_datei(freenet_csv)) + db.importieren(aid, lies_datei(vodafone_xlsx)) + pos = db.positionen(aid) + eigene = [p for p in pos if p["haendler_id"] == hid] + assert len(eigene) == 6 + assert sum(1 for p in pos if p["haendler_id"] == UNZUGEORDNET) == 2 + + # Regel: Sonderbonus nur 80 % + db.regel_setzen(hid, "Sonderbonus Aktion", 80) + db.neu_berechnen(aid) + sb = next(p for p in db.positionen(aid) if p["provisionsart"] == "Sonderbonus Aktion") + assert sb["betrag"] == 13600 and sb["prozent"] == 80 + + # manuelle Änderung bleibt bei Neuberechnung erhalten + sim = next(p for p in db.positionen(aid) if p["provisionsart"] == "SIM-Karten" and p["haendler_id"] == hid) + db.betrag_setzen(sim["id"], 500) + db.neu_berechnen(aid) + sim = next(p for p in db.positionen(aid) if p["id"] == sim["id"]) + assert sim["betrag"] == 500 and sim["manuell"] == 1 + db.neu_berechnen(aid, auch_manuelle=True) + sim = next(p for p in db.positionen(aid) if p["id"] == sim["id"]) + assert sim["betrag"] == 800 and sim["manuell"] == 0 + + # Export + eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid] + dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026") + assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx", + "Händler A - Vodafone - Oktober 2026.xlsx"] + ws = openpyxl.load_workbook(dateien[0]).active + zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)] + # Sortierung Zeitraum → Provisionsart → Datum + assert [z[1] for z in zeilen] == ["Grundvergütung Mobilfunk Credit freenet"] * 2 + ["SIM-Karten", "Sonderbonus Aktion"] + assert zeilen[0][2] == 111 and zeilen[0][4] == 1601111111 + assert zeilen[3][10] == 136.0 and zeilen[3][13] == "A123" + assert ws["K6"].value == "=SUM(K1:K4)" + + +def test_doppelte_kennung_verboten(db): + db.haendler_speichern(Haendler(0, "A"), [("freenet", "1")]) + with pytest.raises(ValueError): + db.haendler_speichern(Haendler(0, "B"), [("freenet", "1")]) + + +def test_datei_doppelt_erkannt_und_entfernbar(db, freenet_csv): + aid = db.abrechnung_neu("X") + erg = lies_datei(freenet_csv) + db.importieren(aid, erg) + assert db.datei_schon_importiert(erg.sha256) == ["X"] + did = db.dateien(aid)[0]["id"] + db.datei_entfernen(did) + assert db.positionen(aid) == []