16 Commits
Author SHA1 Message Date
Kerim AtilganandClaude Opus 5.5 2275b92188 Resize-Zeiger blieb im Inhalt hängen (vererbt von der Trennlinie): Bereiche mit eigenem Zeiger; Version 1.8.1
Build / linux (push) Successful in 1m55s
Build / windows-exe (push) Successful in 8m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:33:09 +01:00
Kerim AtilganandClaude Opus 5.5 b85a75857e Updater: Prüfung beim Start und unter Hilfe, Setup herunterladen, DB sichern, still installieren, Neustart
Build / linux (push) Successful in 1m56s
Build / windows-exe (push) Successful in 8m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:18:22 +01:00
Kerim AtilganandClaude Opus 5.5 968c68ac1a Fixes aus den Audits (Fable/Codex): Doppelte, Staffel, Export-Sperre, Netz-DB, atomare Einstellungen, Formeln, Wiederherstellung, Importgrenzen, schnellere Suche; toter Code entfernt; Version 1.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:58:51 +01:00
Kerim AtilganandClaude Opus 5.5 72677ebed7 Selbsttest: Win95-Szenario im normalen Fenster (Rahmenprüfung gilt nicht maximiert)
Build / linux (push) Successful in 1m55s
Build / windows-exe (push) Successful in 8m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:45:38 +01:00
Kerim AtilganandClaude Opus 5.5 03b48f063c Maustest startet im normalen Fenster (Programm sonst maximiert)
Build / linux (push) Successful in 1m53s
Build / windows-exe (push) Failing after 7m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:36:43 +01:00
Kerim AtilganandClaude Opus 5.5 c332c5a487 Programm startet maximiert; Version 1.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:23:01 +01:00
Kerim AtilganandClaude Opus 5.5 c96cf1bd94 Test: Filter-Knopf direkt auslösen (Wine)
Build / linux (push) Successful in 1m51s
Build / windows-exe (push) Successful in 7m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:14:02 +01:00
Kerim AtilganandClaude Opus 5.5 c35356aeb9 Win95-Design an das neue Hauptfenster angepasst, Version 1.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:04:14 +01:00
Kerim AtilganandClaude Opus 5.5 f296483fdc Neues Design im Stil der Handy-Insel-Webtools: Kopfzeile, Seitenleiste, Kennzahlen, Filter, hell/dunkel, IBM Plex
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:55:17 +01:00
Kerim AtilganandClaude Opus 5.5 2600eea574 Abrechnungen links im Hauptfenster (Klick öffnet), Import fragt nach der Ziel-Abrechnung
Build / linux (push) Successful in 1m50s
Build / windows-exe (push) Successful in 7m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:35:16 +01:00
Kerim AtilganandClaude Opus 5.5 057cca6378 Export: Knopf „Noch nicht abgerechnet“; Version 1.5.0 (mit Windows-Setup)
Build / linux (push) Successful in 1m52s
Build / windows-exe (push) Successful in 7m39s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:44:46 +01:00
Kerim AtilganandClaude Opus 5.5 ec99042569 Windows-Setup (Inno Setup): Installation pro Benutzer, Installer-Test im Build
- PyInstaller baut unter Windows zusätzlich die Ordner-Variante dist/Provisionsmanager/
  (schnellerer Start); onefile-.exe und .zip bleiben, Linux unverändert
- build/installer.iss: ohne Admin nach %LOCALAPPDATA%\Programs\Provisionsmanager,
  Startmenü, optional Desktop, deutsch, feste AppId, CloseApplications,
  Benutzerdaten in %APPDATA%\Provisionsmanager95 bleiben bei Deinstallation erhalten
- build_windows.sh: Inno Setup 6.7.3 (SHA256 geprüft) unter Wine installieren, Setup bauen,
  still installieren, Selbsttest der installierten .exe, still deinstallieren und prüfen
- Pipeline: Setup als Artefakt und am Release; README-Abschnitt Installation

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:03:33 +01:00
62 changed files with 2763 additions and 423 deletions

No files matched your search

