16 Commits
Author SHA1 Message Date
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
adminandClaude Opus 5.5 c0b740db06 Tests: Warten auf das sichtbare Hauptfenster als Schritt im Ablauf statt eigener update()-Schleife
Auf dem langsamen Runner öffnete sich währenddessen der modale Einrichtungs-Assistent und hielt die
Schleife fest (Hänger). Als Tk-Callback-Schritt läuft das Warten auch bei offenem Dialog weiter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:01:57 +01:00
adminandClaude Opus 5.5 03c5eaf5da Build: harte Zeitgrenzen für Tests (90 s je Test, 15 min gesamt, Job 30 min), Tests ausführlich protokollieren
Ein Test hing auf dem Runner fast eine Stunde in einem Systemaufruf, wo der Tk-Watchdog der GUI-Tests
nicht greift. pytest-timeout bricht ab und gibt den Aufrufstapel aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:52:24 +01:00
adminandClaude Opus 5.5 dcb1d0378c Eigene Kennungen ausblenden, Win95-Menüs, verschiebbares Hauptfenster bei offenem Dialog u. a.
Neu:
- Eigene Kennungen (eigene Filiale) ausblenden: nicht angezeigt, nicht mitgerechnet, nicht
  exportiert; per Rechtsklick, Händler-Menü oder Import-Bericht; Händler-Zuordnung hat Vorrang
- Menüs im Win95-Stil: wechseln beim Überfahren der Menüleiste, Tastatur, Kontextmenü gleich
- Dialoge sperren nur den Inhalt: Hauptfenster bleibt verschiebbar/maximierbar; Dialoge haben
  eigenen Taskleisten-Eintrag; Klick auf gesperrten Inhalt holt den Dialog nach vorn (blinkt)
- Trennlinie zwischen Händler- und Positionsliste sichtbar und ziehbar, Position wird gemerkt
- Strg+Rücktaste / Strg+Entf löschen ganze Wörter in allen Eingabefeldern

Behoben:
- Gedrückte Knöpfe zeigten keine Beschriftung (activeForeground gehört in Tk zur Klasse Background)
- Tipp des Tages: Text lief aus dem sichtbaren Bereich
- Größenänderung: breiterer Griffbereich mit Ecken, Zeiger wird zuverlässig zurückgesetzt,
  flüssiger; Verschieben über Windows selbst (inkl. Andocken), Minimieren über Windows
