14 Commits
Author SHA1 Message Date
Kerim AtilganandClaude Opus 5.5 72677ebed7 Selbsttest: Win95-Szenario im normalen Fenster (Rahmenprüfung gilt nicht maximiert)
Build / linux (push) Successful in 1m55s
Build / windows-exe (push) Successful in 8m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:45:38 +01:00
Kerim AtilganandClaude Opus 5.5 03b48f063c Maustest startet im normalen Fenster (Programm sonst maximiert)
Build / linux (push) Successful in 1m53s
Build / windows-exe (push) Failing after 7m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:36:43 +01:00
Kerim AtilganandClaude Opus 5.5 c332c5a487 Programm startet maximiert; Version 1.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:23:01 +01:00
Kerim AtilganandClaude Opus 5.5 c96cf1bd94 Test: Filter-Knopf direkt auslösen (Wine)
Build / linux (push) Successful in 1m51s
Build / windows-exe (push) Successful in 7m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:14:02 +01:00
Kerim AtilganandClaude Opus 5.5 c35356aeb9 Win95-Design an das neue Hauptfenster angepasst, Version 1.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:04:14 +01:00
Kerim AtilganandClaude Opus 5.5 f296483fdc Neues Design im Stil der Handy-Insel-Webtools: Kopfzeile, Seitenleiste, Kennzahlen, Filter, hell/dunkel, IBM Plex
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:55:17 +01:00
Kerim AtilganandClaude Opus 5.5 2600eea574 Abrechnungen links im Hauptfenster (Klick öffnet), Import fragt nach der Ziel-Abrechnung
Build / linux (push) Successful in 1m50s
Build / windows-exe (push) Successful in 7m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:35:16 +01:00
Kerim AtilganandClaude Opus 5.5 057cca6378 Export: Knopf „Noch nicht abgerechnet“; Version 1.5.0 (mit Windows-Setup)
Build / linux (push) Successful in 1m52s
Build / windows-exe (push) Successful in 7m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:44:46 +01:00
Kerim AtilganandClaude Opus 5.5 ec99042569 Windows-Setup (Inno Setup): Installation pro Benutzer, Installer-Test im Build
- PyInstaller baut unter Windows zusätzlich die Ordner-Variante dist/Provisionsmanager/
  (schnellerer Start); onefile-.exe und .zip bleiben, Linux unverändert
- build/installer.iss: ohne Admin nach %LOCALAPPDATA%\Programs\Provisionsmanager,
  Startmenü, optional Desktop, deutsch, feste AppId, CloseApplications,
  Benutzerdaten in %APPDATA%\Provisionsmanager95 bleiben bei Deinstallation erhalten
- build_windows.sh: Inno Setup 6.7.3 (SHA256 geprüft) unter Wine installieren, Setup bauen,
  still installieren, Selbsttest der installierten .exe, still deinstallieren und prüfen
- Pipeline: Setup als Artefakt und am Release; README-Abschnitt Installation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:31:16 +01:00
Kerim AtilganandClaude Opus 5.5 7b9a2acec0 Testdaten: erfundene statt echter Kundennummer
Build / linux (push) Successful in 1m58s
Build / windows-exe (push) Successful in 7m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:15:08 +01:00
Kerim AtilganandClaude Opus 5.5 8c86571034 Modernes Design: neue Symbole und Programm-Icon, ohne „95“ im Namen, Tipp des Tages ohne Büroklammer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:12:05 +01:00
Kerim AtilganandClaude Opus 5.5 851fd6600a Modernes Design (Standard, Win95 als Legacy), Zeitraum abrechnen, Abrechnung erledigt, Linux-Build
- Design: „modern“ (hell, flach, wie Windows 11, normaler Windows-Rahmen) ist Standard;
  „win95“ bleibt unter Datei > Einstellungen wählbar (Einstellung „design“).
- Export: Zeitraum von–bis (nach Aktivierungsdatum oder Abrechnungszeitraum des Anbieters),
  exportierte Positionen werden als abgerechnet gekennzeichnet (Betrag/Händler eingefroren) und
  per Fingerabdruck auch in späteren Dateien/Abrechnungen nicht noch einmal abgerechnet.
- Datei > Abrechnung erledigt / wieder öffnen: erledigte Abrechnungen sind gegen Änderungen gesperrt.
- DB-Schema v4 (Migration inkl. Fingerabdrücke für alte Positionen).
- Linux-Build (python:3.12-bookworm) als zweiter Pipeline-Job mit Tests und Selbsttest;
  Release-Upload für beide Jobs über build/release_hochladen.sh.
- Behoben: normaler Windows-Rahmen stürzte beim Start ab (Hauptrahmen.nativ fehlte).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:47:00 +01:00
Kerim AtilganandClaude Opus 5.5 97c73b82c7 Absturz beim Ziehen des Fensters behoben, Zeiger am Rand wieder über Tk, Vodafone-Datum in Spalte A
- Verschieben/Größe ziehen per PostMessage statt SendMessage: Die Windows-Verschiebeschleife lief
  innerhalb eines Tk-Ereignisses; Tk führte darin Python-Ereignisse (<Configure>) aus -> harter
  Absturz „PyEval_RestoreThread … thread state is NULL“ (normal und aus maximiert).
