Compare commits
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
# Baut die Windows-Version (Provisionsmanager95.exe) mit Windows-Python unter Wine.
|
# Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version.
|
||||||
# - jeder Push / PR: Tests + .exe als Build-Artefakt
|
# - jeder Push / PR: Tests + Programmdateien als Build-Artefakte
|
||||||
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip
|
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip, Setup und Linux-.tar.gz
|
||||||
name: Windows-Build
|
name: Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -14,6 +14,7 @@ jobs:
|
|||||||
windows-exe:
|
windows-exe:
|
||||||
# Label ggf. an euren Runner anpassen (Runner braucht Docker; Wine steckt im Container-Image).
|
# Label ggf. an euren Runner anpassen (Runner braucht Docker; Wine steckt im Container-Image).
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30 # normal ca. 5 Minuten – ein Hänger darf den Runner nicht stundenlang belegen
|
||||||
container:
|
container:
|
||||||
image: tobix/pywine:3.12
|
image: tobix/pywine:3.12
|
||||||
env:
|
env:
|
||||||
@@ -22,7 +23,7 @@ jobs:
|
|||||||
- name: Werkzeuge für Checkout/Upload installieren
|
- name: Werkzeuge für Checkout/Upload installieren
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates >/dev/null
|
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xdotool >/dev/null
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -36,24 +37,44 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
dist/Provisionsmanager95.exe
|
dist/Provisionsmanager95.exe
|
||||||
dist/Provisionsmanager95-*.zip
|
dist/Provisionsmanager95-*.zip
|
||||||
|
dist/Provisionsmanager-Setup-*.exe
|
||||||
|
|
||||||
- name: Release erstellen
|
- name: Release erstellen / ergänzen
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
V=$(tr -d '\r\n' < dist/VERSION)
|
||||||
VERSION=$(cat dist/VERSION | tr -d '\r\n')
|
build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$V.zip dist/Provisionsmanager-Setup-$V.exe
|
||||||
if [ "v$VERSION" != "$TAG" ]; then
|
|
||||||
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1
|
linux:
|
||||||
fi
|
runs-on: ubuntu-latest
|
||||||
resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
timeout-minutes: 30
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}")
|
container:
|
||||||
id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
image: python:3.12-bookworm
|
||||||
for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do
|
steps:
|
||||||
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
|
- name: Werkzeuge installieren
|
||||||
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
|
run: |
|
||||||
echo "hochgeladen: $f"
|
apt-get update -qq
|
||||||
done
|
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xvfb xauth fonts-dejavu-core >/dev/null
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Tests + Build (Linux)
|
||||||
|
run: ./build/build_linux.sh
|
||||||
|
|
||||||
|
- name: Build-Artefakt hochladen
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: Provisionsmanager95-Linux
|
||||||
|
path: dist/Provisionsmanager95-linux-*.tar.gz
|
||||||
|
|
||||||
|
- name: Release ergänzen
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: build/release_hochladen.sh dist/Provisionsmanager95-linux-x86_64-*.tar.gz
|
||||||
@@ -13,3 +13,7 @@ dist/
|
|||||||
build/tmp/
|
build/tmp/
|
||||||
build/version_info.txt
|
build/version_info.txt
|
||||||
*.db
|
*.db
|
||||||
|
selbsttest.txt
|
||||||
|
/*.jpg
|
||||||
|
/*.png
|
||||||
|
.claude/
|
||||||
@@ -5,14 +5,41 @@ von **freenet** (CSV/Excel) und **Vodafone/Euronics** (Excel) ein, ordnet die Po
|
|||||||
Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine
|
Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine
|
||||||
fertige Excel-Abrechnung. Optik: Windows 95.
|
fertige Excel-Abrechnung. Optik: Windows 95.
|
||||||
|
|
||||||
|
## Installation (Windows)
|
||||||
|
|
||||||
|
`Provisionsmanager-Setup-<version>.exe` aus dem Release herunterladen und starten – keine
|
||||||
|
Admin-Rechte nötig. Das Programm landet in `%LOCALAPPDATA%\Programs\Provisionsmanager` mit
|
||||||
|
Eintrag im Startmenü (Desktop-Verknüpfung optional) und startet schneller als die einzelne `.exe`.
|
||||||
|
Ein Update installiert man einfach drüber (ein laufendes Programm wird dafür geschlossen).
|
||||||
|
Deinstallieren über *Einstellungen ▸ Apps*; Datenbank und Einstellungen in
|
||||||
|
`%APPDATA%\Provisionsmanager95` bleiben dabei erhalten. Ohne Installation geht weiterhin
|
||||||
|
`Provisionsmanager95.exe` (bzw. das `.zip`) – einfach starten.
|
||||||
|
|
||||||
|
## Erster Start
|
||||||
|
|
||||||
|
Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler
|
||||||
|
(inkl. „Nummern aus Abrechnung vorschlagen“) und die erste Abrechnung. Danach zeigt ein
|
||||||
|
**Rundgang** mit Hinweis-Sprechblasen die wichtigsten Stellen. Beides sowie den
|
||||||
|
**Tipp des Tages** gibt es jederzeit im Menü **?**.
|
||||||
|
|
||||||
|
Die Windows-95-Titelleiste ist selbst gezeichnet (Windows lässt die echte nicht umfärben).
|
||||||
|
Falls sie auf einem PC Probleme macht: *Datei ▸ Einstellungen ▸ Darstellung* – dann wird die
|
||||||
|
normale Windows-Titelleiste verwendet.
|
||||||
|
|
||||||
## Bedienung in Kürze
|
## Bedienung in Kürze
|
||||||
|
|
||||||
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*:
|
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in %, **Airtime in %** (0 % = keine
|
||||||
|
Airtime) und die *Kennungen*:
|
||||||
- freenet → Spalte **Unterhaendler** (z. B. `11111102`)
|
- freenet → Spalte **Unterhaendler** (z. B. `11111102`)
|
||||||
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder**
|
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder** das Häkchen
|
||||||
„Vodafone (Name in GROSSBUCHSTABEN)“: alle Vodafone-Positionen, deren Kundenname komplett
|
„Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN“: alle Vodafone-Positionen, deren Kundenname
|
||||||
großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur ein
|
komplett großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur
|
||||||
Händler haben und hat Vorrang vor der Vo-Nr.
|
ein Händler haben und hat Vorrang vor der Vo-Nr.
|
||||||
|
- **Jahresbonus Neuaktivierung**: Betrag je Karte nach Staffel, abhängig von der Anzahl Karten des
|
||||||
|
Händlers in der Abrechnung. Standard (änderbar unter *Händler ▸ Standard-Staffel Jahresbonus…*):
|
||||||
|
1–9 Karten 5 €, 10–14 10 €, 15–19 15 €, 20–24 20 €, 25–29 25 €, 30–34 30 €, 35–39 35 € je Karte,
|
||||||
|
ab 40 der volle Betrag. Je Händler kann eine eigene Staffel hinterlegt werden. Nie mehr als die
|
||||||
|
Provision laut Abrechnung; Stornos zählen ab.
|
||||||
Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern.
|
Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern.
|
||||||
Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…**
|
Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…**
|
||||||
(feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“).
|
(feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“).
|
||||||
@@ -21,14 +48,22 @@ fertige Excel-Abrechnung. Optik: Windows 95.
|
|||||||
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
|
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
|
||||||
Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter
|
Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter
|
||||||
„(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen.
|
„(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen.
|
||||||
4. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach
|
4. **Eigene Filiale ausblenden**: Positionen eurer eigenen Kennungen (z. B. freenet `11111100`,
|
||||||
|
Vodafone `80000001`) müssen nicht verprovisioniert werden. Rechtsklick auf eine nicht zugeordnete
|
||||||
|
Gruppe ▸ *Kennung als eigene ausblenden* (oder *Händler ▸ Eigene Kennungen…* bzw. direkt im
|
||||||
|
Import-Bericht). Sie verschwinden aus Liste, Summen und Export und stehen grau unter
|
||||||
|
„(Eigene – ausgeblendet)“. Händler-Zuordnungen (auch GROSSBUCHSTABEN) haben Vorrang.
|
||||||
|
5. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach
|
||||||
Provisionsart gruppiert.
|
Provisionsart gruppiert.
|
||||||
- Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch
|
- Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch
|
||||||
als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe).
|
als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe).
|
||||||
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
|
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
|
||||||
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
|
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
|
||||||
- Rechtsklick: u. a. „Auszahlung 0 €“.
|
- Rechtsklick: u. a. „Auszahlung 0 €“.
|
||||||
5. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage:
|
6. **Export** (`Strg+E`): je Händler eine `.xlsx` im Format der Vorlage – freenet und Vodafone
|
||||||
|
zusammen (auf Wunsch getrennt), **Airtime nur als eine Summenzeile** „Airtime“ – plus eine
|
||||||
|
eigene **Airtime-Liste** (`<Händler> - Airtime - <Titel>.xlsx`). Positionen mit 0,00 € werden
|
||||||
|
weggelassen (abschaltbar):
|
||||||
|
|
||||||
| A | B | C | D | E | F | G | H | I | J | K | L | M | N |
|
| A | B | C | D | E | F | G | H | I | J | K | L | M | N |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
@@ -37,6 +72,38 @@ fertige Excel-Abrechnung. Optik: Windows 95.
|
|||||||
Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile.
|
Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile.
|
||||||
Kopfzeile optional zuschaltbar.
|
Kopfzeile optional zuschaltbar.
|
||||||
|
|
||||||
|
Welche Provisionsart Airtime bzw. Jahresbonus ist, erkennt das Programm an den Texten (z. B.
|
||||||
|
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
|
||||||
|
*Händler ▸ Provisionsarten…*.
|
||||||
|
|
||||||
|
Das Programm startet maximiert. Das Hauptfenster ist unter Windows ein echtes Programmfenster: an den Bildschirmrand
|
||||||
|
ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
|
||||||
|
wie gewohnt.
|
||||||
|
|
||||||
|
**Zeitraum abrechnen:** Im Export-Dialog „Nur von … bis …“ eintragen (oder „Vormonat“; „Noch nicht abgerechnet“ = alles Offene), wahlweise nach
|
||||||
|
Aktivierungsdatum oder Abrechnungszeitraum des Anbieters. Exportierte Positionen werden als *abgerechnet*
|
||||||
|
gekennzeichnet (grau) und nie wieder exportiert – auch nicht, wenn dieselbe Position später in einer
|
||||||
|
anderen Datei oder Abrechnung auftaucht. Versehentlich? Rechtsklick ▸ *Kennzeichnung „abgerechnet“ aufheben*.
|
||||||
|
*Datei ▸ Abrechnung erledigt / wieder öffnen* sperrt eine fertige Abrechnung gegen Änderungen.
|
||||||
|
|
||||||
|
**Doppelte Positionen:** Taucht dieselbe Position mehrfach auf (z. B. in Wochen- und Monatsdatei oder in zwei
|
||||||
|
Abrechnungen), zählt nur die zuerst importierte; die anderen stehen als *doppelt* in der Liste und werden
|
||||||
|
weder exportiert noch bei der Jahresbonus-Staffel mitgezählt. Abgerechnete Positionen lassen sich erst
|
||||||
|
löschen, wenn ihre Kennzeichnung „abgerechnet“ aufgehoben ist.
|
||||||
|
|
||||||
|
**Design:** Standard ist das Design der Handy-Insel-Webtools (Lila, Schrift IBM Plex – liegt dem Programm bei):
|
||||||
|
Kopfzeile mit Menüs und Suche, links Abrechnungen und Händler, oben Kennzahlen, über der Tabelle Filter
|
||||||
|
(nicht zugeordnet, von Hand geändert, doppelt, bereits abgerechnet). Oben rechts „Dunkel“/„Hell“ schaltet
|
||||||
|
das dunkle Design um. Das Windows-95-Design gibt es weiter unter *Datei ▸ Einstellungen ▸ Darstellung*.
|
||||||
|
|
||||||
|
**Updates:** Das Programm schaut beim Start auf Gitea nach einer neueren Version (abschaltbar unter
|
||||||
|
*Datei ▸ Einstellungen*, von Hand unter *Hilfe ▸ Nach Updates suchen*). Die per Setup installierte
|
||||||
|
Windows-Version lädt das neue Setup, sichert die Datenbank (`vor-update-<alte Version>.db` im Einstellungsordner),
|
||||||
|
installiert still und startet neu. Die einzelne .exe und Linux öffnen stattdessen die Download-Seite.
|
||||||
|
|
||||||
|
**Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten
|
||||||
|
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
|
||||||
|
|
||||||
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
|
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
|
||||||
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
|
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
|
||||||
|
|
||||||
@@ -74,7 +141,10 @@ Aufbau:
|
|||||||
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
|
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
|
||||||
| `provtool/export.py` | Excel-Export im Zielformat |
|
| `provtool/export.py` | Excel-Export im Zielformat |
|
||||||
| `provtool/money.py` | Beträge (intern immer in Cent) |
|
| `provtool/money.py` | Beträge (intern immer in Cent) |
|
||||||
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) |
|
| `provtool/gui/` | Oberfläche (Tkinter; Designs hell, dunkel, Win95) |
|
||||||
|
| `provtool/gui/schriften/` | IBM Plex (SIL Open Font License, siehe `OFL.txt`) |
|
||||||
|
| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung |
|
||||||
|
| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
|
||||||
|
|
||||||
**Datenschutz:** Echte Abrechnungen enthalten Kundendaten und gehören nicht ins Repository
|
**Datenschutz:** Echte Abrechnungen enthalten Kundendaten und gehören nicht ins Repository
|
||||||
(`.gitignore` schließt `*.csv`, `*.xlsx` im Hauptordner und `beispieldaten/` aus).
|
(`.gitignore` schließt `*.csv`, `*.xlsx` im Hauptordner und `beispieldaten/` aus).
|
||||||
@@ -84,8 +154,18 @@ Aufbau:
|
|||||||
Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container
|
Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container
|
||||||
`tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller:
|
`tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller:
|
||||||
|
|
||||||
- jeder Push: Tests unter Windows-Python + `Provisionsmanager95.exe` als Artefakt
|
- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
|
||||||
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip`
|
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
|
||||||
|
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
|
||||||
|
- 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.
|
||||||
|
Jede Ausnahme in der Oberfläche und jeder Eintrag in `fehler.log` lässt sie fehlschlagen; ein
|
||||||
|
Watchdog bricht hängende Tests ab, statt den Build zu blockieren. Ohne Bildschirm werden sie lokal
|
||||||
|
übersprungen, im Build (`PROVTOOL_GUI_TESTS=1`) ist das ein Fehler.
|
||||||
|
|
||||||
Lokal geht dasselbe mit Docker:
|
Lokal geht dasselbe mit Docker:
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 980 B After Width: | Height: | Size: 24 KiB |
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12).
|
# Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12).
|
||||||
# Ergebnis: dist/Provisionsmanager95.exe und dist/Provisionsmanager95-<version>.zip
|
# Ergebnis: dist/Provisionsmanager95.exe, dist/Provisionsmanager95-<version>.zip und
|
||||||
|
# dist/Provisionsmanager-Setup-<version>.exe (Inno Setup, Installation pro Benutzer)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
@@ -9,11 +10,28 @@ export LANG=C.UTF-8 LC_ALL=C.UTF-8 # sonst kann Wine keine Umlaute in Dateinam
|
|||||||
export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}"
|
export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}"
|
||||||
PY="wine python"
|
PY="wine python"
|
||||||
|
|
||||||
|
# Ein virtueller Bildschirm für den GANZEN Build – vor dem ersten Wine-Aufruf starten!
|
||||||
|
# Wine bindet seinen Hintergrundprozess (wineserver/Desktop) an das DISPLAY des ersten Aufrufs.
|
||||||
|
# Läuft z. B. „pip install“ ohne Bildschirm und die GUI-Tests später mit, scheitert Tk unter
|
||||||
|
# Wine mit „Failed to create the menu window“.
|
||||||
|
wineserver -k 2>/dev/null || true
|
||||||
|
ANZEIGE=":$((90 + RANDOM % 9))"
|
||||||
|
Xvfb "$ANZEIGE" -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 &
|
||||||
|
XVFB_PID=$!
|
||||||
|
trap 'wineserver -k 2>/dev/null || true; kill $XVFB_PID 2>/dev/null || true' EXIT
|
||||||
|
export DISPLAY="$ANZEIGE"
|
||||||
|
sleep 1
|
||||||
|
|
||||||
$PY --version
|
$PY --version
|
||||||
$PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt
|
$PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt
|
||||||
|
|
||||||
echo "== Tests (Windows-Python) =="
|
echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) =="
|
||||||
$PY -m pytest -q
|
# PROVTOOL_GUI_TESTS=1: fehlt der Bildschirm, ist das ein Fehler (nicht „übersprungen“)
|
||||||
|
# -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 =="
|
echo "== Version =="
|
||||||
VERSION=$($PY build/version_info.py | tr -d '\r')
|
VERSION=$($PY build/version_info.py | tr -d '\r')
|
||||||
@@ -23,6 +41,24 @@ echo "== PyInstaller =="
|
|||||||
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
|
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
|
||||||
|
|
||||||
test -f dist/Provisionsmanager95.exe
|
test -f dist/Provisionsmanager95.exe
|
||||||
|
test -f dist/Provisionsmanager/Provisionsmanager.exe # Ordner-Variante für das Setup
|
||||||
|
|
||||||
|
selbsttest() { # $1 = zu prüfende .exe
|
||||||
|
rm -f selbsttest.txt
|
||||||
|
set +e
|
||||||
|
timeout 300 wine "$1" --selbsttest selbsttest.txt
|
||||||
|
CODE=$?
|
||||||
|
set -e
|
||||||
|
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
|
||||||
|
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
|
||||||
|
echo "Selbsttest von $1 FEHLGESCHLAGEN (Exit-Code $CODE)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Selbsttest bestanden"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== Selbsttest der fertigen .exe =="
|
||||||
|
selbsttest dist/Provisionsmanager95.exe
|
||||||
cp README.md dist/LIESMICH.md 2>/dev/null || true
|
cp README.md dist/LIESMICH.md 2>/dev/null || true
|
||||||
( cd dist && rm -f Provisionsmanager95-*.zip && \
|
( cd dist && rm -f Provisionsmanager95-*.zip && \
|
||||||
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
|
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
|
||||||
@@ -30,4 +66,34 @@ cp README.md dist/LIESMICH.md 2>/dev/null || true
|
|||||||
|| $PY -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
|
|| $PY -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
|
||||||
"Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md )
|
"Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md )
|
||||||
echo "$VERSION" > dist/VERSION
|
echo "$VERSION" > dist/VERSION
|
||||||
|
|
||||||
|
echo "== Setup (Inno Setup) =="
|
||||||
|
INNO_VERSION=6.7.3
|
||||||
|
INNO_SHA256=9c73c3bae7ed48d44112a0f48e66742c00090bdb5bef71d9d3c056c66e97b732
|
||||||
|
INNO_EXE=build/tmp/innosetup-$INNO_VERSION.exe
|
||||||
|
mkdir -p build/tmp
|
||||||
|
curl -fsSL -o "$INNO_EXE" "https://github.com/jrsoftware/issrc/releases/download/is-${INNO_VERSION//./_}/innosetup-$INNO_VERSION.exe"
|
||||||
|
echo "$INNO_SHA256 $INNO_EXE" | sha256sum -c -
|
||||||
|
timeout 300 wine "$INNO_EXE" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /CURRENTUSER '/DIR=C:\InnoSetup'
|
||||||
|
rm -f dist/Provisionsmanager-Setup-*.exe
|
||||||
|
timeout 300 wine 'C:\InnoSetup\ISCC.exe' /Q "/DAppVersion=$VERSION" 'build\installer.iss'
|
||||||
|
SETUP="dist/Provisionsmanager-Setup-$VERSION.exe"
|
||||||
|
test -f "$SETUP"
|
||||||
|
|
||||||
|
echo "== Installer-Test (still installieren, Selbsttest, still deinstallieren) =="
|
||||||
|
winordner() { winepath -u "$(wine cmd /c echo "%$1%" | tr -d '\r')"; } # z. B. APPDATA -> /opt/wineprefix/...
|
||||||
|
DATEN="$(winordner APPDATA)/Provisionsmanager95"
|
||||||
|
ZIEL="$(winordner LOCALAPPDATA)/Programs/Provisionsmanager"
|
||||||
|
mkdir -p "$DATEN"
|
||||||
|
echo "Benutzerdaten" > "$DATEN/installer-test.txt"
|
||||||
|
timeout 300 wine "$SETUP" /VERYSILENT /SUPPRESSMSGBOXES /CURRENTUSER /NORESTART
|
||||||
|
test -f "$(winordner APPDATA)/Microsoft/Windows/Start Menu/Programs/Provisions-Manager.lnk"
|
||||||
|
selbsttest "$ZIEL/Provisionsmanager.exe"
|
||||||
|
timeout 300 wine "$ZIEL/unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||||
|
# Der Deinstaller arbeitet als Kopie im Temp-Ordner weiter – auf das Ende warten
|
||||||
|
for _ in $(seq 60); do [ -e "$ZIEL" ] || break; sleep 1; done
|
||||||
|
if [ -e "$ZIEL" ]; then echo "Deinstallation FEHLGESCHLAGEN – Programmordner noch da:"; ls -la "$ZIEL"; exit 1; fi
|
||||||
|
if [ ! -f "$DATEN/installer-test.txt" ]; then echo "Deinstallation hat Benutzerdaten gelöscht!"; exit 1; fi
|
||||||
|
rm "$DATEN/installer-test.txt"
|
||||||
|
echo "Installer-Test bestanden"
|
||||||
ls -la dist
|
ls -la dist
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
; Inno-Setup-Skript: Installation pro Benutzer (ohne Admin-Rechte) aus dist/Provisionsmanager/
|
||||||
|
; Aufruf (siehe build/build_windows.sh): ISCC.exe /DAppVersion=1.2.3 build\installer.iss
|
||||||
|
; Ergebnis: dist/Provisionsmanager-Setup-<version>.exe
|
||||||
|
#ifndef AppVersion
|
||||||
|
#error AppVersion fehlt – ISCC mit /DAppVersion=<version> aufrufen
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; AppId nie ändern: daran erkennt Windows bei Updates die bestehende Installation
|
||||||
|
AppId={{79CF85D1-16DC-4FD5-924B-B6C9AFB90309}
|
||||||
|
AppName=Provisions-Manager
|
||||||
|
AppVersion={#AppVersion}
|
||||||
|
AppPublisher=Handy-Insel Miesbach
|
||||||
|
PrivilegesRequired=lowest
|
||||||
|
DefaultDirName={localappdata}\Programs\Provisionsmanager
|
||||||
|
DefaultGroupName=Provisions-Manager
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
UninstallDisplayIcon={app}\Provisionsmanager.exe
|
||||||
|
SetupIconFile=..\assets\app.ico
|
||||||
|
OutputDir=..\dist
|
||||||
|
OutputBaseFilename=Provisionsmanager-Setup-{#AppVersion}
|
||||||
|
CloseApplications=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
; Benutzerdaten liegen in %APPDATA%\Provisionsmanager95 – das Setup fasst sie nicht an,
|
||||||
|
; der Deinstaller entfernt nur, was er selbst installiert hat.
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "de"; MessagesFile: "compiler:Languages\German.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||||
|
|
||||||
|
[InstallDelete]
|
||||||
|
; Update: Bibliotheken der alten Version entfernen, damit keine veralteten Dateien liegen bleiben
|
||||||
|
Type: filesandordirs; Name: "{app}\_internal"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "..\dist\Provisionsmanager\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{autoprograms}\Provisions-Manager"; Filename: "{app}\Provisionsmanager.exe"
|
||||||
|
Name: "{autodesktop}\Provisions-Manager"; Filename: "{app}\Provisionsmanager.exe"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\Provisionsmanager.exe"; Description: "{cm:LaunchProgram,Provisions-Manager}"; Flags: nowait postinstall skipifsilent
|
||||||
|
; Update aus dem Programm (/VERYSILENT /NEUSTART=1): danach wieder starten
|
||||||
|
Filename: "{app}\Provisionsmanager.exe"; Flags: nowait; Check: NachUpdateStarten
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
function NachUpdateStarten: Boolean;
|
||||||
|
begin
|
||||||
|
Result := ExpandConstant('{param:NEUSTART|0}') = '1';
|
||||||
|
end;
|
||||||
@@ -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")
|
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Erzeugt provtool/gui/bilder/tipp.png (freigestellt, verkleinert, ohne Metadaten) aus dem Foto
|
||||||
|
für den „Tipp des Tages“. Nur zum Entwickeln nötig (Pillow): python build/make_tipp_bild.py <foto>"""
|
||||||
|
import sys
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ZIEL_HOEHE = 170
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def freistellen(im: Image.Image, schwelle: int = 232, min_flaeche: int = 6000) -> Image.Image:
|
||||||
|
"""Weißen Hintergrund transparent machen: alle hellen Flächen, die den Rand berühren ODER groß
|
||||||
|
sind (z. B. das Innere der Büroklammer-Schlaufen). Kleine helle Flächen (Augenweiß) bleiben."""
|
||||||
|
im = im.convert("RGBA")
|
||||||
|
w, h = im.size
|
||||||
|
px = im.load()
|
||||||
|
hell = lambda p: p[0] >= schwelle and p[1] >= schwelle and p[2] >= schwelle # noqa: E731
|
||||||
|
gesehen = bytearray(w * h)
|
||||||
|
for sy in range(h):
|
||||||
|
for sx in range(w):
|
||||||
|
if gesehen[sy * w + sx] or not hell(px[sx, sy]):
|
||||||
|
continue
|
||||||
|
flaeche, rand, q = [], False, deque([(sx, sy)])
|
||||||
|
gesehen[sy * w + sx] = 1
|
||||||
|
while q:
|
||||||
|
x, y = q.popleft()
|
||||||
|
flaeche.append((x, y))
|
||||||
|
if x in (0, w - 1) or y in (0, h - 1):
|
||||||
|
rand = True
|
||||||
|
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
||||||
|
if 0 <= nx < w and 0 <= ny < h and not gesehen[ny * w + nx] and hell(px[nx, ny]):
|
||||||
|
gesehen[ny * w + nx] = 1
|
||||||
|
q.append((nx, ny))
|
||||||
|
if rand or len(flaeche) >= min_flaeche:
|
||||||
|
for x, y in flaeche:
|
||||||
|
px[x, y] = (255, 255, 255, 0)
|
||||||
|
return im.crop(im.getbbox())
|
||||||
|
|
||||||
|
|
||||||
|
def main(foto: str, ziel: Path, modus: str = "lanczos"):
|
||||||
|
im = freistellen(Image.open(foto))
|
||||||
|
faktor = ZIEL_HOEHE / im.height
|
||||||
|
groesse = (max(1, round(im.width * faktor)), ZIEL_HOEHE)
|
||||||
|
if modus == "raster": # erst auf das Pixel-Raster (ca. 8 px) mitteln, dann hochskalieren
|
||||||
|
klein = im.resize((im.width // 8, im.height // 8), Image.BOX)
|
||||||
|
im = klein.resize(groesse, Image.NEAREST)
|
||||||
|
else:
|
||||||
|
im = im.resize(groesse, Image.LANCZOS)
|
||||||
|
# Halbtransparente Randpixel hart machen (Tk/Win95-Optik, keine weißen Säume)
|
||||||
|
alpha = im.getchannel("A").point(lambda a: 255 if a >= 128 else 0)
|
||||||
|
im.putalpha(alpha)
|
||||||
|
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
im.save(ziel, optimize=True) # PNG ohne EXIF-Metadaten
|
||||||
|
print(ziel, im.size)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1], Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "provtool" / "gui" / "bilder" / "tipp.png",
|
||||||
|
sys.argv[3] if len(sys.argv) > 3 else "lanczos")
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# PyInstaller-Spezifikation – erzeugt eine einzelne Provisionsmanager95.exe
|
# PyInstaller-Spezifikation – erzeugt eine einzelne Programmdatei (Windows: .exe, Linux: ELF),
|
||||||
# Aufruf (unter Windows oder Wine): pyinstaller --noconfirm build/provisionsmanager.spec
|
# unter Windows zusätzlich den Ordner dist/Provisionsmanager/ (schneller Start, Inhalt des Setups)
|
||||||
|
# Aufruf: pyinstaller --noconfirm build/provisionsmanager.spec
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
WIN = sys.platform.startswith("win")
|
||||||
|
|
||||||
ROOT = os.path.abspath(os.path.join(SPECPATH, ".."))
|
ROOT = os.path.abspath(os.path.join(SPECPATH, ".."))
|
||||||
|
|
||||||
@@ -8,6 +12,8 @@ a = Analysis(
|
|||||||
[os.path.join(ROOT, "build", "start.py")],
|
[os.path.join(ROOT, "build", "start.py")],
|
||||||
pathex=[ROOT],
|
pathex=[ROOT],
|
||||||
hiddenimports=["openpyxl", "xlrd"],
|
hiddenimports=["openpyxl", "xlrd"],
|
||||||
|
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder")),
|
||||||
|
(os.path.join(ROOT, "provtool", "gui", "schriften"), os.path.join("provtool", "gui", "schriften"))],
|
||||||
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
|
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
@@ -19,11 +25,27 @@ exe = EXE(
|
|||||||
a.datas,
|
a.datas,
|
||||||
[],
|
[],
|
||||||
name="Provisionsmanager95",
|
name="Provisionsmanager95",
|
||||||
icon=os.path.join(ROOT, "assets", "app.ico"),
|
icon=os.path.join(ROOT, "assets", "app.ico") if WIN else None,
|
||||||
version=os.path.join(ROOT, "build", "version_info.txt"),
|
version=os.path.join(ROOT, "build", "version_info.txt") if WIN else None,
|
||||||
console=False,
|
console=False,
|
||||||
upx=False,
|
upx=False,
|
||||||
debug=False,
|
debug=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if WIN:
|
||||||
|
exe_ordner = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
[],
|
||||||
|
exclude_binaries=True,
|
||||||
|
name="Provisionsmanager",
|
||||||
|
icon=os.path.join(ROOT, "assets", "app.ico"),
|
||||||
|
version=os.path.join(ROOT, "build", "version_info.txt"),
|
||||||
|
console=False,
|
||||||
|
upx=False,
|
||||||
|
debug=False,
|
||||||
|
strip=False,
|
||||||
|
)
|
||||||
|
COLLECT(exe_ordner, a.binaries, a.datas, name="Provisionsmanager", upx=False, strip=False)
|
||||||
@@ -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,5 +1,5 @@
|
|||||||
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.8.0"
|
||||||
APP_NAME = "Provisions-Manager 95"
|
APP_NAME = "Provisions-Manager 95"
|
||||||
APP_ORG = "Handy-Insel Miesbach"
|
APP_ORG = "Handy-Insel Miesbach"
|
||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
APP_DIR_NAME = "Provisionsmanager95"
|
APP_DIR_NAME = "Provisionsmanager95"
|
||||||
@@ -35,23 +36,37 @@ DEFAULTS = {
|
|||||||
"import_ordner": None,
|
"import_ordner": None,
|
||||||
"export_kopfzeile": False,
|
"export_kopfzeile": False,
|
||||||
"export_summe": True,
|
"export_summe": True,
|
||||||
"export_pro_anbieter": True,
|
"export_anbieter_getrennt": False, # freenet + Vodafone in einer Datei
|
||||||
|
"export_null_weglassen": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def load() -> dict:
|
def load() -> dict:
|
||||||
|
"""Einstellungen lesen. Ist die Datei unlesbar (z. B. Absturz beim Schreiben), wird sie als
|
||||||
|
*.defekt.json aufbewahrt und unter "defekt" gemeldet – statt still mit Standardwerten weiterzumachen
|
||||||
|
(dabei ginge z. B. der Pfad zur Datenbank auf dem Netzlaufwerk verloren)."""
|
||||||
data = dict(DEFAULTS)
|
data = dict(DEFAULTS)
|
||||||
f = _settings_file()
|
f = _settings_file()
|
||||||
if f.exists():
|
if f.exists():
|
||||||
try:
|
try:
|
||||||
data.update(json.loads(f.read_text(encoding="utf-8")))
|
data.update(json.loads(f.read_text(encoding="utf-8")))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
sicherung = f.with_suffix(".defekt.json")
|
||||||
|
try:
|
||||||
|
shutil.copyfile(f, sicherung)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
data["defekt"] = str(sicherung)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def save(data: dict) -> None:
|
def save(data: dict) -> None:
|
||||||
_settings_file().write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
"""Atomar: erst in eine Hilfsdatei, dann umbenennen – ein Absturz hinterlässt nie eine halbe Datei."""
|
||||||
|
data = {k: v for k, v in data.items() if k != "defekt"}
|
||||||
|
f = _settings_file()
|
||||||
|
tmp = f.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(tmp, f)
|
||||||
|
|
||||||
|
|
||||||
def db_path(settings: dict | None = None) -> Path:
|
def db_path(settings: dict | None = None) -> Path:
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
|
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from provtool import regeln
|
||||||
from provtool.importer import ImportErgebnis
|
from provtool.importer import ImportErgebnis
|
||||||
from provtool.money import apply_percent
|
from provtool.money import apply_percent
|
||||||
|
|
||||||
SCHEMA_VERSION = 2
|
SCHEMA_VERSION = 4
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
|
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
|
||||||
@@ -23,7 +26,15 @@ CREATE TABLE IF NOT EXISTS haendler (
|
|||||||
email TEXT DEFAULT '',
|
email TEXT DEFAULT '',
|
||||||
notiz TEXT DEFAULT '',
|
notiz TEXT DEFAULT '',
|
||||||
standard_prozent REAL NOT NULL DEFAULT 100,
|
standard_prozent REAL NOT NULL DEFAULT 100,
|
||||||
aktiv INTEGER NOT NULL DEFAULT 1
|
aktiv INTEGER NOT NULL DEFAULT 1,
|
||||||
|
airtime_prozent REAL NOT NULL DEFAULT 0, -- 0 = keine Airtime
|
||||||
|
staffel TEXT -- Jahresbonus-Staffel (JSON), NULL = Standard-Staffel
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Fest eingestellter Typ einer Provisionsart (sonst automatisch erkannt, siehe regeln.py).
|
||||||
|
CREATE TABLE IF NOT EXISTS art_typ (
|
||||||
|
provisionsart TEXT PRIMARY KEY,
|
||||||
|
typ TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu
|
-- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu
|
||||||
@@ -45,6 +56,14 @@ CREATE TABLE IF NOT EXISTS regel (
|
|||||||
UNIQUE (haendler_id, provisionsart)
|
UNIQUE (haendler_id, provisionsart)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Eigene Kennungen (eigene Filiale): diese Positionen werden ausgeblendet und nicht verprovisioniert.
|
||||||
|
CREATE TABLE IF NOT EXISTS eigen (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
anbieter TEXT NOT NULL,
|
||||||
|
wert TEXT NOT NULL,
|
||||||
|
UNIQUE (anbieter, wert)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS abrechnung (
|
CREATE TABLE IF NOT EXISTS abrechnung (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
titel TEXT NOT NULL,
|
titel TEXT NOT NULL,
|
||||||
@@ -79,9 +98,18 @@ CREATE TABLE IF NOT EXISTS position (
|
|||||||
roh TEXT
|
roh TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
|
CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
|
||||||
|
|
||||||
|
-- Abgerechnete Positionen (Fingerabdruck), über alle Abrechnungen hinweg: taucht eine schon
|
||||||
|
-- ausgezahlte Position in einer späteren Datei/Abrechnung wieder auf, wird sie nicht erneut abgerechnet.
|
||||||
|
CREATE TABLE IF NOT EXISTS abgerechnet_fp (
|
||||||
|
fp TEXT PRIMARY KEY,
|
||||||
|
wann TEXT NOT NULL,
|
||||||
|
abrechnung TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
|
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
|
||||||
|
EIGEN = -2 # Pseudo-Händler-ID für Positionen eigener Kennungen (ausgeblendet)
|
||||||
|
|
||||||
# Sonder-Kennung: Vodafone-Positionen, deren Kundenname komplett in GROSSBUCHSTABEN steht
|
# Sonder-Kennung: Vodafone-Positionen, deren Kundenname komplett in GROSSBUCHSTABEN steht
|
||||||
# (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr.
|
# (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr.
|
||||||
@@ -89,6 +117,28 @@ CAPS_ANBIETER = "vodafone_caps"
|
|||||||
CAPS_WERT = "*"
|
CAPS_WERT = "*"
|
||||||
|
|
||||||
|
|
||||||
|
FP_FELDER = ("anbieter", "zeitraum", "provisionsart", "kennung", "datum", "rufnummer", "imei", "vorname", "name",
|
||||||
|
"tarif", "dienst", "aktionscode", "betrag_orig")
|
||||||
|
|
||||||
|
|
||||||
|
def fingerabdruecke(zeilen: list[dict]) -> list[str]:
|
||||||
|
"""Fingerabdruck je Position einer Datei. Gleiche Zeilen innerhalb einer Datei (z. B. zwei gleiche
|
||||||
|
SIM-Karten-Gutschriften) werden durchgezählt, damit beide als eigene Positionen gelten."""
|
||||||
|
gesehen: dict[str, int] = {}
|
||||||
|
out = []
|
||||||
|
for z in zeilen:
|
||||||
|
roh = "\x1f".join(str(z.get(k) if z.get(k) is not None else "").strip() for k in FP_FELDER)
|
||||||
|
h = hashlib.sha1(roh.encode("utf-8")).hexdigest()[:24]
|
||||||
|
gesehen[h] = gesehen.get(h, 0) + 1
|
||||||
|
out.append(f"{h}#{gesehen[h]}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class AbrechnungErledigt(Exception):
|
||||||
|
"""Die Abrechnung ist als erledigt gekennzeichnet (oder enthält abgerechnete Positionen) und darf so
|
||||||
|
nicht verändert werden."""
|
||||||
|
|
||||||
|
|
||||||
def ist_grossgeschrieben(name: str | None) -> bool:
|
def ist_grossgeschrieben(name: str | None) -> bool:
|
||||||
buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"]
|
buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"]
|
||||||
return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben)
|
return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben)
|
||||||
@@ -103,6 +153,8 @@ class Haendler:
|
|||||||
notiz: str = ""
|
notiz: str = ""
|
||||||
standard_prozent: float = 100.0
|
standard_prozent: float = 100.0
|
||||||
aktiv: bool = True
|
aktiv: bool = True
|
||||||
|
airtime_prozent: float = 0.0
|
||||||
|
staffel: list | None = None # None = Standard-Staffel
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
def _now() -> str:
|
||||||
@@ -118,17 +170,40 @@ class Datenbank:
|
|||||||
self.con.execute("PRAGMA foreign_keys = ON")
|
self.con.execute("PRAGMA foreign_keys = ON")
|
||||||
self.con.executescript(SCHEMA)
|
self.con.executescript(SCHEMA)
|
||||||
self._migrieren()
|
self._migrieren()
|
||||||
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
|
|
||||||
self.con.commit()
|
|
||||||
|
|
||||||
def _migrieren(self):
|
def _migrieren(self):
|
||||||
spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")}
|
spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")}
|
||||||
if "haendler_fest" not in spalten:
|
if "haendler_fest" not in spalten:
|
||||||
# NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner)
|
# NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner)
|
||||||
self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER")
|
self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER")
|
||||||
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
|
h_spalten = {r[1] for r in self.con.execute("PRAGMA table_info(haendler)")}
|
||||||
|
if "airtime_prozent" not in h_spalten:
|
||||||
|
self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0")
|
||||||
|
if "staffel" not in h_spalten:
|
||||||
|
self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT")
|
||||||
|
if "abgerechnet" not in spalten:
|
||||||
|
# Zeitpunkt der Abrechnung (NULL = offen), Händler zum Zeitpunkt der Abrechnung (eingefroren),
|
||||||
|
# Fingerabdruck (erkennt dieselbe Position in späteren Dateien)
|
||||||
|
self.con.execute("ALTER TABLE position ADD COLUMN abgerechnet TEXT")
|
||||||
|
self.con.execute("ALTER TABLE position ADD COLUMN abgerechnet_hid INTEGER")
|
||||||
|
self.con.execute("ALTER TABLE position ADD COLUMN fp TEXT")
|
||||||
|
a_spalten = {r[1] for r in self.con.execute("PRAGMA table_info(abrechnung)")}
|
||||||
|
if "erledigt" not in a_spalten:
|
||||||
|
self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT")
|
||||||
|
self._fingerabdruecke_nachtragen()
|
||||||
|
self.con.execute("INSERT INTO meta VALUES ('schema_version', ?) "
|
||||||
|
"ON CONFLICT(schluessel) DO UPDATE SET wert=excluded.wert", (str(SCHEMA_VERSION),))
|
||||||
self.con.commit()
|
self.con.commit()
|
||||||
|
|
||||||
|
def _fingerabdruecke_nachtragen(self):
|
||||||
|
"""Für Positionen aus älteren Versionen den Fingerabdruck berechnen."""
|
||||||
|
dateien = [r[0] for r in self.con.execute("SELECT DISTINCT datei_id FROM position WHERE fp IS NULL")]
|
||||||
|
for did in dateien:
|
||||||
|
zeilen = [dict(r) for r in self.con.execute(
|
||||||
|
"SELECT * FROM position WHERE datei_id IS ? ORDER BY reihenfolge", (did,))]
|
||||||
|
for pid, fp in zip([z["id"] for z in zeilen], fingerabdruecke(zeilen)):
|
||||||
|
self.con.execute("UPDATE position SET fp=? WHERE id=? AND fp IS NULL", (fp, pid))
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.con.close()
|
self.con.close()
|
||||||
|
|
||||||
@@ -141,38 +216,56 @@ class Datenbank:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def restore(quelle: str | Path, ziel: str | Path) -> None:
|
def restore(quelle: str | Path, ziel: str | Path) -> None:
|
||||||
test = sqlite3.connect(str(quelle))
|
"""Spielt eine Sicherung ein. Geprüft wird an einer Kopie (unversehrt, unsere Tabellen, lässt sich
|
||||||
|
öffnen und migrieren) – erst dann ersetzt sie die Datenbank. Schlägt die Prüfung fehl, bleibt alles."""
|
||||||
|
ziel = Path(ziel)
|
||||||
|
probe = ziel.with_suffix(".pruefung.db")
|
||||||
|
shutil.copyfile(quelle, probe)
|
||||||
try:
|
try:
|
||||||
test.execute("SELECT count(*) FROM haendler").fetchone()
|
con = sqlite3.connect(str(probe))
|
||||||
|
try:
|
||||||
|
if con.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
|
||||||
|
raise ValueError("Die Datei ist beschädigt (integrity_check).")
|
||||||
|
tabellen = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
|
fehlt = {"haendler", "abrechnung", "position"} - tabellen
|
||||||
|
if fehlt:
|
||||||
|
raise ValueError(f"Keine Sicherung des Provisions-Managers (Tabellen fehlen: {', '.join(sorted(fehlt))}).")
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
Datenbank(probe).close() # öffnet und migriert – wirft, wenn das nicht geht
|
||||||
|
os.replace(probe, ziel)
|
||||||
finally:
|
finally:
|
||||||
test.close()
|
probe.unlink(missing_ok=True)
|
||||||
shutil.copyfile(quelle, ziel)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ Händler
|
# ------------------------------------------------------------------ Händler
|
||||||
|
@staticmethod
|
||||||
|
def _haendler_aus(r) -> Haendler:
|
||||||
|
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"], r["standard_prozent"],
|
||||||
|
bool(r["aktiv"]), r["airtime_prozent"] or 0.0, regeln.staffel_laden(r["staffel"]))
|
||||||
|
|
||||||
def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]:
|
def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]:
|
||||||
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name"
|
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name"
|
||||||
return [Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
|
return [self._haendler_aus(r) for r in self.con.execute(sql)]
|
||||||
r["standard_prozent"], bool(r["aktiv"])) for r in self.con.execute(sql)]
|
|
||||||
|
|
||||||
def haendler(self, hid: int) -> Haendler | None:
|
def haendler(self, hid: int) -> Haendler | None:
|
||||||
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
|
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
|
||||||
if not r:
|
return self._haendler_aus(r) if r else None
|
||||||
return None
|
|
||||||
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
|
|
||||||
r["standard_prozent"], bool(r["aktiv"]))
|
|
||||||
|
|
||||||
def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
|
def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
|
||||||
"""Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]"""
|
"""Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]"""
|
||||||
|
staffel = regeln.staffel_text(regeln.staffel_pruefen(h.staffel)) if h.staffel is not None else None
|
||||||
|
werte = (h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv),
|
||||||
|
h.airtime_prozent or 0.0, staffel)
|
||||||
with self.con:
|
with self.con:
|
||||||
if h.id:
|
if h.id:
|
||||||
self.con.execute(
|
self.con.execute(
|
||||||
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=? WHERE id=?",
|
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=?, "
|
||||||
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv), h.id))
|
"airtime_prozent=?, staffel=? WHERE id=?", werte + (h.id,))
|
||||||
hid = h.id
|
hid = h.id
|
||||||
else:
|
else:
|
||||||
cur = self.con.execute(
|
cur = self.con.execute(
|
||||||
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv) VALUES (?,?,?,?,?,?)",
|
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv, "
|
||||||
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv)))
|
"airtime_prozent, staffel) VALUES (?,?,?,?,?,?,?,?)", werte)
|
||||||
hid = cur.lastrowid
|
hid = cur.lastrowid
|
||||||
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
|
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
|
||||||
for anbieter, wert in kennungen:
|
for anbieter, wert in kennungen:
|
||||||
@@ -206,15 +299,31 @@ class Datenbank:
|
|||||||
kmap = self.kennung_map()
|
kmap = self.kennung_map()
|
||||||
caps = kmap.get((CAPS_ANBIETER, CAPS_WERT))
|
caps = kmap.get((CAPS_ANBIETER, CAPS_WERT))
|
||||||
aktive = {h.id for h in self.haendler_liste(nur_aktive=True)}
|
aktive = {h.id for h in self.haendler_liste(nur_aktive=True)}
|
||||||
|
eigene = set(self.eigene_kennungen())
|
||||||
|
|
||||||
def finde(anbieter: str, kennung: str, name: str | None = None, fest: int | None = None) -> int:
|
def finde(anbieter: str, kennung: str, name: str | None = None, fest: int | None = None) -> int:
|
||||||
if fest is not None:
|
if fest is not None:
|
||||||
return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET
|
return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET
|
||||||
if caps and anbieter == "vodafone" and ist_grossgeschrieben(name):
|
if caps and anbieter == "vodafone" and ist_grossgeschrieben(name):
|
||||||
return caps
|
return caps
|
||||||
return kmap.get((anbieter, kennung), UNZUGEORDNET)
|
hid = kmap.get((anbieter, kennung), UNZUGEORDNET)
|
||||||
|
if hid == UNZUGEORDNET and (anbieter, kennung) in eigene:
|
||||||
|
return EIGEN
|
||||||
|
return hid
|
||||||
return finde
|
return finde
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ eigene Kennungen
|
||||||
|
def eigene_kennungen(self) -> list[tuple[str, str]]:
|
||||||
|
return [(r[0], r[1]) for r in self.con.execute("SELECT anbieter, wert FROM eigen ORDER BY anbieter, wert")]
|
||||||
|
|
||||||
|
def eigene_setzen(self, anbieter: str, wert: str, eigen: bool = True) -> None:
|
||||||
|
wert = (wert or "").strip()
|
||||||
|
with self.con:
|
||||||
|
if eigen:
|
||||||
|
self.con.execute("INSERT OR IGNORE INTO eigen (anbieter, wert) VALUES (?,?)", (anbieter, wert))
|
||||||
|
else:
|
||||||
|
self.con.execute("DELETE FROM eigen WHERE anbieter=? AND wert=?", (anbieter, wert))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ Regeln
|
# ------------------------------------------------------------------ Regeln
|
||||||
def regeln(self, hid: int) -> dict[str, float]:
|
def regeln(self, hid: int) -> dict[str, float]:
|
||||||
return {r[0]: r[1] for r in self.con.execute(
|
return {r[0]: r[1] for r in self.con.execute(
|
||||||
@@ -229,15 +338,50 @@ class Datenbank:
|
|||||||
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent",
|
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent",
|
||||||
(hid, provisionsart, prozent))
|
(hid, provisionsart, prozent))
|
||||||
|
|
||||||
def prozent_fuer(self, hid: int, provisionsart: str) -> float:
|
# ------------------------------------------------------------------ Typen der Provisionsarten
|
||||||
if hid == UNZUGEORDNET:
|
def art_typen(self) -> dict[str, str]:
|
||||||
return 100.0
|
"""Fest eingestellte Typen je Provisionsart (alles andere wird automatisch erkannt)."""
|
||||||
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
|
return {r[0]: r[1] for r in self.con.execute("SELECT provisionsart, typ FROM art_typ")}
|
||||||
(hid, provisionsart)).fetchone()
|
|
||||||
if r:
|
def art_typ_setzen(self, provisionsart: str, typ: str | None) -> None:
|
||||||
return r[0]
|
"""typ=None: wieder automatisch erkennen."""
|
||||||
h = self.haendler(hid)
|
if typ is not None and typ not in regeln.TYPEN:
|
||||||
return h.standard_prozent if h else 100.0
|
raise ValueError(f"Unbekannter Typ: {typ}")
|
||||||
|
with self.con:
|
||||||
|
if typ is None:
|
||||||
|
self.con.execute("DELETE FROM art_typ WHERE provisionsart=?", (provisionsart,))
|
||||||
|
else:
|
||||||
|
self.con.execute("INSERT INTO art_typ (provisionsart, typ) VALUES (?,?) "
|
||||||
|
"ON CONFLICT(provisionsart) DO UPDATE SET typ=excluded.typ", (provisionsart, typ))
|
||||||
|
|
||||||
|
def provisionsarten(self) -> list[tuple[str, str, int]]:
|
||||||
|
"""Alle bekannten Provisionsarten: (Provisionsart, automatisch erkannter Typ, Anzahl Positionen)."""
|
||||||
|
arten: dict[str, list] = {}
|
||||||
|
for r in self.con.execute("SELECT provisionsart, dienst, tarif FROM position"):
|
||||||
|
art = r["provisionsart"] or ""
|
||||||
|
e = arten.setdefault(art, [regeln.NORMAL, 0])
|
||||||
|
erkannt = regeln.erkennen(art, r["dienst"], r["tarif"])
|
||||||
|
if erkannt != regeln.NORMAL:
|
||||||
|
e[0] = erkannt
|
||||||
|
e[1] += 1
|
||||||
|
for art in self.art_typen():
|
||||||
|
arten.setdefault(art, [regeln.erkennen(art), 0])
|
||||||
|
return sorted(((a, t, n) for a, (t, n) in arten.items()), key=lambda x: x[0].lower())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Jahresbonus-Staffel
|
||||||
|
def standard_staffel(self) -> regeln.Staffel:
|
||||||
|
r = self.con.execute("SELECT wert FROM meta WHERE schluessel='staffel_standard'").fetchone()
|
||||||
|
return (regeln.staffel_laden(r[0]) if r else None) or list(regeln.STANDARD_STAFFEL)
|
||||||
|
|
||||||
|
def standard_staffel_setzen(self, staffel) -> None:
|
||||||
|
text = regeln.staffel_text(regeln.staffel_pruefen(staffel))
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("INSERT INTO meta VALUES ('staffel_standard', ?) "
|
||||||
|
"ON CONFLICT(schluessel) DO UPDATE SET wert=excluded.wert", (text,))
|
||||||
|
|
||||||
|
def staffel_fuer(self, hid: int) -> regeln.Staffel:
|
||||||
|
h = self.haendler(hid) if hid > 0 else None
|
||||||
|
return h.staffel if h and h.staffel is not None else self.standard_staffel()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ Abrechnungen
|
# ------------------------------------------------------------------ Abrechnungen
|
||||||
def abrechnung_neu(self, titel: str) -> int:
|
def abrechnung_neu(self, titel: str) -> int:
|
||||||
@@ -249,6 +393,7 @@ class Datenbank:
|
|||||||
def abrechnungen(self) -> list[sqlite3.Row]:
|
def abrechnungen(self) -> list[sqlite3.Row]:
|
||||||
return list(self.con.execute(
|
return list(self.con.execute(
|
||||||
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
|
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
|
||||||
|
"(SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id AND p.abgerechnet IS NOT NULL) AS abgerechnet, "
|
||||||
"(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien "
|
"(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien "
|
||||||
"FROM abrechnung a ORDER BY a.geaendert DESC"))
|
"FROM abrechnung a ORDER BY a.geaendert DESC"))
|
||||||
|
|
||||||
@@ -260,9 +405,20 @@ class Datenbank:
|
|||||||
self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid))
|
self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid))
|
||||||
|
|
||||||
def abrechnung_loeschen(self, aid: int) -> None:
|
def abrechnung_loeschen(self, aid: int) -> None:
|
||||||
|
self._keine_abgerechneten("abrechnung_id=?", aid)
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("DELETE FROM abrechnung WHERE id=?", (aid,))
|
self.con.execute("DELETE FROM abrechnung WHERE id=?", (aid,))
|
||||||
|
|
||||||
|
def _keine_abgerechneten(self, wo: str, wert: int) -> None:
|
||||||
|
"""Abgerechnete Positionen dürfen nicht gelöscht werden: ihr Fingerabdruck bliebe in abgerechnet_fp
|
||||||
|
zurück (die Position gälte nach erneutem Import für immer als „doppelt“) – oder, würde man ihn
|
||||||
|
mitlöschen, könnte sie ein zweites Mal ausgezahlt werden. Zuerst die Kennzeichnung aufheben."""
|
||||||
|
n = self.con.execute(f"SELECT count(*) FROM position WHERE {wo} AND abgerechnet IS NOT NULL",
|
||||||
|
(wert,)).fetchone()[0]
|
||||||
|
if n:
|
||||||
|
raise AbrechnungErledigt(f"{n} Position(en) sind bereits abgerechnet. Zuerst die Kennzeichnung "
|
||||||
|
"„abgerechnet“ aufheben (Rechtsklick auf die Positionen), dann löschen.")
|
||||||
|
|
||||||
def dateien(self, aid: int) -> list[sqlite3.Row]:
|
def dateien(self, aid: int) -> list[sqlite3.Row]:
|
||||||
return list(self.con.execute("SELECT * FROM abrechnung_datei WHERE abrechnung_id=? ORDER BY id", (aid,)))
|
return list(self.con.execute("SELECT * FROM abrechnung_datei WHERE abrechnung_id=? ORDER BY id", (aid,)))
|
||||||
|
|
||||||
@@ -272,12 +428,18 @@ class Datenbank:
|
|||||||
(sha256,))]
|
(sha256,))]
|
||||||
|
|
||||||
def datei_entfernen(self, datei_id: int) -> None:
|
def datei_entfernen(self, datei_id: int) -> None:
|
||||||
|
r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone()
|
||||||
|
if not r:
|
||||||
|
return
|
||||||
|
self._nicht_erledigt(r[0])
|
||||||
|
self._keine_abgerechneten("datei_id=?", datei_id)
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
|
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
|
||||||
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
||||||
|
self._regeln_anwenden(r[0], set()) # weniger Karten -> Jahresbonus-Staffel der übrigen neu
|
||||||
|
|
||||||
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
||||||
finde = self.zuordner()
|
self._nicht_erledigt(aid)
|
||||||
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
|
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
|
||||||
(aid,)).fetchone()[0]
|
(aid,)).fetchone()[0]
|
||||||
with self.con:
|
with self.con:
|
||||||
@@ -286,51 +448,153 @@ class Datenbank:
|
|||||||
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
|
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
|
||||||
did = cur.lastrowid
|
did = cur.lastrowid
|
||||||
for i, p in enumerate(erg.positionen, start=start + 1):
|
for i, p in enumerate(erg.positionen, start=start + 1):
|
||||||
hid = finde(p.anbieter, p.kennung, p.name)
|
|
||||||
proz = self.prozent_fuer(hid, p.provisionsart)
|
|
||||||
self.con.execute(
|
self.con.execute(
|
||||||
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
|
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
|
||||||
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
|
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
|
||||||
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)",
|
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,0,?)",
|
||||||
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung,
|
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung,
|
||||||
p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name,
|
p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name,
|
||||||
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent,
|
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent,
|
||||||
apply_percent(p.betrag_cent, proz), proz, json.dumps(p.roh, ensure_ascii=False, default=str)))
|
json.dumps(p.roh, ensure_ascii=False, default=str)))
|
||||||
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
||||||
|
zeilen = [dict(r) for r in self.con.execute(
|
||||||
|
"SELECT * FROM position WHERE datei_id=? ORDER BY reihenfolge", (did,))]
|
||||||
|
for z, fp in zip(zeilen, fingerabdruecke(zeilen)):
|
||||||
|
self.con.execute("UPDATE position SET fp=? WHERE id=?", (fp, z["id"]))
|
||||||
|
neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))}
|
||||||
|
self._regeln_anwenden(aid, neue, auch_manuelle=True)
|
||||||
return len(erg.positionen)
|
return len(erg.positionen)
|
||||||
|
|
||||||
def positionen(self, aid: int) -> list[dict]:
|
def positionen(self, aid: int) -> list[dict]:
|
||||||
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen)."""
|
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
|
||||||
finde = self.zuordner()
|
finde = self.zuordner()
|
||||||
|
fest = self.art_typen()
|
||||||
|
erledigt = self.erledigt(aid)
|
||||||
|
# Fingerabdrücke dieser Abrechnung, die schon abgerechnet sind (in dieser oder einer anderen)
|
||||||
|
schon = dict(self.con.execute(
|
||||||
|
"SELECT f.fp, f.abrechnung FROM abgerechnet_fp f WHERE f.fp IN (SELECT fp FROM position WHERE abrechnung_id=?)",
|
||||||
|
(aid,)).fetchall())
|
||||||
|
# Offene Doppelte: dieselbe Position (Fingerabdruck) ein zweites Mal offen – z. B. Wochen- und
|
||||||
|
# Monatsdatei in derselben Abrechnung. Die zuerst importierte zählt, die anderen sind „doppelt“.
|
||||||
|
erste: dict[str, tuple[int, str]] = {}
|
||||||
|
for fp, pid, titel in self.con.execute(
|
||||||
|
"SELECT p.fp, p.id, a.titel FROM position p JOIN abrechnung a ON a.id=p.abrechnung_id "
|
||||||
|
"WHERE p.abgerechnet IS NULL AND p.fp IN (SELECT fp FROM position WHERE abrechnung_id=?) "
|
||||||
|
"ORDER BY p.id", (aid,)):
|
||||||
|
erste.setdefault(fp, (pid, titel))
|
||||||
out = []
|
out = []
|
||||||
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
|
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
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"])
|
||||||
|
if erledigt and d["haendler_id"] > 0:
|
||||||
|
# Erledigt: was dabei nicht abgerechnet wurde, bekommt nachträglich keinen Händler mehr
|
||||||
|
# (sonst stünde es bei einem später angelegten Händler mit 100 % zur Auszahlung)
|
||||||
|
d["haendler_id"] = UNZUGEORDNET
|
||||||
|
d["typ"] = regeln.typ_von(d, fest)
|
||||||
|
d["doppelt"] = None
|
||||||
|
if d["fp"] and not d["abgerechnet"]:
|
||||||
|
if d["fp"] in schon:
|
||||||
|
d["doppelt"] = schon[d["fp"]]
|
||||||
|
elif erste.get(d["fp"], (d["id"],))[0] != d["id"]:
|
||||||
|
d["doppelt"] = erste[d["fp"]][1]
|
||||||
out.append(d)
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Berechnung nach Regeln
|
||||||
|
def _rechner(self):
|
||||||
|
"""Funktion (position, händler_id, typ, karten) -> (auszahlung_cent, prozent).
|
||||||
|
prozent ist None, wenn der Betrag aus der Jahresbonus-Staffel kommt."""
|
||||||
|
haendler = {h.id: h for h in self.haendler_liste()}
|
||||||
|
art_regeln: dict[int, dict[str, float]] = {}
|
||||||
|
for r in self.con.execute("SELECT haendler_id, provisionsart, prozent FROM regel"):
|
||||||
|
art_regeln.setdefault(r[0], {})[r[1]] = r[2]
|
||||||
|
std_staffel = self.standard_staffel()
|
||||||
|
|
||||||
|
def rechne(p: dict, hid: int, typ: str, karten: int) -> tuple[int, float | None]:
|
||||||
|
orig = p["betrag_orig"]
|
||||||
|
h = haendler.get(hid) if hid > 0 else None
|
||||||
|
if h is None: # nicht zugeordnet / eigene Filiale: unverändert
|
||||||
|
return orig, 100.0
|
||||||
|
regel = art_regeln.get(hid, {}).get(p["provisionsart"])
|
||||||
|
if regel is not None: # ausdrücklich je Provisionsart festgelegt -> hat Vorrang
|
||||||
|
proz = regel
|
||||||
|
elif typ == regeln.AIRTIME:
|
||||||
|
proz = h.airtime_prozent
|
||||||
|
elif typ == regeln.JAHRESBONUS:
|
||||||
|
return regeln.bonus_betrag(h.staffel if h.staffel is not None else std_staffel, karten, orig), None
|
||||||
|
else:
|
||||||
|
proz = h.standard_prozent
|
||||||
|
return apply_percent(orig, proz), proz
|
||||||
|
return rechne
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]:
|
||||||
|
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab, Doppelte nicht mit)."""
|
||||||
|
karten: dict[tuple[int, str], int] = {}
|
||||||
|
for p in positionen:
|
||||||
|
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"] and not p["doppelt"]:
|
||||||
|
k = (p["haendler_id"], p["anbieter"])
|
||||||
|
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1)
|
||||||
|
return {k: max(0, n) for k, n in karten.items()}
|
||||||
|
|
||||||
|
def _regeln_anwenden(self, aid: int, pids: set[int] | None = None, auch_manuelle: bool = False) -> int:
|
||||||
|
"""Berechnet die Auszahlung nach den Händler-Regeln neu: für `pids` (None = alle) und immer für
|
||||||
|
alle Jahresbonus-Positionen (deren Staffel hängt von der Anzahl aller Karten ab).
|
||||||
|
Von Hand geänderte Beträge bleiben, außer auch_manuelle=True. Gibt die Anzahl Änderungen zurück."""
|
||||||
|
if self.erledigt(aid):
|
||||||
|
return 0 # erledigte Abrechnungen bleiben, wie sie abgerechnet wurden
|
||||||
|
alle = self.positionen(aid)
|
||||||
|
karten = self.karten_zaehlen(alle)
|
||||||
|
rechne = self._rechner()
|
||||||
|
n = 0
|
||||||
|
with self.con:
|
||||||
|
for p in alle:
|
||||||
|
if p["abgerechnet"]:
|
||||||
|
continue # schon ausgezahlt: Betrag bleibt
|
||||||
|
if pids is not None and p["id"] not in pids and p["typ"] != regeln.JAHRESBONUS:
|
||||||
|
continue
|
||||||
|
if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)):
|
||||||
|
continue
|
||||||
|
betrag, proz = rechne(p, p["haendler_id"], p["typ"], karten.get((p["haendler_id"], p["anbieter"]), 0))
|
||||||
|
if betrag != p["betrag"] or proz != p["prozent"] or p["manuell"]:
|
||||||
|
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?",
|
||||||
|
(betrag, proz, p["id"]))
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
def _abrechnungen_von(self, pids: list[int]) -> list[int]:
|
||||||
|
if not pids:
|
||||||
|
return []
|
||||||
|
marken = ",".join("?" * len(pids))
|
||||||
|
return [r[0] for r in self.con.execute(
|
||||||
|
f"SELECT DISTINCT abrechnung_id FROM position WHERE id IN ({marken})", list(pids))]
|
||||||
|
|
||||||
def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
|
def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
|
||||||
"""Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen).
|
"""Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen).
|
||||||
Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet."""
|
Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet."""
|
||||||
|
pids = self._offene(pids)
|
||||||
with self.con:
|
with self.con:
|
||||||
for pid in pids:
|
for pid in pids:
|
||||||
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
|
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
|
||||||
finde = self.zuordner()
|
for aid in self._abrechnungen_von(pids):
|
||||||
with self.con:
|
self._regeln_anwenden(aid, set(pids))
|
||||||
for pid in pids:
|
|
||||||
r = self.con.execute("SELECT * FROM position WHERE id=?", (pid,)).fetchone()
|
def regeln_wiederherstellen(self, pids: list[int]) -> None:
|
||||||
if r is None or r["manuell"]:
|
"""Ausgewählte Positionen wieder nach den Händler-Regeln berechnen (auch von Hand geänderte)."""
|
||||||
continue
|
for aid in self._abrechnungen_von(pids):
|
||||||
neu_hid = finde(r["anbieter"], r["kennung"], r["name"], r["haendler_fest"])
|
self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
|
||||||
proz = self.prozent_fuer(neu_hid, r["provisionsart"])
|
|
||||||
self.con.execute("UPDATE position SET betrag=?, prozent=? WHERE id=?",
|
|
||||||
(apply_percent(r["betrag_orig"], proz), proz, pid))
|
|
||||||
|
|
||||||
def betrag_setzen(self, pid: int, cent: int) -> None:
|
def betrag_setzen(self, pid: int, cent: int) -> None:
|
||||||
|
if not self._offene([pid]):
|
||||||
|
raise AbrechnungErledigt("Die Position ist bereits abgerechnet.")
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
|
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
|
||||||
|
|
||||||
def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
|
def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
|
||||||
|
pids = self._offene(pids)
|
||||||
n = 0
|
n = 0
|
||||||
with self.con:
|
with self.con:
|
||||||
for pid in pids:
|
for pid in pids:
|
||||||
@@ -345,18 +609,86 @@ class Datenbank:
|
|||||||
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
|
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
|
||||||
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an.
|
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an.
|
||||||
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
|
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
|
||||||
|
return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ abgerechnet / erledigt
|
||||||
|
def _offene(self, pids) -> list[int]:
|
||||||
|
"""Nur Positionen, die noch geändert werden dürfen (nicht abgerechnet, Abrechnung nicht erledigt)."""
|
||||||
|
pids = list(pids)
|
||||||
|
if not pids:
|
||||||
|
return []
|
||||||
|
marken = ",".join("?" * len(pids))
|
||||||
|
return [r[0] for r in self.con.execute(
|
||||||
|
f"SELECT p.id FROM position p JOIN abrechnung a ON a.id=p.abrechnung_id "
|
||||||
|
f"WHERE p.id IN ({marken}) AND p.abgerechnet IS NULL AND a.erledigt IS NULL", pids)]
|
||||||
|
|
||||||
|
def erledigt(self, aid: int) -> str | None:
|
||||||
|
r = self.con.execute("SELECT erledigt FROM abrechnung WHERE id=?", (aid,)).fetchone()
|
||||||
|
return r[0] if r else None
|
||||||
|
|
||||||
|
def _nicht_erledigt(self, aid: int) -> None:
|
||||||
|
if self.erledigt(aid):
|
||||||
|
raise AbrechnungErledigt("Diese Abrechnung ist als erledigt gekennzeichnet.")
|
||||||
|
|
||||||
|
def abrechnen(self, pids: list[int], wann: str | None = None) -> int:
|
||||||
|
"""Positionen als abgerechnet kennzeichnen: Betrag und Händler werden eingefroren, der
|
||||||
|
Fingerabdruck gemerkt (dieselbe Position wird später nicht noch einmal abgerechnet).
|
||||||
|
Nur Positionen echter Händler, die noch offen und nicht schon anderswo abgerechnet sind."""
|
||||||
|
wann = wann or _now()
|
||||||
|
ids = set(pids)
|
||||||
n = 0
|
n = 0
|
||||||
with self.con:
|
for aid in self._abrechnungen_von(list(ids)):
|
||||||
for p in self.positionen(aid):
|
if self.erledigt(aid):
|
||||||
if p["manuell"] and not auch_manuelle:
|
continue # erledigt: nichts wird nachträglich abgerechnet
|
||||||
continue
|
titel = self.abrechnung(aid)["titel"]
|
||||||
proz = self.prozent_fuer(p["haendler_id"], p["provisionsart"])
|
with self.con:
|
||||||
neu = apply_percent(p["betrag_orig"], proz)
|
for p in self.positionen(aid):
|
||||||
if neu != p["betrag"] or proz != p["prozent"] or p["manuell"]:
|
if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
|
||||||
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?", (neu, proz, p["id"]))
|
continue
|
||||||
|
# innerhalb der Transaktion noch einmal: derselbe Fingerabdruck darf nur einmal abgerechnet sein
|
||||||
|
if p["fp"] and self.con.execute("SELECT 1 FROM abgerechnet_fp WHERE fp=?", (p["fp"],)).fetchone():
|
||||||
|
continue
|
||||||
|
self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE id=?",
|
||||||
|
(wann, p["haendler_id"], p["id"]))
|
||||||
|
if p["fp"]:
|
||||||
|
self.con.execute("INSERT INTO abgerechnet_fp (fp, wann, abrechnung) VALUES (?,?,?)",
|
||||||
|
(p["fp"], wann, titel))
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
def abrechnung_aufheben(self, pids: list[int]) -> int:
|
||||||
|
"""Kennzeichnung „abgerechnet“ zurücknehmen (z. B. versehentlich exportiert)."""
|
||||||
|
n = 0
|
||||||
|
for aid in self._abrechnungen_von(list(pids)):
|
||||||
|
self._nicht_erledigt(aid)
|
||||||
|
with self.con:
|
||||||
|
for pid in pids:
|
||||||
|
r = self.con.execute("SELECT fp, abgerechnet FROM position WHERE id=?", (pid,)).fetchone()
|
||||||
|
if not r or not r["abgerechnet"]:
|
||||||
|
continue
|
||||||
|
self.con.execute("UPDATE position SET abgerechnet=NULL, abgerechnet_hid=NULL WHERE id=?", (pid,))
|
||||||
|
if r["fp"]:
|
||||||
|
self.con.execute("DELETE FROM abgerechnet_fp WHERE fp=?", (r["fp"],))
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
def erledigt_setzen(self, aid: int, erledigt: bool, positionen_aufheben: bool = False) -> int:
|
||||||
|
"""Abrechnung als erledigt kennzeichnen (alle offenen Händler-Positionen werden dabei
|
||||||
|
abgerechnet) bzw. wieder öffnen (auf Wunsch samt Kennzeichnung der Positionen).
|
||||||
|
Gibt die Anzahl der dabei (ab)gerechneten Positionen zurück."""
|
||||||
|
n = 0
|
||||||
|
if erledigt:
|
||||||
|
wann = _now()
|
||||||
|
n = self.abrechnen([p["id"] for p in self.positionen(aid)], wann)
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("UPDATE abrechnung SET erledigt=?, geaendert=? WHERE id=?", (wann, wann, aid))
|
||||||
|
else:
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("UPDATE abrechnung SET erledigt=NULL, geaendert=? WHERE id=?", (_now(), aid))
|
||||||
|
if positionen_aufheben:
|
||||||
|
n = self.abrechnung_aufheben([p["id"] for p in self.positionen(aid)])
|
||||||
|
return n
|
||||||
|
|
||||||
def touch(self, aid: int) -> None:
|
def touch(self, aid: int) -> None:
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN |
|
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN |
|
||||||
G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode
|
G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode
|
||||||
Sortierung: Zeitraum → Provisionsart → Datum (sonst Reihenfolge der Abrechnung).
|
Sortierung: Zeitraum -> Provisionsart -> Datum (sonst Reihenfolge der Abrechnung).
|
||||||
|
|
||||||
|
Je Händler entstehen zwei Dateien: die Abrechnung (Airtime darin nur als eine Summenzeile) und die
|
||||||
|
Airtime-Liste mit den Einzelposten. Positionen mit 0,00 € werden weggelassen.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,6 +15,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from provtool.importer import ANBIETER
|
from provtool.importer import ANBIETER
|
||||||
from provtool.money import to_euro
|
from provtool.money import to_euro
|
||||||
|
from provtool.regeln import AIRTIME
|
||||||
|
|
||||||
SPALTEN = [
|
SPALTEN = [
|
||||||
("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13),
|
("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13),
|
||||||
@@ -38,8 +42,27 @@ def dateiname(text: str) -> str:
|
|||||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export"
|
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export"
|
||||||
|
|
||||||
|
|
||||||
|
AIRTIME_TEXT = "Airtime"
|
||||||
|
|
||||||
|
|
||||||
|
def spalte_a(p: dict):
|
||||||
|
"""Spalte A: bei freenet der Zeitraum, bei Vodafone das Aktivierungsdatum (dort steht als Zeitraum
|
||||||
|
nur der Abrechnungslauf wie „VF2609“ – kein Datum)."""
|
||||||
|
if p.get("anbieter") == "vodafone" and p.get("datum"):
|
||||||
|
return date.fromisoformat(p["datum"])
|
||||||
|
return p.get("zeitraum") or None
|
||||||
|
|
||||||
|
|
||||||
|
def airtime_zeile(airtime: list[dict]) -> dict:
|
||||||
|
"""Eine Zeile „Airtime = Summe“ für die Abrechnung."""
|
||||||
|
zeitraeume = sorted({p.get("zeitraum") or "" for p in airtime} - {""})
|
||||||
|
return {"zeitraum": zeitraeume[0] if len(zeitraeume) == 1 else "", "provisionsart": AIRTIME_TEXT,
|
||||||
|
"tarif": "Summe laut Airtime-Liste", "betrag": sum(p["betrag"] for p in airtime), "_summe": True}
|
||||||
|
|
||||||
|
|
||||||
def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True,
|
def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True,
|
||||||
blattname: str = "Tabelle1") -> Path:
|
blattname: str = "Tabelle1", schluss: list[dict] | None = None) -> Path:
|
||||||
|
"""schluss: Zeilen, die nach den sortierten Positionen kommen (z. B. die Airtime-Summe)."""
|
||||||
import openpyxl
|
import openpyxl
|
||||||
from openpyxl.styles import Font
|
from openpyxl.styles import Font
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
@@ -59,9 +82,9 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
|
|||||||
zeile += 1
|
zeile += 1
|
||||||
|
|
||||||
erste = zeile
|
erste = zeile
|
||||||
for p in sortiert(positionen):
|
for p in sortiert(positionen) + list(schluss or []):
|
||||||
werte = [
|
werte = [
|
||||||
p.get("zeitraum") or None,
|
spalte_a(p),
|
||||||
p.get("provisionsart") or None,
|
p.get("provisionsart") or None,
|
||||||
_zahl_oder_text(p.get("kennung")),
|
_zahl_oder_text(p.get("kennung")),
|
||||||
date.fromisoformat(p["datum"]) if p.get("datum") else None,
|
date.fromisoformat(p["datum"]) if p.get("datum") else None,
|
||||||
@@ -78,8 +101,12 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
|
|||||||
]
|
]
|
||||||
for c, v in enumerate(werte, start=1):
|
for c, v in enumerate(werte, start=1):
|
||||||
cell = ws.cell(zeile, c, v)
|
cell = ws.cell(zeile, c, v)
|
||||||
cell.font = font
|
if isinstance(v, str) and v.startswith("="):
|
||||||
|
cell.data_type = "s" # Text aus der Abrechnung bleibt Text – nie eine Formel
|
||||||
|
cell.font = bold if p.get("_summe") and c in (2, 11) else font
|
||||||
ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
|
ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
|
||||||
|
if isinstance(werte[0], date):
|
||||||
|
ws.cell(zeile, 1).number_format = "DD.MM.YYYY"
|
||||||
ws.cell(zeile, 5).number_format = "0"
|
ws.cell(zeile, 5).number_format = "0"
|
||||||
ws.cell(zeile, 6).number_format = "0"
|
ws.cell(zeile, 6).number_format = "0"
|
||||||
ws.cell(zeile, 11).number_format = EURO_FORMAT
|
ws.cell(zeile, 11).number_format = EURO_FORMAT
|
||||||
@@ -104,20 +131,44 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
|
|||||||
|
|
||||||
|
|
||||||
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
|
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
|
||||||
pro_anbieter: bool = True, kopfzeile: bool = False, summe: bool = True) -> list[Path]:
|
pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
|
||||||
"""Schreibt eine (oder je Anbieter eine) Datei für einen Händler. Gibt die erzeugten Pfade zurück."""
|
null_weglassen: bool = True, vergeben: set[str] | None = None) -> list[Path]:
|
||||||
|
"""Schreibt die Abrechnung eines Händlers (bei pro_anbieter je Anbieter eine) und – falls es Airtime
|
||||||
|
gibt – die Airtime-Liste. In der Abrechnung steht Airtime nur als eine Summenzeile.
|
||||||
|
vergeben: Dateinamen (klein geschrieben), die in diesem Export schon geschrieben wurden – gleich
|
||||||
|
lautende Namen (z. B. Händler „A/B“ und „A:B“) bekommen „ (2)“ statt die Datei zu überschreiben.
|
||||||
|
Gibt die erzeugten Pfade zurück."""
|
||||||
ordner = Path(ordner)
|
ordner = Path(ordner)
|
||||||
|
if null_weglassen:
|
||||||
|
positionen = [p for p in positionen if p["betrag"]]
|
||||||
if not positionen:
|
if not positionen:
|
||||||
return []
|
return []
|
||||||
gruppen: dict[str, list[dict]] = {}
|
gruppen: dict[str, list[dict]] = {}
|
||||||
if pro_anbieter:
|
for p in positionen:
|
||||||
for p in positionen:
|
gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
|
||||||
gruppen.setdefault(p["anbieter"], []).append(p)
|
vergeben = set() if vergeben is None else vergeben
|
||||||
else:
|
|
||||||
gruppen[""] = positionen
|
def name(*teile) -> str:
|
||||||
|
basis = dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t))
|
||||||
|
for n in range(1, 1000):
|
||||||
|
kandidat = basis + (f" ({n})" if n > 1 else "") + ".xlsx"
|
||||||
|
if kandidat.lower() not in vergeben:
|
||||||
|
vergeben.add(kandidat.lower())
|
||||||
|
return kandidat
|
||||||
|
raise ValueError(f"Kein freier Dateiname für {basis}")
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for anbieter, pos in sorted(gruppen.items()):
|
for anbieter, pos in sorted(gruppen.items()):
|
||||||
teile = [haendler_name] + ([ANBIETER.get(anbieter, anbieter)] if anbieter else []) + [titel]
|
provision = [p for p in pos if p.get("typ") != AIRTIME]
|
||||||
name = dateiname(" - ".join(t for t in teile if t)) + ".xlsx"
|
airtime = [p for p in pos if p.get("typ") == AIRTIME]
|
||||||
out.append(schreibe_xlsx(ordner / name, pos, kopfzeile=kopfzeile, summe=summe))
|
schluss = [airtime_zeile(airtime)] if airtime else []
|
||||||
|
if schluss and null_weglassen and not schluss[0]["betrag"]:
|
||||||
|
schluss = []
|
||||||
|
if provision or schluss:
|
||||||
|
out.append(schreibe_xlsx(ordner / name(ANBIETER.get(anbieter, anbieter) if anbieter else ""),
|
||||||
|
provision, kopfzeile=kopfzeile, summe=summe, schluss=schluss))
|
||||||
|
airtime = [p for p in positionen if p.get("typ") == AIRTIME]
|
||||||
|
if airtime:
|
||||||
|
out.append(schreibe_xlsx(ordner / name(AIRTIME_TEXT), airtime, kopfzeile=kopfzeile, summe=summe,
|
||||||
|
blattname=AIRTIME_TEXT))
|
||||||
return out
|
return out
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
"""Ersteinrichtung im Stil der Windows-95-Setup-Assistenten, Programm-Rundgang und „Tipp des Tages“."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import tkinter as tk
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog, ttk
|
||||||
|
|
||||||
|
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
|
||||||
|
from provtool.gui import win32
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
from provtool.importer import lies_datei
|
||||||
|
from provtool.money import fmt, parse_percent
|
||||||
|
|
||||||
|
DATEITYPEN = [("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"), ("Alle Dateien", "*.*")]
|
||||||
|
|
||||||
|
|
||||||
|
def _nummern(text: str) -> list[str]:
|
||||||
|
"""'11111102 (6 Pos.), 27518999' -> ['11111102', '27518999']"""
|
||||||
|
out = []
|
||||||
|
for teil in text.split(","):
|
||||||
|
m = re.match(r"\s*([A-Za-z0-9]+)", teil)
|
||||||
|
if m:
|
||||||
|
out.append(m.group(1))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Assistent
|
||||||
|
|
||||||
|
class Einrichtungsassistent(w.Dialog):
|
||||||
|
BREITE, HOEHE = 470, 340
|
||||||
|
|
||||||
|
def __init__(self, app):
|
||||||
|
super().__init__(app.root, f"{w.APP_NAME} – Einrichtung")
|
||||||
|
self.app = app
|
||||||
|
self.dateien: list[str] = []
|
||||||
|
self.vorschlaege: dict[str, list[str]] = {"freenet": [], "vodafone": []}
|
||||||
|
self.caps_anzahl = 0
|
||||||
|
|
||||||
|
haupt = tk.Frame(self, bg=w.FACE)
|
||||||
|
haupt.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
bild = tk.Canvas(haupt, width=150, height=self.HOEHE, bg=w.TEAL, bd=2, relief="sunken",
|
||||||
|
highlightthickness=0)
|
||||||
|
bild.pack(side="left", fill="y")
|
||||||
|
self._bild(bild)
|
||||||
|
self.seite = tk.Frame(haupt, bg=w.FACE, width=self.BREITE, height=self.HOEHE)
|
||||||
|
self.seite.pack(side="left", fill="both", expand=True, padx=(14, 0))
|
||||||
|
self.seite.pack_propagate(False)
|
||||||
|
|
||||||
|
w.trenner(self, vertikal=False).pack(fill="x", padx=10)
|
||||||
|
k = tk.Frame(self, bg=w.FACE)
|
||||||
|
k.pack(fill="x", padx=10, pady=10)
|
||||||
|
self.b_abbr = w.knopf(k, "Abbrechen", self.abbrechen, breite=10)
|
||||||
|
self.b_abbr.pack(side="right")
|
||||||
|
self.b_weiter = w.knopf(k, "Weiter >", self._weiter, breite=10, default="active")
|
||||||
|
self.b_weiter.pack(side="right", padx=(0, 10))
|
||||||
|
self.b_zurueck = w.knopf(k, "< Zurück", self._zurueck, breite=10)
|
||||||
|
self.b_zurueck.pack(side="right")
|
||||||
|
self.bind("<Return>", lambda e: self._weiter() if not isinstance(e.widget, tk.Entry) else None)
|
||||||
|
|
||||||
|
self.v_titel = tk.StringVar(value=app._vorschlag_titel())
|
||||||
|
self.v_abrechnung = tk.BooleanVar(value=True)
|
||||||
|
self.v_rundgang = tk.BooleanVar(value=True)
|
||||||
|
self.v_db_eigen = tk.BooleanVar(value=bool(app.settings.get("db_pfad")))
|
||||||
|
self.v_db_pfad = tk.StringVar(value=str(app.db.path))
|
||||||
|
|
||||||
|
self.seiten = [self._s_willkommen, self._s_datenbank, self._s_haendler, self._s_abrechnung, self._s_fertig]
|
||||||
|
self.nr = 0
|
||||||
|
self._zeige(0)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Rahmen
|
||||||
|
def _bild(self, c: tk.Canvas):
|
||||||
|
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="#ffffff", outline="")
|
||||||
|
c.create_image(77, self.HOEHE // 2 - 10, image=self._img)
|
||||||
|
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):
|
||||||
|
for kind in self.seite.winfo_children():
|
||||||
|
kind.destroy()
|
||||||
|
|
||||||
|
def _ueberschrift(self, text: str):
|
||||||
|
tk.Label(self.seite, text=text, font=w.FONT_BIG, anchor="w", justify="left",
|
||||||
|
wraplength=self.BREITE).pack(fill="x", pady=(0, 10))
|
||||||
|
|
||||||
|
def _text(self, text: str, master=None, **kw):
|
||||||
|
lbl = tk.Label(master or self.seite, text=text, anchor="w", justify="left", wraplength=self.BREITE, **kw)
|
||||||
|
lbl.pack(fill="x", pady=(0, 6))
|
||||||
|
return lbl
|
||||||
|
|
||||||
|
def _zeige(self, nr: int):
|
||||||
|
self.nr = nr
|
||||||
|
self._leeren()
|
||||||
|
self.seiten[nr]()
|
||||||
|
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"{w.APP_NAME} – Einrichtung (Schritt {nr + 1} von {len(self.seiten)})")
|
||||||
|
|
||||||
|
def _weiter(self):
|
||||||
|
pruefer = getattr(self, f"_pruefe_{self.nr}", None)
|
||||||
|
if pruefer and not pruefer():
|
||||||
|
return
|
||||||
|
if self.nr == len(self.seiten) - 1:
|
||||||
|
self.result = dict(titel=self.v_titel.get().strip() if self.v_abrechnung.get() else "",
|
||||||
|
dateien=list(self.dateien) if self.v_abrechnung.get() else [],
|
||||||
|
rundgang=self.v_rundgang.get())
|
||||||
|
self.destroy()
|
||||||
|
return
|
||||||
|
self._zeige(self.nr + 1)
|
||||||
|
|
||||||
|
def _zurueck(self):
|
||||||
|
if self.nr > 0:
|
||||||
|
self._zeige(self.nr - 1)
|
||||||
|
|
||||||
|
def abbrechen(self):
|
||||||
|
if w.frage(self, "Einrichtung abbrechen",
|
||||||
|
"Möchten Sie die Einrichtung wirklich abbrechen?\n\n"
|
||||||
|
"Bereits angelegte Händler bleiben gespeichert. Den Assistenten finden Sie jederzeit "
|
||||||
|
"unter ? > Einrichtungs-Assistent.", default_ja=False):
|
||||||
|
self.result = None
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Seite 1
|
||||||
|
def _s_willkommen(self):
|
||||||
|
self._ueberschrift(f"Willkommen beim {w.APP_NAME}!")
|
||||||
|
self._text("Dieser Assistent richtet das Programm in wenigen Schritten ein:")
|
||||||
|
for punkt in ("Speicherort der Daten festlegen",
|
||||||
|
"Unterhändler mit ihren Kennungen anlegen",
|
||||||
|
"die erste Abrechnung anlegen und Dateien importieren"):
|
||||||
|
self._text(f" - {punkt}")
|
||||||
|
self._text("\nDanach zeigt Ihnen ein kurzer Rundgang, wo Sie was finden.\n\n"
|
||||||
|
"Alle Angaben lassen sich später jederzeit ändern.")
|
||||||
|
self._text("Klicken Sie auf „Weiter“, um fortzufahren.", fg=w.NAVY)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Seite 2
|
||||||
|
def _s_datenbank(self):
|
||||||
|
self._ueberschrift("Wo sollen die Daten gespeichert werden?")
|
||||||
|
self._text("In der Datenbank liegen Ihre Händler, die Auszahlungsregeln und alle Abrechnungen "
|
||||||
|
"mit Ihren Änderungen.")
|
||||||
|
f = tk.Frame(self.seite, bg=w.FACE)
|
||||||
|
f.pack(fill="x", pady=6)
|
||||||
|
from provtool import config
|
||||||
|
standard = str(config.app_dir() / "provisionen.db")
|
||||||
|
tk.Radiobutton(f, text="Auf diesem Computer (empfohlen)", variable=self.v_db_eigen, value=False,
|
||||||
|
command=lambda: self.v_db_pfad.set(standard)).pack(anchor="w")
|
||||||
|
tk.Label(f, text=standard, fg=w.SHADOW, anchor="w", wraplength=self.BREITE - 30,
|
||||||
|
justify="left").pack(anchor="w", padx=22)
|
||||||
|
tk.Radiobutton(f, text="An einem eigenen Ort, z. B. auf einem Netzlaufwerk:", variable=self.v_db_eigen,
|
||||||
|
value=True).pack(anchor="w", pady=(10, 0))
|
||||||
|
zeile = tk.Frame(f, bg=w.FACE)
|
||||||
|
zeile.pack(fill="x", padx=22, pady=2)
|
||||||
|
e = tk.Entry(zeile, textvariable=self.v_db_pfad)
|
||||||
|
e.pack(side="left", fill="x", expand=True)
|
||||||
|
e.bind("<Key>", lambda ev: self.v_db_eigen.set(True))
|
||||||
|
w.knopf(zeile, "Durchsuchen...", self._db_waehlen).pack(side="left", padx=(4, 0))
|
||||||
|
self._text("\nTipp: Auf einem Netzlaufwerk können mehrere PCs dieselben Händler nutzen – "
|
||||||
|
"bitte aber nicht gleichzeitig damit arbeiten.", fg=w.SHADOW)
|
||||||
|
|
||||||
|
def _db_waehlen(self):
|
||||||
|
p = filedialog.asksaveasfilename(parent=self, title="Speicherort der Datenbank", defaultextension=".db",
|
||||||
|
initialfile="provisionen.db", confirmoverwrite=False,
|
||||||
|
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
|
||||||
|
if p:
|
||||||
|
self.v_db_pfad.set(p)
|
||||||
|
self.v_db_eigen.set(True)
|
||||||
|
|
||||||
|
def _pruefe_1(self) -> bool:
|
||||||
|
from provtool import config
|
||||||
|
ziel = self.v_db_pfad.get().strip() if self.v_db_eigen.get() else str(config.app_dir() / "provisionen.db")
|
||||||
|
if not ziel:
|
||||||
|
w.warnung(self, "Speicherort", "Bitte einen Speicherort angeben.")
|
||||||
|
return False
|
||||||
|
if Path(ziel) != Path(self.app.db.path):
|
||||||
|
try:
|
||||||
|
self.app.db_wechseln(ziel if self.v_db_eigen.get() else None)
|
||||||
|
except Exception as exc:
|
||||||
|
w.fehler(self, "Speicherort", f"Die Datenbank konnte nicht angelegt werden:\n{exc}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Seite 3
|
||||||
|
def _s_haendler(self):
|
||||||
|
db = self.app.db
|
||||||
|
tk.Label(self.seite, text="Unterhändler anlegen", font=w.FONT_BIG, anchor="w").pack(fill="x")
|
||||||
|
self._text("Für jeden Unterhändler: Name, Auszahlung und woran seine Positionen zu erkennen sind. "
|
||||||
|
"Ihre eigene Filiale legen Sie nicht an.", fg=w.SHADOW)
|
||||||
|
|
||||||
|
g = tk.Frame(self.seite, bg=w.FACE)
|
||||||
|
g.pack(fill="x")
|
||||||
|
self.v_name, self.v_proz = tk.StringVar(), tk.StringVar(value="100")
|
||||||
|
self.v_fn, self.v_vf, self.v_caps = tk.StringVar(), tk.StringVar(), tk.BooleanVar()
|
||||||
|
tk.Label(g, text="Name / Firma:").grid(row=0, column=0, sticky="w", pady=1)
|
||||||
|
e_name = tk.Entry(g, textvariable=self.v_name)
|
||||||
|
e_name.grid(row=0, column=1, columnspan=2, sticky="we", pady=1)
|
||||||
|
tk.Label(g, text="Auszahlung:").grid(row=1, column=0, sticky="w", pady=1)
|
||||||
|
pf = tk.Frame(g, bg=w.FACE)
|
||||||
|
pf.grid(row=1, column=1, sticky="w")
|
||||||
|
tk.Entry(pf, textvariable=self.v_proz, width=6, justify="right").pack(side="left")
|
||||||
|
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
|
||||||
|
tk.Label(g, text="freenet Unterhändler-Nr.:").grid(row=2, column=0, sticky="w", pady=1)
|
||||||
|
self.cb_fn = ttk.Combobox(g, textvariable=self.v_fn, values=self.vorschlaege["freenet"])
|
||||||
|
self.cb_fn.grid(row=2, column=1, columnspan=2, sticky="we", pady=1)
|
||||||
|
tk.Label(g, text="Vodafone VO-Nr.:").grid(row=3, column=0, sticky="w", pady=1)
|
||||||
|
self.cb_vf = ttk.Combobox(g, textvariable=self.v_vf, values=self.vorschlaege["vodafone"])
|
||||||
|
self.cb_vf.grid(row=3, column=1, columnspan=2, sticky="we", pady=1)
|
||||||
|
caps_besitzer = db.kennung_map().get((CAPS_ANBIETER, CAPS_WERT))
|
||||||
|
caps_text = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
|
||||||
|
if self.caps_anzahl:
|
||||||
|
caps_text += f" ({self.caps_anzahl} Pos. in der Datei)"
|
||||||
|
cb = tk.Checkbutton(g, text=caps_text, variable=self.v_caps, wraplength=self.BREITE - 30, justify="left")
|
||||||
|
cb.grid(row=4, column=0, columnspan=3, sticky="w", pady=(2, 0))
|
||||||
|
if caps_besitzer:
|
||||||
|
cb.configure(state="disabled", text=f"GROSSBUCHSTABEN-Regel: bereits bei „{db.haendler(caps_besitzer).name}“")
|
||||||
|
g.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
k = tk.Frame(self.seite, bg=w.FACE)
|
||||||
|
k.pack(fill="x", pady=(4, 4))
|
||||||
|
w.knopf(k, "Händler hinzufügen", self._haendler_dazu, self.app.icons("haendler")).pack(side="left")
|
||||||
|
w.knopf(k, "Nummern aus Abrechnung vorschlagen...", self._vorschlagen).pack(side="left", padx=6)
|
||||||
|
|
||||||
|
tk.Label(self.seite, text="Bereits angelegt:", anchor="w").pack(fill="x")
|
||||||
|
lf = tk.Frame(self.seite, bg=w.FACE)
|
||||||
|
lf.pack(fill="both", expand=True)
|
||||||
|
self.lb = tk.Listbox(lf, height=4, activestyle="none")
|
||||||
|
self.lb.pack(side="left", fill="both", expand=True)
|
||||||
|
w.knopf(lf, "Entfernen", self._haendler_weg).pack(side="left", anchor="n", padx=(6, 0))
|
||||||
|
self._liste()
|
||||||
|
e_name.focus_set()
|
||||||
|
|
||||||
|
def _liste(self):
|
||||||
|
self.lb.delete(0, "end")
|
||||||
|
self._lb_ids = []
|
||||||
|
for h in self.app.db.haendler_liste():
|
||||||
|
teile = []
|
||||||
|
for anb, wert in self.app.db.kennungen(h.id):
|
||||||
|
teile.append("GROSSBUCHSTABEN" if anb == CAPS_ANBIETER else f"{anb} {wert}")
|
||||||
|
proz = f"{h.standard_prozent:g} %".replace(".", ",")
|
||||||
|
self.lb.insert("end", f"{h.name} – {proz} – {', '.join(teile) or 'keine Kennung'}")
|
||||||
|
self._lb_ids.append(h.id)
|
||||||
|
|
||||||
|
def _haendler_dazu(self) -> bool:
|
||||||
|
name = self.v_name.get().strip()
|
||||||
|
if not name:
|
||||||
|
w.warnung(self, "Händler", "Bitte einen Namen eingeben.")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
proz = parse_percent(self.v_proz.get())
|
||||||
|
except ValueError:
|
||||||
|
w.warnung(self, "Händler", "Die Auszahlung muss eine Zahl sein, z. B. 100 oder 85.")
|
||||||
|
return False
|
||||||
|
kenn = [("freenet", n) for n in _nummern(self.v_fn.get())]
|
||||||
|
kenn += [("vodafone", n) for n in _nummern(self.v_vf.get())]
|
||||||
|
if self.v_caps.get():
|
||||||
|
kenn.append((CAPS_ANBIETER, CAPS_WERT))
|
||||||
|
if not kenn and not w.frage(self, "Händler", f"Für „{name}“ ist keine Kennung angegeben – ihm werden "
|
||||||
|
"daher keine Positionen zugeordnet.\n\nTrotzdem anlegen?"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.app.db.haendler_speichern(Haendler(0, name, standard_prozent=proz), kenn)
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
if "UNIQUE" in msg:
|
||||||
|
msg = f"Es gibt bereits einen Händler „{name}“."
|
||||||
|
w.fehler(self, "Händler", msg)
|
||||||
|
return False
|
||||||
|
for v in (self.v_name, self.v_fn, self.v_vf):
|
||||||
|
v.set("")
|
||||||
|
self.v_proz.set("100")
|
||||||
|
self._zeige(self.nr) # neu aufbauen (GROSSBUCHSTABEN-Regel evtl. vergeben)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _haendler_weg(self):
|
||||||
|
sel = self.lb.curselection()
|
||||||
|
if sel:
|
||||||
|
self.app.db.haendler_loeschen(self._lb_ids[sel[0]])
|
||||||
|
self._zeige(self.nr)
|
||||||
|
|
||||||
|
def _vorschlagen(self):
|
||||||
|
dateien = filedialog.askopenfilenames(parent=self, title="Abrechnung zum Auslesen der Nummern wählen",
|
||||||
|
filetypes=DATEITYPEN)
|
||||||
|
if not dateien:
|
||||||
|
return
|
||||||
|
anzahl: dict[str, Counter] = defaultdict(Counter)
|
||||||
|
summe: dict[tuple[str, str], int] = defaultdict(int)
|
||||||
|
caps = 0
|
||||||
|
fehler = []
|
||||||
|
for f in dateien:
|
||||||
|
try:
|
||||||
|
erg = lies_datei(f)
|
||||||
|
except Exception as exc:
|
||||||
|
fehler.append(f"{Path(f).name}: {exc}")
|
||||||
|
continue
|
||||||
|
if f not in self.dateien:
|
||||||
|
self.dateien.append(f)
|
||||||
|
for p in erg.positionen:
|
||||||
|
anzahl[p.anbieter][p.kennung] += 1
|
||||||
|
summe[(p.anbieter, p.kennung)] += p.betrag_cent
|
||||||
|
if p.anbieter == "vodafone" and ist_grossgeschrieben(p.name):
|
||||||
|
caps += 1
|
||||||
|
vergeben = self.app.db.kennung_map()
|
||||||
|
for anb in ("freenet", "vodafone"):
|
||||||
|
self.vorschlaege[anb] = [f"{k} ({n} Pos., {fmt(summe[(anb, k)])})"
|
||||||
|
for k, n in anzahl[anb].most_common() if k and (anb, k) not in vergeben]
|
||||||
|
self.caps_anzahl = caps
|
||||||
|
self._zeige(self.nr)
|
||||||
|
if fehler:
|
||||||
|
w.warnung(self, "Nummern vorschlagen", "Nicht lesbar:\n" + "\n".join(fehler))
|
||||||
|
else:
|
||||||
|
w.info(self, "Nummern vorschlagen",
|
||||||
|
"Die gefundenen Nummern stehen jetzt in den Auswahllisten (Pfeil-Knopf rechts) bei freenet und Vodafone – "
|
||||||
|
"sortiert nach Anzahl der Positionen.\n\nDie Nummer mit den meisten Positionen ist meist "
|
||||||
|
"Ihre eigene Filiale.\n\nDie Dateien werden im nächsten Schritt gleich mit importiert.")
|
||||||
|
|
||||||
|
def _pruefe_2(self) -> bool:
|
||||||
|
if self.v_name.get().strip():
|
||||||
|
if w.frage(self, "Händler", f"„{self.v_name.get().strip()}“ wurde noch nicht hinzugefügt.\n\n"
|
||||||
|
"Jetzt hinzufügen?"):
|
||||||
|
return self._haendler_dazu() and False # Seite bleibt offen, Liste ist aktualisiert
|
||||||
|
if not self.app.db.haendler_liste():
|
||||||
|
return w.frage(self, "Händler", "Es ist noch kein Händler angelegt.\n\n"
|
||||||
|
"Trotzdem weiter? (Händler lassen sich später unter „Händler“ anlegen.)",
|
||||||
|
default_ja=False)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Seite 4
|
||||||
|
def _s_abrechnung(self):
|
||||||
|
self._ueberschrift("Die erste Abrechnung")
|
||||||
|
self._text("Pro Abrechnungszeitraum (meist ein Monat) legen Sie eine Abrechnung an und importieren "
|
||||||
|
"dort alle Dateien dieses Zeitraums – z. B. alle freenet-Wochenlisten und die Vodafone-Datei.")
|
||||||
|
tk.Checkbutton(self.seite, text="Jetzt eine Abrechnung anlegen", variable=self.v_abrechnung,
|
||||||
|
command=self._s_abrechnung_status).pack(anchor="w")
|
||||||
|
f = tk.Frame(self.seite, bg=w.FACE)
|
||||||
|
f.pack(fill="both", expand=True, padx=(22, 0))
|
||||||
|
z = tk.Frame(f, bg=w.FACE)
|
||||||
|
z.pack(fill="x", pady=4)
|
||||||
|
tk.Label(z, text="Bezeichnung:").pack(side="left")
|
||||||
|
self._e_titel = tk.Entry(z, textvariable=self.v_titel, width=28)
|
||||||
|
self._e_titel.pack(side="left", padx=6)
|
||||||
|
tk.Label(f, text="Diese Dateien gleich importieren (optional):", anchor="w").pack(fill="x", pady=(6, 2))
|
||||||
|
lf = tk.Frame(f, bg=w.FACE)
|
||||||
|
lf.pack(fill="both", expand=True)
|
||||||
|
self.lb_d = tk.Listbox(lf, height=5)
|
||||||
|
self.lb_d.pack(side="left", fill="both", expand=True)
|
||||||
|
kf = tk.Frame(lf, bg=w.FACE)
|
||||||
|
kf.pack(side="left", anchor="n", padx=(6, 0))
|
||||||
|
self._b_dazu = w.knopf(kf, "Hinzufügen...", self._datei_dazu, breite=11)
|
||||||
|
self._b_dazu.pack(pady=(0, 4))
|
||||||
|
self._b_weg = w.knopf(kf, "Entfernen", self._datei_weg, breite=11)
|
||||||
|
self._b_weg.pack()
|
||||||
|
for d in self.dateien:
|
||||||
|
self.lb_d.insert("end", Path(d).name)
|
||||||
|
self._s_abrechnung_status()
|
||||||
|
|
||||||
|
def _s_abrechnung_status(self):
|
||||||
|
st = "normal" if self.v_abrechnung.get() else "disabled"
|
||||||
|
for wdg in (self._e_titel, self.lb_d, self._b_dazu, self._b_weg):
|
||||||
|
wdg.configure(state=st)
|
||||||
|
|
||||||
|
def _datei_dazu(self):
|
||||||
|
for f in filedialog.askopenfilenames(parent=self, title="Dateien zum Import wählen", filetypes=DATEITYPEN):
|
||||||
|
if f not in self.dateien:
|
||||||
|
self.dateien.append(f)
|
||||||
|
self.lb_d.insert("end", Path(f).name)
|
||||||
|
|
||||||
|
def _datei_weg(self):
|
||||||
|
sel = self.lb_d.curselection()
|
||||||
|
if sel:
|
||||||
|
del self.dateien[sel[0]]
|
||||||
|
self.lb_d.delete(sel[0])
|
||||||
|
|
||||||
|
def _pruefe_3(self) -> bool:
|
||||||
|
if self.v_abrechnung.get() and not self.v_titel.get().strip():
|
||||||
|
w.warnung(self, "Abrechnung", "Bitte eine Bezeichnung eingeben, z. B. „Oktober 2026“.")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Seite 5
|
||||||
|
def _s_fertig(self):
|
||||||
|
self._ueberschrift("Fertig!")
|
||||||
|
n = len(self.app.db.haendler_liste())
|
||||||
|
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()}“"
|
||||||
|
+ (f" mit {len(self.dateien)} Datei(en)" if self.dateien else " (noch ohne Dateien)"))
|
||||||
|
self._text("\nSo geht es danach jeden Monat weiter:\n"
|
||||||
|
" Neu > Import > prüfen & anpassen > Export")
|
||||||
|
tk.Checkbutton(self.seite, text="Kurzen Rundgang durch das Programm starten", variable=self.v_rundgang
|
||||||
|
).pack(anchor="w", pady=(12, 0))
|
||||||
|
self._text("\nKlicken Sie auf „Fertig stellen“.", fg=w.NAVY)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Rundgang
|
||||||
|
|
||||||
|
class Rundgang:
|
||||||
|
"""Führt mit gelben Hinweis-Sprechblasen durch die Oberfläche und rahmt das jeweilige Element rot ein."""
|
||||||
|
|
||||||
|
def __init__(self, app, schritte: list[tuple]):
|
||||||
|
"""schritte: (widget_oder_funktion, titel, text)"""
|
||||||
|
self.app, self.root, self.schritte = app, app.root, schritte
|
||||||
|
self.nr = 0
|
||||||
|
self.blase: tk.Toplevel | None = None
|
||||||
|
self.rahmen: list[tk.Toplevel] = []
|
||||||
|
self._sperre = None # Windows: Hauptfenster während des Rundgangs gesperrt (win32.Modal)
|
||||||
|
self._zeige(0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _entsorgen(blase, rahmen):
|
||||||
|
for r in rahmen:
|
||||||
|
try:
|
||||||
|
r.destroy()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
if blase is not None:
|
||||||
|
try:
|
||||||
|
blase.grab_release()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
blase.destroy()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _weg(self):
|
||||||
|
self._entsorgen(self.blase, self.rahmen)
|
||||||
|
self.rahmen = []
|
||||||
|
self.blase = None
|
||||||
|
|
||||||
|
def beenden(self):
|
||||||
|
if self._sperre is not None: # vor dem Schließen freigeben, sonst springt Windows woandershin
|
||||||
|
self._sperre.beenden()
|
||||||
|
self._sperre = None
|
||||||
|
self._weg()
|
||||||
|
|
||||||
|
def _markieren(self, x, y, bw, bh, dicke=3, farbe="#ff0000"):
|
||||||
|
for gx, gy, gw, gh in ((x - dicke, y - dicke, bw + 2 * dicke, dicke), (x - dicke, y + bh, bw + 2 * dicke, dicke),
|
||||||
|
(x - dicke, y, dicke, bh), (x + bw, y, dicke, bh)):
|
||||||
|
t = tk.Toplevel(self.root, bg=farbe)
|
||||||
|
t.overrideredirect(True) # kein transient() – siehe win32.py
|
||||||
|
t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}")
|
||||||
|
t.update_idletasks()
|
||||||
|
win32.besitzer_setzen(t, self.root) # bleibt über dem Hauptfenster, auch nach Alt+Tab
|
||||||
|
self.rahmen.append(t)
|
||||||
|
|
||||||
|
def _zeige(self, nr: int):
|
||||||
|
# Die alte Sprechblase verschwindet erst, wenn die neue steht: Wäre kurz kein Popup da,
|
||||||
|
# während das Hauptfenster gesperrt ist, würde Windows ein fremdes Programm aktivieren.
|
||||||
|
alt_blase, alt_rahmen = self.blase, self.rahmen
|
||||||
|
self.blase, self.rahmen = None, []
|
||||||
|
if getattr(self.root, "_provtool_schliesst", False):
|
||||||
|
self._entsorgen(alt_blase, alt_rahmen)
|
||||||
|
return
|
||||||
|
self.nr = nr
|
||||||
|
ziel, titel, text = self.schritte[nr]
|
||||||
|
if callable(ziel) and not isinstance(ziel, tk.Misc):
|
||||||
|
ziel = ziel()
|
||||||
|
self.root.update_idletasks()
|
||||||
|
x, y = ziel.winfo_rootx(), ziel.winfo_rooty()
|
||||||
|
bw, bh = ziel.winfo_width(), ziel.winfo_height()
|
||||||
|
self._markieren(x, y, bw, bh)
|
||||||
|
|
||||||
|
b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
|
||||||
|
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
|
||||||
|
b.withdraw()
|
||||||
|
b.overrideredirect(True) # kein transient() – Besitzer setzt win32.Modal
|
||||||
|
innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8)
|
||||||
|
innen.pack(padx=1, pady=1)
|
||||||
|
kopf = tk.Frame(innen, bg=w.INFO_BG)
|
||||||
|
kopf.pack(fill="x")
|
||||||
|
self._ic = w.make_icon(b, "birne")
|
||||||
|
tk.Label(kopf, image=self._ic, bg=w.INFO_BG).pack(side="left")
|
||||||
|
tk.Label(kopf, text=titel, bg=w.INFO_BG, font=w.FONT_BOLD).pack(side="left", padx=6)
|
||||||
|
tk.Label(innen, text=text, bg=w.INFO_BG, justify="left", anchor="w", wraplength=330).pack(fill="x", pady=(6, 8))
|
||||||
|
fuss = tk.Frame(innen, bg=w.INFO_BG)
|
||||||
|
fuss.pack(fill="x")
|
||||||
|
tk.Label(fuss, text=f"Schritt {nr + 1} von {len(self.schritte)}", bg=w.INFO_BG,
|
||||||
|
fg=w.SHADOW).pack(side="left")
|
||||||
|
letzter = nr == len(self.schritte) - 1
|
||||||
|
w.knopf(fuss, "Fertig" if letzter else "Weiter >", self._weiter, default="active").pack(side="right")
|
||||||
|
zb = w.knopf(fuss, "< Zurück", self._zurueck)
|
||||||
|
zb.pack(side="right", padx=4)
|
||||||
|
if nr == 0:
|
||||||
|
zb.configure(state="disabled")
|
||||||
|
if not letzter:
|
||||||
|
w.knopf(fuss, "Beenden", self.beenden).pack(side="right", padx=(0, 8))
|
||||||
|
b.bind("<Return>", lambda e: self._weiter())
|
||||||
|
b.bind("<Right>", lambda e: self._weiter())
|
||||||
|
b.bind("<Left>", lambda e: self._zurueck())
|
||||||
|
b.bind("<Escape>", lambda e: self.beenden())
|
||||||
|
|
||||||
|
# Position: unter dem Element, sonst darüber, sonst innen oben links; immer im Hauptfenster
|
||||||
|
b.update_idletasks()
|
||||||
|
pw, ph = b.winfo_reqwidth(), b.winfo_reqheight()
|
||||||
|
rx, ry = self.root.winfo_rootx(), self.root.winfo_rooty()
|
||||||
|
rw, rh = self.root.winfo_width(), self.root.winfo_height()
|
||||||
|
if y + bh + 12 + ph <= ry + rh:
|
||||||
|
px, py = x, y + bh + 12
|
||||||
|
elif y - 12 - ph >= ry:
|
||||||
|
px, py = x, y - 12 - ph
|
||||||
|
else:
|
||||||
|
px, py = x + 40, y + 40
|
||||||
|
px = min(max(rx + 8, px), rx + rw - pw - 8)
|
||||||
|
py = min(max(ry + 8, py), ry + rh - ph - 8)
|
||||||
|
b.geometry(f"{pw}x{ph}+{px}+{py}")
|
||||||
|
b.deiconify()
|
||||||
|
b.lift()
|
||||||
|
b.update_idletasks()
|
||||||
|
b.focus_force()
|
||||||
|
if self._sperre is None:
|
||||||
|
self._sperre = win32.Modal(b, self.root)
|
||||||
|
else:
|
||||||
|
win32.besitzer_setzen(b, self.root)
|
||||||
|
self._entsorgen(alt_blase, alt_rahmen)
|
||||||
|
try:
|
||||||
|
b.grab_set()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
b.focus_force()
|
||||||
|
|
||||||
|
def _weiter(self):
|
||||||
|
if self.nr >= len(self.schritte) - 1:
|
||||||
|
self.beenden()
|
||||||
|
else:
|
||||||
|
self._zeige(self.nr + 1)
|
||||||
|
|
||||||
|
def _zurueck(self):
|
||||||
|
if self.nr > 0:
|
||||||
|
self._zeige(self.nr - 1)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Tipp des Tages
|
||||||
|
|
||||||
|
TIPPS = [
|
||||||
|
"Airtime wird pro Händler in Prozent ausgezahlt (0 % = keine Airtime). Im Export steht sie als eine "
|
||||||
|
"Summenzeile, die Einzelposten kommen in eine eigene Airtime-Liste.",
|
||||||
|
"Der Jahresbonus Neuaktivierung richtet sich nach der Anzahl Karten: Die Staffel stellen Sie unter "
|
||||||
|
"Händler > Standard-Staffel ein – oder im Händler-Dialog eine eigene für einzelne Händler.",
|
||||||
|
"Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats "
|
||||||
|
"und die Vodafone-Datei in einem Rutsch.",
|
||||||
|
"Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent...“, um z. B. nur 80 % "
|
||||||
|
"auszuzahlen. Mit dem Häkchen „Als Standard merken“ gilt das automatisch auch in den nächsten Monaten.",
|
||||||
|
"Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit Pfeil runter geht es "
|
||||||
|
"gleich in die nächste Zeile.",
|
||||||
|
"Blau dargestellte Beträge wurden von Hand geändert, rote weichen durch eine Prozent-Regel von der "
|
||||||
|
"Provision ab.",
|
||||||
|
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick > „Auszahlung 0 €“.",
|
||||||
|
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick > „Händler zuweisen...“ – diese "
|
||||||
|
"Zuordnung hat immer Vorrang.",
|
||||||
|
"Unter „(Nicht zugeordnet)“ sehen Sie alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
|
||||||
|
"Diese Positionen werden nicht exportiert.",
|
||||||
|
"Mit Strg+F springen Sie ins Suchfeld. Gesucht wird in Rufnummer, Name, Tarif, Provisionsart und mehr.",
|
||||||
|
"Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei > Importierte Dateien... – dort lässt "
|
||||||
|
"sie sich wieder entfernen.",
|
||||||
|
"Erstellen Sie ab und zu eine Datensicherung: Datei > Datensicherung erstellen...",
|
||||||
|
"Alle Änderungen werden sofort gespeichert. Ein „Speichern“-Knopf ist daher nicht nötig.",
|
||||||
|
"Ein Händler wurde erst nach dem Import angelegt? Kein Problem – die Zuordnung passt sich sofort an.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
BILDER = Path(__file__).resolve().parent / "bilder"
|
||||||
|
|
||||||
|
|
||||||
|
def tipp_bild(master) -> tk.PhotoImage | None:
|
||||||
|
"""Der Büroklammer-Assistent für den Tipp des Tages (liegt auch in der .exe, siehe .spec)."""
|
||||||
|
try:
|
||||||
|
return tk.PhotoImage(master=master, file=str(BILDER / "tipp.png"))
|
||||||
|
except tk.TclError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TippDesTages(w.Dialog):
|
||||||
|
def __init__(self, app):
|
||||||
|
super().__init__(app.root, "Tipp des Tages")
|
||||||
|
self.app = app
|
||||||
|
haupt = tk.Frame(self, bg=w.FACE)
|
||||||
|
haupt.pack(padx=12, pady=12)
|
||||||
|
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
|
||||||
|
box.pack(side="left", fill="both")
|
||||||
|
# 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=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"),
|
||||||
|
anchor="w").pack(fill="x")
|
||||||
|
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6)
|
||||||
|
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=380 - 2 * 12)
|
||||||
|
self.text.pack(fill="both", expand=True)
|
||||||
|
knoepfe = tk.Frame(haupt, bg=w.FACE)
|
||||||
|
knoepfe.pack(side="left", anchor="n", padx=(10, 0))
|
||||||
|
w.knopf(knoepfe, "Schließen", self.abbrechen, breite=12, default="active").pack(pady=(0, 6))
|
||||||
|
w.knopf(knoepfe, "Nächster Tipp", self._naechster, breite=12).pack()
|
||||||
|
self.v_start = tk.BooleanVar(value=app.settings.get("tipps_beim_start", True))
|
||||||
|
tk.Checkbutton(self, text="Tipps beim Start anzeigen", variable=self.v_start).pack(anchor="w", padx=12,
|
||||||
|
pady=(0, 10))
|
||||||
|
self.bind("<Return>", lambda e: self.abbrechen())
|
||||||
|
self._naechster()
|
||||||
|
|
||||||
|
def _naechster(self):
|
||||||
|
nr = self.app.settings.get("tipp_nr", 0) % len(TIPPS)
|
||||||
|
self.text.configure(text=TIPPS[nr])
|
||||||
|
self.app.settings["tipp_nr"] = nr + 1
|
||||||
|
|
||||||
|
def abbrechen(self):
|
||||||
|
self.app.settings["tipps_beim_start"] = self.v_start.get()
|
||||||
|
self.destroy()
|
||||||
|
After Width: | Height: | Size: 990 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 787 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 550 B |
|
After Width: | Height: | Size: 951 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 964 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 876 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 594 B |
|
After Width: | Height: | Size: 1007 B |
|
After Width: | Height: | Size: 827 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 789 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 539 B |
|
After Width: | Height: | Size: 811 B |
|
After Width: | Height: | Size: 686 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 583 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 742 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -4,16 +4,17 @@ from __future__ import annotations
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import filedialog, ttk
|
from tkinter import filedialog, ttk
|
||||||
|
|
||||||
from provtool import APP_NAME, APP_ORG, __version__
|
from provtool import APP_ORG, __version__
|
||||||
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler
|
from provtool import regeln
|
||||||
|
from provtool import zeitraum as zr
|
||||||
|
from provtool.db import CAPS_ANBIETER, CAPS_WERT, AbrechnungErledigt, Datenbank, Haendler
|
||||||
from provtool.gui import win95 as w
|
from provtool.gui import win95 as w
|
||||||
from provtool.importer import ANBIETER
|
from provtool.importer import ANBIETER
|
||||||
|
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
|
||||||
|
|
||||||
# Auswahl im Händler-Dialog: Schlüssel → Anzeige
|
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige
|
||||||
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)",
|
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
|
||||||
CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"}
|
CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
|
||||||
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
|
|
||||||
from provtool.money import fmt, fmt_percent, parse_percent
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================== Händlerverwaltung
|
# =========================================================================== Händlerverwaltung
|
||||||
@@ -51,6 +52,7 @@ class HaendlerDialog(w.Dialog):
|
|||||||
g.pack(fill="x")
|
g.pack(fill="x")
|
||||||
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar()
|
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar()
|
||||||
self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
|
self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
|
||||||
|
self.v_air = tk.StringVar(value="0")
|
||||||
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
|
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
|
||||||
("E-Mail:", self.v_mail))):
|
("E-Mail:", self.v_mail))):
|
||||||
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
|
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
|
||||||
@@ -61,14 +63,20 @@ class HaendlerDialog(w.Dialog):
|
|||||||
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left")
|
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left")
|
||||||
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
|
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
|
||||||
tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e")
|
tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e")
|
||||||
tk.Label(g, text="Notiz:", anchor="nw").grid(row=4, column=0, sticky="nw", pady=2)
|
tk.Label(g, text="Airtime:", anchor="w").grid(row=4, column=0, sticky="w", pady=2)
|
||||||
self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT)
|
af = tk.Frame(g, bg=w.FACE)
|
||||||
self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2)
|
af.grid(row=4, column=1, columnspan=2, sticky="w")
|
||||||
|
self.e_air = tk.Entry(af, textvariable=self.v_air, width=7, justify="right")
|
||||||
|
self.e_air.pack(side="left")
|
||||||
|
tk.Label(af, text="% der Airtime (0 % = keine Airtime)").pack(side="left", padx=4)
|
||||||
|
tk.Label(g, text="Notiz:", anchor="nw").grid(row=5, column=0, sticky="nw", pady=2)
|
||||||
|
self.t_notiz = tk.Text(g, width=36, height=2, font=w.FONT)
|
||||||
|
self.t_notiz.grid(row=5, column=1, columnspan=2, sticky="we", pady=2)
|
||||||
g.columnconfigure(1, weight=1)
|
g.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
|
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
|
||||||
g2.pack(fill="both", expand=True, pady=(8, 0))
|
g2.pack(fill="both", expand=True, pady=(8, 0))
|
||||||
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=4,
|
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=3,
|
||||||
selectmode="browse")
|
selectmode="browse")
|
||||||
self.kliste.heading("anbieter", text="Anbieter")
|
self.kliste.heading("anbieter", text="Anbieter")
|
||||||
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
||||||
@@ -89,10 +97,27 @@ class HaendlerDialog(w.Dialog):
|
|||||||
ke.bind("<Return>", lambda e: self._kennung_dazu())
|
ke.bind("<Return>", lambda e: self._kennung_dazu())
|
||||||
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
|
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
|
||||||
w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4)
|
w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4)
|
||||||
|
self.v_caps = tk.BooleanVar(value=False)
|
||||||
|
self.cb_caps = tk.Checkbutton(g2, text=CAPS_HAKEN, variable=self.v_caps, anchor="w")
|
||||||
|
self.cb_caps.pack(fill="x", pady=(4, 0))
|
||||||
|
|
||||||
|
g4 = w.gruppe(rechts, "Jahresbonus Neuaktivierung (Betrag je Karte nach Staffel)")
|
||||||
|
g4.pack(fill="x", pady=(8, 0))
|
||||||
|
self.v_eigen_staffel = tk.BooleanVar(value=False)
|
||||||
|
sf = tk.Frame(g4, bg=w.FACE)
|
||||||
|
sf.pack(fill="x")
|
||||||
|
tk.Radiobutton(sf, text="Standard-Staffel", variable=self.v_eigen_staffel, value=False,
|
||||||
|
command=self._staffel_zeigen).pack(side="left")
|
||||||
|
tk.Radiobutton(sf, text="Eigene Staffel für diesen Händler", variable=self.v_eigen_staffel, value=True,
|
||||||
|
command=self._staffel_zeigen).pack(side="left", padx=(8, 0))
|
||||||
|
w.knopf(sf, "Bearbeiten...", self._staffel_bearbeiten).pack(side="right")
|
||||||
|
self.l_staffel = tk.Label(g4, text="", anchor="w", justify="left", fg=w.NAVY, wraplength=560)
|
||||||
|
self.l_staffel.pack(fill="x", pady=(2, 0))
|
||||||
|
self._eigene_staffel: regeln.Staffel | None = None
|
||||||
|
|
||||||
g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart")
|
g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart")
|
||||||
g3.pack(fill="both", expand=True, pady=(8, 0))
|
g3.pack(fill="both", expand=True, pady=(8, 0))
|
||||||
r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=4, selectmode="browse")
|
r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=3, selectmode="browse")
|
||||||
self.rliste.heading("art", text="Provisionsart")
|
self.rliste.heading("art", text="Provisionsart")
|
||||||
self.rliste.heading("proz", text="Auszahlung")
|
self.rliste.heading("proz", text="Auszahlung")
|
||||||
self.rliste.column("art", width=240)
|
self.rliste.column("art", width=240)
|
||||||
@@ -100,7 +125,7 @@ class HaendlerDialog(w.Dialog):
|
|||||||
r3.pack(fill="both", expand=True)
|
r3.pack(fill="both", expand=True)
|
||||||
rf = tk.Frame(g3, bg=w.FACE)
|
rf = tk.Frame(g3, bg=w.FACE)
|
||||||
rf.pack(fill="x", pady=(4, 0))
|
rf.pack(fill="x", pady=(4, 0))
|
||||||
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent…“.", fg=w.SHADOW).pack(side="left")
|
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent...“.", fg=w.SHADOW).pack(side="left")
|
||||||
w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
|
w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
|
||||||
|
|
||||||
unten = tk.Frame(self, bg=w.FACE)
|
unten = tk.Frame(self, bg=w.FACE)
|
||||||
@@ -112,9 +137,14 @@ class HaendlerDialog(w.Dialog):
|
|||||||
self._liste_laden(auswahl_id)
|
self._liste_laden(auswahl_id)
|
||||||
if neue_kennung:
|
if neue_kennung:
|
||||||
self._neu()
|
self._neu()
|
||||||
self._kennungen = [neue_kennung]
|
if neue_kennung[0] == CAPS_ANBIETER:
|
||||||
|
self.v_caps.set(True)
|
||||||
|
else:
|
||||||
|
self._kennungen = [neue_kennung]
|
||||||
self._kennungen_zeigen()
|
self._kennungen_zeigen()
|
||||||
self.minsize(640, 460)
|
elif self.liste.selection():
|
||||||
|
self._auswahl() # sofort füllen – sonst wächst der Dialog erst nach der Größenberechnung
|
||||||
|
self.minsize(660, 560)
|
||||||
|
|
||||||
# ---- intern
|
# ---- intern
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -122,9 +152,42 @@ class HaendlerDialog(w.Dialog):
|
|||||||
return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige)
|
return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige)
|
||||||
|
|
||||||
def _art_gewaehlt(self):
|
def _art_gewaehlt(self):
|
||||||
caps = self._anbieter_key(self.v_kanb.get()) == CAPS_ANBIETER
|
self._ke.focus_set()
|
||||||
self.v_kwert.set("")
|
|
||||||
self._ke.configure(state="disabled" if caps else "normal")
|
def _caps_einstellen(self, hid: int, an: bool):
|
||||||
|
"""GROSSBUCHSTABEN-Häkchen: gesperrt, wenn die Regel schon einem anderen Händler gehört."""
|
||||||
|
self.v_caps.set(an)
|
||||||
|
besitzer = self.db.kennung_map().get((CAPS_ANBIETER, CAPS_WERT))
|
||||||
|
if besitzer and besitzer != hid:
|
||||||
|
h = self.db.haendler(besitzer)
|
||||||
|
self.cb_caps.configure(state="disabled", text=f"GROSSBUCHSTABEN-Regel: gehört zu „{h.name if h else '?'}“")
|
||||||
|
else:
|
||||||
|
self.cb_caps.configure(state="normal", text=CAPS_HAKEN)
|
||||||
|
|
||||||
|
def _staffel_zeigen(self):
|
||||||
|
eigen = self.v_eigen_staffel.get()
|
||||||
|
if eigen and self._eigene_staffel is None:
|
||||||
|
self._eigene_staffel = list(self.db.standard_staffel())
|
||||||
|
st = self._eigene_staffel if eigen else self.db.standard_staffel()
|
||||||
|
self.l_staffel.configure(text=("Eigene Staffel: " if eigen else "Standard: ")
|
||||||
|
+ regeln.staffel_beschreibung(st, kurz=True))
|
||||||
|
|
||||||
|
def _staffel_bearbeiten(self):
|
||||||
|
if not self.v_eigen_staffel.get():
|
||||||
|
if w.frage(self, "Jahresbonus-Staffel",
|
||||||
|
"Die Standard-Staffel gilt für alle Händler ohne eigene Staffel.\n\n"
|
||||||
|
"Soll dieser Händler eine eigene Staffel bekommen?\n"
|
||||||
|
"(Die Standard-Staffel ändern Sie unter Händler > Standard-Staffel Jahresbonus...)",
|
||||||
|
ja="Eigene Staffel", nein="Abbrechen"):
|
||||||
|
self.v_eigen_staffel.set(True)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
neu = StaffelDialog(self, self._eigene_staffel or self.db.standard_staffel(),
|
||||||
|
f"Jahresbonus-Staffel – {self.v_name.get().strip() or 'neuer Händler'}",
|
||||||
|
standard=self.db.standard_staffel()).zeigen()
|
||||||
|
if neu is not None:
|
||||||
|
self._eigene_staffel = neu
|
||||||
|
self._staffel_zeigen()
|
||||||
|
|
||||||
def _liste_laden(self, auswahl_id=None):
|
def _liste_laden(self, auswahl_id=None):
|
||||||
self.liste.delete(*self.liste.get_children())
|
self.liste.delete(*self.liste.get_children())
|
||||||
@@ -150,20 +213,23 @@ class HaendlerDialog(w.Dialog):
|
|||||||
self.v_ap.set(h.ansprechpartner)
|
self.v_ap.set(h.ansprechpartner)
|
||||||
self.v_mail.set(h.email)
|
self.v_mail.set(h.email)
|
||||||
self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", ""))
|
self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", ""))
|
||||||
|
self.v_air.set(fmt_percent(h.airtime_prozent).replace(" %", ""))
|
||||||
self.v_aktiv.set(h.aktiv)
|
self.v_aktiv.set(h.aktiv)
|
||||||
self.t_notiz.delete("1.0", "end")
|
self.t_notiz.delete("1.0", "end")
|
||||||
self.t_notiz.insert("1.0", h.notiz or "")
|
self.t_notiz.insert("1.0", h.notiz or "")
|
||||||
self._kennungen = self.db.kennungen(h.id)
|
kenn = self.db.kennungen(h.id)
|
||||||
|
self._kennungen = [k for k in kenn if k[0] != CAPS_ANBIETER]
|
||||||
|
self._caps_einstellen(h.id, (CAPS_ANBIETER, CAPS_WERT) in kenn)
|
||||||
|
self._eigene_staffel = list(h.staffel) if h.staffel is not None else None
|
||||||
|
self.v_eigen_staffel.set(h.staffel is not None)
|
||||||
|
self._staffel_zeigen()
|
||||||
self._kennungen_zeigen()
|
self._kennungen_zeigen()
|
||||||
self._regeln_zeigen()
|
self._regeln_zeigen()
|
||||||
|
|
||||||
def _kennungen_zeigen(self):
|
def _kennungen_zeigen(self):
|
||||||
self.kliste.delete(*self.kliste.get_children())
|
self.kliste.delete(*self.kliste.get_children())
|
||||||
for i, (a, v) in enumerate(self._kennungen):
|
for i, (a, v) in enumerate(self._kennungen):
|
||||||
if a == CAPS_ANBIETER:
|
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
|
||||||
self.kliste.insert("", "end", iid=str(i), values=("Vodafone", CAPS_TEXT))
|
|
||||||
else:
|
|
||||||
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
|
|
||||||
|
|
||||||
def _regeln_zeigen(self):
|
def _regeln_zeigen(self):
|
||||||
self.rliste.delete(*self.rliste.get_children())
|
self.rliste.delete(*self.rliste.get_children())
|
||||||
@@ -177,15 +243,20 @@ class HaendlerDialog(w.Dialog):
|
|||||||
for v in (self.v_name, self.v_ap, self.v_mail):
|
for v in (self.v_name, self.v_ap, self.v_mail):
|
||||||
v.set("")
|
v.set("")
|
||||||
self.v_proz.set("100")
|
self.v_proz.set("100")
|
||||||
|
self.v_air.set("0")
|
||||||
self.v_aktiv.set(True)
|
self.v_aktiv.set(True)
|
||||||
self.t_notiz.delete("1.0", "end")
|
self.t_notiz.delete("1.0", "end")
|
||||||
self._kennungen = []
|
self._kennungen = []
|
||||||
|
self._caps_einstellen(0, False)
|
||||||
|
self._eigene_staffel = None
|
||||||
|
self.v_eigen_staffel.set(False)
|
||||||
|
self._staffel_zeigen()
|
||||||
self._kennungen_zeigen()
|
self._kennungen_zeigen()
|
||||||
self._regeln_zeigen()
|
self._regeln_zeigen()
|
||||||
|
|
||||||
def _kennung_dazu(self):
|
def _kennung_dazu(self):
|
||||||
anb = self._anbieter_key(self.v_kanb.get())
|
anb = self._anbieter_key(self.v_kanb.get())
|
||||||
wert = CAPS_WERT if anb == CAPS_ANBIETER else self.v_kwert.get().strip()
|
wert = self.v_kwert.get().strip()
|
||||||
if not wert:
|
if not wert:
|
||||||
return
|
return
|
||||||
if (anb, wert) not in self._kennungen:
|
if (anb, wert) not in self._kennungen:
|
||||||
@@ -216,12 +287,27 @@ class HaendlerDialog(w.Dialog):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
|
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
air = parse_percent(self.v_air.get() or "0")
|
||||||
|
except ValueError:
|
||||||
|
w.warnung(self, "Händler", "Die Airtime-Auszahlung muss eine Zahl sein (z. B. 0, 50 oder 100).")
|
||||||
|
return
|
||||||
if self.v_kwert.get().strip():
|
if self.v_kwert.get().strip():
|
||||||
self._kennung_dazu()
|
self._kennung_dazu()
|
||||||
|
if self.aktuell and self.aktuell.aktiv and not self.v_aktiv.get() and not w.frage(
|
||||||
|
self, "Händler deaktivieren",
|
||||||
|
f"„{name}“ inaktiv setzen?\n\nSeine noch offenen Positionen gelten dann als „nicht zugeordnet“ "
|
||||||
|
"und zeigen wieder die volle Provision (100 %); exportiert werden sie nicht. Bereits "
|
||||||
|
"abgerechnete Positionen bleiben, wie sie sind.", ja="Inaktiv setzen", default_ja=False):
|
||||||
|
return
|
||||||
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
|
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
|
||||||
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get())
|
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air,
|
||||||
|
self._eigene_staffel if self.v_eigen_staffel.get() else None)
|
||||||
|
kennungen = list(self._kennungen)
|
||||||
|
if self.v_caps.get() and str(self.cb_caps.cget("state")) != "disabled":
|
||||||
|
kennungen.append((CAPS_ANBIETER, CAPS_WERT))
|
||||||
try:
|
try:
|
||||||
hid = self.db.haendler_speichern(h, self._kennungen)
|
hid = self.db.haendler_speichern(h, kennungen)
|
||||||
except Exception as exc: # sqlite3.IntegrityError / ValueError
|
except Exception as exc: # sqlite3.IntegrityError / ValueError
|
||||||
msg = str(exc)
|
msg = str(exc)
|
||||||
if "UNIQUE" in msg and "haendler.name" in msg:
|
if "UNIQUE" in msg and "haendler.name" in msg:
|
||||||
@@ -292,7 +378,7 @@ class ProzentDialog(w.Dialog):
|
|||||||
from provtool.money import apply_percent
|
from provtool.money import apply_percent
|
||||||
try:
|
try:
|
||||||
p = parse_percent(self.v.get())
|
p = parse_percent(self.v.get())
|
||||||
self.vorschau.configure(text=f"→ Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
|
self.vorschau.configure(text=f"-> Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.vorschau.configure(text="(ungültiger Wert)")
|
self.vorschau.configure(text="(ungültiger Wert)")
|
||||||
|
|
||||||
@@ -309,16 +395,184 @@ class ProzentDialog(w.Dialog):
|
|||||||
return super().zeigen(self._e)
|
return super().zeigen(self._e)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Jahresbonus-Staffel
|
||||||
|
|
||||||
|
class StaffelDialog(w.Dialog):
|
||||||
|
"""Staffel bearbeiten: je Stufe „ab N Karten“ und Betrag je Karte (oder „voll“)."""
|
||||||
|
|
||||||
|
def __init__(self, master, staffel: regeln.Staffel, titel: str = "Jahresbonus-Staffel",
|
||||||
|
standard: regeln.Staffel | None = None):
|
||||||
|
super().__init__(master, titel)
|
||||||
|
self.standard = standard
|
||||||
|
f = tk.Frame(self, bg=w.FACE)
|
||||||
|
f.pack(fill="both", expand=True, padx=12, pady=(10, 4))
|
||||||
|
tk.Label(f, justify="left", anchor="w", wraplength=380, text=(
|
||||||
|
"Jahresbonus Neuaktivierung: Der Händler bekommt je Karte den Betrag der Stufe, die zu seiner "
|
||||||
|
"Anzahl Karten in der Abrechnung passt. „voll“ = ganzer Betrag laut Abrechnung. Nie mehr als "
|
||||||
|
"die Provision laut Abrechnung; Stornos zählen ab.")).pack(fill="x")
|
||||||
|
kopf = tk.Frame(f, bg=w.FACE)
|
||||||
|
kopf.pack(fill="x", pady=(8, 2))
|
||||||
|
tk.Label(kopf, text="ab Karten", width=9, anchor="w").pack(side="left")
|
||||||
|
tk.Label(kopf, text="Betrag je Karte (oder „voll“)", anchor="w").pack(side="left", padx=(8, 0))
|
||||||
|
self.tabelle = tk.Frame(f, bg=w.FACE)
|
||||||
|
self.tabelle.pack(fill="x")
|
||||||
|
self.zeilen: list[tuple[tk.Frame, tk.StringVar, tk.StringVar]] = []
|
||||||
|
for ab, cent in staffel:
|
||||||
|
self._zeile(ab, cent)
|
||||||
|
kk = tk.Frame(f, bg=w.FACE)
|
||||||
|
kk.pack(fill="x", pady=(6, 0))
|
||||||
|
w.knopf(kk, "Stufe hinzufügen", self._dazu).pack(side="left")
|
||||||
|
w.knopf(kk, "Letzte entfernen", self._weg).pack(side="left", padx=4)
|
||||||
|
if standard is not None:
|
||||||
|
w.knopf(kk, "Wie Standard", lambda: self._setzen(standard)).pack(side="right")
|
||||||
|
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(6, 10))
|
||||||
|
|
||||||
|
def _zeile(self, ab: int | str = "", cent: int | None | str = ""):
|
||||||
|
z = tk.Frame(self.tabelle, bg=w.FACE)
|
||||||
|
z.pack(fill="x", pady=1)
|
||||||
|
v_ab = tk.StringVar(value=str(ab))
|
||||||
|
v_bt = tk.StringVar(value="voll" if cent is None else (fmt(cent, symbol=False) if isinstance(cent, int)
|
||||||
|
else str(cent)))
|
||||||
|
tk.Entry(z, textvariable=v_ab, width=8, justify="right").pack(side="left")
|
||||||
|
tk.Entry(z, textvariable=v_bt, width=10, justify="right").pack(side="left", padx=(12, 0))
|
||||||
|
tk.Label(z, text="€ je Karte").pack(side="left", padx=4)
|
||||||
|
self.zeilen.append((z, v_ab, v_bt))
|
||||||
|
|
||||||
|
def _dazu(self):
|
||||||
|
letzte = self.zeilen[-1][1].get() if self.zeilen else "0"
|
||||||
|
try:
|
||||||
|
ab = int(letzte) + 5
|
||||||
|
except ValueError:
|
||||||
|
ab = ""
|
||||||
|
self._zeile(ab, "")
|
||||||
|
|
||||||
|
def _weg(self):
|
||||||
|
if len(self.zeilen) > 1:
|
||||||
|
self.zeilen.pop()[0].destroy()
|
||||||
|
|
||||||
|
def _setzen(self, staffel):
|
||||||
|
for z, _, _ in self.zeilen:
|
||||||
|
z.destroy()
|
||||||
|
self.zeilen = []
|
||||||
|
for ab, cent in staffel:
|
||||||
|
self._zeile(ab, cent)
|
||||||
|
|
||||||
|
def lesen(self) -> regeln.Staffel:
|
||||||
|
out = []
|
||||||
|
for i, (_, v_ab, v_bt) in enumerate(self.zeilen, start=1):
|
||||||
|
try:
|
||||||
|
ab = int(v_ab.get().strip())
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Stufe {i}: „ab Karten“ muss eine ganze Zahl sein.") from None
|
||||||
|
text = v_bt.get().strip()
|
||||||
|
if text.lower() == "voll":
|
||||||
|
cent = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
cent = parse_cent(text)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Stufe {i}: Bitte einen Betrag (z. B. 5 oder 7,50) oder „voll“ eingeben.") \
|
||||||
|
from None
|
||||||
|
out.append((ab, cent))
|
||||||
|
return regeln.staffel_pruefen(out)
|
||||||
|
|
||||||
|
def _ok(self):
|
||||||
|
try:
|
||||||
|
self.result = self.lesen()
|
||||||
|
except ValueError as exc:
|
||||||
|
w.warnung(self, "Staffel", str(exc))
|
||||||
|
return
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Provisionsarten
|
||||||
|
|
||||||
|
TYP_AUTO = "Automatisch"
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionsartenDialog(w.Dialog):
|
||||||
|
"""Welche Provisionsart ist Airtime bzw. Jahresbonus? Erkannt wird automatisch, umstellbar je Art."""
|
||||||
|
|
||||||
|
def __init__(self, master, db: Datenbank):
|
||||||
|
super().__init__(master, "Provisionsarten", resizable=True)
|
||||||
|
self.db = db
|
||||||
|
self.geaendert = False
|
||||||
|
f = tk.Frame(self, bg=w.FACE)
|
||||||
|
f.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
tk.Label(f, anchor="w", justify="left", text=(
|
||||||
|
"Airtime wird pro Händler mit dem Airtime-Prozentsatz ausgezahlt und im Export als eigene Liste\n"
|
||||||
|
"geführt. Der Jahresbonus Neuaktivierung wird nach der Staffel berechnet.")).pack(fill="x")
|
||||||
|
r, self.tree = w.scroll_tree(f, columns=("art", "anzahl", "typ"), show="headings", height=12,
|
||||||
|
selectmode="browse")
|
||||||
|
for c, t, b, a in (("art", "Provisionsart", 300, "w"), ("anzahl", "Pos.", 50, "e"),
|
||||||
|
("typ", "Behandlung", 230, "w")):
|
||||||
|
self.tree.heading(c, text=t)
|
||||||
|
self.tree.column(c, width=b, anchor=a)
|
||||||
|
r.pack(fill="both", expand=True, pady=(6, 4))
|
||||||
|
self.tree.bind("<<TreeviewSelect>>", lambda e: self._auswahl())
|
||||||
|
u = tk.Frame(f, bg=w.FACE)
|
||||||
|
u.pack(fill="x")
|
||||||
|
tk.Label(u, text="Behandlung:").pack(side="left")
|
||||||
|
self.v_typ = tk.StringVar(value=TYP_AUTO)
|
||||||
|
self.cb = ttk.Combobox(u, textvariable=self.v_typ, state="readonly", width=36,
|
||||||
|
values=[TYP_AUTO] + list(regeln.TYPEN.values()))
|
||||||
|
self.cb.pack(side="left", padx=4)
|
||||||
|
w.knopf(u, "Übernehmen", self._setzen).pack(side="left")
|
||||||
|
self.knopfleiste([("Schließen", self.abbrechen)]).pack(pady=(0, 10))
|
||||||
|
self._laden()
|
||||||
|
self.minsize(620, 380)
|
||||||
|
|
||||||
|
def _laden(self, auswahl: str | None = None):
|
||||||
|
self.tree.delete(*self.tree.get_children())
|
||||||
|
fest = self.db.art_typen()
|
||||||
|
self._arten = {}
|
||||||
|
for i, (art, erkannt, n) in enumerate(self.db.provisionsarten()):
|
||||||
|
typ = fest.get(art)
|
||||||
|
text = regeln.TYPEN[typ] if typ else f"{TYP_AUTO}: {regeln.TYPEN[erkannt]}"
|
||||||
|
iid = str(i)
|
||||||
|
self._arten[iid] = art
|
||||||
|
self.tree.insert("", "end", iid=iid, values=(art or "(ohne)", n, text))
|
||||||
|
if art == auswahl:
|
||||||
|
self.tree.selection_set(iid)
|
||||||
|
self.tree.see(iid)
|
||||||
|
|
||||||
|
def _auswahl(self):
|
||||||
|
sel = self.tree.selection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
typ = self.db.art_typen().get(self._arten[sel[0]])
|
||||||
|
self.v_typ.set(regeln.TYPEN[typ] if typ else TYP_AUTO)
|
||||||
|
|
||||||
|
def _setzen(self):
|
||||||
|
sel = self.tree.selection()
|
||||||
|
if not sel:
|
||||||
|
w.info(self, "Provisionsarten", "Bitte zuerst eine Provisionsart in der Liste auswählen.")
|
||||||
|
return
|
||||||
|
art = self._arten[sel[0]]
|
||||||
|
wahl = self.v_typ.get()
|
||||||
|
typ = next((k for k, v in regeln.TYPEN.items() if v == wahl), None)
|
||||||
|
self.db.art_typ_setzen(art, typ)
|
||||||
|
self.geaendert = True
|
||||||
|
self._laden(art)
|
||||||
|
|
||||||
|
def abbrechen(self):
|
||||||
|
self.result = self.geaendert
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================== Abrechnungen
|
# =========================================================================== Abrechnungen
|
||||||
|
|
||||||
class AbrechnungenDialog(w.Dialog):
|
class AbrechnungenDialog(w.Dialog):
|
||||||
def __init__(self, master, db: Datenbank, aktuelle_id: int | None):
|
def __init__(self, master, db: Datenbank, aktuelle_id: int | None, fuer_import: bool = False):
|
||||||
super().__init__(master, "Abrechnung öffnen", resizable=True)
|
"""fuer_import: Ziel eines Imports wählen (Ergebnis ("oeffnen", id) oder ("neu",))."""
|
||||||
|
super().__init__(master, "Import – in welche Abrechnung?" if fuer_import else "Abrechnung öffnen",
|
||||||
|
resizable=True)
|
||||||
self.db = db
|
self.db = db
|
||||||
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
tk.Label(self, text="In welche Abrechnung sollen die Dateien importiert werden?" if fuer_import
|
||||||
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings",
|
else "Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||||
height=12, selectmode="browse")
|
r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
|
||||||
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120),
|
show="headings", height=12, selectmode="browse")
|
||||||
|
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
|
||||||
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
|
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
|
||||||
self.tree.heading(c, text=t)
|
self.tree.heading(c, text=t)
|
||||||
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
|
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
|
||||||
@@ -326,9 +580,13 @@ class AbrechnungenDialog(w.Dialog):
|
|||||||
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
|
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
|
||||||
k = tk.Frame(self, bg=w.FACE)
|
k = tk.Frame(self, bg=w.FACE)
|
||||||
k.pack(fill="x", padx=10, pady=10)
|
k.pack(fill="x", padx=10, pady=10)
|
||||||
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
|
if fuer_import:
|
||||||
w.knopf(k, "Umbenennen…", self._umbenennen, breite=12).pack(side="left", padx=6)
|
w.knopf(k, "Hierhin importieren", self._oeffnen, default="active").pack(side="left")
|
||||||
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
|
w.knopf(k, "Neue Abrechnung...", self._neu).pack(side="left", padx=6)
|
||||||
|
else:
|
||||||
|
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
|
||||||
|
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
|
||||||
|
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
|
||||||
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
|
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
|
||||||
self.bind("<Return>", lambda e: self._oeffnen())
|
self.bind("<Return>", lambda e: self._oeffnen())
|
||||||
self._laden(aktuelle_id)
|
self._laden(aktuelle_id)
|
||||||
@@ -336,8 +594,9 @@ class AbrechnungenDialog(w.Dialog):
|
|||||||
def _laden(self, sel=None):
|
def _laden(self, sel=None):
|
||||||
self.tree.delete(*self.tree.get_children())
|
self.tree.delete(*self.tree.get_children())
|
||||||
for a in self.db.abrechnungen():
|
for a in self.db.abrechnungen():
|
||||||
|
status = "erledigt" if a["erledigt"] else (f"{a['abgerechnet']} abgerechnet" if a["abgerechnet"] else "offen")
|
||||||
self.tree.insert("", "end", iid=str(a["id"]),
|
self.tree.insert("", "end", iid=str(a["id"]),
|
||||||
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
|
values=(a["titel"], status, a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
|
||||||
kids = self.tree.get_children()
|
kids = self.tree.get_children()
|
||||||
if sel and self.tree.exists(str(sel)):
|
if sel and self.tree.exists(str(sel)):
|
||||||
self.tree.selection_set(str(sel))
|
self.tree.selection_set(str(sel))
|
||||||
@@ -353,6 +612,10 @@ class AbrechnungenDialog(w.Dialog):
|
|||||||
self.result = ("oeffnen", self._sel())
|
self.result = ("oeffnen", self._sel())
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
def _neu(self):
|
||||||
|
self.result = ("neu",)
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
def _umbenennen(self):
|
def _umbenennen(self):
|
||||||
aid = self._sel()
|
aid = self._sel()
|
||||||
if not aid:
|
if not aid:
|
||||||
@@ -370,7 +633,11 @@ class AbrechnungenDialog(w.Dialog):
|
|||||||
titel = self.db.abrechnung(aid)["titel"]
|
titel = self.db.abrechnung(aid)["titel"]
|
||||||
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
|
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
|
||||||
default_ja=False):
|
default_ja=False):
|
||||||
self.db.abrechnung_loeschen(aid)
|
try:
|
||||||
|
self.db.abrechnung_loeschen(aid)
|
||||||
|
except AbrechnungErledigt as exc:
|
||||||
|
w.info(self, "Löschen", str(exc))
|
||||||
|
return
|
||||||
self._laden()
|
self._laden()
|
||||||
self.result = ("geloescht", aid)
|
self.result = ("geloescht", aid)
|
||||||
|
|
||||||
@@ -410,7 +677,11 @@ class DateienDialog(w.Dialog):
|
|||||||
name = self.tree.item(s[0], "values")[0]
|
name = self.tree.item(s[0], "values")[0]
|
||||||
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
|
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
|
||||||
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
|
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
|
||||||
self.db.datei_entfernen(int(s[0]))
|
try:
|
||||||
|
self.db.datei_entfernen(int(s[0]))
|
||||||
|
except AbrechnungErledigt as exc:
|
||||||
|
w.info(self, "Datei entfernen", str(exc))
|
||||||
|
return
|
||||||
self.geaendert = True
|
self.geaendert = True
|
||||||
self._laden()
|
self._laden()
|
||||||
|
|
||||||
@@ -440,7 +711,7 @@ class ImportBericht(w.Dialog):
|
|||||||
tk.Label(self, text="Nicht zugeordnete Kennungen (kein Händler hinterlegt – z. B. eigene Filiale):",
|
tk.Label(self, text="Nicht zugeordnete Kennungen (kein Händler hinterlegt – z. B. eigene Filiale):",
|
||||||
anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||||
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings",
|
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings",
|
||||||
height=min(6, len(unbekannt)), selectmode="browse")
|
height=min(6, len(unbekannt)), selectmode="extended")
|
||||||
for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
|
for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
|
||||||
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
|
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
|
||||||
self.ut.heading(c, text=h)
|
self.ut.heading(c, text=h)
|
||||||
@@ -451,16 +722,82 @@ class ImportBericht(w.Dialog):
|
|||||||
k = tk.Frame(self, bg=w.FACE)
|
k = tk.Frame(self, bg=w.FACE)
|
||||||
k.pack(fill="x", padx=10, pady=10)
|
k.pack(fill="x", padx=10, pady=10)
|
||||||
if unbekannt:
|
if unbekannt:
|
||||||
w.knopf(k, "Kennung einem Händler zuordnen…", self._zuordnen).pack(side="left")
|
w.knopf(k, "Kennung einem Händler zuordnen...", self._zuordnen).pack(side="left")
|
||||||
|
w.knopf(k, "Als eigene ausblenden...", self._eigen).pack(side="left", padx=6)
|
||||||
w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
|
w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
|
||||||
self.bind("<Return>", lambda e: self.abbrechen())
|
self.bind("<Return>", lambda e: self.abbrechen())
|
||||||
|
|
||||||
def _zuordnen(self):
|
def _zuordnen(self):
|
||||||
s = self.ut.selection()
|
s = self.ut.selection()
|
||||||
if s:
|
if s:
|
||||||
self.result = tuple(s[0].split("\t", 1))
|
a, k = s[0].split("\t", 1)
|
||||||
|
self.result = ("zuordnen", a, k)
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
def _eigen(self):
|
||||||
|
s = self.ut.selection()
|
||||||
|
if s:
|
||||||
|
self.result = ("eigen", [tuple(x.split("\t", 1)) for x in s])
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class EigeneKennungenDialog(w.Dialog):
|
||||||
|
"""Kennungen der eigenen Filiale: Positionen damit werden ausgeblendet und nicht exportiert."""
|
||||||
|
|
||||||
|
def __init__(self, master, db: Datenbank):
|
||||||
|
super().__init__(master, "Eigene Kennungen")
|
||||||
|
self.db, self.geaendert = db, False
|
||||||
|
tk.Label(self, text="Positionen mit diesen Kennungen gehören zur eigenen Filiale. Sie werden\n"
|
||||||
|
"ausgeblendet, nicht mitgerechnet und nicht exportiert.\n"
|
||||||
|
"(Ist eine Kennung einem Händler zugeordnet, hat der Händler Vorrang.)",
|
||||||
|
justify="left", anchor="w").pack(fill="x", padx=10, pady=(10, 4))
|
||||||
|
r, self.tree = w.scroll_tree(self, columns=("anbieter", "wert"), show="headings", height=7,
|
||||||
|
selectmode="extended")
|
||||||
|
self.tree.heading("anbieter", text="Anbieter")
|
||||||
|
self.tree.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
||||||
|
self.tree.column("anbieter", width=100)
|
||||||
|
self.tree.column("wert", width=200)
|
||||||
|
r.pack(fill="both", expand=True, padx=10)
|
||||||
|
z = tk.Frame(self, bg=w.FACE)
|
||||||
|
z.pack(fill="x", padx=10, pady=6)
|
||||||
|
self.v_anb = tk.StringVar(value="freenet")
|
||||||
|
ttk.Combobox(z, textvariable=self.v_anb, values=list(ANBIETER.values()), width=10,
|
||||||
|
state="readonly").pack(side="left")
|
||||||
|
self.v_wert = tk.StringVar()
|
||||||
|
e = tk.Entry(z, textvariable=self.v_wert, width=18)
|
||||||
|
e.pack(side="left", padx=4)
|
||||||
|
e.bind("<Return>", lambda ev: self._dazu())
|
||||||
|
w.knopf(z, "Hinzufügen", self._dazu).pack(side="left")
|
||||||
|
w.knopf(z, "Entfernen", self._weg).pack(side="left", padx=4)
|
||||||
|
self.knopfleiste([("Schließen", self.abbrechen)]).pack(pady=(0, 10))
|
||||||
|
self._laden()
|
||||||
|
|
||||||
|
def _laden(self):
|
||||||
|
self.tree.delete(*self.tree.get_children())
|
||||||
|
for a, k in self.db.eigene_kennungen():
|
||||||
|
self.tree.insert("", "end", iid=f"{a}\t{k}", values=(ANBIETER.get(a, a), k))
|
||||||
|
|
||||||
|
def _dazu(self):
|
||||||
|
wert = self.v_wert.get().strip()
|
||||||
|
if not wert:
|
||||||
|
return
|
||||||
|
anb = next((k for k, v in ANBIETER.items() if v == self.v_anb.get()), self.v_anb.get())
|
||||||
|
self.db.eigene_setzen(anb, wert, True)
|
||||||
|
self.v_wert.set("")
|
||||||
|
self.geaendert = True
|
||||||
|
self._laden()
|
||||||
|
|
||||||
|
def _weg(self):
|
||||||
|
for iid in self.tree.selection():
|
||||||
|
a, k = iid.split("\t", 1)
|
||||||
|
self.db.eigene_setzen(a, k, False)
|
||||||
|
self.geaendert = True
|
||||||
|
self._laden()
|
||||||
|
|
||||||
|
def abbrechen(self):
|
||||||
|
self.result = self.geaendert
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
class KennungZuordnen(w.Dialog):
|
class KennungZuordnen(w.Dialog):
|
||||||
"""Fragt, ob eine Kennung einem bestehenden oder neuen Händler zugeordnet werden soll."""
|
"""Fragt, ob eine Kennung einem bestehenden oder neuen Händler zugeordnet werden soll."""
|
||||||
@@ -473,7 +810,7 @@ class KennungZuordnen(w.Dialog):
|
|||||||
self.v = tk.StringVar(value="__neu__")
|
self.v = tk.StringVar(value="__neu__")
|
||||||
f = tk.Frame(self, bg=w.FACE)
|
f = tk.Frame(self, bg=w.FACE)
|
||||||
f.pack(fill="x", padx=20)
|
f.pack(fill="x", padx=20)
|
||||||
tk.Radiobutton(f, text="Neuem Händler …", variable=self.v, value="__neu__").pack(anchor="w")
|
tk.Radiobutton(f, text="Neuem Händler ...", variable=self.v, value="__neu__").pack(anchor="w")
|
||||||
haendler = db.haendler_liste()
|
haendler = db.haendler_liste()
|
||||||
if haendler:
|
if haendler:
|
||||||
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w")
|
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w")
|
||||||
@@ -515,7 +852,7 @@ class HaendlerWaehlen(w.Dialog):
|
|||||||
self.cb = ttk.Combobox(self, values=list(self._hmap) + [self.KEINER], state="readonly", width=40)
|
self.cb = ttk.Combobox(self, values=list(self._hmap) + [self.KEINER], state="readonly", width=40)
|
||||||
self.cb.pack(padx=12, pady=4)
|
self.cb.pack(padx=12, pady=4)
|
||||||
tk.Label(self, text="Die feste Zuweisung hat Vorrang vor Kennungen und der GROSSBUCHSTABEN-Regel.\n"
|
tk.Label(self, text="Die feste Zuweisung hat Vorrang vor Kennungen und der GROSSBUCHSTABEN-Regel.\n"
|
||||||
"Rückgängig: Rechtsklick ▸ „Automatisch zuordnen“.", justify="left",
|
"Rückgängig: Rechtsklick > „Automatisch zuordnen“.", justify="left",
|
||||||
fg=w.SHADOW).pack(fill="x", padx=12)
|
fg=w.SHADOW).pack(fill="x", padx=12)
|
||||||
self.bind("<Return>", lambda e: self._ok())
|
self.bind("<Return>", lambda e: self._ok())
|
||||||
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
|
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
|
||||||
@@ -540,13 +877,18 @@ class ExportDialog(w.Dialog):
|
|||||||
"""haendler: (id, name, anzahl, summe_cent)"""
|
"""haendler: (id, name, anzahl, summe_cent)"""
|
||||||
super().__init__(master, "Abrechnungen exportieren")
|
super().__init__(master, "Abrechnungen exportieren")
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
f = tk.Frame(self, bg=w.FACE)
|
rumpf = tk.Frame(self, bg=w.FACE)
|
||||||
f.pack(fill="both", expand=True, padx=10, pady=10)
|
rumpf.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
# zwei Spalten (links Händler, rechts Zeitraum und Optionen) – passt auch auf kleine Bildschirme
|
||||||
|
f = tk.Frame(rumpf, bg=w.FACE)
|
||||||
|
f.pack(side="left", fill="both", expand=True)
|
||||||
|
rechts = tk.Frame(rumpf, bg=w.FACE)
|
||||||
|
rechts.pack(side="left", fill="y", padx=(14, 0))
|
||||||
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
|
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
|
||||||
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8,
|
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=12,
|
||||||
selectmode="extended")
|
selectmode="extended")
|
||||||
self.tree.heading("#0", text="")
|
self.tree.heading("#0", text="")
|
||||||
self.tree.column("#0", width=30, stretch=False)
|
self.tree.column("#0", width=40, stretch=False)
|
||||||
for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")):
|
for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")):
|
||||||
self.tree.heading(c, text=t)
|
self.tree.heading(c, text=t)
|
||||||
self.tree.column(c, width=b, anchor=a)
|
self.tree.column(c, width=b, anchor=a)
|
||||||
@@ -563,7 +905,7 @@ class ExportDialog(w.Dialog):
|
|||||||
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
|
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
|
||||||
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
|
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
|
||||||
|
|
||||||
g = w.gruppe(f, "Optionen")
|
g = w.gruppe(rechts, "Optionen")
|
||||||
g.pack(fill="x", pady=(8, 0))
|
g.pack(fill="x", pady=(8, 0))
|
||||||
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
|
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
|
||||||
self.v_titel = tk.StringVar(value=titel)
|
self.v_titel = tk.StringVar(value=titel)
|
||||||
@@ -571,22 +913,55 @@ class ExportDialog(w.Dialog):
|
|||||||
tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
|
tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
|
||||||
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
|
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
|
||||||
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
|
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
|
||||||
w.knopf(g, "Durchsuchen…", self._ordner).grid(row=1, column=2, padx=(4, 0))
|
w.knopf(g, "Durchsuchen...", self._ordner).grid(row=1, column=2, padx=(4, 0))
|
||||||
self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True))
|
# Neuer Schlüssel: freenet und Vodafone stehen jetzt standardmäßig in EINER Datei
|
||||||
|
self.v_anb = tk.BooleanVar(value=settings.get("export_anbieter_getrennt", False))
|
||||||
|
self.v_null = tk.BooleanVar(value=settings.get("export_null_weglassen", True))
|
||||||
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
|
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
|
||||||
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
|
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
|
||||||
self.v_auf = tk.BooleanVar(value=True)
|
self.v_auf = tk.BooleanVar(value=True)
|
||||||
tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei",
|
for zeile, (text, var) in enumerate((
|
||||||
variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w")
|
("freenet und Vodafone in getrennte Dateien (sonst beide in einer)", self.v_anb),
|
||||||
tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3,
|
("Positionen mit 0,00 € weglassen", self.v_null),
|
||||||
sticky="w")
|
("Kopfzeile mit Spaltennamen", self.v_kopf),
|
||||||
tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0,
|
("Summenzeile unter der Tabelle", self.v_sum),
|
||||||
columnspan=3, sticky="w")
|
("Ordner nach dem Export öffnen", self.v_auf)), start=2):
|
||||||
tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0,
|
tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w")
|
||||||
columnspan=3, sticky="w")
|
tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine "
|
||||||
|
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w",
|
||||||
|
wraplength=420).grid(
|
||||||
|
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
|
||||||
g.columnconfigure(1, weight=1)
|
g.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
z = w.gruppe(rechts, "Zeitraum und Abrechnung")
|
||||||
|
z.pack(fill="x", before=g)
|
||||||
|
zf = tk.Frame(z, bg=w.FACE)
|
||||||
|
zf.pack(fill="x")
|
||||||
|
tk.Label(zf, text="Nur von:").pack(side="left")
|
||||||
|
self.v_von, self.v_bis = tk.StringVar(), tk.StringVar()
|
||||||
|
tk.Entry(zf, textvariable=self.v_von, width=11).pack(side="left", padx=4)
|
||||||
|
tk.Label(zf, text="bis:").pack(side="left")
|
||||||
|
tk.Entry(zf, textvariable=self.v_bis, width=11).pack(side="left", padx=4)
|
||||||
|
w.knopf(zf, "Vormonat", self._vormonat).pack(side="left", padx=(4, 0))
|
||||||
|
# ohne Zeitraum = alles, was noch nicht abgerechnet ist (abgerechnete Positionen exportiert er nie)
|
||||||
|
w.knopf(zf, "Noch nicht abgerechnet", lambda: (self.v_von.set(""), self.v_bis.set(""))).pack(
|
||||||
|
side="left", padx=(4, 0))
|
||||||
|
self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)])
|
||||||
|
ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly",
|
||||||
|
width=40).pack(anchor="w", pady=(4, 0))
|
||||||
|
tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)",
|
||||||
|
fg=w.SHADOW, anchor="w", justify="left", wraplength=420).pack(fill="x")
|
||||||
|
self.v_kennz = tk.BooleanVar(value=True)
|
||||||
|
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
|
||||||
|
"einmal abgerechnet)", variable=self.v_kennz, anchor="w", justify="left",
|
||||||
|
wraplength=400).pack(fill="x", pady=(4, 0))
|
||||||
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||||
|
|
||||||
|
def _vormonat(self):
|
||||||
|
von, bis = zr.vormonat()
|
||||||
|
self.v_von.set(von.strftime("%d.%m.%Y"))
|
||||||
|
self.v_bis.set(bis.strftime("%d.%m.%Y"))
|
||||||
|
|
||||||
def _klick(self, e):
|
def _klick(self, e):
|
||||||
if self.tree.identify_column(e.x) == "#0":
|
if self.tree.identify_column(e.x) == "#0":
|
||||||
iid = self.tree.identify_row(e.y)
|
iid = self.tree.identify_row(e.y)
|
||||||
@@ -613,15 +988,26 @@ class ExportDialog(w.Dialog):
|
|||||||
if not ids:
|
if not ids:
|
||||||
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
|
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
von, bis = zr.datum_lesen(self.v_von.get()), zr.datum_lesen(self.v_bis.get())
|
||||||
|
except ValueError as exc:
|
||||||
|
w.warnung(self, "Zeitraum", str(exc))
|
||||||
|
return
|
||||||
|
basis = next(k for k, v in zr.BASEN.items() if v == self.v_basis.get())
|
||||||
if not self.v_ordner.get().strip():
|
if not self.v_ordner.get().strip():
|
||||||
self._ordner()
|
self._ordner()
|
||||||
if not self.v_ordner.get().strip():
|
if not self.v_ordner.get().strip():
|
||||||
return
|
return
|
||||||
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(),
|
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_anbieter_getrennt=self.v_anb.get(),
|
||||||
export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get())
|
export_null_weglassen=self.v_null.get(), export_kopfzeile=self.v_kopf.get(),
|
||||||
|
export_summe=self.v_sum.get())
|
||||||
|
self.settings.pop("export_pro_anbieter", None)
|
||||||
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
|
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
|
||||||
pro_anbieter=self.v_anb.get(), kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(),
|
pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.get(),
|
||||||
oeffnen=self.v_auf.get())
|
kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get(),
|
||||||
|
zeitraum=zr.Zeitraum(von, bis, basis) if (von or bis) else None,
|
||||||
|
kennzeichnen=self.v_kennz.get())
|
||||||
|
self.settings["export_basis"] = basis
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
@@ -637,10 +1023,22 @@ class EinstellungenDialog(w.Dialog):
|
|||||||
row=0, column=0, columnspan=2, sticky="w")
|
row=0, column=0, columnspan=2, sticky="w")
|
||||||
self.v = tk.StringVar(value=db_pfad)
|
self.v = tk.StringVar(value=db_pfad)
|
||||||
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4)
|
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4)
|
||||||
w.knopf(g, "Ändern…", self._waehlen).grid(row=1, column=1, padx=(4, 0))
|
w.knopf(g, "Ändern...", self._waehlen).grid(row=1, column=1, padx=(4, 0))
|
||||||
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
|
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
|
||||||
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
|
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
|
||||||
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
|
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
|
||||||
|
g2 = w.gruppe(self, "Darstellung")
|
||||||
|
g2.pack(fill="x", padx=10, pady=(0, 10))
|
||||||
|
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")
|
||||||
|
g3 = w.gruppe(self, "Updates")
|
||||||
|
g3.pack(fill="x", padx=10, pady=(0, 10))
|
||||||
|
self.v_update = tk.BooleanVar(value=settings.get("update_pruefen", True))
|
||||||
|
tk.Checkbutton(g3, text="Beim Start nach neuen Versionen suchen", variable=self.v_update).pack(anchor="w")
|
||||||
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||||
|
|
||||||
def _waehlen(self):
|
def _waehlen(self):
|
||||||
@@ -651,7 +1049,7 @@ class EinstellungenDialog(w.Dialog):
|
|||||||
self.v.set(p)
|
self.v.set(p)
|
||||||
|
|
||||||
def _ok(self):
|
def _ok(self):
|
||||||
self.result = self.v.get().strip()
|
self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get(), update=self.v_update.get())
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
@@ -659,7 +1057,7 @@ class EinstellungenDialog(w.Dialog):
|
|||||||
|
|
||||||
class InfoDialog(w.Dialog):
|
class InfoDialog(w.Dialog):
|
||||||
def __init__(self, master):
|
def __init__(self, master):
|
||||||
super().__init__(master, f"Info über {APP_NAME}")
|
super().__init__(master, f"Info über {w.APP_NAME}")
|
||||||
top = tk.Frame(self, bg=w.FACE)
|
top = tk.Frame(self, bg=w.FACE)
|
||||||
top.pack(padx=14, pady=14)
|
top.pack(padx=14, pady=14)
|
||||||
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
|
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
|
||||||
@@ -668,7 +1066,7 @@ class InfoDialog(w.Dialog):
|
|||||||
c.create_image(57, 57, image=self._img)
|
c.create_image(57, 57, image=self._img)
|
||||||
rechts = tk.Frame(top, bg=w.FACE)
|
rechts = tk.Frame(top, bg=w.FACE)
|
||||||
rechts.pack(side="left", padx=(14, 0), anchor="n")
|
rechts.pack(side="left", padx=(14, 0), anchor="n")
|
||||||
tk.Label(rechts, text=APP_NAME, font=w.FONT_BIG).pack(anchor="w")
|
tk.Label(rechts, text=w.APP_NAME, font=w.FONT_BIG).pack(anchor="w")
|
||||||
tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w")
|
tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w")
|
||||||
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
|
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
|
||||||
tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
|
tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""Menüs im Windows-95-Stil (Menüleiste, Aufklapp- und Kontextmenüs).
|
||||||
|
|
||||||
|
Die nativen Windows-Menüs sind weiß und wechseln nicht mit, wenn man bei offenem Menü mit der
|
||||||
|
Maus zum nächsten Menüleisten-Eintrag fährt. Diese Menüs sind selbst gezeichnet:
|
||||||
|
- grau, Markierung in Navy, Tastenkürzel rechtsbündig, Trennlinien eingelassen
|
||||||
|
- bei offenem Menü wechselt das Menü beim Überfahren der Menüleiste
|
||||||
|
- Tastatur: Pfeil hoch/runter wählen, Enter ausführen, Esc schließen, Pfeil links/rechts benachbartes Menü
|
||||||
|
- Klick daneben oder Wechsel in ein anderes Programm schließt das Menü
|
||||||
|
Die Schnittstelle (add_command, add_separator, tk_popup) entspricht tk.Menu.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
|
||||||
|
|
||||||
|
class Popup:
|
||||||
|
"""Ein Aufklapp- bzw. Kontextmenü."""
|
||||||
|
|
||||||
|
def __init__(self, master: tk.Misc):
|
||||||
|
self.master = master
|
||||||
|
self.eintraege: list[dict] = []
|
||||||
|
self.fenster: tk.Toplevel | None = None
|
||||||
|
self.zeilen: list[tuple[dict, tk.Frame, list[tk.Label]]] = []
|
||||||
|
self.aktiv = -1
|
||||||
|
self.leiste: Menueleiste | None = None
|
||||||
|
self._pruefer = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- tk.Menu-kompatibel
|
||||||
|
def add_command(self, label: str, command=None, accelerator: str = "", state: str = "normal"):
|
||||||
|
self.eintraege.append(dict(art="befehl", label=label, command=command, accelerator=accelerator,
|
||||||
|
state=state))
|
||||||
|
|
||||||
|
def add_separator(self):
|
||||||
|
self.eintraege.append(dict(art="trenner"))
|
||||||
|
|
||||||
|
def tk_popup(self, x: int, y: int):
|
||||||
|
self.oeffnen(x, y)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Anzeige
|
||||||
|
@property
|
||||||
|
def offen(self) -> bool:
|
||||||
|
try:
|
||||||
|
return self.fenster is not None and bool(self.fenster.winfo_exists())
|
||||||
|
except tk.TclError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def oeffnen(self, x: int, y: int, leiste: Menueleiste | None = None):
|
||||||
|
self.schliessen()
|
||||||
|
self.leiste = leiste
|
||||||
|
root = self.master._root()
|
||||||
|
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
|
||||||
|
if w.MODERN:
|
||||||
|
f.configure(bg=w.WHITE, bd=0, relief="flat", highlightthickness=1, highlightbackground=w.RAND)
|
||||||
|
f.withdraw()
|
||||||
|
f.overrideredirect(True)
|
||||||
|
self.zeilen = []
|
||||||
|
grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2)
|
||||||
|
for e in self.eintraege:
|
||||||
|
if e["art"] == "trenner":
|
||||||
|
t = tk.Frame(f, bg=grund, height=8)
|
||||||
|
t.pack(fill="x")
|
||||||
|
linie = w.trenner(t, vertikal=False)
|
||||||
|
linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
|
||||||
|
continue
|
||||||
|
zeile = tk.Frame(f, bg=grund)
|
||||||
|
zeile.pack(fill="x")
|
||||||
|
grau = e["state"] == "disabled"
|
||||||
|
text = tk.Label(zeile, text=e["label"], bg=grund, anchor="w", padx=18, pady=py,
|
||||||
|
fg=w.SHADOW if grau else w.DARK)
|
||||||
|
text.pack(side="left", fill="x", expand=True)
|
||||||
|
labels = [text]
|
||||||
|
if e["accelerator"]:
|
||||||
|
acc = tk.Label(zeile, text=e["accelerator"], bg=grund, anchor="e", padx=12, pady=py,
|
||||||
|
fg=w.SHADOW if grau else w.DARK)
|
||||||
|
acc.pack(side="right")
|
||||||
|
labels.append(acc)
|
||||||
|
idx = len(self.zeilen)
|
||||||
|
for wdg in [zeile] + labels:
|
||||||
|
wdg.bind("<Enter>", lambda ev, i=idx: self._markieren(i))
|
||||||
|
wdg.bind("<ButtonRelease-1>", lambda ev, i=idx: self._ausfuehren(i))
|
||||||
|
self.zeilen.append((e, zeile, labels))
|
||||||
|
|
||||||
|
f.update_idletasks()
|
||||||
|
bw, bh = f.winfo_reqwidth(), f.winfo_reqheight()
|
||||||
|
sw, sh = f.winfo_screenwidth(), f.winfo_screenheight()
|
||||||
|
x = max(0, min(x, sw - bw))
|
||||||
|
y = max(0, min(y, sh - bh))
|
||||||
|
# Größe UND Position zusammen setzen: eine reine Positionsangabe ignoriert Tk unter Windows bei
|
||||||
|
# rahmenlosen Fenstern – das Menü landete sonst oben links in der Bildschirmecke.
|
||||||
|
f.geometry(f"{bw}x{bh}+{x}+{y}")
|
||||||
|
f.deiconify()
|
||||||
|
f.lift()
|
||||||
|
f.update_idletasks()
|
||||||
|
try:
|
||||||
|
from provtool.gui import win32
|
||||||
|
win32.besitzer_setzen(f, root) # bleibt über dem Hauptfenster
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Alle Maus-Ereignisse der Anwendung an das Menü (lokale Tk-Sperre): Klick daneben schließt,
|
||||||
|
# Bewegung über die Menüleiste wechselt das Menü.
|
||||||
|
try:
|
||||||
|
f.grab_set()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
f.focus_force()
|
||||||
|
f.bind("<ButtonPress>", self._klick)
|
||||||
|
f.bind("<Motion>", self._bewegung)
|
||||||
|
f.bind("<Escape>", lambda e: self.schliessen())
|
||||||
|
f.bind("<Up>", lambda e: self._schritt(-1))
|
||||||
|
f.bind("<Down>", lambda e: self._schritt(1))
|
||||||
|
f.bind("<Return>", lambda e: self._ausfuehren(self.aktiv))
|
||||||
|
f.bind("<KP_Enter>", lambda e: self._ausfuehren(self.aktiv))
|
||||||
|
f.bind("<Left>", lambda e: self.leiste and self.leiste.nachbar(-1))
|
||||||
|
f.bind("<Right>", lambda e: self.leiste and self.leiste.nachbar(1))
|
||||||
|
self.aktiv = -1
|
||||||
|
self._pruefer = f.after(250, self._fokus_pruefen)
|
||||||
|
|
||||||
|
def schliessen(self):
|
||||||
|
if self._pruefer is not None and self.fenster is not None:
|
||||||
|
try:
|
||||||
|
self.fenster.after_cancel(self._pruefer)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
self._pruefer = None
|
||||||
|
if self.offen:
|
||||||
|
try:
|
||||||
|
self.fenster.grab_release()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
self.fenster.destroy()
|
||||||
|
self.fenster = None
|
||||||
|
if self.leiste is not None:
|
||||||
|
self.leiste._geschlossen(self)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Bedienung
|
||||||
|
def _innerhalb(self, x_root: int, y_root: int) -> bool:
|
||||||
|
"""Liegt der Punkt über diesem Menü? Fragt das Fenstersystem, welches Fenster dort liegt –
|
||||||
|
die Geometrie rahmenloser Fenster meldet Tk unter Windows nicht zuverlässig."""
|
||||||
|
f = self.fenster
|
||||||
|
try:
|
||||||
|
unter = f.winfo_containing(x_root, y_root)
|
||||||
|
except (tk.TclError, KeyError):
|
||||||
|
unter = None
|
||||||
|
if unter is not None:
|
||||||
|
return unter.winfo_toplevel() is f
|
||||||
|
return (f.winfo_rootx() <= x_root < f.winfo_rootx() + f.winfo_width()
|
||||||
|
and f.winfo_rooty() <= y_root < f.winfo_rooty() + f.winfo_height())
|
||||||
|
|
||||||
|
def _klick(self, e):
|
||||||
|
if self._innerhalb(e.x_root, e.y_root):
|
||||||
|
return
|
||||||
|
if self.leiste is not None and self.leiste.knopf_bei(e.x_root, e.y_root) is not None:
|
||||||
|
knopf = self.leiste.knopf_bei(e.x_root, e.y_root)
|
||||||
|
if knopf is self.leiste.offener_knopf:
|
||||||
|
self.schliessen() # Klick auf den eigenen Menütitel klappt zu
|
||||||
|
else:
|
||||||
|
self.leiste.oeffnen(knopf)
|
||||||
|
return "break"
|
||||||
|
self.schliessen()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _bewegung(self, e):
|
||||||
|
if self.leiste is None or self._innerhalb(e.x_root, e.y_root):
|
||||||
|
return
|
||||||
|
knopf = self.leiste.knopf_bei(e.x_root, e.y_root)
|
||||||
|
if knopf is not None and knopf is not self.leiste.offener_knopf:
|
||||||
|
self.leiste.oeffnen(knopf)
|
||||||
|
|
||||||
|
def _markieren(self, i: int):
|
||||||
|
self.aktiv = i
|
||||||
|
for j, (e, zeile, labels) in enumerate(self.zeilen):
|
||||||
|
an = j == i and e.get("state") != "disabled"
|
||||||
|
bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE)
|
||||||
|
fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
|
||||||
|
zeile.configure(bg=bg)
|
||||||
|
for lbl in labels:
|
||||||
|
lbl.configure(bg=bg, fg=fg)
|
||||||
|
|
||||||
|
def _schritt(self, richtung: int):
|
||||||
|
if not self.zeilen:
|
||||||
|
return
|
||||||
|
i = self.aktiv
|
||||||
|
for _ in range(len(self.zeilen)):
|
||||||
|
i = (i + richtung) % len(self.zeilen)
|
||||||
|
if self.zeilen[i][0].get("state") != "disabled":
|
||||||
|
break
|
||||||
|
self._markieren(i)
|
||||||
|
|
||||||
|
def _ausfuehren(self, i: int):
|
||||||
|
if not (0 <= i < len(self.zeilen)):
|
||||||
|
return
|
||||||
|
e = self.zeilen[i][0]
|
||||||
|
if e.get("state") == "disabled":
|
||||||
|
return
|
||||||
|
self.schliessen()
|
||||||
|
if e.get("command"):
|
||||||
|
# nach dem Schließen ausführen (der Befehl öffnet evtl. einen Dialog)
|
||||||
|
self.master.after_idle(e["command"])
|
||||||
|
|
||||||
|
def _fokus_pruefen(self):
|
||||||
|
"""In ein anderes Programm gewechselt -> Menü schließen."""
|
||||||
|
self._pruefer = None
|
||||||
|
if not self.offen:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
weg = self.fenster.focus_get() is None
|
||||||
|
except (tk.TclError, KeyError):
|
||||||
|
weg = False
|
||||||
|
if weg:
|
||||||
|
self.schliessen()
|
||||||
|
else:
|
||||||
|
self._pruefer = self.fenster.after(250, self._fokus_pruefen)
|
||||||
|
|
||||||
|
|
||||||
|
class Menueleiste(tk.Frame):
|
||||||
|
"""Graue Menüleiste mit Popup-Menüs (die native von Windows 10/11 ist weiß)."""
|
||||||
|
|
||||||
|
def __init__(self, master, root: tk.Misc):
|
||||||
|
super().__init__(master, bg=w.FACE)
|
||||||
|
self.root = root
|
||||||
|
self.knoepfe: dict[str, tk.Label] = {}
|
||||||
|
self.menues: dict[tk.Label, Popup] = {}
|
||||||
|
self.offener_knopf: tk.Label | None = None
|
||||||
|
|
||||||
|
def menue(self, label: str, underline: int | None = 0) -> Popup:
|
||||||
|
knopf = tk.Label(self, text=label, bg=w.FACE, padx=10 if w.MODERN else 7, pady=6 if w.MODERN else 2,
|
||||||
|
bd=1, relief="flat",
|
||||||
|
underline=-1 if underline is None else underline)
|
||||||
|
knopf.pack(side="left")
|
||||||
|
popup = Popup(self)
|
||||||
|
self.knoepfe[label] = knopf
|
||||||
|
self.menues[knopf] = popup
|
||||||
|
knopf.bind("<ButtonPress-1>", lambda e, k=knopf: self._klick(k))
|
||||||
|
knopf.bind("<Enter>", lambda e, k=knopf: self._hover(k, True))
|
||||||
|
knopf.bind("<Leave>", lambda e, k=knopf: self._hover(k, False))
|
||||||
|
if underline is not None:
|
||||||
|
taste = label[underline].lower()
|
||||||
|
self.root.bind_all(f"<Alt-KeyPress-{taste}>", lambda e, k=knopf: self._alt(k))
|
||||||
|
return popup
|
||||||
|
|
||||||
|
# ---- Zustand
|
||||||
|
def knopf_bei(self, x_root: int, y_root: int) -> tk.Label | None:
|
||||||
|
for k in self.knoepfe.values():
|
||||||
|
if (k.winfo_rootx() <= x_root < k.winfo_rootx() + k.winfo_width()
|
||||||
|
and k.winfo_rooty() <= y_root < k.winfo_rooty() + k.winfo_height()):
|
||||||
|
return k
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _darstellen(self):
|
||||||
|
for k in self.knoepfe.values():
|
||||||
|
if k is self.offener_knopf:
|
||||||
|
k.configure(bg=w.SEL_BG, fg=w.SEL_FG, relief="flat")
|
||||||
|
else:
|
||||||
|
k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
|
||||||
|
|
||||||
|
# ---- Bedienung
|
||||||
|
def oeffnen(self, knopf: tk.Label):
|
||||||
|
from provtool.gui import modal
|
||||||
|
if modal.offen():
|
||||||
|
return
|
||||||
|
alt = self.offener_knopf
|
||||||
|
if alt is not None and alt is not knopf:
|
||||||
|
self.menues[alt].leiste = None # ohne Rückmeldung schließen, wir öffnen gleich das nächste
|
||||||
|
self.menues[alt].schliessen()
|
||||||
|
self.offener_knopf = knopf
|
||||||
|
self._darstellen()
|
||||||
|
self.menues[knopf].oeffnen(knopf.winfo_rootx(), knopf.winfo_rooty() + knopf.winfo_height(), leiste=self)
|
||||||
|
|
||||||
|
def schliessen(self):
|
||||||
|
if self.offener_knopf is not None:
|
||||||
|
self.menues[self.offener_knopf].schliessen()
|
||||||
|
|
||||||
|
def _geschlossen(self, popup: Popup):
|
||||||
|
if self.offener_knopf is not None and self.menues.get(self.offener_knopf) is popup:
|
||||||
|
self.offener_knopf = None
|
||||||
|
self._darstellen()
|
||||||
|
|
||||||
|
def nachbar(self, richtung: int):
|
||||||
|
liste = list(self.knoepfe.values())
|
||||||
|
if self.offener_knopf in liste:
|
||||||
|
i = (liste.index(self.offener_knopf) + richtung) % len(liste)
|
||||||
|
self.oeffnen(liste[i])
|
||||||
|
|
||||||
|
def _klick(self, knopf):
|
||||||
|
if self.offener_knopf is knopf:
|
||||||
|
self.schliessen()
|
||||||
|
else:
|
||||||
|
self.oeffnen(knopf)
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _hover(self, knopf, drin: bool):
|
||||||
|
if self.offener_knopf is None:
|
||||||
|
if w.MODERN:
|
||||||
|
knopf.configure(bg=w.LIGHT if drin else w.FACE)
|
||||||
|
else:
|
||||||
|
knopf.configure(relief="raised" if drin else "flat")
|
||||||
|
|
||||||
|
def _alt(self, knopf):
|
||||||
|
self.oeffnen(knopf)
|
||||||
|
if self.offener_knopf is knopf:
|
||||||
|
self.menues[knopf]._schritt(1)
|
||||||
|
return "break"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Modale Sperre ohne Tk-„grab“: Nur der INHALT des aufrufenden Fensters wird gesperrt.
|
||||||
|
|
||||||
|
Titelleiste und Rand bleiben bedienbar – das Hauptfenster lässt sich also verschieben, maximieren
|
||||||
|
und in der Größe ändern, während ein Dialog offen ist. Klickt man auf den gesperrten Inhalt, kommt
|
||||||
|
der Dialog nach vorn und blinkt kurz (wie bei Windows).
|
||||||
|
|
||||||
|
Technik: Jedes Widget im gesperrten Bereich bekommt vorn das Bindungs-Tag ``Pm95Gesperrt``. Dessen
|
||||||
|
Bindungen laufen vor allen anderen und beenden Klicks/Tastendrücke mit „break“.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
TAG = "Pm95Gesperrt"
|
||||||
|
_stapel: list[tk.Toplevel] = [] # offene modale Fenster, innerstes zuletzt
|
||||||
|
|
||||||
|
GESPERRTE_EREIGNISSE = ("<ButtonPress>", "<ButtonRelease>", "<Double-ButtonPress>", "<Triple-ButtonPress>",
|
||||||
|
"<B1-Motion>", "<KeyPress>", "<KeyRelease>", "<MouseWheel>")
|
||||||
|
|
||||||
|
|
||||||
|
def oberstes() -> tk.Toplevel | None:
|
||||||
|
"""Das zuletzt geöffnete, noch offene modale Fenster (oder None)."""
|
||||||
|
while _stapel:
|
||||||
|
d = _stapel[-1]
|
||||||
|
try:
|
||||||
|
if d.winfo_exists():
|
||||||
|
return d
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
_stapel.pop()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def offen() -> bool:
|
||||||
|
return oberstes() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _gesperrt_geklickt(event):
|
||||||
|
d = oberstes()
|
||||||
|
if d is not None and hasattr(d, "nach_vorn"):
|
||||||
|
d.nach_vorn(blinken=str(event.type) in ("ButtonPress", "4"))
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
|
||||||
|
def _einrichten(root: tk.Misc):
|
||||||
|
# Merker am Tk-Objekt selbst (nicht am Namen: das Hauptfenster heißt in jeder Tk-Instanz „.“)
|
||||||
|
if getattr(root, "_pm95_sperre_eingerichtet", False):
|
||||||
|
return
|
||||||
|
for seq in GESPERRTE_EREIGNISSE:
|
||||||
|
root.bind_class(TAG, seq, _gesperrt_geklickt)
|
||||||
|
root._pm95_sperre_eingerichtet = True
|
||||||
|
|
||||||
|
|
||||||
|
def sperrbereiche(fenster: tk.Misc) -> list[tk.Misc]:
|
||||||
|
"""Welche Widgets eines Fensters gesperrt werden: alles außer Titelleiste (und Rand)."""
|
||||||
|
eigene = getattr(fenster, "sperrbereich", None)
|
||||||
|
if callable(eigene):
|
||||||
|
return list(eigene())
|
||||||
|
titel = getattr(fenster, "titelleiste", None)
|
||||||
|
return [k for k in fenster.winfo_children() if k is not titel and not isinstance(k, tk.Toplevel)]
|
||||||
|
|
||||||
|
|
||||||
|
class Sperre:
|
||||||
|
"""Sperrt den Inhalt von `fenster`, solange `dialog` offen ist."""
|
||||||
|
|
||||||
|
def __init__(self, fenster: tk.Misc, dialog: tk.Toplevel):
|
||||||
|
_einrichten(fenster._root())
|
||||||
|
self.widgets: list[tk.Misc] = []
|
||||||
|
for bereich in sperrbereiche(fenster):
|
||||||
|
self._sperren(bereich)
|
||||||
|
_stapel.append(dialog)
|
||||||
|
self.dialog = dialog
|
||||||
|
|
||||||
|
def _sperren(self, w: tk.Misc):
|
||||||
|
if isinstance(w, tk.Toplevel):
|
||||||
|
return # eigene Fenster (z. B. andere Dialoge) nicht anfassen
|
||||||
|
tags = w.bindtags()
|
||||||
|
if TAG not in tags:
|
||||||
|
w.bindtags((TAG,) + tuple(tags))
|
||||||
|
self.widgets.append(w)
|
||||||
|
for kind in w.winfo_children():
|
||||||
|
self._sperren(kind)
|
||||||
|
|
||||||
|
def aufheben(self):
|
||||||
|
for w in self.widgets:
|
||||||
|
try:
|
||||||
|
if w.winfo_exists():
|
||||||
|
w.bindtags(tuple(t for t in w.bindtags() if t != TAG))
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
self.widgets = []
|
||||||
|
if self.dialog in _stapel:
|
||||||
|
_stapel.remove(self.dialog)
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Windows: echtes Programmfenster mit selbst gezeichnetem Win95-Rahmen.
|
||||||
|
|
||||||
|
Das Hauptfenster ist für Tk rahmenlos („overrideredirect“). Für Windows war es damit ein Popup ohne
|
||||||
|
Größenrahmen – deshalb gingen Andocken (Fenster an den Rand ziehen), Win+Hoch/Runter/Links/Rechts und
|
||||||
|
das native Ziehen der Fenstergröße nicht.
|
||||||
|
|
||||||
|
Jetzt bekommt das äußere Windows-Fenster die Stile eines normalen Programmfensters (Titelleiste,
|
||||||
|
Größenrahmen, Min/Max, Taskleiste), deren Rahmen aber *unsichtbar* bleibt: Bei WM_NCCALCSIZE melden
|
||||||
|
wir „der Innenbereich ist das ganze Fenster“. Für Tk bleibt es rahmenlos – Tk rechnet bei
|
||||||
|
Größenänderungen also weiter ohne Rahmenaufschlag (sonst würde das Fenster bei jeder internen
|
||||||
|
Größenanpassung um die Rahmenbreite wachsen). Windows erledigt alles Weitere selbst:
|
||||||
|
- Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren)
|
||||||
|
- Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab
|
||||||
|
- Größe ändern: Den Größen-Mauszeiger am Rand zeigt Tk (rahmen.py) – so weiß Tk immer, welcher Zeiger
|
||||||
|
gilt, und setzt ihn beim Zurückfahren in das Fenster wieder richtig. Beim Drücken auf den Rand zieht
|
||||||
|
Windows die Größe (flüssig).
|
||||||
|
|
||||||
|
WICHTIG:
|
||||||
|
- Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre ->
|
||||||
|
Verklemmung), nur Win32-Funktionen.
|
||||||
|
- Verschieben/Größe ziehen NIE synchron (SendMessage) aus einem Tk-Ereignis heraus starten: Windows
|
||||||
|
läuft dann in einer eigenen Schleife, in der Tk Python-Ereignisse (z. B. <Configure>) ausführt,
|
||||||
|
während Python das nicht erwartet -> harter Absturz („PyEval_RestoreThread … thread state is
|
||||||
|
NULL“). Deshalb PostMessage: Die Schleife startet, sobald Tk wieder in seiner Ereignisschleife ist.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import sys
|
||||||
|
import tkinter as tk
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
IS_WIN = sys.platform.startswith("win")
|
||||||
|
|
||||||
|
WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086
|
||||||
|
WM_NCLBUTTONDOWN, WM_SYSCOMMAND = 0x00A1, 0x0112
|
||||||
|
HTCLIENT, HTCAPTION = 1, 2
|
||||||
|
HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17}
|
||||||
|
GWL_STYLE, GWL_EXSTYLE = -16, -20
|
||||||
|
WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000
|
||||||
|
WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000
|
||||||
|
WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000
|
||||||
|
SWP_NOSIZE, SWP_NOMOVE, SWP_NOZORDER, SWP_NOACTIVATE, SWP_FRAMECHANGED = 0x1, 0x2, 0x4, 0x10, 0x20
|
||||||
|
SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020
|
||||||
|
|
||||||
|
LRESULT = ctypes.c_ssize_t
|
||||||
|
SUBCLASSPROC = ctypes.WINFUNCTYPE(LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM,
|
||||||
|
ctypes.c_size_t, ctypes.c_size_t) if IS_WIN else None
|
||||||
|
|
||||||
|
|
||||||
|
class NCCALCSIZE_PARAMS(ctypes.Structure):
|
||||||
|
_fields_ = [("rgrc", wintypes.RECT * 3), ("lppos", ctypes.c_void_p)]
|
||||||
|
|
||||||
|
|
||||||
|
class MONITORINFO(ctypes.Structure):
|
||||||
|
_fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), ("rcWork", wintypes.RECT),
|
||||||
|
("dwFlags", wintypes.DWORD)]
|
||||||
|
|
||||||
|
|
||||||
|
_dll = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _api():
|
||||||
|
if _dll:
|
||||||
|
return _dll["u"], _dll["c"]
|
||||||
|
u = ctypes.WinDLL("user32", use_last_error=True)
|
||||||
|
c = ctypes.WinDLL("comctl32", use_last_error=True)
|
||||||
|
H, U, W, L = wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM
|
||||||
|
for name, args, res in (
|
||||||
|
("GetAncestor", [H, U], H), ("GetWindowRect", [H, ctypes.POINTER(wintypes.RECT)], wintypes.BOOL),
|
||||||
|
("GetClientRect", [H, ctypes.POINTER(wintypes.RECT)], wintypes.BOOL),
|
||||||
|
("IsZoomed", [H], wintypes.BOOL), ("IsIconic", [H], wintypes.BOOL),
|
||||||
|
("GetWindowLongW", [H, ctypes.c_int], ctypes.c_long),
|
||||||
|
("SetWindowLongW", [H, ctypes.c_int, ctypes.c_long], ctypes.c_long),
|
||||||
|
("SetWindowPos", [H, H, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, U], wintypes.BOOL),
|
||||||
|
("MonitorFromRect", [ctypes.POINTER(wintypes.RECT), wintypes.DWORD], wintypes.HMONITOR),
|
||||||
|
("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL),
|
||||||
|
("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT),
|
||||||
|
("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL),
|
||||||
|
("WindowFromPoint", [wintypes.POINT], H)):
|
||||||
|
f = getattr(u, name)
|
||||||
|
f.argtypes, f.restype = args, res
|
||||||
|
c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t]
|
||||||
|
c.SetWindowSubclass.restype = wintypes.BOOL
|
||||||
|
c.RemoveWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t]
|
||||||
|
c.RemoveWindowSubclass.restype = wintypes.BOOL
|
||||||
|
c.DefSubclassProc.argtypes = [H, U, W, L]
|
||||||
|
c.DefSubclassProc.restype = LRESULT
|
||||||
|
_dll.update(u=u, c=c)
|
||||||
|
return u, c
|
||||||
|
|
||||||
|
|
||||||
|
def _rect(h) -> wintypes.RECT:
|
||||||
|
r = wintypes.RECT()
|
||||||
|
_api()[0].GetWindowRect(h, ctypes.byref(r))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _xy(lparam: int) -> tuple[int, int]:
|
||||||
|
"""Bildschirmkoordinaten aus lParam (vorzeichenbehaftet, mehrere Monitore)."""
|
||||||
|
x, y = lparam & 0xFFFF, (lparam >> 16) & 0xFFFF
|
||||||
|
return (x - 0x10000 if x & 0x8000 else x), (y - 0x10000 if y & 0x8000 else y)
|
||||||
|
|
||||||
|
|
||||||
|
def arbeitsbereich_von(rect: wintypes.RECT) -> wintypes.RECT | None:
|
||||||
|
u, _ = _api()
|
||||||
|
mon = u.MonitorFromRect(ctypes.byref(rect), 2) # MONITOR_DEFAULTTONEAREST
|
||||||
|
mi = MONITORINFO()
|
||||||
|
mi.cbSize = ctypes.sizeof(MONITORINFO)
|
||||||
|
return mi.rcWork if mon and u.GetMonitorInfoW(mon, ctypes.byref(mi)) else None
|
||||||
|
|
||||||
|
|
||||||
|
class NativerRahmen:
|
||||||
|
"""Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen."""
|
||||||
|
|
||||||
|
def __init__(self, root: tk.Tk, rand: int, ecke: int):
|
||||||
|
self.root = root
|
||||||
|
self.rand, self.ecke = rand, ecke
|
||||||
|
self.hwnd = None
|
||||||
|
self.aktiv = False
|
||||||
|
self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Einrichten
|
||||||
|
def einrichten(self) -> bool:
|
||||||
|
"""Sobald Tk das äußere Windows-Fenster angelegt hat (möglichst vor dem ersten Anzeigen)."""
|
||||||
|
if self.aktiv:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
u, c = _api()
|
||||||
|
self.root.update_idletasks()
|
||||||
|
h = self.aeusseres_fenster()
|
||||||
|
if not h:
|
||||||
|
return False
|
||||||
|
self.hwnd = h
|
||||||
|
stil = u.GetWindowLongW(h, GWL_STYLE)
|
||||||
|
u.SetWindowLongW(h, GWL_STYLE, stil | WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX
|
||||||
|
| WS_MAXIMIZEBOX)
|
||||||
|
ex = u.GetWindowLongW(h, GWL_EXSTYLE)
|
||||||
|
u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW)
|
||||||
|
if not self._unterklasse(h, self._aussen_proc):
|
||||||
|
return False
|
||||||
|
self.aktiv = True
|
||||||
|
# Rahmen neu berechnen lassen (jetzt: unsichtbar)
|
||||||
|
u.SetWindowPos(h, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
|
||||||
|
| SWP_FRAMECHANGED)
|
||||||
|
self._ecken_nicht_runden(h)
|
||||||
|
return True
|
||||||
|
except (OSError, AttributeError, tk.TclError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def aeusseres_fenster(self) -> int | None:
|
||||||
|
"""Das äußere Windows-Fenster („wm frame“) – None, solange Tk es noch nicht angelegt hat."""
|
||||||
|
try:
|
||||||
|
h = int(self.root.wm_frame(), 16)
|
||||||
|
except (tk.TclError, ValueError):
|
||||||
|
return None
|
||||||
|
return h if h and h != self.root.winfo_id() else None
|
||||||
|
|
||||||
|
def veraltet(self) -> bool:
|
||||||
|
"""Hat Tk das äußere Fenster neu angelegt (z. B. nach „wm resizable“)? Dann neu einrichten."""
|
||||||
|
return self.aktiv and self.aeusseres_fenster() not in (None, self.hwnd)
|
||||||
|
|
||||||
|
def neu_einrichten(self) -> bool:
|
||||||
|
self.aktiv = False
|
||||||
|
return self.einrichten()
|
||||||
|
|
||||||
|
def _unterklasse(self, h: int, fn) -> bool:
|
||||||
|
_, c = _api()
|
||||||
|
proc = SUBCLASSPROC(fn)
|
||||||
|
if not c.SetWindowSubclass(h, proc, 1, 0):
|
||||||
|
return False
|
||||||
|
self._procs[h] = proc
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ecken_nicht_runden(h):
|
||||||
|
"""Windows 11 rundet Fensterecken – passt nicht zum Win95-Look."""
|
||||||
|
try:
|
||||||
|
wert = ctypes.c_int(1) # DWMWCP_DONOTROUND
|
||||||
|
ctypes.windll.dwmapi.DwmSetWindowAttribute(wintypes.HWND(h), 33, ctypes.byref(wert), 4)
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Fensterprozeduren (KEIN Tk!)
|
||||||
|
def randbereich(self, x: int, y: int) -> str:
|
||||||
|
"""Welcher Rand liegt am Bildschirmpunkt? '' = keiner (oder maximiert)."""
|
||||||
|
u, _ = _api()
|
||||||
|
h = self.hwnd
|
||||||
|
if not h or u.IsZoomed(h) or u.IsIconic(h):
|
||||||
|
return ""
|
||||||
|
r = _rect(h)
|
||||||
|
if not (r.left <= x < r.right and r.top <= y < r.bottom):
|
||||||
|
return ""
|
||||||
|
d, k = self.rand, self.ecke
|
||||||
|
ns = "n" if y < r.top + d else "s" if y >= r.bottom - d else ""
|
||||||
|
we = "w" if x < r.left + d else "e" if x >= r.right - d else ""
|
||||||
|
if ns and not we:
|
||||||
|
we = "w" if x < r.left + k else "e" if x >= r.right - k else ""
|
||||||
|
elif we and not ns:
|
||||||
|
ns = "n" if y < r.top + k else "s" if y >= r.bottom - k else ""
|
||||||
|
return ns + we
|
||||||
|
|
||||||
|
def _aussen_proc(self, hwnd, msg, wparam, lparam, _id, _ref):
|
||||||
|
_, c = _api()
|
||||||
|
try:
|
||||||
|
if msg == WM_NCCALCSIZE:
|
||||||
|
if wparam:
|
||||||
|
# Innenbereich = ganzes Fenster. Maximiert legt Windows das Fenster um die (jetzt
|
||||||
|
# unsichtbare) Rahmenbreite über den Bildschirmrand -> auf den Arbeitsbereich begrenzen.
|
||||||
|
if _api()[0].IsZoomed(hwnd):
|
||||||
|
p = ctypes.cast(lparam, ctypes.POINTER(NCCALCSIZE_PARAMS)).contents
|
||||||
|
arbeit = arbeitsbereich_von(p.rgrc[0])
|
||||||
|
if arbeit is not None:
|
||||||
|
p.rgrc[0] = arbeit
|
||||||
|
return 0
|
||||||
|
if msg == WM_NCACTIVATE:
|
||||||
|
# Nicht den (unsichtbaren) Windows-Rahmen neu zeichnen lassen (klassisches Design)
|
||||||
|
return c.DefSubclassProc(hwnd, msg, wparam, -1)
|
||||||
|
if msg == WM_NCHITTEST:
|
||||||
|
rand = self.randbereich(*_xy(lparam))
|
||||||
|
if rand:
|
||||||
|
return HT[rand]
|
||||||
|
return HTCLIENT
|
||||||
|
if msg == WM_NCDESTROY:
|
||||||
|
self._entfernen(hwnd)
|
||||||
|
except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen
|
||||||
|
pass
|
||||||
|
return c.DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||||
|
|
||||||
|
def _entfernen(self, hwnd):
|
||||||
|
proc = self._procs.pop(hwnd, None)
|
||||||
|
if proc is not None:
|
||||||
|
try:
|
||||||
|
_api()[1].RemoveWindowSubclass(hwnd, proc, 1)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Callback-Objekt bleibt bis Programmende referenziert (Windows könnte ihn noch einmal rufen)
|
||||||
|
_BEHALTEN.append(proc)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Bedienung
|
||||||
|
def verschieben(self) -> None:
|
||||||
|
"""Titelleiste gedrückt: Windows zieht das Fenster (mit Andocken an den Bildschirmrand).
|
||||||
|
PostMessage, nicht SendMessage – siehe Modulbeschreibung."""
|
||||||
|
u, _ = _api()
|
||||||
|
u.ReleaseCapture()
|
||||||
|
u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
|
||||||
|
|
||||||
|
def groesse_ziehen(self, rand: str) -> None:
|
||||||
|
u, _ = _api()
|
||||||
|
u.ReleaseCapture()
|
||||||
|
u.PostMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0)
|
||||||
|
|
||||||
|
def maximiert(self) -> bool:
|
||||||
|
return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd))
|
||||||
|
|
||||||
|
def befehl(self, sc: int) -> None:
|
||||||
|
"""SC_MAXIMIZE / SC_RESTORE / SC_MINIMIZE – wie über das Systemmenü (mit Windows-Animation)."""
|
||||||
|
_api()[0].SendMessageW(self.hwnd, WM_SYSCOMMAND, sc, 0)
|
||||||
|
|
||||||
|
def aussenmass(self) -> tuple[int, int, int, int]:
|
||||||
|
r = _rect(self.hwnd)
|
||||||
|
return r.left, r.top, r.right - r.left, r.bottom - r.top
|
||||||
|
|
||||||
|
def setzen(self, x: int, y: int, breite: int, hoehe: int) -> None:
|
||||||
|
"""Position und Größe des Fensters genau so setzen (ohne Tk-Rahmenaufschlag)."""
|
||||||
|
_api()[0].SetWindowPos(self.hwnd, None, x, y, breite, hoehe, SWP_NOZORDER | SWP_NOACTIVATE)
|
||||||
|
|
||||||
|
def innen_gleich_aussen(self) -> bool:
|
||||||
|
"""Für Tests: Ist der Windows-Rahmen wirklich unsichtbar?"""
|
||||||
|
u, _ = _api()
|
||||||
|
a, i = _rect(self.hwnd), wintypes.RECT()
|
||||||
|
u.GetClientRect(self.hwnd, ctypes.byref(i))
|
||||||
|
return (i.right - i.left, i.bottom - i.top) == (a.right - a.left, a.bottom - a.top)
|
||||||
|
|
||||||
|
def stil(self) -> tuple[int, int]:
|
||||||
|
u, _ = _api()
|
||||||
|
return u.GetWindowLongW(self.hwnd, GWL_STYLE), u.GetWindowLongW(self.hwnd, GWL_EXSTYLE)
|
||||||
|
|
||||||
|
def trefferpruefung(self, hwnd: int, x: int, y: int) -> int:
|
||||||
|
"""Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?"""
|
||||||
|
lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
|
||||||
|
return _api()[0].SendMessageW(hwnd, WM_NCHITTEST, 0, lp)
|
||||||
|
|
||||||
|
|
||||||
|
_BEHALTEN: list = []
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
"""Selbst gezeichneter Windows-95-Fensterrahmen.
|
||||||
|
|
||||||
|
Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet
|
||||||
|
(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben,
|
||||||
|
Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts,
|
||||||
|
graue Menüleiste.
|
||||||
|
|
||||||
|
Unter Windows ist das Hauptfenster trotzdem ein echtes Programmfenster (siehe nativ.py): Windows
|
||||||
|
übernimmt Verschieben, Größe ändern, Andocken am Bildschirmrand, Win+Pfeiltasten, Taskleiste und
|
||||||
|
Alt+Tab. Auf anderen Systemen (Entwicklung/Tests) macht Tk das selbst.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
|
||||||
|
IS_WIN = sys.platform.startswith("win")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Glyphen
|
||||||
|
|
||||||
|
GLYPHEN = {
|
||||||
|
"min": """
|
||||||
|
........
|
||||||
|
........
|
||||||
|
........
|
||||||
|
........
|
||||||
|
........
|
||||||
|
........
|
||||||
|
kkkkkk..
|
||||||
|
kkkkkk..""",
|
||||||
|
"max": """
|
||||||
|
kkkkkkkkk
|
||||||
|
kkkkkkkkk
|
||||||
|
k.......k
|
||||||
|
k.......k
|
||||||
|
k.......k
|
||||||
|
k.......k
|
||||||
|
k.......k
|
||||||
|
kkkkkkkkk""",
|
||||||
|
"restore": """
|
||||||
|
..kkkkkk
|
||||||
|
..kkkkkk
|
||||||
|
..k....k
|
||||||
|
kkkkkk.k
|
||||||
|
kkkkkk.k
|
||||||
|
k....kkk
|
||||||
|
k....k..
|
||||||
|
kkkkkk..""",
|
||||||
|
"close": """
|
||||||
|
kk....kk
|
||||||
|
.kk..kk.
|
||||||
|
..kkkk..
|
||||||
|
...kk...
|
||||||
|
..kkkk..
|
||||||
|
.kk..kk.
|
||||||
|
kk....kk""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def glyph(master, name: str) -> tk.PhotoImage:
|
||||||
|
rows = [r.strip() for r in GLYPHEN[name].strip("\n").splitlines() if r.strip()]
|
||||||
|
breite = max(len(r) for r in rows)
|
||||||
|
img = tk.PhotoImage(master=master, width=breite, height=len(rows))
|
||||||
|
for y, row in enumerate(rows):
|
||||||
|
for x, ch in enumerate(row):
|
||||||
|
if ch != ".":
|
||||||
|
img.put(w.PALETTE[ch], (x, y))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def griff_bild(master, groesse: int = 12) -> tk.PhotoImage:
|
||||||
|
"""Der typische schraffierte Größen-Anfasser unten rechts."""
|
||||||
|
img = tk.PhotoImage(master=master, width=groesse, height=groesse)
|
||||||
|
for y in range(groesse):
|
||||||
|
for x in range(groesse):
|
||||||
|
d = x + y - (groesse - 1)
|
||||||
|
if d >= 0:
|
||||||
|
farbe = (w.HILITE, w.SHADOW, w.SHADOW, None)[d % 4]
|
||||||
|
if farbe:
|
||||||
|
img.put(farbe, (x, y))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Win32-Hilfen
|
||||||
|
|
||||||
|
def _hwnd(fenster: tk.Misc) -> int:
|
||||||
|
import ctypes
|
||||||
|
return ctypes.windll.user32.GetParent(fenster.winfo_id())
|
||||||
|
|
||||||
|
|
||||||
|
def taskleiste_anzeigen(fenster: tk.Tk) -> None:
|
||||||
|
"""Ein overrideredirect-Fenster erscheint unter Windows sonst nicht in Taskleiste/Alt+Tab."""
|
||||||
|
if not IS_WIN:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080
|
||||||
|
hwnd = _hwnd(fenster)
|
||||||
|
stil = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
|
||||||
|
stil = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
|
||||||
|
ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, stil)
|
||||||
|
fenster.wm_withdraw()
|
||||||
|
fenster.after(10, lambda: (fenster.wm_deiconify(), fenster.focus_force()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def arbeitsbereich(fenster: tk.Misc) -> tuple[int, int, int, int]:
|
||||||
|
"""Bildschirmbereich ohne Taskleiste (des Monitors, auf dem das Fenster steht): x, y, breite, höhe."""
|
||||||
|
if IS_WIN:
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
class MONITORINFO(ctypes.Structure):
|
||||||
|
_fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT),
|
||||||
|
("rcWork", wintypes.RECT), ("dwFlags", wintypes.DWORD)]
|
||||||
|
|
||||||
|
mon = ctypes.windll.user32.MonitorFromWindow(_hwnd(fenster), 2) # MONITOR_DEFAULTTONEAREST
|
||||||
|
mi = MONITORINFO()
|
||||||
|
mi.cbSize = ctypes.sizeof(MONITORINFO)
|
||||||
|
if ctypes.windll.user32.GetMonitorInfoW(mon, ctypes.byref(mi)):
|
||||||
|
r = mi.rcWork
|
||||||
|
return r.left, r.top, r.right - r.left, r.bottom - r.top
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0, 0, fenster.winfo_screenwidth(), fenster.winfo_screenheight()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Bausteine
|
||||||
|
|
||||||
|
class TitelKnopf(tk.Label):
|
||||||
|
def __init__(self, master, bild: tk.PhotoImage, command):
|
||||||
|
super().__init__(master, image=bild, bg=w.FACE, bd=2, relief="raised", width=14, height=12)
|
||||||
|
self.command = command
|
||||||
|
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
|
||||||
|
self.bind("<ButtonRelease-1>", self._los)
|
||||||
|
|
||||||
|
def _los(self, e):
|
||||||
|
self.configure(relief="raised")
|
||||||
|
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height():
|
||||||
|
self.command()
|
||||||
|
|
||||||
|
|
||||||
|
class Titelleiste(tk.Frame):
|
||||||
|
"""Blaue Win95-Titelleiste. knoepfe: Teilmenge von ("min", "max", "close")."""
|
||||||
|
|
||||||
|
def __init__(self, master, fenster: tk.Misc, titel: str, icon: tk.PhotoImage | None, knoepfe=("close",),
|
||||||
|
schliessen=None, minimieren=None, maximieren=None):
|
||||||
|
super().__init__(master, bg=w.NAVY, height=20)
|
||||||
|
self.fenster = fenster
|
||||||
|
self._bilder = {n: glyph(self, n) for n in ("min", "max", "restore", "close")}
|
||||||
|
if icon is not None:
|
||||||
|
tk.Label(self, image=icon, bg=w.NAVY).pack(side="left", padx=(2, 0))
|
||||||
|
self.titel_var = tk.StringVar(value=titel)
|
||||||
|
self.titel_lbl = tk.Label(self, textvariable=self.titel_var, bg=w.NAVY, fg=w.WHITE, font=w.FONT_BOLD,
|
||||||
|
anchor="w")
|
||||||
|
self.titel_lbl.pack(side="left", fill="x", expand=True, padx=3)
|
||||||
|
self.knoepfe: dict[str, TitelKnopf] = {}
|
||||||
|
if "close" in knoepfe:
|
||||||
|
self.knoepfe["close"] = TitelKnopf(self, self._bilder["close"], schliessen)
|
||||||
|
self.knoepfe["close"].pack(side="right", padx=(2, 2), pady=2)
|
||||||
|
if "max" in knoepfe:
|
||||||
|
self.knoepfe["max"] = TitelKnopf(self, self._bilder["max"], maximieren)
|
||||||
|
self.knoepfe["max"].pack(side="right", pady=2)
|
||||||
|
if "min" in knoepfe:
|
||||||
|
self.knoepfe["min"] = TitelKnopf(self, self._bilder["min"], minimieren)
|
||||||
|
self.knoepfe["min"].pack(side="right", pady=2)
|
||||||
|
self._maximieren = maximieren
|
||||||
|
for widget in (self, self.titel_lbl):
|
||||||
|
widget.bind("<ButtonPress-1>", self._start)
|
||||||
|
widget.bind("<B1-Motion>", self._ziehen)
|
||||||
|
if maximieren:
|
||||||
|
widget.bind("<Double-Button-1>", lambda e: maximieren())
|
||||||
|
self._ab = (0, 0)
|
||||||
|
self.ziehen_erlaubt = lambda: True
|
||||||
|
|
||||||
|
def set_titel(self, t: str):
|
||||||
|
self.titel_var.set(t)
|
||||||
|
|
||||||
|
def max_symbol(self, maximiert: bool):
|
||||||
|
if "max" in self.knoepfe:
|
||||||
|
self.knoepfe["max"].configure(image=self._bilder["restore" if maximiert else "max"])
|
||||||
|
|
||||||
|
def _start(self, e):
|
||||||
|
self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty())
|
||||||
|
self._nativ = False
|
||||||
|
if NATIV_VERSCHIEBEN and IS_WIN and self.ziehen_erlaubt():
|
||||||
|
# Windows übernimmt das Ziehen: flüssig und mit Andocken am Bildschirmrand
|
||||||
|
from provtool.gui import win32
|
||||||
|
self._nativ = win32.nativ_verschieben(self.fenster)
|
||||||
|
if self._nativ:
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _ziehen(self, e):
|
||||||
|
if self._nativ or not self.ziehen_erlaubt():
|
||||||
|
return
|
||||||
|
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}")
|
||||||
|
|
||||||
|
def blinken(self, mal: int = 3):
|
||||||
|
"""Titelleiste kurz grau/blau blinken lassen (wie Windows bei gesperrtem Fenster)."""
|
||||||
|
farben = [w.SHADOW, w.NAVY] * mal
|
||||||
|
|
||||||
|
def schritt(i=0):
|
||||||
|
if i >= len(farben) or not self.winfo_exists():
|
||||||
|
return
|
||||||
|
for widget in (self, self.titel_lbl):
|
||||||
|
widget.configure(bg=farben[i])
|
||||||
|
self.after(70, lambda: schritt(i + 1))
|
||||||
|
schritt()
|
||||||
|
|
||||||
|
|
||||||
|
NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen
|
||||||
|
NATIVER_RAHMEN = True # Windows: echtes Fenster mit unsichtbarem Rahmen (nativ.py)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Hauptfenster
|
||||||
|
|
||||||
|
class Hauptrahmen:
|
||||||
|
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container."""
|
||||||
|
|
||||||
|
RAND = 6 # Pixel am Rand, an denen man die Größe ziehen kann (= sichtbarer Rahmen)
|
||||||
|
ECKE = 18 # so weit vom Eck entfernt gilt der Rand noch als Ecke (diagonal ziehen)
|
||||||
|
|
||||||
|
def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True,
|
||||||
|
minbreite: int = 900, minhoehe: int = 520):
|
||||||
|
self.root, self.klassisch = root, klassisch
|
||||||
|
self.minbreite, self.minhoehe = minbreite, minhoehe
|
||||||
|
self.maximiert = False
|
||||||
|
self._normal_geo: str | None = None
|
||||||
|
self._schliessen = schliessen
|
||||||
|
self.nativ = None
|
||||||
|
root.minsize(minbreite, minhoehe)
|
||||||
|
if not klassisch:
|
||||||
|
self.aussen = tk.Frame(root, bg=w.FACE)
|
||||||
|
self.aussen.pack(fill="both", expand=True)
|
||||||
|
self.inhalt = self.aussen
|
||||||
|
self.titelleiste = None
|
||||||
|
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] # für modal.py
|
||||||
|
return
|
||||||
|
|
||||||
|
root.overrideredirect(True)
|
||||||
|
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=self.RAND - 2, pady=self.RAND - 2)
|
||||||
|
self.aussen.pack(fill="both", expand=True)
|
||||||
|
self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"),
|
||||||
|
schliessen=schliessen, minimieren=self.minimieren,
|
||||||
|
maximieren=self.umschalten)
|
||||||
|
self.titelleiste.pack(fill="x")
|
||||||
|
# Maximiert darf man nur nativ ziehen (Windows stellt das Fenster dabei wieder her)
|
||||||
|
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert or self.nativ_an
|
||||||
|
self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
|
||||||
|
self.inhalt.pack(fill="both", expand=True)
|
||||||
|
# Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar
|
||||||
|
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
self._rand: str = ""
|
||||||
|
self._resize_geplant = None
|
||||||
|
self.aussen.bind("<Motion>", self._rand_cursor)
|
||||||
|
# Mauszeiger zurücksetzen, sobald der Rand verlassen wird – Kindfenster erben sonst den
|
||||||
|
# Größenänderungs-Zeiger, wenn das letzte Motion-Ereignis am Rand verpasst wurde.
|
||||||
|
self.aussen.bind("<Leave>", self._cursor_zurueck)
|
||||||
|
self.inhalt.bind("<Enter>", self._cursor_zurueck)
|
||||||
|
self.titelleiste.bind("<Enter>", self._cursor_zurueck, add="+")
|
||||||
|
self.aussen.bind("<ButtonPress-1>", self._resize_start)
|
||||||
|
self.aussen.bind("<B1-Motion>", self._resize)
|
||||||
|
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
|
||||||
|
|
||||||
|
if IS_WIN and NATIVER_RAHMEN:
|
||||||
|
from provtool.gui.nativ import NativerRahmen
|
||||||
|
self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
|
||||||
|
root.bind("<Configure>", self._fenster_geaendert, add="+")
|
||||||
|
root.bind("<Map>", lambda e: e.widget is root and self._nativ_einrichten(), add="+")
|
||||||
|
self._nativ_einrichten() # möglichst schon vor dem ersten Anzeigen
|
||||||
|
root.after(50, self._fenster_einrichten)
|
||||||
|
|
||||||
|
# ---- natives Windows-Fenster
|
||||||
|
@property
|
||||||
|
def nativ_an(self) -> bool:
|
||||||
|
return self.nativ is not None and self.nativ.aktiv
|
||||||
|
|
||||||
|
def _nativ_einrichten(self) -> bool:
|
||||||
|
if self.nativ is None:
|
||||||
|
return False
|
||||||
|
if self.nativ.aktiv and not self.nativ.veraltet():
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
sichtbar = bool(self.root.winfo_viewable())
|
||||||
|
except tk.TclError:
|
||||||
|
sichtbar = True
|
||||||
|
if self.nativ.veraltet(): # Tk hat das äußere Fenster neu angelegt
|
||||||
|
ok = self.nativ.neu_einrichten()
|
||||||
|
else:
|
||||||
|
ok = self.nativ.einrichten()
|
||||||
|
if ok:
|
||||||
|
# Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen
|
||||||
|
self._taskleiste_neu = sichtbar
|
||||||
|
return ok
|
||||||
|
|
||||||
|
_taskleiste_neu = True
|
||||||
|
|
||||||
|
def _fenster_einrichten(self):
|
||||||
|
self._nativ_einrichten()
|
||||||
|
if self.nativ_an and not self._taskleiste_neu:
|
||||||
|
return # vor dem ersten Anzeigen eingerichtet: Windows kennt den Taskleisten-Stil schon
|
||||||
|
taskleiste_anzeigen(self.root)
|
||||||
|
|
||||||
|
def _fenster_geaendert(self, e):
|
||||||
|
"""Zustand nachführen – maximieren/wiederherstellen geht jetzt auch über Windows selbst
|
||||||
|
(Win+Hoch, an den oberen Rand ziehen, Doppelklick, Taskleiste)."""
|
||||||
|
if e.widget is not self.root or self.nativ is None:
|
||||||
|
return
|
||||||
|
if self.nativ.veraltet():
|
||||||
|
self._nativ_einrichten()
|
||||||
|
if not self.nativ_an:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
maximiert = self.nativ.maximiert()
|
||||||
|
if maximiert != self.maximiert:
|
||||||
|
self.maximiert = maximiert
|
||||||
|
self.titelleiste.max_symbol(maximiert)
|
||||||
|
self._cursor_zurueck()
|
||||||
|
if not maximiert and self.root.wm_state() == "normal":
|
||||||
|
self._normal_geo = self.root.geometry()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def alt_f4(self, widget) -> str:
|
||||||
|
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
|
||||||
|
try:
|
||||||
|
# Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt
|
||||||
|
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
|
||||||
|
# Hauptfenster sein).
|
||||||
|
from provtool.gui import modal
|
||||||
|
sperre = self.root.grab_current() or modal.oberstes()
|
||||||
|
popup = self._offenes_popup()
|
||||||
|
if sperre is not None and sperre.winfo_toplevel() is not self.root:
|
||||||
|
fenster = sperre.winfo_toplevel()
|
||||||
|
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
|
||||||
|
fenster = popup
|
||||||
|
else:
|
||||||
|
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
|
||||||
|
except tk.TclError:
|
||||||
|
fenster = self.root
|
||||||
|
if fenster is self.root:
|
||||||
|
from provtool.gui import modal
|
||||||
|
offen = modal.oberstes()
|
||||||
|
if offen is not None: # Alt+F4 bei offenem Dialog schließt den Dialog
|
||||||
|
offen.abbrechen()
|
||||||
|
return "break"
|
||||||
|
self._schliessen()
|
||||||
|
elif hasattr(fenster, "abbrechen"):
|
||||||
|
fenster.abbrechen()
|
||||||
|
else:
|
||||||
|
fenster.destroy()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _offenes_popup(self):
|
||||||
|
"""Innerstes sichtbares Popup mit abbrechen() (Dialog, Rückfrage, Rundgang-Sprechblase)."""
|
||||||
|
gefunden = []
|
||||||
|
|
||||||
|
def suche(widget):
|
||||||
|
for kind in widget.winfo_children():
|
||||||
|
# Fensterstatus statt winfo_ismapped(): direkt nach dem Einblenden ist der Status schon
|
||||||
|
# „normal“, gezeichnet ist das Fenster aber evtl. noch nicht (langsamer PC, Wine).
|
||||||
|
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen"):
|
||||||
|
try:
|
||||||
|
offen = kind.winfo_exists() and kind.wm_state() != "withdrawn"
|
||||||
|
except tk.TclError:
|
||||||
|
offen = False
|
||||||
|
if offen:
|
||||||
|
gefunden.append(kind)
|
||||||
|
suche(kind)
|
||||||
|
suche(self.root)
|
||||||
|
return gefunden[-1] if gefunden else None
|
||||||
|
|
||||||
|
# ---- öffentliche Helfer
|
||||||
|
def set_titel(self, text: str):
|
||||||
|
self.root.title(text)
|
||||||
|
if self.titelleiste:
|
||||||
|
self.titelleiste.set_titel(text)
|
||||||
|
|
||||||
|
def griff(self, master) -> tk.Label | None:
|
||||||
|
"""Anfasser für die Statusleiste (nur im klassischen Modus)."""
|
||||||
|
if not self.klassisch:
|
||||||
|
return None
|
||||||
|
self._griff_bild = griff_bild(master)
|
||||||
|
g = tk.Label(master, image=self._griff_bild, bg=w.FACE, cursor="sizing" if not IS_WIN else "size_nw_se")
|
||||||
|
g.bind("<ButtonPress-1>", self._griff_start)
|
||||||
|
g.bind("<B1-Motion>", self._resize)
|
||||||
|
return g
|
||||||
|
|
||||||
|
def _griff_start(self, e):
|
||||||
|
if self.nativ_an and not self.maximiert:
|
||||||
|
self.nativ.groesse_ziehen("se") # Windows zieht die Größe (flüssig)
|
||||||
|
return "break"
|
||||||
|
self._resize_start(e, "se")
|
||||||
|
|
||||||
|
def geometrie_setzen(self, geo: str | None, maximiert: bool = False):
|
||||||
|
self.root.update_idletasks()
|
||||||
|
if not geo or "+" not in geo:
|
||||||
|
breite, hoehe = 1200, 720
|
||||||
|
if geo and "x" in geo:
|
||||||
|
try:
|
||||||
|
breite, hoehe = (int(v) for v in geo.split("+")[0].split("x"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
x0, y0, bw, bh = arbeitsbereich(self.root)
|
||||||
|
breite, hoehe = min(breite, bw), min(hoehe, bh)
|
||||||
|
geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}"
|
||||||
|
self.root.geometry(geo)
|
||||||
|
self._normal_geo = geo
|
||||||
|
self._nativ_einrichten()
|
||||||
|
if maximiert and self.klassisch:
|
||||||
|
self.root.after(100, self.maximieren)
|
||||||
|
elif maximiert: # normaler Windows-Rahmen: Windows maximiert selbst
|
||||||
|
try:
|
||||||
|
self.root.state("zoomed")
|
||||||
|
except tk.TclError:
|
||||||
|
self.root.attributes("-zoomed", True) # Linux/X11
|
||||||
|
|
||||||
|
def normale_geometrie(self) -> str:
|
||||||
|
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
|
||||||
|
|
||||||
|
# ---- Fensterfunktionen
|
||||||
|
def minimieren(self):
|
||||||
|
if IS_WIN:
|
||||||
|
# Direkt über Windows: offene Dialoge (gehören dem Hauptfenster) werden mit aus- und
|
||||||
|
# wieder eingeblendet. NICHT overrideredirect umschalten – Tk baut dabei das Fenster neu
|
||||||
|
# und Windows würde die zugehörigen Dialoge zerstören.
|
||||||
|
from provtool.gui import win32
|
||||||
|
if win32.minimieren(self.root):
|
||||||
|
return
|
||||||
|
# Tk minimiert keine overrideredirect-Fenster -> kurz normalen Rahmen aktivieren
|
||||||
|
geo = self.root.geometry()
|
||||||
|
self.root.overrideredirect(False)
|
||||||
|
self.root.iconify()
|
||||||
|
|
||||||
|
def wieder_da(e):
|
||||||
|
if e.widget is self.root and self.root.state() == "normal":
|
||||||
|
self.root.unbind("<Map>")
|
||||||
|
self.root.overrideredirect(True)
|
||||||
|
self.root.geometry(geo)
|
||||||
|
taskleiste_anzeigen(self.root)
|
||||||
|
self.root.bind("<Map>", wieder_da)
|
||||||
|
|
||||||
|
def maximieren(self):
|
||||||
|
if self.maximiert:
|
||||||
|
return
|
||||||
|
if self.nativ_an:
|
||||||
|
from provtool.gui.nativ import SC_MAXIMIZE
|
||||||
|
self._normal_geo = self.root.geometry()
|
||||||
|
self.nativ.befehl(SC_MAXIMIZE)
|
||||||
|
self.maximiert = self.nativ.maximiert()
|
||||||
|
self.titelleiste.max_symbol(self.maximiert)
|
||||||
|
return
|
||||||
|
self._normal_geo = self.root.geometry()
|
||||||
|
x, y, bw, bh = arbeitsbereich(self.root)
|
||||||
|
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
|
||||||
|
self.maximiert = True
|
||||||
|
self.titelleiste.max_symbol(True)
|
||||||
|
|
||||||
|
def wiederherstellen(self):
|
||||||
|
if not self.maximiert:
|
||||||
|
return
|
||||||
|
if self.nativ_an:
|
||||||
|
from provtool.gui.nativ import SC_RESTORE
|
||||||
|
self.nativ.befehl(SC_RESTORE)
|
||||||
|
self.maximiert = self.nativ.maximiert()
|
||||||
|
self.titelleiste.max_symbol(self.maximiert)
|
||||||
|
return
|
||||||
|
self.maximiert = False
|
||||||
|
if self._normal_geo:
|
||||||
|
self.root.geometry(self._normal_geo)
|
||||||
|
self.titelleiste.max_symbol(False)
|
||||||
|
|
||||||
|
def umschalten(self):
|
||||||
|
self.wiederherstellen() if self.maximiert else self.maximieren()
|
||||||
|
|
||||||
|
# ---- Größe ändern
|
||||||
|
def _welcher_rand(self, x, y) -> str:
|
||||||
|
bw, bh, r, k = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND, self.ECKE
|
||||||
|
ns = "n" if y < r else "s" if y >= bh - r else ""
|
||||||
|
we = "w" if x < r else "e" if x >= bw - r else ""
|
||||||
|
# an einer Kante nahe am Eck -> Ecke (diagonal), wie bei Windows
|
||||||
|
if ns and not we:
|
||||||
|
we = "w" if x < k else "e" if x >= bw - k else ""
|
||||||
|
elif we and not ns:
|
||||||
|
ns = "n" if y < k else "s" if y >= bh - k else ""
|
||||||
|
return ns + we
|
||||||
|
|
||||||
|
def _cursor_zurueck(self, _e=None):
|
||||||
|
if self._rand_zeiger:
|
||||||
|
self._rand_zeiger = False
|
||||||
|
try:
|
||||||
|
self.aussen.configure(cursor="")
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_rand_zeiger = False
|
||||||
|
|
||||||
|
CURSOR_WIN = {"n": "size_ns", "s": "size_ns", "e": "size_we", "w": "size_we",
|
||||||
|
"nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw"}
|
||||||
|
CURSOR_X11 = {"n": "top_side", "s": "bottom_side", "e": "right_side", "w": "left_side",
|
||||||
|
"nw": "top_left_corner", "se": "bottom_right_corner", "ne": "top_right_corner",
|
||||||
|
"sw": "bottom_left_corner"}
|
||||||
|
|
||||||
|
def _rand_cursor(self, e):
|
||||||
|
if self.maximiert:
|
||||||
|
self._cursor_zurueck()
|
||||||
|
return
|
||||||
|
rand = self._welcher_rand(e.x, e.y)
|
||||||
|
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
|
||||||
|
try:
|
||||||
|
self.aussen.configure(cursor=zeiger)
|
||||||
|
self._rand_zeiger = bool(zeiger)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _resize_start(self, e, rand: str | None = None):
|
||||||
|
self._rand = rand or self._welcher_rand(e.x, e.y)
|
||||||
|
if self._rand and self.nativ_an and not self.maximiert:
|
||||||
|
# Den Zeiger zeigt Tk (weiß dann immer, welcher gilt); ziehen lässt Windows (flüssig,
|
||||||
|
# mit Andocken). Angestoßen per PostMessage – siehe nativ.py.
|
||||||
|
self.nativ.groesse_ziehen(self._rand)
|
||||||
|
self._rand = ""
|
||||||
|
return "break"
|
||||||
|
self._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(),
|
||||||
|
self.root.winfo_width(), self.root.winfo_height())
|
||||||
|
|
||||||
|
def _resize(self, e):
|
||||||
|
if not self._rand or self.maximiert:
|
||||||
|
return
|
||||||
|
sx, sy, x, y, bw, bh = self._start
|
||||||
|
dx, dy = e.x_root - sx, e.y_root - sy
|
||||||
|
if "e" in self._rand:
|
||||||
|
bw = max(self.minbreite, bw + dx)
|
||||||
|
if "s" in self._rand:
|
||||||
|
bh = max(self.minhoehe, bh + dy)
|
||||||
|
if "w" in self._rand:
|
||||||
|
neu = max(self.minbreite, bw - dx)
|
||||||
|
x, bw = x + (bw - neu), neu
|
||||||
|
if "n" in self._rand:
|
||||||
|
neu = max(self.minhoehe, bh - dy)
|
||||||
|
y, bh = y + (bh - neu), neu
|
||||||
|
# Mausbewegungen bündeln: nur die jeweils letzte Größe anwenden (flüssiger unter Windows)
|
||||||
|
self._resize_ziel = f"{bw}x{bh}+{x}+{y}"
|
||||||
|
if self._resize_geplant is None:
|
||||||
|
self._resize_geplant = self.root.after(15, self._resize_anwenden)
|
||||||
|
|
||||||
|
def _resize_anwenden(self):
|
||||||
|
self._resize_geplant = None
|
||||||
|
try:
|
||||||
|
self.root.geometry(self._resize_ziel)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Win32-Hilfen für modale Fenster (nur unter Windows wirksam, sonst ohne Funktion).
|
||||||
|
|
||||||
|
Wichtig: Rahmenlose Fenster NICHT zusätzlich mit Tk `transient()` versehen – Tk legt sie dann als
|
||||||
|
Kindfenster des Desktops (WS_CHILD) an, und denen kann Windows keinen Besitzer geben.
|
||||||
|
|
||||||
|
Rahmenlose Tk-Fenster (overrideredirect) gehören für Windows zu keinem anderen Fenster. Holt man das
|
||||||
|
Programm per Taskleiste oder Alt+Tab zurück, kommt dann nur das Hauptfenster nach vorn – ein offener
|
||||||
|
Dialog bliebe dahinter verborgen. Deshalb machen wir es wie Windows bei echten modalen Dialogen:
|
||||||
|
der Dialog bekommt das aufrufende Fenster als *Besitzer*, und der Besitzer wird solange *deaktiviert*.
|
||||||
|
Windows bringt dann bei Klick aufs Hauptfenster, Taskleiste und Alt+Tab automatisch den Dialog nach vorn.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tkinter as tk
|
||||||
|
|
||||||
|
IS_WIN = sys.platform.startswith("win")
|
||||||
|
GWLP_HWNDPARENT = -8
|
||||||
|
GW_OWNER = 4
|
||||||
|
|
||||||
|
_u32 = None
|
||||||
|
|
||||||
|
|
||||||
|
def user32():
|
||||||
|
"""user32 mit korrekten 64-Bit-Signaturen (Fensterhandles dürfen nicht abgeschnitten werden)."""
|
||||||
|
global _u32
|
||||||
|
if _u32 is None:
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
u = ctypes.WinDLL("user32", use_last_error=True)
|
||||||
|
u.GetParent.argtypes = [wintypes.HWND]
|
||||||
|
u.GetParent.restype = wintypes.HWND
|
||||||
|
u.SetWindowLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_void_p]
|
||||||
|
u.SetWindowLongPtrW.restype = ctypes.c_void_p
|
||||||
|
u.EnableWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
|
||||||
|
u.EnableWindow.restype = wintypes.BOOL
|
||||||
|
u.IsWindowEnabled.argtypes = [wintypes.HWND]
|
||||||
|
u.IsWindowEnabled.restype = wintypes.BOOL
|
||||||
|
u.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
|
||||||
|
u.GetWindow.restype = wintypes.HWND
|
||||||
|
u.GetLastActivePopup.argtypes = [wintypes.HWND]
|
||||||
|
u.GetLastActivePopup.restype = wintypes.HWND
|
||||||
|
u.SetForegroundWindow.argtypes = [wintypes.HWND]
|
||||||
|
u.SetForegroundWindow.restype = wintypes.BOOL
|
||||||
|
u.IsWindow.argtypes = [wintypes.HWND]
|
||||||
|
u.IsWindow.restype = wintypes.BOOL
|
||||||
|
u.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
|
||||||
|
u.GetAncestor.restype = wintypes.HWND
|
||||||
|
u.GetTopWindow.argtypes = [wintypes.HWND]
|
||||||
|
u.GetTopWindow.restype = wintypes.HWND
|
||||||
|
u.BringWindowToTop.argtypes = [wintypes.HWND]
|
||||||
|
u.BringWindowToTop.restype = wintypes.BOOL
|
||||||
|
u.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
|
||||||
|
u.GetWindowLongW.restype = ctypes.c_long
|
||||||
|
u.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
|
||||||
|
u.SetWindowLongW.restype = ctypes.c_long
|
||||||
|
u.ReleaseCapture.argtypes = []
|
||||||
|
u.ReleaseCapture.restype = wintypes.BOOL
|
||||||
|
u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
|
||||||
|
u.SendMessageW.restype = wintypes.LPARAM
|
||||||
|
u.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
|
||||||
|
u.PostMessageW.restype = wintypes.BOOL
|
||||||
|
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
|
||||||
|
u.ShowWindow.restype = wintypes.BOOL
|
||||||
|
u.IsIconic.argtypes = [wintypes.HWND]
|
||||||
|
u.IsIconic.restype = wintypes.BOOL
|
||||||
|
u.FlashWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
|
||||||
|
u.FlashWindow.restype = wintypes.BOOL
|
||||||
|
_u32 = u
|
||||||
|
return _u32
|
||||||
|
|
||||||
|
|
||||||
|
def hwnd(fenster: tk.Misc) -> int | None:
|
||||||
|
"""Handle des äußeren (obersten) Windows-Fensters eines Tk-Toplevels."""
|
||||||
|
if not IS_WIN:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return user32().GetAncestor(fenster.winfo_toplevel().winfo_id(), 2) # GA_ROOT
|
||||||
|
except (tk.TclError, OSError):
|
||||||
|
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]
|
||||||
|
out, w = [], user32().GetTopWindow(None)
|
||||||
|
while w:
|
||||||
|
if w in ziele:
|
||||||
|
out.append(ziele.index(w))
|
||||||
|
w = user32().GetWindow(w, 2) # GW_HWNDNEXT
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def nach_vorn(fenster: tk.Misc) -> None:
|
||||||
|
h = hwnd(fenster)
|
||||||
|
if h:
|
||||||
|
user32().SetForegroundWindow(h)
|
||||||
|
user32().BringWindowToTop(h)
|
||||||
|
|
||||||
|
|
||||||
|
GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080
|
||||||
|
|
||||||
|
|
||||||
|
def hat_taskleisteneintrag(fenster: tk.Misc) -> bool:
|
||||||
|
h = hwnd(fenster)
|
||||||
|
if not h:
|
||||||
|
return False
|
||||||
|
stil = user32().GetWindowLongW(h, GWL_EXSTYLE)
|
||||||
|
return bool(stil & WS_EX_APPWINDOW) and not (stil & WS_EX_TOOLWINDOW)
|
||||||
|
|
||||||
|
|
||||||
|
def taskleisteneintrag(fenster: tk.Misc) -> bool:
|
||||||
|
"""Eigener Eintrag in Taskleiste/Alt+Tab (rahmenlose Tk-Fenster haben sonst keinen).
|
||||||
|
Wirkt erst nach erneutem Einblenden – gibt True zurück, wenn das nötig ist."""
|
||||||
|
h = hwnd(fenster)
|
||||||
|
if not h:
|
||||||
|
return False
|
||||||
|
stil = user32().GetWindowLongW(h, GWL_EXSTYLE)
|
||||||
|
neu = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
|
||||||
|
if neu != stil:
|
||||||
|
user32().SetWindowLongW(h, GWL_EXSTYLE, neu)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def nativ_verschieben(fenster: tk.Misc) -> bool:
|
||||||
|
"""Windows übernimmt das Ziehen (flüssig, mit Andocken am Bildschirmrand). True bei Erfolg."""
|
||||||
|
h = hwnd(fenster)
|
||||||
|
if not h:
|
||||||
|
return False
|
||||||
|
user32().ReleaseCapture()
|
||||||
|
# PostMessage statt SendMessage: Die Verschiebe-Schleife von Windows darf nicht innerhalb eines
|
||||||
|
# Tk-Ereignisses laufen (Tk führt darin Python-Ereignisse aus -> harter Absturz, siehe nativ.py).
|
||||||
|
user32().PostMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def minimieren(fenster: tk.Misc) -> bool:
|
||||||
|
"""Direkt über Windows minimieren – Tk verweigert das bei rahmenlosen Fenstern. Eigene Popups
|
||||||
|
(Dialoge) werden dabei von Windows mit ausgeblendet und beim Wiederherstellen mit eingeblendet."""
|
||||||
|
h = hwnd(fenster)
|
||||||
|
if not h:
|
||||||
|
return False
|
||||||
|
user32().ShowWindow(h, 6) # SW_MINIMIZE
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ist_minimiert(fenster: tk.Misc) -> bool:
|
||||||
|
h = hwnd(fenster)
|
||||||
|
return bool(user32().IsIconic(h)) if h else False
|
||||||
|
|
||||||
|
|
||||||
|
def blinken(fenster: tk.Misc) -> None:
|
||||||
|
h = hwnd(fenster)
|
||||||
|
if h:
|
||||||
|
user32().FlashWindow(h, True)
|
||||||
|
|
||||||
|
|
||||||
|
def besitzer_setzen(fenster: tk.Misc, besitzer: tk.Misc) -> None:
|
||||||
|
h, b = hwnd(fenster), hwnd(besitzer)
|
||||||
|
if h and b:
|
||||||
|
user32().SetWindowLongPtrW(h, GWLP_HWNDPARENT, b)
|
||||||
|
|
||||||
|
|
||||||
|
def besitzer_von(fenster: tk.Misc) -> int | None:
|
||||||
|
h = hwnd(fenster)
|
||||||
|
return user32().GetWindow(h, GW_OWNER) if h else None
|
||||||
|
|
||||||
|
|
||||||
|
def bedienbar(fenster: tk.Misc, an: bool) -> None:
|
||||||
|
h = hwnd(fenster)
|
||||||
|
if h and user32().IsWindow(h):
|
||||||
|
user32().EnableWindow(h, bool(an))
|
||||||
|
|
||||||
|
|
||||||
|
def ist_bedienbar(fenster: tk.Misc) -> bool:
|
||||||
|
h = hwnd(fenster)
|
||||||
|
return bool(user32().IsWindowEnabled(h)) if h else True
|
||||||
|
|
||||||
|
|
||||||
|
class Modal:
|
||||||
|
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
|
||||||
|
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
|
||||||
|
beim Schließen ein fremdes Programm statt unseres Hauptfensters."""
|
||||||
|
|
||||||
|
def __init__(self, fenster: tk.Misc, besitzer: tk.Misc, sperren: bool = True):
|
||||||
|
self.besitzer = besitzer
|
||||||
|
self.gesperrt = False
|
||||||
|
if not IS_WIN:
|
||||||
|
return
|
||||||
|
besitzer_setzen(fenster, besitzer)
|
||||||
|
if sperren and ist_bedienbar(besitzer):
|
||||||
|
bedienbar(besitzer, False)
|
||||||
|
self.gesperrt = True
|
||||||
|
|
||||||
|
def beenden(self) -> None:
|
||||||
|
if self.gesperrt:
|
||||||
|
self.gesperrt = False
|
||||||
|
try:
|
||||||
|
bedienbar(self.besitzer, True)
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine."""
|
"""Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.
|
||||||
|
|
||||||
|
Zwei Designs: „modern“ (hell und flach, wie Windows 11 – Standard) und „win95“ (klassisch, Legacy).
|
||||||
|
design_setzen() muss VOR dem Aufbau der Oberfläche laufen; alle Module lesen die Farben als w.FACE usw.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
from pathlib import Path
|
||||||
from tkinter import ttk
|
from tkinter import ttk
|
||||||
|
|
||||||
FACE = "#c0c0c0"
|
FACE = "#c0c0c0"
|
||||||
@@ -14,6 +19,38 @@ NAVY = "#000080"
|
|||||||
TEAL = "#008080"
|
TEAL = "#008080"
|
||||||
WHITE = "#ffffff"
|
WHITE = "#ffffff"
|
||||||
INFO_BG = "#ffffe1"
|
INFO_BG = "#ffffe1"
|
||||||
|
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
|
||||||
|
RAND = SHADOW # dünne Rahmenlinien (nur modern)
|
||||||
|
DESIGN = "win95"
|
||||||
|
MODERN = False # „modern“ oder „dunkel“
|
||||||
|
DUNKEL = False
|
||||||
|
APP_NAME = "Provisions-Manager 95" # modern: ohne „95“
|
||||||
|
|
||||||
|
# Farbnamen: FACE = Fläche (Fenster, Karten, Dialoge), BG = Grund hinter den Karten, WHITE = Eingabefelder/Listen,
|
||||||
|
# DARK = Text, TEXT2 = Nebentext, SHADOW = leiser Text/Win95-Schatten, NAVY = Akzent, RAND/RAND2 = Linien,
|
||||||
|
# PRIMAER/AUF_PRIMAER = Hauptknopf, MANUELL = von Hand geänderte Beträge, *_SOFT = Grund der Status-Pills.
|
||||||
|
_PALETTEN = {
|
||||||
|
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
|
||||||
|
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
|
||||||
|
SEL_FG="#ffffff", RAND="#808080", BG="#008080", RAND2="#808080", TEXT2="#000000",
|
||||||
|
PRIMAER="#c0c0c0", AUF_PRIMAER="#000000", ZEBRA="#ffffff", MANUELL="#0000c0", ROT="#a00000",
|
||||||
|
ROT_SOFT="#c0c0c0", GRUEN="#008000", GRUEN_SOFT="#c0c0c0", BLAU="#0000ff", BLAU_SOFT="#c0c0c0",
|
||||||
|
GOLD="#808000", GOLD_SOFT="#c0c0c0"),
|
||||||
|
# Hausfarben der Handy-Insel-Webtools (VVL-Tool): Lila, IBM Plex
|
||||||
|
"modern": dict(FACE="#ffffff", HILITE="#ffffff", LIGHT="#f1edf8", SHADOW="#6b6386", DARK="#211a38",
|
||||||
|
NAVY="#5e35b1", TEAL="#f1eef8", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#ede7fa",
|
||||||
|
SEL_FG="#2d1b69", RAND="#e5e0f0", BG="#f1eef8", RAND2="#d6cee8", TEXT2="#5b5372",
|
||||||
|
PRIMAER="#2d1b69", AUF_PRIMAER="#ffffff", ZEBRA="#faf8fd", MANUELL="#5e35b1", ROT="#b8223f",
|
||||||
|
ROT_SOFT="#fbe4e9", GRUEN="#11734d", GRUEN_SOFT="#def3ea", BLAU="#2a59c4", BLAU_SOFT="#e4ebfb",
|
||||||
|
GOLD="#8a6212", GOLD_SOFT="#f7eed8"),
|
||||||
|
"dunkel": dict(FACE="#1c1436", HILITE="#1c1436", LIGHT="#2a2049", SHADOW="#948daa", DARK="#ece8f6",
|
||||||
|
NAVY="#a88beb", TEAL="#120c22", WHITE="#221940", INFO_BG="#2a2049", SEL_BG="#3a2d66",
|
||||||
|
SEL_FG="#ece8f6", RAND="#312551", BG="#120c22", RAND2="#3f3167", TEXT2="#ada5c6",
|
||||||
|
PRIMAER="#c3adee", AUF_PRIMAER="#1c1436", ZEBRA="#201838", MANUELL="#c3adee", ROT="#f26982",
|
||||||
|
ROT_SOFT="#3a1e28", GRUEN="#4fc796", GRUEN_SOFT="#163025", BLAU="#7da0f0", BLAU_SOFT="#1e2743",
|
||||||
|
GOLD="#d8b058", GOLD_SOFT="#332a17"),
|
||||||
|
}
|
||||||
|
globals().update(_PALETTEN["win95"])
|
||||||
|
|
||||||
IS_WIN = sys.platform.startswith("win")
|
IS_WIN = sys.platform.startswith("win")
|
||||||
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
|
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
|
||||||
@@ -21,7 +58,57 @@ FONT_SIZE = 8 if IS_WIN else 9
|
|||||||
FONT = (FONT_FAMILY, FONT_SIZE)
|
FONT = (FONT_FAMILY, FONT_SIZE)
|
||||||
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
|
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
|
||||||
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
|
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
|
||||||
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
|
MONO_FAMILY = "Courier New" if IS_WIN else "DejaVu Sans Mono"
|
||||||
|
FONT_MONO = (MONO_FAMILY, FONT_SIZE + 1)
|
||||||
|
FONT_KLEIN_FETT = FONT_TITEL = FONT_ZAHL = FONT_BOLD
|
||||||
|
|
||||||
|
|
||||||
|
def design_setzen(name: str) -> None:
|
||||||
|
"""„modern“ (hell), „dunkel“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
|
||||||
|
global DESIGN, MODERN, DUNKEL, FONT_FAMILY, FONT_SIZE, APP_NAME, MONO_FAMILY
|
||||||
|
DESIGN = name if name in _PALETTEN else "modern"
|
||||||
|
MODERN = DESIGN in ("modern", "dunkel")
|
||||||
|
DUNKEL = DESIGN == "dunkel"
|
||||||
|
APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95"
|
||||||
|
globals().update(_PALETTEN[DESIGN])
|
||||||
|
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
|
||||||
|
MONO_FAMILY = ("Consolas" if MODERN else "Courier New") if IS_WIN else "DejaVu Sans Mono"
|
||||||
|
FONT_SIZE = 10 if MODERN else (8 if IS_WIN else 9)
|
||||||
|
_schriften()
|
||||||
|
|
||||||
|
|
||||||
|
def _schriften():
|
||||||
|
global FONT, FONT_BOLD, FONT_BIG, FONT_MONO, FONT_KLEIN_FETT, FONT_TITEL, FONT_ZAHL
|
||||||
|
FONT = (FONT_FAMILY, FONT_SIZE)
|
||||||
|
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
|
||||||
|
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
|
||||||
|
FONT_MONO = (MONO_FAMILY, FONT_SIZE + (0 if MODERN else 1))
|
||||||
|
FONT_KLEIN_FETT = (FONT_FAMILY, FONT_SIZE - 2, "bold") # Abschnitts-Überschriften (ABRECHNUNGEN …)
|
||||||
|
FONT_TITEL = (FONT_FAMILY, FONT_SIZE + 8, "bold") # Name der geöffneten Abrechnung
|
||||||
|
FONT_ZAHL = (MONO_FAMILY, FONT_SIZE + 6) if MODERN else (FONT_FAMILY, FONT_SIZE + 4, "bold") # Kennzahlen
|
||||||
|
|
||||||
|
|
||||||
|
SCHRIFTEN = Path(__file__).resolve().parent / "schriften"
|
||||||
|
|
||||||
|
|
||||||
|
def _schriften_laden() -> None:
|
||||||
|
"""IBM Plex (Hausschrift der Handy-Insel-Webtools) nur für dieses Programm laden – ohne Installation."""
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
dateien = sorted(SCHRIFTEN.glob("*.ttf"))
|
||||||
|
try:
|
||||||
|
if IS_WIN:
|
||||||
|
for f in dateien:
|
||||||
|
ctypes.windll.gdi32.AddFontResourceExW(str(f), 0x10, 0) # FR_PRIVATE
|
||||||
|
return
|
||||||
|
lib = ctypes.util.find_library("fontconfig")
|
||||||
|
if lib:
|
||||||
|
fc = ctypes.CDLL(lib)
|
||||||
|
fc.FcConfigAppFontAddFile.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
||||||
|
for f in dateien:
|
||||||
|
fc.FcConfigAppFontAddFile(None, str(f).encode())
|
||||||
|
except Exception:
|
||||||
|
pass # dann eben Segoe UI / Systemschrift
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
|
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
|
||||||
PALETTE = {
|
PALETTE = {
|
||||||
@@ -218,6 +305,23 @@ ICONS = {
|
|||||||
...kkwwwwwwkk...
|
...kkwwwwwwkk...
|
||||||
.....kkkkkkk....
|
.....kkkkkkk....
|
||||||
................""",
|
................""",
|
||||||
|
"birne": """
|
||||||
|
.....kkkkk......
|
||||||
|
....kyyyyyk.....
|
||||||
|
...kyywwyyyk....
|
||||||
|
..kyywwyyyyyk...
|
||||||
|
..kywwyyyyyyk...
|
||||||
|
..kyyyyyyyyyk...
|
||||||
|
..kyyyyyyyyyk...
|
||||||
|
...kyyyyyyyk....
|
||||||
|
....kyyyyyk.....
|
||||||
|
.....kyyyk......
|
||||||
|
.....kdddk......
|
||||||
|
.....kgggk......
|
||||||
|
.....kdddk......
|
||||||
|
.....kgggk......
|
||||||
|
......kkk.......
|
||||||
|
................""",
|
||||||
"app": """
|
"app": """
|
||||||
................
|
................
|
||||||
.....kkkkkkk....
|
.....kkkkkkk....
|
||||||
@@ -243,7 +347,13 @@ def _icon_rows(name: str) -> list[str]:
|
|||||||
return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows))
|
return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
MODERNE_ICONS = Path(__file__).resolve().parent / "bilder" / "modern"
|
||||||
|
|
||||||
|
|
||||||
def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage:
|
def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage:
|
||||||
|
datei = MODERNE_ICONS / f"{name}{'_gross' if scale > 1 else ''}.png"
|
||||||
|
if MODERN and datei.exists(): # glatte Symbole aus build/make_moderne_icons.py
|
||||||
|
return tk.PhotoImage(master=master, file=str(datei))
|
||||||
img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale)
|
img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale)
|
||||||
for y, row in enumerate(_icon_rows(name)):
|
for y, row in enumerate(_icon_rows(name)):
|
||||||
for x, ch in enumerate(row):
|
for x, ch in enumerate(row):
|
||||||
@@ -271,12 +381,25 @@ class Icons:
|
|||||||
# --------------------------------------------------------------------------- Theme
|
# --------------------------------------------------------------------------- Theme
|
||||||
|
|
||||||
def apply(root: tk.Tk) -> None:
|
def apply(root: tk.Tk) -> None:
|
||||||
|
global FONT_FAMILY, MONO_FAMILY
|
||||||
|
if MODERN: # die erste vorhandene moderne Schrift, am liebsten IBM Plex
|
||||||
|
from tkinter import font as tkfont
|
||||||
|
_schriften_laden()
|
||||||
|
vorhanden = set(tkfont.families(root))
|
||||||
|
FONT_FAMILY = next((f for f in ("IBM Plex Sans", "Segoe UI", "Noto Sans", "Cantarell", "Ubuntu",
|
||||||
|
"DejaVu Sans") if f in vorhanden), FONT_FAMILY)
|
||||||
|
MONO_FAMILY = next((f for f in ("IBM Plex Mono", "Consolas", "DejaVu Sans Mono") if f in vorhanden),
|
||||||
|
MONO_FAMILY)
|
||||||
|
_schriften()
|
||||||
root.configure(bg=FACE)
|
root.configure(bg=FACE)
|
||||||
o = root.option_add
|
o = root.option_add
|
||||||
o("*Font", FONT)
|
o("*Font", FONT)
|
||||||
o("*Background", FACE)
|
o("*Background", FACE)
|
||||||
o("*Foreground", DARK)
|
o("*Foreground", DARK)
|
||||||
o("*activeBackground", FACE)
|
o("*activeBackground", FACE)
|
||||||
|
# Tk-Eigenheit: „activeForeground“ gehört zur Optionsklasse „Background“ – ohne diese Zeile
|
||||||
|
# wäre die Schrift gedrückter Knöpfe grau auf grau (unsichtbar).
|
||||||
|
o("*activeForeground", DARK)
|
||||||
o("*highlightThickness", 0)
|
o("*highlightThickness", 0)
|
||||||
o("*selectBackground", NAVY)
|
o("*selectBackground", NAVY)
|
||||||
o("*selectForeground", WHITE)
|
o("*selectForeground", WHITE)
|
||||||
@@ -305,6 +428,7 @@ def apply(root: tk.Tk) -> None:
|
|||||||
|
|
||||||
from tkinter import font as tkfont
|
from tkinter import font as tkfont
|
||||||
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
|
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
|
||||||
|
wort_loeschen_einrichten(root)
|
||||||
st = ttk.Style(root)
|
st = ttk.Style(root)
|
||||||
st.theme_use("classic" if "classic" in st.theme_names() else "default")
|
st.theme_use("classic" if "classic" in st.theme_names() else "default")
|
||||||
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT,
|
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT,
|
||||||
@@ -322,6 +446,138 @@ def apply(root: tk.Tk) -> None:
|
|||||||
selectforeground=[("readonly", DARK)])
|
selectforeground=[("readonly", DARK)])
|
||||||
st.configure("TPanedwindow", background=FACE)
|
st.configure("TPanedwindow", background=FACE)
|
||||||
st.configure("Sash", sashthickness=6, background=FACE)
|
st.configure("Sash", sashthickness=6, background=FACE)
|
||||||
|
if MODERN:
|
||||||
|
_modern(root, st, zeile)
|
||||||
|
|
||||||
|
|
||||||
|
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
|
||||||
|
"""Flach wie die Handy-Insel-Webtools: weiße Flächen, dünne Linien, Lila als Akzent."""
|
||||||
|
o = root.option_add
|
||||||
|
for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
|
||||||
|
o(f"*{klasse}.relief", "flat")
|
||||||
|
o(f"*{klasse}.borderWidth", 4)
|
||||||
|
o(f"*{klasse}.highlightThickness", 1)
|
||||||
|
o(f"*{klasse}.highlightBackground", RAND2)
|
||||||
|
o(f"*{klasse}.highlightColor", NAVY)
|
||||||
|
o(f"*{klasse}.background", WHITE)
|
||||||
|
o(f"*{klasse}.foreground", DARK)
|
||||||
|
o(f"*{klasse}.insertBackground", DARK)
|
||||||
|
o("*Entry.disabledBackground", LIGHT)
|
||||||
|
o("*Button.relief", "flat")
|
||||||
|
o("*Button.borderWidth", 0)
|
||||||
|
o("*Button.highlightThickness", 1)
|
||||||
|
o("*Button.highlightBackground", RAND2)
|
||||||
|
o("*Button.background", FACE)
|
||||||
|
o("*Button.activeBackground", LIGHT)
|
||||||
|
o("*Button.padX", 12)
|
||||||
|
o("*Button.padY", 4)
|
||||||
|
o("*Button.cursor", "hand2")
|
||||||
|
for klasse in ("Checkbutton", "Radiobutton"):
|
||||||
|
o(f"*{klasse}.activeBackground", FACE)
|
||||||
|
o(f"*{klasse}.selectColor", WHITE)
|
||||||
|
o("*selectBackground", SEL_BG)
|
||||||
|
o("*selectForeground", SEL_FG)
|
||||||
|
o("*Menu.relief", "flat")
|
||||||
|
o("*TCombobox*Listbox.background", WHITE)
|
||||||
|
o("*TCombobox*Listbox.foreground", DARK)
|
||||||
|
o("*TCombobox*Listbox.selectBackground", SEL_BG)
|
||||||
|
o("*TCombobox*Listbox.selectForeground", SEL_FG)
|
||||||
|
st.theme_use("clam")
|
||||||
|
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
|
||||||
|
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=FACE,
|
||||||
|
darkcolor=RAND, focuscolor=NAVY, insertcolor=DARK)
|
||||||
|
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, foreground=DARK, rowheight=zeile + 10,
|
||||||
|
borderwidth=0, font=FONT)
|
||||||
|
st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)])
|
||||||
|
st.configure("Treeview.Heading", background=FACE, foreground=SHADOW, relief="flat", borderwidth=1,
|
||||||
|
font=FONT_KLEIN_FETT, padding=(10, 7), bordercolor=RAND, lightcolor=FACE, darkcolor=RAND)
|
||||||
|
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
|
||||||
|
# Seitenleiste: ohne Kopf, hohe Zeilen, Auswahl lila wie im VVL-Tool
|
||||||
|
st.configure("Seite.Treeview", background=FACE, fieldbackground=FACE, rowheight=zeile + 16, font=FONT,
|
||||||
|
borderwidth=0)
|
||||||
|
st.layout("Seite.Treeview", [("Treeview.treearea", {"sticky": "nswe"})]) # ohne Rahmen
|
||||||
|
st.map("Seite.Treeview", background=[("selected", SEL_BG)], foreground=[("selected", NAVY)])
|
||||||
|
st.configure("TScrollbar", background=RAND2, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE,
|
||||||
|
lightcolor=RAND2, darkcolor=RAND2, relief="flat", arrowsize=10, width=10, gripcount=0)
|
||||||
|
st.map("TScrollbar", background=[("active", SHADOW)])
|
||||||
|
st.configure("TCombobox", fieldbackground=WHITE, background=FACE, foreground=DARK, arrowcolor=DARK, padding=4,
|
||||||
|
bordercolor=RAND2, lightcolor=WHITE, darkcolor=WHITE)
|
||||||
|
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
|
||||||
|
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
|
||||||
|
st.configure("TPanedwindow", background=BG)
|
||||||
|
st.configure("Sash", sashthickness=6, background=BG)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Wort löschen
|
||||||
|
|
||||||
|
def _wortgrenze(text: str, pos: int, richtung: int) -> int:
|
||||||
|
"""Position der nächsten Wortgrenze von pos aus (richtung -1 = links, +1 = rechts),
|
||||||
|
wie Strg+Rücktaste / Strg+Entf unter Windows: erst Leerzeichen, dann das Wort."""
|
||||||
|
if richtung < 0:
|
||||||
|
i = pos
|
||||||
|
while i > 0 and text[i - 1].isspace():
|
||||||
|
i -= 1
|
||||||
|
if i > 0 and text[i - 1].isalnum():
|
||||||
|
while i > 0 and text[i - 1].isalnum():
|
||||||
|
i -= 1
|
||||||
|
elif i > 0:
|
||||||
|
i -= 1 # Satzzeichen einzeln
|
||||||
|
return i
|
||||||
|
i = pos
|
||||||
|
if i < len(text) and text[i].isalnum():
|
||||||
|
while i < len(text) and text[i].isalnum():
|
||||||
|
i += 1
|
||||||
|
elif i < len(text):
|
||||||
|
i += 1
|
||||||
|
while i < len(text) and text[i].isspace():
|
||||||
|
i += 1
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def _wort_weg(event, richtung: int):
|
||||||
|
e = event.widget
|
||||||
|
try:
|
||||||
|
if str(e.cget("state")) in ("disabled", "readonly"):
|
||||||
|
return "break"
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if e.selection_present():
|
||||||
|
e.delete("sel.first", "sel.last")
|
||||||
|
return "break"
|
||||||
|
except (tk.TclError, AttributeError):
|
||||||
|
pass
|
||||||
|
text, pos = e.get(), e.index("insert")
|
||||||
|
grenze = _wortgrenze(text, pos, richtung)
|
||||||
|
if richtung < 0 and grenze < pos:
|
||||||
|
e.delete(grenze, pos)
|
||||||
|
elif richtung > 0 and grenze > pos:
|
||||||
|
e.delete(pos, grenze)
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
|
||||||
|
def _text_wort_weg(event, richtung: int):
|
||||||
|
t = event.widget
|
||||||
|
if str(t.cget("state")) == "disabled":
|
||||||
|
return "break"
|
||||||
|
if t.tag_ranges("sel"):
|
||||||
|
t.delete("sel.first", "sel.last")
|
||||||
|
return "break"
|
||||||
|
if richtung < 0:
|
||||||
|
t.delete("insert -1c wordstart" if t.get("insert -1c").isspace() else "insert wordstart", "insert")
|
||||||
|
else:
|
||||||
|
t.delete("insert", "insert wordend")
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
|
||||||
|
def wort_loeschen_einrichten(root: tk.Misc):
|
||||||
|
"""Strg+Rücktaste / Strg+Entf löschen das ganze Wort – in allen Eingabefeldern (Tk kann das
|
||||||
|
unter Windows von Haus aus nicht, dort wird nur ein Zeichen gelöscht)."""
|
||||||
|
for klasse in ("Entry", "TEntry", "TCombobox", "Spinbox", "TSpinbox"):
|
||||||
|
root.bind_class(klasse, "<Control-BackSpace>", lambda e: _wort_weg(e, -1))
|
||||||
|
root.bind_class(klasse, "<Control-Delete>", lambda e: _wort_weg(e, 1))
|
||||||
|
root.bind_class("Text", "<Control-BackSpace>", lambda e: _text_wort_weg(e, -1))
|
||||||
|
root.bind_class("Text", "<Control-Delete>", lambda e: _text_wort_weg(e, 1))
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- Bausteine
|
# --------------------------------------------------------------------------- Bausteine
|
||||||
@@ -330,37 +586,20 @@ class Sunken(tk.Frame):
|
|||||||
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
|
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
|
||||||
|
|
||||||
def __init__(self, master, **kw):
|
def __init__(self, master, **kw):
|
||||||
|
if MODERN:
|
||||||
|
kw.update(bd=0, relief="flat", highlightthickness=1, highlightbackground=RAND)
|
||||||
kw.setdefault("bd", 2)
|
kw.setdefault("bd", 2)
|
||||||
kw.setdefault("relief", "sunken")
|
kw.setdefault("relief", "sunken")
|
||||||
super().__init__(master, **kw)
|
super().__init__(master, **kw)
|
||||||
|
|
||||||
|
|
||||||
class Fenster(tk.Frame):
|
|
||||||
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
|
|
||||||
|
|
||||||
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
|
|
||||||
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
|
|
||||||
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
|
|
||||||
self.titelleiste.pack(fill="x", padx=1, pady=1)
|
|
||||||
if icon is not None:
|
|
||||||
tk.Label(self.titelleiste, image=icon, bg=NAVY).pack(side="left", padx=(2, 0))
|
|
||||||
self.titel_var = tk.StringVar(value=titel)
|
|
||||||
tk.Label(self.titelleiste, textvariable=self.titel_var, bg=NAVY, fg=WHITE, font=FONT_BOLD,
|
|
||||||
anchor="w").pack(side="left", fill="x", padx=3)
|
|
||||||
self.inhalt = tk.Frame(self, bg=FACE)
|
|
||||||
self.inhalt.pack(fill="both", expand=True, padx=2, pady=2)
|
|
||||||
|
|
||||||
def set_titel(self, t: str):
|
|
||||||
self.titel_var.set(t)
|
|
||||||
|
|
||||||
|
|
||||||
class Statusleiste(tk.Frame):
|
class Statusleiste(tk.Frame):
|
||||||
def __init__(self, master, felder: list[tuple[str, int]]):
|
def __init__(self, master, felder: list[tuple[str, int]]):
|
||||||
super().__init__(master, bg=FACE)
|
super().__init__(master, bg=FACE)
|
||||||
self.vars: dict[str, tk.StringVar] = {}
|
self.vars: dict[str, tk.StringVar] = {}
|
||||||
for i, (key, breite) in enumerate(felder):
|
for i, (key, breite) in enumerate(felder):
|
||||||
v = tk.StringVar()
|
v = tk.StringVar()
|
||||||
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4)
|
lbl = tk.Label(self, textvariable=v, bd=1, relief="flat" if MODERN else "sunken", anchor="w", padx=4)
|
||||||
if breite:
|
if breite:
|
||||||
lbl.configure(width=breite)
|
lbl.configure(width=breite)
|
||||||
lbl.pack(side="left", padx=(0, 2), pady=2)
|
lbl.pack(side="left", padx=(0, 2), pady=2)
|
||||||
@@ -372,9 +611,19 @@ class Statusleiste(tk.Frame):
|
|||||||
self.vars[key].set(text)
|
self.vars[key].set(text)
|
||||||
|
|
||||||
|
|
||||||
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
|
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None,
|
||||||
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
|
primaer: bool = False, **kw):
|
||||||
activebackground=FACE, **kw)
|
"""primaer: der eine Hauptknopf einer Fläche (modern dunkel-lila gefüllt, Win95 mit dickem Rahmen)."""
|
||||||
|
if MODERN:
|
||||||
|
if primaer:
|
||||||
|
kw = dict(bg=PRIMAER, fg=AUF_PRIMAER, activebackground=NAVY, activeforeground=AUF_PRIMAER,
|
||||||
|
highlightbackground=PRIMAER, font=FONT_BOLD) | kw
|
||||||
|
b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern()
|
||||||
|
else:
|
||||||
|
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
|
||||||
|
activebackground=FACE, **kw)
|
||||||
|
if primaer:
|
||||||
|
b.configure(default="active")
|
||||||
if icon is not None:
|
if icon is not None:
|
||||||
b.configure(image=icon, compound="left", padx=6)
|
b.configure(image=icon, compound="left", padx=6)
|
||||||
if breite:
|
if breite:
|
||||||
@@ -382,25 +631,114 @@ def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, br
|
|||||||
return b
|
return b
|
||||||
|
|
||||||
|
|
||||||
class ToolbarButton(tk.Label):
|
class Karte(tk.Frame):
|
||||||
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick."""
|
"""Fläche, die Inhalte zusammenfasst (Kennzahl, Tabelle): modern weiß mit dünner Linie."""
|
||||||
|
|
||||||
def __init__(self, master, text: str, icon: tk.PhotoImage, command, tooltip: str = ""):
|
def __init__(self, master, **kw):
|
||||||
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
|
if MODERN:
|
||||||
padx=6, pady=2, font=FONT)
|
kw = dict(bg=FACE, bd=0, highlightthickness=1, highlightbackground=RAND, highlightcolor=RAND) | kw
|
||||||
self.command = command
|
else:
|
||||||
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
|
kw = dict(bg=FACE, bd=2, relief="raised") | kw
|
||||||
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
|
super().__init__(master, **kw)
|
||||||
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
|
|
||||||
self.bind("<ButtonRelease-1>", self._release)
|
|
||||||
if tooltip:
|
|
||||||
Tooltip(self, tooltip)
|
|
||||||
|
|
||||||
def _release(self, e):
|
|
||||||
self.configure(relief="raised")
|
def titelleiste(master, text: str = "") -> tuple[tk.Frame, tk.Label]:
|
||||||
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
|
"""Win95: blaue Titelleiste oben in einer Karte („Fenster im Fenster“); rechts passen kleine Knöpfe hinein."""
|
||||||
|
f = tk.Frame(master, bg=NAVY)
|
||||||
|
f.pack(fill="x", padx=1, pady=1)
|
||||||
|
lbl = tk.Label(f, text=text, bg=NAVY, fg=WHITE, font=FONT_BOLD, anchor="w", padx=3)
|
||||||
|
lbl.pack(side="left", pady=1)
|
||||||
|
return f, lbl
|
||||||
|
|
||||||
|
|
||||||
|
def abschnitt(master, text: str, **kw) -> tk.Label:
|
||||||
|
"""Kleine Überschrift (modern in Großbuchstaben und grau, wie die Tabellenköpfe der Webtools)."""
|
||||||
|
if MODERN:
|
||||||
|
return tk.Label(master, text=text.upper(), fg=SHADOW, font=FONT_KLEIN_FETT, anchor="w", **kw)
|
||||||
|
return tk.Label(master, text=text, font=FONT_BOLD, anchor="w", **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _rund(c: tk.Canvas, x0, y0, x1, y1, r, **kw):
|
||||||
|
"""Abgerundetes Rechteck auf einem Canvas."""
|
||||||
|
r = min(r, (x1 - x0) / 2, (y1 - y0) / 2)
|
||||||
|
pkt = [x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r, x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1, x0, y1,
|
||||||
|
x0, y1 - r, x0, y0 + r, x0, y0]
|
||||||
|
return c.create_polygon(pkt, smooth=True, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class Pill(tk.Canvas):
|
||||||
|
"""Umschalter für Filter („Alle“, „Von Hand geändert“ …): modern abgerundet, Win95 als Knopf,
|
||||||
|
der eingedrückt bleibt. command bekommt nichts übergeben; aktiv wird von außen gesetzt."""
|
||||||
|
|
||||||
|
def __init__(self, master, text: str, command=None, aktiv: bool = False, **kw):
|
||||||
|
from tkinter import font as tkfont
|
||||||
|
f = tkfont.Font(root=master, font=FONT)
|
||||||
|
breite, hoehe = f.measure(text) + (24 if MODERN else 16), f.metrics("linespace") + (10 if MODERN else 8)
|
||||||
|
bg = kw.pop("bg", master.cget("bg"))
|
||||||
|
super().__init__(master, width=breite, height=hoehe, bg=bg, highlightthickness=0, bd=0, takefocus=1,
|
||||||
|
cursor="hand2" if MODERN else "", **kw)
|
||||||
|
self.text, self.command, self.aktiv = text, command, aktiv
|
||||||
|
self._zeichnen()
|
||||||
|
self.bind("<Button-1>", lambda e: self._ausloesen())
|
||||||
|
self.bind("<space>", lambda e: self._ausloesen())
|
||||||
|
self.bind("<Return>", lambda e: self._ausloesen())
|
||||||
|
|
||||||
|
def _ausloesen(self):
|
||||||
|
self.focus_set()
|
||||||
|
if self.command:
|
||||||
self.command()
|
self.command()
|
||||||
|
|
||||||
|
def setzen(self, aktiv: bool):
|
||||||
|
if aktiv != self.aktiv:
|
||||||
|
self.aktiv = aktiv
|
||||||
|
self._zeichnen()
|
||||||
|
|
||||||
|
def _zeichnen(self):
|
||||||
|
self.delete("all")
|
||||||
|
b, h = int(self["width"]), int(self["height"])
|
||||||
|
if MODERN:
|
||||||
|
fuell, linie, fg = (PRIMAER, PRIMAER, AUF_PRIMAER) if self.aktiv else (FACE, RAND2, TEXT2)
|
||||||
|
_rund(self, 1, 1, b - 2, h - 2, h, fill=fuell, outline=linie)
|
||||||
|
else:
|
||||||
|
oben, unten = (SHADOW, HILITE) if self.aktiv else (HILITE, SHADOW)
|
||||||
|
self.create_rectangle(0, 0, b - 1, h - 1, fill=LIGHT if self.aktiv else FACE, outline=DARK)
|
||||||
|
self.create_line(1, h - 2, 1, 1, b - 2, 1, fill=oben)
|
||||||
|
self.create_line(1, h - 2, b - 2, h - 2, b - 2, 1, fill=unten)
|
||||||
|
fg = DARK
|
||||||
|
self.create_text(b // 2 + (1 if self.aktiv and not MODERN else 0), h // 2, text=self.text, fill=fg, font=FONT)
|
||||||
|
|
||||||
|
|
||||||
|
def punkt(master, farbe: str, d: int = 9) -> tk.PhotoImage:
|
||||||
|
"""Farbiger Punkt (Status in Listen); Ecken bleiben durchsichtig."""
|
||||||
|
img = tk.PhotoImage(master=master, width=d + 4, height=d)
|
||||||
|
r = d / 2
|
||||||
|
# Win95: grauer Ring, damit der Punkt auf weißem Grund und auf der blauen Auswahl sichtbar ist
|
||||||
|
for f, rr in ((farbe, r),) if MODERN else ((SHADOW, r), (farbe, r - 1)):
|
||||||
|
for y in range(d):
|
||||||
|
xs = [x for x in range(d) if (x + .5 - r) ** 2 + (y + .5 - r) ** 2 <= rr * rr]
|
||||||
|
if xs:
|
||||||
|
img.put(f, to=(xs[0], y, xs[-1] + 1, y + 1))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def logo(master, groesse: int = 34) -> tk.Canvas:
|
||||||
|
"""Logo-Kachel wie in den Webtools: Lila-Verlauf, abgerundet, weißes %."""
|
||||||
|
c = tk.Canvas(master, width=groesse, height=groesse, bg=master.cget("bg"), highlightthickness=0, bd=0)
|
||||||
|
img = tk.PhotoImage(master=master, width=groesse, height=groesse)
|
||||||
|
a, e = (0x2d, 0x1b, 0x69), (0x5e, 0x35, 0xb1)
|
||||||
|
r = groesse * 0.28
|
||||||
|
for y in range(groesse):
|
||||||
|
for x in range(groesse):
|
||||||
|
dx = max(r - x - .5, x + .5 - (groesse - r), 0)
|
||||||
|
dy = max(r - y - .5, y + .5 - (groesse - r), 0)
|
||||||
|
if dx * dx + dy * dy <= r * r:
|
||||||
|
t = (x + y) / (2 * groesse - 2)
|
||||||
|
img.put("#%02x%02x%02x" % tuple(round(a[i] + (e[i] - a[i]) * t) for i in range(3)), (x, y))
|
||||||
|
c.create_image(0, 0, image=img, anchor="nw")
|
||||||
|
c.bild = img # type: ignore[attr-defined] # Referenz halten
|
||||||
|
c.create_text(groesse / 2, groesse / 2, text="%", fill="#ffffff", font=(FONT_FAMILY, int(groesse * .4), "bold"))
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
class Tooltip:
|
class Tooltip:
|
||||||
def __init__(self, widget, text: str):
|
def __init__(self, widget, text: str):
|
||||||
@@ -417,8 +755,12 @@ class Tooltip:
|
|||||||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||||
self.tip = tw = tk.Toplevel(self.widget)
|
self.tip = tw = tk.Toplevel(self.widget)
|
||||||
tw.wm_overrideredirect(True)
|
tw.wm_overrideredirect(True)
|
||||||
tw.wm_geometry(f"+{x}+{y}")
|
tw.withdraw()
|
||||||
tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack()
|
tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack()
|
||||||
|
tw.update_idletasks()
|
||||||
|
# Größe + Position zusammen (reine Position ignoriert Tk unter Windows bei rahmenlosen Fenstern)
|
||||||
|
tw.wm_geometry(f"{tw.winfo_reqwidth()}x{tw.winfo_reqheight()}+{x}+{y}")
|
||||||
|
tw.deiconify()
|
||||||
|
|
||||||
def _weg(self, _e=None):
|
def _weg(self, _e=None):
|
||||||
if self._after:
|
if self._after:
|
||||||
@@ -431,6 +773,10 @@ class Tooltip:
|
|||||||
|
|
||||||
def trenner(master, vertikal: bool = True):
|
def trenner(master, vertikal: bool = True):
|
||||||
f = tk.Frame(master, bg=FACE)
|
f = tk.Frame(master, bg=FACE)
|
||||||
|
if MODERN:
|
||||||
|
tk.Frame(f, width=1, height=1, bg=RAND).pack(side="left", fill="y") if vertikal else \
|
||||||
|
tk.Frame(f, height=1, bg=RAND).pack(fill="x")
|
||||||
|
return f
|
||||||
if vertikal:
|
if vertikal:
|
||||||
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
|
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
|
||||||
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
|
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
|
||||||
@@ -441,18 +787,33 @@ def trenner(master, vertikal: bool = True):
|
|||||||
|
|
||||||
|
|
||||||
def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
|
def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
|
||||||
|
if MODERN:
|
||||||
|
return tk.LabelFrame(master, text=titel, bd=0, relief="flat", bg=FACE, padx=4, pady=2, font=FONT_BOLD, **kw)
|
||||||
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
|
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
|
||||||
|
|
||||||
|
|
||||||
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]:
|
def _bei_bedarf(leiste: ttk.Scrollbar):
|
||||||
rahmen = Sunken(master, bg=WHITE)
|
"""Bildlaufleiste nur zeigen, wenn nicht alles hineinpasst."""
|
||||||
|
def setzen(lo, hi):
|
||||||
|
leiste.grid() if float(lo) > 0 or float(hi) < 1 else leiste.grid_remove()
|
||||||
|
leiste.set(lo, hi)
|
||||||
|
return setzen
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_tree(master, auto: bool = False, **kw) -> tuple[tk.Frame, ttk.Treeview]:
|
||||||
|
"""Treeview mit Bildlaufleisten (modern nur bei Bedarf). auto: keine waagrechte Leiste (Seitenleiste)."""
|
||||||
|
rahmen = Sunken(master, bg=WHITE if MODERN else FACE) # Win95: graue Ecke zwischen den Leisten
|
||||||
tree = ttk.Treeview(rahmen, **kw)
|
tree = ttk.Treeview(rahmen, **kw)
|
||||||
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
|
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
|
||||||
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
|
|
||||||
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
|
|
||||||
tree.grid(row=0, column=0, sticky="nsew")
|
tree.grid(row=0, column=0, sticky="nsew")
|
||||||
vs.grid(row=0, column=1, sticky="ns")
|
vs.grid(row=0, column=1, sticky="ns")
|
||||||
hs.grid(row=1, column=0, sticky="ew")
|
if auto and MODERN:
|
||||||
|
rahmen.configure(highlightthickness=0, bg=FACE)
|
||||||
|
if not auto:
|
||||||
|
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
|
||||||
|
hs.grid(row=1, column=0, sticky="ew")
|
||||||
|
tree.configure(xscrollcommand=_bei_bedarf(hs) if MODERN else hs.set)
|
||||||
|
tree.configure(yscrollcommand=_bei_bedarf(vs) if auto or MODERN else vs.set)
|
||||||
rahmen.rowconfigure(0, weight=1)
|
rahmen.rowconfigure(0, weight=1)
|
||||||
rahmen.columnconfigure(0, weight=1)
|
rahmen.columnconfigure(0, weight=1)
|
||||||
return rahmen, tree
|
return rahmen, tree
|
||||||
@@ -480,40 +841,101 @@ def auto_breite(tree: ttk.Treeview, flex: dict[str, float], reserve: int = 4):
|
|||||||
|
|
||||||
# --------------------------------------------------------------------------- Dialoge
|
# --------------------------------------------------------------------------- Dialoge
|
||||||
|
|
||||||
|
# Klassischer Win95-Rahmen auch für Dialoge (wird von der App gesetzt, abschaltbar in den Einstellungen)
|
||||||
|
KLASSISCH = False
|
||||||
|
|
||||||
|
|
||||||
class Dialog(tk.Toplevel):
|
class Dialog(tk.Toplevel):
|
||||||
"""Modaler Dialog im Win95-Stil."""
|
"""Modaler Dialog im Win95-Stil.
|
||||||
|
|
||||||
|
Modal heißt hier: Der INHALT des aufrufenden Fensters ist gesperrt (siehe modal.py), dessen
|
||||||
|
Titelleiste und Rand bleiben bedienbar. Unter Windows gehört der Dialog dem aufrufenden Fenster
|
||||||
|
(bleibt darüber, kommt mit Alt+Tab nach vorn) und hat einen eigenen Taskleisten-Eintrag.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, master, titel: str, resizable: bool = False):
|
def __init__(self, master, titel: str, resizable: bool = False):
|
||||||
super().__init__(master, bg=FACE)
|
super().__init__(master, bg=FACE)
|
||||||
self.withdraw()
|
self.withdraw()
|
||||||
self.title(titel)
|
self.title(titel)
|
||||||
self.transient(master.winfo_toplevel())
|
if not KLASSISCH:
|
||||||
|
# Rahmenlose Dialoge bekommen ihren Besitzer über win32 – mit transient() würde Tk sie
|
||||||
|
# unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
|
||||||
|
self.transient(master.winfo_toplevel())
|
||||||
self.resizable(resizable, resizable)
|
self.resizable(resizable, resizable)
|
||||||
self.result = None
|
self.result = None
|
||||||
|
self._sperre = None
|
||||||
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
|
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
|
||||||
self.bind("<Escape>", lambda e: self.abbrechen())
|
self.bind("<Escape>", lambda e: self.abbrechen())
|
||||||
try:
|
try:
|
||||||
self.iconphoto(False, master.winfo_toplevel()._app_icon) # type: ignore[attr-defined]
|
self.iconphoto(False, master.winfo_toplevel()._app_icon) # type: ignore[attr-defined]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self.titelleiste = None
|
||||||
|
if KLASSISCH:
|
||||||
|
from provtool.gui.rahmen import Titelleiste
|
||||||
|
self.overrideredirect(True)
|
||||||
|
self.configure(bd=2, relief="raised", padx=2, pady=2)
|
||||||
|
self.titelleiste = Titelleiste(self, self, titel, None, ("close",), schliessen=self.abbrechen)
|
||||||
|
self.titelleiste.pack(side="top", fill="x")
|
||||||
|
|
||||||
def zeigen(self, fokus=None):
|
def zeigen(self, fokus=None):
|
||||||
|
from provtool.gui import modal, win32
|
||||||
|
if getattr(self._root(), "_provtool_schliesst", False):
|
||||||
|
self.destroy() # Programm wird beendet – keine neuen Dialoge mehr
|
||||||
|
return None
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
m = self.master.winfo_toplevel()
|
m = self.master.winfo_toplevel()
|
||||||
|
self._aufrufer = m
|
||||||
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
|
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
|
||||||
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
|
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
|
||||||
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
|
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
|
||||||
self.geometry(f"+{x}+{y}")
|
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
|
||||||
self.deiconify()
|
self.deiconify()
|
||||||
|
self.update_idletasks()
|
||||||
|
if DUNKEL:
|
||||||
|
win32.dunkle_titelleiste(self)
|
||||||
|
if KLASSISCH and win32.IS_WIN:
|
||||||
|
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
|
||||||
|
if win32.taskleisteneintrag(self):
|
||||||
|
self.withdraw()
|
||||||
|
self.deiconify()
|
||||||
|
self.update_idletasks()
|
||||||
|
win32.besitzer_setzen(self, m)
|
||||||
|
self._sperre = modal.Sperre(m, self)
|
||||||
self.lift()
|
self.lift()
|
||||||
try:
|
self.focus_force()
|
||||||
self.grab_set()
|
|
||||||
except tk.TclError:
|
|
||||||
pass
|
|
||||||
(fokus or self).focus_set()
|
(fokus or self).focus_set()
|
||||||
self.wait_window()
|
self.wait_window()
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
|
def nach_vorn(self, blinken: bool = False):
|
||||||
|
"""Dialog nach vorn holen (z. B. nach Klick auf das gesperrte Hauptfenster)."""
|
||||||
|
from provtool.gui import win32
|
||||||
|
try:
|
||||||
|
self.lift()
|
||||||
|
self.focus_force()
|
||||||
|
win32.nach_vorn(self)
|
||||||
|
except tk.TclError:
|
||||||
|
return
|
||||||
|
if blinken:
|
||||||
|
self.bell()
|
||||||
|
win32.blinken(self)
|
||||||
|
if self.titelleiste is not None:
|
||||||
|
self.titelleiste.blinken()
|
||||||
|
|
||||||
|
def destroy(self):
|
||||||
|
sperre = getattr(self, "_sperre", None)
|
||||||
|
if sperre is not None:
|
||||||
|
sperre.aufheben()
|
||||||
|
self._sperre = None
|
||||||
|
aufrufer = getattr(self, "_aufrufer", None)
|
||||||
|
super().destroy()
|
||||||
|
try: # Fokus zurück an das aufrufende Fenster
|
||||||
|
if aufrufer is not None and aufrufer.winfo_exists():
|
||||||
|
aufrufer.focus_force()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
|
||||||
def abbrechen(self):
|
def abbrechen(self):
|
||||||
self.result = None
|
self.result = None
|
||||||
self.destroy()
|
self.destroy()
|
||||||
@@ -521,7 +943,7 @@ class Dialog(tk.Toplevel):
|
|||||||
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
|
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
|
||||||
f = tk.Frame(self, bg=FACE)
|
f = tk.Frame(self, bg=FACE)
|
||||||
for i, (text, cmd) in enumerate(knoepfe):
|
for i, (text, cmd) in enumerate(knoepfe):
|
||||||
b = knopf(f, text, cmd, breite=10)
|
b = knopf(f, text, cmd, breite=10, primaer=MODERN and i == default)
|
||||||
if i == default:
|
if i == default:
|
||||||
b.configure(default="active")
|
b.configure(default="active")
|
||||||
b.pack(side="left", padx=4)
|
b.pack(side="left", padx=4)
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from __future__ import annotations
|
|||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import io
|
import io
|
||||||
from dataclasses import asdict, dataclass, field
|
import itertools
|
||||||
|
from collections.abc import Iterable, Iterator
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -18,6 +20,10 @@ FREENET = "freenet"
|
|||||||
VODAFONE = "vodafone"
|
VODAFONE = "vodafone"
|
||||||
ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"}
|
ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"}
|
||||||
|
|
||||||
|
# Grenzen gegen versehentlich falsche oder kaputte Dateien (echte Abrechnungen: wenige MB, einige tausend Zeilen)
|
||||||
|
MAX_BYTES = 100 * 1024 * 1024 # Dateigröße, bei .xlsx auch entpackt
|
||||||
|
MAX_ZEILEN = 200_000 # Datenzeilen je Tabelle
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Position:
|
class Position:
|
||||||
@@ -39,11 +45,6 @@ class Position:
|
|||||||
quelle: str = ""
|
quelle: str = ""
|
||||||
roh: dict = field(default_factory=dict)
|
roh: dict = field(default_factory=dict)
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
d = asdict(self)
|
|
||||||
d["datum"] = self.datum.isoformat() if self.datum else None
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ImportErgebnis:
|
class ImportErgebnis:
|
||||||
@@ -183,15 +184,24 @@ FORMATE: list[Format] = [FreenetFormat(), VodafoneFormat()]
|
|||||||
|
|
||||||
# --------------------------------------------------------------------------- Tabellen lesen
|
# --------------------------------------------------------------------------- Tabellen lesen
|
||||||
|
|
||||||
def _tabellen_aus_rows(rows: list[list], quelle: str, blatt: str = "") -> tuple[Format, list[Position]] | None:
|
def _tabellen_aus_rows(rows: Iterable[list], quelle: str, blatt: str = "",
|
||||||
"""Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter."""
|
nur_kopf: bool = False) -> tuple[Format, list[Position]] | None:
|
||||||
for idx, row in enumerate(rows[:40]):
|
"""Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter.
|
||||||
|
Ohne Kopfzeile (oder mit nur_kopf) wird der Rest der Tabelle gar nicht gelesen."""
|
||||||
|
rows = iter(rows)
|
||||||
|
anfang = list(itertools.islice(rows, 40))
|
||||||
|
for idx, row in enumerate(anfang):
|
||||||
header = [_norm(c) for c in row]
|
header = [_norm(c) for c in row]
|
||||||
for fmt in FORMATE:
|
for fmt in FORMATE:
|
||||||
if fmt.passt(header):
|
if fmt.passt(header):
|
||||||
pos: list[Position] = []
|
pos: list[Position] = []
|
||||||
|
if nur_kopf:
|
||||||
|
return fmt, pos
|
||||||
leer = 0
|
leer = 0
|
||||||
for data in rows[idx + 1:]:
|
for nr, data in enumerate(itertools.chain(anfang[idx + 1:], rows)):
|
||||||
|
if nr >= MAX_ZEILEN:
|
||||||
|
raise ImportFehler(f"„{quelle}“ hat mehr als {MAX_ZEILEN // 1000}.000 Zeilen – das ist "
|
||||||
|
"keine Provisionsabrechnung.")
|
||||||
if not any(_text(c) for c in data):
|
if not any(_text(c) for c in data):
|
||||||
leer += 1
|
leer += 1
|
||||||
if leer > 20:
|
if leer > 20:
|
||||||
@@ -222,7 +232,7 @@ def _decode(raw: bytes) -> str:
|
|||||||
return raw.decode("latin-1", errors="replace")
|
return raw.decode("latin-1", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
def _lies_csv(path: Path) -> list[tuple[str, list[list]]]:
|
def _lies_csv(path: Path) -> Iterator[tuple[str, Iterable[list]]]:
|
||||||
text = _decode(path.read_bytes())
|
text = _decode(path.read_bytes())
|
||||||
sample = text[:5000]
|
sample = text[:5000]
|
||||||
try:
|
try:
|
||||||
@@ -230,15 +240,20 @@ def _lies_csv(path: Path) -> list[tuple[str, list[list]]]:
|
|||||||
delim = dialect.delimiter
|
delim = dialect.delimiter
|
||||||
except csv.Error:
|
except csv.Error:
|
||||||
delim = ";"
|
delim = ";"
|
||||||
rows = list(csv.reader(io.StringIO(text), delimiter=delim))
|
yield "", csv.reader(io.StringIO(text), delimiter=delim)
|
||||||
return [("", rows)]
|
|
||||||
|
|
||||||
|
|
||||||
def _lies_xlsx(path: Path) -> list[tuple[str, list[list]]]:
|
def _lies_xlsx(path: Path) -> Iterator[tuple[str, Iterable[list]]]:
|
||||||
|
import zipfile
|
||||||
|
|
||||||
import openpyxl
|
import openpyxl
|
||||||
|
with zipfile.ZipFile(path) as z: # entpackte Größe prüfen (eine .xlsx ist ein Zip-Archiv)
|
||||||
|
if sum(i.file_size for i in z.infolist()) > MAX_BYTES:
|
||||||
|
raise ImportFehler(f"„{path.name}“ ist entpackt größer als {MAX_BYTES // 1024 // 1024} MB.")
|
||||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||||
try:
|
try:
|
||||||
return [(ws.title.strip(), [list(r) for r in ws.iter_rows(values_only=True)]) for ws in wb.worksheets]
|
for ws in wb.worksheets: # Zeilen werden erst beim Durchlaufen gelesen (nur Blätter mit Kopfzeile ganz)
|
||||||
|
yield ws.title.strip(), (list(r) for r in ws.iter_rows(values_only=True))
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
|
|
||||||
@@ -265,19 +280,33 @@ def _lies_xls(path: Path) -> list[tuple[str, list[list]]]:
|
|||||||
def lies_datei(path: str | Path) -> ImportErgebnis:
|
def lies_datei(path: str | Path) -> ImportErgebnis:
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
ext = path.suffix.lower()
|
ext = path.suffix.lower()
|
||||||
|
if ext not in (".csv", ".txt", ".xlsx", ".xlsm", ".xls"):
|
||||||
|
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.")
|
||||||
|
if path.stat().st_size > MAX_BYTES:
|
||||||
|
raise ImportFehler(f"„{path.name}“ ist größer als {MAX_BYTES // 1024 // 1024} MB – das ist keine "
|
||||||
|
"Provisionsabrechnung.")
|
||||||
if ext in (".csv", ".txt"):
|
if ext in (".csv", ".txt"):
|
||||||
blaetter = _lies_csv(path)
|
blaetter = _lies_csv(path)
|
||||||
elif ext in (".xlsx", ".xlsm"):
|
elif ext in (".xlsx", ".xlsm"):
|
||||||
blaetter = _lies_xlsx(path)
|
blaetter = _lies_xlsx(path)
|
||||||
elif ext == ".xls":
|
|
||||||
blaetter = _lies_xls(path)
|
|
||||||
else:
|
else:
|
||||||
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.")
|
blaetter = _lies_xls(path)
|
||||||
|
|
||||||
gefunden: list[tuple[str, Format, list[Position]]] = []
|
gefunden: list[tuple[str, Format, list[Position]]] = []
|
||||||
|
ignoriert: list[str] = []
|
||||||
|
erstes = None
|
||||||
for name, rows in blaetter:
|
for name, rows in blaetter:
|
||||||
res = _tabellen_aus_rows(rows, path.name, name)
|
if erstes is None:
|
||||||
if res and res[1]:
|
erstes = name
|
||||||
|
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – weitere Blätter mit Abrechnung
|
||||||
|
# werden nur erkannt (für den Hinweis), aber nicht mehr gelesen
|
||||||
|
vodafone_schon = any(f.key == VodafoneFormat.key for _, f, _ in gefunden)
|
||||||
|
res = _tabellen_aus_rows(rows, path.name, name, nur_kopf=vodafone_schon)
|
||||||
|
if not res:
|
||||||
|
continue
|
||||||
|
if vodafone_schon:
|
||||||
|
ignoriert.append(name)
|
||||||
|
elif res[1]:
|
||||||
gefunden.append((name, res[0], res[1]))
|
gefunden.append((name, res[0], res[1]))
|
||||||
|
|
||||||
if not gefunden:
|
if not gefunden:
|
||||||
@@ -288,11 +317,10 @@ def lies_datei(path: str | Path) -> ImportErgebnis:
|
|||||||
)
|
)
|
||||||
|
|
||||||
warnungen: list[str] = []
|
warnungen: list[str] = []
|
||||||
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – sie enthält alle
|
# Vodafone/Euronics: die erste Seite ("All") enthält alle Abrechnungsläufe, weitere Blätter würden
|
||||||
# Abrechnungsläufe. Weitere Blätter würden Positionen doppelt zählen.
|
# Positionen doppelt zählen.
|
||||||
if any(f.key == VodafoneFormat.key for _, f, _ in gefunden):
|
if any(f.key == VodafoneFormat.key for _, f, _ in gefunden):
|
||||||
erstes = blaetter[0][0]
|
uebrig = [g[0] for g in gefunden if g[0] != erstes] + ignoriert
|
||||||
uebrig = [g[0] for g in gefunden if g[0] != erstes]
|
|
||||||
gefunden = [g for g in gefunden if g[0] == erstes]
|
gefunden = [g for g in gefunden if g[0] == erstes]
|
||||||
if uebrig:
|
if uebrig:
|
||||||
warnungen.append(f"Nur die erste Seite „{erstes}“ übernommen, ignoriert: {', '.join(uebrig)}")
|
warnungen.append(f"Nur die erste Seite „{erstes}“ übernommen, ignoriert: {', '.join(uebrig)}")
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Besondere Provisionsarten: Airtime und Jahresbonus Neuaktivierung (Staffel).
|
||||||
|
|
||||||
|
Typ einer Position
|
||||||
|
------------------
|
||||||
|
- ``normal`` – Auszahlung = Prozent der Provision (Händler-Standard bzw. Regel je Provisionsart)
|
||||||
|
- ``airtime`` – Auszahlung = Airtime-Prozent des Händlers (0 % = keine Airtime). Im Export als eine
|
||||||
|
Summenzeile in der Abrechnung, die Einzelposten in einer eigenen Airtime-Liste.
|
||||||
|
- ``jahresbonus`` – Jahresbonus Neuaktivierung: Betrag je Karte nach Staffel, abhängig davon, wie viele
|
||||||
|
Karten der Händler in der Abrechnung hat (z. B. 1–9 Karten: 5 € je Karte ... ab 40: voll).
|
||||||
|
|
||||||
|
Der Typ wird aus Provisionsart/Dienst/Tarif erkannt und kann je Provisionsart fest eingestellt werden.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
NORMAL, AIRTIME, JAHRESBONUS = "normal", "airtime", "jahresbonus"
|
||||||
|
TYPEN = {NORMAL: "Normale Provision", AIRTIME: "Airtime", JAHRESBONUS: "Jahresbonus Neuaktivierung (Staffel)"}
|
||||||
|
|
||||||
|
# Staffel: [(ab Anzahl Karten, Cent je Karte oder None = voller Betrag laut Abrechnung), ...]
|
||||||
|
Staffel = list[tuple[int, "int | None"]]
|
||||||
|
STANDARD_STAFFEL: Staffel = [(1, 500), (10, 1000), (15, 1500), (20, 2000), (25, 2500), (30, 3000), (35, 3500),
|
||||||
|
(40, None)]
|
||||||
|
|
||||||
|
_AIRTIME_WOERTER = ("airtime", "air time", "air-time", "umsatzbeteiligung")
|
||||||
|
|
||||||
|
|
||||||
|
def _text(*teile) -> str:
|
||||||
|
return " ".join(str(t or "") for t in teile).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def erkennen(provisionsart: str | None, dienst: str | None = None, tarif: str | None = None) -> str:
|
||||||
|
"""Typ aus den Texten der Position erkennen (ohne feste Einstellung)."""
|
||||||
|
art = _text(provisionsart)
|
||||||
|
if "jahresbonus" in art or ("bonus" in art and "neuaktiv" in art):
|
||||||
|
return JAHRESBONUS
|
||||||
|
if any(wort in _text(provisionsart, dienst, tarif) for wort in _AIRTIME_WOERTER):
|
||||||
|
return AIRTIME
|
||||||
|
return NORMAL
|
||||||
|
|
||||||
|
|
||||||
|
def typ_von(p: dict, fest: dict[str, str]) -> str:
|
||||||
|
"""Typ einer Position: feste Einstellung der Provisionsart, sonst automatisch erkannt."""
|
||||||
|
art = p.get("provisionsart") or ""
|
||||||
|
if art in fest:
|
||||||
|
return fest[art]
|
||||||
|
return erkennen(art, p.get("dienst"), p.get("tarif"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Staffel
|
||||||
|
|
||||||
|
def staffel_pruefen(staffel) -> Staffel:
|
||||||
|
"""Normalisiert und prüft eine Staffel (aufsteigend, ab >= 1, Beträge >= 0)."""
|
||||||
|
out: Staffel = []
|
||||||
|
for ab, cent in staffel:
|
||||||
|
ab = int(ab)
|
||||||
|
if ab < 1:
|
||||||
|
raise ValueError("Die Anzahl Karten muss mindestens 1 sein.")
|
||||||
|
if cent is not None:
|
||||||
|
cent = int(cent)
|
||||||
|
if cent < 0:
|
||||||
|
raise ValueError("Der Betrag je Karte darf nicht negativ sein.")
|
||||||
|
out.append((ab, cent))
|
||||||
|
out.sort(key=lambda s: s[0])
|
||||||
|
if len({ab for ab, _ in out}) != len(out):
|
||||||
|
raise ValueError("Jede Stufe braucht eine andere Anzahl Karten.")
|
||||||
|
if not out:
|
||||||
|
raise ValueError("Die Staffel braucht mindestens eine Stufe.")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def staffel_laden(text: str | None) -> Staffel | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return staffel_pruefen(json.loads(text))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def staffel_text(staffel: Staffel) -> str:
|
||||||
|
return json.dumps([[ab, cent] for ab, cent in staffel])
|
||||||
|
|
||||||
|
|
||||||
|
def stufe(staffel: Staffel, karten: int) -> tuple[bool, int | None]:
|
||||||
|
"""(gefunden, Cent je Karte oder None = voll) für die Anzahl Karten."""
|
||||||
|
treffer = None
|
||||||
|
for ab, cent in staffel:
|
||||||
|
if karten >= ab:
|
||||||
|
treffer = (True, cent)
|
||||||
|
return treffer or (False, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def bonus_betrag(staffel: Staffel, karten: int, betrag_orig: int) -> int:
|
||||||
|
"""Auszahlung einer Jahresbonus-Position. Nie mehr als die Provision laut Abrechnung; Stornos
|
||||||
|
(negative Beträge) werden mit derselben Stufe abgezogen."""
|
||||||
|
gefunden, cent = stufe(staffel, karten)
|
||||||
|
if not gefunden:
|
||||||
|
return 0
|
||||||
|
if cent is None:
|
||||||
|
return betrag_orig
|
||||||
|
if betrag_orig >= 0:
|
||||||
|
return min(cent, betrag_orig)
|
||||||
|
return -min(cent, -betrag_orig)
|
||||||
|
|
||||||
|
|
||||||
|
def staffel_beschreibung(staffel: Staffel, kurz: bool = False) -> str:
|
||||||
|
from provtool.money import fmt
|
||||||
|
teile = []
|
||||||
|
for i, (ab, cent) in enumerate(staffel):
|
||||||
|
bis = staffel[i + 1][0] - 1 if i + 1 < len(staffel) else None
|
||||||
|
bereich = f"ab {ab}" if bis is None else (f"{ab}" if bis == ab else f"{ab}-{bis}")
|
||||||
|
wert = "voll" if cent is None else fmt(cent)
|
||||||
|
teile.append(f"{bereich}: {wert}")
|
||||||
|
return (", " if kurz else "\n").join(teile)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""Selbsttest der fertigen Programmdatei: `Provisionsmanager95.exe --selbsttest ergebnis.txt`
|
||||||
|
|
||||||
|
Läuft im Build (Wine + virtueller Bildschirm) gegen die tatsächlich ausgelieferte .exe. Startet die
|
||||||
|
echte Oberfläche in einem Wegwerf-Profil, spielt kritische Abläufe durch und schreibt „OK“ bzw. die
|
||||||
|
gefundenen Fehler in die Ergebnisdatei (Exit-Code 0 = bestanden).
|
||||||
|
|
||||||
|
Geprüft wird u. a. der Fall „Programm schließen, während der Einrichtungs-Assistent offen ist“
|
||||||
|
sowie Import und Export (openpyxl/xlrd müssen in der .exe enthalten sein).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import tkinter as tk
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ZEITLIMIT = 60 # Sekunden je Szenario
|
||||||
|
|
||||||
|
|
||||||
|
class _Szenario:
|
||||||
|
def __init__(self, name: str, home: Path, einstellungen: str | None = None):
|
||||||
|
self.name, self.home, self.fehler = name, home, []
|
||||||
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.environ["PROVTOOL_HOME"] = str(home)
|
||||||
|
if einstellungen:
|
||||||
|
(home / "einstellungen.json").write_text(einstellungen, encoding="utf-8")
|
||||||
|
from provtool.gui.app import App
|
||||||
|
self.root = tk.Tk()
|
||||||
|
self.app = App(self.root)
|
||||||
|
echt = self.root.report_callback_exception
|
||||||
|
|
||||||
|
def melden(exc, val, tb):
|
||||||
|
self.fehler.append("".join(traceback.format_exception(exc, val, tb)))
|
||||||
|
try:
|
||||||
|
echt(exc, val, tb)
|
||||||
|
except Exception:
|
||||||
|
self.fehler.append("Fehler-Handler ist abgestürzt:\n" + traceback.format_exc())
|
||||||
|
self.root.report_callback_exception = melden
|
||||||
|
|
||||||
|
def lebt(self) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(self.root.winfo_exists())
|
||||||
|
except tk.TclError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def dialog(self, typ):
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
gefunden = []
|
||||||
|
|
||||||
|
def suche(widget):
|
||||||
|
for kind in widget.winfo_children():
|
||||||
|
if isinstance(kind, w.Dialog) and isinstance(kind, typ) and kind.winfo_exists():
|
||||||
|
gefunden.append(kind)
|
||||||
|
suche(kind)
|
||||||
|
if self.lebt():
|
||||||
|
suche(self.root)
|
||||||
|
return gefunden[0] if gefunden else None
|
||||||
|
|
||||||
|
def ablauf(self, schritte):
|
||||||
|
offen, start = list(schritte), time.time()
|
||||||
|
|
||||||
|
def poll():
|
||||||
|
if not self.lebt():
|
||||||
|
return
|
||||||
|
if time.time() - start > ZEITLIMIT:
|
||||||
|
self.fehler.append(f"Zeitüberschreitung bei: {offen[0][2] if offen else 'Ende'}")
|
||||||
|
try:
|
||||||
|
self.root.destroy()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if offen:
|
||||||
|
try:
|
||||||
|
bereit = offen[0][0]()
|
||||||
|
except Exception:
|
||||||
|
bereit = False
|
||||||
|
if bereit:
|
||||||
|
_, aktion, _ = offen.pop(0)
|
||||||
|
|
||||||
|
def ausfuehren(a=aktion):
|
||||||
|
try:
|
||||||
|
a()
|
||||||
|
except Exception:
|
||||||
|
self.fehler.append(traceback.format_exc())
|
||||||
|
self.root.after(0, ausfuehren)
|
||||||
|
self.root.after(20, poll)
|
||||||
|
|
||||||
|
self.root.after(20, poll)
|
||||||
|
self.root.mainloop()
|
||||||
|
if offen:
|
||||||
|
self.fehler.append("Nicht ausgeführt: " + ", ".join(s[2] for s in offen))
|
||||||
|
log = self.home / "fehler.log"
|
||||||
|
if log.exists() and log.read_text(encoding="utf-8").strip():
|
||||||
|
self.fehler.append("fehler.log:\n" + log.read_text(encoding="utf-8"))
|
||||||
|
return [f"[{self.name}] {f}" for f in self.fehler]
|
||||||
|
|
||||||
|
|
||||||
|
def _beispiel_csv(pfad: Path):
|
||||||
|
kopf = ("Zeitraum;Verguetungsart;Haendlernummer;Unterhaendler;Vertragsnummer;Aktiv;Deaktiv;Rufnummer;"
|
||||||
|
"Kartennummer;IMEI_SN;Geraetename;Kundenvorname;Kundename;Tarif-Kurz;Tarif;Prozent;Betrag")
|
||||||
|
zeilen = [kopf,
|
||||||
|
"2026.10.01-2026.10.07;SIM-Karten;1;111;1;05.10.2026;;1601111111;;;;;Muster GmbH;;Allnet;0;8",
|
||||||
|
"2026.10.01-2026.10.07;Sonderbonus Aktion;1;111;2;06.10.2026;;1602222222;;;;;Beispiel KG;;Classic;0;170"]
|
||||||
|
pfad.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
|
||||||
|
|
||||||
|
|
||||||
|
def ausfuehren(ergebnisdatei: str) -> int:
|
||||||
|
from provtool.gui.assistent import Einrichtungsassistent
|
||||||
|
basis = Path(tempfile.mkdtemp(prefix="pm95-selbsttest-"))
|
||||||
|
fehler: list[str] = []
|
||||||
|
immer = lambda: True # noqa: E731
|
||||||
|
try:
|
||||||
|
# 1) Gemeldeter Absturz: Schließen, während der Einrichtungs-Assistent offen ist
|
||||||
|
s = _Szenario("Schließen bei offenem Assistenten", basis / "a")
|
||||||
|
cmd = s.root.tk.call("wm", "protocol", s.root._w, "WM_DELETE_WINDOW")
|
||||||
|
fehler += s.ablauf([(lambda: s.dialog(Einrichtungsassistent) is not None, lambda: None, "Assistent erscheint"),
|
||||||
|
(immer, lambda: s.root.tk.call(cmd), "Fenster schließen")])
|
||||||
|
|
||||||
|
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
|
||||||
|
csv = basis / "freenet.csv"
|
||||||
|
_beispiel_csv(csv)
|
||||||
|
# Win95-Design im normalen Fenster: prüft den nativen Rahmen (Szenario 1: modern, startet maximiert)
|
||||||
|
s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95", "vollbild": false}')
|
||||||
|
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
|
||||||
|
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
|
||||||
|
|
||||||
|
def haendler():
|
||||||
|
wiz().v_name.set("Selbsttest-Händler")
|
||||||
|
wiz().v_fn.set("111")
|
||||||
|
wiz()._haendler_dazu()
|
||||||
|
|
||||||
|
def abrechnung():
|
||||||
|
wiz().dateien.append(str(csv))
|
||||||
|
wiz().v_rundgang.set(False)
|
||||||
|
wiz()._weiter()
|
||||||
|
|
||||||
|
def import_bericht_zu():
|
||||||
|
from provtool.gui.dialogs import ImportBericht
|
||||||
|
s.dialog(ImportBericht).abbrechen()
|
||||||
|
|
||||||
|
def export():
|
||||||
|
from provtool import export as exp
|
||||||
|
app = s.app
|
||||||
|
pos = [p for p in app.positionen if p["haendler_id"] > 0]
|
||||||
|
dateien = exp.exportiere_haendler(basis / "export", "Selbsttest", pos, "Test")
|
||||||
|
if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists():
|
||||||
|
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
|
||||||
|
|
||||||
|
from provtool.gui.assistent import TippDesTages
|
||||||
|
from provtool.gui.dialogs import ImportBericht
|
||||||
|
|
||||||
|
def fenster_pruefen():
|
||||||
|
"""Windows: echtes Programmfenster (Andocken, Win+Pfeiltasten) mit unsichtbarem Rahmen."""
|
||||||
|
r = s.app.rahmen
|
||||||
|
if sys.platform.startswith("win") and r.klassisch:
|
||||||
|
if not r.nativ_an:
|
||||||
|
s.fehler.append("Natives Fenster (Andocken/Win+Pfeiltasten) wurde nicht eingerichtet")
|
||||||
|
elif not r.nativ.innen_gleich_aussen():
|
||||||
|
s.fehler.append("Windows-Rahmen des Hauptfensters ist sichtbar")
|
||||||
|
|
||||||
|
def tipp_pruefen():
|
||||||
|
d = s.dialog(TippDesTages)
|
||||||
|
if not d.bild_ok:
|
||||||
|
s.fehler.append("Bild für den Tipp des Tages fehlt in der .exe (provtool/gui/bilder/tipp.png)")
|
||||||
|
d.abbrechen()
|
||||||
|
|
||||||
|
fehler += s.ablauf([
|
||||||
|
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
|
||||||
|
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
|
||||||
|
(seite(2), haendler, "Händler anlegen"),
|
||||||
|
(lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler -> weiter"),
|
||||||
|
(seite(3), abrechnung, "Abrechnung mit Datei"),
|
||||||
|
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
|
||||||
|
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
|
||||||
|
(lambda: len(s.app.positionen) == 2, export, "Export"),
|
||||||
|
(immer, fenster_pruefen, "Fensterrahmen"),
|
||||||
|
(immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"),
|
||||||
|
(lambda: s.dialog(TippDesTages) is not None, tipp_pruefen, "Tipp-Bild in der .exe"),
|
||||||
|
(lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"),
|
||||||
|
(lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"),
|
||||||
|
])
|
||||||
|
except Exception:
|
||||||
|
fehler.append(traceback.format_exc())
|
||||||
|
|
||||||
|
Path(ergebnisdatei).write_text("OK\n" if not fehler else "FEHLER\n\n" + "\n\n".join(fehler), encoding="utf-8")
|
||||||
|
return 0 if not fehler else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(ausfuehren(sys.argv[1] if len(sys.argv) > 1 else "selbsttest.txt"))
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Updates: neuestes Release auf Gitea finden und das Windows-Setup herunterladen.
|
||||||
|
|
||||||
|
Nur Netzwerk und Dateien – kein Tk (läuft im Hintergrund-Thread). Das Installieren startet die App (gui/app.py).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
HOST = "git.handy-insel.de"
|
||||||
|
API = f"https://{HOST}/api/v1/repos/kerimatilgan/provisionsmanager95/releases/latest"
|
||||||
|
SETUP = re.compile(r"Provisionsmanager-Setup-[\d.]+\.exe")
|
||||||
|
MAX_BYTES = 200 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateFehler(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def version_tupel(v: str) -> tuple[int, ...]:
|
||||||
|
"""'v1.10.0' -> (1, 10, 0); Unlesbares -> ()"""
|
||||||
|
m = re.fullmatch(r"v?(\d+(?:\.\d+)*)", (v or "").strip())
|
||||||
|
return tuple(int(x) for x in m[1].split(".")) if m else ()
|
||||||
|
|
||||||
|
|
||||||
|
def ist_neuer(neu: str, alt: str) -> bool:
|
||||||
|
return version_tupel(neu) > version_tupel(alt) and bool(version_tupel(alt))
|
||||||
|
|
||||||
|
|
||||||
|
def _eigene_url(url: str) -> bool:
|
||||||
|
"""Nur über HTTPS vom eigenen Gitea laden (die Antwort des Servers ist hier die Vertrauensgrenze)."""
|
||||||
|
u = urlparse(url)
|
||||||
|
return u.scheme == "https" and u.hostname == HOST
|
||||||
|
|
||||||
|
|
||||||
|
def neueste(timeout: float = 8) -> dict:
|
||||||
|
"""Neuestes Release: {"version", "seite", "setup_url", "groesse"} (setup_url None, wenn kein Setup dabei)."""
|
||||||
|
with urllib.request.urlopen(API, timeout=timeout) as r:
|
||||||
|
daten = json.loads(r.read(1024 * 1024).decode("utf-8"))
|
||||||
|
setup = next((a for a in daten.get("assets", []) if SETUP.fullmatch(a.get("name", ""))), None)
|
||||||
|
url = setup.get("browser_download_url") if setup else None
|
||||||
|
if url and not _eigene_url(url):
|
||||||
|
url = None
|
||||||
|
seite = daten.get("html_url") or ""
|
||||||
|
return {"version": str(daten.get("tag_name", "")).lstrip("v"),
|
||||||
|
"seite": seite if _eigene_url(seite) else f"https://{HOST}/kerimatilgan/provisionsmanager95/releases",
|
||||||
|
"setup_url": url, "groesse": int(setup.get("size") or 0) if setup else 0}
|
||||||
|
|
||||||
|
|
||||||
|
def herunterladen(info: dict, ordner: Path, timeout: float = 60) -> Path:
|
||||||
|
"""Setup nach `ordner` laden und prüfen (Größe wie angekündigt, Windows-Programm). Gibt den Pfad zurück."""
|
||||||
|
url = info.get("setup_url")
|
||||||
|
if not url or not _eigene_url(url):
|
||||||
|
raise UpdateFehler("Für diese Version gibt es kein Setup zum Herunterladen.")
|
||||||
|
ordner.mkdir(parents=True, exist_ok=True)
|
||||||
|
ziel = ordner / Path(urlparse(url).path).name
|
||||||
|
if not SETUP.fullmatch(ziel.name):
|
||||||
|
raise UpdateFehler(f"Unerwarteter Dateiname: {ziel.name}")
|
||||||
|
teil = ziel.with_suffix(".teil")
|
||||||
|
n = 0
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=timeout) as r, teil.open("wb") as f:
|
||||||
|
if not _eigene_url(r.geturl()): # Weiterleitung auf einen fremden Server
|
||||||
|
raise UpdateFehler("Download wurde auf einen fremden Server umgeleitet.")
|
||||||
|
while block := r.read(1 << 16):
|
||||||
|
n += len(block)
|
||||||
|
if n > MAX_BYTES:
|
||||||
|
raise UpdateFehler("Download ist unerwartet groß.")
|
||||||
|
f.write(block)
|
||||||
|
except BaseException:
|
||||||
|
teil.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
with teil.open("rb") as f:
|
||||||
|
kopf = f.read(2)
|
||||||
|
if kopf != b"MZ" or (info.get("groesse") and n != info["groesse"]):
|
||||||
|
teil.unlink(missing_ok=True)
|
||||||
|
raise UpdateFehler("Download ist unvollständig oder beschädigt – bitte später erneut versuchen.")
|
||||||
|
teil.replace(ziel)
|
||||||
|
return ziel
|
||||||
@@ -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)
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
pythonpath = .
|
pythonpath = .
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
|
# Kein Test darf den Build blockieren: nach 90 s wird abgebrochen und der Aufrufstapel aller
|
||||||
|
# Threads ausgegeben (zeigt, wo es hängt – auch in Windows-Systemaufrufen, wo der Tk-Watchdog
|
||||||
|
# der GUI-Tests nicht greift).
|
||||||
|
timeout = 90
|
||||||
|
timeout_method = thread
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
-r requirements.txt
|
-r requirements.txt
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
pyinstaller==6.11.1
|
pyinstaller==6.11.1
|
||||||
|
pytest-timeout==2.3.1
|
||||||
@@ -159,9 +159,22 @@ def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path):
|
|||||||
|
|
||||||
# Export
|
# Export
|
||||||
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
|
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
|
||||||
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026")
|
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026", pro_anbieter=True)
|
||||||
assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx",
|
assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx",
|
||||||
"Händler A - Vodafone - Oktober 2026.xlsx"]
|
"Händler A - Vodafone - Oktober 2026.xlsx"]
|
||||||
|
# Standard: freenet UND Vodafone in einer Datei (gemeldet: Vodafone fehlte im Export)
|
||||||
|
eine = export.exportiere_haendler(tmp_path / "out1", "Händler A", eigene, "Oktober 2026")
|
||||||
|
assert [d.name for d in eine] == ["Händler A - Oktober 2026.xlsx"]
|
||||||
|
zeilen1 = [r for r in openpyxl.load_workbook(eine[0]).active.iter_rows(values_only=True) if r[1]]
|
||||||
|
arten = [r[1] for r in zeilen1]
|
||||||
|
assert "Freischaltungsvergütung Vodafone" in arten and "SIM-Karten" in arten
|
||||||
|
# Spalte A: Vodafone mit Aktivierungsdatum, freenet mit Zeitraum
|
||||||
|
vf = next(r for r in zeilen1 if r[1] == "Freischaltungsvergütung Vodafone")
|
||||||
|
assert vf[0] == datetime(2026, 9, 8)
|
||||||
|
assert next(r for r in zeilen1 if r[1] == "SIM-Karten")[0] == "2026.10.01-2026.10.07"
|
||||||
|
ws1 = openpyxl.load_workbook(eine[0]).active
|
||||||
|
zeile_vf = next(c.row for c in ws1["B"] if c.value == "Freischaltungsvergütung Vodafone")
|
||||||
|
assert ws1.cell(zeile_vf, 1).number_format == "DD.MM.YYYY"
|
||||||
ws = openpyxl.load_workbook(dateien[0]).active
|
ws = openpyxl.load_workbook(dateien[0]).active
|
||||||
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
|
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
|
||||||
# Sortierung Zeitraum → Provisionsart → Datum
|
# Sortierung Zeitraum → Provisionsart → Datum
|
||||||
@@ -265,3 +278,486 @@ def test_migration_alter_datenbank(tmp_path):
|
|||||||
spalten = {r[1] for r in d.con.execute("PRAGMA table_info(position)")}
|
spalten = {r[1] for r in d.con.execute("PRAGMA table_info(position)")}
|
||||||
assert "haendler_fest" in spalten
|
assert "haendler_fest" in spalten
|
||||||
d.close()
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_eigene_kennungen_ausblenden(db, freenet_csv):
|
||||||
|
from provtool.db import EIGEN
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")])
|
||||||
|
aid = db.abrechnung_neu("X")
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
assert {p["haendler_id"] for p in db.positionen(aid)} == {hid, UNZUGEORDNET}
|
||||||
|
db.eigene_setzen("freenet", "999")
|
||||||
|
ids = {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}
|
||||||
|
assert ids["999"] == EIGEN and ids["111"] == hid
|
||||||
|
# Händler-Kennung hat Vorrang vor „eigen“
|
||||||
|
db.eigene_setzen("freenet", "111")
|
||||||
|
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid
|
||||||
|
db.eigene_setzen("freenet", "999", False)
|
||||||
|
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["999"] == UNZUGEORDNET
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Airtime / Jahresbonus
|
||||||
|
|
||||||
|
def _pos(art, betrag, unterh="111", rufnr="1601111111", name="Kunde", dienst="Teilnehmerverhaeltnis"):
|
||||||
|
z = freenet_zeile(art, unterh, "05.10.2026", rufnr, name, "Tarif", betrag).split(";")
|
||||||
|
z[24] = dienst
|
||||||
|
return ";".join(z)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bonus_csv(tmp_path):
|
||||||
|
zeilen = [FREENET_KOPF,
|
||||||
|
_pos("Grundvergütung Mobilfunk Credit freenet", "50"),
|
||||||
|
_pos("Grundvergütung Mobilfunk Credit freenet", "0"),
|
||||||
|
_pos("Prepaidvergütung", "20", dienst="AirTime Provision"),
|
||||||
|
_pos("Airtime Umsatzbeteiligung", "10,50")]
|
||||||
|
zeilen += [_pos("Jahresbonus Neuaktivierung", "40", rufnr=f"16000000{i:02d}") for i in range(12)]
|
||||||
|
zeilen += [_pos("Jahresbonus Neuaktivierung", "3", rufnr="1600000099")] # weniger als die Stufe
|
||||||
|
p = tmp_path / "bonus.csv"
|
||||||
|
p.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_typ_erkennung():
|
||||||
|
from provtool import regeln
|
||||||
|
assert regeln.erkennen("Prepaidvergütung Vodafone", "AirTime Provision") == regeln.AIRTIME
|
||||||
|
assert regeln.erkennen("Airtime freenet") == regeln.AIRTIME
|
||||||
|
assert regeln.erkennen("Jahresbonus Neuaktivierung") == regeln.JAHRESBONUS
|
||||||
|
assert regeln.erkennen("SIM-Karten", "Teilnehmerverhaeltnis") == regeln.NORMAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_staffel_stufen():
|
||||||
|
from provtool import regeln
|
||||||
|
st = regeln.STANDARD_STAFFEL
|
||||||
|
assert regeln.bonus_betrag(st, 0, 4000) == 0
|
||||||
|
assert regeln.bonus_betrag(st, 1, 4000) == 500 and regeln.bonus_betrag(st, 9, 4000) == 500
|
||||||
|
assert regeln.bonus_betrag(st, 10, 4000) == 1000 and regeln.bonus_betrag(st, 14, 4000) == 1000
|
||||||
|
assert regeln.bonus_betrag(st, 39, 4000) == 3500
|
||||||
|
assert regeln.bonus_betrag(st, 40, 4000) == 4000 and regeln.bonus_betrag(st, 80, 6000) == 6000 # voll
|
||||||
|
assert regeln.bonus_betrag(st, 25, 1000) == 1000 # nie mehr als laut Abrechnung
|
||||||
|
assert regeln.bonus_betrag(st, 12, -4000) == -1000 # Storno mit derselben Stufe
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
regeln.staffel_pruefen([(1, 500), (1, 600)])
|
||||||
|
assert "1-9: 5,00 €" in regeln.staffel_beschreibung(st) and "ab 40: voll" in regeln.staffel_beschreibung(st)
|
||||||
|
|
||||||
|
|
||||||
|
def test_airtime_und_jahresbonus(db, bonus_csv, tmp_path):
|
||||||
|
from provtool import regeln
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "Sub", standard_prozent=100), [("freenet", "111")])
|
||||||
|
aid = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(aid, lies_datei(bonus_csv))
|
||||||
|
pos = db.positionen(aid)
|
||||||
|
air = [p for p in pos if p["typ"] == regeln.AIRTIME]
|
||||||
|
bonus = [p for p in pos if p["typ"] == regeln.JAHRESBONUS]
|
||||||
|
assert len(air) == 2 and len(bonus) == 13
|
||||||
|
assert all(p["betrag"] == 0 for p in air), "Standard: 0 % Airtime = keine Airtime"
|
||||||
|
# 13 Karten -> Stufe 10-14 = 10 € je Karte (die 3-€-Position bleibt bei 3 €)
|
||||||
|
assert sorted(p["betrag"] for p in bonus) == [300] + [1000] * 12
|
||||||
|
assert all(p["prozent"] is None for p in bonus)
|
||||||
|
|
||||||
|
# Airtime 50 %, eigene Staffel
|
||||||
|
h = db.haendler(hid)
|
||||||
|
h.airtime_prozent = 50
|
||||||
|
h.staffel = [(1, 200), (13, None)]
|
||||||
|
db.haendler_speichern(h, db.kennungen(hid))
|
||||||
|
db.neu_berechnen(aid)
|
||||||
|
pos = db.positionen(aid)
|
||||||
|
assert sorted(p["betrag"] for p in pos if p["typ"] == regeln.AIRTIME) == [525, 1000]
|
||||||
|
assert sorted(p["betrag"] for p in pos if p["typ"] == regeln.JAHRESBONUS) == [300] + [4000] * 12
|
||||||
|
|
||||||
|
# Zuweisung ändert die Anzahl Karten -> Staffel der übrigen wird neu berechnet
|
||||||
|
weg = [p["id"] for p in pos if p["typ"] == regeln.JAHRESBONUS][:2]
|
||||||
|
db.haendler_zuweisen(weg, UNZUGEORDNET)
|
||||||
|
rest = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and p["haendler_id"] == hid]
|
||||||
|
assert len(rest) == 11 and all(p["betrag"] == min(200, p["betrag_orig"]) for p in rest)
|
||||||
|
|
||||||
|
# Typ fest einstellen
|
||||||
|
db.art_typ_setzen("Airtime Umsatzbeteiligung", regeln.NORMAL)
|
||||||
|
assert any(a == "Airtime Umsatzbeteiligung" for a, _, _ in db.provisionsarten())
|
||||||
|
db.neu_berechnen(aid)
|
||||||
|
p = next(p for p in db.positionen(aid) if p["provisionsart"] == "Airtime Umsatzbeteiligung")
|
||||||
|
assert p["typ"] == regeln.NORMAL and p["betrag"] == 1050
|
||||||
|
db.art_typ_setzen("Airtime Umsatzbeteiligung", None)
|
||||||
|
|
||||||
|
# Export: Abrechnung mit Airtime-Summenzeile + eigene Airtime-Liste, 0-€-Positionen weg
|
||||||
|
db.neu_berechnen(aid)
|
||||||
|
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
|
||||||
|
dateien = export.exportiere_haendler(tmp_path / "out", "Sub", eigene, "Okt")
|
||||||
|
assert [d.name for d in dateien] == ["Sub - Okt.xlsx", "Sub - Airtime - Okt.xlsx"]
|
||||||
|
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True)]
|
||||||
|
daten = [r for r in zeilen if r[1]]
|
||||||
|
assert all(r[10] != 0 for r in daten), "0-€-Positionen dürfen nicht im Export stehen"
|
||||||
|
assert daten[-1][1] == "Airtime" and daten[-1][10] == 15.25
|
||||||
|
assert not any(r[1] == "Prepaidvergütung" for r in daten), "Airtime-Einzelposten gehören in die eigene Liste"
|
||||||
|
air_zeilen = [r for r in openpyxl.load_workbook(dateien[1]).active.iter_rows(values_only=True) if r[1]]
|
||||||
|
assert sorted(r[10] for r in air_zeilen) == [5.25, 10.0]
|
||||||
|
|
||||||
|
# 0 % Airtime: keine Airtime-Zeile, keine Airtime-Datei
|
||||||
|
h = db.haendler(hid)
|
||||||
|
h.airtime_prozent = 0
|
||||||
|
db.haendler_speichern(h, db.kennungen(hid))
|
||||||
|
db.neu_berechnen(aid)
|
||||||
|
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
|
||||||
|
dateien = export.exportiere_haendler(tmp_path / "out0", "Sub", eigene, "Okt")
|
||||||
|
assert [d.name for d in dateien] == ["Sub - Okt.xlsx"]
|
||||||
|
assert not any(r[1] == "Airtime" for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_v2_haendler_ohne_airtime(tmp_path):
|
||||||
|
import sqlite3
|
||||||
|
pfad = tmp_path / "v2.db"
|
||||||
|
con = sqlite3.connect(pfad)
|
||||||
|
con.executescript("""CREATE TABLE haendler (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
ansprechpartner TEXT DEFAULT '', email TEXT DEFAULT '', notiz TEXT DEFAULT '',
|
||||||
|
standard_prozent REAL NOT NULL DEFAULT 100, aktiv INTEGER NOT NULL DEFAULT 1);
|
||||||
|
INSERT INTO haendler (name, standard_prozent) VALUES ('Alt', 90);""")
|
||||||
|
con.close()
|
||||||
|
d = Datenbank(pfad)
|
||||||
|
h = d.haendler_liste()[0]
|
||||||
|
assert h.name == "Alt" and h.airtime_prozent == 0 and h.staffel is None
|
||||||
|
assert d.staffel_fuer(h.id) == d.standard_staffel()
|
||||||
|
d.standard_staffel_setzen([(1, 100), (5, None)])
|
||||||
|
assert d.staffel_fuer(h.id) == [(1, 100), (5, None)]
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Zeitraum / abgerechnet / erledigt
|
||||||
|
|
||||||
|
def test_zeitraum_lesen_und_bezugsdatum():
|
||||||
|
from datetime import date
|
||||||
|
from provtool import zeitraum as zr
|
||||||
|
assert zr.datum_lesen("1.10.2026") == date(2026, 10, 1) and zr.datum_lesen("01.10.26") == date(2026, 10, 1)
|
||||||
|
assert zr.datum_lesen("") is None
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
zr.datum_lesen("32.13.2026")
|
||||||
|
assert zr.abrechnungsdatum("2026.10.08-2026.10.15") == date(2026, 10, 8)
|
||||||
|
assert zr.abrechnungsdatum("VF2609") == date(2026, 9, 1) == zr.abrechnungsdatum("CY202609UM")
|
||||||
|
assert zr.abrechnungsdatum("VF2609P6 Fil 0") == date(2026, 9, 1)
|
||||||
|
p = {"zeitraum": "VF2609", "datum": "2026-08-30"}
|
||||||
|
assert zr.bezugsdatum(p, zr.AKTIVIERUNG) == date(2026, 8, 30)
|
||||||
|
assert zr.bezugsdatum(p, zr.ABRECHNUNG) == date(2026, 9, 1)
|
||||||
|
okt = zr.Zeitraum(date(2026, 10, 1), date(2026, 10, 31))
|
||||||
|
assert okt.enthaelt({"datum": "2026-10-31"}) and not okt.enthaelt({"datum": "2026-11-01"})
|
||||||
|
assert not okt.enthaelt({"datum": None})
|
||||||
|
assert zr.vormonat(date(2026, 3, 15)) == (date(2026, 2, 1), date(2026, 2, 28))
|
||||||
|
assert zr.dieser_monat(date(2026, 12, 5)) == (date(2026, 12, 1), date(2026, 12, 31))
|
||||||
|
|
||||||
|
|
||||||
|
def test_abgerechnet_einfrieren_und_doppelt_erkennen(db, freenet_csv):
|
||||||
|
from provtool.db import AbrechnungErledigt
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
|
||||||
|
a1 = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(a1, lies_datei(freenet_csv))
|
||||||
|
pos = [p for p in db.positionen(a1) if p["haendler_id"] == hid]
|
||||||
|
assert len(pos) == 4 and all(p["fp"] for p in pos)
|
||||||
|
erste = pos[:2]
|
||||||
|
assert db.abrechnen([p["id"] for p in erste]) == 2
|
||||||
|
# abgerechnet: Betrag und Händler eingefroren
|
||||||
|
h = db.haendler(hid)
|
||||||
|
h.standard_prozent = 50
|
||||||
|
db.haendler_speichern(h, []) # Kennung weg, 50 %
|
||||||
|
db.neu_berechnen(a1)
|
||||||
|
nach = {p["id"]: p for p in db.positionen(a1)}
|
||||||
|
for p in erste:
|
||||||
|
assert nach[p["id"]]["haendler_id"] == hid and nach[p["id"]]["betrag"] == p["betrag"]
|
||||||
|
with pytest.raises(AbrechnungErledigt):
|
||||||
|
db.betrag_setzen(erste[0]["id"], 1)
|
||||||
|
assert db.prozent_anwenden([erste[0]["id"]], 10) == 0
|
||||||
|
|
||||||
|
# dieselbe Datei in einer neuen Abrechnung: die beiden abgerechneten Positionen sind „doppelt“
|
||||||
|
db.haendler_speichern(db.haendler(hid), [("freenet", "111")])
|
||||||
|
a2 = db.abrechnung_neu("Nov")
|
||||||
|
db.importieren(a2, lies_datei(freenet_csv))
|
||||||
|
# (2 davon schon abgerechnet, die 3 anderen sind in „Okt“ noch offen – auch die zählen nur einmal)
|
||||||
|
doppelt = [p for p in db.positionen(a2) if p["doppelt"]]
|
||||||
|
assert len(doppelt) == 5 and all(p["doppelt"] == "Okt" for p in doppelt)
|
||||||
|
assert db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden"
|
||||||
|
|
||||||
|
# Kennzeichnung aufheben -> die Positionen in „Okt“ sind wieder offen und bleiben die zuerst importierten;
|
||||||
|
# „Nov“ bleibt doppelt. Erst wenn „Okt“ weg ist, ist „Nov“ frei.
|
||||||
|
assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2
|
||||||
|
assert all(p["doppelt"] == "Okt" for p in db.positionen(a2))
|
||||||
|
db.abrechnung_loeschen(a1)
|
||||||
|
assert not any(p["doppelt"] for p in db.positionen(a2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_abrechnung_erledigt_sperrt(db, freenet_csv):
|
||||||
|
from provtool.db import AbrechnungErledigt
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
|
||||||
|
aid = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
assert db.erledigt_setzen(aid, True) == 4 # nur die 4 Händler-Positionen, nicht die unzugeordnete
|
||||||
|
assert db.erledigt(aid)
|
||||||
|
assert all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid)
|
||||||
|
with pytest.raises(AbrechnungErledigt):
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
with pytest.raises(AbrechnungErledigt):
|
||||||
|
db.datei_entfernen(db.dateien(aid)[0]["id"])
|
||||||
|
unz = next(p for p in db.positionen(aid) if p["haendler_id"] <= 0)
|
||||||
|
db.haendler_zuweisen([unz["id"]], hid)
|
||||||
|
assert next(p for p in db.positionen(aid) if p["id"] == unz["id"])["haendler_id"] <= 0, "erledigt: keine Änderung"
|
||||||
|
assert db.abrechnungen()[0]["abgerechnet"] == 4
|
||||||
|
# wieder öffnen, Positionen bleiben abgerechnet bzw. werden auf Wunsch freigegeben
|
||||||
|
db.erledigt_setzen(aid, False)
|
||||||
|
assert not db.erledigt(aid) and all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid)
|
||||||
|
db.erledigt_setzen(aid, True)
|
||||||
|
db.erledigt_setzen(aid, False, positionen_aufheben=True)
|
||||||
|
assert not any(p["abgerechnet"] for p in db.positionen(aid))
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_v3_fingerabdruck(tmp_path, freenet_csv):
|
||||||
|
import sqlite3
|
||||||
|
d = Datenbank(tmp_path / "x.db")
|
||||||
|
aid = d.abrechnung_neu("Alt")
|
||||||
|
d.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
d.close()
|
||||||
|
con = sqlite3.connect(tmp_path / "x.db") # wie eine v3-Datenbank: ohne Fingerabdrücke
|
||||||
|
con.execute("UPDATE position SET fp=NULL")
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
d = Datenbank(tmp_path / "x.db")
|
||||||
|
assert all(p["fp"] for p in d.positionen(aid))
|
||||||
|
assert len({p["fp"] for p in d.positionen(aid)}) == 5
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Audit-Befunde (Nachtests)
|
||||||
|
|
||||||
|
def test_offene_doppelte_nur_einmal_exportieren_und_abrechnen(db, freenet_csv, tmp_path):
|
||||||
|
"""Wochen- und Monatsdatei mit denselben Zeilen in EINER Abrechnung: die zweite ist „doppelt“ und
|
||||||
|
wird weder exportiert noch abgerechnet. Zwei gleiche Zeilen in EINER Datei bleiben zwei Positionen."""
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
|
||||||
|
aid = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
monat = tmp_path / "monat.csv"
|
||||||
|
monat.write_bytes(freenet_csv.read_bytes() + freenet_csv.read_bytes().split(b"\r\n", 1)[1]) # Zeilen 2x
|
||||||
|
db.importieren(aid, lies_datei(monat))
|
||||||
|
pos = db.positionen(aid)
|
||||||
|
assert len(pos) == 15
|
||||||
|
erste_datei = [p for p in pos if p["datei_id"] == db.dateien(aid)[0]["id"]]
|
||||||
|
assert not any(p["doppelt"] for p in erste_datei), "die zuerst importierte Datei zählt"
|
||||||
|
zweite = [p for p in pos if p["datei_id"] != db.dateien(aid)[0]["id"]]
|
||||||
|
assert sum(1 for p in zweite if p["doppelt"] == "Okt") == 5, "je Zeile ist nur die erste Wiederholung doppelt"
|
||||||
|
assert sum(1 for p in zweite if not p["doppelt"]) == 5, "zweite Vorkommen innerhalb der Datei (#2) sind eigene Positionen"
|
||||||
|
# abrechnen aller Händler-Positionen: Doppelte bleiben offen
|
||||||
|
n = db.abrechnen([p["id"] for p in pos])
|
||||||
|
offen_haendler = [p for p in db.positionen(aid) if p["haendler_id"] == hid and not p["abgerechnet"]]
|
||||||
|
assert n == 8 and all(p["doppelt"] for p in offen_haendler)
|
||||||
|
# und noch einmal: derselbe Fingerabdruck wird in der Transaktion erneut geprüft
|
||||||
|
assert db.abrechnen([p["id"] for p in db.positionen(aid)]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_staffel_ohne_doppelte_und_datei_entfernen_rechnet_neu(db, bonus_csv, tmp_path):
|
||||||
|
from provtool import regeln
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "Sub"), [("freenet", "111")])
|
||||||
|
aid = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(aid, lies_datei(bonus_csv))
|
||||||
|
kopie = tmp_path / "bonus2.csv"
|
||||||
|
kopie.write_bytes(bonus_csv.read_bytes())
|
||||||
|
db.importieren(aid, lies_datei(kopie))
|
||||||
|
bonus = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and p["haendler_id"] == hid]
|
||||||
|
assert len(bonus) == 26 and sum(1 for p in bonus if p["doppelt"]) == 13
|
||||||
|
echte = [p for p in bonus if not p["doppelt"]]
|
||||||
|
assert sorted(p["betrag"] for p in echte) == [300] + [1000] * 12, "Doppelte zählen nicht als Karten (13, nicht 26)"
|
||||||
|
# zweite Datei (3 Zeilen) zusätzlich mit anderen Rufnummern -> 16 Karten -> Stufe 15: 15 € je Karte
|
||||||
|
extra = tmp_path / "extra.csv"
|
||||||
|
extra.write_bytes(("\r\n".join([FREENET_KOPF] + [_pos("Jahresbonus Neuaktivierung", "40", rufnr=f"17000000{i:02d}")
|
||||||
|
for i in range(3)]) + "\r\n").encode("cp1252"))
|
||||||
|
db.importieren(aid, lies_datei(extra))
|
||||||
|
echte = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and not p["doppelt"]]
|
||||||
|
assert sorted(p["betrag"] for p in echte) == [300] + [1500] * 15
|
||||||
|
# Datei entfernen -> Staffel der übrigen wieder 10 € je Karte
|
||||||
|
db.datei_entfernen(db.dateien(aid)[-1]["id"])
|
||||||
|
echte = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and not p["doppelt"]]
|
||||||
|
assert sorted(p["betrag"] for p in echte) == [300] + [1000] * 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_abgerechnete_positionen_nicht_loeschbar(db, freenet_csv):
|
||||||
|
"""Datei/Abrechnung mit abgerechneten Positionen: Löschen verweigert (sonst Fingerabdruck-Leichen
|
||||||
|
oder doppelte Auszahlung). Nach Aufheben der Kennzeichnung geht es – und hinterlässt keine Fingerabdrücke."""
|
||||||
|
from provtool.db import AbrechnungErledigt
|
||||||
|
db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
|
||||||
|
aid = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
pos = db.positionen(aid)
|
||||||
|
assert db.abrechnen([pos[0]["id"]]) == 1
|
||||||
|
with pytest.raises(AbrechnungErledigt):
|
||||||
|
db.datei_entfernen(db.dateien(aid)[0]["id"])
|
||||||
|
with pytest.raises(AbrechnungErledigt):
|
||||||
|
db.abrechnung_loeschen(aid)
|
||||||
|
assert len(db.positionen(aid)) == 5
|
||||||
|
db.abrechnung_aufheben([pos[0]["id"]])
|
||||||
|
db.abrechnung_loeschen(aid)
|
||||||
|
assert db.con.execute("SELECT count(*) FROM abgerechnet_fp").fetchone()[0] == 0
|
||||||
|
a2 = db.abrechnung_neu("Nov")
|
||||||
|
db.importieren(a2, lies_datei(freenet_csv))
|
||||||
|
assert not any(p["doppelt"] for p in db.positionen(a2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_erledigt_offene_positionen_bekommen_keinen_haendler_mehr(db, freenet_csv):
|
||||||
|
"""Erledigt: eine damals nicht zugeordnete Position darf einem später angelegten Händler nicht mit
|
||||||
|
100 % zur Auszahlung erscheinen; abrechnen() fasst erledigte Abrechnungen nicht an."""
|
||||||
|
db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
|
||||||
|
aid = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
db.erledigt_setzen(aid, True)
|
||||||
|
hid_b = db.haendler_speichern(Haendler(0, "B", standard_prozent=50), [("freenet", "999")])
|
||||||
|
p = next(p for p in db.positionen(aid) if p["kennung"] == "999")
|
||||||
|
assert p["haendler_id"] == UNZUGEORDNET and not p["abgerechnet"]
|
||||||
|
assert db.abrechnen([p["id"]]) == 0
|
||||||
|
# in einer offenen Abrechnung gehört dieselbe Kennung sehr wohl zu B (mit 50 %)
|
||||||
|
a2 = db.abrechnung_neu("Nov")
|
||||||
|
db.importieren(a2, lies_datei(freenet_csv))
|
||||||
|
q = next(p for p in db.positionen(a2) if p["kennung"] == "999")
|
||||||
|
assert q["haendler_id"] == hid_b and q["betrag"] == -400
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_text_mit_gleichheitszeichen_bleibt_text(tmp_path):
|
||||||
|
pos = [{"anbieter": "freenet", "zeitraum": "2026.10.01-2026.10.07", "provisionsart": "=1+1", "kennung": "111",
|
||||||
|
"datum": "2026-10-05", "rufnummer": "1601", "imei": "", "geraet": "", "vorname": "",
|
||||||
|
"name": '=HYPERLINK("http://böse.example","Klick")', "tarif": "T", "betrag": 800, "betrag_orig": 800,
|
||||||
|
"netz": "", "dienst": "", "aktionscode": "=cmd|' /C calc'!A0", "typ": "normal", "reihenfolge": 1}]
|
||||||
|
datei = export.exportiere_haendler(tmp_path, "A", pos, "Okt")[0]
|
||||||
|
ws = openpyxl.load_workbook(datei).active
|
||||||
|
zeile = [c for c in ws[1]]
|
||||||
|
assert zeile[1].value == "=1+1" and zeile[1].data_type == "s"
|
||||||
|
assert zeile[8].value.startswith("=HYPERLINK") and zeile[8].data_type == "s"
|
||||||
|
assert zeile[13].data_type == "s"
|
||||||
|
summe = next(c for row in ws.iter_rows() for c in row if isinstance(c.value, str) and c.value.startswith("=SUM"))
|
||||||
|
assert summe.data_type == "f", "unsere Summenformel bleibt eine Formel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_gleiche_dateinamen_ueberschreiben_sich_nicht(tmp_path):
|
||||||
|
p = {"anbieter": "freenet", "zeitraum": "", "provisionsart": "X", "kennung": "1", "datum": None, "rufnummer": "",
|
||||||
|
"imei": "", "geraet": "", "vorname": "", "name": "K", "tarif": "", "betrag": 100, "betrag_orig": 100,
|
||||||
|
"netz": "", "dienst": "", "aktionscode": "", "typ": "normal", "reihenfolge": 1}
|
||||||
|
vergeben: set[str] = set()
|
||||||
|
a = export.exportiere_haendler(tmp_path, "A/B", [p], "Okt", vergeben=vergeben)
|
||||||
|
b = export.exportiere_haendler(tmp_path, "A:B", [dict(p, betrag=200)], "Okt", vergeben=vergeben)
|
||||||
|
assert a[0].name == "A_B - Okt.xlsx" and b[0].name == "A_B - Okt (2).xlsx"
|
||||||
|
assert openpyxl.load_workbook(a[0]).active["K1"].value == 1.0
|
||||||
|
assert openpyxl.load_workbook(b[0]).active["K1"].value == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_prueft_sicherung(db, freenet_csv, tmp_path):
|
||||||
|
import sqlite3
|
||||||
|
aid = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
db.close()
|
||||||
|
ziel = db.path
|
||||||
|
vorher = ziel.read_bytes()
|
||||||
|
# keine SQLite-Datei
|
||||||
|
muell = tmp_path / "muell.db"
|
||||||
|
muell.write_bytes(b"kein sqlite")
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
Datenbank.restore(muell, ziel)
|
||||||
|
# SQLite, aber nicht unsere Tabellen
|
||||||
|
fremd = tmp_path / "fremd.db"
|
||||||
|
con = sqlite3.connect(fremd)
|
||||||
|
con.execute("CREATE TABLE haendler (id INTEGER)")
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Datenbank.restore(fremd, ziel)
|
||||||
|
assert ziel.read_bytes() == vorher and not ziel.with_suffix(".pruefung.db").exists()
|
||||||
|
# echte Sicherung
|
||||||
|
d = Datenbank(ziel)
|
||||||
|
sicherung = tmp_path / "sicherung.db"
|
||||||
|
d.backup(sicherung)
|
||||||
|
d.abrechnung_loeschen(aid)
|
||||||
|
d.close()
|
||||||
|
Datenbank.restore(sicherung, ziel)
|
||||||
|
d = Datenbank(ziel)
|
||||||
|
assert len(d.positionen(aid)) == 5
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_grenzen(tmp_path, freenet_csv, vodafone_xlsx, monkeypatch):
|
||||||
|
from provtool import importer
|
||||||
|
monkeypatch.setattr(importer, "MAX_ZEILEN", 3)
|
||||||
|
with pytest.raises(ImportFehler, match="Zeilen"):
|
||||||
|
lies_datei(freenet_csv)
|
||||||
|
monkeypatch.setattr(importer, "MAX_ZEILEN", 200_000)
|
||||||
|
monkeypatch.setattr(importer, "MAX_BYTES", 10)
|
||||||
|
with pytest.raises(ImportFehler, match="MB"):
|
||||||
|
lies_datei(freenet_csv)
|
||||||
|
monkeypatch.setattr(importer, "MAX_BYTES", vodafone_xlsx.stat().st_size + 1) # gepackt klein, entpackt zu groß
|
||||||
|
with pytest.raises(ImportFehler, match="entpackt"):
|
||||||
|
lies_datei(vodafone_xlsx)
|
||||||
|
|
||||||
|
|
||||||
|
def test_einstellungen_atomar_und_defekt(tmp_path, monkeypatch):
|
||||||
|
from provtool import config
|
||||||
|
monkeypatch.setenv("PROVTOOL_HOME", str(tmp_path))
|
||||||
|
config.save({"db_pfad": "Z:/x.db", "defekt": "weg"})
|
||||||
|
assert not (tmp_path / "einstellungen.json.tmp").exists()
|
||||||
|
assert config.load()["db_pfad"] == "Z:/x.db" and "defekt" not in config.load()
|
||||||
|
(tmp_path / "einstellungen.json").write_text("{ kaputt", encoding="utf-8")
|
||||||
|
daten = config.load()
|
||||||
|
assert daten["db_pfad"] is None and daten["defekt"].endswith("einstellungen.defekt.json")
|
||||||
|
assert (tmp_path / "einstellungen.defekt.json").read_text(encoding="utf-8") == "{ kaputt"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Updates
|
||||||
|
|
||||||
|
class _Antwort:
|
||||||
|
"""Ersatz für urlopen(): liefert Bytes, kennt seine (End-)URL."""
|
||||||
|
|
||||||
|
def __init__(self, daten: bytes, url: str):
|
||||||
|
import io
|
||||||
|
self._f, self._url = io.BytesIO(daten), url
|
||||||
|
|
||||||
|
def read(self, n=-1):
|
||||||
|
return self._f.read(n)
|
||||||
|
|
||||||
|
def geturl(self):
|
||||||
|
return self._url
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_version_vergleich():
|
||||||
|
from provtool import updater
|
||||||
|
assert updater.ist_neuer("1.10.0", "1.9.2") and updater.ist_neuer("v1.8.0", "1.7.0")
|
||||||
|
assert not updater.ist_neuer("1.7.0", "1.7.0") and not updater.ist_neuer("1.6.9", "1.7.0")
|
||||||
|
assert not updater.ist_neuer("kaputt", "1.7.0") and not updater.ist_neuer("1.8.0", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_neueste_nimmt_nur_eigenes_setup(monkeypatch):
|
||||||
|
import json
|
||||||
|
from provtool import updater
|
||||||
|
basis = "https://git.handy-insel.de/kerimatilgan/provisionsmanager95/releases/download/v1.8.0/"
|
||||||
|
release = {"tag_name": "v1.8.0", "html_url": "https://git.handy-insel.de/x/releases/tag/v1.8.0", "assets": [
|
||||||
|
{"name": "Provisionsmanager95.exe", "browser_download_url": basis + "Provisionsmanager95.exe", "size": 5},
|
||||||
|
{"name": "Provisionsmanager-Setup-1.8.0.exe", "browser_download_url": basis + "Provisionsmanager-Setup-1.8.0.exe",
|
||||||
|
"size": 7}]}
|
||||||
|
monkeypatch.setattr(updater.urllib.request, "urlopen",
|
||||||
|
lambda url, timeout=0: _Antwort(json.dumps(release).encode(), url))
|
||||||
|
info = updater.neueste()
|
||||||
|
assert info == {"version": "1.8.0", "seite": release["html_url"],
|
||||||
|
"setup_url": basis + "Provisionsmanager-Setup-1.8.0.exe", "groesse": 7}
|
||||||
|
release["assets"][1]["browser_download_url"] = "https://boese.example/Provisionsmanager-Setup-1.8.0.exe"
|
||||||
|
assert updater.neueste()["setup_url"] is None, "Setup von fremdem Server darf nicht geladen werden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_download_wird_geprueft(monkeypatch, tmp_path):
|
||||||
|
from provtool import updater
|
||||||
|
url = "https://git.handy-insel.de/r/releases/download/v1.8.0/Provisionsmanager-Setup-1.8.0.exe"
|
||||||
|
inhalt = {"daten": b"MZ" + b"x" * 98, "url": url}
|
||||||
|
monkeypatch.setattr(updater.urllib.request, "urlopen", lambda u, timeout=0: _Antwort(inhalt["daten"], inhalt["url"]))
|
||||||
|
setup = updater.herunterladen({"setup_url": url, "groesse": 100}, tmp_path)
|
||||||
|
assert setup.name == "Provisionsmanager-Setup-1.8.0.exe" and setup.read_bytes().startswith(b"MZ")
|
||||||
|
for daten, ende, groesse in ((b"MZ" + b"x" * 10, url, 100), # unvollständig
|
||||||
|
(b"PK" + b"x" * 98, url, 100), # kein Windows-Programm
|
||||||
|
(b"MZ" + b"x" * 98, "https://boese.example/a.exe", 100)): # umgeleitet
|
||||||
|
inhalt.update(daten=daten, url=ende)
|
||||||
|
setup.unlink(missing_ok=True)
|
||||||
|
with pytest.raises(updater.UpdateFehler):
|
||||||
|
updater.herunterladen({"setup_url": url, "groesse": groesse}, tmp_path)
|
||||||
|
assert not list(tmp_path.iterdir()), "halber Download bleibt liegen"
|
||||||
|
with pytest.raises(updater.UpdateFehler):
|
||||||
|
updater.herunterladen({"setup_url": "http://git.handy-insel.de/a/Provisionsmanager-Setup-1.8.0.exe"}, tmp_path)
|
||||||