- Zeichen, die MS Sans Serif nicht hat (…, ▸, →, •), durch Win95-übliche ersetzt
- Menü/Tooltip/Dialog: Größe+Position zusammen setzen (Tk ignoriert sonst die Position)
- Suche reagiert erst nach kurzer Tipp-Pause (flüssigeres Tippen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:58:18 +01:00
64 changed files with 4523 additions and 494 deletions

No files matched your search

+40 -19
View File
@@ -1,7 +1,7 @@
# Baut die Windows-Version (Provisionsmanager95.exe) mit Windows-Python unter Wine.
# - jeder Push / PR: Tests + .exe als Build-Artefakt
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip
name: Windows-Build
# Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version.
# - jeder Push / PR: Tests + Programmdateien als Build-Artefakte
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip, Setup und Linux-.tar.gz
name: Build
on:
push:
@@ -14,6 +14,7 @@ jobs:
windows-exe:
# Label ggf. an euren Runner anpassen (Runner braucht Docker; Wine steckt im Container-Image).
runs-on: ubuntu-latest
timeout-minutes: 30 # normal ca. 5 Minuten – ein Hänger darf den Runner nicht stundenlang belegen
container:
image: tobix/pywine:3.12
env:
@@ -22,7 +23,7 @@ jobs:
- name: Werkzeuge für Checkout/Upload installieren
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates >/dev/null
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xdotool >/dev/null
- uses: actions/checkout@v4
@@ -36,24 +37,44 @@ jobs:
path: |
dist/Provisionsmanager95.exe
dist/Provisionsmanager95-*.zip
dist/Provisionsmanager-Setup-*.exe
- name: Release erstellen
- name: Release erstellen / 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: |
set -eu
VERSION=$(cat dist/VERSION | tr -d '\r\n')
if [ "v$VERSION" != "$TAG" ]; then
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1
fi
resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}")
id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
echo "hochgeladen: $f"
done
V=$(tr -d '\r\n' < dist/VERSION)
build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$V.zip dist/Provisionsmanager-Setup-$V.exe
linux:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: python:3.12-bookworm
steps:
- name: Werkzeuge installieren
run: |
apt-get update -qq
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
/*.jpg
/*.png
.claude/
+58 -9
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
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
Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler
@@ -18,12 +28,18 @@ normale Windows-Titelleiste verwendet.
## 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`)
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder**
„Vodafone (Name in GROSSBUCHSTABEN)“: alle Vodafone-Positionen, deren Kundenname komplett
großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur ein
Händler haben und hat Vorrang vor der Vo-Nr.
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder** das Häkchen
„Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN“: alle Vodafone-Positionen, deren Kundenname
komplett großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur
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.
Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…**
(feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“).
@@ -32,14 +48,22 @@ normale Windows-Titelleiste verwendet.
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter
„(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen.
4. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach
4. **Eigene Filiale ausblenden**: Positionen eurer eigenen Kennungen (z. B. freenet `11111100`,
Vodafone `80000001`) müssen nicht verprovisioniert werden. Rechtsklick auf eine nicht zugeordnete
Gruppe ▸ *Kennung als eigene ausblenden* (oder *Händler ▸ Eigene Kennungen…* bzw. direkt im
Import-Bericht). Sie verschwinden aus Liste, Summen und Export und stehen grau unter
„(Eigene – ausgeblendet)“. Händler-Zuordnungen (auch GROSSBUCHSTABEN) haben Vorrang.
5. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach
Provisionsart gruppiert.
- Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch
als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe).
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
- Rechtsklick: u. a. „Auszahlung 0 €“.
5. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage:
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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -48,6 +72,28 @@ normale Windows-Titelleiste verwendet.
Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile.
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`).
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
@@ -85,7 +131,8 @@ Aufbau:
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
| `provtool/export.py` | Excel-Export im Zielformat |
| `provtool/money.py` | Beträge (intern immer in Cent) |
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) |
| `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/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
@@ -100,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
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
`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,
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
+53 -13
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12).
# Ergebnis: dist/Provisionsmanager95.exe und dist/Provisionsmanager95-<version>.zip
# Ergebnis: dist/Provisionsmanager95.exe, dist/Provisionsmanager95-<version>.zip und
# dist/Provisionsmanager-Setup-<version>.exe (Inno Setup, Installation pro Benutzer)
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -26,7 +27,11 @@ $PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt
echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) =="
# PROVTOOL_GUI_TESTS=1: fehlt der Bildschirm, ist das ein Fehler (nicht „übersprungen“)
PROVTOOL_GUI_TESTS=1 $PY -m pytest -q -p no:cacheprovider
# -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
echo "== Maustest (echte Maus: Fenster ziehen, Rand ziehen, maximiert ziehen) =="
PY="$PY" timeout 300 bash build/maustest.sh
echo "== Version =="
VERSION=$($PY build/version_info.py | tr -d '\r')
@@ -36,19 +41,24 @@ echo "== PyInstaller =="
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
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 =="
rm -f selbsttest.txt
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"
selbsttest dist/Provisionsmanager95.exe
cp README.md dist/LIESMICH.md 2>/dev/null || true
( cd dist && rm -f Provisionsmanager95-*.zip && \
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
@@ -56,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()" \
"Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md )
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
+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
# Aufruf (unter Windows oder Wine): pyinstaller --noconfirm build/provisionsmanager.spec
# PyInstaller-Spezifikation – erzeugt eine einzelne Programmdatei (Windows: .exe, Linux: ELF),
# unter Windows zusätzlich den Ordner dist/Provisionsmanager/ (schneller Start, Inhalt des Setups)
# Aufruf: pyinstaller --noconfirm build/provisionsmanager.spec
import os
import sys
WIN = sys.platform.startswith("win")
ROOT = os.path.abspath(os.path.join(SPECPATH, ".."))
@@ -8,7 +12,8 @@ a = Analysis(
[os.path.join(ROOT, "build", "start.py")],
pathex=[ROOT],
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"],
noarchive=False,
)
@@ -20,11 +25,27 @@ exe = EXE(
a.datas,
[],
name="Provisionsmanager95",
icon=os.path.join(ROOT, "assets", "app.ico"),
version=os.path.join(ROOT, "build", "version_info.txt"),
icon=os.path.join(ROOT, "assets", "app.ico") if WIN else None,
version=os.path.join(ROOT, "build", "version_info.txt") if WIN else None,
console=False,
upx=False,
debug=False,
strip=False,
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."""
__version__ = "1.1.3"
__version__ = "1.7.0"
APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach"
+2 -1
View File
@@ -35,7 +35,8 @@ DEFAULTS = {
"import_ordner": None,
"export_kopfzeile": False,
"export_summe": True,
"export_pro_anbieter": True,
"export_anbieter_getrennt": False, # freenet + Vodafone in einer Datei
"export_null_weglassen": True,
}
+324 -36
View File
@@ -1,6 +1,7 @@
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
from __future__ import annotations
import hashlib
import json
import shutil
import sqlite3
@@ -8,10 +9,11 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from provtool import regeln
from provtool.importer import ImportErgebnis
from provtool.money import apply_percent
SCHEMA_VERSION = 2
SCHEMA_VERSION = 4
SCHEMA = """
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 '',
notiz TEXT DEFAULT '',
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
@@ -45,6 +55,14 @@ CREATE TABLE IF NOT EXISTS regel (
UNIQUE (haendler_id, provisionsart)
);
-- Eigene Kennungen (eigene Filiale): diese Positionen werden ausgeblendet und nicht verprovisioniert.
CREATE TABLE IF NOT EXISTS eigen (
id INTEGER PRIMARY KEY,
anbieter TEXT NOT NULL,
wert TEXT NOT NULL,
UNIQUE (anbieter, wert)
);
CREATE TABLE IF NOT EXISTS abrechnung (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL,
@@ -79,9 +97,18 @@ CREATE TABLE IF NOT EXISTS position (
roh TEXT
);
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
EIGEN = -2 # Pseudo-Händler-ID für Positionen eigener Kennungen (ausgeblendet)
# Sonder-Kennung: Vodafone-Positionen, deren Kundenname komplett in GROSSBUCHSTABEN steht
# (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr.
@@ -89,6 +116,27 @@ CAPS_ANBIETER = "vodafone_caps"
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:
buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"]
return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben)
@@ -103,6 +151,8 @@ class Haendler:
notiz: str = ""
standard_prozent: float = 100.0
aktiv: bool = True
airtime_prozent: float = 0.0
staffel: list | None = None # None = Standard-Staffel
def _now() -> str:
@@ -126,9 +176,33 @@ class Datenbank:
if "haendler_fest" not in spalten:
# NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner)
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.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):
self.con.close()
@@ -149,30 +223,34 @@ class Datenbank:
shutil.copyfile(quelle, ziel)
# ------------------------------------------------------------------ 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]:
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name"
return [Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
r["standard_prozent"], bool(r["aktiv"])) for r in self.con.execute(sql)]
return [self._haendler_aus(r) for r in self.con.execute(sql)]
def haendler(self, hid: int) -> Haendler | None:
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
if not r:
return None
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
r["standard_prozent"], bool(r["aktiv"]))
return self._haendler_aus(r) if r else None
def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
"""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:
if h.id:
self.con.execute(
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=? WHERE id=?",
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv), h.id))
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=?, "
"airtime_prozent=?, staffel=? WHERE id=?", werte + (h.id,))
hid = h.id
else:
cur = self.con.execute(
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv) VALUES (?,?,?,?,?,?)",
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv)))
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv, "
"airtime_prozent, staffel) VALUES (?,?,?,?,?,?,?,?)", werte)
hid = cur.lastrowid
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
for anbieter, wert in kennungen:
@@ -206,15 +284,31 @@ class Datenbank:
kmap = self.kennung_map()
caps = kmap.get((CAPS_ANBIETER, CAPS_WERT))
aktive = {h.id for h in self.haendler_liste(nur_aktive=True)}
eigene = set(self.eigene_kennungen())
def finde(anbieter: str, kennung: str, name: str | None = None, fest: int | None = None) -> int:
if fest is not None:
return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET
if caps and anbieter == "vodafone" and ist_grossgeschrieben(name):
return caps
return kmap.get((anbieter, kennung), UNZUGEORDNET)
hid = kmap.get((anbieter, kennung), UNZUGEORDNET)
if hid == UNZUGEORDNET and (anbieter, kennung) in eigene:
return EIGEN
return hid
return finde
# ------------------------------------------------------------------ eigene Kennungen
def eigene_kennungen(self) -> list[tuple[str, str]]:
return [(r[0], r[1]) for r in self.con.execute("SELECT anbieter, wert FROM eigen ORDER BY anbieter, wert")]
def eigene_setzen(self, anbieter: str, wert: str, eigen: bool = True) -> None:
wert = (wert or "").strip()
with self.con:
if eigen:
self.con.execute("INSERT OR IGNORE INTO eigen (anbieter, wert) VALUES (?,?)", (anbieter, wert))
else:
self.con.execute("DELETE FROM eigen WHERE anbieter=? AND wert=?", (anbieter, wert))
# ------------------------------------------------------------------ Regeln
def regeln(self, hid: int) -> dict[str, float]:
return {r[0]: r[1] for r in self.con.execute(
@@ -229,8 +323,53 @@ class Datenbank:
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.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:
if hid == UNZUGEORDNET:
if hid <= 0: # nicht zugeordnet / eigene Filiale
return 100.0
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
(hid, provisionsart)).fetchone()
@@ -249,6 +388,7 @@ class Datenbank:
def abrechnungen(self) -> list[sqlite3.Row]:
return list(self.con.execute(
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
"(SELECT 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 "
"FROM abrechnung a ORDER BY a.geaendert DESC"))
@@ -272,12 +412,15 @@ class Datenbank:
(sha256,))]
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:
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
finde = self.zuordner()
self._nicht_erledigt(aid)
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
(aid,)).fetchone()[0]
with self.con:
@@ -286,51 +429,133 @@ class Datenbank:
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
did = cur.lastrowid
for i, p in enumerate(erg.positionen, start=start + 1):
hid = finde(p.anbieter, p.kennung, p.name)
proz = self.prozent_fuer(hid, p.provisionsart)
self.con.execute(
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)",
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,0,?)",
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung,
p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name,
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent,
apply_percent(p.betrag_cent, proz), proz, json.dumps(p.roh, ensure_ascii=False, default=str)))
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent,
json.dumps(p.roh, ensure_ascii=False, default=str)))
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)
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()
fest = self.art_typen()
schon = dict(self.con.execute("SELECT fp, abrechnung FROM abgerechnet_fp").fetchall())
out = []
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
d = dict(r)
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)
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:
"""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."""
pids = self._offene(pids)
with self.con:
for pid in pids:
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
finde = self.zuordner()
with self.con:
for pid in pids:
r = self.con.execute("SELECT * FROM position WHERE id=?", (pid,)).fetchone()
if r is None or r["manuell"]:
continue
neu_hid = finde(r["anbieter"], r["kennung"], r["name"], r["haendler_fest"])
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))
for aid in self._abrechnungen_von(pids):
self._regeln_anwenden(aid, set(pids))
def regeln_wiederherstellen(self, pids: list[int]) -> None:
"""Ausgewählte Positionen wieder nach den Händler-Regeln berechnen (auch von Hand geänderte)."""
for aid in self._abrechnungen_von(pids):
self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
def betrag_setzen(self, pid: int, cent: int) -> None:
if not self._offene([pid]):
raise AbrechnungErledigt("Die Position ist bereits abgerechnet.")
with self.con:
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
pids = self._offene(pids)
n = 0
with self.con:
for pid in pids:
@@ -345,18 +570,81 @@ class Datenbank:
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an.
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
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
for aid in self._abrechnungen_von(list(ids)):
titel = self.abrechnung(aid)["titel"]
with self.con:
for p in self.positionen(aid):
if p["manuell"] and not auch_manuelle:
if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
continue
proz = self.prozent_fuer(p["haendler_id"], p["provisionsart"])
neu = apply_percent(p["betrag_orig"], proz)
if neu != p["betrag"] or proz != p["prozent"] or p["manuell"]:
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?", (neu, proz, p["id"]))
self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE 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
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:
with self.con:
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
+54 -14
View File
@@ -2,7 +2,10 @@
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN |
G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode
Sortierung: Zeitraum → Provisionsart → Datum (sonst Reihenfolge der Abrechnung).
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
@@ -12,6 +15,7 @@ from pathlib import Path
from provtool.importer import ANBIETER
from provtool.money import to_euro
from provtool.regeln import AIRTIME
SPALTEN = [
("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"
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,
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
from openpyxl.styles import Font
from openpyxl.utils import get_column_letter
@@ -59,9 +82,9 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
zeile += 1
erste = zeile
for p in sortiert(positionen):
for p in sortiert(positionen) + list(schluss or []):
werte = [
p.get("zeitraum") or None,
spalte_a(p),
p.get("provisionsart") or None,
_zahl_oder_text(p.get("kennung")),
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):
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"
if isinstance(werte[0], date):
ws.cell(zeile, 1).number_format = "DD.MM.YYYY"
ws.cell(zeile, 5).number_format = "0"
ws.cell(zeile, 6).number_format = "0"
ws.cell(zeile, 11).number_format = EURO_FORMAT
@@ -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, *,
pro_anbieter: bool = True, kopfzeile: bool = False, summe: bool = True) -> list[Path]:
"""Schreibt eine (oder je Anbieter eine) Datei für einen Händler. Gibt die erzeugten Pfade zurück."""
pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
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)
if null_weglassen:
positionen = [p for p in positionen if p["betrag"]]
if not positionen:
return []
gruppen: dict[str, list[dict]] = {}
if pro_anbieter:
for p in positionen:
gruppen.setdefault(p["anbieter"], []).append(p)
else:
gruppen[""] = positionen
gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
def name(*teile) -> str:
return dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t)) + ".xlsx"
out = []
for anbieter, pos in sorted(gruppen.items()):
teile = [haendler_name] + ([ANBIETER.get(anbieter, anbieter)] if anbieter else []) + [titel]
name = dateiname(" - ".join(t for t in teile if t)) + ".xlsx"
out.append(schreibe_xlsx(ordner / name, pos, kopfzeile=kopfzeile, summe=summe))
provision = [p for p in pos if p.get("typ") != AIRTIME]
airtime = [p for p in pos if p.get("typ") == AIRTIME]
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
+559 -159
View File
File diff suppressed because it is too large. Load diff
+36 -30
View File
@@ -7,7 +7,6 @@ from collections import Counter, defaultdict
from pathlib import Path
from tkinter import filedialog, ttk
from provtool import APP_NAME
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
from provtool.gui import win32
from provtool.gui import win95 as w
@@ -18,7 +17,7 @@ DATEITYPEN = [("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
def _nummern(text: str) -> list[str]:
"""'11111102 (6 Pos.), 27518999' → ['11111102', '27518999']"""
"""'11111102 (6 Pos.), 27518999' -> ['11111102', '27518999']"""
out = []
for teil in text.split(","):
m = re.match(r"\s*([A-Za-z0-9]+)", teil)
@@ -33,7 +32,7 @@ class Einrichtungsassistent(w.Dialog):
BREITE, HOEHE = 470, 340
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.dateien: list[str] = []
self.vorschlaege: dict[str, list[str]] = {"freenet": [], "vodafone": []}
@@ -75,9 +74,9 @@ class Einrichtungsassistent(w.Dialog):
self._img = w.make_icon(self, "app", 7)
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))):
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_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")
def _leeren(self):
@@ -100,7 +99,7 @@ class Einrichtungsassistent(w.Dialog):
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 >")
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):
pruefer = getattr(self, f"_pruefe_{self.nr}", None)
@@ -122,18 +121,18 @@ class Einrichtungsassistent(w.Dialog):
if w.frage(self, "Einrichtung abbrechen",
"Möchten Sie die Einrichtung wirklich abbrechen?\n\n"
"Bereits angelegte Händler bleiben gespeichert. Den Assistenten finden Sie jederzeit "
"unter ? ▸ Einrichtungs-Assistent.", default_ja=False):
"unter ? > Einrichtungs-Assistent.", default_ja=False):
self.result = None
self.destroy()
# ---------------------------------------------------------------- Seite 1
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:")
for punkt in ("Speicherort der Daten festlegen",
"Unterhändler mit ihren Kennungen anlegen",
"die erste Abrechnung anlegen und Dateien importieren"):
self._text(f" • {punkt}")
self._text(f" - {punkt}")
self._text("\nDanach zeigt Ihnen ein kurzer Rundgang, wo Sie was finden.\n\n"
"Alle Angaben lassen sich später jederzeit ändern.")
self._text("Klicken Sie auf „Weiter“, um fortzufahren.", fg=w.NAVY)
@@ -158,7 +157,7 @@ class Einrichtungsassistent(w.Dialog):
e = tk.Entry(zeile, textvariable=self.v_db_pfad)
e.pack(side="left", fill="x", expand=True)
e.bind("<Key>", lambda ev: self.v_db_eigen.set(True))
w.knopf(zeile, "Durchsuchen…", self._db_waehlen).pack(side="left", padx=(4, 0))
w.knopf(zeile, "Durchsuchen...", self._db_waehlen).pack(side="left", padx=(4, 0))
self._text("\nTipp: Auf einem Netzlaufwerk können mehrere PCs dieselben Händler nutzen – "
"bitte aber nicht gleichzeitig damit arbeiten.", fg=w.SHADOW)
@@ -222,7 +221,7 @@ class Einrichtungsassistent(w.Dialog):
k = tk.Frame(self.seite, bg=w.FACE)
k.pack(fill="x", pady=(4, 4))
w.knopf(k, "Händler hinzufügen", self._haendler_dazu, self.app.icons("haendler")).pack(side="left")
w.knopf(k, "Nummern aus Abrechnung vorschlagen…", self._vorschlagen).pack(side="left", padx=6)
w.knopf(k, "Nummern aus Abrechnung vorschlagen...", self._vorschlagen).pack(side="left", padx=6)
tk.Label(self.seite, text="Bereits angelegt:", anchor="w").pack(fill="x")
lf = tk.Frame(self.seite, bg=w.FACE)
@@ -313,7 +312,7 @@ class Einrichtungsassistent(w.Dialog):
w.warnung(self, "Nummern vorschlagen", "Nicht lesbar:\n" + "\n".join(fehler))
else:
w.info(self, "Nummern vorschlagen",
"Die gefundenen Nummern stehen jetzt in den Auswahllisten (▼) bei freenet und Vodafone – "
"Die gefundenen Nummern stehen jetzt in den Auswahllisten (Pfeil-Knopf rechts) bei freenet und Vodafone – "
"sortiert nach Anzahl der Positionen.\n\nDie Nummer mit den meisten Positionen ist meist "
"Ihre eigene Filiale.\n\nDie Dateien werden im nächsten Schritt gleich mit importiert.")
@@ -349,7 +348,7 @@ class Einrichtungsassistent(w.Dialog):
self.lb_d.pack(side="left", fill="both", expand=True)
kf = tk.Frame(lf, bg=w.FACE)
kf.pack(side="left", anchor="n", padx=(6, 0))
self._b_dazu = w.knopf(kf, "Hinzufügen…", self._datei_dazu, breite=11)
self._b_dazu = w.knopf(kf, "Hinzufügen...", self._datei_dazu, breite=11)
self._b_dazu.pack(pady=(0, 4))
self._b_weg = w.knopf(kf, "Entfernen", self._datei_weg, breite=11)
self._b_weg.pack()
@@ -384,13 +383,13 @@ class Einrichtungsassistent(w.Dialog):
def _s_fertig(self):
self._ueberschrift("Fertig!")
n = len(self.app.db.haendler_liste())
self._text(f"Der {APP_NAME} ist eingerichtet.")
self._text(f" • {n} Händler angelegt")
self._text(f"Der {w.APP_NAME} ist eingerichtet.")
self._text(f" - {n} Händler angelegt")
if self.v_abrechnung.get():
self._text(f" • Abrechnung „{self.v_titel.get().strip()}“"
self._text(f" - Abrechnung „{self.v_titel.get().strip()}“"
+ (f" mit {len(self.dateien)} Datei(en)" if self.dateien else " (noch ohne Dateien)"))
self._text("\nSo geht es danach jeden Monat weiter:\n"
" Neu → Import → prüfen & anpassen → Export")
" Neu > Import > prüfen & anpassen > Export")
tk.Checkbutton(self.seite, text="Kurzen Rundgang durch das Programm starten", variable=self.v_rundgang
).pack(anchor="w", pady=(12, 0))
self._text("\nKlicken Sie auf „Fertig stellen“.", fg=w.NAVY)
@@ -537,23 +536,27 @@ class Rundgang:
# =========================================================================== Tipp des Tages
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 "
"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 % "
"auszuzahlen. Mit dem Häkchen „Als Standard merken“ gilt das automatisch auch in den nächsten Monaten.",
"Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit ↓ geht es "
"Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit Pfeil runter geht es "
"gleich in die nächste Zeile.",
"Blau dargestellte Beträge wurden von Hand geändert, rote weichen durch eine Prozent-Regel von der "
"Provision ab.",
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick ▸ „Auszahlung 0 €“.",
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick ▸ „Händler zuweisen…“ – diese "
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick > „Auszahlung 0 €“.",
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick > „Händler zuweisen...“ – diese "
"Zuordnung hat immer Vorrang.",
"Unter „(Nicht zugeordnet)“ sehen Sie alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
"Diese Positionen werden nicht exportiert.",
"Mit Strg+F springen Sie ins Suchfeld. Gesucht wird in Rufnummer, Name, Tarif, Provisionsart und mehr.",
"Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei ▸ Importierte Dateien… – dort lässt "
"Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei > Importierte Dateien... – dort lässt "
"sie sich wieder entfernen.",
"Erstellen Sie ab und zu eine Datensicherung: Datei ▸ Datensicherung erstellen…",
"Erstellen Sie ab und zu eine Datensicherung: Datei > Datensicherung erstellen...",
"Alle Änderungen werden sofort gespeichert. Ein „Speichern“-Knopf ist daher nicht nötig.",
"Ein Händler wurde erst nach dem Import angelegt? Kein Problem – die Zuordnung passt sich sofort an.",
]
@@ -578,18 +581,21 @@ class TippDesTages(w.Dialog):
haupt.pack(padx=12, pady=12)
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
box.pack(side="left", fill="both")
self._bild = tipp_bild(self)
self.bild_ok = self._bild is not None
if self._bild is None: # Ersatz, falls das Bild fehlt
# Büroklammer nur im Win95-Design; modern eine schlichte Glühbirne
self._bild = None if w.MODERN else tipp_bild(self)
self.bild_ok = self._bild is not None or w.MODERN
if self._bild is None:
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)
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10)
# 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=max(self._bild.height(), 170) + 20)
rechts.pack(side="left", fill="both")
tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
rechts.pack_propagate(False)
tk.Label(rechts, text="Wussten Sie schon...", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
anchor="w").pack(fill="x")
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6)
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=8)
self.text.pack(fill="both")
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=380 - 2 * 12)
self.text.pack(fill="both", expand=True)
knoepfe = tk.Frame(haupt, bg=w.FACE)
knoepfe.pack(side="left", anchor="n", padx=(10, 0))
w.knopf(knoepfe, "Schließen", self.abbrechen, breite=12, default="active").pack(pady=(0, 6))
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

+443 -64
View File
@@ -4,16 +4,18 @@ from __future__ import annotations
import tkinter as tk
from tkinter import filedialog, ttk
from provtool import APP_NAME, APP_ORG, __version__
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler
from provtool import APP_ORG, __version__
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.importer import ANBIETER
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
# Auswahl im Händler-Dialog: Schlüssel → Anzeige
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)",
CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"}
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
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
@@ -51,6 +53,7 @@ class HaendlerDialog(w.Dialog):
g.pack(fill="x")
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar()
self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
self.v_air = tk.StringVar(value="0")
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
("E-Mail:", self.v_mail))):
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
@@ -61,14 +64,20 @@ class HaendlerDialog(w.Dialog):
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left")
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e")
tk.Label(g, text="Notiz:", anchor="nw").grid(row=4, column=0, sticky="nw", pady=2)
self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT)
self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2)
tk.Label(g, text="Airtime:", anchor="w").grid(row=4, column=0, sticky="w", pady=2)
af = tk.Frame(g, bg=w.FACE)
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)
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
g2.pack(fill="both", expand=True, pady=(8, 0))
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=4,
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=3,
selectmode="browse")
self.kliste.heading("anbieter", text="Anbieter")
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())
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
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.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("proz", text="Auszahlung")
self.rliste.column("art", width=240)
@@ -100,7 +126,7 @@ class HaendlerDialog(w.Dialog):
r3.pack(fill="both", expand=True)
rf = tk.Frame(g3, bg=w.FACE)
rf.pack(fill="x", pady=(4, 0))
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent…“.", fg=w.SHADOW).pack(side="left")
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent...“.", fg=w.SHADOW).pack(side="left")
w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
unten = tk.Frame(self, bg=w.FACE)
@@ -112,9 +138,14 @@ class HaendlerDialog(w.Dialog):
self._liste_laden(auswahl_id)
if neue_kennung:
self._neu()
if neue_kennung[0] == CAPS_ANBIETER:
self.v_caps.set(True)
else:
self._kennungen = [neue_kennung]
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
@staticmethod
@@ -122,9 +153,42 @@ class HaendlerDialog(w.Dialog):
return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige)
def _art_gewaehlt(self):
caps = self._anbieter_key(self.v_kanb.get()) == CAPS_ANBIETER
self.v_kwert.set("")
self._ke.configure(state="disabled" if caps else "normal")
self._ke.focus_set()
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):
self.liste.delete(*self.liste.get_children())
@@ -150,19 +214,22 @@ class HaendlerDialog(w.Dialog):
self.v_ap.set(h.ansprechpartner)
self.v_mail.set(h.email)
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.t_notiz.delete("1.0", "end")
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._regeln_zeigen()
def _kennungen_zeigen(self):
self.kliste.delete(*self.kliste.get_children())
for i, (a, v) in enumerate(self._kennungen):
if a == CAPS_ANBIETER:
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):
@@ -177,15 +244,20 @@ class HaendlerDialog(w.Dialog):
for v in (self.v_name, self.v_ap, self.v_mail):
v.set("")
self.v_proz.set("100")
self.v_air.set("0")
self.v_aktiv.set(True)
self.t_notiz.delete("1.0", "end")
self._kennungen = []
self._caps_einstellen(0, False)
self._eigene_staffel = None
self.v_eigen_staffel.set(False)
self._staffel_zeigen()
self._kennungen_zeigen()
self._regeln_zeigen()
def _kennung_dazu(self):
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:
return
if (anb, wert) not in self._kennungen:
@@ -216,12 +288,21 @@ class HaendlerDialog(w.Dialog):
except ValueError:
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
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():
self._kennung_dazu()
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get())
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:
hid = self.db.haendler_speichern(h, self._kennungen)
hid = self.db.haendler_speichern(h, kennungen)
except Exception as exc: # sqlite3.IntegrityError / ValueError
msg = str(exc)
if "UNIQUE" in msg and "haendler.name" in msg:
@@ -292,7 +373,7 @@ class ProzentDialog(w.Dialog):
from provtool.money import apply_percent
try:
p = parse_percent(self.v.get())
self.vorschau.configure(text=f"→ Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
self.vorschau.configure(text=f"-> Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
except ValueError:
self.vorschau.configure(text="(ungültiger Wert)")
@@ -309,16 +390,184 @@ class ProzentDialog(w.Dialog):
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
class AbrechnungenDialog(w.Dialog):
def __init__(self, master, db: Datenbank, aktuelle_id: int | None):
super().__init__(master, "Abrechnung öffnen", resizable=True)
def __init__(self, master, db: Datenbank, aktuelle_id: int | None, fuer_import: bool = False):
"""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
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings",
height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120),
tk.Label(self, text="In welche Abrechnung sollen die Dateien importiert werden?" if fuer_import
else "Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
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)):
self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
@@ -326,8 +575,12 @@ class AbrechnungenDialog(w.Dialog):
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
if fuer_import:
w.knopf(k, "Hierhin importieren", self._oeffnen, default="active").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, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
self.bind("<Return>", lambda e: self._oeffnen())
@@ -336,8 +589,9 @@ class AbrechnungenDialog(w.Dialog):
def _laden(self, sel=None):
self.tree.delete(*self.tree.get_children())
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"]),
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()
if sel and self.tree.exists(str(sel)):
self.tree.selection_set(str(sel))
@@ -353,6 +607,10 @@ class AbrechnungenDialog(w.Dialog):
self.result = ("oeffnen", self._sel())
self.destroy()
def _neu(self):
self.result = ("neu",)
self.destroy()
def _umbenennen(self):
aid = self._sel()
if not aid:
@@ -410,7 +668,11 @@ class DateienDialog(w.Dialog):
name = self.tree.item(s[0], "values")[0]
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
try:
self.db.datei_entfernen(int(s[0]))
except AbrechnungErledigt as exc:
w.info(self, "Datei entfernen", str(exc))
return
self.geaendert = True
self._laden()
@@ -440,7 +702,7 @@ class ImportBericht(w.Dialog):
tk.Label(self, text="Nicht zugeordnete Kennungen (kein Händler hinterlegt – z. B. eigene Filiale):",
anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings",
height=min(6, len(unbekannt)), selectmode="browse")
height=min(6, len(unbekannt)), selectmode="extended")
for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
self.ut.heading(c, text=h)
@@ -451,14 +713,80 @@ class ImportBericht(w.Dialog):
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
if unbekannt:
w.knopf(k, "Kennung einem Händler zuordnen…", self._zuordnen).pack(side="left")
w.knopf(k, "Kennung einem Händler zuordnen...", self._zuordnen).pack(side="left")
w.knopf(k, "Als eigene ausblenden...", self._eigen).pack(side="left", padx=6)
w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
self.bind("<Return>", lambda e: self.abbrechen())
def _zuordnen(self):
s = self.ut.selection()
if s:
self.result = tuple(s[0].split("\t", 1))
a, k = s[0].split("\t", 1)
self.result = ("zuordnen", a, k)
self.destroy()
def _eigen(self):
s = self.ut.selection()
if s:
self.result = ("eigen", [tuple(x.split("\t", 1)) for x in s])
self.destroy()
class EigeneKennungenDialog(w.Dialog):
"""Kennungen der eigenen Filiale: Positionen damit werden ausgeblendet und nicht exportiert."""
def __init__(self, master, db: Datenbank):
super().__init__(master, "Eigene Kennungen")
self.db, self.geaendert = db, False
tk.Label(self, text="Positionen mit diesen Kennungen gehören zur eigenen Filiale. Sie werden\n"
"ausgeblendet, nicht mitgerechnet und nicht exportiert.\n"
"(Ist eine Kennung einem Händler zugeordnet, hat der Händler Vorrang.)",
justify="left", anchor="w").pack(fill="x", padx=10, pady=(10, 4))
r, self.tree = w.scroll_tree(self, columns=("anbieter", "wert"), show="headings", height=7,
selectmode="extended")
self.tree.heading("anbieter", text="Anbieter")
self.tree.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
self.tree.column("anbieter", width=100)
self.tree.column("wert", width=200)
r.pack(fill="both", expand=True, padx=10)
z = tk.Frame(self, bg=w.FACE)
z.pack(fill="x", padx=10, pady=6)
self.v_anb = tk.StringVar(value="freenet")
ttk.Combobox(z, textvariable=self.v_anb, values=list(ANBIETER.values()), width=10,
state="readonly").pack(side="left")
self.v_wert = tk.StringVar()
e = tk.Entry(z, textvariable=self.v_wert, width=18)
e.pack(side="left", padx=4)
e.bind("<Return>", lambda ev: self._dazu())
w.knopf(z, "Hinzufügen", self._dazu).pack(side="left")
w.knopf(z, "Entfernen", self._weg).pack(side="left", padx=4)
self.knopfleiste([("Schließen", self.abbrechen)]).pack(pady=(0, 10))
self._laden()
def _laden(self):
self.tree.delete(*self.tree.get_children())
for a, k in self.db.eigene_kennungen():
self.tree.insert("", "end", iid=f"{a}\t{k}", values=(ANBIETER.get(a, a), k))
def _dazu(self):
wert = self.v_wert.get().strip()
if not wert:
return
anb = next((k for k, v in ANBIETER.items() if v == self.v_anb.get()), self.v_anb.get())
self.db.eigene_setzen(anb, wert, True)
self.v_wert.set("")
self.geaendert = True
self._laden()
def _weg(self):
for iid in self.tree.selection():
a, k = iid.split("\t", 1)
self.db.eigene_setzen(a, k, False)
self.geaendert = True
self._laden()
def abbrechen(self):
self.result = self.geaendert
self.destroy()
@@ -473,7 +801,7 @@ class KennungZuordnen(w.Dialog):
self.v = tk.StringVar(value="__neu__")
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="x", padx=20)
tk.Radiobutton(f, text="Neuem Händler …", variable=self.v, value="__neu__").pack(anchor="w")
tk.Radiobutton(f, text="Neuem Händler ...", variable=self.v, value="__neu__").pack(anchor="w")
haendler = db.haendler_liste()
if haendler:
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w")
@@ -515,7 +843,7 @@ class HaendlerWaehlen(w.Dialog):
self.cb = ttk.Combobox(self, values=list(self._hmap) + [self.KEINER], state="readonly", width=40)
self.cb.pack(padx=12, pady=4)
tk.Label(self, text="Die feste Zuweisung hat Vorrang vor Kennungen und der GROSSBUCHSTABEN-Regel.\n"
"Rückgängig: Rechtsklick ▸ „Automatisch zuordnen“.", justify="left",
"Rückgängig: Rechtsklick > „Automatisch zuordnen“.", justify="left",
fg=w.SHADOW).pack(fill="x", padx=12)
self.bind("<Return>", lambda e: self._ok())
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
@@ -540,13 +868,18 @@ class ExportDialog(w.Dialog):
"""haendler: (id, name, anzahl, summe_cent)"""
super().__init__(master, "Abrechnungen exportieren")
self.settings = settings
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=10, pady=10)
rumpf = tk.Frame(self, bg=w.FACE)
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")
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")
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")):
self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor=a)
@@ -563,7 +896,7 @@ class ExportDialog(w.Dialog):
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
g = w.gruppe(f, "Optionen")
g = w.gruppe(rechts, "Optionen")
g.pack(fill="x", pady=(8, 0))
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
self.v_titel = tk.StringVar(value=titel)
@@ -571,22 +904,55 @@ class ExportDialog(w.Dialog):
tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
w.knopf(g, "Durchsuchen…", self._ordner).grid(row=1, column=2, padx=(4, 0))
self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True))
w.knopf(g, "Durchsuchen...", self._ordner).grid(row=1, column=2, padx=(4, 0))
# 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_sum = tk.BooleanVar(value=settings.get("export_summe", True))
self.v_auf = tk.BooleanVar(value=True)
tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei",
variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w")
tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3,
sticky="w")
tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0,
columnspan=3, sticky="w")
tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0,
columnspan=3, sticky="w")
for zeile, (text, var) in enumerate((
("freenet und Vodafone in getrennte Dateien (sonst beide in einer)", self.v_anb),
("Positionen mit 0,00 € weglassen", self.v_null),
("Kopfzeile mit Spaltennamen", self.v_kopf),
("Summenzeile unter der Tabelle", self.v_sum),
("Ordner nach dem Export öffnen", self.v_auf)), start=2):
tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, 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)
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))
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):
if self.tree.identify_column(e.x) == "#0":
iid = self.tree.identify_row(e.y)
@@ -613,15 +979,26 @@ class ExportDialog(w.Dialog):
if not ids:
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
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():
self._ordner()
if not self.v_ordner.get().strip():
return
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(),
export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get())
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_anbieter_getrennt=self.v_anb.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(),
pro_anbieter=self.v_anb.get(), kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(),
oeffnen=self.v_auf.get())
pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.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()
@@ -637,16 +1014,18 @@ class EinstellungenDialog(w.Dialog):
row=0, column=0, columnspan=2, sticky="w")
self.v = tk.StringVar(value=db_pfad)
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4)
w.knopf(g, "Ändern…", self._waehlen).grid(row=1, column=1, padx=(4, 0))
w.knopf(g, "Ändern...", self._waehlen).grid(row=1, column=1, padx=(4, 0))
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
g2 = w.gruppe(self, "Darstellung")
g2.pack(fill="x", padx=10, pady=(0, 10))
self.v_klassisch = tk.BooleanVar(value=bool(settings.get("klassischer_rahmen", True)))
tk.Checkbutton(g2, text="Windows-95-Titelleiste verwenden (sonst die normale von Windows)",
variable=self.v_klassisch).pack(anchor="w")
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w")
self.v_design = tk.StringVar(value=settings.get("design", "modern"))
tk.Radiobutton(g2, text="Modern hell", variable=self.v_design, value="modern").pack(anchor="w")
tk.Radiobutton(g2, text="Modern dunkel", variable=self.v_design, value="dunkel").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))
def _waehlen(self):
@@ -657,7 +1036,7 @@ class EinstellungenDialog(w.Dialog):
self.v.set(p)
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()
@@ -665,7 +1044,7 @@ class EinstellungenDialog(w.Dialog):
class InfoDialog(w.Dialog):
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.pack(padx=14, pady=14)
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
@@ -674,7 +1053,7 @@ class InfoDialog(w.Dialog):
c.create_image(57, 57, image=self._img)
rechts = tk.Frame(top, bg=w.FACE)
rechts.pack(side="left", padx=(14, 0), anchor="n")
tk.Label(rechts, text=APP_NAME, font=w.FONT_BIG).pack(anchor="w")
tk.Label(rechts, text=w.APP_NAME, font=w.FONT_BIG).pack(anchor="w")
tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w")
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
+305
View File
@@ -0,0 +1,305 @@
"""Menüs im Windows-95-Stil (Menüleiste, Aufklapp- und Kontextmenüs).
Die nativen Windows-Menüs sind weiß und wechseln nicht mit, wenn man bei offenem Menü mit der
Maus zum nächsten Menüleisten-Eintrag fährt. Diese Menüs sind selbst gezeichnet:
- grau, Markierung in Navy, Tastenkürzel rechtsbündig, Trennlinien eingelassen
- bei offenem Menü wechselt das Menü beim Überfahren der Menüleiste
- Tastatur: Pfeil hoch/runter wählen, Enter ausführen, Esc schließen, Pfeil links/rechts benachbartes Menü
- Klick daneben oder Wechsel in ein anderes Programm schließt das Menü
Die Schnittstelle (add_command, add_separator, tk_popup) entspricht tk.Menu.
"""
from __future__ import annotations
import tkinter as tk
from provtool.gui import win95 as w
class Popup:
"""Ein Aufklapp- bzw. Kontextmenü."""
def __init__(self, master: tk.Misc):
self.master = master
self.eintraege: list[dict] = []
self.fenster: tk.Toplevel | None = None
self.zeilen: list[tuple[dict, tk.Frame, list[tk.Label]]] = []
self.aktiv = -1
self.leiste: Menueleiste | None = None
self._pruefer = None
# ------------------------------------------------------------- tk.Menu-kompatibel
def add_command(self, label: str, command=None, accelerator: str = "", state: str = "normal"):
self.eintraege.append(dict(art="befehl", label=label, command=command, accelerator=accelerator,
state=state))
def add_separator(self):
self.eintraege.append(dict(art="trenner"))
def tk_popup(self, x: int, y: int):
self.oeffnen(x, y)
# ------------------------------------------------------------- Anzeige
@property
def offen(self) -> bool:
try:
return self.fenster is not None and bool(self.fenster.winfo_exists())
except tk.TclError:
return False
def oeffnen(self, x: int, y: int, leiste: Menueleiste | None = None):
self.schliessen()
self.leiste = leiste
root = self.master._root()
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.overrideredirect(True)
self.zeilen = []
grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2)
for e in self.eintraege:
if e["art"] == "trenner":
t = tk.Frame(f, bg=grund, height=8)
t.pack(fill="x")
linie = w.trenner(t, vertikal=False)
linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
continue
zeile = tk.Frame(f, bg=grund)
zeile.pack(fill="x")
grau = e["state"] == "disabled"
text = tk.Label(zeile, text=e["label"], bg=grund, anchor="w", padx=18, pady=py,
fg=w.SHADOW if grau else w.DARK)
text.pack(side="left", fill="x", expand=True)
labels = [text]
if e["accelerator"]:
acc = tk.Label(zeile, text=e["accelerator"], bg=grund, anchor="e", padx=12, pady=py,
fg=w.SHADOW if grau else w.DARK)
acc.pack(side="right")
labels.append(acc)
idx = len(self.zeilen)
for wdg in [zeile] + labels:
wdg.bind("<Enter>", lambda ev, i=idx: self._markieren(i))
wdg.bind("<ButtonRelease-1>", lambda ev, i=idx: self._ausfuehren(i))
self.zeilen.append((e, zeile, labels))
f.update_idletasks()
bw, bh = f.winfo_reqwidth(), f.winfo_reqheight()
sw, sh = f.winfo_screenwidth(), f.winfo_screenheight()
x = max(0, min(x, sw - bw))
y = max(0, min(y, sh - bh))
# Größe UND Position zusammen setzen: eine reine Positionsangabe ignoriert Tk unter Windows bei
# rahmenlosen Fenstern – das Menü landete sonst oben links in der Bildschirmecke.
f.geometry(f"{bw}x{bh}+{x}+{y}")
f.deiconify()
f.lift()
f.update_idletasks()
try:
from provtool.gui import win32
win32.besitzer_setzen(f, root) # bleibt über dem Hauptfenster
except Exception:
pass
# Alle Maus-Ereignisse der Anwendung an das Menü (lokale Tk-Sperre): Klick daneben schließt,
# Bewegung über die Menüleiste wechselt das Menü.
try:
f.grab_set()
except tk.TclError:
pass
f.focus_force()
f.bind("<ButtonPress>", self._klick)
f.bind("<Motion>", self._bewegung)
f.bind("<Escape>", lambda e: self.schliessen())
f.bind("<Up>", lambda e: self._schritt(-1))
f.bind("<Down>", lambda e: self._schritt(1))
f.bind("<Return>", lambda e: self._ausfuehren(self.aktiv))
f.bind("<KP_Enter>", lambda e: self._ausfuehren(self.aktiv))
f.bind("<Left>", lambda e: self.leiste and self.leiste.nachbar(-1))
f.bind("<Right>", lambda e: self.leiste and self.leiste.nachbar(1))
self.aktiv = -1
self._pruefer = f.after(250, self._fokus_pruefen)
def schliessen(self):
if self._pruefer is not None and self.fenster is not None:
try:
self.fenster.after_cancel(self._pruefer)
except tk.TclError:
pass
self._pruefer = None
if self.offen:
try:
self.fenster.grab_release()
except tk.TclError:
pass
self.fenster.destroy()
self.fenster = None
if self.leiste is not None:
self.leiste._geschlossen(self)
# ------------------------------------------------------------- Bedienung
def _innerhalb(self, x_root: int, y_root: int) -> bool:
"""Liegt der Punkt über diesem Menü? Fragt das Fenstersystem, welches Fenster dort liegt –
die Geometrie rahmenloser Fenster meldet Tk unter Windows nicht zuverlässig."""
f = self.fenster
try:
unter = f.winfo_containing(x_root, y_root)
except (tk.TclError, KeyError):
unter = None
if unter is not None:
return unter.winfo_toplevel() is f
return (f.winfo_rootx() <= x_root < f.winfo_rootx() + f.winfo_width()
and f.winfo_rooty() <= y_root < f.winfo_rooty() + f.winfo_height())
def _klick(self, e):
if self._innerhalb(e.x_root, e.y_root):
return
if self.leiste is not None and self.leiste.knopf_bei(e.x_root, e.y_root) is not None:
knopf = self.leiste.knopf_bei(e.x_root, e.y_root)
if knopf is self.leiste.offener_knopf:
self.schliessen() # Klick auf den eigenen Menütitel klappt zu
else:
self.leiste.oeffnen(knopf)
return "break"
self.schliessen()
return "break"
def _bewegung(self, e):
if self.leiste is None or self._innerhalb(e.x_root, e.y_root):
return
knopf = self.leiste.knopf_bei(e.x_root, e.y_root)
if knopf is not None and knopf is not self.leiste.offener_knopf:
self.leiste.oeffnen(knopf)
def _markieren(self, i: int):
self.aktiv = i
for j, (e, zeile, labels) in enumerate(self.zeilen):
an = j == i and e.get("state") != "disabled"
bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE)
fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
zeile.configure(bg=bg)
for lbl in labels:
lbl.configure(bg=bg, fg=fg)
def _schritt(self, richtung: int):
if not self.zeilen:
return
i = self.aktiv
for _ in range(len(self.zeilen)):
i = (i + richtung) % len(self.zeilen)
if self.zeilen[i][0].get("state") != "disabled":
break
self._markieren(i)
def _ausfuehren(self, i: int):
if not (0 <= i < len(self.zeilen)):
return
e = self.zeilen[i][0]
if e.get("state") == "disabled":
return
self.schliessen()
if e.get("command"):
# nach dem Schließen ausführen (der Befehl öffnet evtl. einen Dialog)
self.master.after_idle(e["command"])
def _fokus_pruefen(self):
"""In ein anderes Programm gewechselt -> Menü schließen."""
self._pruefer = None
if not self.offen:
return
try:
weg = self.fenster.focus_get() is None
except (tk.TclError, KeyError):
weg = False
if weg:
self.schliessen()
else:
self._pruefer = self.fenster.after(250, self._fokus_pruefen)
class Menueleiste(tk.Frame):
"""Graue Menüleiste mit Popup-Menüs (die native von Windows 10/11 ist weiß)."""
def __init__(self, master, root: tk.Misc):
super().__init__(master, bg=w.FACE)
self.root = root
self.knoepfe: dict[str, tk.Label] = {}
self.menues: dict[tk.Label, Popup] = {}
self.offener_knopf: tk.Label | None = None
def menue(self, label: str, underline: int | None = 0) -> Popup:
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)
knopf.pack(side="left")
popup = Popup(self)
self.knoepfe[label] = knopf
self.menues[knopf] = popup
knopf.bind("<ButtonPress-1>", lambda e, k=knopf: self._klick(k))
knopf.bind("<Enter>", lambda e, k=knopf: self._hover(k, True))
knopf.bind("<Leave>", lambda e, k=knopf: self._hover(k, False))
if underline is not None:
taste = label[underline].lower()
self.root.bind_all(f"<Alt-KeyPress-{taste}>", lambda e, k=knopf: self._alt(k))
return popup
# ---- Zustand
def knopf_bei(self, x_root: int, y_root: int) -> tk.Label | None:
for k in self.knoepfe.values():
if (k.winfo_rootx() <= x_root < k.winfo_rootx() + k.winfo_width()
and k.winfo_rooty() <= y_root < k.winfo_rooty() + k.winfo_height()):
return k
return None
def _darstellen(self):
for k in self.knoepfe.values():
if k is self.offener_knopf:
k.configure(bg=w.SEL_BG, fg=w.SEL_FG, relief="flat")
else:
k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
# ---- Bedienung
def oeffnen(self, knopf: tk.Label):
from provtool.gui import modal
if modal.offen():
return
alt = self.offener_knopf
if alt is not None and alt is not knopf:
self.menues[alt].leiste = None # ohne Rückmeldung schließen, wir öffnen gleich das nächste
self.menues[alt].schliessen()
self.offener_knopf = knopf
self._darstellen()
self.menues[knopf].oeffnen(knopf.winfo_rootx(), knopf.winfo_rooty() + knopf.winfo_height(), leiste=self)
def schliessen(self):
if self.offener_knopf is not None:
self.menues[self.offener_knopf].schliessen()
def _geschlossen(self, popup: Popup):
if self.offener_knopf is not None and self.menues.get(self.offener_knopf) is popup:
self.offener_knopf = None
self._darstellen()
def nachbar(self, richtung: int):
liste = list(self.knoepfe.values())
if self.offener_knopf in liste:
i = (liste.index(self.offener_knopf) + richtung) % len(liste)
self.oeffnen(liste[i])
def _klick(self, knopf):
if self.offener_knopf is knopf:
self.schliessen()
else:
self.oeffnen(knopf)
return "break"
def _hover(self, knopf, drin: bool):
if self.offener_knopf is None:
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):
self.oeffnen(knopf)
if self.offener_knopf is knopf:
self.menues[knopf]._schritt(1)
return "break"
+93
View File
@@ -0,0 +1,93 @@
"""Modale Sperre ohne Tk-„grab“: Nur der INHALT des aufrufenden Fensters wird gesperrt.
Titelleiste und Rand bleiben bedienbar – das Hauptfenster lässt sich also verschieben, maximieren
und in der Größe ändern, während ein Dialog offen ist. Klickt man auf den gesperrten Inhalt, kommt
der Dialog nach vorn und blinkt kurz (wie bei Windows).
Technik: Jedes Widget im gesperrten Bereich bekommt vorn das Bindungs-Tag ``Pm95Gesperrt``. Dessen
Bindungen laufen vor allen anderen und beenden Klicks/Tastendrücke mit „break“.
"""
from __future__ import annotations
import tkinter as tk
TAG = "Pm95Gesperrt"
_stapel: list[tk.Toplevel] = [] # offene modale Fenster, innerstes zuletzt
GESPERRTE_EREIGNISSE = ("<ButtonPress>", "<ButtonRelease>", "<Double-ButtonPress>", "<Triple-ButtonPress>",
"<B1-Motion>", "<KeyPress>", "<KeyRelease>", "<MouseWheel>")
def oberstes() -> tk.Toplevel | None:
"""Das zuletzt geöffnete, noch offene modale Fenster (oder None)."""
while _stapel:
d = _stapel[-1]
try:
if d.winfo_exists():
return d
except tk.TclError:
pass
_stapel.pop()
return None
def offen() -> bool:
return oberstes() is not None
def _gesperrt_geklickt(event):
d = oberstes()
if d is not None and hasattr(d, "nach_vorn"):
d.nach_vorn(blinken=str(event.type) in ("ButtonPress", "4"))
return "break"
def _einrichten(root: tk.Misc):
# Merker am Tk-Objekt selbst (nicht am Namen: das Hauptfenster heißt in jeder Tk-Instanz „.“)
if getattr(root, "_pm95_sperre_eingerichtet", False):
return
for seq in GESPERRTE_EREIGNISSE:
root.bind_class(TAG, seq, _gesperrt_geklickt)
root._pm95_sperre_eingerichtet = True
def sperrbereiche(fenster: tk.Misc) -> list[tk.Misc]:
"""Welche Widgets eines Fensters gesperrt werden: alles außer Titelleiste (und Rand)."""
eigene = getattr(fenster, "sperrbereich", None)
if callable(eigene):
return list(eigene())
titel = getattr(fenster, "titelleiste", None)
return [k for k in fenster.winfo_children() if k is not titel and not isinstance(k, tk.Toplevel)]
class Sperre:
"""Sperrt den Inhalt von `fenster`, solange `dialog` offen ist."""
def __init__(self, fenster: tk.Misc, dialog: tk.Toplevel):
_einrichten(fenster._root())
self.widgets: list[tk.Misc] = []
for bereich in sperrbereiche(fenster):
self._sperren(bereich)
_stapel.append(dialog)
self.dialog = dialog
def _sperren(self, w: tk.Misc):
if isinstance(w, tk.Toplevel):
return # eigene Fenster (z. B. andere Dialoge) nicht anfassen
tags = w.bindtags()
if TAG not in tags:
w.bindtags((TAG,) + tuple(tags))
self.widgets.append(w)
for kind in w.winfo_children():
self._sperren(kind)
def aufheben(self):
for w in self.widgets:
try:
if w.winfo_exists():
w.bindtags(tuple(t for t in w.bindtags() if t != TAG))
except tk.TclError:
pass
self.widgets = []
if self.dialog in _stapel:
_stapel.remove(self.dialog)
+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 = []
+181 -39
View File
@@ -3,8 +3,11 @@
Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet
(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben,
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
trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann.
graue Menüleiste.
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
@@ -183,38 +186,34 @@ class Titelleiste(tk.Frame):
def _start(self, e):
self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty())
self._nativ = False
if NATIV_VERSCHIEBEN and IS_WIN and self.ziehen_erlaubt():
# Windows übernimmt das Ziehen: flüssig und mit Andocken am Bildschirmrand
from provtool.gui import win32
self._nativ = win32.nativ_verschieben(self.fenster)
if self._nativ:
return "break"
def _ziehen(self, e):
if not self.ziehen_erlaubt():
if self._nativ or not self.ziehen_erlaubt():
return
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}")
def blinken(self, mal: int = 3):
"""Titelleiste kurz grau/blau blinken lassen (wie Windows bei gesperrtem Fenster)."""
farben = [w.SHADOW, w.NAVY] * mal
class Menueleiste(tk.Frame):
"""Graue Menüleiste (die native von Windows 10/11 ist weiß und lässt sich nicht umfärben)."""
def schritt(i=0):
if i >= len(farben) or not self.winfo_exists():
return
for widget in (self, self.titel_lbl):
widget.configure(bg=farben[i])
self.after(70, lambda: schritt(i + 1))
schritt()
def __init__(self, master, root: tk.Misc):
super().__init__(master, bg=w.FACE)
self.root = root
self.knoepfe: dict[str, tk.Menubutton] = {}
def menue(self, label: str, underline: int | None = 0) -> tk.Menu:
mb = tk.Menubutton(self, text=label, bg=w.FACE, relief="flat", bd=1, padx=6, pady=1,
activebackground=w.NAVY, activeforeground=w.WHITE,
underline=-1 if underline is None else underline)
menu = tk.Menu(mb, tearoff=0)
mb.configure(menu=menu)
mb.pack(side="left")
self.knoepfe[label] = mb
if underline is not None:
taste = label[underline].lower()
self.root.bind_all(f"<Alt-KeyPress-{taste}>", lambda e, m=mb: self.oeffnen(m))
return menu
def oeffnen(self, mb: tk.Menubutton):
menu = mb.nametowidget(mb.cget("menu"))
menu.tk_popup(mb.winfo_rootx(), mb.winfo_rooty() + mb.winfo_height())
return "break"
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
@@ -222,7 +221,8 @@ class Menueleiste(tk.Frame):
class Hauptrahmen:
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container."""
RAND = 5 # Pixel am Rand, an denen man die Größe ziehen kann
RAND = 6 # Pixel am Rand, an denen man die Größe ziehen kann (= sichtbarer Rahmen)
ECKE = 18 # so weit vom Eck entfernt gilt der Rand noch als Ecke (diagonal ziehen)
def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True,
minbreite: int = 900, minhoehe: int = 520):
@@ -231,32 +231,100 @@ class Hauptrahmen:
self.maximiert = False
self._normal_geo: str | None = None
self._schliessen = schliessen
self.nativ = None
root.minsize(minbreite, minhoehe)
if not klassisch:
self.aussen = tk.Frame(root, bg=w.FACE)
self.aussen.pack(fill="both", expand=True)
self.inhalt = self.aussen
self.titelleiste = None
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] # für modal.py
return
root.overrideredirect(True)
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=2, pady=2)
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=self.RAND - 2, pady=self.RAND - 2)
self.aussen.pack(fill="both", expand=True)
self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"),
schliessen=schliessen, minimieren=self.minimieren,
maximieren=self.umschalten)
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.pack(fill="both", expand=True)
# Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined]
self._rand: str = ""
self._resize_geplant = None
self.aussen.bind("<Motion>", self._rand_cursor)
self.aussen.bind("<Leave>", lambda e: self.aussen.configure(cursor=""))
# Mauszeiger zurücksetzen, sobald der Rand verlassen wird – Kindfenster erben sonst den
# Größenänderungs-Zeiger, wenn das letzte Motion-Ereignis am Rand verpasst wurde.
self.aussen.bind("<Leave>", self._cursor_zurueck)
self.inhalt.bind("<Enter>", self._cursor_zurueck)
self.titelleiste.bind("<Enter>", self._cursor_zurueck, add="+")
self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize)
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:
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
@@ -264,7 +332,8 @@ class Hauptrahmen:
# Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
# Hauptfenster sein).
sperre = self.root.grab_current()
from provtool.gui import modal
sperre = self.root.grab_current() or modal.oberstes()
popup = self._offenes_popup()
if sperre is not None and sperre.winfo_toplevel() is not self.root:
fenster = sperre.winfo_toplevel()
@@ -275,6 +344,11 @@ class Hauptrahmen:
except tk.TclError:
fenster = self.root
if fenster is self.root:
from provtool.gui import modal
offen = modal.oberstes()
if offen is not None: # Alt+F4 bei offenem Dialog schließt den Dialog
offen.abbrechen()
return "break"
self._schliessen()
elif hasattr(fenster, "abbrechen"):
fenster.abbrechen()
@@ -313,10 +387,16 @@ class Hauptrahmen:
return None
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.bind("<ButtonPress-1>", lambda e: self._resize_start(e, "se"))
g.bind("<ButtonPress-1>", self._griff_start)
g.bind("<B1-Motion>", self._resize)
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):
self.root.update_idletasks()
if not geo or "+" not in geo:
@@ -330,15 +410,29 @@ class Hauptrahmen:
breite, hoehe = min(breite, bw), min(hoehe, bh)
geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}"
self.root.geometry(geo)
self._normal_geo = geo
self._nativ_einrichten()
if maximiert and self.klassisch:
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:
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
# ---- Fensterfunktionen
def minimieren(self):
# Windows minimiert keine overrideredirect-Fenster → kurz normalen Rahmen aktivieren
if IS_WIN:
# Direkt über Windows: offene Dialoge (gehören dem Hauptfenster) werden mit aus- und
# wieder eingeblendet. NICHT overrideredirect umschalten – Tk baut dabei das Fenster neu
# und Windows würde die zugehörigen Dialoge zerstören.
from provtool.gui import win32
if win32.minimieren(self.root):
return
# Tk minimiert keine overrideredirect-Fenster -> kurz normalen Rahmen aktivieren
geo = self.root.geometry()
self.root.overrideredirect(False)
self.root.iconify()
@@ -354,6 +448,13 @@ class Hauptrahmen:
def maximieren(self):
if self.maximiert:
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()
x, y, bw, bh = arbeitsbereich(self.root)
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
@@ -363,6 +464,12 @@ class Hauptrahmen:
def wiederherstellen(self):
if not self.maximiert:
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
if self._normal_geo:
self.root.geometry(self._normal_geo)
@@ -373,9 +480,25 @@ class Hauptrahmen:
# ---- Größe ändern
def _welcher_rand(self, x, y) -> str:
bw, bh, r = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND
rand = ("n" if y < r else "s" if y >= bh - r else "") + ("w" if x < r else "e" if x >= bw - r else "")
return rand
bw, bh, r, k = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND, self.ECKE
ns = "n" if y < r else "s" if y >= bh - r else ""
we = "w" if x < r else "e" if x >= bw - r else ""
# an einer Kante nahe am Eck -> Ecke (diagonal), wie bei Windows
if ns and not we:
we = "w" if x < k else "e" if x >= bw - k else ""
elif we and not ns:
ns = "n" if y < k else "s" if y >= bh - k else ""
return ns + we
def _cursor_zurueck(self, _e=None):
if self._rand_zeiger:
self._rand_zeiger = False
try:
self.aussen.configure(cursor="")
except tk.TclError:
pass
_rand_zeiger = False
CURSOR_WIN = {"n": "size_ns", "s": "size_ns", "e": "size_we", "w": "size_we",
"nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw"}
@@ -385,15 +508,24 @@ class Hauptrahmen:
def _rand_cursor(self, e):
if self.maximiert:
self._cursor_zurueck()
return
rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
try:
self.aussen.configure(cursor=(self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, ""))
self.aussen.configure(cursor=zeiger)
self._rand_zeiger = bool(zeiger)
except tk.TclError:
pass
def _resize_start(self, e, rand: str | None = None):
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.root.winfo_width(), self.root.winfo_height())
@@ -412,4 +544,14 @@ class Hauptrahmen:
if "n" in self._rand:
neu = max(self.minhoehe, bh - dy)
y, bh = y + (bh - neu), neu
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
# Mausbewegungen bündeln: nur die jeweils letzte Größe anwenden (flüssiger unter Windows)
self._resize_ziel = f"{bw}x{bh}+{x}+{y}"
if self._resize_geplant is None:
self._resize_geplant = self.root.after(15, self._resize_anwenden)
def _resize_anwenden(self):
self._resize_geplant = None
try:
self.root.geometry(self._resize_ziel)
except tk.TclError:
pass
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.
+87
View File
@@ -50,6 +50,22 @@ def user32():
u.GetTopWindow.restype = wintypes.HWND
u.BringWindowToTop.argtypes = [wintypes.HWND]
u.BringWindowToTop.restype = wintypes.BOOL
u.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
u.GetWindowLongW.restype = ctypes.c_long
u.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
u.SetWindowLongW.restype = ctypes.c_long
u.ReleaseCapture.argtypes = []
u.ReleaseCapture.restype = wintypes.BOOL
u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, 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.restype = wintypes.BOOL
u.IsIconic.argtypes = [wintypes.HWND]
u.IsIconic.restype = wintypes.BOOL
u.FlashWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
u.FlashWindow.restype = wintypes.BOOL
_u32 = u
return _u32
@@ -64,6 +80,19 @@ def hwnd(fenster: tk.Misc) -> int | 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]:
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
ziele = [hwnd(f) for f in fenster]
@@ -82,6 +111,64 @@ def nach_vorn(fenster: tk.Misc) -> None:
user32().BringWindowToTop(h)
GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080
def hat_taskleisteneintrag(fenster: tk.Misc) -> bool:
h = hwnd(fenster)
if not h:
return False
stil = user32().GetWindowLongW(h, GWL_EXSTYLE)
return bool(stil & WS_EX_APPWINDOW) and not (stil & WS_EX_TOOLWINDOW)
def taskleisteneintrag(fenster: tk.Misc) -> bool:
"""Eigener Eintrag in Taskleiste/Alt+Tab (rahmenlose Tk-Fenster haben sonst keinen).
Wirkt erst nach erneutem Einblenden – gibt True zurück, wenn das nötig ist."""
h = hwnd(fenster)
if not h:
return False
stil = user32().GetWindowLongW(h, GWL_EXSTYLE)
neu = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
if neu != stil:
user32().SetWindowLongW(h, GWL_EXSTYLE, neu)
return True
return False
def nativ_verschieben(fenster: tk.Misc) -> bool:
"""Windows übernimmt das Ziehen (flüssig, mit Andocken am Bildschirmrand). True bei Erfolg."""
h = hwnd(fenster)
if not h:
return False
user32().ReleaseCapture()
# 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
def minimieren(fenster: tk.Misc) -> bool:
"""Direkt über Windows minimieren – Tk verweigert das bei rahmenlosen Fenstern. Eigene Popups
(Dialoge) werden dabei von Windows mit ausgeblendet und beim Wiederherstellen mit eingeblendet."""
h = hwnd(fenster)
if not h:
return False
user32().ShowWindow(h, 6) # SW_MINIMIZE
return True
def ist_minimiert(fenster: tk.Misc) -> bool:
h = hwnd(fenster)
return bool(user32().IsIconic(h)) if h else False
def blinken(fenster: tk.Misc) -> None:
h = hwnd(fenster)
if h:
user32().FlashWindow(h, True)
def besitzer_setzen(fenster: tk.Misc, besitzer: tk.Misc) -> None:
h, b = hwnd(fenster), hwnd(besitzer)
if h and b:
+457 -53
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
import sys
import tkinter as tk
from pathlib import Path
from tkinter import ttk
FACE = "#c0c0c0"
@@ -14,6 +19,38 @@ NAVY = "#000080"
TEAL = "#008080"
WHITE = "#ffffff"
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")
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_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
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)
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))
MODERNE_ICONS = Path(__file__).resolve().parent / "bilder" / "modern"
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)
for y, row in enumerate(_icon_rows(name)):
for x, ch in enumerate(row):
@@ -288,12 +381,25 @@ class Icons:
# --------------------------------------------------------------------------- Theme
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)
o = root.option_add
o("*Font", FONT)
o("*Background", FACE)
o("*Foreground", DARK)
o("*activeBackground", FACE)
# Tk-Eigenheit: „activeForeground“ gehört zur Optionsklasse „Background“ – ohne diese Zeile
# wäre die Schrift gedrückter Knöpfe grau auf grau (unsichtbar).
o("*activeForeground", DARK)
o("*highlightThickness", 0)
o("*selectBackground", NAVY)
o("*selectForeground", WHITE)
@@ -322,6 +428,7 @@ def apply(root: tk.Tk) -> None:
from tkinter import font as tkfont
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
wort_loeschen_einrichten(root)
st = ttk.Style(root)
st.theme_use("classic" if "classic" in st.theme_names() else "default")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT,
@@ -339,6 +446,138 @@ def apply(root: tk.Tk) -> None:
selectforeground=[("readonly", DARK)])
st.configure("TPanedwindow", 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
def _wortgrenze(text: str, pos: int, richtung: int) -> int:
"""Position der nächsten Wortgrenze von pos aus (richtung -1 = links, +1 = rechts),
wie Strg+Rücktaste / Strg+Entf unter Windows: erst Leerzeichen, dann das Wort."""
if richtung < 0:
i = pos
while i > 0 and text[i - 1].isspace():
i -= 1
if i > 0 and text[i - 1].isalnum():
while i > 0 and text[i - 1].isalnum():
i -= 1
elif i > 0:
i -= 1 # Satzzeichen einzeln
return i
i = pos
if i < len(text) and text[i].isalnum():
while i < len(text) and text[i].isalnum():
i += 1
elif i < len(text):
i += 1
while i < len(text) and text[i].isspace():
i += 1
return i
def _wort_weg(event, richtung: int):
e = event.widget
try:
if str(e.cget("state")) in ("disabled", "readonly"):
return "break"
except tk.TclError:
pass
try:
if e.selection_present():
e.delete("sel.first", "sel.last")
return "break"
except (tk.TclError, AttributeError):
pass
text, pos = e.get(), e.index("insert")
grenze = _wortgrenze(text, pos, richtung)
if richtung < 0 and grenze < pos:
e.delete(grenze, pos)
elif richtung > 0 and grenze > pos:
e.delete(pos, grenze)
return "break"
def _text_wort_weg(event, richtung: int):
t = event.widget
if str(t.cget("state")) == "disabled":
return "break"
if t.tag_ranges("sel"):
t.delete("sel.first", "sel.last")
return "break"
if richtung < 0:
t.delete("insert -1c wordstart" if t.get("insert -1c").isspace() else "insert wordstart", "insert")
else:
t.delete("insert", "insert wordend")
return "break"
def wort_loeschen_einrichten(root: tk.Misc):
"""Strg+Rücktaste / Strg+Entf löschen das ganze Wort – in allen Eingabefeldern (Tk kann das
unter Windows von Haus aus nicht, dort wird nur ein Zeichen gelöscht)."""
for klasse in ("Entry", "TEntry", "TCombobox", "Spinbox", "TSpinbox"):
root.bind_class(klasse, "<Control-BackSpace>", lambda e: _wort_weg(e, -1))
root.bind_class(klasse, "<Control-Delete>", lambda e: _wort_weg(e, 1))
root.bind_class("Text", "<Control-BackSpace>", lambda e: _text_wort_weg(e, -1))
root.bind_class("Text", "<Control-Delete>", lambda e: _text_wort_weg(e, 1))
# --------------------------------------------------------------------------- Bausteine
@@ -347,6 +586,8 @@ class Sunken(tk.Frame):
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
def __init__(self, master, **kw):
if MODERN:
kw.update(bd=0, relief="flat", highlightthickness=1, highlightbackground=RAND)
kw.setdefault("bd", 2)
kw.setdefault("relief", "sunken")
super().__init__(master, **kw)
@@ -356,6 +597,15 @@ class Fenster(tk.Frame):
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
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)
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
self.titelleiste.pack(fill="x", padx=1, pady=1)
@@ -377,7 +627,7 @@ class Statusleiste(tk.Frame):
self.vars: dict[str, tk.StringVar] = {}
for i, (key, breite) in enumerate(felder):
v = tk.StringVar()
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4)
lbl = tk.Label(self, textvariable=v, bd=1, relief="flat" if MODERN else "sunken", anchor="w", padx=4)
if breite:
lbl.configure(width=breite)
lbl.pack(side="left", padx=(0, 2), pady=2)
@@ -389,9 +639,19 @@ class Statusleiste(tk.Frame):
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,
primaer: bool = False, **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:
b.configure(image=icon, compound="left", padx=6)
if breite:
@@ -399,6 +659,115 @@ def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, br
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):
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick."""
@@ -406,6 +775,15 @@ class ToolbarButton(tk.Label):
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
padx=6, pady=2, font=FONT)
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("<Leave>", lambda e: self.configure(relief="flat"))
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
@@ -414,7 +792,7 @@ class ToolbarButton(tk.Label):
Tooltip(self, tooltip)
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:
self.command()
@@ -434,8 +812,12 @@ class Tooltip:
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
self.tip = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True)
tw.wm_geometry(f"+{x}+{y}")
tw.withdraw()
tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack()
tw.update_idletasks()
# Größe + Position zusammen (reine Position ignoriert Tk unter Windows bei rahmenlosen Fenstern)
tw.wm_geometry(f"{tw.winfo_reqwidth()}x{tw.winfo_reqheight()}+{x}+{y}")
tw.deiconify()
def _weg(self, _e=None):
if self._after:
@@ -448,6 +830,10 @@ class Tooltip:
def trenner(master, vertikal: bool = True):
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:
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
@@ -458,18 +844,33 @@ def trenner(master, vertikal: bool = True):
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)
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]:
rahmen = Sunken(master, bg=WHITE)
def _bei_bedarf(leiste: ttk.Scrollbar):
"""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)
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
tree.grid(row=0, column=0, sticky="nsew")
vs.grid(row=0, column=1, sticky="ns")
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.columnconfigure(0, weight=1)
return rahmen, tree
@@ -502,19 +903,24 @@ KLASSISCH = False
class Dialog(tk.Toplevel):
"""Modaler Dialog im Win95-Stil."""
"""Modaler Dialog im Win95-Stil.
Modal heißt hier: Der INHALT des aufrufenden Fensters ist gesperrt (siehe modal.py), dessen
Titelleiste und Rand bleiben bedienbar. Unter Windows gehört der Dialog dem aufrufenden Fenster
(bleibt darüber, kommt mit Alt+Tab nach vorn) und hat einen eigenen Taskleisten-Eintrag.
"""
def __init__(self, master, titel: str, resizable: bool = False):
super().__init__(master, bg=FACE)
self.withdraw()
self.title(titel)
if not KLASSISCH:
# Rahmenlose Dialoge bekommen ihren Besitzer über win32.Modal – mit transient() würde Tk
# sie unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
# Rahmenlose Dialoge bekommen ihren Besitzer über win32 – mit transient() würde Tk sie
# unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
self.transient(master.winfo_toplevel())
self.resizable(resizable, resizable)
self.result = None
self._modal = None # Windows: Besitzer + Sperre des aufrufenden Fensters (siehe win32.Modal)
self._sperre = None
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
self.bind("<Escape>", lambda e: self.abbrechen())
try:
@@ -530,64 +936,62 @@ class Dialog(tk.Toplevel):
self.titelleiste.pack(side="top", fill="x")
def zeigen(self, fokus=None):
from provtool.gui import modal, win32
if getattr(self._root(), "_provtool_schliesst", False):
self.destroy() # Programm wird beendet – keine neuen Dialoge mehr
return None
self.update_idletasks()
m = self.master.winfo_toplevel()
self._aufrufer = m
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
self.geometry(f"+{max(0, x)}+{max(0, y)}")
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
self.deiconify()
self.lift()
# Windows: Dialog gehört dem aufrufenden Fenster, das solange gesperrt ist → Klick aufs
# Hauptfenster, Taskleiste und Alt+Tab holen automatisch den Dialog nach vorn.
from provtool.gui.win32 import Modal
self.update_idletasks()
self.focus_force()
self._modal = Modal(self, m)
vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht
self._sperre_setzen()
bind_id = None
if KLASSISCH:
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden
bind_id = m.bind("<FocusIn>", lambda e: self.winfo_exists() and self.lift(), add="+")
if DUNKEL:
win32.dunkle_titelleiste(self)
if KLASSISCH and win32.IS_WIN:
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
if win32.taskleisteneintrag(self):
self.withdraw()
self.deiconify()
self.update_idletasks()
win32.besitzer_setzen(self, m)
self._sperre = modal.Sperre(m, self)
self.lift()
self.focus_force()
(fokus or self).focus_set()
self.wait_window()
if bind_id:
try:
m.unbind("<FocusIn>", bind_id)
except tk.TclError:
pass
# Tk gibt beim Schließen die Sperre frei, stellt die vorige aber nicht wieder her
try:
if vorher is not None and vorher.winfo_exists():
vorher.grab_set()
vorher.focus_force()
except tk.TclError:
pass
return self.result
def _sperre_setzen(self, versuch: int = 0):
"""Tk-Modalsperre setzen. Ist das Fenster noch nicht ganz sichtbar (langsamer PC, Wine),
schlägt grab_set fehl – dann kurz danach erneut versuchen statt ohne Sperre weiterzumachen."""
def nach_vorn(self, blinken: bool = False):
"""Dialog nach vorn holen (z. B. nach Klick auf das gesperrte Hauptfenster)."""
from provtool.gui import win32
try:
if self.winfo_exists():
self.grab_set()
self.lift()
self.focus_force()
win32.nach_vorn(self)
except tk.TclError:
if versuch < 50:
self.after(20, lambda: self._sperre_setzen(versuch + 1))
return
if blinken:
self.bell()
win32.blinken(self)
if self.titelleiste is not None:
self.titelleiste.blinken()
def destroy(self):
# Erst das aufrufende Fenster wieder freigeben, dann schließen – sonst aktiviert Windows
# beim Schließen ein fremdes Programm statt unseres Fensters.
modal = getattr(self, "_modal", None)
if modal is not None:
modal.beenden()
self._modal = None
sperre = getattr(self, "_sperre", None)
if sperre is not None:
sperre.aufheben()
self._sperre = None
aufrufer = getattr(self, "_aufrufer", None)
super().destroy()
try: # Fokus zurück an das aufrufende Fenster
if aufrufer is not None and aufrufer.winfo_exists():
aufrufer.focus_force()
except tk.TclError:
pass
def abbrechen(self):
self.result = None
@@ -596,7 +1000,7 @@ class Dialog(tk.Toplevel):
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
f = tk.Frame(self, bg=FACE)
for i, (text, cmd) in enumerate(knoepfe):
b = knopf(f, text, cmd, breite=10)
b = knopf(f, text, cmd, breite=10, primaer=MODERN and i == default)
if i == default:
b.configure(default="active")
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)
+13 -2
View File
@@ -122,7 +122,8 @@ def ausfuehren(ergebnisdatei: str) -> int:
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
csv = basis / "freenet.csv"
_beispiel_csv(csv)
s = _Szenario("Assistent, Import, Export", basis / "b")
# Win95-Design: prüft auch den nativen Fensterrahmen (Szenario 1 läuft im modernen Standard)
s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95"}')
wiz = lambda: s.dialog(Einrichtungsassistent) # 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.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():
d = s.dialog(TippDesTages)
if not d.bild_ok:
@@ -161,11 +171,12 @@ def ausfuehren(ergebnisdatei: str) -> int:
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
(seite(2), haendler, "Händler anlegen"),
(lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler → weiter"),
(lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler -> weiter"),
(seite(3), abrechnung, "Abrechnung mit Datei"),
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
(lambda: len(s.app.positionen) == 2, export, "Export"),
(immer, fenster_pruefen, "Fensterrahmen"),
(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 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)
+5
View File
@@ -1,3 +1,8 @@
[pytest]
pythonpath = .
testpaths = tests
# Kein Test darf den Build blockieren: nach 90 s wird abgebrochen und der Aufrufstapel aller
# Threads ausgegeben (zeigt, wo es hängt – auch in Windows-Systemaufrufen, wo der Tk-Watchdog
# der GUI-Tests nicht greift).
timeout = 90
timeout_method = thread
+1
View File
@@ -1,3 +1,4 @@
-r requirements.txt
pytest==8.3.3
pyinstaller==6.11.1
pytest-timeout==2.3.1
+616 -28
View File
@@ -29,7 +29,8 @@ class Lauf:
def __init__(self, home, einstellungen: dict | None = None):
self.home = home
if einstellungen is not None:
# Tests starten im normalen Fenster (die App sonst maximiert)
einstellungen = {"vollbild": False} | (einstellungen or {})
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
self.root = None
for versuch in range(3):
@@ -61,6 +62,28 @@ class Lauf:
# eine evtl. geöffnete Fehlermeldung schließen, damit der Test nicht hängt
self.root.after(100, self._meldungen_zu)
self.root.report_callback_exception = melden
self._sichtbar_seit: float | None = None
def hauptfenster_sichtbar(self) -> bool:
"""Wie ein Mensch erst bedienen, wenn das Hauptfenster zu sehen ist: Unter Windows wird es beim
Start für den Taskleisten-Eintrag kurz aus- und wieder eingeblendet (unter Wine langsamer).
Wird als erster Schritt jedes Ablaufs geprüft – NICHT mit einer eigenen update()-Schleife,
denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten."""
try:
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:
return True
# 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
return False
if self._sichtbar_seit is None:
self._sichtbar_seit = time.time()
return time.time() - self._sichtbar_seit >= 0.15
# ---------------------------------------------------------------- Zustand
def lebt_sonst(self, text: str) -> str:
@@ -118,7 +141,7 @@ class Lauf:
def ablauf(self, schritte: list[tuple], sekunden: float = 20.0, nachlauf: float = 0.3):
"""schritte: [(bedingung, aktion, beschreibung), ...] – jede Aktion läuft als eigener
Tk-Callback, sobald ihre Bedingung erfüllt ist (auch während ein modaler Dialog offen ist)."""
offen = list(schritte)
offen = [(self.hauptfenster_sichtbar, lambda: None, "Hauptfenster ist sichtbar")] + list(schritte)
start = time.time()
zustand = {"fertig_seit": None}
@@ -155,6 +178,10 @@ class Lauf:
time.sleep(0.01)
self.uebrig = [s[2] for s in offen]
def diagnose(self) -> str:
"""Für Fehlermeldungen: welche Schritte nicht liefen + alle gesammelten Fehler."""
return f"nicht ausgeführt: {self.uebrig}\n" + "\n".join(self.fehler)
def schliessen_ueber_fenster(self):
"""Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW)."""
cmd = self.root.tk.call("wm", "protocol", self.root._w, "WM_DELETE_WINDOW")
@@ -177,9 +204,7 @@ def home(tmp_path, monkeypatch):
return tmp_path
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
def _profil_mit_daten(home):
from tests.test_kern import FREENET_KOPF, freenet_zeile
csv = home / "freenet.csv"
csv.write_bytes(("\r\n".join([
@@ -193,6 +218,21 @@ def lauf_mit_daten(home):
from provtool.importer import lies_datei
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
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})
yield lauf
lauf.ende()
@@ -233,7 +273,7 @@ def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home):
(lambda: lauf.dialog(Einrichtungsassistent) is None, lambda: None, "Assistent ist zu"),
])
assert lauf.lebt, lauf.lebt_sonst("Programm darf nicht beendet werden")
assert not lauf.uebrig, lauf.uebrig
assert not lauf.uebrig, lauf.diagnose()
assert lauf.app.settings.get("assistent_erledigt") is True
lauf.pruefen()
lauf.ende()
@@ -290,13 +330,18 @@ def _dialog_oeffner():
"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(),
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",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen"])
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
lauf = lauf_mit_daten
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
"staffel"])
@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]
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
@@ -342,7 +387,6 @@ def test_alt_f4_bevor_die_sperre_steht(lauf_mit_daten, monkeypatch):
Zeichnen sind noch nicht fertig. Alt+F4 muss trotzdem nur den Dialog schließen."""
from provtool.gui import win95 as w
from provtool.gui.dialogs import InfoDialog
monkeypatch.setattr(w.Dialog, "_sperre_setzen", lambda self, versuch=0: None) # keine Sperre
monkeypatch.setattr(w.Dialog, "winfo_ismapped", lambda self: 0) # „noch nicht gezeichnet“
lauf = lauf_mit_daten
lauf.ablauf([
@@ -353,7 +397,7 @@ def test_alt_f4_bevor_die_sperre_steht(lauf_mit_daten, monkeypatch):
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
], sekunden=8)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 darf bei offenem Dialog nicht das ganze Programm beenden")
assert not lauf.uebrig, lauf.uebrig
assert not lauf.uebrig, lauf.diagnose()
lauf.pruefen()
@@ -367,7 +411,7 @@ def test_alt_f4_im_rundgang_beendet_nur_den_rundgang(lauf_mit_daten):
(lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"),
], sekunden=8)
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Rundgang darf nicht das Programm beenden")
assert not lauf.uebrig, lauf.uebrig
assert not lauf.uebrig, lauf.diagnose()
lauf.pruefen()
@@ -396,7 +440,7 @@ def test_betrag_dialog_wenn_zelle_unsichtbar(lauf_mit_daten):
(lambda: bool(app.ptree.get_children()), oeffnen, "Betrag ändern bei unsichtbarer Zelle"),
(lambda: eingabe() is not None, tippen, "Betrag im Dialog eingeben"),
])
assert not lauf.uebrig, lauf.uebrig
assert not lauf.uebrig, lauf.diagnose()
assert any(p["betrag"] == 777 and p["manuell"] for p in app.positionen)
del app.ptree.bbox
lauf.pruefen()
@@ -533,7 +577,7 @@ def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten):
fremd.geometry("400x300+50+50")
fremd.update()
m.update(besitzer=win32.besitzer_von(d), haupt=win32.hwnd(lauf.root),
haupt_bedienbar=win32.ist_bedienbar(lauf.root))
haupt_bedienbar=win32.ist_bedienbar(lauf.root), taskleiste=win32.hat_taskleisteneintrag(d))
win32.nach_vorn(fremd) # „woanders hingeklickt“
lauf.root.update()
m["woanders"] = win32.stapel(d, lauf.root, fremd)
@@ -548,34 +592,38 @@ def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten):
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, messen), "messen"),
(lambda: "zurueck" in m and lauf.dialog(InfoDialog) is None, lambda: None, "geschlossen"),
], sekunden=10)
assert not lauf.uebrig, lauf.uebrig
assert not lauf.uebrig, lauf.diagnose()
assert m["besitzer"] == m["haupt"], "Popup gehört nicht dem Hauptfenster"
assert m["haupt_bedienbar"] is False, "Hauptfenster muss gesperrt sein, solange das Popup offen ist"
assert m["haupt_bedienbar"] is True, "Hauptfenster soll verschiebbar bleiben (nur der Inhalt ist gesperrt)"
assert m["taskleiste"] is True, "Popup braucht einen eigenen Eintrag in der Taskleiste"
assert m["woanders"][0] == 2, f"Testaufbau: das fremde Fenster sollte oben liegen ({m['woanders']})"
assert m["zurueck"][:2] == [0, 1], f"Popup liegt nach dem Zurückholen nicht über dem Hauptfenster: {m['zurueck']}"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
lauf.pruefen()
@nur_windows
def test_windows_verschachtelte_rueckfrage(lauf_mit_daten):
"""Händler-Dialog → Rückfrage: Rückfrage gehört dem Händler-Dialog; beim Schließen der Reihe nach frei."""
from provtool.gui import win32
def test_verschachtelte_rueckfrage_sperrt_der_reihe_nach(lauf_mit_daten):
"""Händler-Dialog -> Rückfrage: gesperrt sind Hauptfenster-Inhalt und Händler-Dialog (nicht dessen
Titelleiste); nach der Rückfrage ist der Händler-Dialog wieder frei, das Hauptfenster noch nicht."""
from provtool.gui import modal, win32
from provtool.gui import win95 as w
from provtool.gui.dialogs import HaendlerDialog
lauf = lauf_mit_daten
m = {}
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Test-Rückfrage"), None) # noqa: E731
gesperrt = lambda wdg: modal.TAG in wdg.bindtags() # noqa: E731
def messen_innen():
h, r = lauf.dialog(HaendlerDialog), rueckfrage()
m.update(r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h), h_an=win32.ist_bedienbar(h),
haupt_an=win32.ist_bedienbar(lauf.root))
m.update(h_inhalt=gesperrt(h.liste), h_titel=gesperrt(h.titelleiste) if h.titelleiste else False,
haupt_inhalt=gesperrt(lauf.app.ptree), haupt_titel=gesperrt(lauf.app.rahmen.titelleiste)
if lauf.app.rahmen.titelleiste else False,
r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h))
r.destroy()
def messen_aussen():
h = lauf.dialog(HaendlerDialog)
m.update(h_an_danach=win32.ist_bedienbar(h), haupt_an_danach=win32.ist_bedienbar(lauf.root))
m.update(h_inhalt_danach=gesperrt(h.liste), haupt_inhalt_danach=gesperrt(lauf.app.ptree))
h.abbrechen()
lauf.ablauf([
@@ -586,11 +634,13 @@ def test_windows_verschachtelte_rueckfrage(lauf_mit_daten):
(lambda: "h" in m and rueckfrage() is None, messen_aussen, "außen messen"),
(lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"),
], sekunden=12)
assert not lauf.uebrig, lauf.uebrig
assert not lauf.uebrig, lauf.diagnose()
assert m["h_inhalt"] and m["haupt_inhalt"], "Inhalte müssen gesperrt sein, solange die Rückfrage offen ist"
assert not m["h_titel"] and not m["haupt_titel"], "Titelleisten (verschieben) dürfen nicht gesperrt sein"
assert not m["h_inhalt_danach"] and m["haupt_inhalt_danach"]
assert not gesperrt(lauf.app.ptree), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
if sys.platform.startswith("win"):
assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog"
assert m["h_an"] is False and m["haupt_an"] is False
assert m["h_an_danach"] is True and m["haupt_an_danach"] is False
assert win32.ist_bedienbar(lauf.root)
lauf.pruefen()
@@ -612,8 +662,546 @@ def test_windows_rundgang_sperrt_und_gibt_frei(lauf_mit_daten):
messen_und_weiter, f"Schritt {i + 1}"))
schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden"))
lauf.ablauf(schritte, sekunden=12)
assert not lauf.uebrig, lauf.uebrig
assert not lauf.uebrig, lauf.diagnose()
assert all(m["gesperrt"]), "Hauptfenster während des Rundgangs nicht gesperrt"
assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster"
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben"
lauf.pruefen()
# =========================================================================== Rückmeldungen vom 2.11. (Teil 2)
def test_gedrueckte_knoepfe_behalten_ihre_schrift(lauf_mit_daten):
"""Gemeldet: Im gedrückten Zustand war die Beschriftung weg (activeforeground = Grau)."""
from provtool.gui import win95 as w
lauf = lauf_mit_daten
for b in (w.knopf(lauf.root, "Test"), tk.Button(lauf.root, text="roh")):
assert b.cget("activeforeground") != b.cget("activebackground"), "Schrift gedrückter Knöpfe unsichtbar"
b.destroy()
lauf.pruefen()
def test_tipp_text_bleibt_im_sichtbaren_bereich(lauf_mit_daten):
from provtool.gui.assistent import TIPPS, TippDesTages
lauf = lauf_mit_daten
m = {}
def messen():
d = lauf.dialog(TippDesTages)
rahmen = d.text.master
for t in TIPPS: # jeden Tipp einmal anzeigen
d.text.configure(text=t)
d.update_idletasks()
m.setdefault("breiten", []).append((d.text.winfo_reqwidth(), rahmen.winfo_width()))
m.setdefault("hoehen", []).append((d.text.winfo_reqheight(), rahmen.winfo_height()))
d.abbrechen()
lauf.ablauf([
(IMMER, lauf.app.tipp_des_tages, "Tipp öffnen"),
(lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.root.after(100, messen), "messen"),
(lambda: "breiten" in m and lauf.dialog(TippDesTages) is None, lambda: None, "zu"),
])
assert not lauf.uebrig, lauf.diagnose()
for text_b, rahmen_b in m["breiten"]:
assert text_b <= rahmen_b, f"Tipp-Text ({text_b}px) breiter als sein Bereich ({rahmen_b}px)"
for text_h, rahmen_h in m["hoehen"]:
assert text_h <= rahmen_h, f"Tipp-Text ({text_h}px) höher als sein Bereich ({rahmen_h}px)"
lauf.pruefen()
def test_strg_ruecktaste_loescht_ganzes_wort(lauf_mit_daten):
from provtool.gui import win95 as w
lauf = lauf_mit_daten
e = tk.Entry(lauf.root)
e.insert(0, "Mobilfunk Schmid GmbH")
ev = type("E", (), {"widget": e})()
e.icursor("end")
w._wort_weg(ev, -1)
assert e.get() == "Mobilfunk Schmid "
w._wort_weg(ev, -1)
assert e.get() == "Mobilfunk "
e.icursor(0)
w._wort_weg(ev, 1)
assert e.get() == ""
# Bindung ist für alle Eingabefelder aktiv
for klasse in ("Entry", "TEntry", "TCombobox"):
assert lauf.root.bind_class(klasse, "<Control-BackSpace>"), klasse
assert lauf.root.bind_class(klasse, "<Control-Delete>"), klasse
e.destroy()
lauf.pruefen()
def test_menue_wechselt_beim_ueberfahren(lauf_mit_daten):
"""Gemeldet: Bei offenem Menü wechselte es nicht mit, wenn man zum nächsten Eintrag fuhr."""
lauf = lauf_mit_daten
leiste = lauf.app.menueleiste
datei, haendler = leiste.knoepfe["Datei"], leiste.knoepfe["Händler"]
m = {}
def ueberfahren():
popup = leiste.menues[datei]
ev = type("E", (), {"x_root": haendler.winfo_rootx() + 3, "y_root": haendler.winfo_rooty() + 3})()
popup._bewegung(ev)
def pruefen_und_esc():
m["offen"] = leiste.offener_knopf
m["popup_offen"] = leiste.menues[haendler].offen
leiste.menues[haendler].fenster.event_generate("<Escape>")
leiste.menues[haendler].schliessen()
lauf.ablauf([
(IMMER, lambda: leiste._klick(datei), "Datei öffnen"),
(lambda: leiste.menues[datei].offen, ueberfahren, "zu „Händler“ fahren"),
(lambda: leiste.menues[haendler].offen, pruefen_und_esc, "prüfen + schließen"),
(lambda: leiste.offener_knopf is None, lambda: None, "alles zu"),
])
assert not lauf.uebrig, lauf.diagnose()
assert m["offen"] is haendler and m["popup_offen"]
assert not leiste.menues[datei].offen
lauf.pruefen()
def test_menue_geht_unter_seinem_menuepunkt_auf(lauf_mit_daten):
"""Unter Windows landete das Menü zunächst oben links in der Bildschirmecke."""
lauf = lauf_mit_daten
leiste = lauf.app.menueleiste
m = {}
for name in ("Datei", "Händler"):
knopf = leiste.knoepfe[name]
def messen(knopf=knopf, name=name):
f = leiste.menues[knopf].fenster
m[name] = (f.winfo_rootx() - knopf.winfo_rootx(),
f.winfo_rooty() - (knopf.winfo_rooty() + knopf.winfo_height()))
leiste.schliessen()
lauf.ablauf([(IMMER, lambda knopf=knopf: leiste._klick(knopf), f"{name} öffnen"),
(lambda knopf=knopf: leiste.menues[knopf].offen, lambda: lauf.root.after(100, messen), "messen"),
(lambda name=name: name in m, lambda: None, "fertig")])
assert not lauf.uebrig, lauf.diagnose()
for name, (dx, dy) in m.items():
assert abs(dx) <= 3 and abs(dy) <= 3, f"Menü „{name}“ nicht unter seinem Menüpunkt (Abstand {dx}, {dy})"
lauf.pruefen()
def test_menue_befehl_wird_ausgefuehrt(lauf_mit_daten):
from provtool.gui.assistent import TippDesTages
lauf = lauf_mit_daten
leiste = lauf.app.menueleiste
hilfe = leiste.knoepfe["?"]
def tipp_waehlen():
popup = leiste.menues[hilfe]
i = next(i for i, (e, _, _) in enumerate(popup.zeilen) if e["label"].startswith("Tipp des Tages"))
popup._ausfuehren(i)
lauf.ablauf([
(IMMER, lambda: leiste._klick(hilfe), "? öffnen"),
(lambda: leiste.menues[hilfe].offen, tipp_waehlen, "Tipp des Tages wählen"),
(lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.dialog(TippDesTages).abbrechen(), "zu"),
])
assert not lauf.uebrig, lauf.diagnose()
assert not leiste.menues[hilfe].offen
lauf.pruefen()
def test_hauptfenster_bei_offenem_dialog_verschiebbar_inhalt_gesperrt(lauf_mit_daten, monkeypatch):
"""Gemeldet: Bei offenem Dialog ließ sich das Hauptfenster nicht verschieben."""
from provtool.gui import modal, rahmen
from provtool.gui.dialogs import InfoDialog
monkeypatch.setattr(rahmen, "NATIV_VERSCHIEBEN", False) # Tk-Weg prüfen (nativ macht Windows selbst)
lauf = lauf_mit_daten
app = lauf.app
if not app.rahmen.klassisch:
pytest.skip("nur im Win95-Rahmen")
m = {"neu_aufgerufen": False}
monkeypatch.setattr(type(app), "neu", lambda self: m.__setitem__("neu_aufgerufen", True))
titel = app.rahmen.titelleiste.titel_lbl
def pruefen():
x0 = lauf.root.winfo_x()
rx, ry = titel.winfo_rootx() + 30, titel.winfo_rooty() + 5
titel.event_generate("<ButtonPress-1>", x=30, y=5, rootx=rx, rooty=ry)
titel.event_generate("<B1-Motion>", x=70, y=5, rootx=rx + 40, rooty=ry)
titel.event_generate("<ButtonRelease-1>", x=70, y=5, rootx=rx + 40, rooty=ry)
lauf.root.update()
m["verschoben"] = lauf.root.winfo_x() - x0
knopf = app.tb["Neu"] # Klick auf den gesperrten Inhalt
knopf.event_generate("<ButtonPress-1>", x=5, y=5)
knopf.event_generate("<ButtonRelease-1>", x=5, y=5)
m["gesperrt"] = modal.TAG in app.ptree.bindtags()
lauf.dialog(InfoDialog).abbrechen()
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Dialog öffnen"),
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(100, pruefen), "prüfen"),
(lambda: "verschoben" in m and lauf.dialog(InfoDialog) is None, lambda: None, "zu"),
])
assert not lauf.uebrig, f"{lauf.uebrig} {m}\n" + "\n".join(lauf.fehler)
assert m["verschoben"] == 40, f"Hauptfenster ließ sich nicht verschieben ({m['verschoben']} px)"
assert m["gesperrt"] and not m["neu_aufgerufen"], "Inhalt des Hauptfensters war nicht gesperrt"
assert modal.TAG not in app.ptree.bindtags(), "Sperre nach dem Schließen nicht aufgehoben"
lauf.pruefen()
def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten):
"""Gemeldet: Der Größenänderungs-Zeiger blieb manchmal stehen."""
lauf = lauf_mit_daten
r = lauf.app.rahmen
if not r.klassisch:
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})()
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"
r.inhalt.event_generate("<Enter>")
lauf.root.update()
assert r.aussen.cget("cursor") == "", "Zeiger wurde beim Betreten des Inhalts nicht zurückgesetzt"
# Ecken: in der Nähe des Ecks diagonal
assert r._welcher_rand(2, 10) == "nw" and r._welcher_rand(r.aussen.winfo_width() - 3, 10) == "ne"
lauf.pruefen()
def test_eigene_kennungen_ausblenden_in_der_oberflaeche(lauf_mit_daten):
from provtool.db import EIGEN
lauf = lauf_mit_daten
app = lauf.app
alle_vorher = int(app.htree.item("-1", "values")[0])
app.db.eigene_setzen("freenet", "999")
app._aktualisieren()
assert app.htree.exists(str(EIGEN)), "Eintrag „(Eigene – ausgeblendet)“ fehlt"
assert int(app.htree.item("-1", "values")[0]) == alle_vorher - 1, "eigene Positionen zählen noch mit"
assert all(p["haendler_id"] != EIGEN for p in app._gefiltert())
lauf.pruefen()
@nur_windows
def test_windows_minimieren_mit_offenem_dialog(lauf_mit_daten):
"""Minimieren bei offenem Dialog darf den Dialog nicht zerstören; nach dem Wiederherstellen ist er da."""
from provtool.gui import win32
from provtool.gui.dialogs import InfoDialog
lauf = lauf_mit_daten
m = {}
def minimieren():
lauf.app.rahmen.minimieren()
lauf.root.update()
m["minimiert"] = win32.ist_minimiert(lauf.root)
win32.user32().ShowWindow(win32.hwnd(lauf.root), 9) # SW_RESTORE (wie Klick in der Taskleiste)
lauf.root.update()
m["dialog_da"] = lauf.dialog(InfoDialog) is not None
lauf.dialog(InfoDialog).abbrechen()
lauf.ablauf([
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Dialog öffnen"),
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, minimieren), "minimieren"),
(lambda: "dialog_da" in m, lambda: None, "fertig"),
], sekunden=12)
assert not lauf.uebrig, lauf.diagnose()
assert m["minimiert"], "Hauptfenster wurde nicht minimiert"
assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden"
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()
+251 -1
View File
@@ -159,9 +159,22 @@ def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path):
# Export
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026")
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",
"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
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
# Sortierung Zeitraum → Provisionsart → Datum
@@ -265,3 +278,240 @@ def test_migration_alter_datenbank(tmp_path):
spalten = {r[1] for r in d.con.execute("PRAGMA table_info(position)")}
assert "haendler_fest" in spalten
d.close()
def test_eigene_kennungen_ausblenden(db, freenet_csv):
from provtool.db import EIGEN
hid = db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")])
aid = db.abrechnung_neu("X")
db.importieren(aid, lies_datei(freenet_csv))
assert {p["haendler_id"] for p in db.positionen(aid)} == {hid, UNZUGEORDNET}
db.eigene_setzen("freenet", "999")
ids = {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}
assert ids["999"] == EIGEN and ids["111"] == hid
# Händler-Kennung hat Vorrang vor „eigen“
db.eigene_setzen("freenet", "111")
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid
db.eigene_setzen("freenet", "999", False)
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()