- Größen-Mauszeiger am Rand wieder von Tk (blieb sonst im Fenster stehen); Windows zieht beim Drücken.
- Neuer Maustest im Build (xdotool, echte Maus): Titelleiste/Rand ziehen, maximieren, herausziehen.
- Export: Bei Vodafone steht in Spalte A das Aktivierungsdatum (statt Abrechnungslauf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:03:33 +01:00
Kerim AtilganandClaude Opus 5.5 0880ab2d42 Airtime je Händler, Jahresbonus-Staffel, Export überarbeitet, natives Windows-Fenster
- Airtime: Auszahlung in % je Händler (0 % = keine). Im Export als eine Summenzeile,
  die Einzelposten in einer eigenen Airtime-Liste (zweite Excel-Datei).
- Jahresbonus Neuaktivierung: Betrag je Karte nach Staffel (Standard 1-9: 5 € ... ab 40: voll),
  Standard-Staffel änderbar, eigene Staffel je Händler möglich.
- Provisionsarten: Airtime/Jahresbonus werden erkannt, Behandlung je Provisionsart umstellbar.
- Export: freenet und Vodafone standardmäßig in einer Datei, Positionen mit 0,00 € weglassen.
- Händler-Dialog: GROSSBUCHSTABEN-Regel als Häkchen (ging verloren, wenn „Hinzufügen“ vergessen wurde).
- Windows: Hauptfenster ist ein echtes Programmfenster mit unsichtbarem Rahmen – Andocken am
  Bildschirmrand, Win+Pfeiltasten und natives Größe-Ziehen; Trennsteg baut erst beim Loslassen neu auf.
- DB-Schema v3 (Migration), Tests für alles Neue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:23:48 +01:00
61 changed files with 3347 additions and 356 deletions

No files matched your search

+39 -19
View File
@@ -1,7 +1,7 @@
# Baut die Windows-Version (Provisionsmanager95.exe) mit Windows-Python unter Wine. # Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version.
# - jeder Push / PR: Tests + .exe als Build-Artefakt # - jeder Push / PR: Tests + Programmdateien als Build-Artefakte
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip # - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip, Setup und Linux-.tar.gz
name: Windows-Build name: Build
on: on:
push: push:
@@ -23,7 +23,7 @@ jobs:
- name: Werkzeuge für Checkout/Upload installieren - name: Werkzeuge für Checkout/Upload installieren
run: | run: |
apt-get update -qq apt-get update -qq
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates >/dev/null apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xdotool >/dev/null
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -37,24 +37,44 @@ jobs:
path: | path: |
dist/Provisionsmanager95.exe dist/Provisionsmanager95.exe
dist/Provisionsmanager95-*.zip dist/Provisionsmanager95-*.zip
dist/Provisionsmanager-Setup-*.exe
- name: Release erstellen - name: Release erstellen / ergänzen
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
env: env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }} TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
set -eu V=$(tr -d '\r\n' < dist/VERSION)
VERSION=$(cat dist/VERSION | tr -d '\r\n') build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$V.zip dist/Provisionsmanager-Setup-$V.exe
if [ "v$VERSION" != "$TAG" ]; then
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1 linux:
fi runs-on: ubuntu-latest
resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ timeout-minutes: 30
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}") container:
id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) image: python:3.12-bookworm
for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do steps:
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \ - name: Werkzeuge installieren
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null run: |
echo "hochgeladen: $f" apt-get update -qq
done apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xvfb xauth fonts-dejavu-core >/dev/null
- uses: actions/checkout@v4
- name: Tests + Build (Linux)
run: ./build/build_linux.sh
- name: Build-Artefakt hochladen
uses: actions/upload-artifact@v3
with:
name: Provisionsmanager95-Linux
path: dist/Provisionsmanager95-linux-*.tar.gz
- name: Release ergänzen
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: build/release_hochladen.sh dist/Provisionsmanager95-linux-x86_64-*.tar.gz
+1
View File
@@ -16,3 +16,4 @@ build/version_info.txt
selbsttest.txt selbsttest.txt
/*.jpg /*.jpg
/*.png /*.png
.claude/
+52 -8
View File
@@ -5,6 +5,16 @@ von **freenet** (CSV/Excel) und **Vodafone/Euronics** (Excel) ein, ordnet die Po
Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine
fertige Excel-Abrechnung. Optik: Windows 95. fertige Excel-Abrechnung. Optik: Windows 95.
## Installation (Windows)
`Provisionsmanager-Setup-<version>.exe` aus dem Release herunterladen und starten – keine
Admin-Rechte nötig. Das Programm landet in `%LOCALAPPDATA%\Programs\Provisionsmanager` mit
Eintrag im Startmenü (Desktop-Verknüpfung optional) und startet schneller als die einzelne `.exe`.
Ein Update installiert man einfach drüber (ein laufendes Programm wird dafür geschlossen).
Deinstallieren über *Einstellungen ▸ Apps*; Datenbank und Einstellungen in
`%APPDATA%\Provisionsmanager95` bleiben dabei erhalten. Ohne Installation geht weiterhin
`Provisionsmanager95.exe` (bzw. das `.zip`) – einfach starten.
## Erster Start ## Erster Start
Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler
@@ -18,12 +28,18 @@ normale Windows-Titelleiste verwendet.
## Bedienung in Kürze ## Bedienung in Kürze
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*: 1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in %, **Airtime in %** (0 % = keine
Airtime) und die *Kennungen*:
- freenet → Spalte **Unterhaendler** (z. B. `11111102`) - freenet → Spalte **Unterhaendler** (z. B. `11111102`)
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder** - Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder** das Häkchen
„Vodafone (Name in GROSSBUCHSTABEN)“: alle Vodafone-Positionen, deren Kundenname komplett „Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN“: alle Vodafone-Positionen, deren Kundenname
großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur ein komplett großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur
Händler haben und hat Vorrang vor der Vo-Nr. ein Händler haben und hat Vorrang vor der Vo-Nr.
- **Jahresbonus Neuaktivierung**: Betrag je Karte nach Staffel, abhängig von der Anzahl Karten des
Händlers in der Abrechnung. Standard (änderbar unter *Händler ▸ Standard-Staffel Jahresbonus…*):
1–9 Karten 5 €, 10–14 10 €, 15–19 15 €, 20–24 20 €, 25–29 25 €, 30–34 30 €, 35–39 35 € je Karte,
ab 40 der volle Betrag. Je Händler kann eine eigene Staffel hinterlegt werden. Nie mehr als die
Provision laut Abrechnung; Stornos zählen ab.
Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern. Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern.
Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…** Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…**
(feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“). (feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“).
@@ -44,7 +60,10 @@ normale Windows-Titelleiste verwendet.
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben - Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert. (`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
- Rechtsklick: u. a. „Auszahlung 0 €“. - Rechtsklick: u. a. „Auszahlung 0 €“.
6. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage: 6. **Export** (`Strg+E`): je Händler eine `.xlsx` im Format der Vorlage – freenet und Vodafone
zusammen (auf Wunsch getrennt), **Airtime nur als eine Summenzeile** „Airtime“ – plus eine
eigene **Airtime-Liste** (`<Händler> - Airtime - <Titel>.xlsx`). Positionen mit 0,00 € werden
weggelassen (abschaltbar):
| A | B | C | D | E | F | G | H | I | J | K | L | M | N | | A | B | C | D | E | F | G | H | I | J | K | L | M | N |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -53,6 +72,28 @@ normale Windows-Titelleiste verwendet.
Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile. Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile.
Kopfzeile optional zuschaltbar. Kopfzeile optional zuschaltbar.
Welche Provisionsart Airtime bzw. Jahresbonus ist, erkennt das Programm an den Texten (z. B.
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
*Händler ▸ Provisionsarten…*.
Das Programm startet maximiert. Das Hauptfenster ist unter Windows ein echtes Programmfenster: an den Bildschirmrand
ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
wie gewohnt.
**Zeitraum abrechnen:** Im Export-Dialog „Nur von … bis …“ eintragen (oder „Vormonat“; „Noch nicht abgerechnet“ = alles Offene), wahlweise nach
Aktivierungsdatum oder Abrechnungszeitraum des Anbieters. Exportierte Positionen werden als *abgerechnet*
gekennzeichnet (grau) und nie wieder exportiert – auch nicht, wenn dieselbe Position später in einer
anderen Datei oder Abrechnung auftaucht. Versehentlich? Rechtsklick ▸ *Kennzeichnung „abgerechnet“ aufheben*.
*Datei ▸ Abrechnung erledigt / wieder öffnen* sperrt eine fertige Abrechnung gegen Änderungen.
**Design:** Standard ist das Design der Handy-Insel-Webtools (Lila, Schrift IBM Plex – liegt dem Programm bei):
Kopfzeile mit Menüs und Suche, links Abrechnungen und Händler, oben Kennzahlen, über der Tabelle Filter
(nicht zugeordnet, von Hand geändert, doppelt, bereits abgerechnet). Oben rechts „Dunkel“/„Hell“ schaltet
das dunkle Design um. Das Windows-95-Design gibt es weiter unter *Datei ▸ Einstellungen ▸ Darstellung*.
**Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`). Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*. Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
@@ -90,7 +131,8 @@ Aufbau:
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen | | `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
| `provtool/export.py` | Excel-Export im Zielformat | | `provtool/export.py` | Excel-Export im Zielformat |
| `provtool/money.py` | Beträge (intern immer in Cent) | | `provtool/money.py` | Beträge (intern immer in Cent) |
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) | | `provtool/gui/` | Oberfläche (Tkinter; Designs hell, dunkel, Win95) |
| `provtool/gui/schriften/` | IBM Plex (SIL Open Font License, siehe `OFL.txt`) |
| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung | | `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung |
| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages | | `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
@@ -105,7 +147,9 @@ Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Containe
- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem - jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt `Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip` - danach das Setup (`build/installer.iss`, Inno Setup unter Wine) bauen, still installieren,
den Selbsttest in der installierten `.exe` laufen lassen und still deinstallieren
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe`, `.zip` und Setup
Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen, Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen,
Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw. Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 B

After

Width:  |  Height:  |  Size: 24 KiB

+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Baut die Linux-Version (einzelne Programmdatei) – im Container python:3.12-bookworm (glibc 2.36:
# läuft ab Debian 12, Ubuntu 24.04, Linux Mint 22; Debian 11 ist ausgelaufen).
# Ergebnis: dist/Provisionsmanager95-linux-x86_64-<version>.tar.gz
set -euo pipefail
cd "$(dirname "$0")/.."
export LANG=C.UTF-8 LC_ALL=C.UTF-8
if ! command -v Xvfb >/dev/null; then
apt-get update -qq && apt-get install -y -qq --no-install-recommends xvfb xauth fonts-dejavu-core >/dev/null
fi
Xvfb :91 -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 &
XVFB_PID=$!
trap 'kill $XVFB_PID 2>/dev/null || true' EXIT
export DISPLAY=:91
sleep 1
python3 --version
python3 -m pip install --disable-pip-version-check -q -r requirements-dev.txt
echo "== Tests inkl. Oberfläche (Linux) =="
PROVTOOL_GUI_TESTS=1 timeout 900 python3 -m pytest -v -p no:cacheprovider
VERSION=$(python3 -c "import provtool; print(provtool.__version__)")
echo "== PyInstaller (Version $VERSION) =="
python3 -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
test -x dist/Provisionsmanager95
echo "== Selbsttest des fertigen Programms =="
rm -f selbsttest.txt
set +e
timeout 300 dist/Provisionsmanager95 --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | grep -qx OK; then
echo "Selbsttest FEHLGESCHLAGEN (Exit-Code $CODE)"; exit 1
fi
echo "Selbsttest bestanden"
PAKET="Provisionsmanager95-linux-x86_64-$VERSION"
rm -rf "dist/$PAKET" && mkdir -p "dist/$PAKET"
cp dist/Provisionsmanager95 assets/app.png "dist/$PAKET/"
cp README.md "dist/$PAKET/LIESMICH.md"
cat > "dist/$PAKET/provisionsmanager95.desktop" <<DESKTOP
[Desktop Entry]
Type=Application
Name=Provisions-Manager 95
Comment=Provisionsabrechnung für Unterhändler
Exec=sh -c '"\$(dirname "%k")/Provisionsmanager95"'
Icon=app
Terminal=false
Categories=Office;Finance;
DESKTOP
tar -C dist -czf "dist/$PAKET.tar.gz" "$PAKET"
echo "$VERSION" > dist/VERSION
ls -la dist
+51 -12
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12). # Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12).
# Ergebnis: dist/Provisionsmanager95.exe und dist/Provisionsmanager95-<version>.zip # Ergebnis: dist/Provisionsmanager95.exe, dist/Provisionsmanager95-<version>.zip und
# dist/Provisionsmanager-Setup-<version>.exe (Inno Setup, Installation pro Benutzer)
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -29,6 +30,9 @@ echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) =="
# -v: im Log ist live sichtbar, welcher Test läuft; timeout: Obergrenze für alle Tests zusammen # -v: im Log ist live sichtbar, welcher Test läuft; timeout: Obergrenze für alle Tests zusammen
PROVTOOL_GUI_TESTS=1 timeout 900 $PY -m pytest -v -p no:cacheprovider PROVTOOL_GUI_TESTS=1 timeout 900 $PY -m pytest -v -p no:cacheprovider
echo "== Maustest (echte Maus: Fenster ziehen, Rand ziehen, maximiert ziehen) =="
PY="$PY" timeout 300 bash build/maustest.sh
echo "== Version ==" echo "== Version =="
VERSION=$($PY build/version_info.py | tr -d '\r') VERSION=$($PY build/version_info.py | tr -d '\r')
echo "Version $VERSION" echo "Version $VERSION"
@@ -37,19 +41,24 @@ echo "== PyInstaller =="
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec $PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
test -f dist/Provisionsmanager95.exe test -f dist/Provisionsmanager95.exe
test -f dist/Provisionsmanager/Provisionsmanager.exe # Ordner-Variante für das Setup
selbsttest() { # $1 = zu prüfende .exe
rm -f selbsttest.txt
set +e
timeout 300 wine "$1" --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
echo "Selbsttest von $1 FEHLGESCHLAGEN (Exit-Code $CODE)"
exit 1
fi
echo "Selbsttest bestanden"
}
echo "== Selbsttest der fertigen .exe ==" echo "== Selbsttest der fertigen .exe =="
rm -f selbsttest.txt selbsttest dist/Provisionsmanager95.exe
set +e
timeout 300 wine dist/Provisionsmanager95.exe --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
echo "Selbsttest der .exe FEHLGESCHLAGEN (Exit-Code $CODE)"
exit 1
fi
echo "Selbsttest bestanden"
cp README.md dist/LIESMICH.md 2>/dev/null || true cp README.md dist/LIESMICH.md 2>/dev/null || true
( cd dist && rm -f Provisionsmanager95-*.zip && \ ( 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()" \ 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()" \
@@ -57,4 +66,34 @@ cp README.md dist/LIESMICH.md 2>/dev/null || true
|| $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()" \ || $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 ) "Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md )
echo "$VERSION" > dist/VERSION echo "$VERSION" > dist/VERSION
echo "== Setup (Inno Setup) =="
INNO_VERSION=6.7.3
INNO_SHA256=9c73c3bae7ed48d44112a0f48e66742c00090bdb5bef71d9d3c056c66e97b732
INNO_EXE=build/tmp/innosetup-$INNO_VERSION.exe
mkdir -p build/tmp
curl -fsSL -o "$INNO_EXE" "https://github.com/jrsoftware/issrc/releases/download/is-${INNO_VERSION//./_}/innosetup-$INNO_VERSION.exe"
echo "$INNO_SHA256 $INNO_EXE" | sha256sum -c -
timeout 300 wine "$INNO_EXE" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /CURRENTUSER '/DIR=C:\InnoSetup'
rm -f dist/Provisionsmanager-Setup-*.exe
timeout 300 wine 'C:\InnoSetup\ISCC.exe' /Q "/DAppVersion=$VERSION" 'build\installer.iss'
SETUP="dist/Provisionsmanager-Setup-$VERSION.exe"
test -f "$SETUP"
echo "== Installer-Test (still installieren, Selbsttest, still deinstallieren) =="
winordner() { winepath -u "$(wine cmd /c echo "%$1%" | tr -d '\r')"; } # z. B. APPDATA -> /opt/wineprefix/...
DATEN="$(winordner APPDATA)/Provisionsmanager95"
ZIEL="$(winordner LOCALAPPDATA)/Programs/Provisionsmanager"
mkdir -p "$DATEN"
echo "Benutzerdaten" > "$DATEN/installer-test.txt"
timeout 300 wine "$SETUP" /VERYSILENT /SUPPRESSMSGBOXES /CURRENTUSER /NORESTART
test -f "$(winordner APPDATA)/Microsoft/Windows/Start Menu/Programs/Provisions-Manager.lnk"
selbsttest "$ZIEL/Provisionsmanager.exe"
timeout 300 wine "$ZIEL/unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
# Der Deinstaller arbeitet als Kopie im Temp-Ordner weiter – auf das Ende warten
for _ in $(seq 60); do [ -e "$ZIEL" ] || break; sleep 1; done
if [ -e "$ZIEL" ]; then echo "Deinstallation FEHLGESCHLAGEN – Programmordner noch da:"; ls -la "$ZIEL"; exit 1; fi
if [ ! -f "$DATEN/installer-test.txt" ]; then echo "Deinstallation hat Benutzerdaten gelöscht!"; exit 1; fi
rm "$DATEN/installer-test.txt"
echo "Installer-Test bestanden"
ls -la dist ls -la dist
+45
View File
@@ -0,0 +1,45 @@
; Inno-Setup-Skript: Installation pro Benutzer (ohne Admin-Rechte) aus dist/Provisionsmanager/
; Aufruf (siehe build/build_windows.sh): ISCC.exe /DAppVersion=1.2.3 build\installer.iss
; Ergebnis: dist/Provisionsmanager-Setup-<version>.exe
#ifndef AppVersion
#error AppVersion fehlt – ISCC mit /DAppVersion=<version> aufrufen
#endif
[Setup]
; AppId nie ändern: daran erkennt Windows bei Updates die bestehende Installation
AppId={{79CF85D1-16DC-4FD5-924B-B6C9AFB90309}
AppName=Provisions-Manager
AppVersion={#AppVersion}
AppPublisher=Handy-Insel Miesbach
PrivilegesRequired=lowest
DefaultDirName={localappdata}\Programs\Provisionsmanager
DefaultGroupName=Provisions-Manager
DisableProgramGroupPage=yes
UninstallDisplayIcon={app}\Provisionsmanager.exe
SetupIconFile=..\assets\app.ico
OutputDir=..\dist
OutputBaseFilename=Provisionsmanager-Setup-{#AppVersion}
CloseApplications=yes
WizardStyle=modern
; Benutzerdaten liegen in %APPDATA%\Provisionsmanager95 – das Setup fasst sie nicht an,
; der Deinstaller entfernt nur, was er selbst installiert hat.
[Languages]
Name: "de"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[InstallDelete]
; Update: Bibliotheken der alten Version entfernen, damit keine veralteten Dateien liegen bleiben
Type: filesandordirs; Name: "{app}\_internal"
[Files]
Source: "..\dist\Provisionsmanager\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\Provisions-Manager"; Filename: "{app}\Provisionsmanager.exe"
Name: "{autodesktop}\Provisions-Manager"; Filename: "{app}\Provisionsmanager.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\Provisionsmanager.exe"; Description: "{cm:LaunchProgram,Provisions-Manager}"; Flags: nowait postinstall skipifsilent
-20
View File
@@ -1,20 +0,0 @@
"""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")
+117
View File
@@ -0,0 +1,117 @@
"""Zeichnet die modernen Symbole (flach, wie Windows 11) und das Programm-Icon.
Ausgabe: provtool/gui/bilder/modern/<name>.png (20 px) und <name>_gross.png (40 px),
assets/app.ico und assets/app.png. Benötigt Pillow – nur zum Entwickeln, nicht zur Laufzeit.
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
ZIEL = ROOT / "provtool" / "gui" / "bilder" / "modern"
N = 160 # Zeichenfläche (8-fach), danach weich verkleinert
LINIE, AKZENT, HELL = (66, 66, 66, 255), (0, 95, 184, 255), (255, 255, 255, 255)
GELB, ROT, ORANGE = (240, 190, 70, 255), (196, 43, 28, 255), (247, 168, 0, 255)
S = 9 # Strichstärke auf der Zeichenfläche
SCHRIFT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
def leinwand():
img = Image.new("RGBA", (N, N), (0, 0, 0, 0))
return img, ImageDraw.Draw(img)
def zeichen(d, text, farbe, groesse=104):
f = ImageFont.truetype(SCHRIFT, groesse)
d.text((N / 2, N / 2 + 4), text, font=f, fill=farbe, anchor="mm")
def prozent(d, farbe, s=S):
d.ellipse((28, 28, 68, 68), outline=farbe, width=s)
d.ellipse((92, 92, 132, 132), outline=farbe, width=s)
d.line((120, 30, 40, 130), fill=farbe, width=s + 2)
def neu(d):
d.polygon([(34, 14), (96, 14), (126, 44), (126, 146), (34, 146)], outline=LINIE, width=S, fill=HELL)
d.line([(96, 14), (96, 44), (126, 44)], fill=LINIE, width=S)
d.ellipse((86, 86, 150, 150), fill=AKZENT)
d.line((118, 100, 118, 136), fill=HELL, width=S)
d.line((100, 118, 136, 118), fill=HELL, width=S)
def oeffnen(d):
d.polygon([(14, 34), (62, 34), (76, 50), (146, 50), (146, 132), (14, 132)], fill=GELB, outline=(193, 140, 30, 255),
width=S - 2)
d.rectangle((14, 66, 146, 132), fill=(250, 210, 100, 255), outline=(193, 140, 30, 255), width=S - 2)
def ablage(d, hoch: bool):
d.line([(18, 96), (18, 140), (142, 140), (142, 96)], fill=LINIE, width=S, joint="curve")
oben, unten = (14, 104) if hoch else (14, 104)
d.line((80, oben, 80, unten), fill=AKZENT, width=S + 2)
if hoch:
d.polygon([(80, 8), (52, 42), (108, 42)], fill=AKZENT)
else:
d.polygon([(80, 118), (52, 82), (108, 82)], fill=AKZENT)
def haendler(d):
d.ellipse((24, 26, 74, 76), outline=LINIE, width=S)
d.arc((8, 82, 90, 170), 180, 360, fill=LINIE, width=S)
d.ellipse((88, 34, 132, 78), fill=AKZENT)
d.pieslice((74, 86, 152, 166), 180, 360, fill=AKZENT)
def rechner(d): # Stift: Betrag ändern
d.polygon([(110, 18), (142, 50), (58, 134), (26, 102)], fill=HELL, outline=LINIE, width=S)
d.polygon([(26, 102), (58, 134), (16, 144)], fill=AKZENT)
d.line((98, 30, 130, 62), fill=LINIE, width=S)
def birne(d):
d.ellipse((34, 10, 126, 102), fill=GELB, outline=(193, 140, 30, 255), width=S - 2)
d.rectangle((56, 96, 104, 122), fill=GELB)
d.rectangle((56, 122, 104, 150), fill=(120, 120, 120, 255))
d.line((60, 134, 100, 134), fill=HELL, width=4)
def kreis(d, farbe, text):
d.ellipse((6, 6, 154, 154), fill=farbe)
zeichen(d, text, HELL)
def warnung(d):
d.polygon([(80, 8), (156, 148), (4, 148)], fill=ORANGE)
zeichen(d, "!", (30, 30, 30, 255), 92)
def app(d):
d.rounded_rectangle((4, 4, 156, 156), radius=34, fill=AKZENT)
prozent(d, HELL, 13)
ICONS = {
"neu": neu, "oeffnen": oeffnen, "import": lambda d: ablage(d, False), "export": lambda d: ablage(d, True),
"haendler": haendler, "prozent": lambda d: prozent(d, AKZENT), "rechner": rechner, "birne": birne,
"info": lambda d: kreis(d, AKZENT, "i"), "frage": lambda d: kreis(d, AKZENT, "?"),
"fehler": lambda d: kreis(d, ROT, "×"), "warnung": warnung, "app": app,
}
def main():
ZIEL.mkdir(parents=True, exist_ok=True)
for name, zeichne in ICONS.items():
img, d = leinwand()
zeichne(d)
img.resize((20, 20), Image.LANCZOS).save(ZIEL / f"{name}.png")
img.resize((40, 40), Image.LANCZOS).save(ZIEL / f"{name}_gross.png")
if name == "app":
gross = img.resize((256, 256), Image.LANCZOS)
gross.save(ROOT / "assets" / "app.png")
gross.save(ROOT / "assets" / "app.ico", sizes=[(s, s) for s in (16, 24, 32, 48, 64, 128, 256)])
print(f"{len(ICONS)} Symbole nach {ZIEL} geschrieben, assets/app.ico + app.png erneuert")
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
"""Für build/maustest.sh: startet die App und schreibt laufend Lage und Zustand des Hauptfensters.
Kein pytest-Test – wird im Windows-Build mit echter Maus (xdotool) bedient. Hintergrund: v1.3.0 stürzte
beim Ziehen an der Titelleiste hart ab; das sieht man nur mit echter Maus.
"""
import faulthandler
import json
import os
import sys
import tempfile
import tkinter as tk
aus = sys.argv[1]
faulthandler.enable(open(os.path.join(aus, "absturz.txt"), "w"))
home = tempfile.mkdtemp()
os.environ["PROVTOOL_HOME"] = home
with open(os.path.join(home, "einstellungen.json"), "w") as f:
json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100",
"design": "win95", "vollbild": False}, f) # nur Win95 hat den eigenen Fensterrahmen
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from provtool.gui import app as A # noqa: E402
root = tk.Tk()
a = A.App(root)
fehler = []
root.report_callback_exception = lambda *e: fehler.append(repr(e[1]))
def melden():
with open(os.path.join(aus, "status.txt"), "a") as f:
f.write(f"{root.winfo_x()} {root.winfo_y()} {root.winfo_width()} {root.winfo_height()} "
f"{int(a.rahmen.maximiert)} {len(fehler)}\n")
root.after(250, melden)
root.after(250, melden)
root.mainloop()
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Echte Maus im virtuellen Bildschirm (xdotool): Fenster an der Titelleiste ziehen, am Rand die Größe
# ziehen, maximieren und aus „maximiert“ herausziehen. v1.3.0 stürzte dabei hart ab – das sieht nur
# ein Test mit echter Maus (Windows-Verschiebeschleife). Aufruf aus build_windows.sh (DISPLAY gesetzt).
set -uo pipefail
cd "$(dirname "$0")/.."
PY="${PY:-wine python}"
AUS="$(mktemp -d)"
command -v xdotool >/dev/null || { apt-get update -qq >/dev/null && apt-get install -y -qq xdotool >/dev/null; }
command -v xdotool >/dev/null || { echo "xdotool fehlt"; exit 1; }
$PY build/maus_app.py "$(winepath -w "$AUS" 2>/dev/null || echo "$AUS")" > "$AUS/app.log" 2>&1 &
PID=$!
for i in $(seq 1 120); do [ -s "$AUS/status.txt" ] && break; sleep 0.5; done
sleep 3
lage() { tail -1 "$AUS/status.txt" | tr -d '\r'; }
lebt() { kill -0 "$PID" 2>/dev/null; }
fehler=0
pruefe() { # $1 = Schritt
sleep 1.5
if ! lebt; then echo "ABSTURZ bei: $1"; fehler=1; return 1; fi
echo "$1: $(lage)"
}
ziehen() { # x y dx dy
xdotool mousemove "$1" "$2"; sleep 0.4; xdotool mousedown 1; sleep 0.3
for i in 1 2 3 4 5 6; do xdotool mousemove_relative -- "$(( $3 / 6 ))" "$(( $4 / 6 ))"; sleep 0.15; done
sleep 0.2; xdotool mouseup 1
}
read -r X Y W H M F <<< "$(lage)"
echo "Start: $X $Y $W $H maximiert=$M"
ziehen $(( X + 300 )) $(( Y + 14 )) 60 42; pruefe "Titelleiste ziehen" || true
read -r X2 Y2 W2 H2 M2 F2 <<< "$(lage)"
[ "$X2" -ne "$X" ] || [ "$Y2" -ne "$Y" ] || { echo "Fenster hat sich nicht bewegt"; fehler=1; }
ziehen $(( X2 + W2 - 2 )) $(( Y2 + H2 / 2 )) 60 0; pruefe "Rand ziehen" || true
read -r X3 Y3 W3 H3 M3 F3 <<< "$(lage)"
[ "$W3" -gt "$W2" ] || { echo "Größe am Rand nicht geändert ($W2 -> $W3)"; fehler=1; }
xdotool mousemove $(( X3 + 300 )) $(( Y3 + 14 )); sleep 0.3
xdotool click --repeat 2 --delay 60 1; pruefe "Doppelklick (maximieren)" || true
read -r X4 Y4 W4 H4 M4 F4 <<< "$(lage)"
[ "$M4" = "1" ] || { echo "nicht maximiert"; fehler=1; }
# Unter echtem Windows wird das Fenster dabei wiederhergestellt, unter Wine nicht – hier zählt: kein Absturz
ziehen $(( X4 + 400 )) $(( Y4 + 12 )) 0 150; pruefe "aus maximiert herausziehen" || true
if lebt; then kill "$PID" 2>/dev/null; fi
sleep 1
if [ -s "$AUS/absturz.txt" ]; then echo "== Absturzprotokoll:"; cat "$AUS/absturz.txt"; fehler=1; fi
if grep -q "Fatal Python error" "$AUS/app.log"; then cat "$AUS/app.log"; fehler=1; fi
read -r _ _ _ _ _ FE <<< "$(lage)"
[ "${FE:-0}" = "0" ] || { echo "Fehler in Tk-Callbacks: $FE"; fehler=1; }
[ "$fehler" = 0 ] && echo "Maustest bestanden" || echo "Maustest FEHLGESCHLAGEN"
exit "$fehler"
+26 -5
View File
@@ -1,6 +1,10 @@
# PyInstaller-Spezifikation – erzeugt eine einzelne Provisionsmanager95.exe # PyInstaller-Spezifikation – erzeugt eine einzelne Programmdatei (Windows: .exe, Linux: ELF),
# Aufruf (unter Windows oder Wine): pyinstaller --noconfirm build/provisionsmanager.spec # unter Windows zusätzlich den Ordner dist/Provisionsmanager/ (schneller Start, Inhalt des Setups)
# Aufruf: pyinstaller --noconfirm build/provisionsmanager.spec
import os import os
import sys
WIN = sys.platform.startswith("win")
ROOT = os.path.abspath(os.path.join(SPECPATH, "..")) ROOT = os.path.abspath(os.path.join(SPECPATH, ".."))
@@ -8,7 +12,8 @@ a = Analysis(
[os.path.join(ROOT, "build", "start.py")], [os.path.join(ROOT, "build", "start.py")],
pathex=[ROOT], pathex=[ROOT],
hiddenimports=["openpyxl", "xlrd"], hiddenimports=["openpyxl", "xlrd"],
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))], datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder")),
(os.path.join(ROOT, "provtool", "gui", "schriften"), os.path.join("provtool", "gui", "schriften"))],
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"], excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
noarchive=False, noarchive=False,
) )
@@ -20,11 +25,27 @@ exe = EXE(
a.datas, a.datas,
[], [],
name="Provisionsmanager95", name="Provisionsmanager95",
icon=os.path.join(ROOT, "assets", "app.ico"), icon=os.path.join(ROOT, "assets", "app.ico") if WIN else None,
version=os.path.join(ROOT, "build", "version_info.txt"), version=os.path.join(ROOT, "build", "version_info.txt") if WIN else None,
console=False, console=False,
upx=False, upx=False,
debug=False, debug=False,
strip=False, strip=False,
runtime_tmpdir=None, runtime_tmpdir=None,
) )
if WIN:
exe_ordner = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="Provisionsmanager",
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,
)
COLLECT(exe_ordner, a.binaries, a.datas, name="Provisionsmanager", upx=False, strip=False)
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Hängt Dateien an das Gitea-Release des Tags (legt es an, falls es noch fehlt – Windows- und
# Linux-Job laufen parallel, wer zuerst fertig ist, legt es an).
# Aufruf: TOKEN=… API=…/api/v1/repos/<owner>/<repo> TAG=v1.2.3 build/release_hochladen.sh datei…
set -eu
VERSION=$(tr -d '\r\n' < dist/VERSION)
if [ "v$VERSION" != "$TAG" ]; then
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1
fi
release_id() { curl -sf "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2; }
id=$(release_id || true)
if [ -z "$id" ]; then
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: Setup installieren (oder die .exe direkt starten). Linux: tar.gz entpacken, Provisionsmanager95 starten (ab Debian 12 / Ubuntu 24.04).\"}" \
>/dev/null || sleep 5 # Gleichzeitig angelegt? Dann gibt es das Release jetzt.
id=$(release_id)
fi
for f in "$@"; 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
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.2.0" __version__ = "1.7.0"
APP_NAME = "Provisions-Manager 95" APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach" APP_ORG = "Handy-Insel Miesbach"
+2 -1
View File
@@ -35,7 +35,8 @@ DEFAULTS = {
"import_ordner": None, "import_ordner": None,
"export_kopfzeile": False, "export_kopfzeile": False,
"export_summe": True, "export_summe": True,
"export_pro_anbieter": True, "export_anbieter_getrennt": False, # freenet + Vodafone in einer Datei
"export_null_weglassen": True,
} }
+301 -38
View File
@@ -1,6 +1,7 @@
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen.""" """SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import shutil import shutil
import sqlite3 import sqlite3
@@ -8,10 +9,11 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from provtool import regeln
from provtool.importer import ImportErgebnis from provtool.importer import ImportErgebnis
from provtool.money import apply_percent from provtool.money import apply_percent
SCHEMA_VERSION = 2 SCHEMA_VERSION = 4
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT); CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
@@ -23,7 +25,15 @@ CREATE TABLE IF NOT EXISTS haendler (
email TEXT DEFAULT '', email TEXT DEFAULT '',
notiz TEXT DEFAULT '', notiz TEXT DEFAULT '',
standard_prozent REAL NOT NULL DEFAULT 100, standard_prozent REAL NOT NULL DEFAULT 100,
aktiv INTEGER NOT NULL DEFAULT 1 aktiv INTEGER NOT NULL DEFAULT 1,
airtime_prozent REAL NOT NULL DEFAULT 0, -- 0 = keine Airtime
staffel TEXT -- Jahresbonus-Staffel (JSON), NULL = Standard-Staffel
);
-- Fest eingestellter Typ einer Provisionsart (sonst automatisch erkannt, siehe regeln.py).
CREATE TABLE IF NOT EXISTS art_typ (
provisionsart TEXT PRIMARY KEY,
typ TEXT NOT NULL
); );
-- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu -- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu
@@ -87,6 +97,14 @@ CREATE TABLE IF NOT EXISTS position (
roh TEXT roh TEXT
); );
CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id); CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
-- Abgerechnete Positionen (Fingerabdruck), über alle Abrechnungen hinweg: taucht eine schon
-- ausgezahlte Position in einer späteren Datei/Abrechnung wieder auf, wird sie nicht erneut abgerechnet.
CREATE TABLE IF NOT EXISTS abgerechnet_fp (
fp TEXT PRIMARY KEY,
wann TEXT NOT NULL,
abrechnung TEXT NOT NULL
);
""" """
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
@@ -98,6 +116,27 @@ CAPS_ANBIETER = "vodafone_caps"
CAPS_WERT = "*" CAPS_WERT = "*"
FP_FELDER = ("anbieter", "zeitraum", "provisionsart", "kennung", "datum", "rufnummer", "imei", "vorname", "name",
"tarif", "dienst", "aktionscode", "betrag_orig")
def fingerabdruecke(zeilen: list[dict]) -> list[str]:
"""Fingerabdruck je Position einer Datei. Gleiche Zeilen innerhalb einer Datei (z. B. zwei gleiche
SIM-Karten-Gutschriften) werden durchgezählt, damit beide als eigene Positionen gelten."""
gesehen: dict[str, int] = {}
out = []
for z in zeilen:
roh = "\x1f".join(str(z.get(k) if z.get(k) is not None else "").strip() for k in FP_FELDER)
h = hashlib.sha1(roh.encode("utf-8")).hexdigest()[:24]
gesehen[h] = gesehen.get(h, 0) + 1
out.append(f"{h}#{gesehen[h]}")
return out
class AbrechnungErledigt(Exception):
"""Die Abrechnung ist als erledigt gekennzeichnet und darf nicht mehr verändert werden."""
def ist_grossgeschrieben(name: str | None) -> bool: def ist_grossgeschrieben(name: str | None) -> bool:
buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"] buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"]
return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben) return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben)
@@ -112,6 +151,8 @@ class Haendler:
notiz: str = "" notiz: str = ""
standard_prozent: float = 100.0 standard_prozent: float = 100.0
aktiv: bool = True aktiv: bool = True
airtime_prozent: float = 0.0
staffel: list | None = None # None = Standard-Staffel
def _now() -> str: def _now() -> str:
@@ -135,9 +176,33 @@ class Datenbank:
if "haendler_fest" not in spalten: if "haendler_fest" not in spalten:
# NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner) # NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner)
self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER") self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER")
h_spalten = {r[1] for r in self.con.execute("PRAGMA table_info(haendler)")}
if "airtime_prozent" not in h_spalten:
self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0")
if "staffel" not in h_spalten:
self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT")
if "abgerechnet" not in spalten:
# Zeitpunkt der Abrechnung (NULL = offen), Händler zum Zeitpunkt der Abrechnung (eingefroren),
# Fingerabdruck (erkennt dieselbe Position in späteren Dateien)
self.con.execute("ALTER TABLE position ADD COLUMN abgerechnet TEXT")
self.con.execute("ALTER TABLE position ADD COLUMN abgerechnet_hid INTEGER")
self.con.execute("ALTER TABLE position ADD COLUMN fp TEXT")
a_spalten = {r[1] for r in self.con.execute("PRAGMA table_info(abrechnung)")}
if "erledigt" not in a_spalten:
self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT")
self._fingerabdruecke_nachtragen()
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),)) self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
self.con.commit() self.con.commit()
def _fingerabdruecke_nachtragen(self):
"""Für Positionen aus älteren Versionen den Fingerabdruck berechnen."""
dateien = [r[0] for r in self.con.execute("SELECT DISTINCT datei_id FROM position WHERE fp IS NULL")]
for did in dateien:
zeilen = [dict(r) for r in self.con.execute(
"SELECT * FROM position WHERE datei_id IS ? ORDER BY reihenfolge", (did,))]
for pid, fp in zip([z["id"] for z in zeilen], fingerabdruecke(zeilen)):
self.con.execute("UPDATE position SET fp=? WHERE id=? AND fp IS NULL", (fp, pid))
def close(self): def close(self):
self.con.close() self.con.close()
@@ -158,30 +223,34 @@ class Datenbank:
shutil.copyfile(quelle, ziel) shutil.copyfile(quelle, ziel)
# ------------------------------------------------------------------ Händler # ------------------------------------------------------------------ Händler
@staticmethod
def _haendler_aus(r) -> Haendler:
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"], r["standard_prozent"],
bool(r["aktiv"]), r["airtime_prozent"] or 0.0, regeln.staffel_laden(r["staffel"]))
def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]: def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]:
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name" 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"], return [self._haendler_aus(r) for r in self.con.execute(sql)]
r["standard_prozent"], bool(r["aktiv"])) for r in self.con.execute(sql)]
def haendler(self, hid: int) -> Haendler | None: def haendler(self, hid: int) -> Haendler | None:
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone() r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
if not r: return self._haendler_aus(r) if r else None
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: def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
"""Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]""" """Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]"""
staffel = regeln.staffel_text(regeln.staffel_pruefen(h.staffel)) if h.staffel is not None else None
werte = (h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv),
h.airtime_prozent or 0.0, staffel)
with self.con: with self.con:
if h.id: if h.id:
self.con.execute( self.con.execute(
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=? WHERE id=?", "UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=?, "
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv), h.id)) "airtime_prozent=?, staffel=? WHERE id=?", werte + (h.id,))
hid = h.id hid = h.id
else: else:
cur = self.con.execute( cur = self.con.execute(
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv) VALUES (?,?,?,?,?,?)", "INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv, "
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv))) "airtime_prozent, staffel) VALUES (?,?,?,?,?,?,?,?)", werte)
hid = cur.lastrowid hid = cur.lastrowid
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,)) self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
for anbieter, wert in kennungen: for anbieter, wert in kennungen:
@@ -254,6 +323,51 @@ class Datenbank:
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent", "ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent",
(hid, provisionsart, prozent)) (hid, provisionsart, prozent))
# ------------------------------------------------------------------ Typen der Provisionsarten
def art_typen(self) -> dict[str, str]:
"""Fest eingestellte Typen je Provisionsart (alles andere wird automatisch erkannt)."""
return {r[0]: r[1] for r in self.con.execute("SELECT provisionsart, typ FROM art_typ")}
def art_typ_setzen(self, provisionsart: str, typ: str | None) -> None:
"""typ=None: wieder automatisch erkennen."""
if typ is not None and typ not in regeln.TYPEN:
raise ValueError(f"Unbekannter Typ: {typ}")
with self.con:
if typ is None:
self.con.execute("DELETE FROM art_typ WHERE provisionsart=?", (provisionsart,))
else:
self.con.execute("INSERT INTO art_typ (provisionsart, typ) VALUES (?,?) "
"ON CONFLICT(provisionsart) DO UPDATE SET typ=excluded.typ", (provisionsart, typ))
def provisionsarten(self) -> list[tuple[str, str, int]]:
"""Alle bekannten Provisionsarten: (Provisionsart, automatisch erkannter Typ, Anzahl Positionen)."""
arten: dict[str, list] = {}
for r in self.con.execute("SELECT provisionsart, dienst, tarif FROM position"):
art = r["provisionsart"] or ""
e = arten.setdefault(art, [regeln.NORMAL, 0])
erkannt = regeln.erkennen(art, r["dienst"], r["tarif"])
if erkannt != regeln.NORMAL:
e[0] = erkannt
e[1] += 1
for art in self.art_typen():
arten.setdefault(art, [regeln.erkennen(art), 0])
return sorted(((a, t, n) for a, (t, n) in arten.items()), key=lambda x: x[0].lower())
# ------------------------------------------------------------------ Jahresbonus-Staffel
def standard_staffel(self) -> regeln.Staffel:
r = self.con.execute("SELECT wert FROM meta WHERE schluessel='staffel_standard'").fetchone()
return (regeln.staffel_laden(r[0]) if r else None) or list(regeln.STANDARD_STAFFEL)
def standard_staffel_setzen(self, staffel) -> None:
text = regeln.staffel_text(regeln.staffel_pruefen(staffel))
with self.con:
self.con.execute("INSERT INTO meta VALUES ('staffel_standard', ?) "
"ON CONFLICT(schluessel) DO UPDATE SET wert=excluded.wert", (text,))
def staffel_fuer(self, hid: int) -> regeln.Staffel:
h = self.haendler(hid) if hid > 0 else None
return h.staffel if h and h.staffel is not None else self.standard_staffel()
def prozent_fuer(self, hid: int, provisionsart: str) -> float: def prozent_fuer(self, hid: int, provisionsart: str) -> float:
if hid <= 0: # nicht zugeordnet / eigene Filiale if hid <= 0: # nicht zugeordnet / eigene Filiale
return 100.0 return 100.0
@@ -274,6 +388,7 @@ class Datenbank:
def abrechnungen(self) -> list[sqlite3.Row]: def abrechnungen(self) -> list[sqlite3.Row]:
return list(self.con.execute( return list(self.con.execute(
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, " "SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
"(SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id AND p.abgerechnet IS NOT NULL) AS abgerechnet, "
"(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien " "(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien "
"FROM abrechnung a ORDER BY a.geaendert DESC")) "FROM abrechnung a ORDER BY a.geaendert DESC"))
@@ -297,12 +412,15 @@ class Datenbank:
(sha256,))] (sha256,))]
def datei_entfernen(self, datei_id: int) -> None: def datei_entfernen(self, datei_id: int) -> None:
r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone()
if r:
self._nicht_erledigt(r[0])
with self.con: with self.con:
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,)) self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,)) self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
def importieren(self, aid: int, erg: ImportErgebnis) -> int: def importieren(self, aid: int, erg: ImportErgebnis) -> int:
finde = self.zuordner() self._nicht_erledigt(aid)
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?", start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
(aid,)).fetchone()[0] (aid,)).fetchone()[0]
with self.con: with self.con:
@@ -311,51 +429,133 @@ class Datenbank:
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen))) (aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
did = cur.lastrowid did = cur.lastrowid
for i, p in enumerate(erg.positionen, start=start + 1): for i, p in enumerate(erg.positionen, start=start + 1):
hid = finde(p.anbieter, p.kennung, p.name)
proz = self.prozent_fuer(hid, p.provisionsart)
self.con.execute( self.con.execute(
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, " "INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, " "datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)", "betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,0,?)",
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung, (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.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, p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent,
apply_percent(p.betrag_cent, proz), proz, json.dumps(p.roh, ensure_ascii=False, default=str))) json.dumps(p.roh, ensure_ascii=False, default=str)))
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid)) self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
zeilen = [dict(r) for r in self.con.execute(
"SELECT * FROM position WHERE datei_id=? ORDER BY reihenfolge", (did,))]
for z, fp in zip(zeilen, fingerabdruecke(zeilen)):
self.con.execute("UPDATE position SET fp=? WHERE id=?", (fp, z["id"]))
neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))}
self._regeln_anwenden(aid, neue, auch_manuelle=True)
return len(erg.positionen) return len(erg.positionen)
def positionen(self, aid: int) -> list[dict]: def positionen(self, aid: int) -> list[dict]:
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen).""" """Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
finde = self.zuordner() finde = self.zuordner()
fest = self.art_typen()
schon = dict(self.con.execute("SELECT fp, abrechnung FROM abgerechnet_fp").fetchall())
out = [] out = []
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)): for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
d = dict(r) d = dict(r)
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"]) if d["abgerechnet"] and d["abgerechnet_hid"] is not None:
d["haendler_id"] = d["abgerechnet_hid"] # abgerechnet: Zuordnung eingefroren
else:
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
d["typ"] = regeln.typ_von(d, fest)
# schon anderswo abgerechnet (dieselbe Position in einer früheren Datei/Abrechnung)?
d["doppelt"] = schon.get(d["fp"]) if d["fp"] and not d["abgerechnet"] else None
out.append(d) out.append(d)
return out return out
# ------------------------------------------------------------------ Berechnung nach Regeln
def _rechner(self):
"""Funktion (position, händler_id, typ, karten) -> (auszahlung_cent, prozent).
prozent ist None, wenn der Betrag aus der Jahresbonus-Staffel kommt."""
haendler = {h.id: h for h in self.haendler_liste()}
art_regeln: dict[int, dict[str, float]] = {}
for r in self.con.execute("SELECT haendler_id, provisionsart, prozent FROM regel"):
art_regeln.setdefault(r[0], {})[r[1]] = r[2]
std_staffel = self.standard_staffel()
def rechne(p: dict, hid: int, typ: str, karten: int) -> tuple[int, float | None]:
orig = p["betrag_orig"]
h = haendler.get(hid) if hid > 0 else None
if h is None: # nicht zugeordnet / eigene Filiale: unverändert
return orig, 100.0
regel = art_regeln.get(hid, {}).get(p["provisionsart"])
if regel is not None: # ausdrücklich je Provisionsart festgelegt -> hat Vorrang
proz = regel
elif typ == regeln.AIRTIME:
proz = h.airtime_prozent
elif typ == regeln.JAHRESBONUS:
return regeln.bonus_betrag(h.staffel if h.staffel is not None else std_staffel, karten, orig), None
else:
proz = h.standard_prozent
return apply_percent(orig, proz), proz
return rechne
@staticmethod
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]:
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab)."""
karten: dict[tuple[int, str], int] = {}
for p in positionen:
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"]:
k = (p["haendler_id"], p["anbieter"])
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1)
return {k: max(0, n) for k, n in karten.items()}
def _regeln_anwenden(self, aid: int, pids: set[int] | None = None, auch_manuelle: bool = False) -> int:
"""Berechnet die Auszahlung nach den Händler-Regeln neu: für `pids` (None = alle) und immer für
alle Jahresbonus-Positionen (deren Staffel hängt von der Anzahl aller Karten ab).
Von Hand geänderte Beträge bleiben, außer auch_manuelle=True. Gibt die Anzahl Änderungen zurück."""
if self.erledigt(aid):
return 0 # erledigte Abrechnungen bleiben, wie sie abgerechnet wurden
alle = self.positionen(aid)
karten = self.karten_zaehlen(alle)
rechne = self._rechner()
n = 0
with self.con:
for p in alle:
if p["abgerechnet"]:
continue # schon ausgezahlt: Betrag bleibt
if pids is not None and p["id"] not in pids and p["typ"] != regeln.JAHRESBONUS:
continue
if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)):
continue
betrag, proz = rechne(p, p["haendler_id"], p["typ"], karten.get((p["haendler_id"], p["anbieter"]), 0))
if betrag != p["betrag"] or proz != p["prozent"] or p["manuell"]:
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?",
(betrag, proz, p["id"]))
n += 1
return n
def _abrechnungen_von(self, pids: list[int]) -> list[int]:
if not pids:
return []
marken = ",".join("?" * len(pids))
return [r[0] for r in self.con.execute(
f"SELECT DISTINCT abrechnung_id FROM position WHERE id IN ({marken})", list(pids))]
def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None: def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
"""Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen). """Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen).
Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet.""" Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet."""
pids = self._offene(pids)
with self.con: with self.con:
for pid in pids: for pid in pids:
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid)) self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
finde = self.zuordner() for aid in self._abrechnungen_von(pids):
with self.con: self._regeln_anwenden(aid, set(pids))
for pid in pids:
r = self.con.execute("SELECT * FROM position WHERE id=?", (pid,)).fetchone() def regeln_wiederherstellen(self, pids: list[int]) -> None:
if r is None or r["manuell"]: """Ausgewählte Positionen wieder nach den Händler-Regeln berechnen (auch von Hand geänderte)."""
continue for aid in self._abrechnungen_von(pids):
neu_hid = finde(r["anbieter"], r["kennung"], r["name"], r["haendler_fest"]) self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
proz = self.prozent_fuer(neu_hid, r["provisionsart"])
self.con.execute("UPDATE position SET betrag=?, prozent=? WHERE id=?",
(apply_percent(r["betrag_orig"], proz), proz, pid))
def betrag_setzen(self, pid: int, cent: int) -> None: def betrag_setzen(self, pid: int, cent: int) -> None:
if not self._offene([pid]):
raise AbrechnungErledigt("Die Position ist bereits abgerechnet.")
with self.con: with self.con:
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid)) 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: def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
pids = self._offene(pids)
n = 0 n = 0
with self.con: with self.con:
for pid in pids: for pid in pids:
@@ -370,18 +570,81 @@ class Datenbank:
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int: def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an. """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ötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle)
# ------------------------------------------------------------------ abgerechnet / erledigt
def _offene(self, pids) -> list[int]:
"""Nur Positionen, die noch geändert werden dürfen (nicht abgerechnet, Abrechnung nicht erledigt)."""
pids = list(pids)
if not pids:
return []
marken = ",".join("?" * len(pids))
return [r[0] for r in self.con.execute(
f"SELECT p.id FROM position p JOIN abrechnung a ON a.id=p.abrechnung_id "
f"WHERE p.id IN ({marken}) AND p.abgerechnet IS NULL AND a.erledigt IS NULL", pids)]
def erledigt(self, aid: int) -> str | None:
r = self.con.execute("SELECT erledigt FROM abrechnung WHERE id=?", (aid,)).fetchone()
return r[0] if r else None
def _nicht_erledigt(self, aid: int) -> None:
if self.erledigt(aid):
raise AbrechnungErledigt("Diese Abrechnung ist als erledigt gekennzeichnet.")
def abrechnen(self, pids: list[int], wann: str | None = None) -> int:
"""Positionen als abgerechnet kennzeichnen: Betrag und Händler werden eingefroren, der
Fingerabdruck gemerkt (dieselbe Position wird später nicht noch einmal abgerechnet).
Nur Positionen echter Händler, die noch offen und nicht schon anderswo abgerechnet sind."""
wann = wann or _now()
ids = set(pids)
n = 0 n = 0
with self.con: for aid in self._abrechnungen_von(list(ids)):
for p in self.positionen(aid): titel = self.abrechnung(aid)["titel"]
if p["manuell"] and not auch_manuelle: with self.con:
continue for p in self.positionen(aid):
proz = self.prozent_fuer(p["haendler_id"], p["provisionsart"]) if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
neu = apply_percent(p["betrag_orig"], proz) continue
if neu != p["betrag"] or proz != p["prozent"] or p["manuell"]: self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE id=?",
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?", (neu, proz, p["id"])) (wann, p["haendler_id"], p["id"]))
if p["fp"]:
self.con.execute("INSERT OR REPLACE INTO abgerechnet_fp (fp, wann, abrechnung) VALUES (?,?,?)",
(p["fp"], wann, titel))
n += 1 n += 1
return n return n
def abrechnung_aufheben(self, pids: list[int]) -> int:
"""Kennzeichnung „abgerechnet“ zurücknehmen (z. B. versehentlich exportiert)."""
n = 0
for aid in self._abrechnungen_von(list(pids)):
self._nicht_erledigt(aid)
with self.con:
for pid in pids:
r = self.con.execute("SELECT fp, abgerechnet FROM position WHERE id=?", (pid,)).fetchone()
if not r or not r["abgerechnet"]:
continue
self.con.execute("UPDATE position SET abgerechnet=NULL, abgerechnet_hid=NULL WHERE id=?", (pid,))
if r["fp"]:
self.con.execute("DELETE FROM abgerechnet_fp WHERE fp=?", (r["fp"],))
n += 1
return n
def erledigt_setzen(self, aid: int, erledigt: bool, positionen_aufheben: bool = False) -> int:
"""Abrechnung als erledigt kennzeichnen (alle offenen Händler-Positionen werden dabei
abgerechnet) bzw. wieder öffnen (auf Wunsch samt Kennzeichnung der Positionen).
Gibt die Anzahl der dabei (ab)gerechneten Positionen zurück."""
n = 0
if erledigt:
wann = _now()
n = self.abrechnen([p["id"] for p in self.positionen(aid)], wann)
with self.con:
self.con.execute("UPDATE abrechnung SET erledigt=?, geaendert=? WHERE id=?", (wann, wann, aid))
else:
with self.con:
self.con.execute("UPDATE abrechnung SET erledigt=NULL, geaendert=? WHERE id=?", (_now(), aid))
if positionen_aufheben:
n = self.abrechnung_aufheben([p["id"] for p in self.positionen(aid)])
return n
def touch(self, aid: int) -> None: def touch(self, aid: int) -> None:
with self.con: with self.con:
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid)) self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
+54 -14
View File
@@ -3,6 +3,9 @@
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN | 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 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). Sortierung: Zeitraum -> Provisionsart -> Datum (sonst Reihenfolge der Abrechnung).
Je Händler entstehen zwei Dateien: die Abrechnung (Airtime darin nur als eine Summenzeile) und die
Airtime-Liste mit den Einzelposten. Positionen mit 0,00 € werden weggelassen.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,6 +15,7 @@ from pathlib import Path
from provtool.importer import ANBIETER from provtool.importer import ANBIETER
from provtool.money import to_euro from provtool.money import to_euro
from provtool.regeln import AIRTIME
SPALTEN = [ SPALTEN = [
("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13), ("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13),
@@ -38,8 +42,27 @@ def dateiname(text: str) -> str:
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export" return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export"
AIRTIME_TEXT = "Airtime"
def spalte_a(p: dict):
"""Spalte A: bei freenet der Zeitraum, bei Vodafone das Aktivierungsdatum (dort steht als Zeitraum
nur der Abrechnungslauf wie „VF2609“ – kein Datum)."""
if p.get("anbieter") == "vodafone" and p.get("datum"):
return date.fromisoformat(p["datum"])
return p.get("zeitraum") or None
def airtime_zeile(airtime: list[dict]) -> dict:
"""Eine Zeile „Airtime = Summe“ für die Abrechnung."""
zeitraeume = sorted({p.get("zeitraum") or "" for p in airtime} - {""})
return {"zeitraum": zeitraeume[0] if len(zeitraeume) == 1 else "", "provisionsart": AIRTIME_TEXT,
"tarif": "Summe laut Airtime-Liste", "betrag": sum(p["betrag"] for p in airtime), "_summe": True}
def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True, def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True,
blattname: str = "Tabelle1") -> Path: blattname: str = "Tabelle1", schluss: list[dict] | None = None) -> Path:
"""schluss: Zeilen, die nach den sortierten Positionen kommen (z. B. die Airtime-Summe)."""
import openpyxl import openpyxl
from openpyxl.styles import Font from openpyxl.styles import Font
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -59,9 +82,9 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
zeile += 1 zeile += 1
erste = zeile erste = zeile
for p in sortiert(positionen): for p in sortiert(positionen) + list(schluss or []):
werte = [ werte = [
p.get("zeitraum") or None, spalte_a(p),
p.get("provisionsart") or None, p.get("provisionsart") or None,
_zahl_oder_text(p.get("kennung")), _zahl_oder_text(p.get("kennung")),
date.fromisoformat(p["datum"]) if p.get("datum") else None, date.fromisoformat(p["datum"]) if p.get("datum") else None,
@@ -78,8 +101,10 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
] ]
for c, v in enumerate(werte, start=1): for c, v in enumerate(werte, start=1):
cell = ws.cell(zeile, c, v) cell = ws.cell(zeile, c, v)
cell.font = font cell.font = bold if p.get("_summe") and c in (2, 11) else font
ws.cell(zeile, 4).number_format = "DD.MM.YYYY" ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
if isinstance(werte[0], date):
ws.cell(zeile, 1).number_format = "DD.MM.YYYY"
ws.cell(zeile, 5).number_format = "0" ws.cell(zeile, 5).number_format = "0"
ws.cell(zeile, 6).number_format = "0" ws.cell(zeile, 6).number_format = "0"
ws.cell(zeile, 11).number_format = EURO_FORMAT ws.cell(zeile, 11).number_format = EURO_FORMAT
@@ -104,20 +129,35 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *, 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]: pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
"""Schreibt eine (oder je Anbieter eine) Datei für einen Händler. Gibt die erzeugten Pfade zurück.""" null_weglassen: bool = True) -> list[Path]:
"""Schreibt die Abrechnung eines Händlers (bei pro_anbieter je Anbieter eine) und – falls es Airtime
gibt – die Airtime-Liste. In der Abrechnung steht Airtime nur als eine Summenzeile.
Gibt die erzeugten Pfade zurück."""
ordner = Path(ordner) ordner = Path(ordner)
if null_weglassen:
positionen = [p for p in positionen if p["betrag"]]
if not positionen: if not positionen:
return [] return []
gruppen: dict[str, list[dict]] = {} gruppen: dict[str, list[dict]] = {}
if pro_anbieter: for p in positionen:
for p in positionen: gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
gruppen.setdefault(p["anbieter"], []).append(p)
else: def name(*teile) -> str:
gruppen[""] = positionen return dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t)) + ".xlsx"
out = [] out = []
for anbieter, pos in sorted(gruppen.items()): for anbieter, pos in sorted(gruppen.items()):
teile = [haendler_name] + ([ANBIETER.get(anbieter, anbieter)] if anbieter else []) + [titel] provision = [p for p in pos if p.get("typ") != AIRTIME]
name = dateiname(" - ".join(t for t in teile if t)) + ".xlsx" airtime = [p for p in pos if p.get("typ") == AIRTIME]
out.append(schreibe_xlsx(ordner / name, pos, kopfzeile=kopfzeile, summe=summe)) schluss = [airtime_zeile(airtime)] if airtime else []
if schluss and null_weglassen and not schluss[0]["betrag"]:
schluss = []
if provision or schluss:
out.append(schreibe_xlsx(ordner / name(ANBIETER.get(anbieter, anbieter) if anbieter else ""),
provision, kopfzeile=kopfzeile, summe=summe, schluss=schluss))
airtime = [p for p in positionen if p.get("typ") == AIRTIME]
if airtime:
out.append(schreibe_xlsx(ordner / name(AIRTIME_TEXT), airtime, kopfzeile=kopfzeile, summe=summe,
blattname=AIRTIME_TEXT))
return out return out
+457 -128
View File
@@ -12,9 +12,10 @@ from datetime import date
from pathlib import Path from pathlib import Path
from tkinter import filedialog from tkinter import filedialog
from provtool import APP_NAME, config from provtool import config
from provtool import export as exp from provtool import export as exp
from provtool.db import EIGEN, UNZUGEORDNET, Datenbank from provtool import regeln
from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank
from provtool.gui import assistent as ass from provtool.gui import assistent as ass
from provtool.gui import dialogs as dlg from provtool.gui import dialogs as dlg
from provtool.gui import menue as mn from provtool.gui import menue as mn
@@ -69,20 +70,25 @@ class App:
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen) self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu self._beendet = False # Datenbank und Fenster sind zu
w.design_setzen(self.settings.get("design", "modern"))
w.apply(root) w.apply(root)
self.icons = w.Icons(root) self.icons = w.Icons(root)
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined] root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1)) root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
root.protocol("WM_DELETE_WINDOW", self.beenden) root.protocol("WM_DELETE_WINDOW", self.beenden)
root.report_callback_exception = self._fehler_handler root.report_callback_exception = self._fehler_handler
klassisch = bool(self.settings.get("klassischer_rahmen", True)) klassisch = w.DESIGN == "win95" # Win95: eigene Titelleiste; modern: normaler Windows-Rahmen
w.KLASSISCH = klassisch w.KLASSISCH = klassisch
self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch) self.rahmen = rh.Hauptrahmen(root, w.APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
self.rahmen.set_titel(APP_NAME) self.rahmen.set_titel(w.APP_NAME)
self.inhalt = self.rahmen.inhalt self.inhalt = self.rahmen.inhalt
if w.DUNKEL:
from provtool.gui import win32
win32.dunkle_titelleiste(root)
self._menue() self.neustart = False # Design umgeschaltet: main() baut das Fenster neu auf
self._toolbar() self.filter = "alle"
self._kopf()
self._body() self._body()
self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)]) self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
griff = self.rahmen.griff(self.status) griff = self.rahmen.griff(self.status)
@@ -90,7 +96,8 @@ class App:
griff.pack(side="right", anchor="se") griff.pack(side="right", anchor="se")
self.status.pack(side="bottom", fill="x") self.status.pack(side="bottom", fill="x")
self._tasten() self._tasten()
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False)) # startet maximiert („vollbild“, abschaltbar nur für Tests); „fenster“ = Größe nach dem Wiederherstellen
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("vollbild", True))
if self.settings.get("trenner"): if self.settings.get("trenner"):
root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0)) root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0))
@@ -105,9 +112,52 @@ class App:
root.after(500, self.tipp_des_tages) root.after(500, self.tipp_des_tages)
# ================================================================= Aufbau # ================================================================= Aufbau
def _menue(self): def _kopf(self):
self.menueleiste = mn.Menueleiste(self.inhalt, self.root) """Kopfzeile wie in den Webtools: Logo, Menüs, rechts Suche (modern auch Hell/Dunkel)."""
self.menueleiste.pack(side="top", fill="x") kopf = tk.Frame(self.inhalt, bg=w.FACE)
kopf.pack(side="top", fill="x")
if w.MODERN:
w.trenner(self.inhalt, vertikal=False).pack(side="top", fill="x")
innen = tk.Frame(kopf, bg=w.FACE)
innen.pack(fill="x", padx=(18, 14) if w.MODERN else 0, pady=10 if w.MODERN else 0)
if w.MODERN:
w.logo(innen).pack(side="left")
namen = tk.Frame(innen, bg=w.FACE)
namen.pack(side="left", padx=(10, 22))
tk.Label(namen, text=w.APP_NAME, font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold")).pack(anchor="w")
tk.Label(namen, text="Handy-Insel", fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(anchor="w")
self._menue(innen)
if w.MODERN:
self.b_design = w.knopf(innen, "Hell" if w.DUNKEL else "Dunkel", self.design_umschalten)
self.b_design.pack(side="right", padx=(8, 0))
w.Tooltip(self.b_design, "Zwischen hellem und dunklem Design wechseln")
self.v_suche = tk.StringVar()
if w.MODERN: # Suchfeld mit Rahmen, Hinweis und Tastenkürzel
sf = tk.Frame(innen, bg=w.WHITE, highlightthickness=1, highlightbackground=w.RAND2,
highlightcolor=w.NAVY)
sf.pack(side="right")
tk.Label(sf, text="Suchen", bg=w.WHITE, fg=w.SHADOW).pack(side="left", padx=(10, 2))
e = tk.Entry(sf, textvariable=self.v_suche, width=28, highlightthickness=0, bd=4)
e.pack(side="left")
tk.Label(sf, text="Strg F", bg=w.WHITE, fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(
side="left", padx=(2, 10))
e.bind("<FocusIn>", lambda _e: sf.configure(highlightbackground=w.NAVY), add="+")
e.bind("<FocusOut>", lambda _e: sf.configure(highlightbackground=w.RAND2), add="+")
else:
sf = tk.Frame(innen, 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._suche_geplant = None
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
self._suchfeld = e
self._suchrahmen = sf
def _menue(self, master):
self.menueleiste = mn.Menueleiste(master, self.root)
self.menueleiste.pack(side="left")
d = self.menueleiste.menue("Datei") d = self.menueleiste.menue("Datei")
d.add_command(label="Neue Abrechnung...", accelerator="Strg+N", command=self.neu) 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_command(label="Abrechnung öffnen...", accelerator="Strg+O", command=self.abrechnung_waehlen)
@@ -115,6 +165,7 @@ class App:
d.add_command(label="Dateien importieren...", accelerator="Strg+I", command=self.importieren) 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="Importierte Dateien...", command=self.dateien)
d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren) d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren)
d.add_command(label="Abrechnung erledigt / wieder öffnen...", command=self.erledigt_umschalten)
d.add_separator() d.add_separator()
d.add_command(label="Datensicherung erstellen...", command=self.sicherung) d.add_command(label="Datensicherung erstellen...", command=self.sicherung)
d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen) d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen)
@@ -137,107 +188,197 @@ class App:
h.add_separator() h.add_separator()
h.add_command(label="Eigene Kennungen (ausblenden)...", command=self.eigene_verwalten) h.add_command(label="Eigene Kennungen (ausblenden)...", command=self.eigene_verwalten)
h.add_command(label="Kennung der Auswahl als eigene ausblenden", command=self.eigene_ausblenden) h.add_command(label="Kennung der Auswahl als eigene ausblenden", command=self.eigene_ausblenden)
h.add_separator()
h.add_command(label="Provisionsarten (Airtime / Jahresbonus)...", command=self.provisionsarten)
h.add_command(label="Standard-Staffel Jahresbonus...", command=self.standard_staffel)
hi = self.menueleiste.menue("?", underline=None) self.hilfe_menue = "Hilfe" if w.MODERN else "?"
hi = self.menueleiste.menue(self.hilfe_menue, underline=None)
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
hi.add_command(label="Rundgang durch das Programm", command=self.rundgang) hi.add_command(label="Rundgang durch das Programm", command=self.rundgang)
hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages) hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages)
hi.add_separator() hi.add_separator()
hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent) hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent)
hi.add_separator() hi.add_separator()
hi.add_command(label=f"Info über {APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen()) hi.add_command(label=f"Info über {w.APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
def _toolbar(self): FILTER = (("alle", "Alle"), ("unzugeordnet", "Nicht zugeordnet"), ("manuell", "Von Hand geändert"),
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised") ("doppelt", "Doppelt"), ("abgerechnet", "Bereits abgerechnet"))
self.tb: dict[str, w.ToolbarButton] = {}
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
self.tb[text] = w.ToolbarButton(tb, text, self.icons(icon), cmd, tip)
self.tb[text].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._suche_geplant = None
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
self._suchfeld = e
self._suchrahmen = sf
def _body(self): def _body(self):
desk = tk.Frame(self.inhalt, bg=w.TEAL) m = w.MODERN
desk = tk.Frame(self.inhalt, bg=w.BG)
desk.pack(side="top", fill="both", expand=True) desk.pack(side="top", fill="both", expand=True)
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar # Trennlinie zwischen den Spalten, mit der Maus ziehbar. Beim Ziehen wandert nur eine Linie mit;
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised", # neu aufgebaut wird erst beim Loslassen (flüssig, auch mit vielen Positionen).
bd=0, opaqueresize=True, sashcursor="sb_h_double_arrow") pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.BG, sashwidth=6 if m else 7,
pw.pack(fill="both", expand=True, padx=6, pady=6) sashrelief="flat" if m else "raised",
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
pw.pack(fill="both", expand=True, padx=0 if m else 6, pady=0 if m else 6)
# --- Händler-Fenster # ---------------- Seitenleiste: Abrechnungen und Händler
self.f_h = w.Fenster(pw, "Händler", self.icons("haendler")) # Win95: zwei eigene Flächen mit blauer Titelleiste auf dem Desktop
r, self.htree = w.scroll_tree(self.f_h.inhalt, columns=("anzahl", "summe"), show="tree headings", links = tk.Frame(pw, bg=w.FACE if m else w.BG)
selectmode="browse") innen = tk.Frame(links, bg=w.FACE if m else w.BG)
self.htree.heading("#0", text="Händler", anchor="w") innen.pack(fill="both", expand=True, padx=12 if m else 0, pady=(16, 10) if m else 0)
self.htree.heading("anzahl", text="Pos.") seite = dict(style="Seite.Treeview") if m else {}
self.htree.heading("summe", text="Auszahlung") rand = {} if m else dict(padx=2, pady=(0, 2)) # Abstand der Liste zum Win95-Rahmen
self.htree.column("#0", width=150, minwidth=80, stretch=False)
self.htree.column("anzahl", width=38, anchor="e", stretch=False) self.f_a = tk.Frame(innen, bg=w.FACE) if m else w.Karte(innen)
self.htree.column("summe", width=84, anchor="e", stretch=False) self.f_a.pack(fill="x", pady=(0, 16 if m else 6))
zeile = self._abschnitt_zeile(self.f_a, "Abrechnungen")
self.b_neu = w.knopf(zeile, "+ Neu", self.neu, padx=8, pady=0, **self._link())
self.b_neu.pack(side="right", padx=0 if m else 1)
w.Tooltip(self.b_neu, "Neue Abrechnung anlegen (Strg+N)")
r, self.atree = w.scroll_tree(self.f_a, columns=("status",), show="tree", height=5, selectmode="browse",
auto=True, **seite)
self.atree.column("#0", width=150, minwidth=80, stretch=False)
self.atree.column("status", width=110, anchor="e", stretch=False)
w.auto_breite(self.atree, {"#0": 1})
self.atree.tag_configure("leise", foreground=w.SHADOW)
self._punkte = {f: w.punkt(self.root, getattr(w, f)) for f in ("BLAU", "GOLD", "GRUEN", "ROT", "SHADOW")}
r.pack(fill="both", expand=True, **rand)
self.atree.bind("<<TreeviewSelect>>", self._abrechnung_gewaehlt)
self.f_h = tk.Frame(innen, bg=w.FACE) if m else w.Karte(innen)
self.f_h.pack(fill="both", expand=True)
zeile = self._abschnitt_zeile(self.f_h, "Händler")
self.b_haendler = w.knopf(zeile, "Verwalten", self.haendler, padx=8, pady=0, **self._link())
self.b_haendler.pack(side="right", padx=0 if m else 1)
w.Tooltip(self.b_haendler, "Händler und Kennungen verwalten (Strg+H)")
r, self.htree = w.scroll_tree(self.f_h, columns=("anzahl", "summe"), show="tree", selectmode="browse",
auto=True, **seite)
self.htree.column("#0", width=140, minwidth=80, stretch=False)
self.htree.column("anzahl", width=40, anchor="e", stretch=False)
self.htree.column("summe", width=96, anchor="e", stretch=False)
w.auto_breite(self.htree, {"#0": 1}) w.auto_breite(self.htree, {"#0": 1})
self.htree.tag_configure("grau", foreground=w.SHADOW) self.htree.tag_configure("grau", foreground=w.SHADOW)
self.htree.tag_configure("fett", font=w.FONT_BOLD) self.htree.tag_configure("fett", font=w.FONT_BOLD)
r.pack(fill="both", expand=True) r.pack(fill="both", expand=True, **rand)
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt) self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick()) self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
pw.add(self.f_h, minsize=240, width=310) pw.add(links, minsize=240, width=320)
# ---------------- Inhalt: Kopf der Abrechnung, Kennzahlen, Positionen
rechts = tk.Frame(pw, bg=w.BG)
innen = tk.Frame(rechts, bg=w.BG)
innen.pack(fill="both", expand=True, padx=(16, 20) if m else 0, pady=(16, 0) if m else 0)
kopf = tk.Frame(innen, bg=w.BG)
kopf.pack(fill="x", pady=(0, 14 if m else 6))
texte = tk.Frame(kopf, bg=w.BG)
texte.pack(side="left", fill="x", expand=True)
self.l_status = w.abschnitt(texte, "", bg=w.BG)
self.l_status.pack(anchor="w")
self.l_titel = tk.Label(texte, text="", bg=w.BG, font=w.FONT_TITEL, anchor="w")
self.l_titel.pack(anchor="w")
self.l_info = tk.Label(texte, text="", bg=w.BG, fg=w.TEXT2, anchor="w")
self.l_info.pack(anchor="w")
if not m: # Win95: weiße Schrift auf dem Desktop, wie die Symbolbeschriftungen
for lbl in (self.l_status, self.l_titel, self.l_info):
lbl.configure(fg=w.WHITE)
self.b_export = w.knopf(kopf, "Exportieren", self.exportieren, primaer=True)
self.b_export.pack(side="right", anchor="s")
w.Tooltip(self.b_export, "Händler-Abrechnungen als Excel speichern (Strg+E)")
self.b_import = w.knopf(kopf, "Importieren", self.importieren)
self.b_import.pack(side="right", anchor="s", padx=8)
w.Tooltip(self.b_import, "CSV-/Excel-Dateien einlesen (Strg+I)")
self.b_erledigt = w.knopf(kopf, "Als erledigt markieren", self.erledigt_umschalten)
self.b_erledigt.pack(side="right", anchor="s")
self.tb = {"Neu": self.b_neu, "Import": self.b_import, "Export": self.b_export, "Händler": self.b_haendler}
kacheln = tk.Frame(innen, bg=w.BG)
kacheln.pack(fill="x", pady=(0, 14 if m else 6))
self.kpi: dict[str, tuple[tk.Label, tk.Label]] = {}
for i, (key, titel) in enumerate((("pos", "Positionen"), ("orig", "Provision"), ("ausz", "Auszahlung"),
("air", "Airtime"))):
k = w.Karte(kacheln)
k.grid(row=0, column=i, sticky="nsew", padx=(0 if i == 0 else 7, 0 if i == 3 else 7))
kacheln.columnconfigure(i, weight=1, uniform="kpi")
ki = tk.Frame(k, bg=w.FACE)
ki.pack(fill="both", expand=True, padx=16 if m else 8, pady=12 if m else 6)
w.abschnitt(ki, titel).pack(anchor="w")
farbe = {"ausz": w.NAVY, "air": w.GRUEN}.get(key, w.DARK)
wert = tk.Label(ki, text="", font=w.FONT_ZAHL, fg=farbe, anchor="w")
wert.pack(anchor="w", pady=(4, 0))
info = tk.Label(ki, text="", fg=w.TEXT2, anchor="w")
info.pack(anchor="w")
self.kpi[key] = (wert, info)
self.f_p = w.Karte(innen)
self.f_p.pack(fill="both", expand=True, pady=(0, 12 if m else 0))
if not m: # Win95: Titel in der blauen Leiste, Filter darunter wie eine Werkzeugleiste
_, self.l_ptitel = w.titelleiste(self.f_p, "Positionen")
kopfzeile = tk.Frame(self.f_p, bg=w.FACE)
kopfzeile.pack(fill="x", padx=14 if m else 4, pady=10 if m else 4)
if m:
self.l_ptitel = tk.Label(kopfzeile, text="Positionen", font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold"))
self.l_ptitel.pack(side="left", padx=(0, 12))
self.pills: dict[str, w.Pill] = {}
for key, text in self.FILTER:
self.pills[key] = w.Pill(kopfzeile, text, lambda k=key: self._filter_setzen(k), aktiv=key == self.filter)
self.pills[key].pack(side="left", padx=(0, 6))
self.l_auswahl = tk.Label(kopfzeile, text="", fg=w.TEXT2)
self.l_auswahl.pack(side="right")
if m:
w.trenner(self.f_p, vertikal=False).pack(fill="x")
# --- Positionen-Fenster
self.f_p = w.Fenster(pw, "Positionen", self.icons("neu"))
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz") 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") r2, self.ptree = w.scroll_tree(self.f_p, columns=cols, show="tree headings", selectmode="extended")
self.ptree.heading("#0", text="Provisionsart / Kunde", anchor="w") if m:
r2.configure(highlightthickness=0)
kopf_text = (lambda t: t.upper()) if m else (lambda t: t)
self.ptree.heading("#0", text=kopf_text("Provisionsart / Kunde"), anchor="w")
self.ptree.column("#0", width=200, minwidth=140, stretch=False) self.ptree.column("#0", width=200, minwidth=140, stretch=False)
for c, t, b, a in (("datum", "Datum", 76, "w"), ("rufnummer", "Rufnummer", 100, "w"), for c, t, b, a in (("datum", "Datum", 86, "w"), ("rufnummer", "Rufnummer", 112, "w"),
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 84, "w"), ("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 94, "w"),
("kennung", "Kennung", 70, "w"), ("kennung", "Kennung", 80, "w"),
("orig", "Provision", 78, "e"), ("proz", "%", 52, "e"), ("ausz", "Auszahlung", 84, "e")): ("orig", "Provision", 100, "e"), ("proz", "%", 64, "e"), ("ausz", "Auszahlung", 112, "e")):
self.ptree.heading(c, text=t, anchor=a) self.ptree.heading(c, text=kopf_text(t), anchor=a)
self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False) 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}) 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("gruppe", background=w.LIGHT, font=w.FONT_BOLD)
self.ptree.tag_configure("manuell", foreground="#0000c0") self.ptree.tag_configure("zebra", background=w.ZEBRA)
self.ptree.tag_configure("abw", foreground="#a00000") self.ptree.tag_configure("manuell", foreground=w.MANUELL)
self.ptree.tag_configure("negativ", foreground="#a00000") self.ptree.tag_configure("abw", foreground=w.ROT)
self.ptree.tag_configure("negativ", foreground=w.ROT)
self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW)
r2.pack(fill="both", expand=True) r2.pack(fill="both", expand=True)
self.ptree.bind("<Double-1>", self._doppelklick) self.ptree.bind("<Double-1>", self._doppelklick)
self.ptree.bind("<Button-3>", self._kontextmenue) self.ptree.bind("<Button-3>", self._kontextmenue)
self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl()) self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl())
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE) leiste = tk.Frame(self.f_p, bg=w.FACE)
leiste.pack(fill="x", pady=(4, 0)) leiste.pack(fill="x", padx=14 if m else 2, pady=10 if m else (4, 2))
self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, self.icons("prozent")) self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, None if m else self.icons("prozent"))
self.b_prozent.pack(side="left") self.b_prozent.pack(side="left")
w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4) w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten,
None if m else self.icons("rechner")).pack(side="left", padx=6)
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left") 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 zuklappen", lambda: self._klappen(False)).pack(side="right")
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4) w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=6)
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack( pw.add(rechts, minsize=560)
side="left", padx=8)
pw.add(self.f_p, minsize=500) @staticmethod
def _abschnitt_zeile(master, titel: str) -> tk.Frame:
"""Überschrift eines Abschnitts der Seitenleiste; Win95 als blaue Titelleiste. Knöpfe kommen rechts hinein."""
if not w.MODERN:
return w.titelleiste(master, titel)[0]
zeile = tk.Frame(master, bg=w.FACE)
zeile.pack(fill="x", pady=(0, 4))
w.abschnitt(zeile, titel).pack(side="left", padx=(8, 0))
return zeile
@staticmethod
def _link() -> dict:
"""Kleine Textknöpfe in der Seitenleiste (modern ohne Rahmen, in Akzentfarbe)."""
return dict(fg=w.NAVY, highlightthickness=0, font=w.FONT_BOLD) if w.MODERN else {}
def _filter_setzen(self, key: str):
self.filter = key
for k, pill in self.pills.items():
pill.setzen(k == key)
self._tabelle()
def _tasten(self): def _tasten(self):
r = self.root r = self.root
@@ -266,17 +407,54 @@ class App:
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()} self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
if self.aid and self.db.abrechnung(self.aid): if self.aid and self.db.abrechnung(self.aid):
self.positionen = self.db.positionen(self.aid) self.positionen = self.db.positionen(self.aid)
titel = self.db.abrechnung(self.aid)["titel"] titel = self.db.abrechnung(self.aid)["titel"] + (" – erledigt" if self.db.erledigt(self.aid) else "")
self.rahmen.set_titel(f"{APP_NAME} - [{titel}]") self.rahmen.set_titel(f"{w.APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}") self.status.set("info", f" Abrechnung: {titel}")
else: else:
self.aid = None self.aid = None
self.positionen = [] self.positionen = []
self.rahmen.set_titel(APP_NAME) self.rahmen.set_titel(w.APP_NAME)
self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import") self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import")
self._abrechnungsliste()
self._seitenkopf()
self._haendlerliste(behalte_auswahl) self._haendlerliste(behalte_auswahl)
self._tabelle() self._tabelle()
def _seitenkopf(self):
"""Titel, Status und Knöpfe über den Kennzahlen."""
a = self.db.abrechnung(self.aid) if self.aid else None
if not a:
self.l_status.configure(text="Keine Abrechnung geöffnet".upper() if w.MODERN else "Keine Abrechnung")
self.l_titel.configure(text="Willkommen")
self.l_info.configure(text="Links „+ Neu“ anlegen oder gleich Dateien importieren.")
self.b_erledigt.configure(text="Als erledigt markieren", state="disabled")
return
erledigt = self.db.erledigt(self.aid)
dateien = self.db.dateien(self.aid)
status = "erledigt" if erledigt else "offen"
self.l_status.configure(text=f"Abrechnung · {status}".upper() if w.MODERN else f"Abrechnung ({status})")
self.l_titel.configure(text=a["titel"])
self.l_info.configure(text=f"{len(dateien)} {'Datei' if len(dateien) == 1 else 'Dateien'} importiert · "
f"{sum(d['anzahl'] for d in dateien)} Zeilen" if dateien else "Noch nichts importiert")
self.b_erledigt.configure(text="Wieder öffnen" if erledigt else "Als erledigt markieren", state="normal")
def _abrechnungsliste(self):
t = self.atree
t.delete(*t.get_children())
for a in self.db.abrechnungen():
status, farbe = (("erledigt", "GRUEN") if a["erledigt"] else
(f"{a['abgerechnet']} abgerechnet", "GOLD") if a["abgerechnet"] else ("offen", "BLAU"))
t.insert("", "end", iid=str(a["id"]), text=" " + a["titel"], values=(status,), image=self._punkte[farbe])
if self.aid and t.exists(str(self.aid)):
t.selection_set(str(self.aid))
t.see(str(self.aid))
def _abrechnung_gewaehlt(self, _e=None):
sel = self.atree.selection()
if sel and int(sel[0]) != self.aid:
self.ansicht = ALLE
self.oeffnen(int(sel[0]))
def _suche_planen(self): def _suche_planen(self):
"""Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen).""" """Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen)."""
if self._suche_geplant is not None: if self._suche_geplant is not None:
@@ -356,6 +534,10 @@ class App:
pos = [p for p in pos if such in " ".join(str(p.get(k) or "") for k in ( 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", "rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
"imei")).lower()] "imei")).lower()]
pruef = {"unzugeordnet": lambda p: p["haendler_id"] == UNZUGEORDNET, "manuell": lambda p: p["manuell"],
"doppelt": lambda p: p["doppelt"], "abgerechnet": lambda p: p["abgerechnet"]}.get(self.filter)
if pruef:
pos = [p for p in pos if pruef(p)]
gruppen: dict[str, list[dict]] = defaultdict(list) gruppen: dict[str, list[dict]] = defaultdict(list)
for p in exp.sortiert(pos): for p in exp.sortiert(pos):
gruppen[self._gruppe_von(p)].append(p) gruppen[self._gruppe_von(p)].append(p)
@@ -366,12 +548,17 @@ class App:
so = sum(p["betrag_orig"] for p in plist) so = sum(p["betrag_orig"] for p in plist)
sa = sum(p["betrag"] for p in plist) sa = sum(p["betrag"] for p in plist)
proz = {p["prozent"] 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 "") if all(_ist_staffel(p) for p in plist):
pz = "Staffel"
else:
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)), 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",)) values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
for p in plist: for i, p in enumerate(plist):
tags = [] tags = ["zebra"] if i % 2 else []
if p["manuell"]: if p["abgerechnet"] or p["doppelt"]:
tags.append("abgerechnet")
elif p["manuell"]:
tags.append("manuell") tags.append("manuell")
elif p["betrag"] != p["betrag_orig"]: elif p["betrag"] != p["betrag_orig"]:
tags.append("abw") tags.append("abw")
@@ -380,22 +567,37 @@ class App:
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]), values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""), p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
fmt(p["betrag_orig"]), fmt(p["betrag_orig"]),
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])), "abgerechnet" if p["abgerechnet"] or p["doppelt"] else
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
fmt(p["betrag"])),
tags=tuple(tags)) tags=tuple(tags))
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet", name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get( EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
self.ansicht, self.haendler_namen.get(self.ansicht, "")) self.ansicht, self.haendler_namen.get(self.ansicht, ""))
self.f_p.set_titel(f"Positionen – {name}") self.l_ptitel.configure(text=f"Positionen – {name}")
so = sum(p["betrag_orig"] for p in pos) so = sum(p["betrag_orig"] for p in pos)
sa = sum(p["betrag"] for p in pos) sa = sum(p["betrag"] for p in pos)
air = [p for p in pos if p.get("typ") == regeln.AIRTIME]
self.status.set("pos", f" {len(pos)} Positionen") self.status.set("pos", f" {len(pos)} Positionen")
self.status.set("orig", f" Provision: {fmt(so)}") self.status.set("orig", f" Provision: {fmt(so)}")
self.status.set("ausz", f" Auszahlung: {fmt(sa)}") self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
self._kennzahl("pos", str(len(pos)), name)
self._kennzahl("orig", fmt(so), "laut Anbieter")
self._kennzahl("ausz", fmt(sa), f"{round(sa * 100 / so)} % im Schnitt" if so > 0 else "")
self._kennzahl("air", fmt(sum(p["betrag"] for p in air)),
(f"{len(air)} Posten · eigene Excel-Datei" if any(p["betrag"] for p in air) else f"{len(air)} Posten")
if air else "keine Airtime")
self.l_auswahl.configure(text="")
def _kennzahl(self, key: str, wert: str, info: str):
self.kpi[key][0].configure(text=wert)
self.kpi[key][1].configure(text=info)
def _status_auswahl(self): def _status_auswahl(self):
pids = self._ausgewaehlte_pids() pids = self._ausgewaehlte_pids()
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())): 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)] ps = [p for p in self.positionen if p["id"] in set(pids)]
self.l_auswahl.configure(text=f"{len(ps)} ausgewählt · {fmt(sum(p['betrag'] for p in ps))}")
self.status.set("pos", f" {len(ps)} ausgewählt") 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("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))}") self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
@@ -449,7 +651,7 @@ class App:
else: else:
self._aktualisieren() self._aktualisieren()
def importieren(self, dateien: list[str] | None = None): def importieren(self, dateien: list[str] | None = None, ziel: int | None = None):
if not dateien: if not dateien:
dateien = list(filedialog.askopenfilenames( dateien = list(filedialog.askopenfilenames(
parent=self.root, title="Provisionsabrechnungen importieren", parent=self.root, title="Provisionsabrechnungen importieren",
@@ -460,7 +662,22 @@ class App:
if not dateien: if not dateien:
return return
self.settings["import_ordner"] = str(Path(dateien[0]).parent) self.settings["import_ordner"] = str(Path(dateien[0]).parent)
if not self._brauche_abrechnung(): if ziel is None: # jedes Mal fragen, in welche Abrechnung
if not self.db.abrechnungen():
if not self.neu():
return
else:
res = dlg.AbrechnungenDialog(self.root, self.db, self.aid, fuer_import=True).zeigen()
if not res:
return
if res[0] == "neu":
if not self.neu():
return
else:
self.aid, self.ansicht = res[1], ALLE
else:
self.aid = ziel
if self._gesperrt():
return return
bericht = [] bericht = []
self.root.configure(cursor="watch") self.root.configure(cursor="watch")
@@ -484,8 +701,12 @@ class App:
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)")) bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
continue continue
n = self.db.importieren(self.aid, erg) n = self.db.importieren(self.aid, erg)
did = self.db.dateien(self.aid)[-1]["id"]
doppelt = sum(1 for p in self.db.positionen(self.aid) if p["datei_id"] == did and p["doppelt"])
hinweise = list(erg.warnungen) + ([f"{doppelt} Pos. bereits abgerechnet (werden nicht erneut "
"exportiert)"] if doppelt else [])
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen), bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
"; ".join(erg.warnungen) or "OK")) "; ".join(hinweise) or "OK"))
finally: finally:
self.root.configure(cursor="") self.root.configure(cursor="")
self._aktualisieren() self._aktualisieren()
@@ -519,6 +740,17 @@ class App:
self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet") self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet")
self._aktualisieren() self._aktualisieren()
def provisionsarten(self):
if dlg.ProvisionsartenDialog(self.root, self.db).zeigen():
self._nach_haendler_aenderung()
def standard_staffel(self):
neu = dlg.StaffelDialog(self.root, self.db.standard_staffel(), "Standard-Staffel Jahresbonus Neuaktivierung",
standard=list(regeln.STANDARD_STAFFEL)).zeigen()
if neu is not None:
self.db.standard_staffel_setzen(neu)
self._nach_haendler_aenderung()
def kennung_zuordnen(self, anbieter: str, kennung: str): def kennung_zuordnen(self, anbieter: str, kennung: str):
res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen() res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen()
if not res: if not res:
@@ -546,7 +778,45 @@ class App:
ps = [p for p in self.positionen if p["id"] in set(pids)] ps = [p for p in self.positionen if p["id"] in set(pids)]
return sel, pids, ps return sel, pids, ps
def _gesperrt(self) -> bool:
"""Erledigte Abrechnung: keine Änderungen (Hinweis statt stillem Nichtstun)."""
if self.aid and self.db.erledigt(self.aid):
w.info(self.root, "Abrechnung erledigt", "Diese Abrechnung ist als erledigt gekennzeichnet und kann "
"nicht mehr geändert werden.\n\nZum Ändern: Datei > Abrechnung erledigt / wieder öffnen.")
return True
return False
def erledigt_umschalten(self):
if not self.aid:
return
titel = self.db.abrechnung(self.aid)["titel"]
if self.db.erledigt(self.aid):
if not w.frage(self.root, "Wieder öffnen", f"Abrechnung „{titel}“ wieder öffnen?"):
return
auch = w.frage(self.root, "Wieder öffnen", "Sollen die Positionen auch wieder als NICHT abgerechnet "
"gelten (z. B. um sie neu zu exportieren)?", ja="Ja, freigeben", nein="Nein",
default_ja=False)
self.db.erledigt_setzen(self.aid, False, positionen_aufheben=auch)
else:
if not w.frage(self.root, "Abrechnung erledigt",
f"Abrechnung „{titel}“ als erledigt kennzeichnen?\n\nAlle Händler-Positionen gelten dann "
"als abgerechnet und werden auch in späteren Dateien nicht noch einmal abgerechnet. "
"Die Abrechnung kann danach nicht mehr geändert werden."):
return
self.db.erledigt_setzen(self.aid, True)
self._aktualisieren()
def abgerechnet_aufheben(self):
if self._gesperrt():
return
_, pids, _ = self._auswahl_info()
n = self.db.abrechnung_aufheben(pids)
self._aktualisieren()
self.status.set("info", f" {n} Position(en) wieder offen")
def prozent(self): def prozent(self):
if self._gesperrt():
return
sel, pids, ps = self._auswahl_info() sel, pids, ps = self._auswahl_info()
if not ps: if not ps:
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen " w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
@@ -580,17 +850,18 @@ class App:
self.ptree.see(vorhanden[0]) self.ptree.see(vorhanden[0])
def zuruecksetzen(self): def zuruecksetzen(self):
if self._gesperrt():
return
sel, pids, ps = self._auswahl_info() sel, pids, ps = self._auswahl_info()
if not ps: if not ps:
return return
for p in ps: self.db.regeln_wiederherstellen([p["id"] 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.db.touch(self.aid)
self._aktualisieren() self._aktualisieren()
self._auswahl_wiederherstellen(sel) self._auswahl_wiederherstellen(sel)
def neu_berechnen(self): def neu_berechnen(self):
if not self.aid: if not self.aid or self._gesperrt():
return return
manuell = sum(1 for p in self.positionen if p["manuell"]) manuell = sum(1 for p in self.positionen if p["manuell"])
auch = False auch = False
@@ -618,6 +889,8 @@ class App:
self.betrag_bearbeiten(iid) self.betrag_bearbeiten(iid)
def betrag_bearbeiten(self, iid: str | None = None): def betrag_bearbeiten(self, iid: str | None = None):
if self._gesperrt():
return
if iid is None: if iid is None:
sel = [i for i in self.ptree.selection() if i.startswith("p")] sel = [i for i in self.ptree.selection() if i.startswith("p")]
if len(sel) != 1: if len(sel) != 1:
@@ -632,6 +905,10 @@ class App:
bbox = self.ptree.bbox(iid, "ausz") bbox = self.ptree.bbox(iid, "ausz")
pid = int(iid[1:]) pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid) p = next(x for x in self.positionen if x["id"] == pid)
if p["abgerechnet"]:
w.info(self.root, "Betrag ändern", "Diese Position ist bereits abgerechnet.\n\n"
"Zum Ändern: Rechtsklick > Kennzeichnung „abgerechnet“ aufheben.")
return
if not bbox: if not bbox:
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld # Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld
self._betrag_dialog(p) self._betrag_dialog(p)
@@ -728,6 +1005,9 @@ class App:
if any(p["haendler_id"] == EIGEN for p in ps): if any(p["haendler_id"] == EIGEN for p in ps):
m.add_separator() m.add_separator()
m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden) m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden)
if any(p["abgerechnet"] for p in ps):
m.add_separator()
m.add_command(label="Kennzeichnung „abgerechnet“ aufheben", command=self.abgerechnet_aufheben)
m.tk_popup(e.x_root, e.y_root) m.tk_popup(e.x_root, e.y_root)
# ----- Eigene Kennungen (eigene Filiale) ausblenden # ----- Eigene Kennungen (eigene Filiale) ausblenden
@@ -760,6 +1040,8 @@ class App:
self._aktualisieren() self._aktualisieren()
def haendler_zuweisen(self, automatisch: bool = False): def haendler_zuweisen(self, automatisch: bool = False):
if self._gesperrt():
return
sel, pids, _ = self._auswahl_info() sel, pids, _ = self._auswahl_info()
if not pids: if not pids:
w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.") w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.")
@@ -775,6 +1057,8 @@ class App:
self._aktualisieren() self._aktualisieren()
def _null(self): def _null(self):
if self._gesperrt():
return
sel, pids, _ = self._auswahl_info() sel, pids, _ = self._auswahl_info()
self.db.prozent_anwenden(pids, 0.0) self.db.prozent_anwenden(pids, 0.0)
self.db.touch(self.aid) self.db.touch(self.aid)
@@ -787,14 +1071,19 @@ class App:
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n" w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
"Bitte zuerst Dateien importieren.") "Bitte zuerst Dateien importieren.")
return return
# nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet)
offen = [p for p in self.positionen if p["haendler_id"] > 0 and not p["abgerechnet"] and not p["doppelt"]]
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0]) stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen: for p in offen:
if p["haendler_id"] != UNZUGEORDNET: stat[p["haendler_id"]][0] += 1
stat[p["haendler_id"]][0] += 1 stat[p["haendler_id"]][1] += p["betrag"]
stat[p["haendler_id"]][1] += p["betrag"]
if not stat: if not stat:
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n" if any(p["haendler_id"] > 0 for p in self.positionen):
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.") w.info(self.root, "Export", "Alle Positionen dieser Abrechnung sind bereits abgerechnet.\n\n"
"Zum erneuten Export: Datei > Abrechnung erledigt / wieder öffnen (Positionen freigeben).")
else:
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 return
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in 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())] sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
@@ -804,19 +1093,27 @@ class App:
if not res: if not res:
return return
config.save(self.settings) config.save(self.settings)
erzeugt = [] erzeugt, exportiert = [], []
zeit = res["zeitraum"]
try: try:
for hid in res["ids"]: for hid in res["ids"]:
pos = [p for p in self.positionen if p["haendler_id"] == hid] pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))]
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"], erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"], pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
summe=res["summe"]) summe=res["summe"], null_weglassen=res["null_weglassen"])
exportiert += pos
except PermissionError as exc: except PermissionError as exc:
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n" 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?") "Ist sie vielleicht noch in Excel geöffnet?")
return return
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n" hinweis = ""
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "")) if res["kennzeichnen"] and exportiert:
n = self.db.abrechnen([p["id"] for p in exportiert])
hinweis = f"\n\n{n} Position(en) als abgerechnet gekennzeichnet."
self._aktualisieren()
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}"
+ (f"\nZeitraum: {zeit.text()}" if zeit else "") + "\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "") + hinweis)
if res["oeffnen"]: if res["oeffnen"]:
_oeffne_ordner(res["ordner"]) _oeffne_ordner(res["ordner"])
@@ -871,21 +1168,30 @@ class App:
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen() res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
if not res: if not res:
return return
if res["klassisch"] != bool(self.settings.get("klassischer_rahmen", True)):
self.settings["klassischer_rahmen"] = res["klassisch"]
config.save(self.settings)
w.info(self.root, "Einstellungen", "Die Änderung der Titelleiste wird beim nächsten Programmstart wirksam.")
if res["pfad"] and Path(res["pfad"]) != self.db.path: if res["pfad"] and Path(res["pfad"]) != self.db.path:
try: try:
self.db_wechseln(res["pfad"]) self.db_wechseln(res["pfad"])
except Exception as exc: except Exception as exc:
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}") w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
if res["design"] != w.DESIGN:
self.design_wechseln(res["design"])
def design_umschalten(self):
self.design_wechseln("modern" if w.DUNKEL else "dunkel")
def design_wechseln(self, design: str):
"""Fenster mit dem neuen Design neu aufbauen (main() startet es nach dem Schließen wieder)."""
self.settings["design"] = design
self.neustart = True
self.root.after_idle(self.beenden)
def hilfe(self): def hilfe(self):
w.info(self.root, "Kurzanleitung", w.info(self.root, "Kurzanleitung",
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n" "1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in %, Airtime in % (0 % = keine)\n"
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr. oder die Regel\n" " und die Kennungen – bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n"
" „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick > Händler zuweisen.\n\n" " oder das Häkchen „Nachnamen in GROSSBUCHSTABEN“. Jahresbonus Neuaktivierung:\n"
" Standard-Staffel oder eigene Staffel je Händler.\n"
" Einzelne Positionen: Rechtsklick > Händler zuweisen.\n\n"
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\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" "3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
" auch alle Wochenlisten eines Monats.\n\n" " auch alle Wochenlisten eines Monats.\n\n"
@@ -894,7 +1200,15 @@ class App:
" optional als Standard für diesen Händler merken.\n" " optional als Standard für diesen Händler merken.\n"
" - Doppelklick / F2 auf eine Position: Betrag frei ändern\n" " - Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
" (Enter = speichern, Pfeil runter/Tab = nächste Zeile, Esc = abbrechen).\n\n" " (Enter = speichern, Pfeil runter/Tab = nächste Zeile, Esc = abbrechen).\n\n"
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n" "5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat (freenet\n"
" und Vodafone zusammen, Airtime als eine Summenzeile) und eine Airtime-Liste.\n"
" Positionen mit 0,00 € werden weggelassen.\n\n"
"Zeitraum: Im Export-Dialog „Nur von … bis …“ (oder „Vormonat“). Exportierte Positionen\n"
"gelten als abgerechnet (grau) und werden nicht noch einmal abgerechnet – auch nicht, wenn\n"
"sie in einer späteren Datei wieder auftauchen. Datei > Abrechnung erledigt sperrt eine\n"
"fertige Abrechnung.\n\n"
"Welche Provisionsart Airtime bzw. Jahresbonus ist, wird automatisch erkannt –\n"
"umstellbar unter Händler > Provisionsarten.\n\n"
"Alles wird automatisch in der Datenbank gespeichert.") "Alles wird automatisch in der Datenbank gespeichert.")
def assistent(self): def assistent(self):
@@ -911,7 +1225,7 @@ class App:
self.ansicht = ALLE self.ansicht = ALLE
self._aktualisieren() self._aktualisieren()
if res["dateien"]: if res["dateien"]:
self.importieren(res["dateien"]) self.importieren(res["dateien"], ziel=self.aid)
if res["rundgang"]: if res["rundgang"]:
self.root.after(200, self.rundgang) self.root.after(200, self.rundgang)
@@ -928,7 +1242,7 @@ class App:
"(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."), "(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."),
(tb["Neu"], "Neue Abrechnung", (tb["Neu"], "Neue Abrechnung",
"Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. " "Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. "
"Mit „Öffnen“ holen Sie frühere Abrechnungen wieder hervor."), "Frühere Abrechnungen öffnen Sie mit einem Klick in der Liste darunter."),
(tb["Import"], "Dateien importieren", (tb["Import"], "Dateien importieren",
"Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. " "Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. "
"Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."), "Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."),
@@ -936,24 +1250,29 @@ class App:
"Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n" "Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n"
"„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. " "„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
"Das wird nicht exportiert."), "Das wird nicht exportiert."),
(self.pills["alle"], "Filter",
"Mit diesen Knöpfen zeigen Sie nur bestimmte Positionen: nicht zugeordnete, von Hand geänderte, "
"doppelte oder bereits abgerechnete."),
(self.f_p, "Positionen prüfen", (self.f_p, "Positionen prüfen",
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n" "Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
"- Doppelklick auf eine Position: Betrag frei ändern\n" "- Doppelklick auf eine Position: Betrag frei ändern\n"
"- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n" "- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n"
"- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"), "- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
(self.b_prozent, "Auszahlung in Prozent", (self.b_prozent, "Auszahlung in Prozent",
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. " "Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. "
"Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."), "Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
(tb["Export"], "Exportieren", (tb["Export"], "Exportieren",
"Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat."), "Zum Schluss erstellt „Exportieren“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat "
"(Airtime darin als eine Summenzeile) und – falls vorhanden – die Airtime-Liste."),
(self.status, "Summen", (self.status, "Summen",
"Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: " "Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: "
"Provision laut Anbieter und Ihre Auszahlung."), "Provision laut Anbieter und Ihre Auszahlung."),
(self._suchrahmen, "Suchen", (self._suchrahmen, "Suchen",
"Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."), "Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."),
(self.menueleiste.knoepfe["?"], "Das war's!", (self.menueleiste.knoepfe[self.hilfe_menue], "Das war's!",
"Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü „?“.\n\n" "Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü "
"Viel Erfolg mit dem Provisions-Manager 95!"), f"„{self.hilfe_menue}“.\n\n"
f"Viel Erfolg mit dem {w.APP_NAME}!"),
] ]
self._rundgang = ass.Rundgang(self, schritte) self._rundgang = ass.Rundgang(self, schritte)
@@ -965,7 +1284,7 @@ class App:
text = "".join(traceback.format_exception(exc, val, tb)) text = "".join(traceback.format_exception(exc, val, tb))
try: try:
with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f: with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f:
f.write(f"--- {date.today().isoformat()} {APP_NAME}\n{text}\n") f.write(f"--- {date.today().isoformat()} {w.APP_NAME}\n{text}\n")
except OSError: except OSError:
pass pass
# Beim/nach dem Beenden keine Meldung mehr zeigen – das Fenster gibt es dann nicht mehr # Beim/nach dem Beenden keine Meldung mehr zeigen – das Fenster gibt es dann nicht mehr
@@ -1041,6 +1360,11 @@ class App:
pass pass
def _ist_staffel(p: dict) -> bool:
"""Betrag kommt aus der Jahresbonus-Staffel (nicht aus einem Prozentsatz)."""
return p.get("typ") == regeln.JAHRESBONUS and p["prozent"] is None and not p["manuell"] and p["haendler_id"] > 0
def main(): def main():
if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest": if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest":
from provtool.selbsttest import ausfuehren from provtool.selbsttest import ausfuehren
@@ -1051,11 +1375,16 @@ def main():
ctypes.windll.shcore.SetProcessDpiAwareness(1) ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception: except Exception:
pass pass
root = tk.Tk(className="Provisionsmanager95") dateien = [a for a in sys.argv[1:] if Path(a).exists()]
app = App(root) while True:
if len(sys.argv) > 1: root = tk.Tk(className="Provisionsmanager95")
root.after(200, lambda: app.importieren([a for a in sys.argv[1:] if Path(a).exists()])) app = App(root)
root.mainloop() if dateien:
root.after(200, lambda: app.importieren(dateien))
dateien = []
root.mainloop()
if not app.neustart: # sonst: Design wurde umgeschaltet – Fenster neu aufbauen
break
if __name__ == "__main__": if __name__ == "__main__":
+15 -11
View File
@@ -7,7 +7,6 @@ from collections import Counter, defaultdict
from pathlib import Path from pathlib import Path
from tkinter import filedialog, ttk from tkinter import filedialog, ttk
from provtool import APP_NAME
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
from provtool.gui import win32 from provtool.gui import win32
from provtool.gui import win95 as w from provtool.gui import win95 as w
@@ -33,7 +32,7 @@ class Einrichtungsassistent(w.Dialog):
BREITE, HOEHE = 470, 340 BREITE, HOEHE = 470, 340
def __init__(self, app): def __init__(self, app):
super().__init__(app.root, "Provisions-Manager 95 – Einrichtung") super().__init__(app.root, f"{w.APP_NAME} – Einrichtung")
self.app = app self.app = app
self.dateien: list[str] = [] self.dateien: list[str] = []
self.vorschlaege: dict[str, list[str]] = {"freenet": [], "vodafone": []} self.vorschlaege: dict[str, list[str]] = {"freenet": [], "vodafone": []}
@@ -75,9 +74,9 @@ class Einrichtungsassistent(w.Dialog):
self._img = w.make_icon(self, "app", 7) self._img = w.make_icon(self, "app", 7)
c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="") c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="")
for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))): for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))):
c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill=w.WHITE, outline="") c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill="#ffffff", outline="")
c.create_image(77, self.HOEHE // 2 - 10, image=self._img) c.create_image(77, self.HOEHE // 2 - 10, image=self._img)
c.create_text(77, self.HOEHE - 38, text="Provisions-\nManager 95", fill=w.WHITE, font=w.FONT_BOLD, c.create_text(77, self.HOEHE - 38, text=w.APP_NAME.replace("-", "-\n", 1), fill="#ffffff", font=w.FONT_BOLD,
justify="center") justify="center")
def _leeren(self): def _leeren(self):
@@ -100,7 +99,7 @@ class Einrichtungsassistent(w.Dialog):
self.b_zurueck.configure(state="normal" if nr > 0 else "disabled") self.b_zurueck.configure(state="normal" if nr > 0 else "disabled")
self.b_weiter.configure(text="Fertig stellen" if nr == len(self.seiten) - 1 else "Weiter >") self.b_weiter.configure(text="Fertig stellen" if nr == len(self.seiten) - 1 else "Weiter >")
if self.titelleiste: if self.titelleiste:
self.titelleiste.set_titel(f"Provisions-Manager 95 – Einrichtung (Schritt {nr + 1} von {len(self.seiten)})") self.titelleiste.set_titel(f"{w.APP_NAME} – Einrichtung (Schritt {nr + 1} von {len(self.seiten)})")
def _weiter(self): def _weiter(self):
pruefer = getattr(self, f"_pruefe_{self.nr}", None) pruefer = getattr(self, f"_pruefe_{self.nr}", None)
@@ -128,7 +127,7 @@ class Einrichtungsassistent(w.Dialog):
# ---------------------------------------------------------------- Seite 1 # ---------------------------------------------------------------- Seite 1
def _s_willkommen(self): def _s_willkommen(self):
self._ueberschrift(f"Willkommen beim {APP_NAME}!") self._ueberschrift(f"Willkommen beim {w.APP_NAME}!")
self._text("Dieser Assistent richtet das Programm in wenigen Schritten ein:") self._text("Dieser Assistent richtet das Programm in wenigen Schritten ein:")
for punkt in ("Speicherort der Daten festlegen", for punkt in ("Speicherort der Daten festlegen",
"Unterhändler mit ihren Kennungen anlegen", "Unterhändler mit ihren Kennungen anlegen",
@@ -384,7 +383,7 @@ class Einrichtungsassistent(w.Dialog):
def _s_fertig(self): def _s_fertig(self):
self._ueberschrift("Fertig!") self._ueberschrift("Fertig!")
n = len(self.app.db.haendler_liste()) n = len(self.app.db.haendler_liste())
self._text(f"Der {APP_NAME} ist eingerichtet.") self._text(f"Der {w.APP_NAME} ist eingerichtet.")
self._text(f" - {n} Händler angelegt") self._text(f" - {n} Händler angelegt")
if self.v_abrechnung.get(): if self.v_abrechnung.get():
self._text(f" - Abrechnung „{self.v_titel.get().strip()}“" self._text(f" - Abrechnung „{self.v_titel.get().strip()}“"
@@ -537,6 +536,10 @@ class Rundgang:
# =========================================================================== Tipp des Tages # =========================================================================== Tipp des Tages
TIPPS = [ TIPPS = [
"Airtime wird pro Händler in Prozent ausgezahlt (0 % = keine Airtime). Im Export steht sie als eine "
"Summenzeile, die Einzelposten kommen in eine eigene Airtime-Liste.",
"Der Jahresbonus Neuaktivierung richtet sich nach der Anzahl Karten: Die Staffel stellen Sie unter "
"Händler > Standard-Staffel ein – oder im Händler-Dialog eine eigene für einzelne Händler.",
"Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats " "Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats "
"und die Vodafone-Datei in einem Rutsch.", "und die Vodafone-Datei in einem Rutsch.",
"Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent...“, um z. B. nur 80 % " "Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent...“, um z. B. nur 80 % "
@@ -578,13 +581,14 @@ class TippDesTages(w.Dialog):
haupt.pack(padx=12, pady=12) haupt.pack(padx=12, pady=12)
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken") box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
box.pack(side="left", fill="both") box.pack(side="left", fill="both")
self._bild = tipp_bild(self) # Büroklammer nur im Win95-Design; modern eine schlichte Glühbirne
self.bild_ok = self._bild is not None self._bild = None if w.MODERN else tipp_bild(self)
if self._bild is None: # Ersatz, falls das Bild fehlt self.bild_ok = self._bild is not None or w.MODERN
if self._bild is None:
self._bild = w.make_icon(self, "birne", 3) self._bild = w.make_icon(self, "birne", 3)
tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10) tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10)
# fester Textbereich: der Tipp bricht darin um und kann nicht mehr abgeschnitten werden # fester Textbereich: der Tipp bricht darin um und kann nicht mehr abgeschnitten werden
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10, width=380, height=self._bild.height() + 20) rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10, width=380, height=max(self._bild.height(), 170) + 20)
rechts.pack(side="left", fill="both") rechts.pack(side="left", fill="both")
rechts.pack_propagate(False) rechts.pack_propagate(False)
tk.Label(rechts, text="Wussten Sie schon...", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"), tk.Label(rechts, text="Wussten Sie schon...", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+372 -59
View File
@@ -4,16 +4,18 @@ from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import filedialog, ttk from tkinter import filedialog, ttk
from provtool import APP_NAME, APP_ORG, __version__ from provtool import APP_ORG, __version__
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler from provtool import regeln
from provtool import zeitraum as zr
from provtool.db import CAPS_ANBIETER, CAPS_WERT, AbrechnungErledigt, Datenbank, Haendler
from provtool.gui import win95 as w from provtool.gui import win95 as w
from provtool.importer import ANBIETER from provtool.importer import ANBIETER
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige # Auswahl im Händler-Dialog: Schlüssel -> Anzeige
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)", KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"}
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN" CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
from provtool.money import fmt, fmt_percent, parse_percent CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
# =========================================================================== Händlerverwaltung # =========================================================================== Händlerverwaltung
@@ -51,6 +53,7 @@ class HaendlerDialog(w.Dialog):
g.pack(fill="x") g.pack(fill="x")
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar() 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) self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
self.v_air = tk.StringVar(value="0")
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap), for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
("E-Mail:", self.v_mail))): ("E-Mail:", self.v_mail))):
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2) tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
@@ -61,14 +64,20 @@ class HaendlerDialog(w.Dialog):
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left") 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.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.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) tk.Label(g, text="Airtime:", anchor="w").grid(row=4, column=0, sticky="w", pady=2)
self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT) af = tk.Frame(g, bg=w.FACE)
self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2) af.grid(row=4, column=1, columnspan=2, sticky="w")
self.e_air = tk.Entry(af, textvariable=self.v_air, width=7, justify="right")
self.e_air.pack(side="left")
tk.Label(af, text="% der Airtime (0 % = keine Airtime)").pack(side="left", padx=4)
tk.Label(g, text="Notiz:", anchor="nw").grid(row=5, column=0, sticky="nw", pady=2)
self.t_notiz = tk.Text(g, width=36, height=2, font=w.FONT)
self.t_notiz.grid(row=5, column=1, columnspan=2, sticky="we", pady=2)
g.columnconfigure(1, weight=1) g.columnconfigure(1, weight=1)
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)") g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
g2.pack(fill="both", expand=True, pady=(8, 0)) g2.pack(fill="both", expand=True, pady=(8, 0))
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=4, r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=3,
selectmode="browse") selectmode="browse")
self.kliste.heading("anbieter", text="Anbieter") self.kliste.heading("anbieter", text="Anbieter")
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.") self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
@@ -89,10 +98,27 @@ class HaendlerDialog(w.Dialog):
ke.bind("<Return>", lambda e: self._kennung_dazu()) ke.bind("<Return>", lambda e: self._kennung_dazu())
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left") w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4) w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4)
self.v_caps = tk.BooleanVar(value=False)
self.cb_caps = tk.Checkbutton(g2, text=CAPS_HAKEN, variable=self.v_caps, anchor="w")
self.cb_caps.pack(fill="x", pady=(4, 0))
g4 = w.gruppe(rechts, "Jahresbonus Neuaktivierung (Betrag je Karte nach Staffel)")
g4.pack(fill="x", pady=(8, 0))
self.v_eigen_staffel = tk.BooleanVar(value=False)
sf = tk.Frame(g4, bg=w.FACE)
sf.pack(fill="x")
tk.Radiobutton(sf, text="Standard-Staffel", variable=self.v_eigen_staffel, value=False,
command=self._staffel_zeigen).pack(side="left")
tk.Radiobutton(sf, text="Eigene Staffel für diesen Händler", variable=self.v_eigen_staffel, value=True,
command=self._staffel_zeigen).pack(side="left", padx=(8, 0))
w.knopf(sf, "Bearbeiten...", self._staffel_bearbeiten).pack(side="right")
self.l_staffel = tk.Label(g4, text="", anchor="w", justify="left", fg=w.NAVY, wraplength=560)
self.l_staffel.pack(fill="x", pady=(2, 0))
self._eigene_staffel: regeln.Staffel | None = None
g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart") g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart")
g3.pack(fill="both", expand=True, pady=(8, 0)) 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") r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=3, selectmode="browse")
self.rliste.heading("art", text="Provisionsart") self.rliste.heading("art", text="Provisionsart")
self.rliste.heading("proz", text="Auszahlung") self.rliste.heading("proz", text="Auszahlung")
self.rliste.column("art", width=240) self.rliste.column("art", width=240)
@@ -112,9 +138,14 @@ class HaendlerDialog(w.Dialog):
self._liste_laden(auswahl_id) self._liste_laden(auswahl_id)
if neue_kennung: if neue_kennung:
self._neu() self._neu()
self._kennungen = [neue_kennung] if neue_kennung[0] == CAPS_ANBIETER:
self.v_caps.set(True)
else:
self._kennungen = [neue_kennung]
self._kennungen_zeigen() self._kennungen_zeigen()
self.minsize(640, 460) elif self.liste.selection():
self._auswahl() # sofort füllen – sonst wächst der Dialog erst nach der Größenberechnung
self.minsize(660, 560)
# ---- intern # ---- intern
@staticmethod @staticmethod
@@ -122,9 +153,42 @@ class HaendlerDialog(w.Dialog):
return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige) return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige)
def _art_gewaehlt(self): def _art_gewaehlt(self):
caps = self._anbieter_key(self.v_kanb.get()) == CAPS_ANBIETER self._ke.focus_set()
self.v_kwert.set("")
self._ke.configure(state="disabled" if caps else "normal") def _caps_einstellen(self, hid: int, an: bool):
"""GROSSBUCHSTABEN-Häkchen: gesperrt, wenn die Regel schon einem anderen Händler gehört."""
self.v_caps.set(an)
besitzer = self.db.kennung_map().get((CAPS_ANBIETER, CAPS_WERT))
if besitzer and besitzer != hid:
h = self.db.haendler(besitzer)
self.cb_caps.configure(state="disabled", text=f"GROSSBUCHSTABEN-Regel: gehört zu „{h.name if h else '?'}“")
else:
self.cb_caps.configure(state="normal", text=CAPS_HAKEN)
def _staffel_zeigen(self):
eigen = self.v_eigen_staffel.get()
if eigen and self._eigene_staffel is None:
self._eigene_staffel = list(self.db.standard_staffel())
st = self._eigene_staffel if eigen else self.db.standard_staffel()
self.l_staffel.configure(text=("Eigene Staffel: " if eigen else "Standard: ")
+ regeln.staffel_beschreibung(st, kurz=True))
def _staffel_bearbeiten(self):
if not self.v_eigen_staffel.get():
if w.frage(self, "Jahresbonus-Staffel",
"Die Standard-Staffel gilt für alle Händler ohne eigene Staffel.\n\n"
"Soll dieser Händler eine eigene Staffel bekommen?\n"
"(Die Standard-Staffel ändern Sie unter Händler > Standard-Staffel Jahresbonus...)",
ja="Eigene Staffel", nein="Abbrechen"):
self.v_eigen_staffel.set(True)
else:
return
neu = StaffelDialog(self, self._eigene_staffel or self.db.standard_staffel(),
f"Jahresbonus-Staffel – {self.v_name.get().strip() or 'neuer Händler'}",
standard=self.db.standard_staffel()).zeigen()
if neu is not None:
self._eigene_staffel = neu
self._staffel_zeigen()
def _liste_laden(self, auswahl_id=None): def _liste_laden(self, auswahl_id=None):
self.liste.delete(*self.liste.get_children()) self.liste.delete(*self.liste.get_children())
@@ -150,20 +214,23 @@ class HaendlerDialog(w.Dialog):
self.v_ap.set(h.ansprechpartner) self.v_ap.set(h.ansprechpartner)
self.v_mail.set(h.email) self.v_mail.set(h.email)
self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", "")) self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", ""))
self.v_air.set(fmt_percent(h.airtime_prozent).replace(" %", ""))
self.v_aktiv.set(h.aktiv) self.v_aktiv.set(h.aktiv)
self.t_notiz.delete("1.0", "end") self.t_notiz.delete("1.0", "end")
self.t_notiz.insert("1.0", h.notiz or "") self.t_notiz.insert("1.0", h.notiz or "")
self._kennungen = self.db.kennungen(h.id) kenn = self.db.kennungen(h.id)
self._kennungen = [k for k in kenn if k[0] != CAPS_ANBIETER]
self._caps_einstellen(h.id, (CAPS_ANBIETER, CAPS_WERT) in kenn)
self._eigene_staffel = list(h.staffel) if h.staffel is not None else None
self.v_eigen_staffel.set(h.staffel is not None)
self._staffel_zeigen()
self._kennungen_zeigen() self._kennungen_zeigen()
self._regeln_zeigen() self._regeln_zeigen()
def _kennungen_zeigen(self): def _kennungen_zeigen(self):
self.kliste.delete(*self.kliste.get_children()) self.kliste.delete(*self.kliste.get_children())
for i, (a, v) in enumerate(self._kennungen): for i, (a, v) in enumerate(self._kennungen):
if a == CAPS_ANBIETER: self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
self.kliste.insert("", "end", iid=str(i), values=("Vodafone", CAPS_TEXT))
else:
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
def _regeln_zeigen(self): def _regeln_zeigen(self):
self.rliste.delete(*self.rliste.get_children()) self.rliste.delete(*self.rliste.get_children())
@@ -177,15 +244,20 @@ class HaendlerDialog(w.Dialog):
for v in (self.v_name, self.v_ap, self.v_mail): for v in (self.v_name, self.v_ap, self.v_mail):
v.set("") v.set("")
self.v_proz.set("100") self.v_proz.set("100")
self.v_air.set("0")
self.v_aktiv.set(True) self.v_aktiv.set(True)
self.t_notiz.delete("1.0", "end") self.t_notiz.delete("1.0", "end")
self._kennungen = [] self._kennungen = []
self._caps_einstellen(0, False)
self._eigene_staffel = None
self.v_eigen_staffel.set(False)
self._staffel_zeigen()
self._kennungen_zeigen() self._kennungen_zeigen()
self._regeln_zeigen() self._regeln_zeigen()
def _kennung_dazu(self): def _kennung_dazu(self):
anb = self._anbieter_key(self.v_kanb.get()) anb = self._anbieter_key(self.v_kanb.get())
wert = CAPS_WERT if anb == CAPS_ANBIETER else self.v_kwert.get().strip() wert = self.v_kwert.get().strip()
if not wert: if not wert:
return return
if (anb, wert) not in self._kennungen: if (anb, wert) not in self._kennungen:
@@ -216,12 +288,21 @@ class HaendlerDialog(w.Dialog):
except ValueError: except ValueError:
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).") w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
return return
try:
air = parse_percent(self.v_air.get() or "0")
except ValueError:
w.warnung(self, "Händler", "Die Airtime-Auszahlung muss eine Zahl sein (z. B. 0, 50 oder 100).")
return
if self.v_kwert.get().strip(): if self.v_kwert.get().strip():
self._kennung_dazu() self._kennung_dazu()
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(), 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()) self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air,
self._eigene_staffel if self.v_eigen_staffel.get() else None)
kennungen = list(self._kennungen)
if self.v_caps.get() and str(self.cb_caps.cget("state")) != "disabled":
kennungen.append((CAPS_ANBIETER, CAPS_WERT))
try: try:
hid = self.db.haendler_speichern(h, self._kennungen) hid = self.db.haendler_speichern(h, kennungen)
except Exception as exc: # sqlite3.IntegrityError / ValueError except Exception as exc: # sqlite3.IntegrityError / ValueError
msg = str(exc) msg = str(exc)
if "UNIQUE" in msg and "haendler.name" in msg: if "UNIQUE" in msg and "haendler.name" in msg:
@@ -309,16 +390,184 @@ class ProzentDialog(w.Dialog):
return super().zeigen(self._e) return super().zeigen(self._e)
# =========================================================================== Jahresbonus-Staffel
class StaffelDialog(w.Dialog):
"""Staffel bearbeiten: je Stufe „ab N Karten“ und Betrag je Karte (oder „voll“)."""
def __init__(self, master, staffel: regeln.Staffel, titel: str = "Jahresbonus-Staffel",
standard: regeln.Staffel | None = None):
super().__init__(master, titel)
self.standard = standard
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=12, pady=(10, 4))
tk.Label(f, justify="left", anchor="w", wraplength=380, text=(
"Jahresbonus Neuaktivierung: Der Händler bekommt je Karte den Betrag der Stufe, die zu seiner "
"Anzahl Karten in der Abrechnung passt. „voll“ = ganzer Betrag laut Abrechnung. Nie mehr als "
"die Provision laut Abrechnung; Stornos zählen ab.")).pack(fill="x")
kopf = tk.Frame(f, bg=w.FACE)
kopf.pack(fill="x", pady=(8, 2))
tk.Label(kopf, text="ab Karten", width=9, anchor="w").pack(side="left")
tk.Label(kopf, text="Betrag je Karte (oder „voll“)", anchor="w").pack(side="left", padx=(8, 0))
self.tabelle = tk.Frame(f, bg=w.FACE)
self.tabelle.pack(fill="x")
self.zeilen: list[tuple[tk.Frame, tk.StringVar, tk.StringVar]] = []
for ab, cent in staffel:
self._zeile(ab, cent)
kk = tk.Frame(f, bg=w.FACE)
kk.pack(fill="x", pady=(6, 0))
w.knopf(kk, "Stufe hinzufügen", self._dazu).pack(side="left")
w.knopf(kk, "Letzte entfernen", self._weg).pack(side="left", padx=4)
if standard is not None:
w.knopf(kk, "Wie Standard", lambda: self._setzen(standard)).pack(side="right")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(6, 10))
def _zeile(self, ab: int | str = "", cent: int | None | str = ""):
z = tk.Frame(self.tabelle, bg=w.FACE)
z.pack(fill="x", pady=1)
v_ab = tk.StringVar(value=str(ab))
v_bt = tk.StringVar(value="voll" if cent is None else (fmt(cent, symbol=False) if isinstance(cent, int)
else str(cent)))
tk.Entry(z, textvariable=v_ab, width=8, justify="right").pack(side="left")
tk.Entry(z, textvariable=v_bt, width=10, justify="right").pack(side="left", padx=(12, 0))
tk.Label(z, text="€ je Karte").pack(side="left", padx=4)
self.zeilen.append((z, v_ab, v_bt))
def _dazu(self):
letzte = self.zeilen[-1][1].get() if self.zeilen else "0"
try:
ab = int(letzte) + 5
except ValueError:
ab = ""
self._zeile(ab, "")
def _weg(self):
if len(self.zeilen) > 1:
self.zeilen.pop()[0].destroy()
def _setzen(self, staffel):
for z, _, _ in self.zeilen:
z.destroy()
self.zeilen = []
for ab, cent in staffel:
self._zeile(ab, cent)
def lesen(self) -> regeln.Staffel:
out = []
for i, (_, v_ab, v_bt) in enumerate(self.zeilen, start=1):
try:
ab = int(v_ab.get().strip())
except ValueError:
raise ValueError(f"Stufe {i}: „ab Karten“ muss eine ganze Zahl sein.") from None
text = v_bt.get().strip()
if text.lower() == "voll":
cent = None
else:
try:
cent = parse_cent(text)
except ValueError:
raise ValueError(f"Stufe {i}: Bitte einen Betrag (z. B. 5 oder 7,50) oder „voll“ eingeben.") \
from None
out.append((ab, cent))
return regeln.staffel_pruefen(out)
def _ok(self):
try:
self.result = self.lesen()
except ValueError as exc:
w.warnung(self, "Staffel", str(exc))
return
self.destroy()
# =========================================================================== Provisionsarten
TYP_AUTO = "Automatisch"
class ProvisionsartenDialog(w.Dialog):
"""Welche Provisionsart ist Airtime bzw. Jahresbonus? Erkannt wird automatisch, umstellbar je Art."""
def __init__(self, master, db: Datenbank):
super().__init__(master, "Provisionsarten", resizable=True)
self.db = db
self.geaendert = False
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=10, pady=10)
tk.Label(f, anchor="w", justify="left", text=(
"Airtime wird pro Händler mit dem Airtime-Prozentsatz ausgezahlt und im Export als eigene Liste\n"
"geführt. Der Jahresbonus Neuaktivierung wird nach der Staffel berechnet.")).pack(fill="x")
r, self.tree = w.scroll_tree(f, columns=("art", "anzahl", "typ"), show="headings", height=12,
selectmode="browse")
for c, t, b, a in (("art", "Provisionsart", 300, "w"), ("anzahl", "Pos.", 50, "e"),
("typ", "Behandlung", 230, "w")):
self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor=a)
r.pack(fill="both", expand=True, pady=(6, 4))
self.tree.bind("<<TreeviewSelect>>", lambda e: self._auswahl())
u = tk.Frame(f, bg=w.FACE)
u.pack(fill="x")
tk.Label(u, text="Behandlung:").pack(side="left")
self.v_typ = tk.StringVar(value=TYP_AUTO)
self.cb = ttk.Combobox(u, textvariable=self.v_typ, state="readonly", width=36,
values=[TYP_AUTO] + list(regeln.TYPEN.values()))
self.cb.pack(side="left", padx=4)
w.knopf(u, "Übernehmen", self._setzen).pack(side="left")
self.knopfleiste([("Schließen", self.abbrechen)]).pack(pady=(0, 10))
self._laden()
self.minsize(620, 380)
def _laden(self, auswahl: str | None = None):
self.tree.delete(*self.tree.get_children())
fest = self.db.art_typen()
self._arten = {}
for i, (art, erkannt, n) in enumerate(self.db.provisionsarten()):
typ = fest.get(art)
text = regeln.TYPEN[typ] if typ else f"{TYP_AUTO}: {regeln.TYPEN[erkannt]}"
iid = str(i)
self._arten[iid] = art
self.tree.insert("", "end", iid=iid, values=(art or "(ohne)", n, text))
if art == auswahl:
self.tree.selection_set(iid)
self.tree.see(iid)
def _auswahl(self):
sel = self.tree.selection()
if not sel:
return
typ = self.db.art_typen().get(self._arten[sel[0]])
self.v_typ.set(regeln.TYPEN[typ] if typ else TYP_AUTO)
def _setzen(self):
sel = self.tree.selection()
if not sel:
w.info(self, "Provisionsarten", "Bitte zuerst eine Provisionsart in der Liste auswählen.")
return
art = self._arten[sel[0]]
wahl = self.v_typ.get()
typ = next((k for k, v in regeln.TYPEN.items() if v == wahl), None)
self.db.art_typ_setzen(art, typ)
self.geaendert = True
self._laden(art)
def abbrechen(self):
self.result = self.geaendert
self.destroy()
# =========================================================================== Abrechnungen # =========================================================================== Abrechnungen
class AbrechnungenDialog(w.Dialog): class AbrechnungenDialog(w.Dialog):
def __init__(self, master, db: Datenbank, aktuelle_id: int | None): def __init__(self, master, db: Datenbank, aktuelle_id: int | None, fuer_import: bool = False):
super().__init__(master, "Abrechnung öffnen", resizable=True) """fuer_import: Ziel eines Imports wählen (Ergebnis ("oeffnen", id) oder ("neu",))."""
super().__init__(master, "Import – in welche Abrechnung?" if fuer_import else "Abrechnung öffnen",
resizable=True)
self.db = db self.db = db
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2)) tk.Label(self, text="In welche Abrechnung sollen die Dateien importiert werden?" if fuer_import
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings", else "Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
height=12, selectmode="browse") r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120), show="headings", height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)): ("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
self.tree.heading(c, text=t) self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w") self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
@@ -326,9 +575,13 @@ class AbrechnungenDialog(w.Dialog):
self.tree.bind("<Double-1>", lambda e: self._oeffnen()) self.tree.bind("<Double-1>", lambda e: self._oeffnen())
k = tk.Frame(self, bg=w.FACE) k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10) k.pack(fill="x", padx=10, pady=10)
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left") if fuer_import:
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6) w.knopf(k, "Hierhin importieren", self._oeffnen, default="active").pack(side="left")
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left") w.knopf(k, "Neue Abrechnung...", self._neu).pack(side="left", padx=6)
else:
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") w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
self.bind("<Return>", lambda e: self._oeffnen()) self.bind("<Return>", lambda e: self._oeffnen())
self._laden(aktuelle_id) self._laden(aktuelle_id)
@@ -336,8 +589,9 @@ class AbrechnungenDialog(w.Dialog):
def _laden(self, sel=None): def _laden(self, sel=None):
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
for a in self.db.abrechnungen(): for a in self.db.abrechnungen():
status = "erledigt" if a["erledigt"] else (f"{a['abgerechnet']} abgerechnet" if a["abgerechnet"] else "offen")
self.tree.insert("", "end", iid=str(a["id"]), self.tree.insert("", "end", iid=str(a["id"]),
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or "")) values=(a["titel"], status, a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
kids = self.tree.get_children() kids = self.tree.get_children()
if sel and self.tree.exists(str(sel)): if sel and self.tree.exists(str(sel)):
self.tree.selection_set(str(sel)) self.tree.selection_set(str(sel))
@@ -353,6 +607,10 @@ class AbrechnungenDialog(w.Dialog):
self.result = ("oeffnen", self._sel()) self.result = ("oeffnen", self._sel())
self.destroy() self.destroy()
def _neu(self):
self.result = ("neu",)
self.destroy()
def _umbenennen(self): def _umbenennen(self):
aid = self._sel() aid = self._sel()
if not aid: if not aid:
@@ -410,7 +668,11 @@ class DateienDialog(w.Dialog):
name = self.tree.item(s[0], "values")[0] name = self.tree.item(s[0], "values")[0]
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n" 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): "Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
self.db.datei_entfernen(int(s[0])) try:
self.db.datei_entfernen(int(s[0]))
except AbrechnungErledigt as exc:
w.info(self, "Datei entfernen", str(exc))
return
self.geaendert = True self.geaendert = True
self._laden() self._laden()
@@ -606,13 +868,18 @@ class ExportDialog(w.Dialog):
"""haendler: (id, name, anzahl, summe_cent)""" """haendler: (id, name, anzahl, summe_cent)"""
super().__init__(master, "Abrechnungen exportieren") super().__init__(master, "Abrechnungen exportieren")
self.settings = settings self.settings = settings
f = tk.Frame(self, bg=w.FACE) rumpf = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=10, pady=10) rumpf.pack(fill="both", expand=True, padx=10, pady=10)
# zwei Spalten (links Händler, rechts Zeitraum und Optionen) – passt auch auf kleine Bildschirme
f = tk.Frame(rumpf, bg=w.FACE)
f.pack(side="left", fill="both", expand=True)
rechts = tk.Frame(rumpf, bg=w.FACE)
rechts.pack(side="left", fill="y", padx=(14, 0))
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x") 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, r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=12,
selectmode="extended") selectmode="extended")
self.tree.heading("#0", text="") self.tree.heading("#0", text="")
self.tree.column("#0", width=30, stretch=False) self.tree.column("#0", width=40, stretch=False)
for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")): 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.heading(c, text=t)
self.tree.column(c, width=b, anchor=a) self.tree.column(c, width=b, anchor=a)
@@ -629,7 +896,7 @@ class ExportDialog(w.Dialog):
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left") w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4) w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
g = w.gruppe(f, "Optionen") g = w.gruppe(rechts, "Optionen")
g.pack(fill="x", pady=(8, 0)) g.pack(fill="x", pady=(8, 0))
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w") tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
self.v_titel = tk.StringVar(value=titel) self.v_titel = tk.StringVar(value=titel)
@@ -638,21 +905,54 @@ class ExportDialog(w.Dialog):
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "") 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) 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)) 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)) # Neuer Schlüssel: freenet und Vodafone stehen jetzt standardmäßig in EINER Datei
self.v_anb = tk.BooleanVar(value=settings.get("export_anbieter_getrennt", False))
self.v_null = tk.BooleanVar(value=settings.get("export_null_weglassen", True))
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False)) self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True)) self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
self.v_auf = tk.BooleanVar(value=True) self.v_auf = tk.BooleanVar(value=True)
tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei", for zeile, (text, var) in enumerate((
variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w") ("freenet und Vodafone in getrennte Dateien (sonst beide in einer)", self.v_anb),
tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3, ("Positionen mit 0,00 € weglassen", self.v_null),
sticky="w") ("Kopfzeile mit Spaltennamen", self.v_kopf),
tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0, ("Summenzeile unter der Tabelle", self.v_sum),
columnspan=3, sticky="w") ("Ordner nach dem Export öffnen", self.v_auf)), start=2):
tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0, tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w")
columnspan=3, sticky="w") tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine "
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w",
wraplength=420).grid(
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
g.columnconfigure(1, weight=1) g.columnconfigure(1, weight=1)
z = w.gruppe(rechts, "Zeitraum und Abrechnung")
z.pack(fill="x", before=g)
zf = tk.Frame(z, bg=w.FACE)
zf.pack(fill="x")
tk.Label(zf, text="Nur von:").pack(side="left")
self.v_von, self.v_bis = tk.StringVar(), tk.StringVar()
tk.Entry(zf, textvariable=self.v_von, width=11).pack(side="left", padx=4)
tk.Label(zf, text="bis:").pack(side="left")
tk.Entry(zf, textvariable=self.v_bis, width=11).pack(side="left", padx=4)
w.knopf(zf, "Vormonat", self._vormonat).pack(side="left", padx=(4, 0))
# ohne Zeitraum = alles, was noch nicht abgerechnet ist (abgerechnete Positionen exportiert er nie)
w.knopf(zf, "Noch nicht abgerechnet", lambda: (self.v_von.set(""), self.v_bis.set(""))).pack(
side="left", padx=(4, 0))
self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)])
ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly",
width=40).pack(anchor="w", pady=(4, 0))
tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)",
fg=w.SHADOW, anchor="w", justify="left", wraplength=420).pack(fill="x")
self.v_kennz = tk.BooleanVar(value=True)
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
"einmal abgerechnet)", variable=self.v_kennz, anchor="w", justify="left",
wraplength=400).pack(fill="x", pady=(4, 0))
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _vormonat(self):
von, bis = zr.vormonat()
self.v_von.set(von.strftime("%d.%m.%Y"))
self.v_bis.set(bis.strftime("%d.%m.%Y"))
def _klick(self, e): def _klick(self, e):
if self.tree.identify_column(e.x) == "#0": if self.tree.identify_column(e.x) == "#0":
iid = self.tree.identify_row(e.y) iid = self.tree.identify_row(e.y)
@@ -679,15 +979,26 @@ class ExportDialog(w.Dialog):
if not ids: if not ids:
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.") w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
return return
try:
von, bis = zr.datum_lesen(self.v_von.get()), zr.datum_lesen(self.v_bis.get())
except ValueError as exc:
w.warnung(self, "Zeitraum", str(exc))
return
basis = next(k for k, v in zr.BASEN.items() if v == self.v_basis.get())
if not self.v_ordner.get().strip(): if not self.v_ordner.get().strip():
self._ordner() self._ordner()
if not self.v_ordner.get().strip(): if not self.v_ordner.get().strip():
return return
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(), self.settings.update(export_ordner=self.v_ordner.get().strip(), export_anbieter_getrennt=self.v_anb.get(),
export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get()) export_null_weglassen=self.v_null.get(), export_kopfzeile=self.v_kopf.get(),
export_summe=self.v_sum.get())
self.settings.pop("export_pro_anbieter", None)
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(), 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(), pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.get(),
oeffnen=self.v_auf.get()) kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get(),
zeitraum=zr.Zeitraum(von, bis, basis) if (von or bis) else None,
kennzeichnen=self.v_kennz.get())
self.settings["export_basis"] = basis
self.destroy() self.destroy()
@@ -709,10 +1020,12 @@ class EinstellungenDialog(w.Dialog):
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w") justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
g2 = w.gruppe(self, "Darstellung") g2 = w.gruppe(self, "Darstellung")
g2.pack(fill="x", padx=10, pady=(0, 10)) g2.pack(fill="x", padx=10, pady=(0, 10))
self.v_klassisch = tk.BooleanVar(value=bool(settings.get("klassischer_rahmen", True))) self.v_design = tk.StringVar(value=settings.get("design", "modern"))
tk.Checkbutton(g2, text="Windows-95-Titelleiste verwenden (sonst die normale von Windows)", tk.Radiobutton(g2, text="Modern hell", variable=self.v_design, value="modern").pack(anchor="w")
variable=self.v_klassisch).pack(anchor="w") tk.Radiobutton(g2, text="Modern dunkel", variable=self.v_design, value="dunkel").pack(anchor="w")
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w") tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack(
anchor="w")
tk.Label(g2, text="Das Fenster wird nach OK mit dem neuen Design neu aufgebaut.", fg=w.SHADOW).pack(anchor="w")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _waehlen(self): def _waehlen(self):
@@ -723,7 +1036,7 @@ class EinstellungenDialog(w.Dialog):
self.v.set(p) self.v.set(p)
def _ok(self): def _ok(self):
self.result = dict(pfad=self.v.get().strip(), klassisch=self.v_klassisch.get()) self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get())
self.destroy() self.destroy()
@@ -731,7 +1044,7 @@ class EinstellungenDialog(w.Dialog):
class InfoDialog(w.Dialog): class InfoDialog(w.Dialog):
def __init__(self, master): def __init__(self, master):
super().__init__(master, f"Info über {APP_NAME}") super().__init__(master, f"Info über {w.APP_NAME}")
top = tk.Frame(self, bg=w.FACE) top = tk.Frame(self, bg=w.FACE)
top.pack(padx=14, pady=14) top.pack(padx=14, pady=14)
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0) c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
@@ -740,7 +1053,7 @@ class InfoDialog(w.Dialog):
c.create_image(57, 57, image=self._img) c.create_image(57, 57, image=self._img)
rechts = tk.Frame(top, bg=w.FACE) rechts = tk.Frame(top, bg=w.FACE)
rechts.pack(side="left", padx=(14, 0), anchor="n") 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=w.APP_NAME, font=w.FONT_BIG).pack(anchor="w")
tk.Label(rechts, text=f"Version {__version__}", anchor="w").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) 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" tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
+16 -9
View File
@@ -51,25 +51,28 @@ class Popup:
self.leiste = leiste self.leiste = leiste
root = self.master._root() root = self.master._root()
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised") f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
if w.MODERN:
f.configure(bg=w.WHITE, bd=0, relief="flat", highlightthickness=1, highlightbackground=w.RAND)
f.withdraw() f.withdraw()
f.overrideredirect(True) f.overrideredirect(True)
self.zeilen = [] self.zeilen = []
grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2)
for e in self.eintraege: for e in self.eintraege:
if e["art"] == "trenner": if e["art"] == "trenner":
t = tk.Frame(f, bg=w.FACE, height=8) t = tk.Frame(f, bg=grund, height=8)
t.pack(fill="x") t.pack(fill="x")
linie = w.trenner(t, vertikal=False) linie = w.trenner(t, vertikal=False)
linie.place(relx=0, rely=0.5, relwidth=1, y=-1) linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
continue continue
zeile = tk.Frame(f, bg=w.FACE) zeile = tk.Frame(f, bg=grund)
zeile.pack(fill="x") zeile.pack(fill="x")
grau = e["state"] == "disabled" grau = e["state"] == "disabled"
text = tk.Label(zeile, text=e["label"], bg=w.FACE, anchor="w", padx=18, pady=2, text = tk.Label(zeile, text=e["label"], bg=grund, anchor="w", padx=18, pady=py,
fg=w.SHADOW if grau else w.DARK) fg=w.SHADOW if grau else w.DARK)
text.pack(side="left", fill="x", expand=True) text.pack(side="left", fill="x", expand=True)
labels = [text] labels = [text]
if e["accelerator"]: if e["accelerator"]:
acc = tk.Label(zeile, text=e["accelerator"], bg=w.FACE, anchor="e", padx=12, pady=2, acc = tk.Label(zeile, text=e["accelerator"], bg=grund, anchor="e", padx=12, pady=py,
fg=w.SHADOW if grau else w.DARK) fg=w.SHADOW if grau else w.DARK)
acc.pack(side="right") acc.pack(side="right")
labels.append(acc) labels.append(acc)
@@ -170,8 +173,8 @@ class Popup:
self.aktiv = i self.aktiv = i
for j, (e, zeile, labels) in enumerate(self.zeilen): for j, (e, zeile, labels) in enumerate(self.zeilen):
an = j == i and e.get("state") != "disabled" an = j == i and e.get("state") != "disabled"
bg = w.NAVY if an else w.FACE bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE)
fg = w.WHITE if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK) fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
zeile.configure(bg=bg) zeile.configure(bg=bg)
for lbl in labels: for lbl in labels:
lbl.configure(bg=bg, fg=fg) lbl.configure(bg=bg, fg=fg)
@@ -223,7 +226,8 @@ class Menueleiste(tk.Frame):
self.offener_knopf: tk.Label | None = None self.offener_knopf: tk.Label | None = None
def menue(self, label: str, underline: int | None = 0) -> Popup: def menue(self, label: str, underline: int | None = 0) -> Popup:
knopf = tk.Label(self, text=label, bg=w.FACE, padx=7, pady=2, bd=1, relief="flat", knopf = tk.Label(self, text=label, bg=w.FACE, padx=10 if w.MODERN else 7, pady=6 if w.MODERN else 2,
bd=1, relief="flat",
underline=-1 if underline is None else underline) underline=-1 if underline is None else underline)
knopf.pack(side="left") knopf.pack(side="left")
popup = Popup(self) popup = Popup(self)
@@ -248,7 +252,7 @@ class Menueleiste(tk.Frame):
def _darstellen(self): def _darstellen(self):
for k in self.knoepfe.values(): for k in self.knoepfe.values():
if k is self.offener_knopf: if k is self.offener_knopf:
k.configure(bg=w.NAVY, fg=w.WHITE, relief="flat") k.configure(bg=w.SEL_BG, fg=w.SEL_FG, relief="flat")
else: else:
k.configure(bg=w.FACE, fg=w.DARK, relief="flat") k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
@@ -289,7 +293,10 @@ class Menueleiste(tk.Frame):
def _hover(self, knopf, drin: bool): def _hover(self, knopf, drin: bool):
if self.offener_knopf is None: if self.offener_knopf is None:
knopf.configure(relief="raised" if drin else "flat") if w.MODERN:
knopf.configure(bg=w.LIGHT if drin else w.FACE)
else:
knopf.configure(relief="raised" if drin else "flat")
def _alt(self, knopf): def _alt(self, knopf):
self.oeffnen(knopf) self.oeffnen(knopf)
+285
View File
@@ -0,0 +1,285 @@
"""Windows: echtes Programmfenster mit selbst gezeichnetem Win95-Rahmen.
Das Hauptfenster ist für Tk rahmenlos („overrideredirect“). Für Windows war es damit ein Popup ohne
Größenrahmen – deshalb gingen Andocken (Fenster an den Rand ziehen), Win+Hoch/Runter/Links/Rechts und
das native Ziehen der Fenstergröße nicht.
Jetzt bekommt das äußere Windows-Fenster die Stile eines normalen Programmfensters (Titelleiste,
Größenrahmen, Min/Max, Taskleiste), deren Rahmen aber *unsichtbar* bleibt: Bei WM_NCCALCSIZE melden
wir „der Innenbereich ist das ganze Fenster“. Für Tk bleibt es rahmenlos – Tk rechnet bei
Größenänderungen also weiter ohne Rahmenaufschlag (sonst würde das Fenster bei jeder internen
Größenanpassung um die Rahmenbreite wachsen). Windows erledigt alles Weitere selbst:
- Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren)
- Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab
- Größe ändern: Den Größen-Mauszeiger am Rand zeigt Tk (rahmen.py) – so weiß Tk immer, welcher Zeiger
gilt, und setzt ihn beim Zurückfahren in das Fenster wieder richtig. Beim Drücken auf den Rand zieht
Windows die Größe (flüssig).
WICHTIG:
- Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre ->
Verklemmung), nur Win32-Funktionen.
- Verschieben/Größe ziehen NIE synchron (SendMessage) aus einem Tk-Ereignis heraus starten: Windows
läuft dann in einer eigenen Schleife, in der Tk Python-Ereignisse (z. B. <Configure>) ausführt,
während Python das nicht erwartet -> harter Absturz („PyEval_RestoreThread … thread state is
NULL“). Deshalb PostMessage: Die Schleife startet, sobald Tk wieder in seiner Ereignisschleife ist.
"""
from __future__ import annotations
import ctypes
import sys
import tkinter as tk
from ctypes import wintypes
IS_WIN = sys.platform.startswith("win")
WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086
WM_NCLBUTTONDOWN, WM_SYSCOMMAND = 0x00A1, 0x0112
HTCLIENT, HTCAPTION = 1, 2
HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17}
GWL_STYLE, GWL_EXSTYLE = -16, -20
WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000
WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000
WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000
SWP_NOSIZE, SWP_NOMOVE, SWP_NOZORDER, SWP_NOACTIVATE, SWP_FRAMECHANGED = 0x1, 0x2, 0x4, 0x10, 0x20
SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020
LRESULT = ctypes.c_ssize_t
SUBCLASSPROC = ctypes.WINFUNCTYPE(LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM,
ctypes.c_size_t, ctypes.c_size_t) if IS_WIN else None
class NCCALCSIZE_PARAMS(ctypes.Structure):
_fields_ = [("rgrc", wintypes.RECT * 3), ("lppos", ctypes.c_void_p)]
class MONITORINFO(ctypes.Structure):
_fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), ("rcWork", wintypes.RECT),
("dwFlags", wintypes.DWORD)]
_dll = {}
def _api():
if _dll:
return _dll["u"], _dll["c"]
u = ctypes.WinDLL("user32", use_last_error=True)
c = ctypes.WinDLL("comctl32", use_last_error=True)
H, U, W, L = wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM
for name, args, res in (
("GetAncestor", [H, U], H), ("GetWindowRect", [H, ctypes.POINTER(wintypes.RECT)], wintypes.BOOL),
("GetClientRect", [H, ctypes.POINTER(wintypes.RECT)], wintypes.BOOL),
("IsZoomed", [H], wintypes.BOOL), ("IsIconic", [H], wintypes.BOOL),
("GetWindowLongW", [H, ctypes.c_int], ctypes.c_long),
("SetWindowLongW", [H, ctypes.c_int, ctypes.c_long], ctypes.c_long),
("SetWindowPos", [H, H, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, U], wintypes.BOOL),
("MonitorFromRect", [ctypes.POINTER(wintypes.RECT), wintypes.DWORD], wintypes.HMONITOR),
("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL),
("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT),
("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL),
("WindowFromPoint", [wintypes.POINT], H)):
f = getattr(u, name)
f.argtypes, f.restype = args, res
c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t]
c.SetWindowSubclass.restype = wintypes.BOOL
c.RemoveWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t]
c.RemoveWindowSubclass.restype = wintypes.BOOL
c.DefSubclassProc.argtypes = [H, U, W, L]
c.DefSubclassProc.restype = LRESULT
_dll.update(u=u, c=c)
return u, c
def _rect(h) -> wintypes.RECT:
r = wintypes.RECT()
_api()[0].GetWindowRect(h, ctypes.byref(r))
return r
def _xy(lparam: int) -> tuple[int, int]:
"""Bildschirmkoordinaten aus lParam (vorzeichenbehaftet, mehrere Monitore)."""
x, y = lparam & 0xFFFF, (lparam >> 16) & 0xFFFF
return (x - 0x10000 if x & 0x8000 else x), (y - 0x10000 if y & 0x8000 else y)
def arbeitsbereich_von(rect: wintypes.RECT) -> wintypes.RECT | None:
u, _ = _api()
mon = u.MonitorFromRect(ctypes.byref(rect), 2) # MONITOR_DEFAULTTONEAREST
mi = MONITORINFO()
mi.cbSize = ctypes.sizeof(MONITORINFO)
return mi.rcWork if mon and u.GetMonitorInfoW(mon, ctypes.byref(mi)) else None
class NativerRahmen:
"""Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen."""
def __init__(self, root: tk.Tk, rand: int, ecke: int):
self.root = root
self.rand, self.ecke = rand, ecke
self.hwnd = None
self.aktiv = False
self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg
# ------------------------------------------------------------------ Einrichten
def einrichten(self) -> bool:
"""Sobald Tk das äußere Windows-Fenster angelegt hat (möglichst vor dem ersten Anzeigen)."""
if self.aktiv:
return True
try:
u, c = _api()
self.root.update_idletasks()
h = self.aeusseres_fenster()
if not h:
return False
self.hwnd = h
stil = u.GetWindowLongW(h, GWL_STYLE)
u.SetWindowLongW(h, GWL_STYLE, stil | WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX
| WS_MAXIMIZEBOX)
ex = u.GetWindowLongW(h, GWL_EXSTYLE)
u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW)
if not self._unterklasse(h, self._aussen_proc):
return False
self.aktiv = True
# Rahmen neu berechnen lassen (jetzt: unsichtbar)
u.SetWindowPos(h, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
| SWP_FRAMECHANGED)
self._ecken_nicht_runden(h)
return True
except (OSError, AttributeError, tk.TclError):
return False
def aeusseres_fenster(self) -> int | None:
"""Das äußere Windows-Fenster („wm frame“) – None, solange Tk es noch nicht angelegt hat."""
try:
h = int(self.root.wm_frame(), 16)
except (tk.TclError, ValueError):
return None
return h if h and h != self.root.winfo_id() else None
def veraltet(self) -> bool:
"""Hat Tk das äußere Fenster neu angelegt (z. B. nach „wm resizable“)? Dann neu einrichten."""
return self.aktiv and self.aeusseres_fenster() not in (None, self.hwnd)
def neu_einrichten(self) -> bool:
self.aktiv = False
return self.einrichten()
def _unterklasse(self, h: int, fn) -> bool:
_, c = _api()
proc = SUBCLASSPROC(fn)
if not c.SetWindowSubclass(h, proc, 1, 0):
return False
self._procs[h] = proc
return True
@staticmethod
def _ecken_nicht_runden(h):
"""Windows 11 rundet Fensterecken – passt nicht zum Win95-Look."""
try:
wert = ctypes.c_int(1) # DWMWCP_DONOTROUND
ctypes.windll.dwmapi.DwmSetWindowAttribute(wintypes.HWND(h), 33, ctypes.byref(wert), 4)
except (OSError, AttributeError):
pass
# ------------------------------------------------------------------ Fensterprozeduren (KEIN Tk!)
def randbereich(self, x: int, y: int) -> str:
"""Welcher Rand liegt am Bildschirmpunkt? '' = keiner (oder maximiert)."""
u, _ = _api()
h = self.hwnd
if not h or u.IsZoomed(h) or u.IsIconic(h):
return ""
r = _rect(h)
if not (r.left <= x < r.right and r.top <= y < r.bottom):
return ""
d, k = self.rand, self.ecke
ns = "n" if y < r.top + d else "s" if y >= r.bottom - d else ""
we = "w" if x < r.left + d else "e" if x >= r.right - d else ""
if ns and not we:
we = "w" if x < r.left + k else "e" if x >= r.right - k else ""
elif we and not ns:
ns = "n" if y < r.top + k else "s" if y >= r.bottom - k else ""
return ns + we
def _aussen_proc(self, hwnd, msg, wparam, lparam, _id, _ref):
_, c = _api()
try:
if msg == WM_NCCALCSIZE:
if wparam:
# Innenbereich = ganzes Fenster. Maximiert legt Windows das Fenster um die (jetzt
# unsichtbare) Rahmenbreite über den Bildschirmrand -> auf den Arbeitsbereich begrenzen.
if _api()[0].IsZoomed(hwnd):
p = ctypes.cast(lparam, ctypes.POINTER(NCCALCSIZE_PARAMS)).contents
arbeit = arbeitsbereich_von(p.rgrc[0])
if arbeit is not None:
p.rgrc[0] = arbeit
return 0
if msg == WM_NCACTIVATE:
# Nicht den (unsichtbaren) Windows-Rahmen neu zeichnen lassen (klassisches Design)
return c.DefSubclassProc(hwnd, msg, wparam, -1)
if msg == WM_NCHITTEST:
rand = self.randbereich(*_xy(lparam))
if rand:
return HT[rand]
return HTCLIENT
if msg == WM_NCDESTROY:
self._entfernen(hwnd)
except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen
pass
return c.DefSubclassProc(hwnd, msg, wparam, lparam)
def _entfernen(self, hwnd):
proc = self._procs.pop(hwnd, None)
if proc is not None:
try:
_api()[1].RemoveWindowSubclass(hwnd, proc, 1)
except OSError:
pass
# Callback-Objekt bleibt bis Programmende referenziert (Windows könnte ihn noch einmal rufen)
_BEHALTEN.append(proc)
# ------------------------------------------------------------------ Bedienung
def verschieben(self) -> None:
"""Titelleiste gedrückt: Windows zieht das Fenster (mit Andocken an den Bildschirmrand).
PostMessage, nicht SendMessage – siehe Modulbeschreibung."""
u, _ = _api()
u.ReleaseCapture()
u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
def groesse_ziehen(self, rand: str) -> None:
u, _ = _api()
u.ReleaseCapture()
u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0)
def maximiert(self) -> bool:
return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd))
def befehl(self, sc: int) -> None:
"""SC_MAXIMIZE / SC_RESTORE / SC_MINIMIZE – wie über das Systemmenü (mit Windows-Animation)."""
_api()[0].SendMessageW(self.hwnd, WM_SYSCOMMAND, sc, 0)
def aussenmass(self) -> tuple[int, int, int, int]:
r = _rect(self.hwnd)
return r.left, r.top, r.right - r.left, r.bottom - r.top
def setzen(self, x: int, y: int, breite: int, hoehe: int) -> None:
"""Position und Größe des Fensters genau so setzen (ohne Tk-Rahmenaufschlag)."""
_api()[0].SetWindowPos(self.hwnd, None, x, y, breite, hoehe, SWP_NOZORDER | SWP_NOACTIVATE)
def innen_gleich_aussen(self) -> bool:
"""Für Tests: Ist der Windows-Rahmen wirklich unsichtbar?"""
u, _ = _api()
a, i = _rect(self.hwnd), wintypes.RECT()
u.GetClientRect(self.hwnd, ctypes.byref(i))
return (i.right - i.left, i.bottom - i.top) == (a.right - a.left, a.bottom - a.top)
def stil(self) -> tuple[int, int]:
u, _ = _api()
return u.GetWindowLongW(self.hwnd, GWL_STYLE), u.GetWindowLongW(self.hwnd, GWL_EXSTYLE)
def trefferpruefung(self, hwnd: int, x: int, y: int) -> int:
"""Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?"""
lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
return _api()[0].SendMessageW(hwnd, WM_NCHITTEST, 0, lp)
_BEHALTEN: list = []
+102 -5
View File
@@ -3,8 +3,11 @@
Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet
(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben, (overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben,
Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts, Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts,
graue Menüleiste. Unter Windows sorgen ein paar Win32-Aufrufe dafür, dass das Fenster graue Menüleiste.
trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann.
Unter Windows ist das Hauptfenster trotzdem ein echtes Programmfenster (siehe nativ.py): Windows
übernimmt Verschieben, Größe ändern, Andocken am Bildschirmrand, Win+Pfeiltasten, Taskleiste und
Alt+Tab. Auf anderen Systemen (Entwicklung/Tests) macht Tk das selbst.
""" """
from __future__ import annotations from __future__ import annotations
@@ -210,6 +213,7 @@ class Titelleiste(tk.Frame):
NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen
NATIVER_RAHMEN = True # Windows: echtes Fenster mit unsichtbarem Rahmen (nativ.py)
# --------------------------------------------------------------------------- Hauptfenster # --------------------------------------------------------------------------- Hauptfenster
@@ -227,6 +231,7 @@ class Hauptrahmen:
self.maximiert = False self.maximiert = False
self._normal_geo: str | None = None self._normal_geo: str | None = None
self._schliessen = schliessen self._schliessen = schliessen
self.nativ = None
root.minsize(minbreite, minhoehe) root.minsize(minbreite, minhoehe)
if not klassisch: if not klassisch:
self.aussen = tk.Frame(root, bg=w.FACE) self.aussen = tk.Frame(root, bg=w.FACE)
@@ -243,7 +248,8 @@ class Hauptrahmen:
schliessen=schliessen, minimieren=self.minimieren, schliessen=schliessen, minimieren=self.minimieren,
maximieren=self.umschalten) maximieren=self.umschalten)
self.titelleiste.pack(fill="x") self.titelleiste.pack(fill="x")
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert # Maximiert darf man nur nativ ziehen (Windows stellt das Fenster dabei wieder her)
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert or self.nativ_an
self.inhalt = tk.Frame(self.aussen, bg=w.FACE) self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
self.inhalt.pack(fill="both", expand=True) self.inhalt.pack(fill="both", expand=True)
# Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar # Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar
@@ -260,7 +266,65 @@ class Hauptrahmen:
self.aussen.bind("<ButtonPress-1>", self._resize_start) self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize) self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget)) root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
root.after(50, lambda: taskleiste_anzeigen(root))
if IS_WIN and NATIVER_RAHMEN:
from provtool.gui.nativ import NativerRahmen
self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
root.bind("<Configure>", self._fenster_geaendert, add="+")
root.bind("<Map>", lambda e: e.widget is root and self._nativ_einrichten(), add="+")
self._nativ_einrichten() # möglichst schon vor dem ersten Anzeigen
root.after(50, self._fenster_einrichten)
# ---- natives Windows-Fenster
@property
def nativ_an(self) -> bool:
return self.nativ is not None and self.nativ.aktiv
def _nativ_einrichten(self) -> bool:
if self.nativ is None:
return False
if self.nativ.aktiv and not self.nativ.veraltet():
return True
try:
sichtbar = bool(self.root.winfo_viewable())
except tk.TclError:
sichtbar = True
if self.nativ.veraltet(): # Tk hat das äußere Fenster neu angelegt
ok = self.nativ.neu_einrichten()
else:
ok = self.nativ.einrichten()
if ok:
# Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen
self._taskleiste_neu = sichtbar
return ok
_taskleiste_neu = True
def _fenster_einrichten(self):
self._nativ_einrichten()
if self.nativ_an and not self._taskleiste_neu:
return # vor dem ersten Anzeigen eingerichtet: Windows kennt den Taskleisten-Stil schon
taskleiste_anzeigen(self.root)
def _fenster_geaendert(self, e):
"""Zustand nachführen – maximieren/wiederherstellen geht jetzt auch über Windows selbst
(Win+Hoch, an den oberen Rand ziehen, Doppelklick, Taskleiste)."""
if e.widget is not self.root or self.nativ is None:
return
if self.nativ.veraltet():
self._nativ_einrichten()
if not self.nativ_an:
return
try:
maximiert = self.nativ.maximiert()
if maximiert != self.maximiert:
self.maximiert = maximiert
self.titelleiste.max_symbol(maximiert)
self._cursor_zurueck()
if not maximiert and self.root.wm_state() == "normal":
self._normal_geo = self.root.geometry()
except Exception:
pass
def alt_f4(self, widget) -> str: def alt_f4(self, widget) -> str:
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist.""" """Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
@@ -323,10 +387,16 @@ class Hauptrahmen:
return None return None
self._griff_bild = griff_bild(master) self._griff_bild = griff_bild(master)
g = tk.Label(master, image=self._griff_bild, bg=w.FACE, cursor="sizing" if not IS_WIN else "size_nw_se") g = tk.Label(master, image=self._griff_bild, bg=w.FACE, cursor="sizing" if not IS_WIN else "size_nw_se")
g.bind("<ButtonPress-1>", lambda e: self._resize_start(e, "se")) g.bind("<ButtonPress-1>", self._griff_start)
g.bind("<B1-Motion>", self._resize) g.bind("<B1-Motion>", self._resize)
return g return g
def _griff_start(self, e):
if self.nativ_an and not self.maximiert:
self.nativ.groesse_ziehen("se") # Windows zieht die Größe (flüssig)
return "break"
self._resize_start(e, "se")
def geometrie_setzen(self, geo: str | None, maximiert: bool = False): def geometrie_setzen(self, geo: str | None, maximiert: bool = False):
self.root.update_idletasks() self.root.update_idletasks()
if not geo or "+" not in geo: if not geo or "+" not in geo:
@@ -340,8 +410,15 @@ class Hauptrahmen:
breite, hoehe = min(breite, bw), min(hoehe, bh) breite, hoehe = min(breite, bw), min(hoehe, bh)
geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}" geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}"
self.root.geometry(geo) self.root.geometry(geo)
self._normal_geo = geo
self._nativ_einrichten()
if maximiert and self.klassisch: if maximiert and self.klassisch:
self.root.after(100, self.maximieren) self.root.after(100, self.maximieren)
elif maximiert: # normaler Windows-Rahmen: Windows maximiert selbst
try:
self.root.state("zoomed")
except tk.TclError:
self.root.attributes("-zoomed", True) # Linux/X11
def normale_geometrie(self) -> str: def normale_geometrie(self) -> str:
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry() return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
@@ -371,6 +448,13 @@ class Hauptrahmen:
def maximieren(self): def maximieren(self):
if self.maximiert: if self.maximiert:
return return
if self.nativ_an:
from provtool.gui.nativ import SC_MAXIMIZE
self._normal_geo = self.root.geometry()
self.nativ.befehl(SC_MAXIMIZE)
self.maximiert = self.nativ.maximiert()
self.titelleiste.max_symbol(self.maximiert)
return
self._normal_geo = self.root.geometry() self._normal_geo = self.root.geometry()
x, y, bw, bh = arbeitsbereich(self.root) x, y, bw, bh = arbeitsbereich(self.root)
self.root.geometry(f"{bw}x{bh}+{x}+{y}") self.root.geometry(f"{bw}x{bh}+{x}+{y}")
@@ -380,6 +464,12 @@ class Hauptrahmen:
def wiederherstellen(self): def wiederherstellen(self):
if not self.maximiert: if not self.maximiert:
return return
if self.nativ_an:
from provtool.gui.nativ import SC_RESTORE
self.nativ.befehl(SC_RESTORE)
self.maximiert = self.nativ.maximiert()
self.titelleiste.max_symbol(self.maximiert)
return
self.maximiert = False self.maximiert = False
if self._normal_geo: if self._normal_geo:
self.root.geometry(self._normal_geo) self.root.geometry(self._normal_geo)
@@ -418,6 +508,7 @@ class Hauptrahmen:
def _rand_cursor(self, e): def _rand_cursor(self, e):
if self.maximiert: if self.maximiert:
self._cursor_zurueck()
return return
rand = self._welcher_rand(e.x, e.y) rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "") zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
@@ -429,6 +520,12 @@ class Hauptrahmen:
def _resize_start(self, e, rand: str | None = None): def _resize_start(self, e, rand: str | None = None):
self._rand = rand or self._welcher_rand(e.x, e.y) self._rand = rand or self._welcher_rand(e.x, e.y)
if self._rand and self.nativ_an and not self.maximiert:
# Den Zeiger zeigt Tk (weiß dann immer, welcher gilt); ziehen lässt Windows (flüssig,
# mit Andocken). Angestoßen per PostMessage – siehe nativ.py.
self.nativ.groesse_ziehen(self._rand)
self._rand = ""
return "break"
self._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(), self._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(),
self.root.winfo_width(), self.root.winfo_height()) self.root.winfo_width(), self.root.winfo_height())
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+18 -1
View File
@@ -58,6 +58,8 @@ def user32():
u.ReleaseCapture.restype = wintypes.BOOL u.ReleaseCapture.restype = wintypes.BOOL
u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
u.SendMessageW.restype = wintypes.LPARAM u.SendMessageW.restype = wintypes.LPARAM
u.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
u.PostMessageW.restype = wintypes.BOOL
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
u.ShowWindow.restype = wintypes.BOOL u.ShowWindow.restype = wintypes.BOOL
u.IsIconic.argtypes = [wintypes.HWND] u.IsIconic.argtypes = [wintypes.HWND]
@@ -78,6 +80,19 @@ def hwnd(fenster: tk.Misc) -> int | None:
return None return None
def dunkle_titelleiste(fenster: tk.Misc) -> None:
"""Windows-Titelleiste dunkel (Design „dunkel“, ab Windows 10 20H1)."""
h = hwnd(fenster)
if not h:
return
try:
import ctypes
an = ctypes.c_int(1)
ctypes.windll.dwmapi.DwmSetWindowAttribute(h, 20, ctypes.byref(an), ctypes.sizeof(an)) # IMMERSIVE_DARK_MODE
except (OSError, AttributeError):
pass
def stapel(*fenster: tk.Misc) -> list[int]: def stapel(*fenster: tk.Misc) -> list[int]:
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes).""" """Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
ziele = [hwnd(f) for f in fenster] ziele = [hwnd(f) for f in fenster]
@@ -127,7 +142,9 @@ def nativ_verschieben(fenster: tk.Misc) -> bool:
if not h: if not h:
return False return False
user32().ReleaseCapture() user32().ReleaseCapture()
user32().SendMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION # PostMessage statt SendMessage: Die Verschiebe-Schleife von Windows darf nicht innerhalb eines
# Tk-Ereignisses laufen (Tk führt darin Python-Ereignisse aus -> harter Absturz, siehe nativ.py).
user32().PostMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION
return True return True
+336 -13
View File
@@ -1,8 +1,13 @@
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.""" """Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.
Zwei Designs: „modern“ (hell und flach, wie Windows 11 – Standard) und „win95“ (klassisch, Legacy).
design_setzen() muss VOR dem Aufbau der Oberfläche laufen; alle Module lesen die Farben als w.FACE usw.
"""
from __future__ import annotations from __future__ import annotations
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path
from tkinter import ttk from tkinter import ttk
FACE = "#c0c0c0" FACE = "#c0c0c0"
@@ -14,6 +19,38 @@ NAVY = "#000080"
TEAL = "#008080" TEAL = "#008080"
WHITE = "#ffffff" WHITE = "#ffffff"
INFO_BG = "#ffffe1" INFO_BG = "#ffffe1"
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
RAND = SHADOW # dünne Rahmenlinien (nur modern)
DESIGN = "win95"
MODERN = False # „modern“ oder „dunkel“
DUNKEL = False
APP_NAME = "Provisions-Manager 95" # modern: ohne „95“
# Farbnamen: FACE = Fläche (Fenster, Karten, Dialoge), BG = Grund hinter den Karten, WHITE = Eingabefelder/Listen,
# DARK = Text, TEXT2 = Nebentext, SHADOW = leiser Text/Win95-Schatten, NAVY = Akzent, RAND/RAND2 = Linien,
# PRIMAER/AUF_PRIMAER = Hauptknopf, MANUELL = von Hand geänderte Beträge, *_SOFT = Grund der Status-Pills.
_PALETTEN = {
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
SEL_FG="#ffffff", RAND="#808080", BG="#008080", RAND2="#808080", TEXT2="#000000",
PRIMAER="#c0c0c0", AUF_PRIMAER="#000000", ZEBRA="#ffffff", MANUELL="#0000c0", ROT="#a00000",
ROT_SOFT="#c0c0c0", GRUEN="#008000", GRUEN_SOFT="#c0c0c0", BLAU="#0000ff", BLAU_SOFT="#c0c0c0",
GOLD="#808000", GOLD_SOFT="#c0c0c0"),
# Hausfarben der Handy-Insel-Webtools (VVL-Tool): Lila, IBM Plex
"modern": dict(FACE="#ffffff", HILITE="#ffffff", LIGHT="#f1edf8", SHADOW="#6b6386", DARK="#211a38",
NAVY="#5e35b1", TEAL="#f1eef8", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#ede7fa",
SEL_FG="#2d1b69", RAND="#e5e0f0", BG="#f1eef8", RAND2="#d6cee8", TEXT2="#5b5372",
PRIMAER="#2d1b69", AUF_PRIMAER="#ffffff", ZEBRA="#faf8fd", MANUELL="#5e35b1", ROT="#b8223f",
ROT_SOFT="#fbe4e9", GRUEN="#11734d", GRUEN_SOFT="#def3ea", BLAU="#2a59c4", BLAU_SOFT="#e4ebfb",
GOLD="#8a6212", GOLD_SOFT="#f7eed8"),
"dunkel": dict(FACE="#1c1436", HILITE="#1c1436", LIGHT="#2a2049", SHADOW="#948daa", DARK="#ece8f6",
NAVY="#a88beb", TEAL="#120c22", WHITE="#221940", INFO_BG="#2a2049", SEL_BG="#3a2d66",
SEL_FG="#ece8f6", RAND="#312551", BG="#120c22", RAND2="#3f3167", TEXT2="#ada5c6",
PRIMAER="#c3adee", AUF_PRIMAER="#1c1436", ZEBRA="#201838", MANUELL="#c3adee", ROT="#f26982",
ROT_SOFT="#3a1e28", GRUEN="#4fc796", GRUEN_SOFT="#163025", BLAU="#7da0f0", BLAU_SOFT="#1e2743",
GOLD="#d8b058", GOLD_SOFT="#332a17"),
}
globals().update(_PALETTEN["win95"])
IS_WIN = sys.platform.startswith("win") IS_WIN = sys.platform.startswith("win")
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans" FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
@@ -21,7 +58,57 @@ FONT_SIZE = 8 if IS_WIN else 9
FONT = (FONT_FAMILY, FONT_SIZE) FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold") FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold") FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1) MONO_FAMILY = "Courier New" if IS_WIN else "DejaVu Sans Mono"
FONT_MONO = (MONO_FAMILY, FONT_SIZE + 1)
FONT_KLEIN_FETT = FONT_TITEL = FONT_ZAHL = FONT_BOLD
def design_setzen(name: str) -> None:
"""„modern“ (hell), „dunkel“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
global DESIGN, MODERN, DUNKEL, FONT_FAMILY, FONT_SIZE, APP_NAME, MONO_FAMILY
DESIGN = name if name in _PALETTEN else "modern"
MODERN = DESIGN in ("modern", "dunkel")
DUNKEL = DESIGN == "dunkel"
APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95"
globals().update(_PALETTEN[DESIGN])
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
MONO_FAMILY = ("Consolas" if MODERN else "Courier New") if IS_WIN else "DejaVu Sans Mono"
FONT_SIZE = 10 if MODERN else (8 if IS_WIN else 9)
_schriften()
def _schriften():
global FONT, FONT_BOLD, FONT_BIG, FONT_MONO, FONT_KLEIN_FETT, FONT_TITEL, FONT_ZAHL
FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = (MONO_FAMILY, FONT_SIZE + (0 if MODERN else 1))
FONT_KLEIN_FETT = (FONT_FAMILY, FONT_SIZE - 2, "bold") # Abschnitts-Überschriften (ABRECHNUNGEN …)
FONT_TITEL = (FONT_FAMILY, FONT_SIZE + 8, "bold") # Name der geöffneten Abrechnung
FONT_ZAHL = (MONO_FAMILY, FONT_SIZE + 6) if MODERN else (FONT_FAMILY, FONT_SIZE + 4, "bold") # Kennzahlen
SCHRIFTEN = Path(__file__).resolve().parent / "schriften"
def _schriften_laden() -> None:
"""IBM Plex (Hausschrift der Handy-Insel-Webtools) nur für dieses Programm laden – ohne Installation."""
import ctypes
import ctypes.util
dateien = sorted(SCHRIFTEN.glob("*.ttf"))
try:
if IS_WIN:
for f in dateien:
ctypes.windll.gdi32.AddFontResourceExW(str(f), 0x10, 0) # FR_PRIVATE
return
lib = ctypes.util.find_library("fontconfig")
if lib:
fc = ctypes.CDLL(lib)
fc.FcConfigAppFontAddFile.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
for f in dateien:
fc.FcConfigAppFontAddFile(None, str(f).encode())
except Exception:
pass # dann eben Segoe UI / Systemschrift
# --------------------------------------------------------------------------- Pixel-Icons (16x16) # --------------------------------------------------------------------------- Pixel-Icons (16x16)
PALETTE = { PALETTE = {
@@ -260,7 +347,13 @@ def _icon_rows(name: str) -> list[str]:
return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows)) return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows))
MODERNE_ICONS = Path(__file__).resolve().parent / "bilder" / "modern"
def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage: def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage:
datei = MODERNE_ICONS / f"{name}{'_gross' if scale > 1 else ''}.png"
if MODERN and datei.exists(): # glatte Symbole aus build/make_moderne_icons.py
return tk.PhotoImage(master=master, file=str(datei))
img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale) img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale)
for y, row in enumerate(_icon_rows(name)): for y, row in enumerate(_icon_rows(name)):
for x, ch in enumerate(row): for x, ch in enumerate(row):
@@ -288,6 +381,16 @@ class Icons:
# --------------------------------------------------------------------------- Theme # --------------------------------------------------------------------------- Theme
def apply(root: tk.Tk) -> None: def apply(root: tk.Tk) -> None:
global FONT_FAMILY, MONO_FAMILY
if MODERN: # die erste vorhandene moderne Schrift, am liebsten IBM Plex
from tkinter import font as tkfont
_schriften_laden()
vorhanden = set(tkfont.families(root))
FONT_FAMILY = next((f for f in ("IBM Plex Sans", "Segoe UI", "Noto Sans", "Cantarell", "Ubuntu",
"DejaVu Sans") if f in vorhanden), FONT_FAMILY)
MONO_FAMILY = next((f for f in ("IBM Plex Mono", "Consolas", "DejaVu Sans Mono") if f in vorhanden),
MONO_FAMILY)
_schriften()
root.configure(bg=FACE) root.configure(bg=FACE)
o = root.option_add o = root.option_add
o("*Font", FONT) o("*Font", FONT)
@@ -343,6 +446,66 @@ def apply(root: tk.Tk) -> None:
selectforeground=[("readonly", DARK)]) selectforeground=[("readonly", DARK)])
st.configure("TPanedwindow", background=FACE) st.configure("TPanedwindow", background=FACE)
st.configure("Sash", sashthickness=6, background=FACE) st.configure("Sash", sashthickness=6, background=FACE)
if MODERN:
_modern(root, st, zeile)
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
"""Flach wie die Handy-Insel-Webtools: weiße Flächen, dünne Linien, Lila als Akzent."""
o = root.option_add
for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
o(f"*{klasse}.relief", "flat")
o(f"*{klasse}.borderWidth", 4)
o(f"*{klasse}.highlightThickness", 1)
o(f"*{klasse}.highlightBackground", RAND2)
o(f"*{klasse}.highlightColor", NAVY)
o(f"*{klasse}.background", WHITE)
o(f"*{klasse}.foreground", DARK)
o(f"*{klasse}.insertBackground", DARK)
o("*Entry.disabledBackground", LIGHT)
o("*Button.relief", "flat")
o("*Button.borderWidth", 0)
o("*Button.highlightThickness", 1)
o("*Button.highlightBackground", RAND2)
o("*Button.background", FACE)
o("*Button.activeBackground", LIGHT)
o("*Button.padX", 12)
o("*Button.padY", 4)
o("*Button.cursor", "hand2")
for klasse in ("Checkbutton", "Radiobutton"):
o(f"*{klasse}.activeBackground", FACE)
o(f"*{klasse}.selectColor", WHITE)
o("*selectBackground", SEL_BG)
o("*selectForeground", SEL_FG)
o("*Menu.relief", "flat")
o("*TCombobox*Listbox.background", WHITE)
o("*TCombobox*Listbox.foreground", DARK)
o("*TCombobox*Listbox.selectBackground", SEL_BG)
o("*TCombobox*Listbox.selectForeground", SEL_FG)
st.theme_use("clam")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=FACE,
darkcolor=RAND, focuscolor=NAVY, insertcolor=DARK)
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, foreground=DARK, rowheight=zeile + 10,
borderwidth=0, font=FONT)
st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)])
st.configure("Treeview.Heading", background=FACE, foreground=SHADOW, relief="flat", borderwidth=1,
font=FONT_KLEIN_FETT, padding=(10, 7), bordercolor=RAND, lightcolor=FACE, darkcolor=RAND)
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
# Seitenleiste: ohne Kopf, hohe Zeilen, Auswahl lila wie im VVL-Tool
st.configure("Seite.Treeview", background=FACE, fieldbackground=FACE, rowheight=zeile + 16, font=FONT,
borderwidth=0)
st.layout("Seite.Treeview", [("Treeview.treearea", {"sticky": "nswe"})]) # ohne Rahmen
st.map("Seite.Treeview", background=[("selected", SEL_BG)], foreground=[("selected", NAVY)])
st.configure("TScrollbar", background=RAND2, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE,
lightcolor=RAND2, darkcolor=RAND2, relief="flat", arrowsize=10, width=10, gripcount=0)
st.map("TScrollbar", background=[("active", SHADOW)])
st.configure("TCombobox", fieldbackground=WHITE, background=FACE, foreground=DARK, arrowcolor=DARK, padding=4,
bordercolor=RAND2, lightcolor=WHITE, darkcolor=WHITE)
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
st.configure("TPanedwindow", background=BG)
st.configure("Sash", sashthickness=6, background=BG)
# --------------------------------------------------------------------------- Wort löschen # --------------------------------------------------------------------------- Wort löschen
@@ -423,6 +586,8 @@ class Sunken(tk.Frame):
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen).""" """Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
def __init__(self, master, **kw): def __init__(self, master, **kw):
if MODERN:
kw.update(bd=0, relief="flat", highlightthickness=1, highlightbackground=RAND)
kw.setdefault("bd", 2) kw.setdefault("bd", 2)
kw.setdefault("relief", "sunken") kw.setdefault("relief", "sunken")
super().__init__(master, **kw) super().__init__(master, **kw)
@@ -432,6 +597,15 @@ class Fenster(tk.Frame):
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche.""" """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): def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
if MODERN: # schlichte Überschrift statt blauer Titelleiste
super().__init__(master, bd=0, bg=FACE, **kw)
self.titel_var = tk.StringVar(value=titel)
self.titelleiste = tk.Label(self, textvariable=self.titel_var, bg=FACE, fg=DARK, font=FONT_BOLD,
anchor="w")
self.titelleiste.pack(fill="x", pady=(0, 4))
self.inhalt = tk.Frame(self, bg=FACE)
self.inhalt.pack(fill="both", expand=True)
return
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw) super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
self.titelleiste = tk.Frame(self, bg=NAVY, height=18) self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
self.titelleiste.pack(fill="x", padx=1, pady=1) self.titelleiste.pack(fill="x", padx=1, pady=1)
@@ -453,7 +627,7 @@ class Statusleiste(tk.Frame):
self.vars: dict[str, tk.StringVar] = {} self.vars: dict[str, tk.StringVar] = {}
for i, (key, breite) in enumerate(felder): for i, (key, breite) in enumerate(felder):
v = tk.StringVar() v = tk.StringVar()
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4) lbl = tk.Label(self, textvariable=v, bd=1, relief="flat" if MODERN else "sunken", anchor="w", padx=4)
if breite: if breite:
lbl.configure(width=breite) lbl.configure(width=breite)
lbl.pack(side="left", padx=(0, 2), pady=2) lbl.pack(side="left", padx=(0, 2), pady=2)
@@ -465,9 +639,19 @@ class Statusleiste(tk.Frame):
self.vars[key].set(text) self.vars[key].set(text)
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw): def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None,
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE, primaer: bool = False, **kw):
activebackground=FACE, **kw) """primaer: der eine Hauptknopf einer Fläche (modern dunkel-lila gefüllt, Win95 mit dickem Rahmen)."""
if MODERN:
if primaer:
kw = dict(bg=PRIMAER, fg=AUF_PRIMAER, activebackground=NAVY, activeforeground=AUF_PRIMAER,
highlightbackground=PRIMAER, font=FONT_BOLD) | kw
b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern()
else:
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
activebackground=FACE, **kw)
if primaer:
b.configure(default="active")
if icon is not None: if icon is not None:
b.configure(image=icon, compound="left", padx=6) b.configure(image=icon, compound="left", padx=6)
if breite: if breite:
@@ -475,6 +659,115 @@ def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, br
return b return b
class Karte(tk.Frame):
"""Fläche, die Inhalte zusammenfasst (Kennzahl, Tabelle): modern weiß mit dünner Linie."""
def __init__(self, master, **kw):
if MODERN:
kw = dict(bg=FACE, bd=0, highlightthickness=1, highlightbackground=RAND, highlightcolor=RAND) | kw
else:
kw = dict(bg=FACE, bd=2, relief="raised") | kw
super().__init__(master, **kw)
def titelleiste(master, text: str = "") -> tuple[tk.Frame, tk.Label]:
"""Win95: blaue Titelleiste oben in einer Karte (wie bei Fenster); rechts passen kleine Knöpfe hinein."""
f = tk.Frame(master, bg=NAVY)
f.pack(fill="x", padx=1, pady=1)
lbl = tk.Label(f, text=text, bg=NAVY, fg=WHITE, font=FONT_BOLD, anchor="w", padx=3)
lbl.pack(side="left", pady=1)
return f, lbl
def abschnitt(master, text: str, **kw) -> tk.Label:
"""Kleine Überschrift (modern in Großbuchstaben und grau, wie die Tabellenköpfe der Webtools)."""
if MODERN:
return tk.Label(master, text=text.upper(), fg=SHADOW, font=FONT_KLEIN_FETT, anchor="w", **kw)
return tk.Label(master, text=text, font=FONT_BOLD, anchor="w", **kw)
def _rund(c: tk.Canvas, x0, y0, x1, y1, r, **kw):
"""Abgerundetes Rechteck auf einem Canvas."""
r = min(r, (x1 - x0) / 2, (y1 - y0) / 2)
pkt = [x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r, x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1, x0, y1,
x0, y1 - r, x0, y0 + r, x0, y0]
return c.create_polygon(pkt, smooth=True, **kw)
class Pill(tk.Canvas):
"""Umschalter für Filter („Alle“, „Von Hand geändert“ …): modern abgerundet, Win95 als Knopf,
der eingedrückt bleibt. command bekommt nichts übergeben; aktiv wird von außen gesetzt."""
def __init__(self, master, text: str, command=None, aktiv: bool = False, **kw):
from tkinter import font as tkfont
f = tkfont.Font(root=master, font=FONT)
breite, hoehe = f.measure(text) + (24 if MODERN else 16), f.metrics("linespace") + (10 if MODERN else 8)
bg = kw.pop("bg", master.cget("bg"))
super().__init__(master, width=breite, height=hoehe, bg=bg, highlightthickness=0, bd=0, takefocus=1,
cursor="hand2" if MODERN else "", **kw)
self.text, self.command, self.aktiv = text, command, aktiv
self._zeichnen()
self.bind("<Button-1>", lambda e: self._ausloesen())
self.bind("<space>", lambda e: self._ausloesen())
self.bind("<Return>", lambda e: self._ausloesen())
def _ausloesen(self):
self.focus_set()
if self.command:
self.command()
def setzen(self, aktiv: bool):
if aktiv != self.aktiv:
self.aktiv = aktiv
self._zeichnen()
def _zeichnen(self):
self.delete("all")
b, h = int(self["width"]), int(self["height"])
if MODERN:
fuell, linie, fg = (PRIMAER, PRIMAER, AUF_PRIMAER) if self.aktiv else (FACE, RAND2, TEXT2)
_rund(self, 1, 1, b - 2, h - 2, h, fill=fuell, outline=linie)
else:
oben, unten = (SHADOW, HILITE) if self.aktiv else (HILITE, SHADOW)
self.create_rectangle(0, 0, b - 1, h - 1, fill=LIGHT if self.aktiv else FACE, outline=DARK)
self.create_line(1, h - 2, 1, 1, b - 2, 1, fill=oben)
self.create_line(1, h - 2, b - 2, h - 2, b - 2, 1, fill=unten)
fg = DARK
self.create_text(b // 2 + (1 if self.aktiv and not MODERN else 0), h // 2, text=self.text, fill=fg, font=FONT)
def punkt(master, farbe: str, d: int = 9) -> tk.PhotoImage:
"""Farbiger Punkt (Status in Listen); Ecken bleiben durchsichtig."""
img = tk.PhotoImage(master=master, width=d + 4, height=d)
r = d / 2
# Win95: grauer Ring, damit der Punkt auf weißem Grund und auf der blauen Auswahl sichtbar ist
for f, rr in ((farbe, r),) if MODERN else ((SHADOW, r), (farbe, r - 1)):
for y in range(d):
xs = [x for x in range(d) if (x + .5 - r) ** 2 + (y + .5 - r) ** 2 <= rr * rr]
if xs:
img.put(f, to=(xs[0], y, xs[-1] + 1, y + 1))
return img
def logo(master, groesse: int = 34) -> tk.Canvas:
"""Logo-Kachel wie in den Webtools: Lila-Verlauf, abgerundet, weißes %."""
c = tk.Canvas(master, width=groesse, height=groesse, bg=master.cget("bg"), highlightthickness=0, bd=0)
img = tk.PhotoImage(master=master, width=groesse, height=groesse)
a, e = (0x2d, 0x1b, 0x69), (0x5e, 0x35, 0xb1)
r = groesse * 0.28
for y in range(groesse):
for x in range(groesse):
dx = max(r - x - .5, x + .5 - (groesse - r), 0)
dy = max(r - y - .5, y + .5 - (groesse - r), 0)
if dx * dx + dy * dy <= r * r:
t = (x + y) / (2 * groesse - 2)
img.put("#%02x%02x%02x" % tuple(round(a[i] + (e[i] - a[i]) * t) for i in range(3)), (x, y))
c.create_image(0, 0, image=img, anchor="nw")
c.bild = img # type: ignore[attr-defined] # Referenz halten
c.create_text(groesse / 2, groesse / 2, text="%", fill="#ffffff", font=(FONT_FAMILY, int(groesse * .4), "bold"))
return c
class ToolbarButton(tk.Label): class ToolbarButton(tk.Label):
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick.""" """Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick."""
@@ -482,6 +775,15 @@ class ToolbarButton(tk.Label):
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE, super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
padx=6, pady=2, font=FONT) padx=6, pady=2, font=FONT)
self.command = command self.command = command
if MODERN: # Hervorhebung durch Farbe statt 3D-Kante
self.configure(bd=0, padx=8, pady=4)
self.bind("<Enter>", lambda e: self.configure(bg=LIGHT))
self.bind("<Leave>", lambda e: self.configure(bg=FACE))
self.bind("<ButtonPress-1>", lambda e: self.configure(bg=SEL_BG))
self.bind("<ButtonRelease-1>", self._release)
if tooltip:
Tooltip(self, tooltip)
return
self.bind("<Enter>", lambda e: self.configure(relief="raised")) self.bind("<Enter>", lambda e: self.configure(relief="raised"))
self.bind("<Leave>", lambda e: self.configure(relief="flat")) self.bind("<Leave>", lambda e: self.configure(relief="flat"))
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken")) self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
@@ -490,7 +792,7 @@ class ToolbarButton(tk.Label):
Tooltip(self, tooltip) Tooltip(self, tooltip)
def _release(self, e): def _release(self, e):
self.configure(relief="raised") self.configure(bg=LIGHT) if MODERN else self.configure(relief="raised")
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command: if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
self.command() self.command()
@@ -528,6 +830,10 @@ class Tooltip:
def trenner(master, vertikal: bool = True): def trenner(master, vertikal: bool = True):
f = tk.Frame(master, bg=FACE) f = tk.Frame(master, bg=FACE)
if MODERN:
tk.Frame(f, width=1, height=1, bg=RAND).pack(side="left", fill="y") if vertikal else \
tk.Frame(f, height=1, bg=RAND).pack(fill="x")
return f
if vertikal: if vertikal:
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y") tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y") tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
@@ -538,18 +844,33 @@ def trenner(master, vertikal: bool = True):
def gruppe(master, titel: str, **kw) -> tk.LabelFrame: def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
if MODERN:
return tk.LabelFrame(master, text=titel, bd=0, relief="flat", bg=FACE, padx=4, pady=2, font=FONT_BOLD, **kw)
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw) 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]: def _bei_bedarf(leiste: ttk.Scrollbar):
rahmen = Sunken(master, bg=WHITE) """Bildlaufleiste nur zeigen, wenn nicht alles hineinpasst."""
def setzen(lo, hi):
leiste.grid() if float(lo) > 0 or float(hi) < 1 else leiste.grid_remove()
leiste.set(lo, hi)
return setzen
def scroll_tree(master, auto: bool = False, **kw) -> tuple[tk.Frame, ttk.Treeview]:
"""Treeview mit Bildlaufleisten (modern nur bei Bedarf). auto: keine waagrechte Leiste (Seitenleiste)."""
rahmen = Sunken(master, bg=WHITE if MODERN else FACE) # Win95: graue Ecke zwischen den Leisten
tree = ttk.Treeview(rahmen, **kw) tree = ttk.Treeview(rahmen, **kw)
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview) 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") tree.grid(row=0, column=0, sticky="nsew")
vs.grid(row=0, column=1, sticky="ns") vs.grid(row=0, column=1, sticky="ns")
hs.grid(row=1, column=0, sticky="ew") if auto and MODERN:
rahmen.configure(highlightthickness=0, bg=FACE)
if not auto:
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
hs.grid(row=1, column=0, sticky="ew")
tree.configure(xscrollcommand=_bei_bedarf(hs) if MODERN else hs.set)
tree.configure(yscrollcommand=_bei_bedarf(vs) if auto or MODERN else vs.set)
rahmen.rowconfigure(0, weight=1) rahmen.rowconfigure(0, weight=1)
rahmen.columnconfigure(0, weight=1) rahmen.columnconfigure(0, weight=1)
return rahmen, tree return rahmen, tree
@@ -628,6 +949,8 @@ class Dialog(tk.Toplevel):
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip) self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
self.deiconify() self.deiconify()
self.update_idletasks() self.update_idletasks()
if DUNKEL:
win32.dunkle_titelleiste(self)
if KLASSISCH and win32.IS_WIN: if KLASSISCH and win32.IS_WIN:
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam # eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
if win32.taskleisteneintrag(self): if win32.taskleisteneintrag(self):
@@ -677,7 +1000,7 @@ class Dialog(tk.Toplevel):
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame: def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
f = tk.Frame(self, bg=FACE) f = tk.Frame(self, bg=FACE)
for i, (text, cmd) in enumerate(knoepfe): for i, (text, cmd) in enumerate(knoepfe):
b = knopf(f, text, cmd, breite=10) b = knopf(f, text, cmd, breite=10, primaer=MODERN and i == default)
if i == default: if i == default:
b.configure(default="active") b.configure(default="active")
b.pack(side="left", padx=4) b.pack(side="left", padx=4)
+115
View File
@@ -0,0 +1,115 @@
"""Besondere Provisionsarten: Airtime und Jahresbonus Neuaktivierung (Staffel).
Typ einer Position
------------------
- ``normal`` – Auszahlung = Prozent der Provision (Händler-Standard bzw. Regel je Provisionsart)
- ``airtime`` – Auszahlung = Airtime-Prozent des Händlers (0 % = keine Airtime). Im Export als eine
Summenzeile in der Abrechnung, die Einzelposten in einer eigenen Airtime-Liste.
- ``jahresbonus`` – Jahresbonus Neuaktivierung: Betrag je Karte nach Staffel, abhängig davon, wie viele
Karten der Händler in der Abrechnung hat (z. B. 1–9 Karten: 5 € je Karte ... ab 40: voll).
Der Typ wird aus Provisionsart/Dienst/Tarif erkannt und kann je Provisionsart fest eingestellt werden.
"""
from __future__ import annotations
import json
NORMAL, AIRTIME, JAHRESBONUS = "normal", "airtime", "jahresbonus"
TYPEN = {NORMAL: "Normale Provision", AIRTIME: "Airtime", JAHRESBONUS: "Jahresbonus Neuaktivierung (Staffel)"}
# Staffel: [(ab Anzahl Karten, Cent je Karte oder None = voller Betrag laut Abrechnung), ...]
Staffel = list[tuple[int, "int | None"]]
STANDARD_STAFFEL: Staffel = [(1, 500), (10, 1000), (15, 1500), (20, 2000), (25, 2500), (30, 3000), (35, 3500),
(40, None)]
_AIRTIME_WOERTER = ("airtime", "air time", "air-time", "umsatzbeteiligung")
def _text(*teile) -> str:
return " ".join(str(t or "") for t in teile).lower()
def erkennen(provisionsart: str | None, dienst: str | None = None, tarif: str | None = None) -> str:
"""Typ aus den Texten der Position erkennen (ohne feste Einstellung)."""
art = _text(provisionsart)
if "jahresbonus" in art or ("bonus" in art and "neuaktiv" in art):
return JAHRESBONUS
if any(wort in _text(provisionsart, dienst, tarif) for wort in _AIRTIME_WOERTER):
return AIRTIME
return NORMAL
def typ_von(p: dict, fest: dict[str, str]) -> str:
"""Typ einer Position: feste Einstellung der Provisionsart, sonst automatisch erkannt."""
art = p.get("provisionsart") or ""
if art in fest:
return fest[art]
return erkennen(art, p.get("dienst"), p.get("tarif"))
# --------------------------------------------------------------------------- Staffel
def staffel_pruefen(staffel) -> Staffel:
"""Normalisiert und prüft eine Staffel (aufsteigend, ab >= 1, Beträge >= 0)."""
out: Staffel = []
for ab, cent in staffel:
ab = int(ab)
if ab < 1:
raise ValueError("Die Anzahl Karten muss mindestens 1 sein.")
if cent is not None:
cent = int(cent)
if cent < 0:
raise ValueError("Der Betrag je Karte darf nicht negativ sein.")
out.append((ab, cent))
out.sort(key=lambda s: s[0])
if len({ab for ab, _ in out}) != len(out):
raise ValueError("Jede Stufe braucht eine andere Anzahl Karten.")
if not out:
raise ValueError("Die Staffel braucht mindestens eine Stufe.")
return out
def staffel_laden(text: str | None) -> Staffel | None:
if not text:
return None
try:
return staffel_pruefen(json.loads(text))
except (ValueError, TypeError):
return None
def staffel_text(staffel: Staffel) -> str:
return json.dumps([[ab, cent] for ab, cent in staffel])
def stufe(staffel: Staffel, karten: int) -> tuple[bool, int | None]:
"""(gefunden, Cent je Karte oder None = voll) für die Anzahl Karten."""
treffer = None
for ab, cent in staffel:
if karten >= ab:
treffer = (True, cent)
return treffer or (False, 0)
def bonus_betrag(staffel: Staffel, karten: int, betrag_orig: int) -> int:
"""Auszahlung einer Jahresbonus-Position. Nie mehr als die Provision laut Abrechnung; Stornos
(negative Beträge) werden mit derselben Stufe abgezogen."""
gefunden, cent = stufe(staffel, karten)
if not gefunden:
return 0
if cent is None:
return betrag_orig
if betrag_orig >= 0:
return min(cent, betrag_orig)
return -min(cent, -betrag_orig)
def staffel_beschreibung(staffel: Staffel, kurz: bool = False) -> str:
from provtool.money import fmt
teile = []
for i, (ab, cent) in enumerate(staffel):
bis = staffel[i + 1][0] - 1 if i + 1 < len(staffel) else None
bereich = f"ab {ab}" if bis is None else (f"{ab}" if bis == ab else f"{ab}-{bis}")
wert = "voll" if cent is None else fmt(cent)
teile.append(f"{bereich}: {wert}")
return (", " if kurz else "\n").join(teile)
+12 -1
View File
@@ -122,7 +122,8 @@ def ausfuehren(ergebnisdatei: str) -> int:
# 2) Assistent komplett, Import, Export, Rundgang, Beenden # 2) Assistent komplett, Import, Export, Rundgang, Beenden
csv = basis / "freenet.csv" csv = basis / "freenet.csv"
_beispiel_csv(csv) _beispiel_csv(csv)
s = _Szenario("Assistent, Import, Export", basis / "b") # Win95-Design im normalen Fenster: prüft den nativen Rahmen (Szenario 1: modern, startet maximiert)
s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95", "vollbild": false}')
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731 wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731 seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
@@ -151,6 +152,15 @@ def ausfuehren(ergebnisdatei: str) -> int:
from provtool.gui.assistent import TippDesTages from provtool.gui.assistent import TippDesTages
from provtool.gui.dialogs import ImportBericht from provtool.gui.dialogs import ImportBericht
def fenster_pruefen():
"""Windows: echtes Programmfenster (Andocken, Win+Pfeiltasten) mit unsichtbarem Rahmen."""
r = s.app.rahmen
if sys.platform.startswith("win") and r.klassisch:
if not r.nativ_an:
s.fehler.append("Natives Fenster (Andocken/Win+Pfeiltasten) wurde nicht eingerichtet")
elif not r.nativ.innen_gleich_aussen():
s.fehler.append("Windows-Rahmen des Hauptfensters ist sichtbar")
def tipp_pruefen(): def tipp_pruefen():
d = s.dialog(TippDesTages) d = s.dialog(TippDesTages)
if not d.bild_ok: if not d.bild_ok:
@@ -166,6 +176,7 @@ def ausfuehren(ergebnisdatei: str) -> int:
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"), (seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"), (lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
(lambda: len(s.app.positionen) == 2, export, "Export"), (lambda: len(s.app.positionen) == 2, export, "Export"),
(immer, fenster_pruefen, "Fensterrahmen"),
(immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"), (immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"),
(lambda: s.dialog(TippDesTages) is not None, tipp_pruefen, "Tipp-Bild in der .exe"), (lambda: s.dialog(TippDesTages) is not None, tipp_pruefen, "Tipp-Bild in der .exe"),
(lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"), (lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"),
+94
View File
@@ -0,0 +1,94 @@
"""Zeitraum für Ansicht und Export: nur Positionen von–bis abrechnen.
Bezugsdatum einer Position („basis“):
- ``aktivierung`` – Aktivierungs-/Anschaltdatum der Position (Spalte „Datum“)
- ``abrechnung`` – Abrechnungszeitraum des Anbieters: bei freenet der Beginn der Abrechnungswoche
(„2026.10.01-2026.10.07“), bei Vodafone der Monat des Abrechnungslaufs („VF2609“,
„CY202609UM“ -> 1.9.2026). Ist er nicht lesbar, gilt das Aktivierungsdatum.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import date, timedelta
AKTIVIERUNG, ABRECHNUNG = "aktivierung", "abrechnung"
BASEN = {AKTIVIERUNG: "nach Aktivierungsdatum", ABRECHNUNG: "nach Abrechnungszeitraum des Anbieters"}
@dataclass(frozen=True)
class Zeitraum:
von: date | None = None
bis: date | None = None
basis: str = AKTIVIERUNG
def enthaelt(self, p: dict) -> bool:
d = bezugsdatum(p, self.basis)
if d is None:
return False # ohne Datum lässt sich nicht sagen, ob sie dazugehört
return (self.von is None or d >= self.von) and (self.bis is None or d <= self.bis)
def text(self) -> str:
von = self.von.strftime("%d.%m.%Y") if self.von else "Anfang"
bis = self.bis.strftime("%d.%m.%Y") if self.bis else "heute"
return f"{von} – {bis} ({BASEN[self.basis]})"
def datum_lesen(text: str) -> date | None:
"""'1.10.2026', '01.10.26', '2026-10-01' -> date; leer -> None; sonst ValueError."""
s = (text or "").strip()
if not s:
return None
m = re.fullmatch(r"(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})", s)
if m:
t, mo, j = (int(x) for x in m.groups())
if j < 100:
j += 2000
return date(j, mo, t)
m = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", s)
if m:
return date(*(int(x) for x in m.groups()))
raise ValueError(f"Kein gültiges Datum: {text!r} (bitte TT.MM.JJJJ)")
def abrechnungsdatum(zeitraum: str | None) -> date | None:
s = (zeitraum or "").strip()
m = re.match(r"(\d{4})\.(\d{2})\.(\d{2})", s) # freenet: 2026.10.01-2026.10.07
if m:
try:
return date(*(int(x) for x in m.groups()))
except ValueError:
return None
m = re.match(r"(?:VF|CY)(\d{4}|\d{2})(\d{2})", s.upper()) # Vodafone: VF2609, CY202609UM
if m:
j, mo = int(m.group(1)), int(m.group(2))
if j < 100:
j += 2000
if 1 <= mo <= 12:
return date(j, mo, 1)
return None
def bezugsdatum(p: dict, basis: str) -> date | None:
if basis == ABRECHNUNG:
d = abrechnungsdatum(p.get("zeitraum"))
if d is not None:
return d
if p.get("datum"):
try:
return date.fromisoformat(p["datum"])
except ValueError:
return None
return None
def vormonat(heute: date | None = None) -> tuple[date, date]:
heute = heute or date.today()
ende = heute.replace(day=1) - timedelta(days=1)
return ende.replace(day=1), ende
def dieser_monat(heute: date | None = None) -> tuple[date, date]:
heute = heute or date.today()
naechster = (heute.replace(day=28) + timedelta(days=4)).replace(day=1)
return heute.replace(day=1), naechster - timedelta(days=1)
+340 -10
View File
@@ -29,8 +29,9 @@ class Lauf:
def __init__(self, home, einstellungen: dict | None = None): def __init__(self, home, einstellungen: dict | None = None):
self.home = home self.home = home
if einstellungen is not None: # Tests starten im normalen Fenster (die App sonst maximiert)
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8") einstellungen = {"vollbild": False} | (einstellungen or {})
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
self.root = None self.root = None
for versuch in range(3): for versuch in range(3):
try: try:
@@ -70,14 +71,19 @@ class Lauf:
denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten.""" denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten."""
try: try:
sichtbar = bool(self.root.winfo_viewable()) and self.app.inhalt.winfo_height() > 50 sichtbar = bool(self.root.winfo_viewable()) and self.app.inhalt.winfo_height() > 50
lage = (self.root.winfo_rootx(), self.root.winfo_rooty(), self.root.winfo_width(),
self.root.winfo_height())
except tk.TclError: except tk.TclError:
return True return True
if not sichtbar: # erst wenn Lage und Größe einen Moment stabil sind (beim ersten Anzeigen gibt es unter Wine
# kurz einen Zwischenstand)
if not sichtbar or lage != getattr(self, "_lage", None):
self._lage = lage
self._sichtbar_seit = None self._sichtbar_seit = None
return False return False
if self._sichtbar_seit is None: if self._sichtbar_seit is None:
self._sichtbar_seit = time.time() self._sichtbar_seit = time.time()
return time.time() - self._sichtbar_seit >= 0.1 return time.time() - self._sichtbar_seit >= 0.15
# ---------------------------------------------------------------- Zustand # ---------------------------------------------------------------- Zustand
def lebt_sonst(self, text: str) -> str: def lebt_sonst(self, text: str) -> str:
@@ -198,9 +204,7 @@ def home(tmp_path, monkeypatch):
return tmp_path return tmp_path
@pytest.fixture def _profil_mit_daten(home):
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
from tests.test_kern import FREENET_KOPF, freenet_zeile from tests.test_kern import FREENET_KOPF, freenet_zeile
csv = home / "freenet.csv" csv = home / "freenet.csv"
csv.write_bytes(("\r\n".join([ csv.write_bytes(("\r\n".join([
@@ -214,6 +218,21 @@ def lauf_mit_daten(home):
from provtool.importer import lies_datei from provtool.importer import lies_datei
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv)) db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
db.close() db.close()
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten), klassisches Win95-Design."""
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": "win95"})
yield lauf
lauf.ende()
@pytest.fixture
def lauf_modern(home):
"""Wie lauf_mit_daten, aber im modernen Design (Standard)."""
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False}) lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
yield lauf yield lauf
lauf.ende() lauf.ende()
@@ -311,13 +330,18 @@ def _dialog_oeffner():
"prozent": lambda a: (a.ptree.selection_set(a.ptree.get_children()[0]), a.prozent()), "prozent": lambda a: (a.ptree.selection_set(a.ptree.get_children()[0]), a.prozent()),
"neu_berechnen": lambda a: (a.db.betrag_setzen(a.positionen[0]["id"], 1), a._aktualisieren(), "neu_berechnen": lambda a: (a.db.betrag_setzen(a.positionen[0]["id"], 1), a._aktualisieren(),
a.neu_berechnen()), a.neu_berechnen()),
"provisionsarten": lambda a: a.provisionsarten(),
"staffel": lambda a: a.standard_staffel(),
} }
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp", @pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen"]) "dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name): "staffel"])
lauf = lauf_mit_daten @pytest.mark.parametrize("design", ["win95", "modern"])
def test_schliessen_waehrend_dialog_offen(home, name, design):
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": design})
oeffnen = _dialog_oeffner()[name] oeffnen = _dialog_oeffner()[name]
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"), lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")]) (lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
@@ -825,8 +849,11 @@ def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten):
r = lauf.app.rahmen r = lauf.app.rahmen
if not r.klassisch: if not r.klassisch:
pytest.skip("nur im Win95-Rahmen") pytest.skip("nur im Win95-Rahmen")
lauf.ablauf([]) # erst wenn das Hauptfenster zu sehen ist (Größen stimmen dann)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})() ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})()
r._rand_cursor(ev) r._rand_cursor(ev)
# Den Zeiger verwaltet immer Tk – auch mit nativem Windows-Rahmen (gemeldet in 1.3.0: setzte
# Windows ihn am Rand, blieb er im Fenster der Größenpfeil, weil Tk davon nichts wusste).
assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen" assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen"
r.inhalt.event_generate("<Enter>") r.inhalt.event_generate("<Enter>")
lauf.root.update() lauf.root.update()
@@ -875,3 +902,306 @@ def test_windows_minimieren_mit_offenem_dialog(lauf_mit_daten):
assert m["minimiert"], "Hauptfenster wurde nicht minimiert" assert m["minimiert"], "Hauptfenster wurde nicht minimiert"
assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden" assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden"
lauf.pruefen() lauf.pruefen()
@nur_windows
def test_windows_echtes_fenster_andocken_und_rand(lauf_mit_daten):
"""Gemeldet: Win+Hoch/Runter und Andocken am Bildschirmrand gingen nicht, Größe ziehen war zäh.
Das Hauptfenster muss für Windows ein echtes Programmfenster sein (Größenrahmen, Min/Max), dessen
Windows-Rahmen unsichtbar ist; am Rand meldet es Windows den Größenrahmen."""
from provtool.gui import nativ, rahmen
lauf = lauf_mit_daten
r = lauf.app.rahmen
if not r.klassisch:
pytest.skip("nur im Win95-Rahmen")
m = {}
def pruefen():
n = r.nativ
m["aktiv"] = r.nativ_an
if not r.nativ_an:
return
stil, ex = n.stil()
m["stil"] = {name: bool(stil & bit) for name, bit in (
("Größenrahmen", nativ.WS_THICKFRAME), ("Maximieren", nativ.WS_MAXIMIZEBOX),
("Minimieren", nativ.WS_MINIMIZEBOX), ("Systemmenü", nativ.WS_SYSMENU), ("Titel", nativ.WS_CAPTION))}
m["programmfenster"] = not (ex & nativ.WS_EX_TOOLWINDOW) and bool(ex & nativ.WS_EX_APPWINDOW)
m["rahmen_unsichtbar"] = n.innen_gleich_aussen()
x, y, bw, bh = n.aussenmass()
m["tk_gleich_aussen"] = (lauf.root.winfo_width(), lauf.root.winfo_height()) == (bw, bh)
my = y + bh // 2
m["links"] = n.trefferpruefung(n.hwnd, x + 2, my) == nativ.HT["w"]
m["ecke"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + bh - 3) == nativ.HT["se"]
m["ecke_lang"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + 10) == nativ.HT["ne"]
m["innen"] = n.trefferpruefung(n.hwnd, x + bw // 2, y + bh // 2) == nativ.HTCLIENT
# Tk darf das Fenster bei internen Größenanfragen nicht um einen Rahmen vergrößern
n.setzen(x, y, bw - 37, bh - 23) # wie Größe ziehen durch den Benutzer
lauf.root.update()
vorher = n.aussenmass()
lauf.app.status.set("info", " " + "sehr langer Statustext " * 20)
lauf.root.update_idletasks()
lauf.root.update()
m["waechst_nicht"] = (n.aussenmass(), vorher)
# Maximieren über Windows (wie Win+Hoch / an den oberen Rand ziehen) und zurück
n.befehl(nativ.SC_MAXIMIZE)
lauf.root.update()
ax, ay, aw, ah = rahmen.arbeitsbereich(lauf.root)
m["max"] = (r.maximiert, n.maximiert())
m["max_inhalt"] = ((lauf.root.winfo_rootx(), lauf.root.winfo_rooty(), lauf.root.winfo_width(),
lauf.root.winfo_height()), (ax, ay, aw, ah))
m["max_symbol"] = str(r.titelleiste.knoepfe["max"].cget("image")) == str(r.titelleiste._bilder["restore"])
m["kein_rand_maximiert"] = n.randbereich(ax + 1, ay + ah // 2) == ""
n.befehl(nativ.SC_RESTORE)
lauf.root.update()
m["zurueck"] = (r.maximiert, n.aussenmass() == vorher)
m["normale_geometrie"] = r.normale_geometrie()
lauf.ablauf([(IMMER, lambda: lauf.root.after(300, pruefen), "prüfen"),
(lambda: "aktiv" in m and ("zurueck" in m or not m["aktiv"]), lambda: None, "fertig")],
sekunden=15)
assert not lauf.uebrig, lauf.diagnose()
assert m["aktiv"], "natives Fenster wurde nicht eingerichtet"
assert all(m["stil"].values()), f"Fensterstil unvollständig: {m['stil']}"
assert m["programmfenster"], "Fenster ist für Windows noch ein Werkzeugfenster (kein Andocken/Win+Pfeil)"
assert m["rahmen_unsichtbar"] and m["tk_gleich_aussen"], "Windows-Rahmen ist sichtbar / Tk-Größe passt nicht"
for k in ("links", "ecke", "ecke_lang", "innen"):
assert m[k], f"Trefferprüfung am Rand falsch: {k} ({m})"
assert m["waechst_nicht"][0] == m["waechst_nicht"][1], f"Fenster ist gewachsen: {m['waechst_nicht']}"
assert m["max"] == (True, True), f"Maximieren über Windows nicht erkannt: {m['max']}"
assert m["max_inhalt"][0] == m["max_inhalt"][1], f"maximiert nicht genau im Arbeitsbereich: {m['max_inhalt']}"
assert m["max_symbol"] and m["kein_rand_maximiert"]
assert m["zurueck"] == (False, True), f"Wiederherstellen: {m['zurueck']}"
assert m["normale_geometrie"].startswith(f"{m['waechst_nicht'][1][2]}x{m['waechst_nicht'][1][3]}+")
lauf.pruefen()
def test_haendler_dialog_airtime_staffel_grossbuchstaben(lauf_mit_daten):
"""Airtime-% und eigene Jahresbonus-Staffel je Händler; die GROSSBUCHSTABEN-Regel ist ein Häkchen
(gemeldet: Vodafone fehlte im Export – die Regel ging verloren, wenn man „Hinzufügen“ vergaß)."""
from provtool.db import CAPS_ANBIETER, CAPS_WERT
from provtool.gui.dialogs import HaendlerDialog, StaffelDialog
lauf = lauf_mit_daten
app = lauf.app
hid = app.db.haendler_liste()[0].id
hd = lambda: lauf.dialog(HaendlerDialog) # noqa: E731
sd = lambda: lauf.dialog(StaffelDialog) # noqa: E731
def eintragen():
d = hd()
d.v_air.set("40")
d.v_caps.set(True)
d.v_eigen_staffel.set(True)
d._staffel_zeigen()
lauf.root.after(10, d._staffel_bearbeiten)
def staffel_eingeben():
d = sd()
d._setzen([(1, 100), (3, None)])
d.zeilen[0][2].set("2,50")
d._ok()
lauf.ablauf([
(IMMER, lambda: app.haendler(hid), "Händler-Dialog"),
(lambda: hd() is not None, eintragen, "Airtime, Häkchen, eigene Staffel"),
(lambda: sd() is not None, staffel_eingeben, "Staffel eingeben"),
(lambda: sd() is None and "2,50" in hd().l_staffel.cget("text"), lambda: hd()._speichern(), "Speichern"),
(IMMER, lambda: hd().abbrechen(), "Schließen"),
(lambda: hd() is None, lambda: None, "zu"),
])
assert not lauf.uebrig, lauf.diagnose()
h = app.db.haendler(hid)
assert h.airtime_prozent == 40
assert h.staffel == [(1, 250), (3, None)]
assert (CAPS_ANBIETER, CAPS_WERT) in app.db.kennungen(hid)
assert ("freenet", "111") in app.db.kennungen(hid)
lauf.pruefen()
def test_export_dialog_standard_eine_datei_ohne_null(lauf_mit_daten, tmp_path):
from provtool.gui.dialogs import ExportDialog
lauf = lauf_mit_daten
app = lauf.app
app.db.betrag_setzen(next(p["id"] for p in app.positionen if p["provisionsart"] == "SIM-Karten"
and p["haendler_id"] > 0), 0)
app._aktualisieren()
app.settings["export_ordner"] = str(tmp_path / "x")
app.settings["export_pro_anbieter"] = True # alte Einstellung darf nicht mehr trennen
ergebnis = {}
def ok():
d = lauf.dialog(ExportDialog)
ergebnis.update(anb=d.v_anb.get(), null=d.v_null.get(), haendler=list(d.haken))
d.v_auf.set(False)
d._ok()
from provtool.gui import win95 as w
lauf.ablauf([
(IMMER, app.exportieren, "Export"),
(lambda: lauf.dialog(ExportDialog) is not None, ok, "Exportieren"),
(lambda: any(d.title() == "Export" for d in lauf.dialoge()),
lambda: [d.abbrechen() for d in lauf.dialoge() if isinstance(d, w.Dialog)], "Meldung schließen"),
])
assert not lauf.uebrig, lauf.diagnose()
assert ergebnis == {"anb": False, "null": True, "haendler": [app.db.haendler_liste()[0].id]}
import openpyxl
dateien = sorted((tmp_path / "x").glob("*.xlsx"))
assert [d.name for d in dateien] == ["Händler A - Oktober 2026.xlsx"]
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True) if r[1]]
assert [r[1] for r in zeilen] == ["Sonderbonus Aktion"], "0-€-Position wurde exportiert"
lauf.pruefen()
def test_rand_druecken_startet_natives_ziehen(lauf_mit_daten, monkeypatch):
"""Mit nativem Rahmen zieht Windows die Größe – angestoßen beim Drücken auf den Rand."""
lauf = lauf_mit_daten
r = lauf.app.rahmen
if not r.nativ_an:
pytest.skip("nur mit nativem Windows-Rahmen")
lauf.ablauf([])
gezogen = []
monkeypatch.setattr(r.nativ, "groesse_ziehen", gezogen.append)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2, "x_root": 0, "y_root": 0})()
assert r._resize_start(ev) == "break"
ev.x, ev.y = r.aussen.winfo_width() - 2, r.aussen.winfo_height() - 2
r._resize_start(ev)
assert gezogen == ["w", "se"]
lauf.pruefen()
def test_modern_ist_standard(lauf_modern):
from provtool.gui import win95 as w
lauf = lauf_modern
assert w.DESIGN == "modern" and not lauf.app.rahmen.klassisch and lauf.app.rahmen.titelleiste is None
lauf.ablauf([(IMMER, lambda: lauf.app.menueleiste._klick(lauf.app.menueleiste.knoepfe["Datei"]), "Menü"),
(lambda: lauf.app.menueleiste.offener_knopf is not None,
lambda: lauf.app.menueleiste.schliessen(), "zu")])
assert not lauf.uebrig, lauf.diagnose()
lauf.pruefen()
def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkeypatch):
"""Zeitraum abrechnen, exportierte Positionen nicht erneut exportieren, Abrechnung erledigt sperrt."""
from provtool.gui import win95 as w
from provtool.gui.dialogs import ExportDialog
lauf = lauf_modern
app = lauf.app
app.settings["export_ordner"] = str(tmp_path / "x")
meldungen = []
monkeypatch.setattr(w, "info", lambda m, t, text: meldungen.append(text))
monkeypatch.setattr(w, "frage", lambda *a, **k: True)
def ok(von="", bis=""):
d = lauf.dialog(ExportDialog)
d.v_von.set(von)
d.v_bis.set(bis)
d.v_auf.set(False)
d._ok()
def ok_offen():
d = lauf.dialog(ExportDialog)
d.v_von.set("01.10.2026")
d.v_bis.set("02.10.2026")
next(k for k in _alle(d) if isinstance(k, tk.Button) and k.cget("text") == "Noch nicht abgerechnet").invoke()
ok(d.v_von.get(), d.v_bis.get())
lauf.ablauf([
(IMMER, app.exportieren, "Export Oktober 6."),
(lambda: lauf.dialog(ExportDialog) is not None, lambda: ok("01.10.2026", "05.10.2026"), "nur bis 5.10."),
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "Export Rest"),
(lambda: lauf.dialog(ExportDialog) is not None, ok_offen, "Noch nicht abgerechnet"),
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "nochmal"),
])
assert not lauf.uebrig, lauf.diagnose()
assert sum(1 for p in app.positionen if p["abgerechnet"]) == 2
assert "1 Position(en) als abgerechnet" in meldungen[0] and "1 Position(en) als abgerechnet" in meldungen[1]
assert "bereits abgerechnet" in meldungen[2], meldungen
# erledigt: Änderungen gesperrt
app.erledigt_umschalten()
assert app.db.erledigt(app.aid)
app.ptree.selection_set(app.ptree.get_children()[0])
app.prozent()
assert "erledigt" in meldungen[-1]
lauf.pruefen()
def _alle(widget):
for kind in widget.winfo_children():
yield kind
yield from _alle(kind)
def test_abrechnungen_links_und_import_fragt_nach_ziel(lauf_modern, home, monkeypatch):
from provtool.gui import win95 as w
from provtool.gui.dialogs import AbrechnungenDialog, ImportBericht
monkeypatch.setattr(w, "frage", lambda *a, **k: True) # „bereits importiert – trotzdem?“ (Testdatei ist schon drin)
lauf = lauf_modern
app = lauf.app
alt = app.aid
neu = app.db.abrechnung_neu("November 2026")
app._aktualisieren()
assert set(app.atree.get_children()) == {str(alt), str(neu)}, "Abrechnungen fehlen links"
app.atree.selection_set(str(neu))
lauf.root.update()
assert app.aid == neu and not app.positionen, "Klick links öffnet die Abrechnung nicht"
app.atree.selection_set(str(alt))
lauf.root.update()
def ziel_waehlen():
d = lauf.dialog(AbrechnungenDialog)
d.tree.selection_set(str(neu))
d._oeffnen()
lauf.ablauf([
(IMMER, lambda: app.importieren([str(home / "freenet.csv")]), "Import"),
(lambda: lauf.dialog(AbrechnungenDialog) is not None, ziel_waehlen, "Ziel: November"),
(lambda: lauf.dialog(ImportBericht) is not None, lambda: lauf.dialog(ImportBericht).abbrechen(), "Bericht zu"),
])
assert not lauf.uebrig, lauf.diagnose()
assert app.aid == neu and len(app.positionen) == 3, "Import nicht in der gewählten Abrechnung"
assert app.atree.selection() == (str(neu),)
lauf.pruefen()
def test_filter_kennzahlen_und_hell_dunkel(lauf_modern):
"""Filter-Knöpfe über der Tabelle, Kennzahl-Kacheln und der Hell/Dunkel-Schalter in der Kopfzeile."""
from provtool import config
from provtool.db import EIGEN
lauf = lauf_modern
app = lauf.app
alle = [p for p in app.positionen if p["haendler_id"] != EIGEN]
assert app.kpi["pos"][0].cget("text") == str(len(alle))
p = alle[0]
app.db.betrag_setzen(p["id"], 123)
app._aktualisieren()
app.pills["manuell"]._ausloesen() # wie ein Klick (event_generate kommt unter Wine nicht an)
lauf.root.update()
manuell = [x for x in app.positionen if x["manuell"] and x["haendler_id"] != EIGEN]
assert len(manuell) == 1
zeilen = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
assert len(zeilen) == len(manuell), "Filter „Von Hand geändert“ wirkt nicht"
assert app.kpi["pos"][0].cget("text") == str(len(manuell))
assert app.pills["manuell"].aktiv and not app.pills["alle"].aktiv
app._filter_setzen("alle")
assert len([i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]) == len(alle)
app.design_umschalten() # schließt das Fenster; main() baut es danach dunkel neu auf
for _ in range(50):
if app._beendet:
break
lauf.root.update()
assert app.neustart and app._beendet
assert config.load()["design"] == "dunkel"
def test_startet_maximiert(home):
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "vollbild": True, "design": "win95"})
try:
for _ in range(30):
lauf.root.update()
time.sleep(0.02)
assert lauf.app.rahmen.maximiert, "Programm startet nicht maximiert"
finally:
lauf.ende()
+235 -1
View File
@@ -159,9 +159,22 @@ def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path):
# Export # Export
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid] 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") dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026", pro_anbieter=True)
assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx", assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx",
"Händler A - Vodafone - Oktober 2026.xlsx"] "Händler A - Vodafone - Oktober 2026.xlsx"]
# Standard: freenet UND Vodafone in einer Datei (gemeldet: Vodafone fehlte im Export)
eine = export.exportiere_haendler(tmp_path / "out1", "Händler A", eigene, "Oktober 2026")
assert [d.name for d in eine] == ["Händler A - Oktober 2026.xlsx"]
zeilen1 = [r for r in openpyxl.load_workbook(eine[0]).active.iter_rows(values_only=True) if r[1]]
arten = [r[1] for r in zeilen1]
assert "Freischaltungsvergütung Vodafone" in arten and "SIM-Karten" in arten
# Spalte A: Vodafone mit Aktivierungsdatum, freenet mit Zeitraum
vf = next(r for r in zeilen1 if r[1] == "Freischaltungsvergütung Vodafone")
assert vf[0] == datetime(2026, 9, 8)
assert next(r for r in zeilen1 if r[1] == "SIM-Karten")[0] == "2026.10.01-2026.10.07"
ws1 = openpyxl.load_workbook(eine[0]).active
zeile_vf = next(c.row for c in ws1["B"] if c.value == "Freischaltungsvergütung Vodafone")
assert ws1.cell(zeile_vf, 1).number_format == "DD.MM.YYYY"
ws = openpyxl.load_workbook(dateien[0]).active 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)] zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
# Sortierung Zeitraum → Provisionsart → Datum # Sortierung Zeitraum → Provisionsart → Datum
@@ -281,3 +294,224 @@ def test_eigene_kennungen_ausblenden(db, freenet_csv):
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid
db.eigene_setzen("freenet", "999", False) db.eigene_setzen("freenet", "999", False)
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["999"] == UNZUGEORDNET assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["999"] == UNZUGEORDNET
# --------------------------------------------------------------------------- Airtime / Jahresbonus
def _pos(art, betrag, unterh="111", rufnr="1601111111", name="Kunde", dienst="Teilnehmerverhaeltnis"):
z = freenet_zeile(art, unterh, "05.10.2026", rufnr, name, "Tarif", betrag).split(";")
z[24] = dienst
return ";".join(z)
@pytest.fixture
def bonus_csv(tmp_path):
zeilen = [FREENET_KOPF,
_pos("Grundvergütung Mobilfunk Credit freenet", "50"),
_pos("Grundvergütung Mobilfunk Credit freenet", "0"),
_pos("Prepaidvergütung", "20", dienst="AirTime Provision"),
_pos("Airtime Umsatzbeteiligung", "10,50")]
zeilen += [_pos("Jahresbonus Neuaktivierung", "40", rufnr=f"16000000{i:02d}") for i in range(12)]
zeilen += [_pos("Jahresbonus Neuaktivierung", "3", rufnr="1600000099")] # weniger als die Stufe
p = tmp_path / "bonus.csv"
p.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
return p
def test_typ_erkennung():
from provtool import regeln
assert regeln.erkennen("Prepaidvergütung Vodafone", "AirTime Provision") == regeln.AIRTIME
assert regeln.erkennen("Airtime freenet") == regeln.AIRTIME
assert regeln.erkennen("Jahresbonus Neuaktivierung") == regeln.JAHRESBONUS
assert regeln.erkennen("SIM-Karten", "Teilnehmerverhaeltnis") == regeln.NORMAL
def test_staffel_stufen():
from provtool import regeln
st = regeln.STANDARD_STAFFEL
assert regeln.bonus_betrag(st, 0, 4000) == 0
assert regeln.bonus_betrag(st, 1, 4000) == 500 and regeln.bonus_betrag(st, 9, 4000) == 500
assert regeln.bonus_betrag(st, 10, 4000) == 1000 and regeln.bonus_betrag(st, 14, 4000) == 1000
assert regeln.bonus_betrag(st, 39, 4000) == 3500
assert regeln.bonus_betrag(st, 40, 4000) == 4000 and regeln.bonus_betrag(st, 80, 6000) == 6000 # voll
assert regeln.bonus_betrag(st, 25, 1000) == 1000 # nie mehr als laut Abrechnung
assert regeln.bonus_betrag(st, 12, -4000) == -1000 # Storno mit derselben Stufe
with pytest.raises(ValueError):
regeln.staffel_pruefen([(1, 500), (1, 600)])
assert "1-9: 5,00 €" in regeln.staffel_beschreibung(st) and "ab 40: voll" in regeln.staffel_beschreibung(st)
def test_airtime_und_jahresbonus(db, bonus_csv, tmp_path):
from provtool import regeln
hid = db.haendler_speichern(Haendler(0, "Sub", standard_prozent=100), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(bonus_csv))
pos = db.positionen(aid)
air = [p for p in pos if p["typ"] == regeln.AIRTIME]
bonus = [p for p in pos if p["typ"] == regeln.JAHRESBONUS]
assert len(air) == 2 and len(bonus) == 13
assert all(p["betrag"] == 0 for p in air), "Standard: 0 % Airtime = keine Airtime"
# 13 Karten -> Stufe 10-14 = 10 € je Karte (die 3-€-Position bleibt bei 3 €)
assert sorted(p["betrag"] for p in bonus) == [300] + [1000] * 12
assert all(p["prozent"] is None for p in bonus)
# Airtime 50 %, eigene Staffel
h = db.haendler(hid)
h.airtime_prozent = 50
h.staffel = [(1, 200), (13, None)]
db.haendler_speichern(h, db.kennungen(hid))
db.neu_berechnen(aid)
pos = db.positionen(aid)
assert sorted(p["betrag"] for p in pos if p["typ"] == regeln.AIRTIME) == [525, 1000]
assert sorted(p["betrag"] for p in pos if p["typ"] == regeln.JAHRESBONUS) == [300] + [4000] * 12
# Zuweisung ändert die Anzahl Karten -> Staffel der übrigen wird neu berechnet
weg = [p["id"] for p in pos if p["typ"] == regeln.JAHRESBONUS][:2]
db.haendler_zuweisen(weg, UNZUGEORDNET)
rest = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and p["haendler_id"] == hid]
assert len(rest) == 11 and all(p["betrag"] == min(200, p["betrag_orig"]) for p in rest)
# Typ fest einstellen
db.art_typ_setzen("Airtime Umsatzbeteiligung", regeln.NORMAL)
assert any(a == "Airtime Umsatzbeteiligung" for a, _, _ in db.provisionsarten())
db.neu_berechnen(aid)
p = next(p for p in db.positionen(aid) if p["provisionsart"] == "Airtime Umsatzbeteiligung")
assert p["typ"] == regeln.NORMAL and p["betrag"] == 1050
db.art_typ_setzen("Airtime Umsatzbeteiligung", None)
# Export: Abrechnung mit Airtime-Summenzeile + eigene Airtime-Liste, 0-€-Positionen weg
db.neu_berechnen(aid)
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
dateien = export.exportiere_haendler(tmp_path / "out", "Sub", eigene, "Okt")
assert [d.name for d in dateien] == ["Sub - Okt.xlsx", "Sub - Airtime - Okt.xlsx"]
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True)]
daten = [r for r in zeilen if r[1]]
assert all(r[10] != 0 for r in daten), "0-€-Positionen dürfen nicht im Export stehen"
assert daten[-1][1] == "Airtime" and daten[-1][10] == 15.25
assert not any(r[1] == "Prepaidvergütung" for r in daten), "Airtime-Einzelposten gehören in die eigene Liste"
air_zeilen = [r for r in openpyxl.load_workbook(dateien[1]).active.iter_rows(values_only=True) if r[1]]
assert sorted(r[10] for r in air_zeilen) == [5.25, 10.0]
# 0 % Airtime: keine Airtime-Zeile, keine Airtime-Datei
h = db.haendler(hid)
h.airtime_prozent = 0
db.haendler_speichern(h, db.kennungen(hid))
db.neu_berechnen(aid)
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
dateien = export.exportiere_haendler(tmp_path / "out0", "Sub", eigene, "Okt")
assert [d.name for d in dateien] == ["Sub - Okt.xlsx"]
assert not any(r[1] == "Airtime" for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True))
def test_migration_v2_haendler_ohne_airtime(tmp_path):
import sqlite3
pfad = tmp_path / "v2.db"
con = sqlite3.connect(pfad)
con.executescript("""CREATE TABLE 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);
INSERT INTO haendler (name, standard_prozent) VALUES ('Alt', 90);""")
con.close()
d = Datenbank(pfad)
h = d.haendler_liste()[0]
assert h.name == "Alt" and h.airtime_prozent == 0 and h.staffel is None
assert d.staffel_fuer(h.id) == d.standard_staffel()
d.standard_staffel_setzen([(1, 100), (5, None)])
assert d.staffel_fuer(h.id) == [(1, 100), (5, None)]
d.close()
# --------------------------------------------------------------------------- Zeitraum / abgerechnet / erledigt
def test_zeitraum_lesen_und_bezugsdatum():
from datetime import date
from provtool import zeitraum as zr
assert zr.datum_lesen("1.10.2026") == date(2026, 10, 1) and zr.datum_lesen("01.10.26") == date(2026, 10, 1)
assert zr.datum_lesen("") is None
with pytest.raises(ValueError):
zr.datum_lesen("32.13.2026")
assert zr.abrechnungsdatum("2026.10.08-2026.10.15") == date(2026, 10, 8)
assert zr.abrechnungsdatum("VF2609") == date(2026, 9, 1) == zr.abrechnungsdatum("CY202609UM")
assert zr.abrechnungsdatum("VF2609P6 Fil 0") == date(2026, 9, 1)
p = {"zeitraum": "VF2609", "datum": "2026-08-30"}
assert zr.bezugsdatum(p, zr.AKTIVIERUNG) == date(2026, 8, 30)
assert zr.bezugsdatum(p, zr.ABRECHNUNG) == date(2026, 9, 1)
okt = zr.Zeitraum(date(2026, 10, 1), date(2026, 10, 31))
assert okt.enthaelt({"datum": "2026-10-31"}) and not okt.enthaelt({"datum": "2026-11-01"})
assert not okt.enthaelt({"datum": None})
assert zr.vormonat(date(2026, 3, 15)) == (date(2026, 2, 1), date(2026, 2, 28))
assert zr.dieser_monat(date(2026, 12, 5)) == (date(2026, 12, 1), date(2026, 12, 31))
def test_abgerechnet_einfrieren_und_doppelt_erkennen(db, freenet_csv):
from provtool.db import AbrechnungErledigt
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
a1 = db.abrechnung_neu("Okt")
db.importieren(a1, lies_datei(freenet_csv))
pos = [p for p in db.positionen(a1) if p["haendler_id"] == hid]
assert len(pos) == 4 and all(p["fp"] for p in pos)
erste = pos[:2]
assert db.abrechnen([p["id"] for p in erste]) == 2
# abgerechnet: Betrag und Händler eingefroren
h = db.haendler(hid)
h.standard_prozent = 50
db.haendler_speichern(h, []) # Kennung weg, 50 %
db.neu_berechnen(a1)
nach = {p["id"]: p for p in db.positionen(a1)}
for p in erste:
assert nach[p["id"]]["haendler_id"] == hid and nach[p["id"]]["betrag"] == p["betrag"]
with pytest.raises(AbrechnungErledigt):
db.betrag_setzen(erste[0]["id"], 1)
assert db.prozent_anwenden([erste[0]["id"]], 10) == 0
# dieselbe Datei in einer neuen Abrechnung: die beiden abgerechneten Positionen sind „doppelt“
db.haendler_speichern(db.haendler(hid), [("freenet", "111")])
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
doppelt = [p for p in db.positionen(a2) if p["doppelt"]]
assert len(doppelt) == 2 and all(p["doppelt"] == "Okt" for p in doppelt)
assert db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden"
# Kennzeichnung aufheben -> nicht mehr doppelt
assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2
assert not any(p["doppelt"] for p in db.positionen(a2))
def test_abrechnung_erledigt_sperrt(db, freenet_csv):
from provtool.db import AbrechnungErledigt
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
assert db.erledigt_setzen(aid, True) == 4 # nur die 4 Händler-Positionen, nicht die unzugeordnete
assert db.erledigt(aid)
assert all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid)
with pytest.raises(AbrechnungErledigt):
db.importieren(aid, lies_datei(freenet_csv))
with pytest.raises(AbrechnungErledigt):
db.datei_entfernen(db.dateien(aid)[0]["id"])
unz = next(p for p in db.positionen(aid) if p["haendler_id"] <= 0)
db.haendler_zuweisen([unz["id"]], hid)
assert next(p for p in db.positionen(aid) if p["id"] == unz["id"])["haendler_id"] <= 0, "erledigt: keine Änderung"
assert db.abrechnungen()[0]["abgerechnet"] == 4
# wieder öffnen, Positionen bleiben abgerechnet bzw. werden auf Wunsch freigegeben
db.erledigt_setzen(aid, False)
assert not db.erledigt(aid) and all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid)
db.erledigt_setzen(aid, True)
db.erledigt_setzen(aid, False, positionen_aufheben=True)
assert not any(p["abgerechnet"] for p in db.positionen(aid))
def test_migration_v3_fingerabdruck(tmp_path, freenet_csv):
import sqlite3
d = Datenbank(tmp_path / "x.db")
aid = d.abrechnung_neu("Alt")
d.importieren(aid, lies_datei(freenet_csv))
d.close()
con = sqlite3.connect(tmp_path / "x.db") # wie eine v3-Datenbank: ohne Fingerabdrücke
con.execute("UPDATE position SET fp=NULL")
con.commit()
con.close()
d = Datenbank(tmp_path / "x.db")
assert all(p["fp"] for p in d.positionen(aid))
assert len({p["fp"] for p in d.positionen(aid)}) == 5
d.close()