+39 -19
View File
@@ -1,7 +1,7 @@
# Baut die Windows-Version (Provisionsmanager95.exe) mit Windows-Python unter Wine. # Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version.
# - jeder Push / PR: Tests + .exe als Build-Artefakt # - jeder Push / PR: Tests + Programmdateien als Build-Artefakte
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip # - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip, Setup und Linux-.tar.gz
name: Windows-Build name: Build
on: on:
push: push:
@@ -23,7 +23,7 @@ jobs:
- name: Werkzeuge für Checkout/Upload installieren - name: Werkzeuge für Checkout/Upload installieren
run: | run: |
apt-get update -qq apt-get update -qq
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates >/dev/null apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xdotool >/dev/null
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -37,24 +37,44 @@ jobs:
path: | path: |
dist/Provisionsmanager95.exe dist/Provisionsmanager95.exe
dist/Provisionsmanager95-*.zip dist/Provisionsmanager95-*.zip
dist/Provisionsmanager-Setup-*.exe
- name: Release erstellen - name: Release erstellen / ergänzen
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
env: env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }} TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
set -eu V=$(tr -d '\r\n' < dist/VERSION)
VERSION=$(cat dist/VERSION | tr -d '\r\n') build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$V.zip dist/Provisionsmanager-Setup-$V.exe
if [ "v$VERSION" != "$TAG" ]; then
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1 linux:
fi runs-on: ubuntu-latest
resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ timeout-minutes: 30
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}") container:
id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) image: python:3.12-bookworm
for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do steps:
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \ - name: Werkzeuge installieren
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null run: |
echo "hochgeladen: $f" apt-get update -qq
done apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xvfb xauth fonts-dejavu-core >/dev/null
- uses: actions/checkout@v4
- name: Tests + Build (Linux)
run: ./build/build_linux.sh
- name: Build-Artefakt hochladen
uses: actions/upload-artifact@v3
with:
name: Provisionsmanager95-Linux
path: dist/Provisionsmanager95-linux-*.tar.gz
- name: Release ergänzen
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: build/release_hochladen.sh dist/Provisionsmanager95-linux-x86_64-*.tar.gz
+1
View File
@@ -16,3 +16,4 @@ build/version_info.txt
selbsttest.txt selbsttest.txt
/*.jpg /*.jpg
/*.png /*.png
.claude/
+40 -3
View File
@@ -5,6 +5,16 @@ von **freenet** (CSV/Excel) und **Vodafone/Euronics** (Excel) ein, ordnet die Po
Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine
fertige Excel-Abrechnung. Optik: Windows 95. fertige Excel-Abrechnung. Optik: Windows 95.
## Installation (Windows)
`Provisionsmanager-Setup-<version>.exe` aus dem Release herunterladen und starten – keine
Admin-Rechte nötig. Das Programm landet in `%LOCALAPPDATA%\Programs\Provisionsmanager` mit
Eintrag im Startmenü (Desktop-Verknüpfung optional) und startet schneller als die einzelne `.exe`.
Ein Update installiert man einfach drüber (ein laufendes Programm wird dafür geschlossen).
Deinstallieren über *Einstellungen ▸ Apps*; Datenbank und Einstellungen in
`%APPDATA%\Provisionsmanager95` bleiben dabei erhalten. Ohne Installation geht weiterhin
`Provisionsmanager95.exe` (bzw. das `.zip`) – einfach starten.
## Erster Start ## Erster Start
Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler
@@ -66,10 +76,34 @@ Welche Provisionsart Airtime bzw. Jahresbonus ist, erkennt das Programm an den T
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter „AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
*Händler ▸ Provisionsarten…*. *Händler ▸ Provisionsarten…*.
Das Hauptfenster ist unter Windows ein echtes Programmfenster mit Win95-Optik: an den Bildschirmrand 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 ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
wie gewohnt. 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.
**Doppelte Positionen:** Taucht dieselbe Position mehrfach auf (z. B. in Wochen- und Monatsdatei oder in zwei
Abrechnungen), zählt nur die zuerst importierte; die anderen stehen als *doppelt* in der Liste und werden
weder exportiert noch bei der Jahresbonus-Staffel mitgezählt. Abgerechnete Positionen lassen sich erst
löschen, wenn ihre Kennzeichnung „abgerechnet“ aufgehoben ist.
**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*.
**Updates:** Das Programm schaut beim Start auf Gitea nach einer neueren Version (abschaltbar unter
*Datei ▸ Einstellungen*, von Hand unter *Hilfe ▸ Nach Updates suchen*). Die per Setup installierte
Windows-Version lädt das neue Setup, sichert die Datenbank (`vor-update-<alte Version>.db` im Einstellungsordner),
installiert still und startet neu. Die einzelne .exe und Linux öffnen stattdessen die Download-Seite.
**Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`). Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*. Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
@@ -107,7 +141,8 @@ Aufbau:
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen | | `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
| `provtool/export.py` | Excel-Export im Zielformat | | `provtool/export.py` | Excel-Export im Zielformat |
| `provtool/money.py` | Beträge (intern immer in Cent) | | `provtool/money.py` | Beträge (intern immer in Cent) |
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) | | `provtool/gui/` | Oberfläche (Tkinter; Designs hell, dunkel, Win95) |
| `provtool/gui/schriften/` | IBM Plex (SIL Open Font License, siehe `OFL.txt`) |
| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung | | `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung |
| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages | | `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
@@ -122,7 +157,9 @@ Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Containe
- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem - jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt `Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip` - danach das Setup (`build/installer.iss`, Inno Setup unter Wine) bauen, still installieren,
den Selbsttest in der installierten `.exe` laufen lassen und still deinstallieren
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe`, `.zip` und Setup
Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen, Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen,
Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw. Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 B

After

Width:  |  Height:  |  Size: 24 KiB

+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Baut die Linux-Version (einzelne Programmdatei) – im Container python:3.12-bookworm (glibc 2.36:
# läuft ab Debian 12, Ubuntu 24.04, Linux Mint 22; Debian 11 ist ausgelaufen).
# Ergebnis: dist/Provisionsmanager95-linux-x86_64-<version>.tar.gz
set -euo pipefail
cd "$(dirname "$0")/.."
export LANG=C.UTF-8 LC_ALL=C.UTF-8
if ! command -v Xvfb >/dev/null; then
apt-get update -qq && apt-get install -y -qq --no-install-recommends xvfb xauth fonts-dejavu-core >/dev/null
fi
Xvfb :91 -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 &
XVFB_PID=$!
trap 'kill $XVFB_PID 2>/dev/null || true' EXIT
export DISPLAY=:91
sleep 1
python3 --version
python3 -m pip install --disable-pip-version-check -q -r requirements-dev.txt
echo "== Tests inkl. Oberfläche (Linux) =="
PROVTOOL_GUI_TESTS=1 timeout 900 python3 -m pytest -v -p no:cacheprovider
VERSION=$(python3 -c "import provtool; print(provtool.__version__)")
echo "== PyInstaller (Version $VERSION) =="
python3 -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
test -x dist/Provisionsmanager95
echo "== Selbsttest des fertigen Programms =="
rm -f selbsttest.txt
set +e
timeout 300 dist/Provisionsmanager95 --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | grep -qx OK; then
echo "Selbsttest FEHLGESCHLAGEN (Exit-Code $CODE)"; exit 1
fi
echo "Selbsttest bestanden"
PAKET="Provisionsmanager95-linux-x86_64-$VERSION"
rm -rf "dist/$PAKET" && mkdir -p "dist/$PAKET"
cp dist/Provisionsmanager95 assets/app.png "dist/$PAKET/"
cp README.md "dist/$PAKET/LIESMICH.md"
cat > "dist/$PAKET/provisionsmanager95.desktop" <<DESKTOP
[Desktop Entry]
Type=Application
Name=Provisions-Manager 95
Comment=Provisionsabrechnung für Unterhändler
Exec=sh -c '"\$(dirname "%k")/Provisionsmanager95"'
Icon=app
Terminal=false
Categories=Office;Finance;
DESKTOP
tar -C dist -czf "dist/$PAKET.tar.gz" "$PAKET"
echo "$VERSION" > dist/VERSION
ls -la dist
+51 -12
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12). # Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12).
# Ergebnis: dist/Provisionsmanager95.exe und dist/Provisionsmanager95-<version>.zip # Ergebnis: dist/Provisionsmanager95.exe, dist/Provisionsmanager95-<version>.zip und
# dist/Provisionsmanager-Setup-<version>.exe (Inno Setup, Installation pro Benutzer)
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -29,6 +30,9 @@ echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) =="
# -v: im Log ist live sichtbar, welcher Test läuft; timeout: Obergrenze für alle Tests zusammen # -v: im Log ist live sichtbar, welcher Test läuft; timeout: Obergrenze für alle Tests zusammen
PROVTOOL_GUI_TESTS=1 timeout 900 $PY -m pytest -v -p no:cacheprovider PROVTOOL_GUI_TESTS=1 timeout 900 $PY -m pytest -v -p no:cacheprovider
echo "== Maustest (echte Maus: Fenster ziehen, Rand ziehen, maximiert ziehen) =="
PY="$PY" timeout 300 bash build/maustest.sh
echo "== Version ==" echo "== Version =="
VERSION=$($PY build/version_info.py | tr -d '\r') VERSION=$($PY build/version_info.py | tr -d '\r')
echo "Version $VERSION" echo "Version $VERSION"
@@ -37,19 +41,24 @@ echo "== PyInstaller =="
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec $PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
test -f dist/Provisionsmanager95.exe test -f dist/Provisionsmanager95.exe
test -f dist/Provisionsmanager/Provisionsmanager.exe # Ordner-Variante für das Setup
selbsttest() { # $1 = zu prüfende .exe
rm -f selbsttest.txt
set +e
timeout 300 wine "$1" --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
echo "Selbsttest von $1 FEHLGESCHLAGEN (Exit-Code $CODE)"
exit 1
fi
echo "Selbsttest bestanden"
}
echo "== Selbsttest der fertigen .exe ==" echo "== Selbsttest der fertigen .exe =="
rm -f selbsttest.txt selbsttest dist/Provisionsmanager95.exe
set +e
timeout 300 wine dist/Provisionsmanager95.exe --selbsttest selbsttest.txt
CODE=$?
set -e
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
echo "Selbsttest der .exe FEHLGESCHLAGEN (Exit-Code $CODE)"
exit 1
fi
echo "Selbsttest bestanden"
cp README.md dist/LIESMICH.md 2>/dev/null || true cp README.md dist/LIESMICH.md 2>/dev/null || true
( cd dist && rm -f Provisionsmanager95-*.zip && \ ( cd dist && rm -f Provisionsmanager95-*.zip && \
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \ python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
@@ -57,4 +66,34 @@ cp README.md dist/LIESMICH.md 2>/dev/null || true
|| $PY -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \ || $PY -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
"Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md ) "Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md )
echo "$VERSION" > dist/VERSION echo "$VERSION" > dist/VERSION
echo "== Setup (Inno Setup) =="
INNO_VERSION=6.7.3
INNO_SHA256=9c73c3bae7ed48d44112a0f48e66742c00090bdb5bef71d9d3c056c66e97b732
INNO_EXE=build/tmp/innosetup-$INNO_VERSION.exe
mkdir -p build/tmp
curl -fsSL -o "$INNO_EXE" "https://github.com/jrsoftware/issrc/releases/download/is-${INNO_VERSION//./_}/innosetup-$INNO_VERSION.exe"
echo "$INNO_SHA256 $INNO_EXE" | sha256sum -c -
timeout 300 wine "$INNO_EXE" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /CURRENTUSER '/DIR=C:\InnoSetup'
rm -f dist/Provisionsmanager-Setup-*.exe
timeout 300 wine 'C:\InnoSetup\ISCC.exe' /Q "/DAppVersion=$VERSION" 'build\installer.iss'
SETUP="dist/Provisionsmanager-Setup-$VERSION.exe"
test -f "$SETUP"
echo "== Installer-Test (still installieren, Selbsttest, still deinstallieren) =="
winordner() { winepath -u "$(wine cmd /c echo "%$1%" | tr -d '\r')"; } # z. B. APPDATA -> /opt/wineprefix/...
DATEN="$(winordner APPDATA)/Provisionsmanager95"
ZIEL="$(winordner LOCALAPPDATA)/Programs/Provisionsmanager"
mkdir -p "$DATEN"
echo "Benutzerdaten" > "$DATEN/installer-test.txt"
timeout 300 wine "$SETUP" /VERYSILENT /SUPPRESSMSGBOXES /CURRENTUSER /NORESTART
test -f "$(winordner APPDATA)/Microsoft/Windows/Start Menu/Programs/Provisions-Manager.lnk"
selbsttest "$ZIEL/Provisionsmanager.exe"
timeout 300 wine "$ZIEL/unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
# Der Deinstaller arbeitet als Kopie im Temp-Ordner weiter – auf das Ende warten
for _ in $(seq 60); do [ -e "$ZIEL" ] || break; sleep 1; done
if [ -e "$ZIEL" ]; then echo "Deinstallation FEHLGESCHLAGEN – Programmordner noch da:"; ls -la "$ZIEL"; exit 1; fi
if [ ! -f "$DATEN/installer-test.txt" ]; then echo "Deinstallation hat Benutzerdaten gelöscht!"; exit 1; fi
rm "$DATEN/installer-test.txt"
echo "Installer-Test bestanden"
ls -la dist ls -la dist
+53
View File
@@ -0,0 +1,53 @@
; 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
; Update aus dem Programm (/VERYSILENT /NEUSTART=1): danach wieder starten
Filename: "{app}\Provisionsmanager.exe"; Flags: nowait; Check: NachUpdateStarten
[Code]
function NachUpdateStarten: Boolean;
begin
Result := ExpandConstant('{param:NEUSTART|0}') = '1';
end;
-20
View File
@@ -1,20 +0,0 @@
"""Erzeugt assets/app.ico aus der Pixel-Grafik in provtool/gui/win95.py (benötigt Pillow, nur zum Entwickeln)."""
import sys
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from provtool.gui.win95 import PALETTE, _icon_rows # noqa: E402
img = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
for y, row in enumerate(_icon_rows("app")):
for x, ch in enumerate(row):
if ch != ".":
img.putpixel((x, y), tuple(int(PALETTE[ch][i:i + 2], 16) for i in (1, 3, 5)) + (255,))
groessen = [16, 32, 48, 64, 128, 256]
big = img.resize((256, 256), Image.NEAREST)
big.save(ROOT / "assets" / "app.ico", sizes=[(s, s) for s in groessen])
big.save(ROOT / "assets" / "app.png")
print("assets/app.ico geschrieben")
+117
View File
@@ -0,0 +1,117 @@
"""Zeichnet die modernen Symbole (flach, wie Windows 11) und das Programm-Icon.
Ausgabe: provtool/gui/bilder/modern/<name>.png (20 px) und <name>_gross.png (40 px),
assets/app.ico und assets/app.png. Benötigt Pillow – nur zum Entwickeln, nicht zur Laufzeit.
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
ZIEL = ROOT / "provtool" / "gui" / "bilder" / "modern"
N = 160 # Zeichenfläche (8-fach), danach weich verkleinert
LINIE, AKZENT, HELL = (66, 66, 66, 255), (0, 95, 184, 255), (255, 255, 255, 255)
GELB, ROT, ORANGE = (240, 190, 70, 255), (196, 43, 28, 255), (247, 168, 0, 255)
S = 9 # Strichstärke auf der Zeichenfläche
SCHRIFT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
def leinwand():
img = Image.new("RGBA", (N, N), (0, 0, 0, 0))
return img, ImageDraw.Draw(img)
def zeichen(d, text, farbe, groesse=104):
f = ImageFont.truetype(SCHRIFT, groesse)
d.text((N / 2, N / 2 + 4), text, font=f, fill=farbe, anchor="mm")
def prozent(d, farbe, s=S):
d.ellipse((28, 28, 68, 68), outline=farbe, width=s)
d.ellipse((92, 92, 132, 132), outline=farbe, width=s)
d.line((120, 30, 40, 130), fill=farbe, width=s + 2)
def neu(d):
d.polygon([(34, 14), (96, 14), (126, 44), (126, 146), (34, 146)], outline=LINIE, width=S, fill=HELL)
d.line([(96, 14), (96, 44), (126, 44)], fill=LINIE, width=S)
d.ellipse((86, 86, 150, 150), fill=AKZENT)
d.line((118, 100, 118, 136), fill=HELL, width=S)
d.line((100, 118, 136, 118), fill=HELL, width=S)
def oeffnen(d):
d.polygon([(14, 34), (62, 34), (76, 50), (146, 50), (146, 132), (14, 132)], fill=GELB, outline=(193, 140, 30, 255),
width=S - 2)
d.rectangle((14, 66, 146, 132), fill=(250, 210, 100, 255), outline=(193, 140, 30, 255), width=S - 2)
def ablage(d, hoch: bool):
d.line([(18, 96), (18, 140), (142, 140), (142, 96)], fill=LINIE, width=S, joint="curve")
oben, unten = (14, 104) if hoch else (14, 104)
d.line((80, oben, 80, unten), fill=AKZENT, width=S + 2)
if hoch:
d.polygon([(80, 8), (52, 42), (108, 42)], fill=AKZENT)
else:
d.polygon([(80, 118), (52, 82), (108, 82)], fill=AKZENT)
def haendler(d):
d.ellipse((24, 26, 74, 76), outline=LINIE, width=S)
d.arc((8, 82, 90, 170), 180, 360, fill=LINIE, width=S)
d.ellipse((88, 34, 132, 78), fill=AKZENT)
d.pieslice((74, 86, 152, 166), 180, 360, fill=AKZENT)
def rechner(d): # Stift: Betrag ändern
d.polygon([(110, 18), (142, 50), (58, 134), (26, 102)], fill=HELL, outline=LINIE, width=S)
d.polygon([(26, 102), (58, 134), (16, 144)], fill=AKZENT)
d.line((98, 30, 130, 62), fill=LINIE, width=S)
def birne(d):
d.ellipse((34, 10, 126, 102), fill=GELB, outline=(193, 140, 30, 255), width=S - 2)
d.rectangle((56, 96, 104, 122), fill=GELB)
d.rectangle((56, 122, 104, 150), fill=(120, 120, 120, 255))
d.line((60, 134, 100, 134), fill=HELL, width=4)
def kreis(d, farbe, text):
d.ellipse((6, 6, 154, 154), fill=farbe)
zeichen(d, text, HELL)
def warnung(d):
d.polygon([(80, 8), (156, 148), (4, 148)], fill=ORANGE)
zeichen(d, "!", (30, 30, 30, 255), 92)
def app(d):
d.rounded_rectangle((4, 4, 156, 156), radius=34, fill=AKZENT)
prozent(d, HELL, 13)
ICONS = {
"neu": neu, "oeffnen": oeffnen, "import": lambda d: ablage(d, False), "export": lambda d: ablage(d, True),
"haendler": haendler, "prozent": lambda d: prozent(d, AKZENT), "rechner": rechner, "birne": birne,
"info": lambda d: kreis(d, AKZENT, "i"), "frage": lambda d: kreis(d, AKZENT, "?"),
"fehler": lambda d: kreis(d, ROT, "×"), "warnung": warnung, "app": app,
}
def main():
ZIEL.mkdir(parents=True, exist_ok=True)
for name, zeichne in ICONS.items():
img, d = leinwand()
zeichne(d)
img.resize((20, 20), Image.LANCZOS).save(ZIEL / f"{name}.png")
img.resize((40, 40), Image.LANCZOS).save(ZIEL / f"{name}_gross.png")
if name == "app":
gross = img.resize((256, 256), Image.LANCZOS)
gross.save(ROOT / "assets" / "app.png")
gross.save(ROOT / "assets" / "app.ico", sizes=[(s, s) for s in (16, 24, 32, 48, 64, 128, 256)])
print(f"{len(ICONS)} Symbole nach {ZIEL} geschrieben, assets/app.ico + app.png erneuert")
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
"""Für build/maustest.sh: startet die App und schreibt laufend Lage und Zustand des Hauptfensters.
Kein pytest-Test – wird im Windows-Build mit echter Maus (xdotool) bedient. Hintergrund: v1.3.0 stürzte
beim Ziehen an der Titelleiste hart ab; das sieht man nur mit echter Maus.
"""
import faulthandler
import json
import os
import sys
import tempfile
import tkinter as tk
aus = sys.argv[1]
faulthandler.enable(open(os.path.join(aus, "absturz.txt"), "w"))
home = tempfile.mkdtemp()
os.environ["PROVTOOL_HOME"] = home
with open(os.path.join(home, "einstellungen.json"), "w") as f:
json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100",
"design": "win95", "vollbild": False}, f) # nur Win95 hat den eigenen Fensterrahmen
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from provtool.gui import app as A # noqa: E402
root = tk.Tk()
a = A.App(root)
fehler = []
root.report_callback_exception = lambda *e: fehler.append(repr(e[1]))
def melden():
with open(os.path.join(aus, "status.txt"), "a") as f:
f.write(f"{root.winfo_x()} {root.winfo_y()} {root.winfo_width()} {root.winfo_height()} "
f"{int(a.rahmen.maximiert)} {len(fehler)}\n")
root.after(250, melden)
root.after(250, melden)
root.mainloop()
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Echte Maus im virtuellen Bildschirm (xdotool): Fenster an der Titelleiste ziehen, am Rand die Größe
# ziehen, maximieren und aus „maximiert“ herausziehen. v1.3.0 stürzte dabei hart ab – das sieht nur
# ein Test mit echter Maus (Windows-Verschiebeschleife). Aufruf aus build_windows.sh (DISPLAY gesetzt).
set -uo pipefail
cd "$(dirname "$0")/.."
PY="${PY:-wine python}"
AUS="$(mktemp -d)"
command -v xdotool >/dev/null || { apt-get update -qq >/dev/null && apt-get install -y -qq xdotool >/dev/null; }
command -v xdotool >/dev/null || { echo "xdotool fehlt"; exit 1; }
$PY build/maus_app.py "$(winepath -w "$AUS" 2>/dev/null || echo "$AUS")" > "$AUS/app.log" 2>&1 &
PID=$!
for i in $(seq 1 120); do [ -s "$AUS/status.txt" ] && break; sleep 0.5; done
sleep 3
lage() { tail -1 "$AUS/status.txt" | tr -d '\r'; }
lebt() { kill -0 "$PID" 2>/dev/null; }
fehler=0
pruefe() { # $1 = Schritt
sleep 1.5
if ! lebt; then echo "ABSTURZ bei: $1"; fehler=1; return 1; fi
echo "$1: $(lage)"
}
ziehen() { # x y dx dy
xdotool mousemove "$1" "$2"; sleep 0.4; xdotool mousedown 1; sleep 0.3
for i in 1 2 3 4 5 6; do xdotool mousemove_relative -- "$(( $3 / 6 ))" "$(( $4 / 6 ))"; sleep 0.15; done
sleep 0.2; xdotool mouseup 1
}
read -r X Y W H M F <<< "$(lage)"
echo "Start: $X $Y $W $H maximiert=$M"
ziehen $(( X + 300 )) $(( Y + 14 )) 60 42; pruefe "Titelleiste ziehen" || true
read -r X2 Y2 W2 H2 M2 F2 <<< "$(lage)"
[ "$X2" -ne "$X" ] || [ "$Y2" -ne "$Y" ] || { echo "Fenster hat sich nicht bewegt"; fehler=1; }
ziehen $(( X2 + W2 - 2 )) $(( Y2 + H2 / 2 )) 60 0; pruefe "Rand ziehen" || true
read -r X3 Y3 W3 H3 M3 F3 <<< "$(lage)"
[ "$W3" -gt "$W2" ] || { echo "Größe am Rand nicht geändert ($W2 -> $W3)"; fehler=1; }
xdotool mousemove $(( X3 + 300 )) $(( Y3 + 14 )); sleep 0.3
xdotool click --repeat 2 --delay 60 1; pruefe "Doppelklick (maximieren)" || true
read -r X4 Y4 W4 H4 M4 F4 <<< "$(lage)"
[ "$M4" = "1" ] || { echo "nicht maximiert"; fehler=1; }
# Unter echtem Windows wird das Fenster dabei wiederhergestellt, unter Wine nicht – hier zählt: kein Absturz
ziehen $(( X4 + 400 )) $(( Y4 + 12 )) 0 150; pruefe "aus maximiert herausziehen" || true
if lebt; then kill "$PID" 2>/dev/null; fi
sleep 1
if [ -s "$AUS/absturz.txt" ]; then echo "== Absturzprotokoll:"; cat "$AUS/absturz.txt"; fehler=1; fi
if grep -q "Fatal Python error" "$AUS/app.log"; then cat "$AUS/app.log"; fehler=1; fi
read -r _ _ _ _ _ FE <<< "$(lage)"
[ "${FE:-0}" = "0" ] || { echo "Fehler in Tk-Callbacks: $FE"; fehler=1; }
[ "$fehler" = 0 ] && echo "Maustest bestanden" || echo "Maustest FEHLGESCHLAGEN"
exit "$fehler"
+26 -5
View File
@@ -1,6 +1,10 @@
# PyInstaller-Spezifikation – erzeugt eine einzelne Provisionsmanager95.exe # PyInstaller-Spezifikation – erzeugt eine einzelne Programmdatei (Windows: .exe, Linux: ELF),
# Aufruf (unter Windows oder Wine): pyinstaller --noconfirm build/provisionsmanager.spec # unter Windows zusätzlich den Ordner dist/Provisionsmanager/ (schneller Start, Inhalt des Setups)
# Aufruf: pyinstaller --noconfirm build/provisionsmanager.spec
import os import os
import sys
WIN = sys.platform.startswith("win")
ROOT = os.path.abspath(os.path.join(SPECPATH, "..")) ROOT = os.path.abspath(os.path.join(SPECPATH, ".."))
@@ -8,7 +12,8 @@ a = Analysis(
[os.path.join(ROOT, "build", "start.py")], [os.path.join(ROOT, "build", "start.py")],
pathex=[ROOT], pathex=[ROOT],
hiddenimports=["openpyxl", "xlrd"], hiddenimports=["openpyxl", "xlrd"],
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))], datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder")),
(os.path.join(ROOT, "provtool", "gui", "schriften"), os.path.join("provtool", "gui", "schriften"))],
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"], excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
noarchive=False, noarchive=False,
) )
@@ -20,11 +25,27 @@ exe = EXE(
a.datas, a.datas,
[], [],
name="Provisionsmanager95", name="Provisionsmanager95",
icon=os.path.join(ROOT, "assets", "app.ico"), icon=os.path.join(ROOT, "assets", "app.ico") if WIN else None,
version=os.path.join(ROOT, "build", "version_info.txt"), version=os.path.join(ROOT, "build", "version_info.txt") if WIN else None,
console=False, console=False,
upx=False, upx=False,
debug=False, debug=False,
strip=False, strip=False,
runtime_tmpdir=None, runtime_tmpdir=None,
) )
if WIN:
exe_ordner = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="Provisionsmanager",
icon=os.path.join(ROOT, "assets", "app.ico"),
version=os.path.join(ROOT, "build", "version_info.txt"),
console=False,
upx=False,
debug=False,
strip=False,
)
COLLECT(exe_ordner, a.binaries, a.datas, name="Provisionsmanager", upx=False, strip=False)
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Hängt Dateien an das Gitea-Release des Tags (legt es an, falls es noch fehlt – Windows- und
# Linux-Job laufen parallel, wer zuerst fertig ist, legt es an).
# Aufruf: TOKEN=… API=…/api/v1/repos/<owner>/<repo> TAG=v1.2.3 build/release_hochladen.sh datei…
set -eu
VERSION=$(tr -d '\r\n' < dist/VERSION)
if [ "v$VERSION" != "$TAG" ]; then
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1
fi
release_id() { curl -sf "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2; }
id=$(release_id || true)
if [ -z "$id" ]; then
curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows: Setup installieren (oder die .exe direkt starten). Linux: tar.gz entpacken, Provisionsmanager95 starten (ab Debian 12 / Ubuntu 24.04).\"}" \
>/dev/null || sleep 5 # Gleichzeitig angelegt? Dann gibt es das Release jetzt.
id=$(release_id)
fi
for f in "$@"; do
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
echo "hochgeladen: $f"
done
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.3.0" __version__ = "1.8.1"
APP_NAME = "Provisions-Manager 95" APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach" APP_ORG = "Handy-Insel Miesbach"
+15 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import os import os
import shutil
from pathlib import Path from pathlib import Path
APP_DIR_NAME = "Provisionsmanager95" APP_DIR_NAME = "Provisionsmanager95"
@@ -41,18 +42,31 @@ DEFAULTS = {
def load() -> dict: def load() -> dict:
"""Einstellungen lesen. Ist die Datei unlesbar (z. B. Absturz beim Schreiben), wird sie als
*.defekt.json aufbewahrt und unter "defekt" gemeldet – statt still mit Standardwerten weiterzumachen
(dabei ginge z. B. der Pfad zur Datenbank auf dem Netzlaufwerk verloren)."""
data = dict(DEFAULTS) data = dict(DEFAULTS)
f = _settings_file() f = _settings_file()
if f.exists(): if f.exists():
try: try:
data.update(json.loads(f.read_text(encoding="utf-8"))) data.update(json.loads(f.read_text(encoding="utf-8")))
except (OSError, ValueError): except (OSError, ValueError):
sicherung = f.with_suffix(".defekt.json")
try:
shutil.copyfile(f, sicherung)
except OSError:
pass pass
data["defekt"] = str(sicherung)
return data return data
def save(data: dict) -> None: def save(data: dict) -> None:
_settings_file().write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") """Atomar: erst in eine Hilfsdatei, dann umbenennen – ein Absturz hinterlässt nie eine halbe Datei."""
data = {k: v for k, v in data.items() if k != "defekt"}
f = _settings_file()
tmp = f.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, f)
def db_path(settings: dict | None = None) -> Path: def db_path(settings: dict | None = None) -> Path:
+209 -20
View File
@@ -1,7 +1,9 @@
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen.""" """SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os
import shutil import shutil
import sqlite3 import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
@@ -12,7 +14,7 @@ from provtool import regeln
from provtool.importer import ImportErgebnis from provtool.importer import ImportErgebnis
from provtool.money import apply_percent from provtool.money import apply_percent
SCHEMA_VERSION = 3 SCHEMA_VERSION = 4
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT); CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
@@ -96,6 +98,14 @@ CREATE TABLE IF NOT EXISTS position (
roh TEXT roh TEXT
); );
CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id); CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
-- Abgerechnete Positionen (Fingerabdruck), über alle Abrechnungen hinweg: taucht eine schon
-- ausgezahlte Position in einer späteren Datei/Abrechnung wieder auf, wird sie nicht erneut abgerechnet.
CREATE TABLE IF NOT EXISTS abgerechnet_fp (
fp TEXT PRIMARY KEY,
wann TEXT NOT NULL,
abrechnung TEXT NOT NULL
);
""" """
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
@@ -107,6 +117,28 @@ CAPS_ANBIETER = "vodafone_caps"
CAPS_WERT = "*" CAPS_WERT = "*"
FP_FELDER = ("anbieter", "zeitraum", "provisionsart", "kennung", "datum", "rufnummer", "imei", "vorname", "name",
"tarif", "dienst", "aktionscode", "betrag_orig")
def fingerabdruecke(zeilen: list[dict]) -> list[str]:
"""Fingerabdruck je Position einer Datei. Gleiche Zeilen innerhalb einer Datei (z. B. zwei gleiche
SIM-Karten-Gutschriften) werden durchgezählt, damit beide als eigene Positionen gelten."""
gesehen: dict[str, int] = {}
out = []
for z in zeilen:
roh = "\x1f".join(str(z.get(k) if z.get(k) is not None else "").strip() for k in FP_FELDER)
h = hashlib.sha1(roh.encode("utf-8")).hexdigest()[:24]
gesehen[h] = gesehen.get(h, 0) + 1
out.append(f"{h}#{gesehen[h]}")
return out
class AbrechnungErledigt(Exception):
"""Die Abrechnung ist als erledigt gekennzeichnet (oder enthält abgerechnete Positionen) und darf so
nicht verändert werden."""
def ist_grossgeschrieben(name: str | None) -> bool: def ist_grossgeschrieben(name: str | None) -> bool:
buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"] buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"]
return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben) return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben)
@@ -138,8 +170,6 @@ class Datenbank:
self.con.execute("PRAGMA foreign_keys = ON") self.con.execute("PRAGMA foreign_keys = ON")
self.con.executescript(SCHEMA) self.con.executescript(SCHEMA)
self._migrieren() self._migrieren()
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
self.con.commit()
def _migrieren(self): def _migrieren(self):
spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")} spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")}
@@ -151,9 +181,29 @@ class Datenbank:
self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0") self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0")
if "staffel" not in h_spalten: if "staffel" not in h_spalten:
self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT") self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT")
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),)) 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("INSERT INTO meta VALUES ('schema_version', ?) "
"ON CONFLICT(schluessel) DO UPDATE SET wert=excluded.wert", (str(SCHEMA_VERSION),))
self.con.commit() self.con.commit()
def _fingerabdruecke_nachtragen(self):
"""Für Positionen aus älteren Versionen den Fingerabdruck berechnen."""
dateien = [r[0] for r in self.con.execute("SELECT DISTINCT datei_id FROM position WHERE fp IS NULL")]
for did in dateien:
zeilen = [dict(r) for r in self.con.execute(
"SELECT * FROM position WHERE datei_id IS ? ORDER BY reihenfolge", (did,))]
for pid, fp in zip([z["id"] for z in zeilen], fingerabdruecke(zeilen)):
self.con.execute("UPDATE position SET fp=? WHERE id=? AND fp IS NULL", (fp, pid))
def close(self): def close(self):
self.con.close() self.con.close()
@@ -166,12 +216,26 @@ class Datenbank:
@staticmethod @staticmethod
def restore(quelle: str | Path, ziel: str | Path) -> None: def restore(quelle: str | Path, ziel: str | Path) -> None:
test = sqlite3.connect(str(quelle)) """Spielt eine Sicherung ein. Geprüft wird an einer Kopie (unversehrt, unsere Tabellen, lässt sich
öffnen und migrieren) – erst dann ersetzt sie die Datenbank. Schlägt die Prüfung fehl, bleibt alles."""
ziel = Path(ziel)
probe = ziel.with_suffix(".pruefung.db")
shutil.copyfile(quelle, probe)
try: try:
test.execute("SELECT count(*) FROM haendler").fetchone() con = sqlite3.connect(str(probe))
try:
if con.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("Die Datei ist beschädigt (integrity_check).")
tabellen = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
fehlt = {"haendler", "abrechnung", "position"} - tabellen
if fehlt:
raise ValueError(f"Keine Sicherung des Provisions-Managers (Tabellen fehlen: {', '.join(sorted(fehlt))}).")
finally: finally:
test.close() con.close()
shutil.copyfile(quelle, ziel) Datenbank(probe).close() # öffnet und migriert – wirft, wenn das nicht geht
os.replace(probe, ziel)
finally:
probe.unlink(missing_ok=True)
# ------------------------------------------------------------------ Händler # ------------------------------------------------------------------ Händler
@staticmethod @staticmethod
@@ -319,16 +383,6 @@ class Datenbank:
h = self.haendler(hid) if hid > 0 else None h = self.haendler(hid) if hid > 0 else None
return h.staffel if h and h.staffel is not None else self.standard_staffel() 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 <= 0: # nicht zugeordnet / eigene Filiale
return 100.0
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
(hid, provisionsart)).fetchone()
if r:
return r[0]
h = self.haendler(hid)
return h.standard_prozent if h else 100.0
# ------------------------------------------------------------------ Abrechnungen # ------------------------------------------------------------------ Abrechnungen
def abrechnung_neu(self, titel: str) -> int: def abrechnung_neu(self, titel: str) -> int:
with self.con: with self.con:
@@ -339,6 +393,7 @@ class Datenbank:
def abrechnungen(self) -> list[sqlite3.Row]: def abrechnungen(self) -> list[sqlite3.Row]:
return list(self.con.execute( return list(self.con.execute(
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, " "SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
"(SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id AND p.abgerechnet IS NOT NULL) AS abgerechnet, "
"(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien " "(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien "
"FROM abrechnung a ORDER BY a.geaendert DESC")) "FROM abrechnung a ORDER BY a.geaendert DESC"))
@@ -350,9 +405,20 @@ class Datenbank:
self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid)) self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid))
def abrechnung_loeschen(self, aid: int) -> None: def abrechnung_loeschen(self, aid: int) -> None:
self._keine_abgerechneten("abrechnung_id=?", aid)
with self.con: with self.con:
self.con.execute("DELETE FROM abrechnung WHERE id=?", (aid,)) self.con.execute("DELETE FROM abrechnung WHERE id=?", (aid,))
def _keine_abgerechneten(self, wo: str, wert: int) -> None:
"""Abgerechnete Positionen dürfen nicht gelöscht werden: ihr Fingerabdruck bliebe in abgerechnet_fp
zurück (die Position gälte nach erneutem Import für immer als „doppelt“) – oder, würde man ihn
mitlöschen, könnte sie ein zweites Mal ausgezahlt werden. Zuerst die Kennzeichnung aufheben."""
n = self.con.execute(f"SELECT count(*) FROM position WHERE {wo} AND abgerechnet IS NOT NULL",
(wert,)).fetchone()[0]
if n:
raise AbrechnungErledigt(f"{n} Position(en) sind bereits abgerechnet. Zuerst die Kennzeichnung "
"„abgerechnet“ aufheben (Rechtsklick auf die Positionen), dann löschen.")
def dateien(self, aid: int) -> list[sqlite3.Row]: def dateien(self, aid: int) -> list[sqlite3.Row]:
return list(self.con.execute("SELECT * FROM abrechnung_datei WHERE abrechnung_id=? ORDER BY id", (aid,))) return list(self.con.execute("SELECT * FROM abrechnung_datei WHERE abrechnung_id=? ORDER BY id", (aid,)))
@@ -362,11 +428,18 @@ class Datenbank:
(sha256,))] (sha256,))]
def datei_entfernen(self, datei_id: int) -> None: def datei_entfernen(self, datei_id: int) -> None:
r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone()
if not r:
return
self._nicht_erledigt(r[0])
self._keine_abgerechneten("datei_id=?", datei_id)
with self.con: with self.con:
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,)) self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,)) self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
self._regeln_anwenden(r[0], set()) # weniger Karten -> Jahresbonus-Staffel der übrigen neu
def importieren(self, aid: int, erg: ImportErgebnis) -> int: def importieren(self, aid: int, erg: ImportErgebnis) -> int:
self._nicht_erledigt(aid)
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?", start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
(aid,)).fetchone()[0] (aid,)).fetchone()[0]
with self.con: with self.con:
@@ -384,6 +457,10 @@ class Datenbank:
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent, 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))) json.dumps(p.roh, ensure_ascii=False, default=str)))
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid)) self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
zeilen = [dict(r) for r in self.con.execute(
"SELECT * FROM position WHERE datei_id=? ORDER BY reihenfolge", (did,))]
for z, fp in zip(zeilen, fingerabdruecke(zeilen)):
self.con.execute("UPDATE position SET fp=? WHERE id=?", (fp, z["id"]))
neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))} 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) self._regeln_anwenden(aid, neue, auch_manuelle=True)
return len(erg.positionen) return len(erg.positionen)
@@ -392,11 +469,37 @@ class Datenbank:
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py).""" """Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
finde = self.zuordner() finde = self.zuordner()
fest = self.art_typen() fest = self.art_typen()
erledigt = self.erledigt(aid)
# Fingerabdrücke dieser Abrechnung, die schon abgerechnet sind (in dieser oder einer anderen)
schon = dict(self.con.execute(
"SELECT f.fp, f.abrechnung FROM abgerechnet_fp f WHERE f.fp IN (SELECT fp FROM position WHERE abrechnung_id=?)",
(aid,)).fetchall())
# Offene Doppelte: dieselbe Position (Fingerabdruck) ein zweites Mal offen – z. B. Wochen- und
# Monatsdatei in derselben Abrechnung. Die zuerst importierte zählt, die anderen sind „doppelt“.
erste: dict[str, tuple[int, str]] = {}
for fp, pid, titel in self.con.execute(
"SELECT p.fp, p.id, a.titel FROM position p JOIN abrechnung a ON a.id=p.abrechnung_id "
"WHERE p.abgerechnet IS NULL AND p.fp IN (SELECT fp FROM position WHERE abrechnung_id=?) "
"ORDER BY p.id", (aid,)):
erste.setdefault(fp, (pid, titel))
out = [] out = []
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)): for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
d = dict(r) d = dict(r)
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["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
if erledigt and d["haendler_id"] > 0:
# Erledigt: was dabei nicht abgerechnet wurde, bekommt nachträglich keinen Händler mehr
# (sonst stünde es bei einem später angelegten Händler mit 100 % zur Auszahlung)
d["haendler_id"] = UNZUGEORDNET
d["typ"] = regeln.typ_von(d, fest) d["typ"] = regeln.typ_von(d, fest)
d["doppelt"] = None
if d["fp"] and not d["abgerechnet"]:
if d["fp"] in schon:
d["doppelt"] = schon[d["fp"]]
elif erste.get(d["fp"], (d["id"],))[0] != d["id"]:
d["doppelt"] = erste[d["fp"]][1]
out.append(d) out.append(d)
return out return out
@@ -429,10 +532,10 @@ class Datenbank:
@staticmethod @staticmethod
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]: 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).""" """Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab, Doppelte nicht mit)."""
karten: dict[tuple[int, str], int] = {} karten: dict[tuple[int, str], int] = {}
for p in positionen: for p in positionen:
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"]: if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"] and not p["doppelt"]:
k = (p["haendler_id"], p["anbieter"]) k = (p["haendler_id"], p["anbieter"])
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1) 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()} return {k: max(0, n) for k, n in karten.items()}
@@ -441,12 +544,16 @@ class Datenbank:
"""Berechnet die Auszahlung nach den Händler-Regeln neu: für `pids` (None = alle) und immer für """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). 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.""" 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) alle = self.positionen(aid)
karten = self.karten_zaehlen(alle) karten = self.karten_zaehlen(alle)
rechne = self._rechner() rechne = self._rechner()
n = 0 n = 0
with self.con: with self.con:
for p in alle: 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: if pids is not None and p["id"] not in pids and p["typ"] != regeln.JAHRESBONUS:
continue continue
if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)): if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)):
@@ -468,6 +575,7 @@ class Datenbank:
def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None: def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
"""Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen). """Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen).
Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet.""" Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet."""
pids = self._offene(pids)
with self.con: with self.con:
for pid in pids: for pid in pids:
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid)) self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
@@ -480,10 +588,13 @@ class Datenbank:
self._regeln_anwenden(aid, set(pids), auch_manuelle=True) self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
def betrag_setzen(self, pid: int, cent: int) -> None: def betrag_setzen(self, pid: int, cent: int) -> None:
if not self._offene([pid]):
raise AbrechnungErledigt("Die Position ist bereits abgerechnet.")
with self.con: with self.con:
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid)) self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int: def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
pids = self._offene(pids)
n = 0 n = 0
with self.con: with self.con:
for pid in pids: for pid in pids:
@@ -500,6 +611,84 @@ class Datenbank:
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde.""" Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle) 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)):
if self.erledigt(aid):
continue # erledigt: nichts wird nachträglich abgerechnet
titel = self.abrechnung(aid)["titel"]
with self.con:
for p in self.positionen(aid):
if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
continue
# innerhalb der Transaktion noch einmal: derselbe Fingerabdruck darf nur einmal abgerechnet sein
if p["fp"] and self.con.execute("SELECT 1 FROM abgerechnet_fp WHERE fp=?", (p["fp"],)).fetchone():
continue
self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE id=?",
(wann, p["haendler_id"], p["id"]))
if p["fp"]:
self.con.execute("INSERT 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: def touch(self, aid: int) -> None:
with self.con: with self.con:
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid)) self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
+24 -3
View File
@@ -45,6 +45,14 @@ def dateiname(text: str) -> str:
AIRTIME_TEXT = "Airtime" 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: def airtime_zeile(airtime: list[dict]) -> dict:
"""Eine Zeile „Airtime = Summe“ für die Abrechnung.""" """Eine Zeile „Airtime = Summe“ für die Abrechnung."""
zeitraeume = sorted({p.get("zeitraum") or "" for p in airtime} - {""}) zeitraeume = sorted({p.get("zeitraum") or "" for p in airtime} - {""})
@@ -76,7 +84,7 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
erste = zeile erste = zeile
for p in sortiert(positionen) + list(schluss or []): for p in sortiert(positionen) + list(schluss or []):
werte = [ werte = [
p.get("zeitraum") or None, spalte_a(p),
p.get("provisionsart") or None, p.get("provisionsart") or None,
_zahl_oder_text(p.get("kennung")), _zahl_oder_text(p.get("kennung")),
date.fromisoformat(p["datum"]) if p.get("datum") else None, date.fromisoformat(p["datum"]) if p.get("datum") else None,
@@ -93,8 +101,12 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
] ]
for c, v in enumerate(werte, start=1): for c, v in enumerate(werte, start=1):
cell = ws.cell(zeile, c, v) cell = ws.cell(zeile, c, v)
if isinstance(v, str) and v.startswith("="):
cell.data_type = "s" # Text aus der Abrechnung bleibt Text – nie eine Formel
cell.font = bold if p.get("_summe") and c in (2, 11) else font cell.font = bold if p.get("_summe") and c in (2, 11) else font
ws.cell(zeile, 4).number_format = "DD.MM.YYYY" ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
if isinstance(werte[0], date):
ws.cell(zeile, 1).number_format = "DD.MM.YYYY"
ws.cell(zeile, 5).number_format = "0" ws.cell(zeile, 5).number_format = "0"
ws.cell(zeile, 6).number_format = "0" ws.cell(zeile, 6).number_format = "0"
ws.cell(zeile, 11).number_format = EURO_FORMAT ws.cell(zeile, 11).number_format = EURO_FORMAT
@@ -120,9 +132,11 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *, def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True, pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
null_weglassen: bool = True) -> list[Path]: null_weglassen: bool = True, vergeben: set[str] | None = None) -> list[Path]:
"""Schreibt die Abrechnung eines Händlers (bei pro_anbieter je Anbieter eine) und – falls es Airtime """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 Airtime-Liste. In der Abrechnung steht Airtime nur als eine Summenzeile.
vergeben: Dateinamen (klein geschrieben), die in diesem Export schon geschrieben wurden – gleich
lautende Namen (z. B. Händler „A/B“ und „A:B“) bekommen „ (2)“ statt die Datei zu überschreiben.
Gibt die erzeugten Pfade zurück.""" Gibt die erzeugten Pfade zurück."""
ordner = Path(ordner) ordner = Path(ordner)
if null_weglassen: if null_weglassen:
@@ -132,9 +146,16 @@ def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list
gruppen: dict[str, list[dict]] = {} gruppen: dict[str, list[dict]] = {}
for p in positionen: for p in positionen:
gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p) gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
vergeben = set() if vergeben is None else vergeben
def name(*teile) -> str: def name(*teile) -> str:
return dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t)) + ".xlsx" basis = dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t))
for n in range(1, 1000):
kandidat = basis + (f" ({n})" if n > 1 else "") + ".xlsx"
if kandidat.lower() not in vergeben:
vergeben.add(kandidat.lower())
return kandidat
raise ValueError(f"Kein freier Dateiname für {basis}")
out = [] out = []
for anbieter, pos in sorted(gruppen.items()): for anbieter, pos in sorted(gruppen.items()):
+619 -148
View File
File diff suppressed because it is too large. Load diff
+11 -11
View File
@@ -7,7 +7,6 @@ from collections import Counter, defaultdict
from pathlib import Path from pathlib import Path
from tkinter import filedialog, ttk from tkinter import filedialog, ttk
from provtool import APP_NAME
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
from provtool.gui import win32 from provtool.gui import win32
from provtool.gui import win95 as w from provtool.gui import win95 as w
@@ -33,7 +32,7 @@ class Einrichtungsassistent(w.Dialog):
BREITE, HOEHE = 470, 340 BREITE, HOEHE = 470, 340
def __init__(self, app): def __init__(self, app):
super().__init__(app.root, "Provisions-Manager 95 – Einrichtung") super().__init__(app.root, f"{w.APP_NAME} – Einrichtung")
self.app = app self.app = app
self.dateien: list[str] = [] self.dateien: list[str] = []
self.vorschlaege: dict[str, list[str]] = {"freenet": [], "vodafone": []} self.vorschlaege: dict[str, list[str]] = {"freenet": [], "vodafone": []}
@@ -75,9 +74,9 @@ class Einrichtungsassistent(w.Dialog):
self._img = w.make_icon(self, "app", 7) self._img = w.make_icon(self, "app", 7)
c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="") c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="")
for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))): for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))):
c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill=w.WHITE, outline="") c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill="#ffffff", outline="")
c.create_image(77, self.HOEHE // 2 - 10, image=self._img) c.create_image(77, self.HOEHE // 2 - 10, image=self._img)
c.create_text(77, self.HOEHE - 38, text="Provisions-\nManager 95", fill=w.WHITE, font=w.FONT_BOLD, c.create_text(77, self.HOEHE - 38, text=w.APP_NAME.replace("-", "-\n", 1), fill="#ffffff", font=w.FONT_BOLD,
justify="center") justify="center")
def _leeren(self): def _leeren(self):
@@ -100,7 +99,7 @@ class Einrichtungsassistent(w.Dialog):
self.b_zurueck.configure(state="normal" if nr > 0 else "disabled") self.b_zurueck.configure(state="normal" if nr > 0 else "disabled")
self.b_weiter.configure(text="Fertig stellen" if nr == len(self.seiten) - 1 else "Weiter >") self.b_weiter.configure(text="Fertig stellen" if nr == len(self.seiten) - 1 else "Weiter >")
if self.titelleiste: if self.titelleiste:
self.titelleiste.set_titel(f"Provisions-Manager 95 – Einrichtung (Schritt {nr + 1} von {len(self.seiten)})") self.titelleiste.set_titel(f"{w.APP_NAME} – Einrichtung (Schritt {nr + 1} von {len(self.seiten)})")
def _weiter(self): def _weiter(self):
pruefer = getattr(self, f"_pruefe_{self.nr}", None) pruefer = getattr(self, f"_pruefe_{self.nr}", None)
@@ -128,7 +127,7 @@ class Einrichtungsassistent(w.Dialog):
# ---------------------------------------------------------------- Seite 1 # ---------------------------------------------------------------- Seite 1
def _s_willkommen(self): def _s_willkommen(self):
self._ueberschrift(f"Willkommen beim {APP_NAME}!") self._ueberschrift(f"Willkommen beim {w.APP_NAME}!")
self._text("Dieser Assistent richtet das Programm in wenigen Schritten ein:") self._text("Dieser Assistent richtet das Programm in wenigen Schritten ein:")
for punkt in ("Speicherort der Daten festlegen", for punkt in ("Speicherort der Daten festlegen",
"Unterhändler mit ihren Kennungen anlegen", "Unterhändler mit ihren Kennungen anlegen",
@@ -384,7 +383,7 @@ class Einrichtungsassistent(w.Dialog):
def _s_fertig(self): def _s_fertig(self):
self._ueberschrift("Fertig!") self._ueberschrift("Fertig!")
n = len(self.app.db.haendler_liste()) n = len(self.app.db.haendler_liste())
self._text(f"Der {APP_NAME} ist eingerichtet.") self._text(f"Der {w.APP_NAME} ist eingerichtet.")
self._text(f" - {n} Händler angelegt") self._text(f" - {n} Händler angelegt")
if self.v_abrechnung.get(): if self.v_abrechnung.get():
self._text(f" - Abrechnung „{self.v_titel.get().strip()}“" self._text(f" - Abrechnung „{self.v_titel.get().strip()}“"
@@ -582,13 +581,14 @@ class TippDesTages(w.Dialog):
haupt.pack(padx=12, pady=12) haupt.pack(padx=12, pady=12)
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken") box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
box.pack(side="left", fill="both") box.pack(side="left", fill="both")
self._bild = tipp_bild(self) # Büroklammer nur im Win95-Design; modern eine schlichte Glühbirne
self.bild_ok = self._bild is not None self._bild = None if w.MODERN else tipp_bild(self)
if self._bild is None: # Ersatz, falls das Bild fehlt self.bild_ok = self._bild is not None or w.MODERN
if self._bild is None:
self._bild = w.make_icon(self, "birne", 3) self._bild = w.make_icon(self, "birne", 3)
tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10) tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10)
# fester Textbereich: der Tipp bricht darin um und kann nicht mehr abgeschnitten werden # fester Textbereich: der Tipp bricht darin um und kann nicht mehr abgeschnitten werden
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10, width=380, height=self._bild.height() + 20) rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10, width=380, height=max(self._bild.height(), 170) + 20)
rechts.pack(side="left", fill="both") rechts.pack(side="left", fill="both")
rechts.pack_propagate(False) rechts.pack_propagate(False)
tk.Label(rechts, text="Wussten Sie schon...", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"), tk.Label(rechts, text="Wussten Sie schon...", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+99 -24
View File
@@ -4,16 +4,16 @@ from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import filedialog, ttk from tkinter import filedialog, ttk
from provtool import APP_NAME, APP_ORG, __version__ from provtool import APP_ORG, __version__
from provtool import regeln from provtool import regeln
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler from provtool import zeitraum as zr
from provtool.db import CAPS_ANBIETER, CAPS_WERT, AbrechnungErledigt, Datenbank, Haendler
from provtool.gui import win95 as w from provtool.gui import win95 as w
from provtool.importer import ANBIETER from provtool.importer import ANBIETER
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige # Auswahl im Händler-Dialog: Schlüssel -> Anzeige
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"} KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler" CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
@@ -294,6 +294,12 @@ class HaendlerDialog(w.Dialog):
return return
if self.v_kwert.get().strip(): if self.v_kwert.get().strip():
self._kennung_dazu() self._kennung_dazu()
if self.aktuell and self.aktuell.aktiv and not self.v_aktiv.get() and not w.frage(
self, "Händler deaktivieren",
f"„{name}“ inaktiv setzen?\n\nSeine noch offenen Positionen gelten dann als „nicht zugeordnet“ "
"und zeigen wieder die volle Provision (100 %); exportiert werden sie nicht. Bereits "
"abgerechnete Positionen bleiben, wie sie sind.", ja="Inaktiv setzen", default_ja=False):
return
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(), h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air, 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) self._eigene_staffel if self.v_eigen_staffel.get() else None)
@@ -557,13 +563,16 @@ class ProvisionsartenDialog(w.Dialog):
# =========================================================================== Abrechnungen # =========================================================================== Abrechnungen
class AbrechnungenDialog(w.Dialog): class AbrechnungenDialog(w.Dialog):
def __init__(self, master, db: Datenbank, aktuelle_id: int | None): def __init__(self, master, db: Datenbank, aktuelle_id: int | None, fuer_import: bool = False):
super().__init__(master, "Abrechnung öffnen", resizable=True) """fuer_import: Ziel eines Imports wählen (Ergebnis ("oeffnen", id) oder ("neu",))."""
super().__init__(master, "Import – in welche Abrechnung?" if fuer_import else "Abrechnung öffnen",
resizable=True)
self.db = db self.db = db
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2)) tk.Label(self, text="In welche Abrechnung sollen die Dateien importiert werden?" if fuer_import
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings", else "Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
height=12, selectmode="browse") r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120), show="headings", height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)): ("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
self.tree.heading(c, text=t) self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w") self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
@@ -571,6 +580,10 @@ class AbrechnungenDialog(w.Dialog):
self.tree.bind("<Double-1>", lambda e: self._oeffnen()) self.tree.bind("<Double-1>", lambda e: self._oeffnen())
k = tk.Frame(self, bg=w.FACE) k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10) k.pack(fill="x", padx=10, pady=10)
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, "Ö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, "Löschen", self._loeschen, breite=10).pack(side="left")
@@ -581,8 +594,9 @@ class AbrechnungenDialog(w.Dialog):
def _laden(self, sel=None): def _laden(self, sel=None):
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
for a in self.db.abrechnungen(): for a in self.db.abrechnungen():
status = "erledigt" if a["erledigt"] else (f"{a['abgerechnet']} abgerechnet" if a["abgerechnet"] else "offen")
self.tree.insert("", "end", iid=str(a["id"]), self.tree.insert("", "end", iid=str(a["id"]),
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or "")) values=(a["titel"], status, a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
kids = self.tree.get_children() kids = self.tree.get_children()
if sel and self.tree.exists(str(sel)): if sel and self.tree.exists(str(sel)):
self.tree.selection_set(str(sel)) self.tree.selection_set(str(sel))
@@ -598,6 +612,10 @@ class AbrechnungenDialog(w.Dialog):
self.result = ("oeffnen", self._sel()) self.result = ("oeffnen", self._sel())
self.destroy() self.destroy()
def _neu(self):
self.result = ("neu",)
self.destroy()
def _umbenennen(self): def _umbenennen(self):
aid = self._sel() aid = self._sel()
if not aid: if not aid:
@@ -615,7 +633,11 @@ class AbrechnungenDialog(w.Dialog):
titel = self.db.abrechnung(aid)["titel"] titel = self.db.abrechnung(aid)["titel"]
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?", if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
default_ja=False): default_ja=False):
try:
self.db.abrechnung_loeschen(aid) self.db.abrechnung_loeschen(aid)
except AbrechnungErledigt as exc:
w.info(self, "Löschen", str(exc))
return
self._laden() self._laden()
self.result = ("geloescht", aid) self.result = ("geloescht", aid)
@@ -655,7 +677,11 @@ class DateienDialog(w.Dialog):
name = self.tree.item(s[0], "values")[0] name = self.tree.item(s[0], "values")[0]
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n" if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False): "Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
try:
self.db.datei_entfernen(int(s[0])) self.db.datei_entfernen(int(s[0]))
except AbrechnungErledigt as exc:
w.info(self, "Datei entfernen", str(exc))
return
self.geaendert = True self.geaendert = True
self._laden() self._laden()
@@ -851,10 +877,15 @@ class ExportDialog(w.Dialog):
"""haendler: (id, name, anzahl, summe_cent)""" """haendler: (id, name, anzahl, summe_cent)"""
super().__init__(master, "Abrechnungen exportieren") super().__init__(master, "Abrechnungen exportieren")
self.settings = settings self.settings = settings
f = tk.Frame(self, bg=w.FACE) rumpf = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=10, pady=10) rumpf.pack(fill="both", expand=True, padx=10, pady=10)
# zwei Spalten (links Händler, rechts Zeitraum und Optionen) – passt auch auf kleine Bildschirme
f = tk.Frame(rumpf, bg=w.FACE)
f.pack(side="left", fill="both", expand=True)
rechts = tk.Frame(rumpf, bg=w.FACE)
rechts.pack(side="left", fill="y", padx=(14, 0))
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x") tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8, r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=12,
selectmode="extended") selectmode="extended")
self.tree.heading("#0", text="") self.tree.heading("#0", text="")
self.tree.column("#0", width=40, stretch=False) self.tree.column("#0", width=40, stretch=False)
@@ -874,7 +905,7 @@ class ExportDialog(w.Dialog):
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left") w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4) w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
g = w.gruppe(f, "Optionen") g = w.gruppe(rechts, "Optionen")
g.pack(fill="x", pady=(8, 0)) g.pack(fill="x", pady=(8, 0))
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w") tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
self.v_titel = tk.StringVar(value=titel) self.v_titel = tk.StringVar(value=titel)
@@ -896,12 +927,41 @@ class ExportDialog(w.Dialog):
("Summenzeile unter der Tabelle", self.v_sum), ("Summenzeile unter der Tabelle", self.v_sum),
("Ordner nach dem Export öffnen", self.v_auf)), start=2): ("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.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\n" 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").grid( "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)) row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
g.columnconfigure(1, weight=1) g.columnconfigure(1, weight=1)
z = w.gruppe(rechts, "Zeitraum und Abrechnung")
z.pack(fill="x", before=g)
zf = tk.Frame(z, bg=w.FACE)
zf.pack(fill="x")
tk.Label(zf, text="Nur von:").pack(side="left")
self.v_von, self.v_bis = tk.StringVar(), tk.StringVar()
tk.Entry(zf, textvariable=self.v_von, width=11).pack(side="left", padx=4)
tk.Label(zf, text="bis:").pack(side="left")
tk.Entry(zf, textvariable=self.v_bis, width=11).pack(side="left", padx=4)
w.knopf(zf, "Vormonat", self._vormonat).pack(side="left", padx=(4, 0))
# ohne Zeitraum = alles, was noch nicht abgerechnet ist (abgerechnete Positionen exportiert er nie)
w.knopf(zf, "Noch nicht abgerechnet", lambda: (self.v_von.set(""), self.v_bis.set(""))).pack(
side="left", padx=(4, 0))
self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)])
ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly",
width=40).pack(anchor="w", pady=(4, 0))
tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)",
fg=w.SHADOW, anchor="w", justify="left", wraplength=420).pack(fill="x")
self.v_kennz = tk.BooleanVar(value=True)
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
"einmal abgerechnet)", variable=self.v_kennz, anchor="w", justify="left",
wraplength=400).pack(fill="x", pady=(4, 0))
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _vormonat(self):
von, bis = zr.vormonat()
self.v_von.set(von.strftime("%d.%m.%Y"))
self.v_bis.set(bis.strftime("%d.%m.%Y"))
def _klick(self, e): def _klick(self, e):
if self.tree.identify_column(e.x) == "#0": if self.tree.identify_column(e.x) == "#0":
iid = self.tree.identify_row(e.y) iid = self.tree.identify_row(e.y)
@@ -928,6 +988,12 @@ class ExportDialog(w.Dialog):
if not ids: if not ids:
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.") w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
return return
try:
von, bis = zr.datum_lesen(self.v_von.get()), zr.datum_lesen(self.v_bis.get())
except ValueError as exc:
w.warnung(self, "Zeitraum", str(exc))
return
basis = next(k for k, v in zr.BASEN.items() if v == self.v_basis.get())
if not self.v_ordner.get().strip(): if not self.v_ordner.get().strip():
self._ordner() self._ordner()
if not self.v_ordner.get().strip(): if not self.v_ordner.get().strip():
@@ -938,7 +1004,10 @@ class ExportDialog(w.Dialog):
self.settings.pop("export_pro_anbieter", None) self.settings.pop("export_pro_anbieter", None)
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(), self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.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()) kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get(),
zeitraum=zr.Zeitraum(von, bis, basis) if (von or bis) else None,
kennzeichnen=self.v_kennz.get())
self.settings["export_basis"] = basis
self.destroy() self.destroy()
@@ -960,10 +1029,16 @@ class EinstellungenDialog(w.Dialog):
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w") justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
g2 = w.gruppe(self, "Darstellung") g2 = w.gruppe(self, "Darstellung")
g2.pack(fill="x", padx=10, pady=(0, 10)) g2.pack(fill="x", padx=10, pady=(0, 10))
self.v_klassisch = tk.BooleanVar(value=bool(settings.get("klassischer_rahmen", True))) self.v_design = tk.StringVar(value=settings.get("design", "modern"))
tk.Checkbutton(g2, text="Windows-95-Titelleiste verwenden (sonst die normale von Windows)", tk.Radiobutton(g2, text="Modern hell", variable=self.v_design, value="modern").pack(anchor="w")
variable=self.v_klassisch).pack(anchor="w") tk.Radiobutton(g2, text="Modern dunkel", variable=self.v_design, value="dunkel").pack(anchor="w")
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w") tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack(
anchor="w")
tk.Label(g2, text="Das Fenster wird nach OK mit dem neuen Design neu aufgebaut.", fg=w.SHADOW).pack(anchor="w")
g3 = w.gruppe(self, "Updates")
g3.pack(fill="x", padx=10, pady=(0, 10))
self.v_update = tk.BooleanVar(value=settings.get("update_pruefen", True))
tk.Checkbutton(g3, text="Beim Start nach neuen Versionen suchen", variable=self.v_update).pack(anchor="w")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _waehlen(self): def _waehlen(self):
@@ -974,7 +1049,7 @@ class EinstellungenDialog(w.Dialog):
self.v.set(p) self.v.set(p)
def _ok(self): def _ok(self):
self.result = dict(pfad=self.v.get().strip(), klassisch=self.v_klassisch.get()) self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get(), update=self.v_update.get())
self.destroy() self.destroy()
@@ -982,7 +1057,7 @@ class EinstellungenDialog(w.Dialog):
class InfoDialog(w.Dialog): class InfoDialog(w.Dialog):
def __init__(self, master): def __init__(self, master):
super().__init__(master, f"Info über {APP_NAME}") super().__init__(master, f"Info über {w.APP_NAME}")
top = tk.Frame(self, bg=w.FACE) top = tk.Frame(self, bg=w.FACE)
top.pack(padx=14, pady=14) top.pack(padx=14, pady=14)
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0) c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
@@ -991,7 +1066,7 @@ class InfoDialog(w.Dialog):
c.create_image(57, 57, image=self._img) c.create_image(57, 57, image=self._img)
rechts = tk.Frame(top, bg=w.FACE) rechts = tk.Frame(top, bg=w.FACE)
rechts.pack(side="left", padx=(14, 0), anchor="n") rechts.pack(side="left", padx=(14, 0), anchor="n")
tk.Label(rechts, text=APP_NAME, font=w.FONT_BIG).pack(anchor="w") tk.Label(rechts, text=w.APP_NAME, font=w.FONT_BIG).pack(anchor="w")
tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w") tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w")
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8) w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n" tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
+15 -8
View File
@@ -51,25 +51,28 @@ class Popup:
self.leiste = leiste self.leiste = leiste
root = self.master._root() root = self.master._root()
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised") f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
if w.MODERN:
f.configure(bg=w.WHITE, bd=0, relief="flat", highlightthickness=1, highlightbackground=w.RAND)
f.withdraw() f.withdraw()
f.overrideredirect(True) f.overrideredirect(True)
self.zeilen = [] self.zeilen = []
grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2)
for e in self.eintraege: for e in self.eintraege:
if e["art"] == "trenner": if e["art"] == "trenner":
t = tk.Frame(f, bg=w.FACE, height=8) t = tk.Frame(f, bg=grund, height=8)
t.pack(fill="x") t.pack(fill="x")
linie = w.trenner(t, vertikal=False) linie = w.trenner(t, vertikal=False)
linie.place(relx=0, rely=0.5, relwidth=1, y=-1) linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
continue continue
zeile = tk.Frame(f, bg=w.FACE) zeile = tk.Frame(f, bg=grund)
zeile.pack(fill="x") zeile.pack(fill="x")
grau = e["state"] == "disabled" grau = e["state"] == "disabled"
text = tk.Label(zeile, text=e["label"], bg=w.FACE, anchor="w", padx=18, pady=2, text = tk.Label(zeile, text=e["label"], bg=grund, anchor="w", padx=18, pady=py,
fg=w.SHADOW if grau else w.DARK) fg=w.SHADOW if grau else w.DARK)
text.pack(side="left", fill="x", expand=True) text.pack(side="left", fill="x", expand=True)
labels = [text] labels = [text]
if e["accelerator"]: if e["accelerator"]:
acc = tk.Label(zeile, text=e["accelerator"], bg=w.FACE, anchor="e", padx=12, pady=2, acc = tk.Label(zeile, text=e["accelerator"], bg=grund, anchor="e", padx=12, pady=py,
fg=w.SHADOW if grau else w.DARK) fg=w.SHADOW if grau else w.DARK)
acc.pack(side="right") acc.pack(side="right")
labels.append(acc) labels.append(acc)
@@ -170,8 +173,8 @@ class Popup:
self.aktiv = i self.aktiv = i
for j, (e, zeile, labels) in enumerate(self.zeilen): for j, (e, zeile, labels) in enumerate(self.zeilen):
an = j == i and e.get("state") != "disabled" an = j == i and e.get("state") != "disabled"
bg = w.NAVY if an else w.FACE bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE)
fg = w.WHITE if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK) fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
zeile.configure(bg=bg) zeile.configure(bg=bg)
for lbl in labels: for lbl in labels:
lbl.configure(bg=bg, fg=fg) lbl.configure(bg=bg, fg=fg)
@@ -223,7 +226,8 @@ class Menueleiste(tk.Frame):
self.offener_knopf: tk.Label | None = None self.offener_knopf: tk.Label | None = None
def menue(self, label: str, underline: int | None = 0) -> Popup: def menue(self, label: str, underline: int | None = 0) -> Popup:
knopf = tk.Label(self, text=label, bg=w.FACE, padx=7, pady=2, bd=1, relief="flat", knopf = tk.Label(self, text=label, bg=w.FACE, padx=10 if w.MODERN else 7, pady=6 if w.MODERN else 2,
bd=1, relief="flat",
underline=-1 if underline is None else underline) underline=-1 if underline is None else underline)
knopf.pack(side="left") knopf.pack(side="left")
popup = Popup(self) popup = Popup(self)
@@ -248,7 +252,7 @@ class Menueleiste(tk.Frame):
def _darstellen(self): def _darstellen(self):
for k in self.knoepfe.values(): for k in self.knoepfe.values():
if k is self.offener_knopf: if k is self.offener_knopf:
k.configure(bg=w.NAVY, fg=w.WHITE, relief="flat") k.configure(bg=w.SEL_BG, fg=w.SEL_FG, relief="flat")
else: else:
k.configure(bg=w.FACE, fg=w.DARK, relief="flat") k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
@@ -289,6 +293,9 @@ class Menueleiste(tk.Frame):
def _hover(self, knopf, drin: bool): def _hover(self, knopf, drin: bool):
if self.offener_knopf is None: if self.offener_knopf is None:
if w.MODERN:
knopf.configure(bg=w.LIGHT if drin else w.FACE)
else:
knopf.configure(relief="raised" if drin else "flat") knopf.configure(relief="raised" if drin else "flat")
def _alt(self, knopf): def _alt(self, knopf):
+22 -48
View File
@@ -11,11 +11,17 @@ Größenänderungen also weiter ohne Rahmenaufschlag (sonst würde das Fenster b
Größenanpassung um die Rahmenbreite wachsen). Windows erledigt alles Weitere selbst: Größenanpassung um die Rahmenbreite wachsen). Windows erledigt alles Weitere selbst:
- Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren) - Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren)
- Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab - Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab
- Größe ändern: Am Rand meldet das Fenster per WM_NCHITTEST „linker Rand“ usw. -> Windows zeigt den - Größe ändern: Den Größen-Mauszeiger am Rand zeigt Tk (rahmen.py) – so weiß Tk immer, welcher Zeiger
passenden Mauszeiger und zieht die Größe selbst (flüssig, kein hängender 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 -> WICHTIG:
Verklemmung), nur Win32-Funktionen. - 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 from __future__ import annotations
@@ -27,15 +33,13 @@ from ctypes import wintypes
IS_WIN = sys.platform.startswith("win") IS_WIN = sys.platform.startswith("win")
WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086 WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086
WM_NCLBUTTONDOWN, WM_SYSCOMMAND, WM_SETCURSOR = 0x00A1, 0x0112, 0x0020 WM_NCLBUTTONDOWN, WM_SYSCOMMAND = 0x00A1, 0x0112
HTCLIENT, HTCAPTION, HTTRANSPARENT = 1, 2, -1 HTCLIENT, HTCAPTION = 1, 2
HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17} HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17}
GWL_STYLE, GWL_EXSTYLE = -16, -20 GWL_STYLE, GWL_EXSTYLE = -16, -20
WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000 WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000
WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000 WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000
WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000 WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000
ZEIGER = {"n": 32645, "s": 32645, "w": 32644, "e": 32644, "nw": 32642, "se": 32642, "ne": 32643, "sw": 32643}
HT_RAND = {v: k for k, v in HT.items()}
SWP_NOSIZE, SWP_NOMOVE, SWP_NOZORDER, SWP_NOACTIVATE, SWP_FRAMECHANGED = 0x1, 0x2, 0x4, 0x10, 0x20 SWP_NOSIZE, SWP_NOMOVE, SWP_NOZORDER, SWP_NOACTIVATE, SWP_FRAMECHANGED = 0x1, 0x2, 0x4, 0x10, 0x20
SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020 SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020
@@ -73,9 +77,7 @@ def _api():
("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL), ("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL),
("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT), ("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT),
("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL), ("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL),
("WindowFromPoint", [wintypes.POINT], H), ("WindowFromPoint", [wintypes.POINT], H)):
("LoadCursorW", [wintypes.HINSTANCE, ctypes.c_void_p], wintypes.HANDLE),
("SetCursor", [wintypes.HANDLE], wintypes.HANDLE)):
f = getattr(u, name) f = getattr(u, name)
f.argtypes, f.restype = args, res f.argtypes, f.restype = args, res
c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t] c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t]
@@ -109,11 +111,7 @@ def arbeitsbereich_von(rect: wintypes.RECT) -> wintypes.RECT | None:
class NativerRahmen: class NativerRahmen:
"""Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen. """Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen."""
rand_widgets: Tk-Widgets, die am Fensterrand liegen (deren Randbereich wird für die Maus
„durchsichtig“, damit Windows dort den Größenrahmen erkennt).
"""
def __init__(self, root: tk.Tk, rand: int, ecke: int): def __init__(self, root: tk.Tk, rand: int, ecke: int):
self.root = root self.root = root
@@ -121,11 +119,10 @@ class NativerRahmen:
self.hwnd = None self.hwnd = None
self.aktiv = False self.aktiv = False
self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg
self._rand_hwnds: set[int] = set()
# ------------------------------------------------------------------ Einrichten # ------------------------------------------------------------------ Einrichten
def einrichten(self, rand_widgets: list[tk.Misc]) -> bool: def einrichten(self) -> bool:
"""Nach dem ersten Anzeigen aufrufen (vorher gibt es das äußere Windows-Fenster noch nicht).""" """Sobald Tk das äußere Windows-Fenster angelegt hat (möglichst vor dem ersten Anzeigen)."""
if self.aktiv: if self.aktiv:
return True return True
try: try:
@@ -142,10 +139,6 @@ class NativerRahmen:
u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW) u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW)
if not self._unterklasse(h, self._aussen_proc): if not self._unterklasse(h, self._aussen_proc):
return False return False
for wdg in rand_widgets:
hw = wdg.winfo_id()
if self._unterklasse(hw, self._rand_proc):
self._rand_hwnds.add(hw)
self.aktiv = True self.aktiv = True
# Rahmen neu berechnen lassen (jetzt: unsichtbar) # Rahmen neu berechnen lassen (jetzt: unsichtbar)
u.SetWindowPos(h, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE u.SetWindowPos(h, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
@@ -167,10 +160,9 @@ class NativerRahmen:
"""Hat Tk das äußere Fenster neu angelegt (z. B. nach „wm resizable“)? Dann neu einrichten.""" """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) return self.aktiv and self.aeusseres_fenster() not in (None, self.hwnd)
def neu_einrichten(self, rand_widgets: list[tk.Misc]) -> bool: def neu_einrichten(self) -> bool:
self.aktiv = False self.aktiv = False
self._rand_hwnds.clear() return self.einrichten()
return self.einrichten(rand_widgets)
def _unterklasse(self, h: int, fn) -> bool: def _unterklasse(self, h: int, fn) -> bool:
_, c = _api() _, c = _api()
@@ -229,27 +221,12 @@ class NativerRahmen:
if rand: if rand:
return HT[rand] return HT[rand]
return HTCLIENT return HTCLIENT
if msg == WM_SETCURSOR and (lparam & 0xFFFF) in HT_RAND:
u = _api()[0]
u.SetCursor(u.LoadCursorW(None, ZEIGER[HT_RAND[lparam & 0xFFFF]]))
return 1
if msg == WM_NCDESTROY: if msg == WM_NCDESTROY:
self._entfernen(hwnd) self._entfernen(hwnd)
except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen
pass pass
return c.DefSubclassProc(hwnd, msg, wparam, lparam) return c.DefSubclassProc(hwnd, msg, wparam, lparam)
def _rand_proc(self, hwnd, msg, wparam, lparam, _id, _ref):
_, c = _api()
try:
if msg == WM_NCHITTEST and self.randbereich(*_xy(lparam)):
return HTTRANSPARENT # Maus „fällt durch“ zum äußeren Fenster -> Größenrahmen
if msg == WM_NCDESTROY:
self._entfernen(hwnd)
except Exception:
pass
return c.DefSubclassProc(hwnd, msg, wparam, lparam)
def _entfernen(self, hwnd): def _entfernen(self, hwnd):
proc = self._procs.pop(hwnd, None) proc = self._procs.pop(hwnd, None)
if proc is not None: if proc is not None:
@@ -262,15 +239,16 @@ class NativerRahmen:
# ------------------------------------------------------------------ Bedienung # ------------------------------------------------------------------ Bedienung
def verschieben(self) -> None: def verschieben(self) -> None:
"""Titelleiste gedrückt: Windows zieht das Fenster (mit Andocken an den Bildschirmrand).""" """Titelleiste gedrückt: Windows zieht das Fenster (mit Andocken an den Bildschirmrand).
PostMessage, nicht SendMessage – siehe Modulbeschreibung."""
u, _ = _api() u, _ = _api()
u.ReleaseCapture() u.ReleaseCapture()
u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0) u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
def groesse_ziehen(self, rand: str) -> None: def groesse_ziehen(self, rand: str) -> None:
u, _ = _api() u, _ = _api()
u.ReleaseCapture() u.ReleaseCapture()
u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0) u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0)
def maximiert(self) -> bool: def maximiert(self) -> bool:
return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd)) return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd))
@@ -298,10 +276,6 @@ class NativerRahmen:
u, _ = _api() u, _ = _api()
return u.GetWindowLongW(self.hwnd, GWL_STYLE), u.GetWindowLongW(self.hwnd, GWL_EXSTYLE) return u.GetWindowLongW(self.hwnd, GWL_STYLE), u.GetWindowLongW(self.hwnd, GWL_EXSTYLE)
def fenster_bei(self, x: int, y: int) -> int | None:
"""Für Tests: Welches Fenster bekommt die Maus am Bildschirmpunkt (berücksichtigt HTTRANSPARENT)?"""
return _api()[0].WindowFromPoint(wintypes.POINT(x, y))
def trefferpruefung(self, hwnd: int, x: int, y: int) -> int: def trefferpruefung(self, hwnd: int, x: int, y: int) -> int:
"""Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?""" """Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?"""
lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF) lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
+12 -5
View File
@@ -231,6 +231,7 @@ class Hauptrahmen:
self.maximiert = False self.maximiert = False
self._normal_geo: str | None = None self._normal_geo: str | None = None
self._schliessen = schliessen self._schliessen = schliessen
self.nativ = None
root.minsize(minbreite, minhoehe) root.minsize(minbreite, minhoehe)
if not klassisch: if not klassisch:
self.aussen = tk.Frame(root, bg=w.FACE) self.aussen = tk.Frame(root, bg=w.FACE)
@@ -266,7 +267,6 @@ class Hauptrahmen:
self.aussen.bind("<B1-Motion>", self._resize) self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget)) root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
self.nativ = None
if IS_WIN and NATIVER_RAHMEN: if IS_WIN and NATIVER_RAHMEN:
from provtool.gui.nativ import NativerRahmen from provtool.gui.nativ import NativerRahmen
self.nativ = NativerRahmen(root, self.RAND, self.ECKE) self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
@@ -290,9 +290,9 @@ class Hauptrahmen:
except tk.TclError: except tk.TclError:
sichtbar = True sichtbar = True
if self.nativ.veraltet(): # Tk hat das äußere Fenster neu angelegt if self.nativ.veraltet(): # Tk hat das äußere Fenster neu angelegt
ok = self.nativ.neu_einrichten([self.root, self.aussen]) ok = self.nativ.neu_einrichten()
else: else:
ok = self.nativ.einrichten([self.root, self.aussen]) ok = self.nativ.einrichten()
if ok: if ok:
# Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen # Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen
self._taskleiste_neu = sichtbar self._taskleiste_neu = sichtbar
@@ -414,6 +414,11 @@ class Hauptrahmen:
self._nativ_einrichten() self._nativ_einrichten()
if maximiert and self.klassisch: if maximiert and self.klassisch:
self.root.after(100, self.maximieren) self.root.after(100, self.maximieren)
elif maximiert: # normaler Windows-Rahmen: Windows maximiert selbst
try:
self.root.state("zoomed")
except tk.TclError:
self.root.attributes("-zoomed", True) # Linux/X11
def normale_geometrie(self) -> str: def normale_geometrie(self) -> str:
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry() return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
@@ -502,7 +507,8 @@ class Hauptrahmen:
"sw": "bottom_left_corner"} "sw": "bottom_left_corner"}
def _rand_cursor(self, e): def _rand_cursor(self, e):
if self.maximiert or self.nativ_an: # nativ: Windows zeigt den Zeiger selbst if self.maximiert:
self._cursor_zurueck()
return return
rand = self._welcher_rand(e.x, e.y) rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "") zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
@@ -515,7 +521,8 @@ class Hauptrahmen:
def _resize_start(self, e, rand: str | None = None): def _resize_start(self, e, rand: str | None = None):
self._rand = rand or self._welcher_rand(e.x, e.y) self._rand = rand or self._welcher_rand(e.x, e.y)
if self._rand and self.nativ_an and not self.maximiert: if self._rand and self.nativ_an and not self.maximiert:
# sollte nicht vorkommen (der Rand ist für Windows durchlässig) – dann eben so # 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.nativ.groesse_ziehen(self._rand)
self._rand = "" self._rand = ""
return "break" return "break"
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+18 -6
View File
@@ -58,6 +58,8 @@ def user32():
u.ReleaseCapture.restype = wintypes.BOOL u.ReleaseCapture.restype = wintypes.BOOL
u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
u.SendMessageW.restype = wintypes.LPARAM u.SendMessageW.restype = wintypes.LPARAM
u.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
u.PostMessageW.restype = wintypes.BOOL
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int] u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
u.ShowWindow.restype = wintypes.BOOL u.ShowWindow.restype = wintypes.BOOL
u.IsIconic.argtypes = [wintypes.HWND] u.IsIconic.argtypes = [wintypes.HWND]
@@ -78,6 +80,19 @@ def hwnd(fenster: tk.Misc) -> int | None:
return None return None
def dunkle_titelleiste(fenster: tk.Misc) -> None:
"""Windows-Titelleiste dunkel (Design „dunkel“, ab Windows 10 20H1)."""
h = hwnd(fenster)
if not h:
return
try:
import ctypes
an = ctypes.c_int(1)
ctypes.windll.dwmapi.DwmSetWindowAttribute(h, 20, ctypes.byref(an), ctypes.sizeof(an)) # IMMERSIVE_DARK_MODE
except (OSError, AttributeError):
pass
def stapel(*fenster: tk.Misc) -> list[int]: def stapel(*fenster: tk.Misc) -> list[int]:
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes).""" """Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
ziele = [hwnd(f) for f in fenster] ziele = [hwnd(f) for f in fenster]
@@ -127,7 +142,9 @@ def nativ_verschieben(fenster: tk.Misc) -> bool:
if not h: if not h:
return False return False
user32().ReleaseCapture() user32().ReleaseCapture()
user32().SendMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION # PostMessage statt SendMessage: Die Verschiebe-Schleife von Windows darf nicht innerhalb eines
# Tk-Ereignisses laufen (Tk führt darin Python-Ereignisse aus -> harter Absturz, siehe nativ.py).
user32().PostMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION
return True return True
@@ -174,11 +191,6 @@ def ist_bedienbar(fenster: tk.Misc) -> bool:
return bool(user32().IsWindowEnabled(h)) if h else True return bool(user32().IsWindowEnabled(h)) if h else True
def letztes_popup(fenster: tk.Misc) -> int | None:
h = hwnd(fenster)
return user32().GetLastActivePopup(h) if h else None
class Modal: class Modal:
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des """Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
+309 -43
View File
@@ -1,8 +1,13 @@
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.""" """Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.
Zwei Designs: „modern“ (hell und flach, wie Windows 11 – Standard) und „win95“ (klassisch, Legacy).
design_setzen() muss VOR dem Aufbau der Oberfläche laufen; alle Module lesen die Farben als w.FACE usw.
"""
from __future__ import annotations from __future__ import annotations
import sys import sys
import tkinter as tk import tkinter as tk
from pathlib import Path
from tkinter import ttk from tkinter import ttk
FACE = "#c0c0c0" FACE = "#c0c0c0"
@@ -14,6 +19,38 @@ NAVY = "#000080"
TEAL = "#008080" TEAL = "#008080"
WHITE = "#ffffff" WHITE = "#ffffff"
INFO_BG = "#ffffe1" INFO_BG = "#ffffe1"
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
RAND = SHADOW # dünne Rahmenlinien (nur modern)
DESIGN = "win95"
MODERN = False # „modern“ oder „dunkel“
DUNKEL = False
APP_NAME = "Provisions-Manager 95" # modern: ohne „95“
# Farbnamen: FACE = Fläche (Fenster, Karten, Dialoge), BG = Grund hinter den Karten, WHITE = Eingabefelder/Listen,
# DARK = Text, TEXT2 = Nebentext, SHADOW = leiser Text/Win95-Schatten, NAVY = Akzent, RAND/RAND2 = Linien,
# PRIMAER/AUF_PRIMAER = Hauptknopf, MANUELL = von Hand geänderte Beträge, *_SOFT = Grund der Status-Pills.
_PALETTEN = {
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
SEL_FG="#ffffff", RAND="#808080", BG="#008080", RAND2="#808080", TEXT2="#000000",
PRIMAER="#c0c0c0", AUF_PRIMAER="#000000", ZEBRA="#ffffff", MANUELL="#0000c0", ROT="#a00000",
ROT_SOFT="#c0c0c0", GRUEN="#008000", GRUEN_SOFT="#c0c0c0", BLAU="#0000ff", BLAU_SOFT="#c0c0c0",
GOLD="#808000", GOLD_SOFT="#c0c0c0"),
# Hausfarben der Handy-Insel-Webtools (VVL-Tool): Lila, IBM Plex
"modern": dict(FACE="#ffffff", HILITE="#ffffff", LIGHT="#f1edf8", SHADOW="#6b6386", DARK="#211a38",
NAVY="#5e35b1", TEAL="#f1eef8", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#ede7fa",
SEL_FG="#2d1b69", RAND="#e5e0f0", BG="#f1eef8", RAND2="#d6cee8", TEXT2="#5b5372",
PRIMAER="#2d1b69", AUF_PRIMAER="#ffffff", ZEBRA="#faf8fd", MANUELL="#5e35b1", ROT="#b8223f",
ROT_SOFT="#fbe4e9", GRUEN="#11734d", GRUEN_SOFT="#def3ea", BLAU="#2a59c4", BLAU_SOFT="#e4ebfb",
GOLD="#8a6212", GOLD_SOFT="#f7eed8"),
"dunkel": dict(FACE="#1c1436", HILITE="#1c1436", LIGHT="#2a2049", SHADOW="#948daa", DARK="#ece8f6",
NAVY="#a88beb", TEAL="#120c22", WHITE="#221940", INFO_BG="#2a2049", SEL_BG="#3a2d66",
SEL_FG="#ece8f6", RAND="#312551", BG="#120c22", RAND2="#3f3167", TEXT2="#ada5c6",
PRIMAER="#c3adee", AUF_PRIMAER="#1c1436", ZEBRA="#201838", MANUELL="#c3adee", ROT="#f26982",
ROT_SOFT="#3a1e28", GRUEN="#4fc796", GRUEN_SOFT="#163025", BLAU="#7da0f0", BLAU_SOFT="#1e2743",
GOLD="#d8b058", GOLD_SOFT="#332a17"),
}
globals().update(_PALETTEN["win95"])
IS_WIN = sys.platform.startswith("win") IS_WIN = sys.platform.startswith("win")
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans" FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
@@ -21,7 +58,57 @@ FONT_SIZE = 8 if IS_WIN else 9
FONT = (FONT_FAMILY, FONT_SIZE) FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold") FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold") FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1) MONO_FAMILY = "Courier New" if IS_WIN else "DejaVu Sans Mono"
FONT_MONO = (MONO_FAMILY, FONT_SIZE + 1)
FONT_KLEIN_FETT = FONT_TITEL = FONT_ZAHL = FONT_BOLD
def design_setzen(name: str) -> None:
"""„modern“ (hell), „dunkel“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
global DESIGN, MODERN, DUNKEL, FONT_FAMILY, FONT_SIZE, APP_NAME, MONO_FAMILY
DESIGN = name if name in _PALETTEN else "modern"
MODERN = DESIGN in ("modern", "dunkel")
DUNKEL = DESIGN == "dunkel"
APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95"
globals().update(_PALETTEN[DESIGN])
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
MONO_FAMILY = ("Consolas" if MODERN else "Courier New") if IS_WIN else "DejaVu Sans Mono"
FONT_SIZE = 10 if MODERN else (8 if IS_WIN else 9)
_schriften()
def _schriften():
global FONT, FONT_BOLD, FONT_BIG, FONT_MONO, FONT_KLEIN_FETT, FONT_TITEL, FONT_ZAHL
FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = (MONO_FAMILY, FONT_SIZE + (0 if MODERN else 1))
FONT_KLEIN_FETT = (FONT_FAMILY, FONT_SIZE - 2, "bold") # Abschnitts-Überschriften (ABRECHNUNGEN …)
FONT_TITEL = (FONT_FAMILY, FONT_SIZE + 8, "bold") # Name der geöffneten Abrechnung
FONT_ZAHL = (MONO_FAMILY, FONT_SIZE + 6) if MODERN else (FONT_FAMILY, FONT_SIZE + 4, "bold") # Kennzahlen
SCHRIFTEN = Path(__file__).resolve().parent / "schriften"
def _schriften_laden() -> None:
"""IBM Plex (Hausschrift der Handy-Insel-Webtools) nur für dieses Programm laden – ohne Installation."""
import ctypes
import ctypes.util
dateien = sorted(SCHRIFTEN.glob("*.ttf"))
try:
if IS_WIN:
for f in dateien:
ctypes.windll.gdi32.AddFontResourceExW(str(f), 0x10, 0) # FR_PRIVATE
return
lib = ctypes.util.find_library("fontconfig")
if lib:
fc = ctypes.CDLL(lib)
fc.FcConfigAppFontAddFile.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
for f in dateien:
fc.FcConfigAppFontAddFile(None, str(f).encode())
except Exception:
pass # dann eben Segoe UI / Systemschrift
# --------------------------------------------------------------------------- Pixel-Icons (16x16) # --------------------------------------------------------------------------- Pixel-Icons (16x16)
PALETTE = { PALETTE = {
@@ -260,7 +347,13 @@ def _icon_rows(name: str) -> list[str]:
return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows)) return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows))
MODERNE_ICONS = Path(__file__).resolve().parent / "bilder" / "modern"
def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage: def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage:
datei = MODERNE_ICONS / f"{name}{'_gross' if scale > 1 else ''}.png"
if MODERN and datei.exists(): # glatte Symbole aus build/make_moderne_icons.py
return tk.PhotoImage(master=master, file=str(datei))
img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale) img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale)
for y, row in enumerate(_icon_rows(name)): for y, row in enumerate(_icon_rows(name)):
for x, ch in enumerate(row): for x, ch in enumerate(row):
@@ -288,6 +381,16 @@ class Icons:
# --------------------------------------------------------------------------- Theme # --------------------------------------------------------------------------- Theme
def apply(root: tk.Tk) -> None: def apply(root: tk.Tk) -> None:
global FONT_FAMILY, MONO_FAMILY
if MODERN: # die erste vorhandene moderne Schrift, am liebsten IBM Plex
from tkinter import font as tkfont
_schriften_laden()
vorhanden = set(tkfont.families(root))
FONT_FAMILY = next((f for f in ("IBM Plex Sans", "Segoe UI", "Noto Sans", "Cantarell", "Ubuntu",
"DejaVu Sans") if f in vorhanden), FONT_FAMILY)
MONO_FAMILY = next((f for f in ("IBM Plex Mono", "Consolas", "DejaVu Sans Mono") if f in vorhanden),
MONO_FAMILY)
_schriften()
root.configure(bg=FACE) root.configure(bg=FACE)
o = root.option_add o = root.option_add
o("*Font", FONT) o("*Font", FONT)
@@ -343,6 +446,66 @@ def apply(root: tk.Tk) -> None:
selectforeground=[("readonly", DARK)]) selectforeground=[("readonly", DARK)])
st.configure("TPanedwindow", background=FACE) st.configure("TPanedwindow", background=FACE)
st.configure("Sash", sashthickness=6, background=FACE) st.configure("Sash", sashthickness=6, background=FACE)
if MODERN:
_modern(root, st, zeile)
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
"""Flach wie die Handy-Insel-Webtools: weiße Flächen, dünne Linien, Lila als Akzent."""
o = root.option_add
for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
o(f"*{klasse}.relief", "flat")
o(f"*{klasse}.borderWidth", 4)
o(f"*{klasse}.highlightThickness", 1)
o(f"*{klasse}.highlightBackground", RAND2)
o(f"*{klasse}.highlightColor", NAVY)
o(f"*{klasse}.background", WHITE)
o(f"*{klasse}.foreground", DARK)
o(f"*{klasse}.insertBackground", DARK)
o("*Entry.disabledBackground", LIGHT)
o("*Button.relief", "flat")
o("*Button.borderWidth", 0)
o("*Button.highlightThickness", 1)
o("*Button.highlightBackground", RAND2)
o("*Button.background", FACE)
o("*Button.activeBackground", LIGHT)
o("*Button.padX", 12)
o("*Button.padY", 4)
o("*Button.cursor", "hand2")
for klasse in ("Checkbutton", "Radiobutton"):
o(f"*{klasse}.activeBackground", FACE)
o(f"*{klasse}.selectColor", WHITE)
o("*selectBackground", SEL_BG)
o("*selectForeground", SEL_FG)
o("*Menu.relief", "flat")
o("*TCombobox*Listbox.background", WHITE)
o("*TCombobox*Listbox.foreground", DARK)
o("*TCombobox*Listbox.selectBackground", SEL_BG)
o("*TCombobox*Listbox.selectForeground", SEL_FG)
st.theme_use("clam")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=FACE,
darkcolor=RAND, focuscolor=NAVY, insertcolor=DARK)
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, foreground=DARK, rowheight=zeile + 10,
borderwidth=0, font=FONT)
st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)])
st.configure("Treeview.Heading", background=FACE, foreground=SHADOW, relief="flat", borderwidth=1,
font=FONT_KLEIN_FETT, padding=(10, 7), bordercolor=RAND, lightcolor=FACE, darkcolor=RAND)
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
# Seitenleiste: ohne Kopf, hohe Zeilen, Auswahl lila wie im VVL-Tool
st.configure("Seite.Treeview", background=FACE, fieldbackground=FACE, rowheight=zeile + 16, font=FONT,
borderwidth=0)
st.layout("Seite.Treeview", [("Treeview.treearea", {"sticky": "nswe"})]) # ohne Rahmen
st.map("Seite.Treeview", background=[("selected", SEL_BG)], foreground=[("selected", NAVY)])
st.configure("TScrollbar", background=RAND2, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE,
lightcolor=RAND2, darkcolor=RAND2, relief="flat", arrowsize=10, width=10, gripcount=0)
st.map("TScrollbar", background=[("active", SHADOW)])
st.configure("TCombobox", fieldbackground=WHITE, background=FACE, foreground=DARK, arrowcolor=DARK, padding=4,
bordercolor=RAND2, lightcolor=WHITE, darkcolor=WHITE)
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
st.configure("TPanedwindow", background=BG)
st.configure("Sash", sashthickness=6, background=BG)
# --------------------------------------------------------------------------- Wort löschen # --------------------------------------------------------------------------- Wort löschen
@@ -423,37 +586,20 @@ class Sunken(tk.Frame):
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen).""" """Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
def __init__(self, master, **kw): def __init__(self, master, **kw):
if MODERN:
kw.update(bd=0, relief="flat", highlightthickness=1, highlightbackground=RAND)
kw.setdefault("bd", 2) kw.setdefault("bd", 2)
kw.setdefault("relief", "sunken") kw.setdefault("relief", "sunken")
super().__init__(master, **kw) super().__init__(master, **kw)
class Fenster(tk.Frame):
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
self.titelleiste.pack(fill="x", padx=1, pady=1)
if icon is not None:
tk.Label(self.titelleiste, image=icon, bg=NAVY).pack(side="left", padx=(2, 0))
self.titel_var = tk.StringVar(value=titel)
tk.Label(self.titelleiste, textvariable=self.titel_var, bg=NAVY, fg=WHITE, font=FONT_BOLD,
anchor="w").pack(side="left", fill="x", padx=3)
self.inhalt = tk.Frame(self, bg=FACE)
self.inhalt.pack(fill="both", expand=True, padx=2, pady=2)
def set_titel(self, t: str):
self.titel_var.set(t)
class Statusleiste(tk.Frame): class Statusleiste(tk.Frame):
def __init__(self, master, felder: list[tuple[str, int]]): def __init__(self, master, felder: list[tuple[str, int]]):
super().__init__(master, bg=FACE) super().__init__(master, bg=FACE)
self.vars: dict[str, tk.StringVar] = {} self.vars: dict[str, tk.StringVar] = {}
for i, (key, breite) in enumerate(felder): for i, (key, breite) in enumerate(felder):
v = tk.StringVar() v = tk.StringVar()
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4) lbl = tk.Label(self, textvariable=v, bd=1, relief="flat" if MODERN else "sunken", anchor="w", padx=4)
if breite: if breite:
lbl.configure(width=breite) lbl.configure(width=breite)
lbl.pack(side="left", padx=(0, 2), pady=2) lbl.pack(side="left", padx=(0, 2), pady=2)
@@ -465,9 +611,19 @@ class Statusleiste(tk.Frame):
self.vars[key].set(text) self.vars[key].set(text)
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw): def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None,
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, b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
activebackground=FACE, **kw) activebackground=FACE, **kw)
if primaer:
b.configure(default="active")
if icon is not None: if icon is not None:
b.configure(image=icon, compound="left", padx=6) b.configure(image=icon, compound="left", padx=6)
if breite: if breite:
@@ -475,25 +631,114 @@ def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, br
return b return b
class ToolbarButton(tk.Label): class Karte(tk.Frame):
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick.""" """Fläche, die Inhalte zusammenfasst (Kennzahl, Tabelle): modern weiß mit dünner Linie."""
def __init__(self, master, text: str, icon: tk.PhotoImage, command, tooltip: str = ""): def __init__(self, master, **kw):
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE, if MODERN:
padx=6, pady=2, font=FONT) kw = dict(bg=FACE, bd=0, highlightthickness=1, highlightbackground=RAND, highlightcolor=RAND) | kw
self.command = command else:
self.bind("<Enter>", lambda e: self.configure(relief="raised")) kw = dict(bg=FACE, bd=2, relief="raised") | kw
self.bind("<Leave>", lambda e: self.configure(relief="flat")) super().__init__(master, **kw)
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
self.bind("<ButtonRelease-1>", self._release)
if tooltip:
Tooltip(self, tooltip)
def _release(self, e):
self.configure(relief="raised") def titelleiste(master, text: str = "") -> tuple[tk.Frame, tk.Label]:
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command: """Win95: blaue Titelleiste oben in einer Karte („Fenster im 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() 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 Tooltip: class Tooltip:
def __init__(self, widget, text: str): def __init__(self, widget, text: str):
@@ -528,6 +773,10 @@ class Tooltip:
def trenner(master, vertikal: bool = True): def trenner(master, vertikal: bool = True):
f = tk.Frame(master, bg=FACE) f = tk.Frame(master, bg=FACE)
if MODERN:
tk.Frame(f, width=1, height=1, bg=RAND).pack(side="left", fill="y") if vertikal else \
tk.Frame(f, height=1, bg=RAND).pack(fill="x")
return f
if vertikal: if vertikal:
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y") tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y") tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
@@ -538,18 +787,33 @@ def trenner(master, vertikal: bool = True):
def gruppe(master, titel: str, **kw) -> tk.LabelFrame: def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
if MODERN:
return tk.LabelFrame(master, text=titel, bd=0, relief="flat", bg=FACE, padx=4, pady=2, font=FONT_BOLD, **kw)
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw) return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]: def _bei_bedarf(leiste: ttk.Scrollbar):
rahmen = Sunken(master, bg=WHITE) """Bildlaufleiste nur zeigen, wenn nicht alles hineinpasst."""
def setzen(lo, hi):
leiste.grid() if float(lo) > 0 or float(hi) < 1 else leiste.grid_remove()
leiste.set(lo, hi)
return setzen
def scroll_tree(master, auto: bool = False, **kw) -> tuple[tk.Frame, ttk.Treeview]:
"""Treeview mit Bildlaufleisten (modern nur bei Bedarf). auto: keine waagrechte Leiste (Seitenleiste)."""
rahmen = Sunken(master, bg=WHITE if MODERN else FACE) # Win95: graue Ecke zwischen den Leisten
tree = ttk.Treeview(rahmen, **kw) tree = ttk.Treeview(rahmen, **kw)
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview) vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
tree.grid(row=0, column=0, sticky="nsew") tree.grid(row=0, column=0, sticky="nsew")
vs.grid(row=0, column=1, sticky="ns") vs.grid(row=0, column=1, sticky="ns")
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") hs.grid(row=1, column=0, sticky="ew")
tree.configure(xscrollcommand=_bei_bedarf(hs) if MODERN else hs.set)
tree.configure(yscrollcommand=_bei_bedarf(vs) if auto or MODERN else vs.set)
rahmen.rowconfigure(0, weight=1) rahmen.rowconfigure(0, weight=1)
rahmen.columnconfigure(0, weight=1) rahmen.columnconfigure(0, weight=1)
return rahmen, tree return rahmen, tree
@@ -628,6 +892,8 @@ class Dialog(tk.Toplevel):
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip) self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
self.deiconify() self.deiconify()
self.update_idletasks() self.update_idletasks()
if DUNKEL:
win32.dunkle_titelleiste(self)
if KLASSISCH and win32.IS_WIN: if KLASSISCH and win32.IS_WIN:
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam # eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
if win32.taskleisteneintrag(self): if win32.taskleisteneintrag(self):
@@ -677,7 +943,7 @@ class Dialog(tk.Toplevel):
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame: def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
f = tk.Frame(self, bg=FACE) f = tk.Frame(self, bg=FACE)
for i, (text, cmd) in enumerate(knoepfe): for i, (text, cmd) in enumerate(knoepfe):
b = knopf(f, text, cmd, breite=10) b = knopf(f, text, cmd, breite=10, primaer=MODERN and i == default)
if i == default: if i == default:
b.configure(default="active") b.configure(default="active")
b.pack(side="left", padx=4) b.pack(side="left", padx=4)
+52 -24
View File
@@ -8,7 +8,9 @@ from __future__ import annotations
import csv import csv
import hashlib import hashlib
import io import io
from dataclasses import asdict, dataclass, field import itertools
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -18,6 +20,10 @@ FREENET = "freenet"
VODAFONE = "vodafone" VODAFONE = "vodafone"
ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"} ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"}
# Grenzen gegen versehentlich falsche oder kaputte Dateien (echte Abrechnungen: wenige MB, einige tausend Zeilen)
MAX_BYTES = 100 * 1024 * 1024 # Dateigröße, bei .xlsx auch entpackt
MAX_ZEILEN = 200_000 # Datenzeilen je Tabelle
@dataclass @dataclass
class Position: class Position:
@@ -39,11 +45,6 @@ class Position:
quelle: str = "" quelle: str = ""
roh: dict = field(default_factory=dict) roh: dict = field(default_factory=dict)
def as_dict(self) -> dict:
d = asdict(self)
d["datum"] = self.datum.isoformat() if self.datum else None
return d
@dataclass @dataclass
class ImportErgebnis: class ImportErgebnis:
@@ -183,15 +184,24 @@ FORMATE: list[Format] = [FreenetFormat(), VodafoneFormat()]
# --------------------------------------------------------------------------- Tabellen lesen # --------------------------------------------------------------------------- Tabellen lesen
def _tabellen_aus_rows(rows: list[list], quelle: str, blatt: str = "") -> tuple[Format, list[Position]] | None: def _tabellen_aus_rows(rows: Iterable[list], quelle: str, blatt: str = "",
"""Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter.""" nur_kopf: bool = False) -> tuple[Format, list[Position]] | None:
for idx, row in enumerate(rows[:40]): """Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter.
Ohne Kopfzeile (oder mit nur_kopf) wird der Rest der Tabelle gar nicht gelesen."""
rows = iter(rows)
anfang = list(itertools.islice(rows, 40))
for idx, row in enumerate(anfang):
header = [_norm(c) for c in row] header = [_norm(c) for c in row]
for fmt in FORMATE: for fmt in FORMATE:
if fmt.passt(header): if fmt.passt(header):
pos: list[Position] = [] pos: list[Position] = []
if nur_kopf:
return fmt, pos
leer = 0 leer = 0
for data in rows[idx + 1:]: for nr, data in enumerate(itertools.chain(anfang[idx + 1:], rows)):
if nr >= MAX_ZEILEN:
raise ImportFehler(f"„{quelle}“ hat mehr als {MAX_ZEILEN // 1000}.000 Zeilen – das ist "
"keine Provisionsabrechnung.")
if not any(_text(c) for c in data): if not any(_text(c) for c in data):
leer += 1 leer += 1
if leer > 20: if leer > 20:
@@ -222,7 +232,7 @@ def _decode(raw: bytes) -> str:
return raw.decode("latin-1", errors="replace") return raw.decode("latin-1", errors="replace")
def _lies_csv(path: Path) -> list[tuple[str, list[list]]]: def _lies_csv(path: Path) -> Iterator[tuple[str, Iterable[list]]]:
text = _decode(path.read_bytes()) text = _decode(path.read_bytes())
sample = text[:5000] sample = text[:5000]
try: try:
@@ -230,15 +240,20 @@ def _lies_csv(path: Path) -> list[tuple[str, list[list]]]:
delim = dialect.delimiter delim = dialect.delimiter
except csv.Error: except csv.Error:
delim = ";" delim = ";"
rows = list(csv.reader(io.StringIO(text), delimiter=delim)) yield "", csv.reader(io.StringIO(text), delimiter=delim)
return [("", rows)]
def _lies_xlsx(path: Path) -> list[tuple[str, list[list]]]: def _lies_xlsx(path: Path) -> Iterator[tuple[str, Iterable[list]]]:
import zipfile
import openpyxl import openpyxl
with zipfile.ZipFile(path) as z: # entpackte Größe prüfen (eine .xlsx ist ein Zip-Archiv)
if sum(i.file_size for i in z.infolist()) > MAX_BYTES:
raise ImportFehler(f"„{path.name}“ ist entpackt größer als {MAX_BYTES // 1024 // 1024} MB.")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True) wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
try: try:
return [(ws.title.strip(), [list(r) for r in ws.iter_rows(values_only=True)]) for ws in wb.worksheets] for ws in wb.worksheets: # Zeilen werden erst beim Durchlaufen gelesen (nur Blätter mit Kopfzeile ganz)
yield ws.title.strip(), (list(r) for r in ws.iter_rows(values_only=True))
finally: finally:
wb.close() wb.close()
@@ -265,19 +280,33 @@ def _lies_xls(path: Path) -> list[tuple[str, list[list]]]:
def lies_datei(path: str | Path) -> ImportErgebnis: def lies_datei(path: str | Path) -> ImportErgebnis:
path = Path(path) path = Path(path)
ext = path.suffix.lower() ext = path.suffix.lower()
if ext not in (".csv", ".txt", ".xlsx", ".xlsm", ".xls"):
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.")
if path.stat().st_size > MAX_BYTES:
raise ImportFehler(f"„{path.name}“ ist größer als {MAX_BYTES // 1024 // 1024} MB – das ist keine "
"Provisionsabrechnung.")
if ext in (".csv", ".txt"): if ext in (".csv", ".txt"):
blaetter = _lies_csv(path) blaetter = _lies_csv(path)
elif ext in (".xlsx", ".xlsm"): elif ext in (".xlsx", ".xlsm"):
blaetter = _lies_xlsx(path) blaetter = _lies_xlsx(path)
elif ext == ".xls":
blaetter = _lies_xls(path)
else: else:
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.") blaetter = _lies_xls(path)
gefunden: list[tuple[str, Format, list[Position]]] = [] gefunden: list[tuple[str, Format, list[Position]]] = []
ignoriert: list[str] = []
erstes = None
for name, rows in blaetter: for name, rows in blaetter:
res = _tabellen_aus_rows(rows, path.name, name) if erstes is None:
if res and res[1]: erstes = name
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – weitere Blätter mit Abrechnung
# werden nur erkannt (für den Hinweis), aber nicht mehr gelesen
vodafone_schon = any(f.key == VodafoneFormat.key for _, f, _ in gefunden)
res = _tabellen_aus_rows(rows, path.name, name, nur_kopf=vodafone_schon)
if not res:
continue
if vodafone_schon:
ignoriert.append(name)
elif res[1]:
gefunden.append((name, res[0], res[1])) gefunden.append((name, res[0], res[1]))
if not gefunden: if not gefunden:
@@ -288,11 +317,10 @@ def lies_datei(path: str | Path) -> ImportErgebnis:
) )
warnungen: list[str] = [] warnungen: list[str] = []
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – sie enthält alle # Vodafone/Euronics: die erste Seite ("All") enthält alle Abrechnungsläufe, weitere Blätter würden
# Abrechnungsläufe. Weitere Blätter würden Positionen doppelt zählen. # Positionen doppelt zählen.
if any(f.key == VodafoneFormat.key for _, f, _ in gefunden): if any(f.key == VodafoneFormat.key for _, f, _ in gefunden):
erstes = blaetter[0][0] uebrig = [g[0] for g in gefunden if g[0] != erstes] + ignoriert
uebrig = [g[0] for g in gefunden if g[0] != erstes]
gefunden = [g for g in gefunden if g[0] == erstes] gefunden = [g for g in gefunden if g[0] == erstes]
if uebrig: if uebrig:
warnungen.append(f"Nur die erste Seite „{erstes}“ übernommen, ignoriert: {', '.join(uebrig)}") warnungen.append(f"Nur die erste Seite „{erstes}“ übernommen, ignoriert: {', '.join(uebrig)}")
+2 -1
View File
@@ -122,7 +122,8 @@ def ausfuehren(ergebnisdatei: str) -> int:
# 2) Assistent komplett, Import, Export, Rundgang, Beenden # 2) Assistent komplett, Import, Export, Rundgang, Beenden
csv = basis / "freenet.csv" csv = basis / "freenet.csv"
_beispiel_csv(csv) _beispiel_csv(csv)
s = _Szenario("Assistent, Import, Export", basis / "b") # Win95-Design im normalen Fenster: prüft den nativen Rahmen (Szenario 1: modern, startet maximiert)
s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95", "vollbild": false}')
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731 wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731 seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
+82
View File
@@ -0,0 +1,82 @@
"""Updates: neuestes Release auf Gitea finden und das Windows-Setup herunterladen.
Nur Netzwerk und Dateien – kein Tk (läuft im Hintergrund-Thread). Das Installieren startet die App (gui/app.py).
"""
from __future__ import annotations
import json
import re
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
HOST = "git.handy-insel.de"
API = f"https://{HOST}/api/v1/repos/kerimatilgan/provisionsmanager95/releases/latest"
SETUP = re.compile(r"Provisionsmanager-Setup-[\d.]+\.exe")
MAX_BYTES = 200 * 1024 * 1024
class UpdateFehler(Exception):
pass
def version_tupel(v: str) -> tuple[int, ...]:
"""'v1.10.0' -> (1, 10, 0); Unlesbares -> ()"""
m = re.fullmatch(r"v?(\d+(?:\.\d+)*)", (v or "").strip())
return tuple(int(x) for x in m[1].split(".")) if m else ()
def ist_neuer(neu: str, alt: str) -> bool:
return version_tupel(neu) > version_tupel(alt) and bool(version_tupel(alt))
def _eigene_url(url: str) -> bool:
"""Nur über HTTPS vom eigenen Gitea laden (die Antwort des Servers ist hier die Vertrauensgrenze)."""
u = urlparse(url)
return u.scheme == "https" and u.hostname == HOST
def neueste(timeout: float = 8) -> dict:
"""Neuestes Release: {"version", "seite", "setup_url", "groesse"} (setup_url None, wenn kein Setup dabei)."""
with urllib.request.urlopen(API, timeout=timeout) as r:
daten = json.loads(r.read(1024 * 1024).decode("utf-8"))
setup = next((a for a in daten.get("assets", []) if SETUP.fullmatch(a.get("name", ""))), None)
url = setup.get("browser_download_url") if setup else None
if url and not _eigene_url(url):
url = None
seite = daten.get("html_url") or ""
return {"version": str(daten.get("tag_name", "")).lstrip("v"),
"seite": seite if _eigene_url(seite) else f"https://{HOST}/kerimatilgan/provisionsmanager95/releases",
"setup_url": url, "groesse": int(setup.get("size") or 0) if setup else 0}
def herunterladen(info: dict, ordner: Path, timeout: float = 60) -> Path:
"""Setup nach `ordner` laden und prüfen (Größe wie angekündigt, Windows-Programm). Gibt den Pfad zurück."""
url = info.get("setup_url")
if not url or not _eigene_url(url):
raise UpdateFehler("Für diese Version gibt es kein Setup zum Herunterladen.")
ordner.mkdir(parents=True, exist_ok=True)
ziel = ordner / Path(urlparse(url).path).name
if not SETUP.fullmatch(ziel.name):
raise UpdateFehler(f"Unerwarteter Dateiname: {ziel.name}")
teil = ziel.with_suffix(".teil")
n = 0
try:
with urllib.request.urlopen(url, timeout=timeout) as r, teil.open("wb") as f:
if not _eigene_url(r.geturl()): # Weiterleitung auf einen fremden Server
raise UpdateFehler("Download wurde auf einen fremden Server umgeleitet.")
while block := r.read(1 << 16):
n += len(block)
if n > MAX_BYTES:
raise UpdateFehler("Download ist unerwartet groß.")
f.write(block)
except BaseException:
teil.unlink(missing_ok=True)
raise
with teil.open("rb") as f:
kopf = f.read(2)
if kopf != b"MZ" or (info.get("groesse") and n != info["groesse"]):
teil.unlink(missing_ok=True)
raise UpdateFehler("Download ist unvollständig oder beschädigt – bitte später erneut versuchen.")
teil.replace(ziel)
return ziel
+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)
+240 -20
View File
@@ -29,7 +29,8 @@ class Lauf:
def __init__(self, home, einstellungen: dict | None = None): def __init__(self, home, einstellungen: dict | None = None):
self.home = home self.home = home
if einstellungen is not None: # Tests starten im normalen Fenster (die App sonst maximiert)
einstellungen = {"vollbild": False} | (einstellungen or {})
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8") (home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
self.root = None self.root = None
for versuch in range(3): for versuch in range(3):
@@ -203,9 +204,7 @@ def home(tmp_path, monkeypatch):
return tmp_path return tmp_path
@pytest.fixture def _profil_mit_daten(home):
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
from tests.test_kern import FREENET_KOPF, freenet_zeile from tests.test_kern import FREENET_KOPF, freenet_zeile
csv = home / "freenet.csv" csv = home / "freenet.csv"
csv.write_bytes(("\r\n".join([ csv.write_bytes(("\r\n".join([
@@ -219,6 +218,21 @@ def lauf_mit_daten(home):
from provtool.importer import lies_datei from provtool.importer import lies_datei
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv)) db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
db.close() db.close()
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten), klassisches Win95-Design."""
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": "win95"})
yield lauf
lauf.ende()
@pytest.fixture
def lauf_modern(home):
"""Wie lauf_mit_daten, aber im modernen Design (Standard)."""
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False}) lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
yield lauf yield lauf
lauf.ende() lauf.ende()
@@ -324,8 +338,10 @@ def _dialog_oeffner():
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp", @pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten", "dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
"staffel"]) "staffel"])
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name): @pytest.mark.parametrize("design", ["win95", "modern"])
lauf = lauf_mit_daten def test_schliessen_waehrend_dialog_offen(home, name, design):
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": design})
oeffnen = _dialog_oeffner()[name] oeffnen = _dialog_oeffner()[name]
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"), lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")]) (lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
@@ -836,13 +852,8 @@ def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten):
lauf.ablauf([]) # erst wenn das Hauptfenster zu sehen ist (Größen stimmen dann) lauf.ablauf([]) # erst wenn das Hauptfenster zu sehen ist (Größen stimmen dann)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})() ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})()
r._rand_cursor(ev) r._rand_cursor(ev)
if r.nativ_an: # Den Zeiger verwaltet immer Tk – auch mit nativem Windows-Rahmen (gemeldet in 1.3.0: setzte
# Windows: den Zeiger setzt Windows selbst (WM_SETCURSOR am äußeren Fenster, siehe # Windows ihn am Rand, blieb er im Fenster der Größenpfeil, weil Tk davon nichts wusste).
# test_windows_echtes_fenster_andocken_und_rand) – Tk darf keinen eigenen setzen, der hängen bleibt.
assert r.aussen.cget("cursor") == "", "Tk-Zeiger trotz nativem Rahmen gesetzt"
assert r._welcher_rand(2, 10) == "nw" and r._welcher_rand(r.aussen.winfo_width() - 3, 10) == "ne"
lauf.pruefen()
return
assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen" assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen"
r.inhalt.event_generate("<Enter>") r.inhalt.event_generate("<Enter>")
lauf.root.update() lauf.root.update()
@@ -923,11 +934,6 @@ def test_windows_echtes_fenster_andocken_und_rand(lauf_mit_daten):
m["ecke"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + bh - 3) == nativ.HT["se"] 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["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 m["innen"] = n.trefferpruefung(n.hwnd, x + bw // 2, y + bh // 2) == nativ.HTCLIENT
m["rand_durchlaessig"] = n.trefferpruefung(r.aussen.winfo_id(), x + 2, my) == nativ.HTTRANSPARENT
m["innen_nicht"] = n.trefferpruefung(r.aussen.winfo_id(), x + bw // 2, y + 3 + 30) != nativ.HTTRANSPARENT
# die Maus landet am Rand wirklich beim äußeren Fenster (durch Tk-Fenster hindurch)
m["maus_am_rand"] = n.fenster_bei(x + 2, my) == n.hwnd
m["maus_innen"] = n.fenster_bei(x + bw // 2, y + bh // 2) != n.hwnd
# Tk darf das Fenster bei internen Größenanfragen nicht um einen Rahmen vergrößern # 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 n.setzen(x, y, bw - 37, bh - 23) # wie Größe ziehen durch den Benutzer
@@ -960,8 +966,7 @@ def test_windows_echtes_fenster_andocken_und_rand(lauf_mit_daten):
assert all(m["stil"].values()), f"Fensterstil unvollständig: {m['stil']}" 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["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" 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", "rand_durchlaessig", "innen_nicht", "maus_am_rand", for k in ("links", "ecke", "ecke_lang", "innen"):
"maus_innen"):
assert m[k], f"Trefferprüfung am Rand falsch: {k} ({m})" 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["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"] == (True, True), f"Maximieren über Windows nicht erkannt: {m['max']}"
@@ -1046,3 +1051,218 @@ def test_export_dialog_standard_eine_datei_ohne_null(lauf_mit_daten, tmp_path):
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True) if r[1]] 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" assert [r[1] for r in zeilen] == ["Sonderbonus Aktion"], "0-€-Position wurde exportiert"
lauf.pruefen() 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]
# ... auch der Export (würde sonst offene Positionen abrechnen)
n = len(meldungen)
app.exportieren()
assert lauf.dialog(ExportDialog) is None and len(meldungen) == n + 1 and "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()
def test_suche_und_filter_blenden_nur_aus(lauf_modern):
"""Suche/Filter bauen die Tabelle nicht neu auf: Zeilen werden aus- und wieder eingehängt."""
lauf = lauf_modern
app = lauf.app
alle = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
assert len(alle) == 3
app.v_suche.set("Beispiel")
app._suche_ausfuehren()
sichtbar = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
assert len(sichtbar) == 1 and app.ptree.item(sichtbar[0], "text") == "Beispiel KG"
assert all(app.ptree.exists(i) for i in alle), "ausgeblendete Zeilen bleiben erhalten (nur ausgehängt)"
assert app.kpi["pos"][0].cget("text") == "1"
gruppe = app.ptree.parent(sichtbar[0])
assert "(1)" in app.ptree.item(gruppe, "text") and app.ptree.item(gruppe, "open")
app.ptree.selection_set(gruppe)
assert app._ausgewaehlte_pids() == [int(sichtbar[0][1:])], "Gruppenauswahl = nur sichtbare Zeilen"
app.v_suche.set("")
app._suche_ausfuehren()
assert [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)] == alle
assert app.kpi["pos"][0].cget("text") == "3"
lauf.pruefen()
def test_start_mit_unerreichbarer_datenbank_fragt(home, monkeypatch):
"""Eingestellter (Netz-)Pfad fehlt: Rückfrage statt Absturz – und es wird dort keine leere DB angelegt."""
from provtool import config
from provtool.gui import win95 as w
_profil_mit_daten(home)
weg = home / "netz" / "provisionen.db"
fragen = []
monkeypatch.setattr(w, "_meldung", lambda m, t, text, icon, knoepfe, default=0: fragen.append(text) or "standard")
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "db_pfad": str(weg)})
try:
assert len(fragen) == 1 and str(weg) in fragen[0]
assert not weg.exists() and lauf.app.db.path == config.app_dir() / "provisionen.db"
assert config.load()["db_pfad"] is None, "Wahl „Standard“ wird gespeichert"
assert len(lauf.app.positionen) == 3
finally:
lauf.ende()
lauf.pruefen()
# „Beenden“ gewählt: Programm endet sauber
monkeypatch.setattr(w, "_meldung", lambda *a, **k: "ende")
(home / "einstellungen.json").write_text(json.dumps({"db_pfad": str(weg), "vollbild": False}), encoding="utf-8")
from provtool.gui import app as A
root = tk.Tk()
with pytest.raises(SystemExit):
A.App(root)
assert not weg.exists()
def test_inhalt_erbt_nicht_den_zeiger_der_trennlinie(lauf_modern):
"""Der Resize-Zeiger der Trennlinie blieb unter Windows im ganzen Inhalt hängen (Kinder erben den Zeiger
der Trennlinie). Beide Bereiche haben daher einen eigenen Zeiger."""
app = lauf_modern.app
assert [str(app.root.nametowidget(str(p)).cget("cursor")) for p in app.pw.panes()] == ["arrow", "arrow"]
+351 -1
View File
@@ -165,8 +165,16 @@ def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path):
# Standard: freenet UND Vodafone in einer Datei (gemeldet: Vodafone fehlte im Export) # 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") 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"] assert [d.name for d in eine] == ["Händler A - Oktober 2026.xlsx"]
arten = [r[1] for r in openpyxl.load_workbook(eine[0]).active.iter_rows(values_only=True) if r[1]] 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 assert "Freischaltungsvergütung Vodafone" in arten and "SIM-Karten" in arten
# Spalte A: Vodafone mit Aktivierungsdatum, freenet mit Zeitraum
vf = next(r for r in zeilen1 if r[1] == "Freischaltungsvergütung Vodafone")
assert vf[0] == datetime(2026, 9, 8)
assert next(r for r in zeilen1 if r[1] == "SIM-Karten")[0] == "2026.10.01-2026.10.07"
ws1 = openpyxl.load_workbook(eine[0]).active
zeile_vf = next(c.row for c in ws1["B"] if c.value == "Freischaltungsvergütung Vodafone")
assert ws1.cell(zeile_vf, 1).number_format == "DD.MM.YYYY"
ws = openpyxl.load_workbook(dateien[0]).active ws = openpyxl.load_workbook(dateien[0]).active
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)] zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
# Sortierung Zeitraum → Provisionsart → Datum # Sortierung Zeitraum → Provisionsart → Datum
@@ -411,3 +419,345 @@ def test_migration_v2_haendler_ohne_airtime(tmp_path):
d.standard_staffel_setzen([(1, 100), (5, None)]) d.standard_staffel_setzen([(1, 100), (5, None)])
assert d.staffel_fuer(h.id) == [(1, 100), (5, None)] assert d.staffel_fuer(h.id) == [(1, 100), (5, None)]
d.close() 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))
# (2 davon schon abgerechnet, die 3 anderen sind in „Okt“ noch offen – auch die zählen nur einmal)
doppelt = [p for p in db.positionen(a2) if p["doppelt"]]
assert len(doppelt) == 5 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 -> die Positionen in „Okt“ sind wieder offen und bleiben die zuerst importierten;
# „Nov“ bleibt doppelt. Erst wenn „Okt“ weg ist, ist „Nov“ frei.
assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2
assert all(p["doppelt"] == "Okt" for p in db.positionen(a2))
db.abrechnung_loeschen(a1)
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()
# --------------------------------------------------------------------------- Audit-Befunde (Nachtests)
def test_offene_doppelte_nur_einmal_exportieren_und_abrechnen(db, freenet_csv, tmp_path):
"""Wochen- und Monatsdatei mit denselben Zeilen in EINER Abrechnung: die zweite ist „doppelt“ und
wird weder exportiert noch abgerechnet. Zwei gleiche Zeilen in EINER Datei bleiben zwei Positionen."""
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
monat = tmp_path / "monat.csv"
monat.write_bytes(freenet_csv.read_bytes() + freenet_csv.read_bytes().split(b"\r\n", 1)[1]) # Zeilen 2x
db.importieren(aid, lies_datei(monat))
pos = db.positionen(aid)
assert len(pos) == 15
erste_datei = [p for p in pos if p["datei_id"] == db.dateien(aid)[0]["id"]]
assert not any(p["doppelt"] for p in erste_datei), "die zuerst importierte Datei zählt"
zweite = [p for p in pos if p["datei_id"] != db.dateien(aid)[0]["id"]]
assert sum(1 for p in zweite if p["doppelt"] == "Okt") == 5, "je Zeile ist nur die erste Wiederholung doppelt"
assert sum(1 for p in zweite if not p["doppelt"]) == 5, "zweite Vorkommen innerhalb der Datei (#2) sind eigene Positionen"
# abrechnen aller Händler-Positionen: Doppelte bleiben offen
n = db.abrechnen([p["id"] for p in pos])
offen_haendler = [p for p in db.positionen(aid) if p["haendler_id"] == hid and not p["abgerechnet"]]
assert n == 8 and all(p["doppelt"] for p in offen_haendler)
# und noch einmal: derselbe Fingerabdruck wird in der Transaktion erneut geprüft
assert db.abrechnen([p["id"] for p in db.positionen(aid)]) == 0
def test_staffel_ohne_doppelte_und_datei_entfernen_rechnet_neu(db, bonus_csv, tmp_path):
from provtool import regeln
hid = db.haendler_speichern(Haendler(0, "Sub"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(bonus_csv))
kopie = tmp_path / "bonus2.csv"
kopie.write_bytes(bonus_csv.read_bytes())
db.importieren(aid, lies_datei(kopie))
bonus = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and p["haendler_id"] == hid]
assert len(bonus) == 26 and sum(1 for p in bonus if p["doppelt"]) == 13
echte = [p for p in bonus if not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1000] * 12, "Doppelte zählen nicht als Karten (13, nicht 26)"
# zweite Datei (3 Zeilen) zusätzlich mit anderen Rufnummern -> 16 Karten -> Stufe 15: 15 € je Karte
extra = tmp_path / "extra.csv"
extra.write_bytes(("\r\n".join([FREENET_KOPF] + [_pos("Jahresbonus Neuaktivierung", "40", rufnr=f"17000000{i:02d}")
for i in range(3)]) + "\r\n").encode("cp1252"))
db.importieren(aid, lies_datei(extra))
echte = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1500] * 15
# Datei entfernen -> Staffel der übrigen wieder 10 € je Karte
db.datei_entfernen(db.dateien(aid)[-1]["id"])
echte = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1000] * 12
def test_abgerechnete_positionen_nicht_loeschbar(db, freenet_csv):
"""Datei/Abrechnung mit abgerechneten Positionen: Löschen verweigert (sonst Fingerabdruck-Leichen
oder doppelte Auszahlung). Nach Aufheben der Kennzeichnung geht es – und hinterlässt keine Fingerabdrücke."""
from provtool.db import AbrechnungErledigt
db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
pos = db.positionen(aid)
assert db.abrechnen([pos[0]["id"]]) == 1
with pytest.raises(AbrechnungErledigt):
db.datei_entfernen(db.dateien(aid)[0]["id"])
with pytest.raises(AbrechnungErledigt):
db.abrechnung_loeschen(aid)
assert len(db.positionen(aid)) == 5
db.abrechnung_aufheben([pos[0]["id"]])
db.abrechnung_loeschen(aid)
assert db.con.execute("SELECT count(*) FROM abgerechnet_fp").fetchone()[0] == 0
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
assert not any(p["doppelt"] for p in db.positionen(a2))
def test_erledigt_offene_positionen_bekommen_keinen_haendler_mehr(db, freenet_csv):
"""Erledigt: eine damals nicht zugeordnete Position darf einem später angelegten Händler nicht mit
100 % zur Auszahlung erscheinen; abrechnen() fasst erledigte Abrechnungen nicht an."""
db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
db.erledigt_setzen(aid, True)
hid_b = db.haendler_speichern(Haendler(0, "B", standard_prozent=50), [("freenet", "999")])
p = next(p for p in db.positionen(aid) if p["kennung"] == "999")
assert p["haendler_id"] == UNZUGEORDNET and not p["abgerechnet"]
assert db.abrechnen([p["id"]]) == 0
# in einer offenen Abrechnung gehört dieselbe Kennung sehr wohl zu B (mit 50 %)
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
q = next(p for p in db.positionen(a2) if p["kennung"] == "999")
assert q["haendler_id"] == hid_b and q["betrag"] == -400
def test_export_text_mit_gleichheitszeichen_bleibt_text(tmp_path):
pos = [{"anbieter": "freenet", "zeitraum": "2026.10.01-2026.10.07", "provisionsart": "=1+1", "kennung": "111",
"datum": "2026-10-05", "rufnummer": "1601", "imei": "", "geraet": "", "vorname": "",
"name": '=HYPERLINK("http://böse.example","Klick")', "tarif": "T", "betrag": 800, "betrag_orig": 800,
"netz": "", "dienst": "", "aktionscode": "=cmd|' /C calc'!A0", "typ": "normal", "reihenfolge": 1}]
datei = export.exportiere_haendler(tmp_path, "A", pos, "Okt")[0]
ws = openpyxl.load_workbook(datei).active
zeile = [c for c in ws[1]]
assert zeile[1].value == "=1+1" and zeile[1].data_type == "s"
assert zeile[8].value.startswith("=HYPERLINK") and zeile[8].data_type == "s"
assert zeile[13].data_type == "s"
summe = next(c for row in ws.iter_rows() for c in row if isinstance(c.value, str) and c.value.startswith("=SUM"))
assert summe.data_type == "f", "unsere Summenformel bleibt eine Formel"
def test_export_gleiche_dateinamen_ueberschreiben_sich_nicht(tmp_path):
p = {"anbieter": "freenet", "zeitraum": "", "provisionsart": "X", "kennung": "1", "datum": None, "rufnummer": "",
"imei": "", "geraet": "", "vorname": "", "name": "K", "tarif": "", "betrag": 100, "betrag_orig": 100,
"netz": "", "dienst": "", "aktionscode": "", "typ": "normal", "reihenfolge": 1}
vergeben: set[str] = set()
a = export.exportiere_haendler(tmp_path, "A/B", [p], "Okt", vergeben=vergeben)
b = export.exportiere_haendler(tmp_path, "A:B", [dict(p, betrag=200)], "Okt", vergeben=vergeben)
assert a[0].name == "A_B - Okt.xlsx" and b[0].name == "A_B - Okt (2).xlsx"
assert openpyxl.load_workbook(a[0]).active["K1"].value == 1.0
assert openpyxl.load_workbook(b[0]).active["K1"].value == 2.0
def test_restore_prueft_sicherung(db, freenet_csv, tmp_path):
import sqlite3
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
db.close()
ziel = db.path
vorher = ziel.read_bytes()
# keine SQLite-Datei
muell = tmp_path / "muell.db"
muell.write_bytes(b"kein sqlite")
with pytest.raises(Exception):
Datenbank.restore(muell, ziel)
# SQLite, aber nicht unsere Tabellen
fremd = tmp_path / "fremd.db"
con = sqlite3.connect(fremd)
con.execute("CREATE TABLE haendler (id INTEGER)")
con.commit()
con.close()
with pytest.raises(ValueError):
Datenbank.restore(fremd, ziel)
assert ziel.read_bytes() == vorher and not ziel.with_suffix(".pruefung.db").exists()
# echte Sicherung
d = Datenbank(ziel)
sicherung = tmp_path / "sicherung.db"
d.backup(sicherung)
d.abrechnung_loeschen(aid)
d.close()
Datenbank.restore(sicherung, ziel)
d = Datenbank(ziel)
assert len(d.positionen(aid)) == 5
d.close()
def test_import_grenzen(tmp_path, freenet_csv, vodafone_xlsx, monkeypatch):
from provtool import importer
monkeypatch.setattr(importer, "MAX_ZEILEN", 3)
with pytest.raises(ImportFehler, match="Zeilen"):
lies_datei(freenet_csv)
monkeypatch.setattr(importer, "MAX_ZEILEN", 200_000)
monkeypatch.setattr(importer, "MAX_BYTES", 10)
with pytest.raises(ImportFehler, match="MB"):
lies_datei(freenet_csv)
monkeypatch.setattr(importer, "MAX_BYTES", vodafone_xlsx.stat().st_size + 1) # gepackt klein, entpackt zu groß
with pytest.raises(ImportFehler, match="entpackt"):
lies_datei(vodafone_xlsx)
def test_einstellungen_atomar_und_defekt(tmp_path, monkeypatch):
from provtool import config
monkeypatch.setenv("PROVTOOL_HOME", str(tmp_path))
config.save({"db_pfad": "Z:/x.db", "defekt": "weg"})
assert not (tmp_path / "einstellungen.json.tmp").exists()
assert config.load()["db_pfad"] == "Z:/x.db" and "defekt" not in config.load()
(tmp_path / "einstellungen.json").write_text("{ kaputt", encoding="utf-8")
daten = config.load()
assert daten["db_pfad"] is None and daten["defekt"].endswith("einstellungen.defekt.json")
assert (tmp_path / "einstellungen.defekt.json").read_text(encoding="utf-8") == "{ kaputt"
# --------------------------------------------------------------------------- Updates
class _Antwort:
"""Ersatz für urlopen(): liefert Bytes, kennt seine (End-)URL."""
def __init__(self, daten: bytes, url: str):
import io
self._f, self._url = io.BytesIO(daten), url
def read(self, n=-1):
return self._f.read(n)
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
return False
def test_update_version_vergleich():
from provtool import updater
assert updater.ist_neuer("1.10.0", "1.9.2") and updater.ist_neuer("v1.8.0", "1.7.0")
assert not updater.ist_neuer("1.7.0", "1.7.0") and not updater.ist_neuer("1.6.9", "1.7.0")
assert not updater.ist_neuer("kaputt", "1.7.0") and not updater.ist_neuer("1.8.0", "")
def test_update_neueste_nimmt_nur_eigenes_setup(monkeypatch):
import json
from provtool import updater
basis = "https://git.handy-insel.de/kerimatilgan/provisionsmanager95/releases/download/v1.8.0/"
release = {"tag_name": "v1.8.0", "html_url": "https://git.handy-insel.de/x/releases/tag/v1.8.0", "assets": [
{"name": "Provisionsmanager95.exe", "browser_download_url": basis + "Provisionsmanager95.exe", "size": 5},
{"name": "Provisionsmanager-Setup-1.8.0.exe", "browser_download_url": basis + "Provisionsmanager-Setup-1.8.0.exe",
"size": 7}]}
monkeypatch.setattr(updater.urllib.request, "urlopen",
lambda url, timeout=0: _Antwort(json.dumps(release).encode(), url))
info = updater.neueste()
assert info == {"version": "1.8.0", "seite": release["html_url"],
"setup_url": basis + "Provisionsmanager-Setup-1.8.0.exe", "groesse": 7}
release["assets"][1]["browser_download_url"] = "https://boese.example/Provisionsmanager-Setup-1.8.0.exe"
assert updater.neueste()["setup_url"] is None, "Setup von fremdem Server darf nicht geladen werden"
def test_update_download_wird_geprueft(monkeypatch, tmp_path):
from provtool import updater
url = "https://git.handy-insel.de/r/releases/download/v1.8.0/Provisionsmanager-Setup-1.8.0.exe"
inhalt = {"daten": b"MZ" + b"x" * 98, "url": url}
monkeypatch.setattr(updater.urllib.request, "urlopen", lambda u, timeout=0: _Antwort(inhalt["daten"], inhalt["url"]))
setup = updater.herunterladen({"setup_url": url, "groesse": 100}, tmp_path)
assert setup.name == "Provisionsmanager-Setup-1.8.0.exe" and setup.read_bytes().startswith(b"MZ")
for daten, ende, groesse in ((b"MZ" + b"x" * 10, url, 100), # unvollständig
(b"PK" + b"x" * 98, url, 100), # kein Windows-Programm
(b"MZ" + b"x" * 98, "https://boese.example/a.exe", 100)): # umgeleitet
inhalt.update(daten=daten, url=ende)
setup.unlink(missing_ok=True)
with pytest.raises(updater.UpdateFehler):
updater.herunterladen({"setup_url": url, "groesse": groesse}, tmp_path)
assert not list(tmp_path.iterdir()), "halber Download bleibt liegen"
with pytest.raises(updater.UpdateFehler):
updater.herunterladen({"setup_url": "http://git.handy-insel.de/a/Provisionsmanager-Setup-1.8.0.exe"}, tmp_path)