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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:03:33 +01:00
59 changed files with 2031 additions and 320 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.
# - jeder Push / PR: Tests + .exe als Build-Artefakt
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip
name: Windows-Build
# Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version.
# - jeder Push / PR: Tests + Programmdateien als Build-Artefakte
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip, Setup und Linux-.tar.gz
name: Build
on:
push:
@@ -23,7 +23,7 @@ jobs:
- name: Werkzeuge für Checkout/Upload installieren
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates >/dev/null
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xdotool >/dev/null
- uses: actions/checkout@v4
@@ -37,24 +37,44 @@ jobs:
path: |
dist/Provisionsmanager95.exe
dist/Provisionsmanager95-*.zip
dist/Provisionsmanager-Setup-*.exe
- name: Release erstellen
- name: Release erstellen / ergänzen
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -eu
VERSION=$(cat dist/VERSION | tr -d '\r\n')
if [ "v$VERSION" != "$TAG" ]; then
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1
fi
resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}")
id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
echo "hochgeladen: $f"
done
V=$(tr -d '\r\n' < dist/VERSION)
build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$V.zip dist/Provisionsmanager-Setup-$V.exe
linux:
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: python:3.12-bookworm
steps:
- name: Werkzeuge installieren
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xvfb xauth fonts-dejavu-core >/dev/null
- uses: actions/checkout@v4
- name: Tests + Build (Linux)
run: ./build/build_linux.sh
- name: Build-Artefakt hochladen
uses: actions/upload-artifact@v3
with:
name: Provisionsmanager95-Linux
path: dist/Provisionsmanager95-linux-*.tar.gz
- name: Release ergänzen
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: build/release_hochladen.sh dist/Provisionsmanager95-linux-x86_64-*.tar.gz
+1
View File
@@ -16,3 +16,4 @@ build/version_info.txt
selbsttest.txt
/*.jpg
/*.png
.claude/
+30 -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
fertige Excel-Abrechnung. Optik: Windows 95.
## Installation (Windows)
`Provisionsmanager-Setup-<version>.exe` aus dem Release herunterladen und starten – keine
Admin-Rechte nötig. Das Programm landet in `%LOCALAPPDATA%\Programs\Provisionsmanager` mit
Eintrag im Startmenü (Desktop-Verknüpfung optional) und startet schneller als die einzelne `.exe`.
Ein Update installiert man einfach drüber (ein laufendes Programm wird dafür geschlossen).
Deinstallieren über *Einstellungen ▸ Apps*; Datenbank und Einstellungen in
`%APPDATA%\Provisionsmanager95` bleiben dabei erhalten. Ohne Installation geht weiterhin
`Provisionsmanager95.exe` (bzw. das `.zip`) – einfach starten.
## Erster Start
Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler
@@ -66,10 +76,24 @@ Welche Provisionsart Airtime bzw. Jahresbonus ist, erkennt das Programm an den T
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
*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
wie gewohnt.
**Zeitraum abrechnen:** Im Export-Dialog „Nur von … bis …“ eintragen (oder „Vormonat“; „Noch nicht abgerechnet“ = alles Offene), wahlweise nach
Aktivierungsdatum oder Abrechnungszeitraum des Anbieters. Exportierte Positionen werden als *abgerechnet*
gekennzeichnet (grau) und nie wieder exportiert – auch nicht, wenn dieselbe Position später in einer
anderen Datei oder Abrechnung auftaucht. Versehentlich? Rechtsklick ▸ *Kennzeichnung „abgerechnet“ aufheben*.
*Datei ▸ Abrechnung erledigt / wieder öffnen* sperrt eine fertige Abrechnung gegen Änderungen.
**Design:** Standard ist das Design der Handy-Insel-Webtools (Lila, Schrift IBM Plex – liegt dem Programm bei):
Kopfzeile mit Menüs und Suche, links Abrechnungen und Händler, oben Kennzahlen, über der Tabelle Filter
(nicht zugeordnet, von Hand geändert, doppelt, bereits abgerechnet). Oben rechts „Dunkel“/„Hell“ schaltet
das dunkle Design um. Das Windows-95-Design gibt es weiter unter *Datei ▸ Einstellungen ▸ Darstellung*.
**Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
@@ -107,7 +131,8 @@ Aufbau:
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
| `provtool/export.py` | Excel-Export im Zielformat |
| `provtool/money.py` | Beträge (intern immer in Cent) |
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) |
| `provtool/gui/` | Oberfläche (Tkinter; Designs hell, dunkel, Win95) |
| `provtool/gui/schriften/` | IBM Plex (SIL Open Font License, siehe `OFL.txt`) |
| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung |
| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
@@ -122,7 +147,9 @@ Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Containe
- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip`
- danach das Setup (`build/installer.iss`, Inno Setup unter Wine) bauen, still installieren,
den Selbsttest in der installierten `.exe` laufen lassen und still deinstallieren
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe`, `.zip` und Setup
Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen,
Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 B

After

Width:  |  Height:  |  Size: 24 KiB

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

+89 -27
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
import tkinter as tk
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.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.importer import ANBIETER
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
@@ -557,13 +558,16 @@ class ProvisionsartenDialog(w.Dialog):
# =========================================================================== Abrechnungen
class AbrechnungenDialog(w.Dialog):
def __init__(self, master, db: Datenbank, aktuelle_id: int | None):
super().__init__(master, "Abrechnung öffnen", resizable=True)
def __init__(self, master, db: Datenbank, aktuelle_id: int | None, fuer_import: bool = False):
"""fuer_import: Ziel eines Imports wählen (Ergebnis ("oeffnen", id) oder ("neu",))."""
super().__init__(master, "Import – in welche Abrechnung?" if fuer_import else "Abrechnung öffnen",
resizable=True)
self.db = db
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings",
height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120),
tk.Label(self, text="In welche Abrechnung sollen die Dateien importiert werden?" if fuer_import
else "Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
show="headings", height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
@@ -571,9 +575,13 @@ class AbrechnungenDialog(w.Dialog):
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10)
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
if fuer_import:
w.knopf(k, "Hierhin importieren", self._oeffnen, default="active").pack(side="left")
w.knopf(k, "Neue Abrechnung...", self._neu).pack(side="left", padx=6)
else:
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
self.bind("<Return>", lambda e: self._oeffnen())
self._laden(aktuelle_id)
@@ -581,8 +589,9 @@ class AbrechnungenDialog(w.Dialog):
def _laden(self, sel=None):
self.tree.delete(*self.tree.get_children())
for a in self.db.abrechnungen():
status = "erledigt" if a["erledigt"] else (f"{a['abgerechnet']} abgerechnet" if a["abgerechnet"] else "offen")
self.tree.insert("", "end", iid=str(a["id"]),
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
values=(a["titel"], status, a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
kids = self.tree.get_children()
if sel and self.tree.exists(str(sel)):
self.tree.selection_set(str(sel))
@@ -598,6 +607,10 @@ class AbrechnungenDialog(w.Dialog):
self.result = ("oeffnen", self._sel())
self.destroy()
def _neu(self):
self.result = ("neu",)
self.destroy()
def _umbenennen(self):
aid = self._sel()
if not aid:
@@ -655,7 +668,11 @@ class DateienDialog(w.Dialog):
name = self.tree.item(s[0], "values")[0]
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
self.db.datei_entfernen(int(s[0]))
try:
self.db.datei_entfernen(int(s[0]))
except AbrechnungErledigt as exc:
w.info(self, "Datei entfernen", str(exc))
return
self.geaendert = True
self._laden()
@@ -851,10 +868,15 @@ class ExportDialog(w.Dialog):
"""haendler: (id, name, anzahl, summe_cent)"""
super().__init__(master, "Abrechnungen exportieren")
self.settings = settings
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=10, pady=10)
rumpf = tk.Frame(self, bg=w.FACE)
rumpf.pack(fill="both", expand=True, padx=10, pady=10)
# zwei Spalten (links Händler, rechts Zeitraum und Optionen) – passt auch auf kleine Bildschirme
f = tk.Frame(rumpf, bg=w.FACE)
f.pack(side="left", fill="both", expand=True)
rechts = tk.Frame(rumpf, bg=w.FACE)
rechts.pack(side="left", fill="y", padx=(14, 0))
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8,
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=12,
selectmode="extended")
self.tree.heading("#0", text="")
self.tree.column("#0", width=40, stretch=False)
@@ -874,7 +896,7 @@ class ExportDialog(w.Dialog):
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
g = w.gruppe(f, "Optionen")
g = w.gruppe(rechts, "Optionen")
g.pack(fill="x", pady=(8, 0))
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
self.v_titel = tk.StringVar(value=titel)
@@ -896,12 +918,41 @@ class ExportDialog(w.Dialog):
("Summenzeile unter der Tabelle", self.v_sum),
("Ordner nach dem Export öffnen", self.v_auf)), start=2):
tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w")
tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine\n"
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid(
tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine "
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w",
wraplength=420).grid(
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
g.columnconfigure(1, weight=1)
z = w.gruppe(rechts, "Zeitraum und Abrechnung")
z.pack(fill="x", before=g)
zf = tk.Frame(z, bg=w.FACE)
zf.pack(fill="x")
tk.Label(zf, text="Nur von:").pack(side="left")
self.v_von, self.v_bis = tk.StringVar(), tk.StringVar()
tk.Entry(zf, textvariable=self.v_von, width=11).pack(side="left", padx=4)
tk.Label(zf, text="bis:").pack(side="left")
tk.Entry(zf, textvariable=self.v_bis, width=11).pack(side="left", padx=4)
w.knopf(zf, "Vormonat", self._vormonat).pack(side="left", padx=(4, 0))
# ohne Zeitraum = alles, was noch nicht abgerechnet ist (abgerechnete Positionen exportiert er nie)
w.knopf(zf, "Noch nicht abgerechnet", lambda: (self.v_von.set(""), self.v_bis.set(""))).pack(
side="left", padx=(4, 0))
self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)])
ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly",
width=40).pack(anchor="w", pady=(4, 0))
tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)",
fg=w.SHADOW, anchor="w", justify="left", wraplength=420).pack(fill="x")
self.v_kennz = tk.BooleanVar(value=True)
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
"einmal abgerechnet)", variable=self.v_kennz, anchor="w", justify="left",
wraplength=400).pack(fill="x", pady=(4, 0))
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _vormonat(self):
von, bis = zr.vormonat()
self.v_von.set(von.strftime("%d.%m.%Y"))
self.v_bis.set(bis.strftime("%d.%m.%Y"))
def _klick(self, e):
if self.tree.identify_column(e.x) == "#0":
iid = self.tree.identify_row(e.y)
@@ -928,6 +979,12 @@ class ExportDialog(w.Dialog):
if not ids:
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
return
try:
von, bis = zr.datum_lesen(self.v_von.get()), zr.datum_lesen(self.v_bis.get())
except ValueError as exc:
w.warnung(self, "Zeitraum", str(exc))
return
basis = next(k for k, v in zr.BASEN.items() if v == self.v_basis.get())
if not self.v_ordner.get().strip():
self._ordner()
if not self.v_ordner.get().strip():
@@ -938,7 +995,10 @@ class ExportDialog(w.Dialog):
self.settings.pop("export_pro_anbieter", None)
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
pro_anbieter=self.v_anb.get(), 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()
@@ -960,10 +1020,12 @@ class EinstellungenDialog(w.Dialog):
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
g2 = w.gruppe(self, "Darstellung")
g2.pack(fill="x", padx=10, pady=(0, 10))
self.v_klassisch = tk.BooleanVar(value=bool(settings.get("klassischer_rahmen", True)))
tk.Checkbutton(g2, text="Windows-95-Titelleiste verwenden (sonst die normale von Windows)",
variable=self.v_klassisch).pack(anchor="w")
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w")
self.v_design = tk.StringVar(value=settings.get("design", "modern"))
tk.Radiobutton(g2, text="Modern hell", variable=self.v_design, value="modern").pack(anchor="w")
tk.Radiobutton(g2, text="Modern dunkel", variable=self.v_design, value="dunkel").pack(anchor="w")
tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack(
anchor="w")
tk.Label(g2, text="Das Fenster wird nach OK mit dem neuen Design neu aufgebaut.", fg=w.SHADOW).pack(anchor="w")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _waehlen(self):
@@ -974,7 +1036,7 @@ class EinstellungenDialog(w.Dialog):
self.v.set(p)
def _ok(self):
self.result = dict(pfad=self.v.get().strip(), klassisch=self.v_klassisch.get())
self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get())
self.destroy()
@@ -982,7 +1044,7 @@ class EinstellungenDialog(w.Dialog):
class InfoDialog(w.Dialog):
def __init__(self, master):
super().__init__(master, f"Info über {APP_NAME}")
super().__init__(master, f"Info über {w.APP_NAME}")
top = tk.Frame(self, bg=w.FACE)
top.pack(padx=14, pady=14)
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
@@ -991,7 +1053,7 @@ class InfoDialog(w.Dialog):
c.create_image(57, 57, image=self._img)
rechts = tk.Frame(top, bg=w.FACE)
rechts.pack(side="left", padx=(14, 0), anchor="n")
tk.Label(rechts, text=APP_NAME, font=w.FONT_BIG).pack(anchor="w")
tk.Label(rechts, text=w.APP_NAME, font=w.FONT_BIG).pack(anchor="w")
tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w")
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
+16 -9
View File
@@ -51,25 +51,28 @@ class Popup:
self.leiste = leiste
root = self.master._root()
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
if w.MODERN:
f.configure(bg=w.WHITE, bd=0, relief="flat", highlightthickness=1, highlightbackground=w.RAND)
f.withdraw()
f.overrideredirect(True)
self.zeilen = []
grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2)
for e in self.eintraege:
if e["art"] == "trenner":
t = tk.Frame(f, bg=w.FACE, height=8)
t = tk.Frame(f, bg=grund, height=8)
t.pack(fill="x")
linie = w.trenner(t, vertikal=False)
linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
continue
zeile = tk.Frame(f, bg=w.FACE)
zeile = tk.Frame(f, bg=grund)
zeile.pack(fill="x")
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)
text.pack(side="left", fill="x", expand=True)
labels = [text]
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)
acc.pack(side="right")
labels.append(acc)
@@ -170,8 +173,8 @@ class Popup:
self.aktiv = i
for j, (e, zeile, labels) in enumerate(self.zeilen):
an = j == i and e.get("state") != "disabled"
bg = w.NAVY if an else w.FACE
fg = w.WHITE if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE)
fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
zeile.configure(bg=bg)
for lbl in labels:
lbl.configure(bg=bg, fg=fg)
@@ -223,7 +226,8 @@ class Menueleiste(tk.Frame):
self.offener_knopf: tk.Label | None = None
def menue(self, label: str, underline: int | None = 0) -> Popup:
knopf = tk.Label(self, text=label, bg=w.FACE, padx=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)
knopf.pack(side="left")
popup = Popup(self)
@@ -248,7 +252,7 @@ class Menueleiste(tk.Frame):
def _darstellen(self):
for k in self.knoepfe.values():
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:
k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
@@ -289,7 +293,10 @@ class Menueleiste(tk.Frame):
def _hover(self, knopf, drin: bool):
if self.offener_knopf is None:
knopf.configure(relief="raised" if drin else "flat")
if w.MODERN:
knopf.configure(bg=w.LIGHT if drin else w.FACE)
else:
knopf.configure(relief="raised" if drin else "flat")
def _alt(self, knopf):
self.oeffnen(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:
- Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren)
- Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab
- Größe ändern: Am Rand meldet das Fenster per WM_NCHITTEST „linker Rand“ usw. -> Windows zeigt den
passenden Mauszeiger und zieht die Größe selbst (flüssig, kein hängender Zeiger).
- Größe ändern: Den Größen-Mauszeiger am Rand zeigt Tk (rahmen.py) – so weiß Tk immer, welcher Zeiger
gilt, und setzt ihn beim Zurückfahren in das Fenster wieder richtig. Beim Drücken auf den Rand zieht
Windows die Größe (flüssig).
WICHTIG: Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre ->
Verklemmung), nur Win32-Funktionen.
WICHTIG:
- Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre ->
Verklemmung), nur Win32-Funktionen.
- Verschieben/Größe ziehen NIE synchron (SendMessage) aus einem Tk-Ereignis heraus starten: Windows
läuft dann in einer eigenen Schleife, in der Tk Python-Ereignisse (z. B. <Configure>) ausführt,
während Python das nicht erwartet -> harter Absturz („PyEval_RestoreThread … thread state is
NULL“). Deshalb PostMessage: Die Schleife startet, sobald Tk wieder in seiner Ereignisschleife ist.
"""
from __future__ import annotations
@@ -27,15 +33,13 @@ from ctypes import wintypes
IS_WIN = sys.platform.startswith("win")
WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086
WM_NCLBUTTONDOWN, WM_SYSCOMMAND, WM_SETCURSOR = 0x00A1, 0x0112, 0x0020
HTCLIENT, HTCAPTION, HTTRANSPARENT = 1, 2, -1
WM_NCLBUTTONDOWN, WM_SYSCOMMAND = 0x00A1, 0x0112
HTCLIENT, HTCAPTION = 1, 2
HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17}
GWL_STYLE, GWL_EXSTYLE = -16, -20
WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000
WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000
WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000
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
SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020
@@ -73,9 +77,7 @@ def _api():
("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL),
("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT),
("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL),
("WindowFromPoint", [wintypes.POINT], H),
("LoadCursorW", [wintypes.HINSTANCE, ctypes.c_void_p], wintypes.HANDLE),
("SetCursor", [wintypes.HANDLE], wintypes.HANDLE)):
("WindowFromPoint", [wintypes.POINT], H)):
f = getattr(u, name)
f.argtypes, f.restype = args, res
c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t]
@@ -109,11 +111,7 @@ def arbeitsbereich_von(rect: wintypes.RECT) -> wintypes.RECT | None:
class NativerRahmen:
"""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).
"""
"""Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen."""
def __init__(self, root: tk.Tk, rand: int, ecke: int):
self.root = root
@@ -121,11 +119,10 @@ class NativerRahmen:
self.hwnd = None
self.aktiv = False
self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg
self._rand_hwnds: set[int] = set()
# ------------------------------------------------------------------ Einrichten
def einrichten(self, rand_widgets: list[tk.Misc]) -> bool:
"""Nach dem ersten Anzeigen aufrufen (vorher gibt es das äußere Windows-Fenster noch nicht)."""
def einrichten(self) -> bool:
"""Sobald Tk das äußere Windows-Fenster angelegt hat (möglichst vor dem ersten Anzeigen)."""
if self.aktiv:
return True
try:
@@ -142,10 +139,6 @@ class NativerRahmen:
u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW)
if not self._unterklasse(h, self._aussen_proc):
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
# Rahmen neu berechnen lassen (jetzt: unsichtbar)
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."""
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._rand_hwnds.clear()
return self.einrichten(rand_widgets)
return self.einrichten()
def _unterklasse(self, h: int, fn) -> bool:
_, c = _api()
@@ -229,27 +221,12 @@ class NativerRahmen:
if rand:
return HT[rand]
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:
self._entfernen(hwnd)
except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen
pass
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):
proc = self._procs.pop(hwnd, None)
if proc is not None:
@@ -262,15 +239,16 @@ class NativerRahmen:
# ------------------------------------------------------------------ Bedienung
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.ReleaseCapture()
u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
def groesse_ziehen(self, rand: str) -> None:
u, _ = _api()
u.ReleaseCapture()
u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0)
u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0)
def maximiert(self) -> bool:
return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd))
@@ -298,10 +276,6 @@ class NativerRahmen:
u, _ = _api()
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:
"""Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?"""
lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
+12 -5
View File
@@ -231,6 +231,7 @@ class Hauptrahmen:
self.maximiert = False
self._normal_geo: str | None = None
self._schliessen = schliessen
self.nativ = None
root.minsize(minbreite, minhoehe)
if not klassisch:
self.aussen = tk.Frame(root, bg=w.FACE)
@@ -266,7 +267,6 @@ class Hauptrahmen:
self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
self.nativ = None
if IS_WIN and NATIVER_RAHMEN:
from provtool.gui.nativ import NativerRahmen
self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
@@ -290,9 +290,9 @@ class Hauptrahmen:
except tk.TclError:
sichtbar = True
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:
ok = self.nativ.einrichten([self.root, self.aussen])
ok = self.nativ.einrichten()
if ok:
# Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen
self._taskleiste_neu = sichtbar
@@ -414,6 +414,11 @@ class Hauptrahmen:
self._nativ_einrichten()
if maximiert and self.klassisch:
self.root.after(100, self.maximieren)
elif maximiert: # normaler Windows-Rahmen: Windows maximiert selbst
try:
self.root.state("zoomed")
except tk.TclError:
self.root.attributes("-zoomed", True) # Linux/X11
def normale_geometrie(self) -> str:
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
@@ -502,7 +507,8 @@ class Hauptrahmen:
"sw": "bottom_left_corner"}
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
rand = self._welcher_rand(e.x, e.y)
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):
self._rand = rand or self._welcher_rand(e.x, e.y)
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._rand = ""
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 -1
View File
@@ -58,6 +58,8 @@ def user32():
u.ReleaseCapture.restype = wintypes.BOOL
u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
u.SendMessageW.restype = wintypes.LPARAM
u.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
u.PostMessageW.restype = wintypes.BOOL
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
u.ShowWindow.restype = wintypes.BOOL
u.IsIconic.argtypes = [wintypes.HWND]
@@ -78,6 +80,19 @@ def hwnd(fenster: tk.Misc) -> int | None:
return None
def dunkle_titelleiste(fenster: tk.Misc) -> None:
"""Windows-Titelleiste dunkel (Design „dunkel“, ab Windows 10 20H1)."""
h = hwnd(fenster)
if not h:
return
try:
import ctypes
an = ctypes.c_int(1)
ctypes.windll.dwmapi.DwmSetWindowAttribute(h, 20, ctypes.byref(an), ctypes.sizeof(an)) # IMMERSIVE_DARK_MODE
except (OSError, AttributeError):
pass
def stapel(*fenster: tk.Misc) -> list[int]:
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
ziele = [hwnd(f) for f in fenster]
@@ -127,7 +142,9 @@ def nativ_verschieben(fenster: tk.Misc) -> bool:
if not h:
return False
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
+336 -13
View File
@@ -1,8 +1,13 @@
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine."""
"""Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.
Zwei Designs: „modern“ (hell und flach, wie Windows 11 – Standard) und „win95“ (klassisch, Legacy).
design_setzen() muss VOR dem Aufbau der Oberfläche laufen; alle Module lesen die Farben als w.FACE usw.
"""
from __future__ import annotations
import sys
import tkinter as tk
from pathlib import Path
from tkinter import ttk
FACE = "#c0c0c0"
@@ -14,6 +19,38 @@ NAVY = "#000080"
TEAL = "#008080"
WHITE = "#ffffff"
INFO_BG = "#ffffe1"
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
RAND = SHADOW # dünne Rahmenlinien (nur modern)
DESIGN = "win95"
MODERN = False # „modern“ oder „dunkel“
DUNKEL = False
APP_NAME = "Provisions-Manager 95" # modern: ohne „95“
# Farbnamen: FACE = Fläche (Fenster, Karten, Dialoge), BG = Grund hinter den Karten, WHITE = Eingabefelder/Listen,
# DARK = Text, TEXT2 = Nebentext, SHADOW = leiser Text/Win95-Schatten, NAVY = Akzent, RAND/RAND2 = Linien,
# PRIMAER/AUF_PRIMAER = Hauptknopf, MANUELL = von Hand geänderte Beträge, *_SOFT = Grund der Status-Pills.
_PALETTEN = {
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
SEL_FG="#ffffff", RAND="#808080", BG="#008080", RAND2="#808080", TEXT2="#000000",
PRIMAER="#c0c0c0", AUF_PRIMAER="#000000", ZEBRA="#ffffff", MANUELL="#0000c0", ROT="#a00000",
ROT_SOFT="#c0c0c0", GRUEN="#008000", GRUEN_SOFT="#c0c0c0", BLAU="#0000ff", BLAU_SOFT="#c0c0c0",
GOLD="#808000", GOLD_SOFT="#c0c0c0"),
# Hausfarben der Handy-Insel-Webtools (VVL-Tool): Lila, IBM Plex
"modern": dict(FACE="#ffffff", HILITE="#ffffff", LIGHT="#f1edf8", SHADOW="#6b6386", DARK="#211a38",
NAVY="#5e35b1", TEAL="#f1eef8", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#ede7fa",
SEL_FG="#2d1b69", RAND="#e5e0f0", BG="#f1eef8", RAND2="#d6cee8", TEXT2="#5b5372",
PRIMAER="#2d1b69", AUF_PRIMAER="#ffffff", ZEBRA="#faf8fd", MANUELL="#5e35b1", ROT="#b8223f",
ROT_SOFT="#fbe4e9", GRUEN="#11734d", GRUEN_SOFT="#def3ea", BLAU="#2a59c4", BLAU_SOFT="#e4ebfb",
GOLD="#8a6212", GOLD_SOFT="#f7eed8"),
"dunkel": dict(FACE="#1c1436", HILITE="#1c1436", LIGHT="#2a2049", SHADOW="#948daa", DARK="#ece8f6",
NAVY="#a88beb", TEAL="#120c22", WHITE="#221940", INFO_BG="#2a2049", SEL_BG="#3a2d66",
SEL_FG="#ece8f6", RAND="#312551", BG="#120c22", RAND2="#3f3167", TEXT2="#ada5c6",
PRIMAER="#c3adee", AUF_PRIMAER="#1c1436", ZEBRA="#201838", MANUELL="#c3adee", ROT="#f26982",
ROT_SOFT="#3a1e28", GRUEN="#4fc796", GRUEN_SOFT="#163025", BLAU="#7da0f0", BLAU_SOFT="#1e2743",
GOLD="#d8b058", GOLD_SOFT="#332a17"),
}
globals().update(_PALETTEN["win95"])
IS_WIN = sys.platform.startswith("win")
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
@@ -21,7 +58,57 @@ FONT_SIZE = 8 if IS_WIN else 9
FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
MONO_FAMILY = "Courier New" if IS_WIN else "DejaVu Sans Mono"
FONT_MONO = (MONO_FAMILY, FONT_SIZE + 1)
FONT_KLEIN_FETT = FONT_TITEL = FONT_ZAHL = FONT_BOLD
def design_setzen(name: str) -> None:
"""„modern“ (hell), „dunkel“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
global DESIGN, MODERN, DUNKEL, FONT_FAMILY, FONT_SIZE, APP_NAME, MONO_FAMILY
DESIGN = name if name in _PALETTEN else "modern"
MODERN = DESIGN in ("modern", "dunkel")
DUNKEL = DESIGN == "dunkel"
APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95"
globals().update(_PALETTEN[DESIGN])
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
MONO_FAMILY = ("Consolas" if MODERN else "Courier New") if IS_WIN else "DejaVu Sans Mono"
FONT_SIZE = 10 if MODERN else (8 if IS_WIN else 9)
_schriften()
def _schriften():
global FONT, FONT_BOLD, FONT_BIG, FONT_MONO, FONT_KLEIN_FETT, FONT_TITEL, FONT_ZAHL
FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = (MONO_FAMILY, FONT_SIZE + (0 if MODERN else 1))
FONT_KLEIN_FETT = (FONT_FAMILY, FONT_SIZE - 2, "bold") # Abschnitts-Überschriften (ABRECHNUNGEN …)
FONT_TITEL = (FONT_FAMILY, FONT_SIZE + 8, "bold") # Name der geöffneten Abrechnung
FONT_ZAHL = (MONO_FAMILY, FONT_SIZE + 6) if MODERN else (FONT_FAMILY, FONT_SIZE + 4, "bold") # Kennzahlen
SCHRIFTEN = Path(__file__).resolve().parent / "schriften"
def _schriften_laden() -> None:
"""IBM Plex (Hausschrift der Handy-Insel-Webtools) nur für dieses Programm laden – ohne Installation."""
import ctypes
import ctypes.util
dateien = sorted(SCHRIFTEN.glob("*.ttf"))
try:
if IS_WIN:
for f in dateien:
ctypes.windll.gdi32.AddFontResourceExW(str(f), 0x10, 0) # FR_PRIVATE
return
lib = ctypes.util.find_library("fontconfig")
if lib:
fc = ctypes.CDLL(lib)
fc.FcConfigAppFontAddFile.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
for f in dateien:
fc.FcConfigAppFontAddFile(None, str(f).encode())
except Exception:
pass # dann eben Segoe UI / Systemschrift
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
PALETTE = {
@@ -260,7 +347,13 @@ def _icon_rows(name: str) -> list[str]:
return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows))
MODERNE_ICONS = Path(__file__).resolve().parent / "bilder" / "modern"
def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage:
datei = MODERNE_ICONS / f"{name}{'_gross' if scale > 1 else ''}.png"
if MODERN and datei.exists(): # glatte Symbole aus build/make_moderne_icons.py
return tk.PhotoImage(master=master, file=str(datei))
img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale)
for y, row in enumerate(_icon_rows(name)):
for x, ch in enumerate(row):
@@ -288,6 +381,16 @@ class Icons:
# --------------------------------------------------------------------------- Theme
def apply(root: tk.Tk) -> None:
global FONT_FAMILY, MONO_FAMILY
if MODERN: # die erste vorhandene moderne Schrift, am liebsten IBM Plex
from tkinter import font as tkfont
_schriften_laden()
vorhanden = set(tkfont.families(root))
FONT_FAMILY = next((f for f in ("IBM Plex Sans", "Segoe UI", "Noto Sans", "Cantarell", "Ubuntu",
"DejaVu Sans") if f in vorhanden), FONT_FAMILY)
MONO_FAMILY = next((f for f in ("IBM Plex Mono", "Consolas", "DejaVu Sans Mono") if f in vorhanden),
MONO_FAMILY)
_schriften()
root.configure(bg=FACE)
o = root.option_add
o("*Font", FONT)
@@ -343,6 +446,66 @@ def apply(root: tk.Tk) -> None:
selectforeground=[("readonly", DARK)])
st.configure("TPanedwindow", background=FACE)
st.configure("Sash", sashthickness=6, background=FACE)
if MODERN:
_modern(root, st, zeile)
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
"""Flach wie die Handy-Insel-Webtools: weiße Flächen, dünne Linien, Lila als Akzent."""
o = root.option_add
for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
o(f"*{klasse}.relief", "flat")
o(f"*{klasse}.borderWidth", 4)
o(f"*{klasse}.highlightThickness", 1)
o(f"*{klasse}.highlightBackground", RAND2)
o(f"*{klasse}.highlightColor", NAVY)
o(f"*{klasse}.background", WHITE)
o(f"*{klasse}.foreground", DARK)
o(f"*{klasse}.insertBackground", DARK)
o("*Entry.disabledBackground", LIGHT)
o("*Button.relief", "flat")
o("*Button.borderWidth", 0)
o("*Button.highlightThickness", 1)
o("*Button.highlightBackground", RAND2)
o("*Button.background", FACE)
o("*Button.activeBackground", LIGHT)
o("*Button.padX", 12)
o("*Button.padY", 4)
o("*Button.cursor", "hand2")
for klasse in ("Checkbutton", "Radiobutton"):
o(f"*{klasse}.activeBackground", FACE)
o(f"*{klasse}.selectColor", WHITE)
o("*selectBackground", SEL_BG)
o("*selectForeground", SEL_FG)
o("*Menu.relief", "flat")
o("*TCombobox*Listbox.background", WHITE)
o("*TCombobox*Listbox.foreground", DARK)
o("*TCombobox*Listbox.selectBackground", SEL_BG)
o("*TCombobox*Listbox.selectForeground", SEL_FG)
st.theme_use("clam")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=FACE,
darkcolor=RAND, focuscolor=NAVY, insertcolor=DARK)
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, foreground=DARK, rowheight=zeile + 10,
borderwidth=0, font=FONT)
st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)])
st.configure("Treeview.Heading", background=FACE, foreground=SHADOW, relief="flat", borderwidth=1,
font=FONT_KLEIN_FETT, padding=(10, 7), bordercolor=RAND, lightcolor=FACE, darkcolor=RAND)
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
# Seitenleiste: ohne Kopf, hohe Zeilen, Auswahl lila wie im VVL-Tool
st.configure("Seite.Treeview", background=FACE, fieldbackground=FACE, rowheight=zeile + 16, font=FONT,
borderwidth=0)
st.layout("Seite.Treeview", [("Treeview.treearea", {"sticky": "nswe"})]) # ohne Rahmen
st.map("Seite.Treeview", background=[("selected", SEL_BG)], foreground=[("selected", NAVY)])
st.configure("TScrollbar", background=RAND2, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE,
lightcolor=RAND2, darkcolor=RAND2, relief="flat", arrowsize=10, width=10, gripcount=0)
st.map("TScrollbar", background=[("active", SHADOW)])
st.configure("TCombobox", fieldbackground=WHITE, background=FACE, foreground=DARK, arrowcolor=DARK, padding=4,
bordercolor=RAND2, lightcolor=WHITE, darkcolor=WHITE)
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
st.configure("TPanedwindow", background=BG)
st.configure("Sash", sashthickness=6, background=BG)
# --------------------------------------------------------------------------- Wort löschen
@@ -423,6 +586,8 @@ class Sunken(tk.Frame):
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
def __init__(self, master, **kw):
if MODERN:
kw.update(bd=0, relief="flat", highlightthickness=1, highlightbackground=RAND)
kw.setdefault("bd", 2)
kw.setdefault("relief", "sunken")
super().__init__(master, **kw)
@@ -432,6 +597,15 @@ class Fenster(tk.Frame):
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
if MODERN: # schlichte Überschrift statt blauer Titelleiste
super().__init__(master, bd=0, bg=FACE, **kw)
self.titel_var = tk.StringVar(value=titel)
self.titelleiste = tk.Label(self, textvariable=self.titel_var, bg=FACE, fg=DARK, font=FONT_BOLD,
anchor="w")
self.titelleiste.pack(fill="x", pady=(0, 4))
self.inhalt = tk.Frame(self, bg=FACE)
self.inhalt.pack(fill="both", expand=True)
return
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
self.titelleiste.pack(fill="x", padx=1, pady=1)
@@ -453,7 +627,7 @@ class Statusleiste(tk.Frame):
self.vars: dict[str, tk.StringVar] = {}
for i, (key, breite) in enumerate(felder):
v = tk.StringVar()
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4)
lbl = tk.Label(self, textvariable=v, bd=1, relief="flat" if MODERN else "sunken", anchor="w", padx=4)
if breite:
lbl.configure(width=breite)
lbl.pack(side="left", padx=(0, 2), pady=2)
@@ -465,9 +639,19 @@ class Statusleiste(tk.Frame):
self.vars[key].set(text)
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
activebackground=FACE, **kw)
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None,
primaer: bool = False, **kw):
"""primaer: der eine Hauptknopf einer Fläche (modern dunkel-lila gefüllt, Win95 mit dickem Rahmen)."""
if MODERN:
if primaer:
kw = dict(bg=PRIMAER, fg=AUF_PRIMAER, activebackground=NAVY, activeforeground=AUF_PRIMAER,
highlightbackground=PRIMAER, font=FONT_BOLD) | kw
b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern()
else:
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
activebackground=FACE, **kw)
if primaer:
b.configure(default="active")
if icon is not None:
b.configure(image=icon, compound="left", padx=6)
if breite:
@@ -475,6 +659,115 @@ def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, br
return b
class Karte(tk.Frame):
"""Fläche, die Inhalte zusammenfasst (Kennzahl, Tabelle): modern weiß mit dünner Linie."""
def __init__(self, master, **kw):
if MODERN:
kw = dict(bg=FACE, bd=0, highlightthickness=1, highlightbackground=RAND, highlightcolor=RAND) | kw
else:
kw = dict(bg=FACE, bd=2, relief="raised") | kw
super().__init__(master, **kw)
def titelleiste(master, text: str = "") -> tuple[tk.Frame, tk.Label]:
"""Win95: blaue Titelleiste oben in einer Karte (wie bei Fenster); rechts passen kleine Knöpfe hinein."""
f = tk.Frame(master, bg=NAVY)
f.pack(fill="x", padx=1, pady=1)
lbl = tk.Label(f, text=text, bg=NAVY, fg=WHITE, font=FONT_BOLD, anchor="w", padx=3)
lbl.pack(side="left", pady=1)
return f, lbl
def abschnitt(master, text: str, **kw) -> tk.Label:
"""Kleine Überschrift (modern in Großbuchstaben und grau, wie die Tabellenköpfe der Webtools)."""
if MODERN:
return tk.Label(master, text=text.upper(), fg=SHADOW, font=FONT_KLEIN_FETT, anchor="w", **kw)
return tk.Label(master, text=text, font=FONT_BOLD, anchor="w", **kw)
def _rund(c: tk.Canvas, x0, y0, x1, y1, r, **kw):
"""Abgerundetes Rechteck auf einem Canvas."""
r = min(r, (x1 - x0) / 2, (y1 - y0) / 2)
pkt = [x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r, x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1, x0, y1,
x0, y1 - r, x0, y0 + r, x0, y0]
return c.create_polygon(pkt, smooth=True, **kw)
class Pill(tk.Canvas):
"""Umschalter für Filter („Alle“, „Von Hand geändert“ …): modern abgerundet, Win95 als Knopf,
der eingedrückt bleibt. command bekommt nichts übergeben; aktiv wird von außen gesetzt."""
def __init__(self, master, text: str, command=None, aktiv: bool = False, **kw):
from tkinter import font as tkfont
f = tkfont.Font(root=master, font=FONT)
breite, hoehe = f.measure(text) + (24 if MODERN else 16), f.metrics("linespace") + (10 if MODERN else 8)
bg = kw.pop("bg", master.cget("bg"))
super().__init__(master, width=breite, height=hoehe, bg=bg, highlightthickness=0, bd=0, takefocus=1,
cursor="hand2" if MODERN else "", **kw)
self.text, self.command, self.aktiv = text, command, aktiv
self._zeichnen()
self.bind("<Button-1>", lambda e: self._ausloesen())
self.bind("<space>", lambda e: self._ausloesen())
self.bind("<Return>", lambda e: self._ausloesen())
def _ausloesen(self):
self.focus_set()
if self.command:
self.command()
def setzen(self, aktiv: bool):
if aktiv != self.aktiv:
self.aktiv = aktiv
self._zeichnen()
def _zeichnen(self):
self.delete("all")
b, h = int(self["width"]), int(self["height"])
if MODERN:
fuell, linie, fg = (PRIMAER, PRIMAER, AUF_PRIMAER) if self.aktiv else (FACE, RAND2, TEXT2)
_rund(self, 1, 1, b - 2, h - 2, h, fill=fuell, outline=linie)
else:
oben, unten = (SHADOW, HILITE) if self.aktiv else (HILITE, SHADOW)
self.create_rectangle(0, 0, b - 1, h - 1, fill=LIGHT if self.aktiv else FACE, outline=DARK)
self.create_line(1, h - 2, 1, 1, b - 2, 1, fill=oben)
self.create_line(1, h - 2, b - 2, h - 2, b - 2, 1, fill=unten)
fg = DARK
self.create_text(b // 2 + (1 if self.aktiv and not MODERN else 0), h // 2, text=self.text, fill=fg, font=FONT)
def punkt(master, farbe: str, d: int = 9) -> tk.PhotoImage:
"""Farbiger Punkt (Status in Listen); Ecken bleiben durchsichtig."""
img = tk.PhotoImage(master=master, width=d + 4, height=d)
r = d / 2
# Win95: grauer Ring, damit der Punkt auf weißem Grund und auf der blauen Auswahl sichtbar ist
for f, rr in ((farbe, r),) if MODERN else ((SHADOW, r), (farbe, r - 1)):
for y in range(d):
xs = [x for x in range(d) if (x + .5 - r) ** 2 + (y + .5 - r) ** 2 <= rr * rr]
if xs:
img.put(f, to=(xs[0], y, xs[-1] + 1, y + 1))
return img
def logo(master, groesse: int = 34) -> tk.Canvas:
"""Logo-Kachel wie in den Webtools: Lila-Verlauf, abgerundet, weißes %."""
c = tk.Canvas(master, width=groesse, height=groesse, bg=master.cget("bg"), highlightthickness=0, bd=0)
img = tk.PhotoImage(master=master, width=groesse, height=groesse)
a, e = (0x2d, 0x1b, 0x69), (0x5e, 0x35, 0xb1)
r = groesse * 0.28
for y in range(groesse):
for x in range(groesse):
dx = max(r - x - .5, x + .5 - (groesse - r), 0)
dy = max(r - y - .5, y + .5 - (groesse - r), 0)
if dx * dx + dy * dy <= r * r:
t = (x + y) / (2 * groesse - 2)
img.put("#%02x%02x%02x" % tuple(round(a[i] + (e[i] - a[i]) * t) for i in range(3)), (x, y))
c.create_image(0, 0, image=img, anchor="nw")
c.bild = img # type: ignore[attr-defined] # Referenz halten
c.create_text(groesse / 2, groesse / 2, text="%", fill="#ffffff", font=(FONT_FAMILY, int(groesse * .4), "bold"))
return c
class ToolbarButton(tk.Label):
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick."""
@@ -482,6 +775,15 @@ class ToolbarButton(tk.Label):
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
padx=6, pady=2, font=FONT)
self.command = command
if MODERN: # Hervorhebung durch Farbe statt 3D-Kante
self.configure(bd=0, padx=8, pady=4)
self.bind("<Enter>", lambda e: self.configure(bg=LIGHT))
self.bind("<Leave>", lambda e: self.configure(bg=FACE))
self.bind("<ButtonPress-1>", lambda e: self.configure(bg=SEL_BG))
self.bind("<ButtonRelease-1>", self._release)
if tooltip:
Tooltip(self, tooltip)
return
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
@@ -490,7 +792,7 @@ class ToolbarButton(tk.Label):
Tooltip(self, tooltip)
def _release(self, e):
self.configure(relief="raised")
self.configure(bg=LIGHT) if MODERN else self.configure(relief="raised")
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
self.command()
@@ -528,6 +830,10 @@ class Tooltip:
def trenner(master, vertikal: bool = True):
f = tk.Frame(master, bg=FACE)
if MODERN:
tk.Frame(f, width=1, height=1, bg=RAND).pack(side="left", fill="y") if vertikal else \
tk.Frame(f, height=1, bg=RAND).pack(fill="x")
return f
if vertikal:
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
@@ -538,18 +844,33 @@ def trenner(master, vertikal: bool = True):
def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
if MODERN:
return tk.LabelFrame(master, text=titel, bd=0, relief="flat", bg=FACE, padx=4, pady=2, font=FONT_BOLD, **kw)
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]:
rahmen = Sunken(master, bg=WHITE)
def _bei_bedarf(leiste: ttk.Scrollbar):
"""Bildlaufleiste nur zeigen, wenn nicht alles hineinpasst."""
def setzen(lo, hi):
leiste.grid() if float(lo) > 0 or float(hi) < 1 else leiste.grid_remove()
leiste.set(lo, hi)
return setzen
def scroll_tree(master, auto: bool = False, **kw) -> tuple[tk.Frame, ttk.Treeview]:
"""Treeview mit Bildlaufleisten (modern nur bei Bedarf). auto: keine waagrechte Leiste (Seitenleiste)."""
rahmen = Sunken(master, bg=WHITE if MODERN else FACE) # Win95: graue Ecke zwischen den Leisten
tree = ttk.Treeview(rahmen, **kw)
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
tree.grid(row=0, column=0, sticky="nsew")
vs.grid(row=0, column=1, sticky="ns")
hs.grid(row=1, column=0, sticky="ew")
if auto and MODERN:
rahmen.configure(highlightthickness=0, bg=FACE)
if not auto:
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
hs.grid(row=1, column=0, sticky="ew")
tree.configure(xscrollcommand=_bei_bedarf(hs) if MODERN else hs.set)
tree.configure(yscrollcommand=_bei_bedarf(vs) if auto or MODERN else vs.set)
rahmen.rowconfigure(0, weight=1)
rahmen.columnconfigure(0, weight=1)
return rahmen, tree
@@ -628,6 +949,8 @@ class Dialog(tk.Toplevel):
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
self.deiconify()
self.update_idletasks()
if DUNKEL:
win32.dunkle_titelleiste(self)
if KLASSISCH and win32.IS_WIN:
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
if win32.taskleisteneintrag(self):
@@ -677,7 +1000,7 @@ class Dialog(tk.Toplevel):
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
f = tk.Frame(self, bg=FACE)
for i, (text, cmd) in enumerate(knoepfe):
b = knopf(f, text, cmd, breite=10)
b = knopf(f, text, cmd, breite=10, primaer=MODERN and i == default)
if i == default:
b.configure(default="active")
b.pack(side="left", padx=4)
+2 -1
View File
@@ -122,7 +122,8 @@ def ausfuehren(ergebnisdatei: str) -> int:
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
csv = basis / "freenet.csv"
_beispiel_csv(csv)
s = _Szenario("Assistent, Import, Export", basis / "b")
# Win95-Design: prüft auch den nativen Fensterrahmen (Szenario 1 läuft im modernen Standard)
s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95"}')
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
+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)
+180 -21
View File
@@ -29,8 +29,9 @@ class Lauf:
def __init__(self, home, einstellungen: dict | None = None):
self.home = home
if einstellungen is not None:
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
# Tests starten im normalen Fenster (die App sonst maximiert)
einstellungen = {"vollbild": False} | (einstellungen or {})
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
self.root = None
for versuch in range(3):
try:
@@ -203,9 +204,7 @@ def home(tmp_path, monkeypatch):
return tmp_path
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
def _profil_mit_daten(home):
from tests.test_kern import FREENET_KOPF, freenet_zeile
csv = home / "freenet.csv"
csv.write_bytes(("\r\n".join([
@@ -219,6 +218,21 @@ def lauf_mit_daten(home):
from provtool.importer import lies_datei
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
db.close()
@pytest.fixture
def lauf_mit_daten(home):
"""App mit einem Händler und einer Abrechnung (erfundene Daten), klassisches Win95-Design."""
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": "win95"})
yield lauf
lauf.ende()
@pytest.fixture
def lauf_modern(home):
"""Wie lauf_mit_daten, aber im modernen Design (Standard)."""
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
yield lauf
lauf.ende()
@@ -324,8 +338,10 @@ def _dialog_oeffner():
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
"staffel"])
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
lauf = lauf_mit_daten
@pytest.mark.parametrize("design", ["win95", "modern"])
def test_schliessen_waehrend_dialog_offen(home, name, design):
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": design})
oeffnen = _dialog_oeffner()[name]
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
(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)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})()
r._rand_cursor(ev)
if r.nativ_an:
# Windows: den Zeiger setzt Windows selbst (WM_SETCURSOR am äußeren Fenster, siehe
# 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
# Den Zeiger verwaltet immer Tk – auch mit nativem Windows-Rahmen (gemeldet in 1.3.0: setzte
# Windows ihn am Rand, blieb er im Fenster der Größenpfeil, weil Tk davon nichts wusste).
assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen"
r.inhalt.event_generate("<Enter>")
lauf.root.update()
@@ -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_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["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
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 m["programmfenster"], "Fenster ist für Windows noch ein Werkzeugfenster (kein Andocken/Win+Pfeil)"
assert m["rahmen_unsichtbar"] and m["tk_gleich_aussen"], "Windows-Rahmen ist sichtbar / Tk-Größe passt nicht"
for k in ("links", "ecke", "ecke_lang", "innen", "rand_durchlaessig", "innen_nicht", "maus_am_rand",
"maus_innen"):
for k in ("links", "ecke", "ecke_lang", "innen"):
assert m[k], f"Trefferprüfung am Rand falsch: {k} ({m})"
assert m["waechst_nicht"][0] == m["waechst_nicht"][1], f"Fenster ist gewachsen: {m['waechst_nicht']}"
assert m["max"] == (True, True), f"Maximieren über Windows nicht erkannt: {m['max']}"
@@ -1046,3 +1051,157 @@ 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]]
assert [r[1] for r in zeilen] == ["Sonderbonus Aktion"], "0-€-Position wurde exportiert"
lauf.pruefen()
def test_rand_druecken_startet_natives_ziehen(lauf_mit_daten, monkeypatch):
"""Mit nativem Rahmen zieht Windows die Größe – angestoßen beim Drücken auf den Rand."""
lauf = lauf_mit_daten
r = lauf.app.rahmen
if not r.nativ_an:
pytest.skip("nur mit nativem Windows-Rahmen")
lauf.ablauf([])
gezogen = []
monkeypatch.setattr(r.nativ, "groesse_ziehen", gezogen.append)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2, "x_root": 0, "y_root": 0})()
assert r._resize_start(ev) == "break"
ev.x, ev.y = r.aussen.winfo_width() - 2, r.aussen.winfo_height() - 2
r._resize_start(ev)
assert gezogen == ["w", "se"]
lauf.pruefen()
def test_modern_ist_standard(lauf_modern):
from provtool.gui import win95 as w
lauf = lauf_modern
assert w.DESIGN == "modern" and not lauf.app.rahmen.klassisch and lauf.app.rahmen.titelleiste is None
lauf.ablauf([(IMMER, lambda: lauf.app.menueleiste._klick(lauf.app.menueleiste.knoepfe["Datei"]), "Menü"),
(lambda: lauf.app.menueleiste.offener_knopf is not None,
lambda: lauf.app.menueleiste.schliessen(), "zu")])
assert not lauf.uebrig, lauf.diagnose()
lauf.pruefen()
def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkeypatch):
"""Zeitraum abrechnen, exportierte Positionen nicht erneut exportieren, Abrechnung erledigt sperrt."""
from provtool.gui import win95 as w
from provtool.gui.dialogs import ExportDialog
lauf = lauf_modern
app = lauf.app
app.settings["export_ordner"] = str(tmp_path / "x")
meldungen = []
monkeypatch.setattr(w, "info", lambda m, t, text: meldungen.append(text))
monkeypatch.setattr(w, "frage", lambda *a, **k: True)
def ok(von="", bis=""):
d = lauf.dialog(ExportDialog)
d.v_von.set(von)
d.v_bis.set(bis)
d.v_auf.set(False)
d._ok()
def ok_offen():
d = lauf.dialog(ExportDialog)
d.v_von.set("01.10.2026")
d.v_bis.set("02.10.2026")
next(k for k in _alle(d) if isinstance(k, tk.Button) and k.cget("text") == "Noch nicht abgerechnet").invoke()
ok(d.v_von.get(), d.v_bis.get())
lauf.ablauf([
(IMMER, app.exportieren, "Export Oktober 6."),
(lambda: lauf.dialog(ExportDialog) is not None, lambda: ok("01.10.2026", "05.10.2026"), "nur bis 5.10."),
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "Export Rest"),
(lambda: lauf.dialog(ExportDialog) is not None, ok_offen, "Noch nicht abgerechnet"),
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "nochmal"),
])
assert not lauf.uebrig, lauf.diagnose()
assert sum(1 for p in app.positionen if p["abgerechnet"]) == 2
assert "1 Position(en) als abgerechnet" in meldungen[0] and "1 Position(en) als abgerechnet" in meldungen[1]
assert "bereits abgerechnet" in meldungen[2], meldungen
# erledigt: Änderungen gesperrt
app.erledigt_umschalten()
assert app.db.erledigt(app.aid)
app.ptree.selection_set(app.ptree.get_children()[0])
app.prozent()
assert "erledigt" in meldungen[-1]
lauf.pruefen()
def _alle(widget):
for kind in widget.winfo_children():
yield kind
yield from _alle(kind)
def test_abrechnungen_links_und_import_fragt_nach_ziel(lauf_modern, home, monkeypatch):
from provtool.gui import win95 as w
from provtool.gui.dialogs import AbrechnungenDialog, ImportBericht
monkeypatch.setattr(w, "frage", lambda *a, **k: True) # „bereits importiert – trotzdem?“ (Testdatei ist schon drin)
lauf = lauf_modern
app = lauf.app
alt = app.aid
neu = app.db.abrechnung_neu("November 2026")
app._aktualisieren()
assert set(app.atree.get_children()) == {str(alt), str(neu)}, "Abrechnungen fehlen links"
app.atree.selection_set(str(neu))
lauf.root.update()
assert app.aid == neu and not app.positionen, "Klick links öffnet die Abrechnung nicht"
app.atree.selection_set(str(alt))
lauf.root.update()
def ziel_waehlen():
d = lauf.dialog(AbrechnungenDialog)
d.tree.selection_set(str(neu))
d._oeffnen()
lauf.ablauf([
(IMMER, lambda: app.importieren([str(home / "freenet.csv")]), "Import"),
(lambda: lauf.dialog(AbrechnungenDialog) is not None, ziel_waehlen, "Ziel: November"),
(lambda: lauf.dialog(ImportBericht) is not None, lambda: lauf.dialog(ImportBericht).abbrechen(), "Bericht zu"),
])
assert not lauf.uebrig, lauf.diagnose()
assert app.aid == neu and len(app.positionen) == 3, "Import nicht in der gewählten Abrechnung"
assert app.atree.selection() == (str(neu),)
lauf.pruefen()
def test_filter_kennzahlen_und_hell_dunkel(lauf_modern):
"""Filter-Knöpfe über der Tabelle, Kennzahl-Kacheln und der Hell/Dunkel-Schalter in der Kopfzeile."""
from provtool import config
from provtool.db import EIGEN
lauf = lauf_modern
app = lauf.app
alle = [p for p in app.positionen if p["haendler_id"] != EIGEN]
assert app.kpi["pos"][0].cget("text") == str(len(alle))
p = alle[0]
app.db.betrag_setzen(p["id"], 123)
app._aktualisieren()
app.pills["manuell"]._ausloesen() # wie ein Klick (event_generate kommt unter Wine nicht an)
lauf.root.update()
manuell = [x for x in app.positionen if x["manuell"] and x["haendler_id"] != EIGEN]
assert len(manuell) == 1
zeilen = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
assert len(zeilen) == len(manuell), "Filter „Von Hand geändert“ wirkt nicht"
assert app.kpi["pos"][0].cget("text") == str(len(manuell))
assert app.pills["manuell"].aktiv and not app.pills["alle"].aktiv
app._filter_setzen("alle")
assert len([i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]) == len(alle)
app.design_umschalten() # schließt das Fenster; main() baut es danach dunkel neu auf
for _ in range(50):
if app._beendet:
break
lauf.root.update()
assert app.neustart and app._beendet
assert config.load()["design"] == "dunkel"
def test_startet_maximiert(home):
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "vollbild": True, "design": "win95"})
try:
for _ in range(30):
lauf.root.update()
time.sleep(0.02)
assert lauf.app.rahmen.maximiert, "Programm startet nicht maximiert"
finally:
lauf.ende()
+105 -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)
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"]
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
# Spalte A: Vodafone mit Aktivierungsdatum, freenet mit Zeitraum
vf = next(r for r in zeilen1 if r[1] == "Freischaltungsvergütung Vodafone")
assert vf[0] == datetime(2026, 9, 8)
assert next(r for r in zeilen1 if r[1] == "SIM-Karten")[0] == "2026.10.01-2026.10.07"
ws1 = openpyxl.load_workbook(eine[0]).active
zeile_vf = next(c.row for c in ws1["B"] if c.value == "Freischaltungsvergütung Vodafone")
assert ws1.cell(zeile_vf, 1).number_format == "DD.MM.YYYY"
ws = openpyxl.load_workbook(dateien[0]).active
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
# Sortierung Zeitraum → Provisionsart → Datum
@@ -411,3 +419,99 @@ def test_migration_v2_haendler_ohne_airtime(tmp_path):
d.standard_staffel_setzen([(1, 100), (5, None)])
assert d.staffel_fuer(h.id) == [(1, 100), (5, None)]
d.close()
# --------------------------------------------------------------------------- Zeitraum / abgerechnet / erledigt
def test_zeitraum_lesen_und_bezugsdatum():
from datetime import date
from provtool import zeitraum as zr
assert zr.datum_lesen("1.10.2026") == date(2026, 10, 1) and zr.datum_lesen("01.10.26") == date(2026, 10, 1)
assert zr.datum_lesen("") is None
with pytest.raises(ValueError):
zr.datum_lesen("32.13.2026")
assert zr.abrechnungsdatum("2026.10.08-2026.10.15") == date(2026, 10, 8)
assert zr.abrechnungsdatum("VF2609") == date(2026, 9, 1) == zr.abrechnungsdatum("CY202609UM")
assert zr.abrechnungsdatum("VF2609P6 Fil 0") == date(2026, 9, 1)
p = {"zeitraum": "VF2609", "datum": "2026-08-30"}
assert zr.bezugsdatum(p, zr.AKTIVIERUNG) == date(2026, 8, 30)
assert zr.bezugsdatum(p, zr.ABRECHNUNG) == date(2026, 9, 1)
okt = zr.Zeitraum(date(2026, 10, 1), date(2026, 10, 31))
assert okt.enthaelt({"datum": "2026-10-31"}) and not okt.enthaelt({"datum": "2026-11-01"})
assert not okt.enthaelt({"datum": None})
assert zr.vormonat(date(2026, 3, 15)) == (date(2026, 2, 1), date(2026, 2, 28))
assert zr.dieser_monat(date(2026, 12, 5)) == (date(2026, 12, 1), date(2026, 12, 31))
def test_abgerechnet_einfrieren_und_doppelt_erkennen(db, freenet_csv):
from provtool.db import AbrechnungErledigt
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
a1 = db.abrechnung_neu("Okt")
db.importieren(a1, lies_datei(freenet_csv))
pos = [p for p in db.positionen(a1) if p["haendler_id"] == hid]
assert len(pos) == 4 and all(p["fp"] for p in pos)
erste = pos[:2]
assert db.abrechnen([p["id"] for p in erste]) == 2
# abgerechnet: Betrag und Händler eingefroren
h = db.haendler(hid)
h.standard_prozent = 50
db.haendler_speichern(h, []) # Kennung weg, 50 %
db.neu_berechnen(a1)
nach = {p["id"]: p for p in db.positionen(a1)}
for p in erste:
assert nach[p["id"]]["haendler_id"] == hid and nach[p["id"]]["betrag"] == p["betrag"]
with pytest.raises(AbrechnungErledigt):
db.betrag_setzen(erste[0]["id"], 1)
assert db.prozent_anwenden([erste[0]["id"]], 10) == 0
# dieselbe Datei in einer neuen Abrechnung: die beiden abgerechneten Positionen sind „doppelt“
db.haendler_speichern(db.haendler(hid), [("freenet", "111")])
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
doppelt = [p for p in db.positionen(a2) if p["doppelt"]]
assert len(doppelt) == 2 and all(p["doppelt"] == "Okt" for p in doppelt)
assert db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden"
# Kennzeichnung aufheben -> nicht mehr doppelt
assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2
assert not any(p["doppelt"] for p in db.positionen(a2))
def test_abrechnung_erledigt_sperrt(db, freenet_csv):
from provtool.db import AbrechnungErledigt
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
assert db.erledigt_setzen(aid, True) == 4 # nur die 4 Händler-Positionen, nicht die unzugeordnete
assert db.erledigt(aid)
assert all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid)
with pytest.raises(AbrechnungErledigt):
db.importieren(aid, lies_datei(freenet_csv))
with pytest.raises(AbrechnungErledigt):
db.datei_entfernen(db.dateien(aid)[0]["id"])
unz = next(p for p in db.positionen(aid) if p["haendler_id"] <= 0)
db.haendler_zuweisen([unz["id"]], hid)
assert next(p for p in db.positionen(aid) if p["id"] == unz["id"])["haendler_id"] <= 0, "erledigt: keine Änderung"
assert db.abrechnungen()[0]["abgerechnet"] == 4
# wieder öffnen, Positionen bleiben abgerechnet bzw. werden auf Wunsch freigegeben
db.erledigt_setzen(aid, False)
assert not db.erledigt(aid) and all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid)
db.erledigt_setzen(aid, True)
db.erledigt_setzen(aid, False, positionen_aufheben=True)
assert not any(p["abgerechnet"] for p in db.positionen(aid))
def test_migration_v3_fingerabdruck(tmp_path, freenet_csv):
import sqlite3
d = Datenbank(tmp_path / "x.db")
aid = d.abrechnung_neu("Alt")
d.importieren(aid, lies_datei(freenet_csv))
d.close()
con = sqlite3.connect(tmp_path / "x.db") # wie eine v3-Datenbank: ohne Fingerabdrücke
con.execute("UPDATE position SET fp=NULL")
con.commit()
con.close()
d = Datenbank(tmp_path / "x.db")
assert all(p["fp"] for p in d.positionen(aid))
assert len({p["fp"] for p in d.positionen(aid)}) == 5
d.close()