Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2275b92188 | ||
|
|
b85a75857e | ||
|
|
968c68ac1a | ||
|
|
72677ebed7 | ||
|
|
03b48f063c | ||
|
|
c332c5a487 | ||
|
|
c96cf1bd94 | ||
|
|
c35356aeb9 | ||
|
|
f296483fdc | ||
|
|
2600eea574 | ||
|
|
057cca6378 | ||
|
|
ec99042569 |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version.
|
# Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version.
|
||||||
# - jeder Push / PR: Tests + Programmdateien als Build-Artefakte
|
# - jeder Push / PR: Tests + Programmdateien als Build-Artefakte
|
||||||
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip und Linux-.tar.gz
|
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip, Setup und Linux-.tar.gz
|
||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -37,6 +37,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
dist/Provisionsmanager95.exe
|
dist/Provisionsmanager95.exe
|
||||||
dist/Provisionsmanager95-*.zip
|
dist/Provisionsmanager95-*.zip
|
||||||
|
dist/Provisionsmanager-Setup-*.exe
|
||||||
|
|
||||||
- name: Release erstellen / ergänzen
|
- name: Release erstellen / ergänzen
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
@@ -44,7 +45,9 @@ jobs:
|
|||||||
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: build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$(tr -d '\r\n' < dist/VERSION).zip
|
run: |
|
||||||
|
V=$(tr -d '\r\n' < dist/VERSION)
|
||||||
|
build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$V.zip dist/Provisionsmanager-Setup-$V.exe
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ von **freenet** (CSV/Excel) und **Vodafone/Euronics** (Excel) ein, ordnet die Po
|
|||||||
Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine
|
Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine
|
||||||
fertige Excel-Abrechnung. Optik: Windows 95.
|
fertige Excel-Abrechnung. Optik: Windows 95.
|
||||||
|
|
||||||
|
## Installation (Windows)
|
||||||
|
|
||||||
|
`Provisionsmanager-Setup-<version>.exe` aus dem Release herunterladen und starten – keine
|
||||||
|
Admin-Rechte nötig. Das Programm landet in `%LOCALAPPDATA%\Programs\Provisionsmanager` mit
|
||||||
|
Eintrag im Startmenü (Desktop-Verknüpfung optional) und startet schneller als die einzelne `.exe`.
|
||||||
|
Ein Update installiert man einfach drüber (ein laufendes Programm wird dafür geschlossen).
|
||||||
|
Deinstallieren über *Einstellungen ▸ Apps*; Datenbank und Einstellungen in
|
||||||
|
`%APPDATA%\Provisionsmanager95` bleiben dabei erhalten. Ohne Installation geht weiterhin
|
||||||
|
`Provisionsmanager95.exe` (bzw. das `.zip`) – einfach starten.
|
||||||
|
|
||||||
## Erster Start
|
## Erster Start
|
||||||
|
|
||||||
Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler
|
Beim ersten Start führt ein **Einrichtungs-Assistent** durch Speicherort, Unterhändler
|
||||||
@@ -66,18 +76,30 @@ Welche Provisionsart Airtime bzw. Jahresbonus ist, erkennt das Programm an den T
|
|||||||
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
|
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
|
||||||
*Händler ▸ Provisionsarten…*.
|
*Händler ▸ Provisionsarten…*.
|
||||||
|
|
||||||
Das Hauptfenster ist unter Windows ein echtes Programmfenster mit Win95-Optik: an den Bildschirmrand
|
Das Programm startet maximiert. Das Hauptfenster ist unter Windows ein echtes Programmfenster: an den Bildschirmrand
|
||||||
ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
|
ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
|
||||||
wie gewohnt.
|
wie gewohnt.
|
||||||
|
|
||||||
**Zeitraum abrechnen:** Im Export-Dialog „Nur von … bis …“ eintragen (oder „Vormonat“), wahlweise nach
|
**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*
|
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
|
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*.
|
anderen Datei oder Abrechnung auftaucht. Versehentlich? Rechtsklick ▸ *Kennzeichnung „abgerechnet“ aufheben*.
|
||||||
*Datei ▸ Abrechnung erledigt / wieder öffnen* sperrt eine fertige Abrechnung gegen Änderungen.
|
*Datei ▸ Abrechnung erledigt / wieder öffnen* sperrt eine fertige Abrechnung gegen Änderungen.
|
||||||
|
|
||||||
**Design:** Standard ist ein modernes, helles Design (wie Windows 11); das Windows-95-Design gibt es weiter
|
**Doppelte Positionen:** Taucht dieselbe Position mehrfach auf (z. B. in Wochen- und Monatsdatei oder in zwei
|
||||||
unter *Datei ▸ Einstellungen ▸ Darstellung* (wirkt nach Neustart).
|
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
|
**Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten
|
||||||
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
|
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
|
||||||
@@ -119,7 +141,8 @@ Aufbau:
|
|||||||
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
|
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
|
||||||
| `provtool/export.py` | Excel-Export im Zielformat |
|
| `provtool/export.py` | Excel-Export im Zielformat |
|
||||||
| `provtool/money.py` | Beträge (intern immer in Cent) |
|
| `provtool/money.py` | Beträge (intern immer in Cent) |
|
||||||
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) |
|
| `provtool/gui/` | Oberfläche (Tkinter; Designs hell, dunkel, Win95) |
|
||||||
|
| `provtool/gui/schriften/` | IBM Plex (SIL Open Font License, siehe `OFL.txt`) |
|
||||||
| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung |
|
| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung |
|
||||||
| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
|
| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
|
||||||
|
|
||||||
@@ -134,7 +157,9 @@ Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Containe
|
|||||||
- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
|
- jeder Push: alle Tests unter Windows-Python (Wine) **inklusive Oberflächen-Tests** auf einem
|
||||||
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
|
virtuellen Bildschirm, danach `Provisionsmanager95.exe` bauen und mit
|
||||||
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
|
`Provisionsmanager95.exe --selbsttest` die **fertige .exe** prüfen; erst dann gibt es das Artefakt
|
||||||
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip`
|
- danach das Setup (`build/installer.iss`, Inno Setup unter Wine) bauen, still installieren,
|
||||||
|
den Selbsttest in der installierten `.exe` laufen lassen und still deinstallieren
|
||||||
|
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe`, `.zip` und Setup
|
||||||
|
|
||||||
Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen,
|
Die Oberflächen-Tests (`tests/test_gui.py`) bedienen die echte Oberfläche: Dialoge öffnen,
|
||||||
Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw.
|
Programm schließen, während Assistent/Rückfragen/Rundgang offen sind, Alt+F4, Beträge ändern usw.
|
||||||
|
|||||||
+48
-12
@@ -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")/.."
|
||||||
|
|
||||||
@@ -40,19 +41,24 @@ echo "== PyInstaller =="
|
|||||||
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
|
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
|
||||||
|
|
||||||
test -f dist/Provisionsmanager95.exe
|
test -f dist/Provisionsmanager95.exe
|
||||||
|
test -f dist/Provisionsmanager/Provisionsmanager.exe # Ordner-Variante für das Setup
|
||||||
|
|
||||||
|
selbsttest() { # $1 = zu prüfende .exe
|
||||||
|
rm -f selbsttest.txt
|
||||||
|
set +e
|
||||||
|
timeout 300 wine "$1" --selbsttest selbsttest.txt
|
||||||
|
CODE=$?
|
||||||
|
set -e
|
||||||
|
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
|
||||||
|
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
|
||||||
|
echo "Selbsttest von $1 FEHLGESCHLAGEN (Exit-Code $CODE)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Selbsttest bestanden"
|
||||||
|
}
|
||||||
|
|
||||||
echo "== Selbsttest der fertigen .exe =="
|
echo "== Selbsttest der fertigen .exe =="
|
||||||
rm -f selbsttest.txt
|
selbsttest dist/Provisionsmanager95.exe
|
||||||
set +e
|
|
||||||
timeout 300 wine dist/Provisionsmanager95.exe --selbsttest selbsttest.txt
|
|
||||||
CODE=$?
|
|
||||||
set -e
|
|
||||||
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei – die .exe ist vermutlich gar nicht gestartet)"
|
|
||||||
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | tr -d '\r' | grep -qx OK; then
|
|
||||||
echo "Selbsttest der .exe FEHLGESCHLAGEN (Exit-Code $CODE)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Selbsttest bestanden"
|
|
||||||
cp README.md dist/LIESMICH.md 2>/dev/null || true
|
cp README.md dist/LIESMICH.md 2>/dev/null || true
|
||||||
( cd dist && rm -f Provisionsmanager95-*.zip && \
|
( cd dist && rm -f Provisionsmanager95-*.zip && \
|
||||||
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
|
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
|
||||||
@@ -60,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
-1
@@ -16,7 +16,7 @@ home = tempfile.mkdtemp()
|
|||||||
os.environ["PROVTOOL_HOME"] = home
|
os.environ["PROVTOOL_HOME"] = home
|
||||||
with open(os.path.join(home, "einstellungen.json"), "w") as f:
|
with open(os.path.join(home, "einstellungen.json"), "w") as f:
|
||||||
json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100",
|
json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100",
|
||||||
"design": "win95"}, f) # nur Win95 hat den eigenen Fensterrahmen
|
"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__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from provtool.gui import app as A # noqa: E402
|
from provtool.gui import app as A # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# PyInstaller-Spezifikation – erzeugt eine einzelne Programmdatei (Windows: .exe, Linux: ELF)
|
# PyInstaller-Spezifikation – erzeugt eine einzelne Programmdatei (Windows: .exe, Linux: ELF),
|
||||||
|
# unter Windows zusätzlich den Ordner dist/Provisionsmanager/ (schneller Start, Inhalt des Setups)
|
||||||
# Aufruf: pyinstaller --noconfirm build/provisionsmanager.spec
|
# Aufruf: pyinstaller --noconfirm build/provisionsmanager.spec
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -11,7 +12,8 @@ a = Analysis(
|
|||||||
[os.path.join(ROOT, "build", "start.py")],
|
[os.path.join(ROOT, "build", "start.py")],
|
||||||
pathex=[ROOT],
|
pathex=[ROOT],
|
||||||
hiddenimports=["openpyxl", "xlrd"],
|
hiddenimports=["openpyxl", "xlrd"],
|
||||||
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))],
|
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder")),
|
||||||
|
(os.path.join(ROOT, "provtool", "gui", "schriften"), os.path.join("provtool", "gui", "schriften"))],
|
||||||
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
|
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
)
|
)
|
||||||
@@ -31,3 +33,19 @@ exe = EXE(
|
|||||||
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)
|
||||||
@@ -11,7 +11,7 @@ release_id() { curl -sf "$API/releases/tags/$TAG" -H "Authorization: token $TOKE
|
|||||||
id=$(release_id || true)
|
id=$(release_id || true)
|
||||||
if [ -z "$id" ]; then
|
if [ -z "$id" ]; then
|
||||||
curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
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: .exe herunterladen und starten. Linux: tar.gz entpacken, Provisionsmanager95 starten (ab Debian 12 / Ubuntu 24.04).\"}" \
|
-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.
|
>/dev/null || sleep 5 # Gleichzeitig angelegt? Dann gibt es das Release jetzt.
|
||||||
id=$(release_id)
|
id=$(release_id)
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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.4.1"
|
__version__ = "1.8.1"
|
||||||
APP_NAME = "Provisions-Manager 95"
|
APP_NAME = "Provisions-Manager 95"
|
||||||
APP_ORG = "Handy-Insel Miesbach"
|
APP_ORG = "Handy-Insel Miesbach"
|
||||||
+15
-1
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
APP_DIR_NAME = "Provisionsmanager95"
|
APP_DIR_NAME = "Provisionsmanager95"
|
||||||
@@ -41,18 +42,31 @@ DEFAULTS = {
|
|||||||
|
|
||||||
|
|
||||||
def load() -> dict:
|
def load() -> dict:
|
||||||
|
"""Einstellungen lesen. Ist die Datei unlesbar (z. B. Absturz beim Schreiben), wird sie als
|
||||||
|
*.defekt.json aufbewahrt und unter "defekt" gemeldet – statt still mit Standardwerten weiterzumachen
|
||||||
|
(dabei ginge z. B. der Pfad zur Datenbank auf dem Netzlaufwerk verloren)."""
|
||||||
data = dict(DEFAULTS)
|
data = dict(DEFAULTS)
|
||||||
f = _settings_file()
|
f = _settings_file()
|
||||||
if f.exists():
|
if f.exists():
|
||||||
try:
|
try:
|
||||||
data.update(json.loads(f.read_text(encoding="utf-8")))
|
data.update(json.loads(f.read_text(encoding="utf-8")))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
|
sicherung = f.with_suffix(".defekt.json")
|
||||||
|
try:
|
||||||
|
shutil.copyfile(f, sicherung)
|
||||||
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
data["defekt"] = str(sicherung)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def save(data: dict) -> None:
|
def save(data: dict) -> None:
|
||||||
_settings_file().write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
"""Atomar: erst in eine Hilfsdatei, dann umbenennen – ein Absturz hinterlässt nie eine halbe Datei."""
|
||||||
|
data = {k: v for k, v in data.items() if k != "defekt"}
|
||||||
|
f = _settings_file()
|
||||||
|
tmp = f.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
os.replace(tmp, f)
|
||||||
|
|
||||||
|
|
||||||
def db_path(settings: dict | None = None) -> Path:
|
def db_path(settings: dict | None = None) -> Path:
|
||||||
|
|||||||
+69
-25
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -134,7 +135,8 @@ def fingerabdruecke(zeilen: list[dict]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
class AbrechnungErledigt(Exception):
|
class AbrechnungErledigt(Exception):
|
||||||
"""Die Abrechnung ist als erledigt gekennzeichnet und darf nicht mehr verändert werden."""
|
"""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:
|
||||||
@@ -168,8 +170,6 @@ class Datenbank:
|
|||||||
self.con.execute("PRAGMA foreign_keys = ON")
|
self.con.execute("PRAGMA foreign_keys = ON")
|
||||||
self.con.executescript(SCHEMA)
|
self.con.executescript(SCHEMA)
|
||||||
self._migrieren()
|
self._migrieren()
|
||||||
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
|
|
||||||
self.con.commit()
|
|
||||||
|
|
||||||
def _migrieren(self):
|
def _migrieren(self):
|
||||||
spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")}
|
spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")}
|
||||||
@@ -191,7 +191,8 @@ class Datenbank:
|
|||||||
if "erledigt" not in a_spalten:
|
if "erledigt" not in a_spalten:
|
||||||
self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT")
|
self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT")
|
||||||
self._fingerabdruecke_nachtragen()
|
self._fingerabdruecke_nachtragen()
|
||||||
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
|
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):
|
def _fingerabdruecke_nachtragen(self):
|
||||||
@@ -215,12 +216,26 @@ class Datenbank:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def restore(quelle: str | Path, ziel: str | Path) -> None:
|
def restore(quelle: str | Path, ziel: str | Path) -> None:
|
||||||
test = sqlite3.connect(str(quelle))
|
"""Spielt eine Sicherung ein. Geprüft wird an einer Kopie (unversehrt, unsere Tabellen, lässt sich
|
||||||
|
öffnen und migrieren) – erst dann ersetzt sie die Datenbank. Schlägt die Prüfung fehl, bleibt alles."""
|
||||||
|
ziel = Path(ziel)
|
||||||
|
probe = ziel.with_suffix(".pruefung.db")
|
||||||
|
shutil.copyfile(quelle, probe)
|
||||||
try:
|
try:
|
||||||
test.execute("SELECT count(*) FROM haendler").fetchone()
|
con = sqlite3.connect(str(probe))
|
||||||
|
try:
|
||||||
|
if con.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
|
||||||
|
raise ValueError("Die Datei ist beschädigt (integrity_check).")
|
||||||
|
tabellen = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||||
|
fehlt = {"haendler", "abrechnung", "position"} - tabellen
|
||||||
|
if fehlt:
|
||||||
|
raise ValueError(f"Keine Sicherung des Provisions-Managers (Tabellen fehlen: {', '.join(sorted(fehlt))}).")
|
||||||
finally:
|
finally:
|
||||||
test.close()
|
con.close()
|
||||||
shutil.copyfile(quelle, ziel)
|
Datenbank(probe).close() # öffnet und migriert – wirft, wenn das nicht geht
|
||||||
|
os.replace(probe, ziel)
|
||||||
|
finally:
|
||||||
|
probe.unlink(missing_ok=True)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ Händler
|
# ------------------------------------------------------------------ Händler
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -368,16 +383,6 @@ class Datenbank:
|
|||||||
h = self.haendler(hid) if hid > 0 else None
|
h = self.haendler(hid) if hid > 0 else None
|
||||||
return h.staffel if h and h.staffel is not None else self.standard_staffel()
|
return h.staffel if h and h.staffel is not None else self.standard_staffel()
|
||||||
|
|
||||||
def prozent_fuer(self, hid: int, provisionsart: str) -> float:
|
|
||||||
if hid <= 0: # nicht zugeordnet / eigene Filiale
|
|
||||||
return 100.0
|
|
||||||
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
|
|
||||||
(hid, provisionsart)).fetchone()
|
|
||||||
if r:
|
|
||||||
return r[0]
|
|
||||||
h = self.haendler(hid)
|
|
||||||
return h.standard_prozent if h else 100.0
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ Abrechnungen
|
# ------------------------------------------------------------------ Abrechnungen
|
||||||
def abrechnung_neu(self, titel: str) -> int:
|
def abrechnung_neu(self, titel: str) -> int:
|
||||||
with self.con:
|
with self.con:
|
||||||
@@ -400,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,)))
|
||||||
|
|
||||||
@@ -413,11 +429,14 @@ class Datenbank:
|
|||||||
|
|
||||||
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()
|
r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone()
|
||||||
if r:
|
if not r:
|
||||||
|
return
|
||||||
self._nicht_erledigt(r[0])
|
self._nicht_erledigt(r[0])
|
||||||
|
self._keine_abgerechneten("datei_id=?", datei_id)
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
|
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
|
||||||
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
||||||
|
self._regeln_anwenden(r[0], set()) # weniger Karten -> Jahresbonus-Staffel der übrigen neu
|
||||||
|
|
||||||
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
||||||
self._nicht_erledigt(aid)
|
self._nicht_erledigt(aid)
|
||||||
@@ -450,7 +469,19 @@ class Datenbank:
|
|||||||
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
|
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
|
||||||
finde = self.zuordner()
|
finde = self.zuordner()
|
||||||
fest = self.art_typen()
|
fest = self.art_typen()
|
||||||
schon = dict(self.con.execute("SELECT fp, abrechnung FROM abgerechnet_fp").fetchall())
|
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)
|
||||||
@@ -458,9 +489,17 @@ class Datenbank:
|
|||||||
d["haendler_id"] = d["abgerechnet_hid"] # abgerechnet: Zuordnung eingefroren
|
d["haendler_id"] = d["abgerechnet_hid"] # abgerechnet: Zuordnung eingefroren
|
||||||
else:
|
else:
|
||||||
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
|
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
|
||||||
|
if erledigt and d["haendler_id"] > 0:
|
||||||
|
# Erledigt: was dabei nicht abgerechnet wurde, bekommt nachträglich keinen Händler mehr
|
||||||
|
# (sonst stünde es bei einem später angelegten Händler mit 100 % zur Auszahlung)
|
||||||
|
d["haendler_id"] = UNZUGEORDNET
|
||||||
d["typ"] = regeln.typ_von(d, fest)
|
d["typ"] = regeln.typ_von(d, fest)
|
||||||
# schon anderswo abgerechnet (dieselbe Position in einer früheren Datei/Abrechnung)?
|
d["doppelt"] = None
|
||||||
d["doppelt"] = schon.get(d["fp"]) if d["fp"] and not d["abgerechnet"] else 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
|
||||||
|
|
||||||
@@ -493,10 +532,10 @@ class Datenbank:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]:
|
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]:
|
||||||
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab)."""
|
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab, Doppelte nicht mit)."""
|
||||||
karten: dict[tuple[int, str], int] = {}
|
karten: dict[tuple[int, str], int] = {}
|
||||||
for p in positionen:
|
for p in positionen:
|
||||||
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"]:
|
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"] and not p["doppelt"]:
|
||||||
k = (p["haendler_id"], p["anbieter"])
|
k = (p["haendler_id"], p["anbieter"])
|
||||||
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1)
|
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1)
|
||||||
return {k: max(0, n) for k, n in karten.items()}
|
return {k: max(0, n) for k, n in karten.items()}
|
||||||
@@ -599,15 +638,20 @@ class Datenbank:
|
|||||||
ids = set(pids)
|
ids = set(pids)
|
||||||
n = 0
|
n = 0
|
||||||
for aid in self._abrechnungen_von(list(ids)):
|
for aid in self._abrechnungen_von(list(ids)):
|
||||||
|
if self.erledigt(aid):
|
||||||
|
continue # erledigt: nichts wird nachträglich abgerechnet
|
||||||
titel = self.abrechnung(aid)["titel"]
|
titel = self.abrechnung(aid)["titel"]
|
||||||
with self.con:
|
with self.con:
|
||||||
for p in self.positionen(aid):
|
for p in self.positionen(aid):
|
||||||
if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
|
if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
|
||||||
continue
|
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=?",
|
self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE id=?",
|
||||||
(wann, p["haendler_id"], p["id"]))
|
(wann, p["haendler_id"], p["id"]))
|
||||||
if p["fp"]:
|
if p["fp"]:
|
||||||
self.con.execute("INSERT OR REPLACE INTO abgerechnet_fp (fp, wann, abrechnung) VALUES (?,?,?)",
|
self.con.execute("INSERT INTO abgerechnet_fp (fp, wann, abrechnung) VALUES (?,?,?)",
|
||||||
(p["fp"], wann, titel))
|
(p["fp"], wann, titel))
|
||||||
n += 1
|
n += 1
|
||||||
return n
|
return n
|
||||||
|
|||||||
+13
-2
@@ -101,6 +101,8 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
|
|||||||
]
|
]
|
||||||
for c, v in enumerate(werte, start=1):
|
for c, v in enumerate(werte, start=1):
|
||||||
cell = ws.cell(zeile, c, v)
|
cell = ws.cell(zeile, c, v)
|
||||||
|
if isinstance(v, str) and v.startswith("="):
|
||||||
|
cell.data_type = "s" # Text aus der Abrechnung bleibt Text – nie eine Formel
|
||||||
cell.font = bold if p.get("_summe") and c in (2, 11) else font
|
cell.font = bold if p.get("_summe") and c in (2, 11) else font
|
||||||
ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
|
ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
|
||||||
if isinstance(werte[0], date):
|
if isinstance(werte[0], date):
|
||||||
@@ -130,9 +132,11 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
|
|||||||
|
|
||||||
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
|
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
|
||||||
pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
|
pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
|
||||||
null_weglassen: bool = True) -> list[Path]:
|
null_weglassen: bool = True, vergeben: set[str] | None = None) -> list[Path]:
|
||||||
"""Schreibt die Abrechnung eines Händlers (bei pro_anbieter je Anbieter eine) und – falls es Airtime
|
"""Schreibt die Abrechnung eines Händlers (bei pro_anbieter je Anbieter eine) und – falls es Airtime
|
||||||
gibt – die Airtime-Liste. In der Abrechnung steht Airtime nur als eine Summenzeile.
|
gibt – die Airtime-Liste. In der Abrechnung steht Airtime nur als eine Summenzeile.
|
||||||
|
vergeben: Dateinamen (klein geschrieben), die in diesem Export schon geschrieben wurden – gleich
|
||||||
|
lautende Namen (z. B. Händler „A/B“ und „A:B“) bekommen „ (2)“ statt die Datei zu überschreiben.
|
||||||
Gibt die erzeugten Pfade zurück."""
|
Gibt die erzeugten Pfade zurück."""
|
||||||
ordner = Path(ordner)
|
ordner = Path(ordner)
|
||||||
if null_weglassen:
|
if null_weglassen:
|
||||||
@@ -142,9 +146,16 @@ def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list
|
|||||||
gruppen: dict[str, list[dict]] = {}
|
gruppen: dict[str, list[dict]] = {}
|
||||||
for p in positionen:
|
for p in positionen:
|
||||||
gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
|
gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
|
||||||
|
vergeben = set() if vergeben is None else vergeben
|
||||||
|
|
||||||
def name(*teile) -> str:
|
def name(*teile) -> str:
|
||||||
return dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t)) + ".xlsx"
|
basis = dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t))
|
||||||
|
for n in range(1, 1000):
|
||||||
|
kandidat = basis + (f" ({n})" if n > 1 else "") + ".xlsx"
|
||||||
|
if kandidat.lower() not in vergeben:
|
||||||
|
vergeben.add(kandidat.lower())
|
||||||
|
return kandidat
|
||||||
|
raise ValueError(f"Kein freier Dateiname für {basis}")
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for anbieter, pos in sorted(gruppen.items()):
|
for anbieter, pos in sorted(gruppen.items()):
|
||||||
|
|||||||
+528
-138
@@ -5,14 +5,17 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
import traceback
|
import traceback
|
||||||
|
import webbrowser
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tkinter import filedialog
|
from tkinter import filedialog
|
||||||
|
|
||||||
from provtool import config
|
from provtool import __version__, config, updater
|
||||||
from provtool import export as exp
|
from provtool import export as exp
|
||||||
from provtool import regeln
|
from provtool import regeln
|
||||||
from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank
|
from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank
|
||||||
@@ -61,11 +64,14 @@ class App:
|
|||||||
def __init__(self, root: tk.Tk):
|
def __init__(self, root: tk.Tk):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.settings = config.load()
|
self.settings = config.load()
|
||||||
self.db = Datenbank(config.db_path(self.settings))
|
self.db: Datenbank | None = None # wird nach dem Aufbau des Fensters geöffnet (_db_oeffnen)
|
||||||
self.aid: int | None = None
|
self.aid: int | None = None
|
||||||
self.positionen: list[dict] = []
|
self.positionen: list[dict] = []
|
||||||
self.haendler_namen: dict[int, str] = {}
|
self.haendler_namen: dict[int, str] = {}
|
||||||
self.ansicht: int = ALLE
|
self.ansicht: int = ALLE
|
||||||
|
self._alle_gruppen: dict[str, tuple[str, list[dict]]] = {}
|
||||||
|
self._gruppen: dict[str, tuple[str, list[dict]]] = {}
|
||||||
|
self._zeilen_tags: dict[str, tuple[str, ...]] = {}
|
||||||
self._edit: tk.Entry | None = None
|
self._edit: tk.Entry | None = None
|
||||||
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
|
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
|
||||||
self._beendet = False # Datenbank und Fenster sind zu
|
self._beendet = False # Datenbank und Fenster sind zu
|
||||||
@@ -82,9 +88,13 @@ class App:
|
|||||||
self.rahmen = rh.Hauptrahmen(root, w.APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
|
self.rahmen = rh.Hauptrahmen(root, w.APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
|
||||||
self.rahmen.set_titel(w.APP_NAME)
|
self.rahmen.set_titel(w.APP_NAME)
|
||||||
self.inhalt = self.rahmen.inhalt
|
self.inhalt = self.rahmen.inhalt
|
||||||
|
if w.DUNKEL:
|
||||||
|
from provtool.gui import win32
|
||||||
|
win32.dunkle_titelleiste(root)
|
||||||
|
|
||||||
self._menue()
|
self.neustart = False # Design umgeschaltet: main() baut das Fenster neu auf
|
||||||
self._toolbar()
|
self.filter = "alle"
|
||||||
|
self._kopf()
|
||||||
self._body()
|
self._body()
|
||||||
self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
|
self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
|
||||||
griff = self.rahmen.griff(self.status)
|
griff = self.rahmen.griff(self.status)
|
||||||
@@ -92,24 +102,103 @@ class App:
|
|||||||
griff.pack(side="right", anchor="se")
|
griff.pack(side="right", anchor="se")
|
||||||
self.status.pack(side="bottom", fill="x")
|
self.status.pack(side="bottom", fill="x")
|
||||||
self._tasten()
|
self._tasten()
|
||||||
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False))
|
# startet maximiert („vollbild“, abschaltbar nur für Tests); „fenster“ = Größe nach dem Wiederherstellen
|
||||||
|
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("vollbild", True))
|
||||||
if self.settings.get("trenner"):
|
if self.settings.get("trenner"):
|
||||||
root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0))
|
root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0))
|
||||||
|
|
||||||
|
self.db = self._db_oeffnen()
|
||||||
abr = self.db.abrechnungen()
|
abr = self.db.abrechnungen()
|
||||||
if abr:
|
if abr:
|
||||||
self.oeffnen(abr[0]["id"])
|
self.oeffnen(abr[0]["id"])
|
||||||
else:
|
else:
|
||||||
self._aktualisieren()
|
self._aktualisieren()
|
||||||
if not self.settings.get("assistent_erledigt") and not self.db.haendler_liste():
|
if self.settings.pop("defekt", None):
|
||||||
|
root.after(300, lambda: w.warnung(
|
||||||
|
root, "Einstellungen",
|
||||||
|
"Die Einstellungsdatei war unlesbar und wurde durch Standardwerte ersetzt (Kopie: "
|
||||||
|
f"{config.app_dir() / 'einstellungen.defekt.json'}).\n\nBitte unter Datei > Einstellungen "
|
||||||
|
"prüfen, ob der Speicherort der Datenbank noch stimmt."))
|
||||||
|
elif not self.settings.get("assistent_erledigt") and not self.db.haendler_liste():
|
||||||
root.after(400, self.assistent)
|
root.after(400, self.assistent)
|
||||||
elif self.settings.get("tipps_beim_start", True):
|
elif self.settings.get("tipps_beim_start", True):
|
||||||
root.after(500, self.tipp_des_tages)
|
root.after(500, self.tipp_des_tages)
|
||||||
|
|
||||||
|
def _db_oeffnen(self) -> Datenbank:
|
||||||
|
"""Datenbank laut Einstellungen öffnen. Ist ein eigener Speicherort eingestellt (Netzlaufwerk) und
|
||||||
|
nicht erreichbar, wird gefragt – statt abzustürzen oder dort still eine leere Datenbank anzulegen."""
|
||||||
|
while True:
|
||||||
|
pfad = config.db_path(self.settings)
|
||||||
|
try:
|
||||||
|
if self.settings.get("db_pfad") and not pfad.exists():
|
||||||
|
raise FileNotFoundError("Die Datei gibt es nicht (Netzlaufwerk nicht verbunden?)")
|
||||||
|
return Datenbank(pfad)
|
||||||
|
except Exception as exc:
|
||||||
|
wahl = w._meldung(self.root, "Datenbank nicht erreichbar",
|
||||||
|
f"Die Datenbank konnte nicht geöffnet werden:\n{pfad}\n\n{exc}\n\n"
|
||||||
|
"Standard: die lokale Datenbank dieses PCs verwenden.",
|
||||||
|
"fehler", [("Standard", "standard"), ("Andere wählen...", "waehlen"),
|
||||||
|
("Beenden", "ende")], default=0)
|
||||||
|
if wahl == "standard":
|
||||||
|
self.settings["db_pfad"] = None
|
||||||
|
config.save(self.settings)
|
||||||
|
elif wahl == "waehlen":
|
||||||
|
p = filedialog.askopenfilename(parent=self.root, title="Datenbank öffnen",
|
||||||
|
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
|
||||||
|
if p:
|
||||||
|
self.settings["db_pfad"] = p
|
||||||
|
config.save(self.settings)
|
||||||
|
else:
|
||||||
|
self.root.destroy()
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
|
||||||
# ================================================================= Aufbau
|
# ================================================================= Aufbau
|
||||||
def _menue(self):
|
def _kopf(self):
|
||||||
self.menueleiste = mn.Menueleiste(self.inhalt, self.root)
|
"""Kopfzeile wie in den Webtools: Logo, Menüs, rechts Suche (modern auch Hell/Dunkel)."""
|
||||||
self.menueleiste.pack(side="top", fill="x")
|
kopf = tk.Frame(self.inhalt, bg=w.FACE)
|
||||||
|
kopf.pack(side="top", fill="x")
|
||||||
|
if w.MODERN:
|
||||||
|
w.trenner(self.inhalt, vertikal=False).pack(side="top", fill="x")
|
||||||
|
innen = tk.Frame(kopf, bg=w.FACE)
|
||||||
|
innen.pack(fill="x", padx=(18, 14) if w.MODERN else 0, pady=10 if w.MODERN else 0)
|
||||||
|
if w.MODERN:
|
||||||
|
w.logo(innen).pack(side="left")
|
||||||
|
namen = tk.Frame(innen, bg=w.FACE)
|
||||||
|
namen.pack(side="left", padx=(10, 22))
|
||||||
|
tk.Label(namen, text=w.APP_NAME, font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold")).pack(anchor="w")
|
||||||
|
tk.Label(namen, text="Handy-Insel", fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(anchor="w")
|
||||||
|
self._menue(innen)
|
||||||
|
|
||||||
|
if w.MODERN:
|
||||||
|
self.b_design = w.knopf(innen, "Hell" if w.DUNKEL else "Dunkel", self.design_umschalten)
|
||||||
|
self.b_design.pack(side="right", padx=(8, 0))
|
||||||
|
w.Tooltip(self.b_design, "Zwischen hellem und dunklem Design wechseln")
|
||||||
|
self.v_suche = tk.StringVar()
|
||||||
|
if w.MODERN: # Suchfeld mit Rahmen, Hinweis und Tastenkürzel
|
||||||
|
sf = tk.Frame(innen, bg=w.WHITE, highlightthickness=1, highlightbackground=w.RAND2,
|
||||||
|
highlightcolor=w.NAVY)
|
||||||
|
sf.pack(side="right")
|
||||||
|
tk.Label(sf, text="Suchen", bg=w.WHITE, fg=w.SHADOW).pack(side="left", padx=(10, 2))
|
||||||
|
e = tk.Entry(sf, textvariable=self.v_suche, width=28, highlightthickness=0, bd=4)
|
||||||
|
e.pack(side="left")
|
||||||
|
tk.Label(sf, text="Strg F", bg=w.WHITE, fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(
|
||||||
|
side="left", padx=(2, 10))
|
||||||
|
e.bind("<FocusIn>", lambda _e: sf.configure(highlightbackground=w.NAVY), add="+")
|
||||||
|
e.bind("<FocusOut>", lambda _e: sf.configure(highlightbackground=w.RAND2), add="+")
|
||||||
|
else:
|
||||||
|
sf = tk.Frame(innen, bg=w.FACE)
|
||||||
|
sf.pack(side="right", padx=8)
|
||||||
|
tk.Label(sf, text="Suchen:").pack(side="left")
|
||||||
|
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
|
||||||
|
e.pack(side="left", padx=4)
|
||||||
|
self._suche_geplant = None
|
||||||
|
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
|
||||||
|
self._suchfeld = e
|
||||||
|
self._suchrahmen = sf
|
||||||
|
|
||||||
|
def _menue(self, master):
|
||||||
|
self.menueleiste = mn.Menueleiste(master, self.root)
|
||||||
|
self.menueleiste.pack(side="left")
|
||||||
d = self.menueleiste.menue("Datei")
|
d = self.menueleiste.menue("Datei")
|
||||||
d.add_command(label="Neue Abrechnung...", accelerator="Strg+N", command=self.neu)
|
d.add_command(label="Neue Abrechnung...", accelerator="Strg+N", command=self.neu)
|
||||||
d.add_command(label="Abrechnung öffnen...", accelerator="Strg+O", command=self.abrechnung_waehlen)
|
d.add_command(label="Abrechnung öffnen...", accelerator="Strg+O", command=self.abrechnung_waehlen)
|
||||||
@@ -144,110 +233,197 @@ class App:
|
|||||||
h.add_command(label="Provisionsarten (Airtime / Jahresbonus)...", command=self.provisionsarten)
|
h.add_command(label="Provisionsarten (Airtime / Jahresbonus)...", command=self.provisionsarten)
|
||||||
h.add_command(label="Standard-Staffel Jahresbonus...", command=self.standard_staffel)
|
h.add_command(label="Standard-Staffel Jahresbonus...", command=self.standard_staffel)
|
||||||
|
|
||||||
hi = self.menueleiste.menue("?", underline=None)
|
self.hilfe_menue = "Hilfe" if w.MODERN else "?"
|
||||||
|
hi = self.menueleiste.menue(self.hilfe_menue, underline=None)
|
||||||
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
|
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
|
||||||
hi.add_command(label="Rundgang durch das Programm", command=self.rundgang)
|
hi.add_command(label="Rundgang durch das Programm", command=self.rundgang)
|
||||||
hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages)
|
hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages)
|
||||||
|
hi.add_command(label="Nach Updates suchen...", command=self.updates_pruefen)
|
||||||
hi.add_separator()
|
hi.add_separator()
|
||||||
hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent)
|
hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent)
|
||||||
hi.add_separator()
|
hi.add_separator()
|
||||||
hi.add_command(label=f"Info über {w.APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
|
hi.add_command(label=f"Info über {w.APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
|
||||||
|
|
||||||
def _toolbar(self):
|
FILTER = (("alle", "Alle"), ("unzugeordnet", "Nicht zugeordnet"), ("manuell", "Von Hand geändert"),
|
||||||
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=0 if w.MODERN else 1, relief="raised")
|
("doppelt", "Doppelt"), ("abgerechnet", "Bereits abgerechnet"))
|
||||||
self.tb: dict[str, w.ToolbarButton] = {}
|
|
||||||
tb.pack(side="top", fill="x")
|
|
||||||
for text, icon, cmd, tip in (
|
|
||||||
("Neu", "neu", self.neu, "Neue Abrechnung anlegen (Strg+N)"),
|
|
||||||
("Öffnen", "oeffnen", self.abrechnung_waehlen, "Gespeicherte Abrechnung öffnen (Strg+O)"),
|
|
||||||
(None, None, None, None),
|
|
||||||
("Import", "import", self.importieren, "CSV-/Excel-Dateien einlesen (Strg+I)"),
|
|
||||||
("Export", "export", self.exportieren, "Händler-Abrechnungen als Excel speichern (Strg+E)"),
|
|
||||||
(None, None, None, None),
|
|
||||||
("Händler", "haendler", self.haendler, "Händler und Kennungen verwalten (Strg+H)"),
|
|
||||||
("Prozent", "prozent", self.prozent, "Auszahlung für Auswahl in Prozent festlegen (F4)"),
|
|
||||||
("Betrag", "rechner", self.betrag_bearbeiten, "Betrag der Position ändern (F2)"),
|
|
||||||
):
|
|
||||||
if text is None:
|
|
||||||
w.trenner(tb).pack(side="left", fill="y", padx=4, pady=3)
|
|
||||||
continue
|
|
||||||
self.tb[text] = w.ToolbarButton(tb, text, self.icons(icon), cmd, tip)
|
|
||||||
self.tb[text].pack(side="left", padx=1, pady=1)
|
|
||||||
self.v_suche = tk.StringVar()
|
|
||||||
sf = tk.Frame(tb, bg=w.FACE)
|
|
||||||
sf.pack(side="right", padx=8)
|
|
||||||
tk.Label(sf, text="Suchen:").pack(side="left")
|
|
||||||
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
|
|
||||||
e.pack(side="left", padx=4)
|
|
||||||
self._suche_geplant = None
|
|
||||||
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
|
|
||||||
self._suchfeld = e
|
|
||||||
self._suchrahmen = sf
|
|
||||||
|
|
||||||
def _body(self):
|
def _body(self):
|
||||||
desk = tk.Frame(self.inhalt, bg=w.TEAL)
|
m = w.MODERN
|
||||||
|
desk = tk.Frame(self.inhalt, bg=w.BG)
|
||||||
desk.pack(side="top", fill="both", expand=True)
|
desk.pack(side="top", fill="both", expand=True)
|
||||||
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar.
|
# Trennlinie zwischen den Spalten, mit der Maus ziehbar. Beim Ziehen wandert nur eine Linie mit;
|
||||||
# Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim
|
# neu aufgebaut wird erst beim Loslassen (flüssig, auch mit vielen Positionen).
|
||||||
# Loslassen (flüssig, auch mit vielen Positionen).
|
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.BG, sashwidth=6 if m else 7,
|
||||||
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7,
|
sashrelief="flat" if m else "raised",
|
||||||
sashrelief="flat" if w.MODERN else "raised",
|
|
||||||
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
|
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
|
||||||
pw.pack(fill="both", expand=True, padx=6, pady=6)
|
pw.pack(fill="both", expand=True, padx=0 if m else 6, pady=0 if m else 6)
|
||||||
|
|
||||||
# --- Händler-Fenster
|
# ---------------- Seitenleiste: Abrechnungen und Händler
|
||||||
self.f_h = w.Fenster(pw, "Händler", self.icons("haendler"))
|
# Win95: zwei eigene Flächen mit blauer Titelleiste auf dem Desktop
|
||||||
r, self.htree = w.scroll_tree(self.f_h.inhalt, columns=("anzahl", "summe"), show="tree headings",
|
# Eigener Pfeil-Zeiger für beide Bereiche: Sonst erben sie den Zeiger der Trennlinie, und der
|
||||||
selectmode="browse")
|
# bleibt unter Windows hängen (Resize-Zeiger mitten im Inhalt, v. a. maximiert).
|
||||||
self.htree.heading("#0", text="Händler", anchor="w")
|
links = tk.Frame(pw, bg=w.FACE if m else w.BG, cursor="arrow")
|
||||||
self.htree.heading("anzahl", text="Pos.")
|
innen = tk.Frame(links, bg=w.FACE if m else w.BG)
|
||||||
self.htree.heading("summe", text="Auszahlung")
|
innen.pack(fill="both", expand=True, padx=12 if m else 0, pady=(16, 10) if m else 0)
|
||||||
self.htree.column("#0", width=150, minwidth=80, stretch=False)
|
seite = dict(style="Seite.Treeview") if m else {}
|
||||||
self.htree.column("anzahl", width=38, anchor="e", stretch=False)
|
rand = {} if m else dict(padx=2, pady=(0, 2)) # Abstand der Liste zum Win95-Rahmen
|
||||||
self.htree.column("summe", width=84, anchor="e", stretch=False)
|
|
||||||
|
self.f_a = tk.Frame(innen, bg=w.FACE) if m else w.Karte(innen)
|
||||||
|
self.f_a.pack(fill="x", pady=(0, 16 if m else 6))
|
||||||
|
zeile = self._abschnitt_zeile(self.f_a, "Abrechnungen")
|
||||||
|
self.b_neu = w.knopf(zeile, "+ Neu", self.neu, padx=8, pady=0, **self._link())
|
||||||
|
self.b_neu.pack(side="right", padx=0 if m else 1)
|
||||||
|
w.Tooltip(self.b_neu, "Neue Abrechnung anlegen (Strg+N)")
|
||||||
|
r, self.atree = w.scroll_tree(self.f_a, columns=("status",), show="tree", height=5, selectmode="browse",
|
||||||
|
auto=True, **seite)
|
||||||
|
self.atree.column("#0", width=150, minwidth=80, stretch=False)
|
||||||
|
self.atree.column("status", width=110, anchor="e", stretch=False)
|
||||||
|
w.auto_breite(self.atree, {"#0": 1})
|
||||||
|
self.atree.tag_configure("leise", foreground=w.SHADOW)
|
||||||
|
self._punkte = {f: w.punkt(self.root, getattr(w, f)) for f in ("BLAU", "GOLD", "GRUEN", "ROT", "SHADOW")}
|
||||||
|
r.pack(fill="both", expand=True, **rand)
|
||||||
|
self.atree.bind("<<TreeviewSelect>>", self._abrechnung_gewaehlt)
|
||||||
|
|
||||||
|
self.f_h = tk.Frame(innen, bg=w.FACE) if m else w.Karte(innen)
|
||||||
|
self.f_h.pack(fill="both", expand=True)
|
||||||
|
zeile = self._abschnitt_zeile(self.f_h, "Händler")
|
||||||
|
self.b_haendler = w.knopf(zeile, "Verwalten", self.haendler, padx=8, pady=0, **self._link())
|
||||||
|
self.b_haendler.pack(side="right", padx=0 if m else 1)
|
||||||
|
w.Tooltip(self.b_haendler, "Händler und Kennungen verwalten (Strg+H)")
|
||||||
|
r, self.htree = w.scroll_tree(self.f_h, columns=("anzahl", "summe"), show="tree", selectmode="browse",
|
||||||
|
auto=True, **seite)
|
||||||
|
self.htree.column("#0", width=140, minwidth=80, stretch=False)
|
||||||
|
self.htree.column("anzahl", width=40, anchor="e", stretch=False)
|
||||||
|
self.htree.column("summe", width=96, anchor="e", stretch=False)
|
||||||
w.auto_breite(self.htree, {"#0": 1})
|
w.auto_breite(self.htree, {"#0": 1})
|
||||||
self.htree.tag_configure("grau", foreground=w.SHADOW)
|
self.htree.tag_configure("grau", foreground=w.SHADOW)
|
||||||
self.htree.tag_configure("fett", font=w.FONT_BOLD)
|
self.htree.tag_configure("fett", font=w.FONT_BOLD)
|
||||||
r.pack(fill="both", expand=True)
|
r.pack(fill="both", expand=True, **rand)
|
||||||
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
|
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
|
||||||
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
|
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
|
||||||
pw.add(self.f_h, minsize=240, width=310)
|
pw.add(links, minsize=240, width=320)
|
||||||
|
|
||||||
|
# ---------------- Inhalt: Kopf der Abrechnung, Kennzahlen, Positionen
|
||||||
|
rechts = tk.Frame(pw, bg=w.BG, cursor="arrow")
|
||||||
|
self._bereiche = (links, rechts)
|
||||||
|
innen = tk.Frame(rechts, bg=w.BG)
|
||||||
|
innen.pack(fill="both", expand=True, padx=(16, 20) if m else 0, pady=(16, 0) if m else 0)
|
||||||
|
|
||||||
|
kopf = tk.Frame(innen, bg=w.BG)
|
||||||
|
kopf.pack(fill="x", pady=(0, 14 if m else 6))
|
||||||
|
texte = tk.Frame(kopf, bg=w.BG)
|
||||||
|
texte.pack(side="left", fill="x", expand=True)
|
||||||
|
self.l_status = w.abschnitt(texte, "", bg=w.BG)
|
||||||
|
self.l_status.pack(anchor="w")
|
||||||
|
self.l_titel = tk.Label(texte, text="", bg=w.BG, font=w.FONT_TITEL, anchor="w")
|
||||||
|
self.l_titel.pack(anchor="w")
|
||||||
|
self.l_info = tk.Label(texte, text="", bg=w.BG, fg=w.TEXT2, anchor="w")
|
||||||
|
self.l_info.pack(anchor="w")
|
||||||
|
if not m: # Win95: weiße Schrift auf dem Desktop, wie die Symbolbeschriftungen
|
||||||
|
for lbl in (self.l_status, self.l_titel, self.l_info):
|
||||||
|
lbl.configure(fg=w.WHITE)
|
||||||
|
self.b_export = w.knopf(kopf, "Exportieren", self.exportieren, primaer=True)
|
||||||
|
self.b_export.pack(side="right", anchor="s")
|
||||||
|
w.Tooltip(self.b_export, "Händler-Abrechnungen als Excel speichern (Strg+E)")
|
||||||
|
self.b_import = w.knopf(kopf, "Importieren", self.importieren)
|
||||||
|
self.b_import.pack(side="right", anchor="s", padx=8)
|
||||||
|
w.Tooltip(self.b_import, "CSV-/Excel-Dateien einlesen (Strg+I)")
|
||||||
|
self.b_erledigt = w.knopf(kopf, "Als erledigt markieren", self.erledigt_umschalten)
|
||||||
|
self.b_erledigt.pack(side="right", anchor="s")
|
||||||
|
self.tb = {"Neu": self.b_neu, "Import": self.b_import, "Export": self.b_export, "Händler": self.b_haendler}
|
||||||
|
|
||||||
|
kacheln = tk.Frame(innen, bg=w.BG)
|
||||||
|
kacheln.pack(fill="x", pady=(0, 14 if m else 6))
|
||||||
|
self.kpi: dict[str, tuple[tk.Label, tk.Label]] = {}
|
||||||
|
for i, (key, titel) in enumerate((("pos", "Positionen"), ("orig", "Provision"), ("ausz", "Auszahlung"),
|
||||||
|
("air", "Airtime"))):
|
||||||
|
k = w.Karte(kacheln)
|
||||||
|
k.grid(row=0, column=i, sticky="nsew", padx=(0 if i == 0 else 7, 0 if i == 3 else 7))
|
||||||
|
kacheln.columnconfigure(i, weight=1, uniform="kpi")
|
||||||
|
ki = tk.Frame(k, bg=w.FACE)
|
||||||
|
ki.pack(fill="both", expand=True, padx=16 if m else 8, pady=12 if m else 6)
|
||||||
|
w.abschnitt(ki, titel).pack(anchor="w")
|
||||||
|
farbe = {"ausz": w.NAVY, "air": w.GRUEN}.get(key, w.DARK)
|
||||||
|
wert = tk.Label(ki, text="", font=w.FONT_ZAHL, fg=farbe, anchor="w")
|
||||||
|
wert.pack(anchor="w", pady=(4, 0))
|
||||||
|
info = tk.Label(ki, text="", fg=w.TEXT2, anchor="w")
|
||||||
|
info.pack(anchor="w")
|
||||||
|
self.kpi[key] = (wert, info)
|
||||||
|
|
||||||
|
self.f_p = w.Karte(innen)
|
||||||
|
self.f_p.pack(fill="both", expand=True, pady=(0, 12 if m else 0))
|
||||||
|
if not m: # Win95: Titel in der blauen Leiste, Filter darunter wie eine Werkzeugleiste
|
||||||
|
_, self.l_ptitel = w.titelleiste(self.f_p, "Positionen")
|
||||||
|
kopfzeile = tk.Frame(self.f_p, bg=w.FACE)
|
||||||
|
kopfzeile.pack(fill="x", padx=14 if m else 4, pady=10 if m else 4)
|
||||||
|
if m:
|
||||||
|
self.l_ptitel = tk.Label(kopfzeile, text="Positionen", font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold"))
|
||||||
|
self.l_ptitel.pack(side="left", padx=(0, 12))
|
||||||
|
self.pills: dict[str, w.Pill] = {}
|
||||||
|
for key, text in self.FILTER:
|
||||||
|
self.pills[key] = w.Pill(kopfzeile, text, lambda k=key: self._filter_setzen(k), aktiv=key == self.filter)
|
||||||
|
self.pills[key].pack(side="left", padx=(0, 6))
|
||||||
|
self.l_auswahl = tk.Label(kopfzeile, text="", fg=w.TEXT2)
|
||||||
|
self.l_auswahl.pack(side="right")
|
||||||
|
if m:
|
||||||
|
w.trenner(self.f_p, vertikal=False).pack(fill="x")
|
||||||
|
|
||||||
# --- Positionen-Fenster
|
|
||||||
self.f_p = w.Fenster(pw, "Positionen", self.icons("neu"))
|
|
||||||
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz")
|
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz")
|
||||||
r2, self.ptree = w.scroll_tree(self.f_p.inhalt, columns=cols, show="tree headings", selectmode="extended")
|
r2, self.ptree = w.scroll_tree(self.f_p, columns=cols, show="tree headings", selectmode="extended")
|
||||||
self.ptree.heading("#0", text="Provisionsart / Kunde", anchor="w")
|
if m:
|
||||||
|
r2.configure(highlightthickness=0)
|
||||||
|
kopf_text = (lambda t: t.upper()) if m else (lambda t: t)
|
||||||
|
self.ptree.heading("#0", text=kopf_text("Provisionsart / Kunde"), anchor="w")
|
||||||
self.ptree.column("#0", width=200, minwidth=140, stretch=False)
|
self.ptree.column("#0", width=200, minwidth=140, stretch=False)
|
||||||
for c, t, b, a in (("datum", "Datum", 76, "w"), ("rufnummer", "Rufnummer", 100, "w"),
|
for c, t, b, a in (("datum", "Datum", 86, "w"), ("rufnummer", "Rufnummer", 112, "w"),
|
||||||
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 84, "w"),
|
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 94, "w"),
|
||||||
("kennung", "Kennung", 70, "w"),
|
("kennung", "Kennung", 80, "w"),
|
||||||
("orig", "Provision", 78, "e"), ("proz", "%", 52, "e"), ("ausz", "Auszahlung", 84, "e")):
|
("orig", "Provision", 100, "e"), ("proz", "%", 64, "e"), ("ausz", "Auszahlung", 112, "e")):
|
||||||
self.ptree.heading(c, text=t, anchor=a)
|
self.ptree.heading(c, text=kopf_text(t), anchor=a)
|
||||||
self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False)
|
self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False)
|
||||||
w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4})
|
w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4})
|
||||||
self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD)
|
self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD)
|
||||||
self.ptree.tag_configure("manuell", foreground="#0000c0")
|
self.ptree.tag_configure("zebra", background=w.ZEBRA)
|
||||||
self.ptree.tag_configure("abw", foreground="#a00000")
|
self.ptree.tag_configure("manuell", foreground=w.MANUELL)
|
||||||
self.ptree.tag_configure("negativ", foreground="#a00000")
|
self.ptree.tag_configure("abw", foreground=w.ROT)
|
||||||
|
self.ptree.tag_configure("negativ", foreground=w.ROT)
|
||||||
self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW)
|
self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW)
|
||||||
r2.pack(fill="both", expand=True)
|
r2.pack(fill="both", expand=True)
|
||||||
self.ptree.bind("<Double-1>", self._doppelklick)
|
self.ptree.bind("<Double-1>", self._doppelklick)
|
||||||
self.ptree.bind("<Button-3>", self._kontextmenue)
|
self.ptree.bind("<Button-3>", self._kontextmenue)
|
||||||
self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl())
|
self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl())
|
||||||
|
|
||||||
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
|
leiste = tk.Frame(self.f_p, bg=w.FACE)
|
||||||
leiste.pack(fill="x", pady=(4, 0))
|
leiste.pack(fill="x", padx=14 if m else 2, pady=10 if m else (4, 2))
|
||||||
self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, self.icons("prozent"))
|
self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, None if m else self.icons("prozent"))
|
||||||
self.b_prozent.pack(side="left")
|
self.b_prozent.pack(side="left")
|
||||||
w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
|
w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten,
|
||||||
|
None if m else self.icons("rechner")).pack(side="left", padx=6)
|
||||||
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
|
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
|
||||||
self.v_alle_auf = tk.BooleanVar(value=True)
|
|
||||||
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
|
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
|
||||||
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
|
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=6)
|
||||||
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend · Grau = abgerechnet", fg=w.SHADOW).pack(
|
pw.add(rechts, minsize=560)
|
||||||
side="left", padx=8)
|
|
||||||
pw.add(self.f_p, minsize=500)
|
@staticmethod
|
||||||
|
def _abschnitt_zeile(master, titel: str) -> tk.Frame:
|
||||||
|
"""Überschrift eines Abschnitts der Seitenleiste; Win95 als blaue Titelleiste. Knöpfe kommen rechts hinein."""
|
||||||
|
if not w.MODERN:
|
||||||
|
return w.titelleiste(master, titel)[0]
|
||||||
|
zeile = tk.Frame(master, bg=w.FACE)
|
||||||
|
zeile.pack(fill="x", pady=(0, 4))
|
||||||
|
w.abschnitt(zeile, titel).pack(side="left", padx=(8, 0))
|
||||||
|
return zeile
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _link() -> dict:
|
||||||
|
"""Kleine Textknöpfe in der Seitenleiste (modern ohne Rahmen, in Akzentfarbe)."""
|
||||||
|
return dict(fg=w.NAVY, highlightthickness=0, font=w.FONT_BOLD) if w.MODERN else {}
|
||||||
|
|
||||||
|
def _filter_setzen(self, key: str):
|
||||||
|
self.filter = key
|
||||||
|
for k, pill in self.pills.items():
|
||||||
|
pill.setzen(k == key)
|
||||||
|
self._filtern()
|
||||||
|
|
||||||
def _tasten(self):
|
def _tasten(self):
|
||||||
r = self.root
|
r = self.root
|
||||||
@@ -274,9 +450,10 @@ class App:
|
|||||||
|
|
||||||
def _aktualisieren(self, behalte_auswahl: bool = True):
|
def _aktualisieren(self, behalte_auswahl: bool = True):
|
||||||
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
|
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
|
||||||
if self.aid and self.db.abrechnung(self.aid):
|
a = self.db.abrechnung(self.aid) if self.aid else None
|
||||||
|
if a:
|
||||||
self.positionen = self.db.positionen(self.aid)
|
self.positionen = self.db.positionen(self.aid)
|
||||||
titel = self.db.abrechnung(self.aid)["titel"] + (" – erledigt" if self.db.erledigt(self.aid) else "")
|
titel = a["titel"] + (" – erledigt" if a["erledigt"] else "")
|
||||||
self.rahmen.set_titel(f"{w.APP_NAME} - [{titel}]")
|
self.rahmen.set_titel(f"{w.APP_NAME} - [{titel}]")
|
||||||
self.status.set("info", f" Abrechnung: {titel}")
|
self.status.set("info", f" Abrechnung: {titel}")
|
||||||
else:
|
else:
|
||||||
@@ -284,9 +461,45 @@ class App:
|
|||||||
self.positionen = []
|
self.positionen = []
|
||||||
self.rahmen.set_titel(w.APP_NAME)
|
self.rahmen.set_titel(w.APP_NAME)
|
||||||
self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import")
|
self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import")
|
||||||
|
self._abrechnungsliste()
|
||||||
|
self._seitenkopf(a)
|
||||||
self._haendlerliste(behalte_auswahl)
|
self._haendlerliste(behalte_auswahl)
|
||||||
self._tabelle()
|
self._tabelle()
|
||||||
|
|
||||||
|
def _seitenkopf(self, a):
|
||||||
|
"""Titel, Status und Knöpfe über den Kennzahlen (a: Zeile der Abrechnung oder None)."""
|
||||||
|
if not a:
|
||||||
|
self.l_status.configure(text="Keine Abrechnung geöffnet".upper() if w.MODERN else "Keine Abrechnung")
|
||||||
|
self.l_titel.configure(text="Willkommen")
|
||||||
|
self.l_info.configure(text="Links „+ Neu“ anlegen oder gleich Dateien importieren.")
|
||||||
|
self.b_erledigt.configure(text="Als erledigt markieren", state="disabled")
|
||||||
|
return
|
||||||
|
erledigt = a["erledigt"]
|
||||||
|
dateien = self.db.dateien(self.aid)
|
||||||
|
status = "erledigt" if erledigt else "offen"
|
||||||
|
self.l_status.configure(text=f"Abrechnung · {status}".upper() if w.MODERN else f"Abrechnung ({status})")
|
||||||
|
self.l_titel.configure(text=a["titel"])
|
||||||
|
self.l_info.configure(text=f"{len(dateien)} {'Datei' if len(dateien) == 1 else 'Dateien'} importiert · "
|
||||||
|
f"{sum(d['anzahl'] for d in dateien)} Zeilen" if dateien else "Noch nichts importiert")
|
||||||
|
self.b_erledigt.configure(text="Wieder öffnen" if erledigt else "Als erledigt markieren", state="normal")
|
||||||
|
|
||||||
|
def _abrechnungsliste(self):
|
||||||
|
t = self.atree
|
||||||
|
t.delete(*t.get_children())
|
||||||
|
for a in self.db.abrechnungen():
|
||||||
|
status, farbe = (("erledigt", "GRUEN") if a["erledigt"] else
|
||||||
|
(f"{a['abgerechnet']} abgerechnet", "GOLD") if a["abgerechnet"] else ("offen", "BLAU"))
|
||||||
|
t.insert("", "end", iid=str(a["id"]), text=" " + a["titel"], values=(status,), image=self._punkte[farbe])
|
||||||
|
if self.aid and t.exists(str(self.aid)):
|
||||||
|
t.selection_set(str(self.aid))
|
||||||
|
t.see(str(self.aid))
|
||||||
|
|
||||||
|
def _abrechnung_gewaehlt(self, _e=None):
|
||||||
|
sel = self.atree.selection()
|
||||||
|
if sel and int(sel[0]) != self.aid:
|
||||||
|
self.ansicht = ALLE
|
||||||
|
self.oeffnen(int(sel[0]))
|
||||||
|
|
||||||
def _suche_planen(self):
|
def _suche_planen(self):
|
||||||
"""Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen)."""
|
"""Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen)."""
|
||||||
if self._suche_geplant is not None:
|
if self._suche_geplant is not None:
|
||||||
@@ -295,7 +508,7 @@ class App:
|
|||||||
|
|
||||||
def _suche_ausfuehren(self):
|
def _suche_ausfuehren(self):
|
||||||
self._suche_geplant = None
|
self._suche_geplant = None
|
||||||
self._tabelle()
|
self._filtern()
|
||||||
|
|
||||||
def _gefiltert(self) -> list[dict]:
|
def _gefiltert(self) -> list[dict]:
|
||||||
if self.ansicht == ALLE:
|
if self.ansicht == ALLE:
|
||||||
@@ -353,6 +566,8 @@ class App:
|
|||||||
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}"
|
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}"
|
||||||
|
|
||||||
def _tabelle(self):
|
def _tabelle(self):
|
||||||
|
"""Baut die Tabelle für die gewählte Ansicht komplett neu auf (nach Datenänderungen).
|
||||||
|
Suche und Filter blenden danach nur noch Zeilen aus und ein (_filtern) – kein Neuaufbau."""
|
||||||
if self._edit:
|
if self._edit:
|
||||||
self._edit.destroy()
|
self._edit.destroy()
|
||||||
self._edit = None
|
self._edit = None
|
||||||
@@ -360,59 +575,107 @@ class App:
|
|||||||
offen = {iid for iid in t.get_children() if t.item(iid, "open")}
|
offen = {iid for iid in t.get_children() if t.item(iid, "open")}
|
||||||
war_leer = not t.get_children()
|
war_leer = not t.get_children()
|
||||||
t.delete(*t.get_children())
|
t.delete(*t.get_children())
|
||||||
such = self.v_suche.get().strip().lower()
|
|
||||||
pos = self._gefiltert()
|
|
||||||
if such:
|
|
||||||
pos = [p for p in pos if such in " ".join(str(p.get(k) or "") for k in (
|
|
||||||
"rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
|
|
||||||
"imei")).lower()]
|
|
||||||
gruppen: dict[str, list[dict]] = defaultdict(list)
|
gruppen: dict[str, list[dict]] = defaultdict(list)
|
||||||
for p in exp.sortiert(pos):
|
for p in exp.sortiert(self._gefiltert()):
|
||||||
gruppen[self._gruppe_von(p)].append(p)
|
gruppen[self._gruppe_von(p)].append(p)
|
||||||
self._gruppen = {}
|
self._alle_gruppen = {} # giid -> (Name, alle Positionen der Gruppe)
|
||||||
|
self._zeilen_tags = {}
|
||||||
for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
|
for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
|
||||||
giid = f"g{gi}"
|
giid = f"g{gi}"
|
||||||
self._gruppen[giid] = (gname, plist)
|
self._alle_gruppen[giid] = (gname, plist)
|
||||||
so = sum(p["betrag_orig"] for p in plist)
|
t.insert("", "end", iid=giid, text="", open=(war_leer or giid in offen), tags=("gruppe",))
|
||||||
sa = sum(p["betrag"] for p in plist)
|
|
||||||
proz = {p["prozent"] for p in plist}
|
|
||||||
if all(_ist_staffel(p) for p in plist):
|
|
||||||
pz = "Staffel"
|
|
||||||
else:
|
|
||||||
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
|
|
||||||
t.insert("", "end", iid=giid, text=f"{gname} ({len(plist)})", open=(war_leer or giid in offen or bool(such)),
|
|
||||||
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
|
|
||||||
for p in plist:
|
for p in plist:
|
||||||
tags = []
|
tags: tuple[str, ...] = ()
|
||||||
if p["abgerechnet"] or p["doppelt"]:
|
if p["abgerechnet"] or p["doppelt"]:
|
||||||
tags.append("abgerechnet")
|
tags = ("abgerechnet",)
|
||||||
elif p["manuell"]:
|
elif p["manuell"]:
|
||||||
tags.append("manuell")
|
tags = ("manuell",)
|
||||||
elif p["betrag"] != p["betrag_orig"]:
|
elif p["betrag"] != p["betrag_orig"]:
|
||||||
tags.append("abw")
|
tags = ("abw",)
|
||||||
|
self._zeilen_tags[f"p{p['id']}"] = tags
|
||||||
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
|
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
|
||||||
t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
|
t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
|
||||||
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
||||||
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
|
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
|
||||||
fmt(p["betrag_orig"]),
|
fmt(p["betrag_orig"]),
|
||||||
"abgerechnet" if p["abgerechnet"] or p["doppelt"] else
|
"abgerechnet" if p["abgerechnet"] else "doppelt" if p["doppelt"] else
|
||||||
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
|
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
|
||||||
fmt(p["betrag"])),
|
fmt(p["betrag"])),
|
||||||
tags=tuple(tags))
|
tags=tags)
|
||||||
|
self._filtern()
|
||||||
|
|
||||||
|
def _filtern(self):
|
||||||
|
"""Suche und Filter anwenden: nicht passende Zeilen werden nur ausgehängt (detach), passende wieder
|
||||||
|
eingehängt – viel schneller als Löschen und Neuanlegen. Gruppenzeilen und Kennzahlen folgen."""
|
||||||
|
t = self.ptree
|
||||||
|
such = self.v_suche.get().strip().lower()
|
||||||
|
pruef = {"unzugeordnet": lambda p: p["haendler_id"] == UNZUGEORDNET, "manuell": lambda p: p["manuell"],
|
||||||
|
"doppelt": lambda p: p["doppelt"], "abgerechnet": lambda p: p["abgerechnet"]}.get(self.filter)
|
||||||
|
|
||||||
|
def passt(p: dict) -> bool:
|
||||||
|
if pruef and not pruef(p):
|
||||||
|
return False
|
||||||
|
return not such or such in " ".join(str(p.get(k) or "") for k in (
|
||||||
|
"rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
|
||||||
|
"imei")).lower()
|
||||||
|
|
||||||
|
self._gruppen = {} # giid -> (Name, sichtbare Positionen) – für Auswahl über die Gruppenzeile
|
||||||
|
pos: list[dict] = []
|
||||||
|
sichtbar_gi = 0
|
||||||
|
for giid, (gname, plist) in self._alle_gruppen.items():
|
||||||
|
treffer = []
|
||||||
|
for p in plist:
|
||||||
|
iid = f"p{p['id']}"
|
||||||
|
if passt(p):
|
||||||
|
t.move(iid, giid, len(treffer))
|
||||||
|
t.item(iid, tags=self._zeilen_tags[iid] + (("zebra",) if len(treffer) % 2 else ()))
|
||||||
|
treffer.append(p)
|
||||||
|
else:
|
||||||
|
t.detach(iid)
|
||||||
|
if not treffer:
|
||||||
|
t.detach(giid)
|
||||||
|
continue
|
||||||
|
t.move(giid, "", sichtbar_gi)
|
||||||
|
sichtbar_gi += 1
|
||||||
|
self._gruppen[giid] = (gname, treffer)
|
||||||
|
pos += treffer
|
||||||
|
so = sum(p["betrag_orig"] for p in treffer)
|
||||||
|
sa = sum(p["betrag"] for p in treffer)
|
||||||
|
proz = {p["prozent"] for p in treffer}
|
||||||
|
if all(_ist_staffel(p) for p in treffer):
|
||||||
|
pz = "Staffel"
|
||||||
|
else:
|
||||||
|
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
|
||||||
|
t.item(giid, text=f"{gname} ({len(treffer)})", values=("", "", "", "", "", fmt(so), pz, fmt(sa)))
|
||||||
|
if such:
|
||||||
|
t.item(giid, open=True)
|
||||||
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
|
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
|
||||||
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
|
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
|
||||||
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
|
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
|
||||||
self.f_p.set_titel(f"Positionen – {name}")
|
self.l_ptitel.configure(text=f"Positionen – {name}")
|
||||||
so = sum(p["betrag_orig"] for p in pos)
|
so = sum(p["betrag_orig"] for p in pos)
|
||||||
sa = sum(p["betrag"] for p in pos)
|
sa = sum(p["betrag"] for p in pos)
|
||||||
|
air = [p for p in pos if p.get("typ") == regeln.AIRTIME]
|
||||||
self.status.set("pos", f" {len(pos)} Positionen")
|
self.status.set("pos", f" {len(pos)} Positionen")
|
||||||
self.status.set("orig", f" Provision: {fmt(so)}")
|
self.status.set("orig", f" Provision: {fmt(so)}")
|
||||||
self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
|
self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
|
||||||
|
self._kennzahl("pos", str(len(pos)), name)
|
||||||
|
self._kennzahl("orig", fmt(so), "laut Anbieter")
|
||||||
|
self._kennzahl("ausz", fmt(sa), f"{round(sa * 100 / so)} % im Schnitt" if so > 0 else "")
|
||||||
|
self._kennzahl("air", fmt(sum(p["betrag"] for p in air)),
|
||||||
|
(f"{len(air)} Posten · eigene Excel-Datei" if any(p["betrag"] for p in air) else f"{len(air)} Posten")
|
||||||
|
if air else "keine Airtime")
|
||||||
|
self.l_auswahl.configure(text="")
|
||||||
|
|
||||||
|
def _kennzahl(self, key: str, wert: str, info: str):
|
||||||
|
self.kpi[key][0].configure(text=wert)
|
||||||
|
self.kpi[key][1].configure(text=info)
|
||||||
|
|
||||||
def _status_auswahl(self):
|
def _status_auswahl(self):
|
||||||
pids = self._ausgewaehlte_pids()
|
pids = self._ausgewaehlte_pids()
|
||||||
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())):
|
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())):
|
||||||
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
||||||
|
self.l_auswahl.configure(text=f"{len(ps)} ausgewählt · {fmt(sum(p['betrag'] for p in ps))}")
|
||||||
self.status.set("pos", f" {len(ps)} ausgewählt")
|
self.status.set("pos", f" {len(ps)} ausgewählt")
|
||||||
self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}")
|
self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}")
|
||||||
self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
|
self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
|
||||||
@@ -431,13 +694,6 @@ class App:
|
|||||||
return list(dict.fromkeys(pids))
|
return list(dict.fromkeys(pids))
|
||||||
|
|
||||||
# ================================================================= Aktionen
|
# ================================================================= Aktionen
|
||||||
def _brauche_abrechnung(self, frage_neu=True) -> bool:
|
|
||||||
if self.aid:
|
|
||||||
return True
|
|
||||||
if frage_neu:
|
|
||||||
return self.neu()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _vorschlag_titel(self) -> str:
|
def _vorschlag_titel(self) -> str:
|
||||||
heute = date.today()
|
heute = date.today()
|
||||||
m = heute.month - 1 or 12
|
m = heute.month - 1 or 12
|
||||||
@@ -466,7 +722,7 @@ class App:
|
|||||||
else:
|
else:
|
||||||
self._aktualisieren()
|
self._aktualisieren()
|
||||||
|
|
||||||
def importieren(self, dateien: list[str] | None = None):
|
def importieren(self, dateien: list[str] | None = None, ziel: int | None = None):
|
||||||
if not dateien:
|
if not dateien:
|
||||||
dateien = list(filedialog.askopenfilenames(
|
dateien = list(filedialog.askopenfilenames(
|
||||||
parent=self.root, title="Provisionsabrechnungen importieren",
|
parent=self.root, title="Provisionsabrechnungen importieren",
|
||||||
@@ -477,10 +733,27 @@ class App:
|
|||||||
if not dateien:
|
if not dateien:
|
||||||
return
|
return
|
||||||
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
|
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
|
||||||
if not self._brauche_abrechnung() or self._gesperrt():
|
if ziel is None: # jedes Mal fragen, in welche Abrechnung
|
||||||
|
if not self.db.abrechnungen():
|
||||||
|
if not self.neu():
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
res = dlg.AbrechnungenDialog(self.root, self.db, self.aid, fuer_import=True).zeigen()
|
||||||
|
if not res:
|
||||||
|
return
|
||||||
|
if res[0] == "neu":
|
||||||
|
if not self.neu():
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.aid, self.ansicht = res[1], ALLE
|
||||||
|
else:
|
||||||
|
self.aid = ziel
|
||||||
|
if self._gesperrt():
|
||||||
return
|
return
|
||||||
bericht = []
|
bericht = []
|
||||||
self.root.configure(cursor="watch")
|
importiert: list[tuple[int, int, list[str]]] = [] # (Zeile im Bericht, datei_id, Hinweise)
|
||||||
|
for f in (self.root, *self._bereiche):
|
||||||
|
f.configure(cursor="watch")
|
||||||
self.root.update()
|
self.root.update()
|
||||||
try:
|
try:
|
||||||
for f in dateien:
|
for f in dateien:
|
||||||
@@ -501,15 +774,22 @@ class App:
|
|||||||
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
|
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
|
||||||
continue
|
continue
|
||||||
n = self.db.importieren(self.aid, erg)
|
n = self.db.importieren(self.aid, erg)
|
||||||
did = self.db.dateien(self.aid)[-1]["id"]
|
importiert.append((len(bericht), self.db.dateien(self.aid)[-1]["id"], list(erg.warnungen)))
|
||||||
doppelt = sum(1 for p in self.db.positionen(self.aid) if p["datei_id"] == did and p["doppelt"])
|
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen), ""))
|
||||||
hinweise = list(erg.warnungen) + ([f"{doppelt} Pos. bereits abgerechnet (werden nicht erneut "
|
|
||||||
"exportiert)"] if doppelt else [])
|
|
||||||
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
|
|
||||||
"; ".join(hinweise) or "OK"))
|
|
||||||
finally:
|
finally:
|
||||||
self.root.configure(cursor="")
|
self.root.configure(cursor="")
|
||||||
|
for f in self._bereiche:
|
||||||
|
f.configure(cursor="arrow")
|
||||||
self._aktualisieren()
|
self._aktualisieren()
|
||||||
|
# Doppelte (schon abgerechnet oder schon offen vorhanden) je Datei zählen – einmal für alle Dateien
|
||||||
|
doppelt: dict[int, int] = defaultdict(int)
|
||||||
|
for p in self.positionen:
|
||||||
|
if p["doppelt"]:
|
||||||
|
doppelt[p["datei_id"]] += 1
|
||||||
|
for zeile, did, hinweise in importiert:
|
||||||
|
if doppelt.get(did):
|
||||||
|
hinweise.append(f"{doppelt[did]} Pos. doppelt bzw. bereits abgerechnet (werden nicht exportiert)")
|
||||||
|
bericht[zeile] = bericht[zeile][:4] + ("; ".join(hinweise) or "OK",)
|
||||||
unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
||||||
for p in self.positionen:
|
for p in self.positionen:
|
||||||
if p["haendler_id"] == UNZUGEORDNET:
|
if p["haendler_id"] == UNZUGEORDNET:
|
||||||
@@ -871,6 +1151,8 @@ class App:
|
|||||||
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
|
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
|
||||||
"Bitte zuerst Dateien importieren.")
|
"Bitte zuerst Dateien importieren.")
|
||||||
return
|
return
|
||||||
|
if self._gesperrt(): # erledigt: nichts mehr exportieren (und damit abrechnen)
|
||||||
|
return
|
||||||
# nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet)
|
# nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet)
|
||||||
offen = [p for p in self.positionen if p["haendler_id"] > 0 and not p["abgerechnet"] and not p["doppelt"]]
|
offen = [p for p in self.positionen if p["haendler_id"] > 0 and not p["abgerechnet"] and not p["doppelt"]]
|
||||||
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
||||||
@@ -895,12 +1177,14 @@ class App:
|
|||||||
config.save(self.settings)
|
config.save(self.settings)
|
||||||
erzeugt, exportiert = [], []
|
erzeugt, exportiert = [], []
|
||||||
zeit = res["zeitraum"]
|
zeit = res["zeitraum"]
|
||||||
|
vergeben: set[str] = set() # Dateinamen dieses Exports – gleich lautende Händler überschreiben sich nicht
|
||||||
try:
|
try:
|
||||||
for hid in res["ids"]:
|
for hid in res["ids"]:
|
||||||
pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))]
|
pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))]
|
||||||
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
|
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
|
||||||
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
|
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
|
||||||
summe=res["summe"], null_weglassen=res["null_weglassen"])
|
summe=res["summe"], null_weglassen=res["null_weglassen"],
|
||||||
|
vergeben=vergeben)
|
||||||
exportiert += pos
|
exportiert += pos
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
|
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
|
||||||
@@ -968,15 +1252,108 @@ class App:
|
|||||||
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
|
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
|
||||||
if not res:
|
if not res:
|
||||||
return
|
return
|
||||||
if res["design"] != w.DESIGN:
|
|
||||||
self.settings["design"] = res["design"]
|
|
||||||
config.save(self.settings)
|
|
||||||
w.info(self.root, "Einstellungen", "Das neue Design wird beim nächsten Programmstart wirksam.")
|
|
||||||
if res["pfad"] and Path(res["pfad"]) != self.db.path:
|
if res["pfad"] and Path(res["pfad"]) != self.db.path:
|
||||||
try:
|
try:
|
||||||
self.db_wechseln(res["pfad"])
|
self.db_wechseln(res["pfad"])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
|
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
|
||||||
|
self.settings["update_pruefen"] = res["update"]
|
||||||
|
config.save(self.settings)
|
||||||
|
if res["design"] != w.DESIGN:
|
||||||
|
self.design_wechseln(res["design"])
|
||||||
|
|
||||||
|
# ================================================================= Updates
|
||||||
|
@staticmethod
|
||||||
|
def _installiert() -> bool:
|
||||||
|
"""Läuft die per Setup installierte Windows-Version? Nur die kann sich selbst aktualisieren."""
|
||||||
|
return (bool(getattr(sys, "frozen", False)) and sys.platform.startswith("win")
|
||||||
|
and Path(sys.executable).name.lower() == "provisionsmanager.exe")
|
||||||
|
|
||||||
|
def _im_hintergrund(self, arbeit, fertig):
|
||||||
|
"""arbeit() im Thread (nur Netzwerk/Dateien, kein Tk), danach fertig(ergebnis) im Tk-Thread."""
|
||||||
|
erg: dict = {}
|
||||||
|
|
||||||
|
def lauf():
|
||||||
|
try:
|
||||||
|
erg["wert"] = arbeit()
|
||||||
|
except Exception as exc:
|
||||||
|
erg["fehler"] = exc
|
||||||
|
threading.Thread(target=lauf, daemon=True).start()
|
||||||
|
|
||||||
|
def warten():
|
||||||
|
if self._beendet or self._schliesst:
|
||||||
|
return
|
||||||
|
if not erg:
|
||||||
|
self.root.after(200, warten)
|
||||||
|
else:
|
||||||
|
fertig(erg)
|
||||||
|
self.root.after(200, warten)
|
||||||
|
|
||||||
|
def updates_pruefen(self, still: bool = False):
|
||||||
|
"""Auf Gitea nach einer neueren Version suchen. still = automatisch beim Start: nur melden, wenn es
|
||||||
|
eine gibt (keine Meldung bei Fehler oder wenn aktuell)."""
|
||||||
|
if getattr(self, "_update_laeuft", False):
|
||||||
|
return
|
||||||
|
self._update_laeuft = True
|
||||||
|
|
||||||
|
def fertig(erg):
|
||||||
|
self._update_laeuft = False
|
||||||
|
self._update_melden(erg, still)
|
||||||
|
self._im_hintergrund(updater.neueste, fertig)
|
||||||
|
|
||||||
|
def _update_melden(self, erg: dict, still: bool):
|
||||||
|
if "fehler" in erg:
|
||||||
|
if not still:
|
||||||
|
w.warnung(self.root, "Updates", f"Konnte nicht nach Updates suchen:\n{erg['fehler']}")
|
||||||
|
return
|
||||||
|
info = erg["wert"]
|
||||||
|
if not updater.ist_neuer(info["version"], __version__):
|
||||||
|
if not still:
|
||||||
|
w.info(self.root, "Updates", f"Sie haben die neueste Version ({__version__}).")
|
||||||
|
return
|
||||||
|
if still and modal.offen(): # z. B. Tipp des Tages offen – später noch einmal
|
||||||
|
self.root.after(3000, lambda: self._update_melden(erg, still))
|
||||||
|
return
|
||||||
|
text = f"Version {info['version']} ist verfügbar (installiert: {__version__})."
|
||||||
|
if self._installiert() and info["setup_url"]:
|
||||||
|
if w.frage(self.root, "Update verfügbar",
|
||||||
|
text + "\n\nJetzt herunterladen und installieren? Die Datenbank wird vorher gesichert, das "
|
||||||
|
"Programm beendet sich kurz und startet danach von selbst wieder.",
|
||||||
|
ja="Jetzt installieren", nein="Später"):
|
||||||
|
self._update_installieren(info)
|
||||||
|
elif w.frage(self.root, "Update verfügbar", text + "\n\nDownload-Seite öffnen?",
|
||||||
|
ja="Seite öffnen", nein="Später"):
|
||||||
|
webbrowser.open(info["seite"])
|
||||||
|
|
||||||
|
def _update_installieren(self, info: dict):
|
||||||
|
self.status.set("info", f" Lade Version {info['version']} herunter ...")
|
||||||
|
ordner = Path(tempfile.gettempdir()) / "Provisionsmanager-Update"
|
||||||
|
|
||||||
|
def fertig(erg):
|
||||||
|
if "fehler" in erg:
|
||||||
|
self.status.set("info", "")
|
||||||
|
w.fehler(self.root, "Update", f"Download fehlgeschlagen:\n{erg['fehler']}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.db.backup(config.app_dir() / f"vor-update-{__version__}.db")
|
||||||
|
except Exception as exc:
|
||||||
|
if not w.frage(self.root, "Update", f"Die Datenbank konnte nicht gesichert werden:\n{exc}\n\n"
|
||||||
|
"Trotzdem installieren?", ja="Installieren", nein="Abbrechen", default_ja=False):
|
||||||
|
return
|
||||||
|
# NEUSTART=1: das Setup startet das Programm danach wieder (installer.iss)
|
||||||
|
subprocess.Popen([str(erg["wert"]), "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART",
|
||||||
|
"/CLOSEAPPLICATIONS", "/NEUSTART=1"], close_fds=True)
|
||||||
|
self.beenden()
|
||||||
|
self._im_hintergrund(lambda: updater.herunterladen(info, ordner), fertig)
|
||||||
|
|
||||||
|
def design_umschalten(self):
|
||||||
|
self.design_wechseln("modern" if w.DUNKEL else "dunkel")
|
||||||
|
|
||||||
|
def design_wechseln(self, design: str):
|
||||||
|
"""Fenster mit dem neuen Design neu aufbauen (main() startet es nach dem Schließen wieder)."""
|
||||||
|
self.settings["design"] = design
|
||||||
|
self.neustart = True
|
||||||
|
self.root.after_idle(self.beenden)
|
||||||
|
|
||||||
def hilfe(self):
|
def hilfe(self):
|
||||||
w.info(self.root, "Kurzanleitung",
|
w.info(self.root, "Kurzanleitung",
|
||||||
@@ -1018,7 +1395,7 @@ class App:
|
|||||||
self.ansicht = ALLE
|
self.ansicht = ALLE
|
||||||
self._aktualisieren()
|
self._aktualisieren()
|
||||||
if res["dateien"]:
|
if res["dateien"]:
|
||||||
self.importieren(res["dateien"])
|
self.importieren(res["dateien"], ziel=self.aid)
|
||||||
if res["rundgang"]:
|
if res["rundgang"]:
|
||||||
self.root.after(200, self.rundgang)
|
self.root.after(200, self.rundgang)
|
||||||
|
|
||||||
@@ -1035,7 +1412,7 @@ class App:
|
|||||||
"(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."),
|
"(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."),
|
||||||
(tb["Neu"], "Neue Abrechnung",
|
(tb["Neu"], "Neue Abrechnung",
|
||||||
"Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. "
|
"Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. "
|
||||||
"Mit „Öffnen“ holen Sie frühere Abrechnungen wieder hervor."),
|
"Frühere Abrechnungen öffnen Sie mit einem Klick in der Liste darunter."),
|
||||||
(tb["Import"], "Dateien importieren",
|
(tb["Import"], "Dateien importieren",
|
||||||
"Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. "
|
"Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. "
|
||||||
"Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."),
|
"Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."),
|
||||||
@@ -1043,24 +1420,28 @@ class App:
|
|||||||
"Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n"
|
"Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n"
|
||||||
"„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
|
"„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
|
||||||
"Das wird nicht exportiert."),
|
"Das wird nicht exportiert."),
|
||||||
|
(self.pills["alle"], "Filter",
|
||||||
|
"Mit diesen Knöpfen zeigen Sie nur bestimmte Positionen: nicht zugeordnete, von Hand geänderte, "
|
||||||
|
"doppelte oder bereits abgerechnete."),
|
||||||
(self.f_p, "Positionen prüfen",
|
(self.f_p, "Positionen prüfen",
|
||||||
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
|
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
|
||||||
"- Doppelklick auf eine Position: Betrag frei ändern\n"
|
"- Doppelklick auf eine Position: Betrag frei ändern\n"
|
||||||
"- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n"
|
"- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n"
|
||||||
"- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
|
"- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
|
||||||
(self.b_prozent, "Auszahlung in Prozent",
|
(self.b_prozent, "Auszahlung in Prozent",
|
||||||
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. "
|
"Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. "
|
||||||
"Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
|
"Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
|
||||||
(tb["Export"], "Exportieren",
|
(tb["Export"], "Exportieren",
|
||||||
"Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat "
|
"Zum Schluss erstellt „Exportieren“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat "
|
||||||
"(Airtime darin als eine Summenzeile) und – falls vorhanden – die Airtime-Liste."),
|
"(Airtime darin als eine Summenzeile) und – falls vorhanden – die Airtime-Liste."),
|
||||||
(self.status, "Summen",
|
(self.status, "Summen",
|
||||||
"Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: "
|
"Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: "
|
||||||
"Provision laut Anbieter und Ihre Auszahlung."),
|
"Provision laut Anbieter und Ihre Auszahlung."),
|
||||||
(self._suchrahmen, "Suchen",
|
(self._suchrahmen, "Suchen",
|
||||||
"Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."),
|
"Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."),
|
||||||
(self.menueleiste.knoepfe["?"], "Das war's!",
|
(self.menueleiste.knoepfe[self.hilfe_menue], "Das war's!",
|
||||||
"Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü „?“.\n\n"
|
"Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü "
|
||||||
|
f"„{self.hilfe_menue}“.\n\n"
|
||||||
f"Viel Erfolg mit dem {w.APP_NAME}!"),
|
f"Viel Erfolg mit dem {w.APP_NAME}!"),
|
||||||
]
|
]
|
||||||
self._rundgang = ass.Rundgang(self, schritte)
|
self._rundgang = ass.Rundgang(self, schritte)
|
||||||
@@ -1135,12 +1516,12 @@ class App:
|
|||||||
try:
|
try:
|
||||||
self.settings["trenner"] = self.pw.sash_coord(0)[0]
|
self.settings["trenner"] = self.pw.sash_coord(0)[0]
|
||||||
self.settings["fenster"] = self.rahmen.normale_geometrie()
|
self.settings["fenster"] = self.rahmen.normale_geometrie()
|
||||||
self.settings["maximiert"] = self.rahmen.maximiert
|
|
||||||
config.save(self.settings)
|
config.save(self.settings)
|
||||||
except (tk.TclError, OSError):
|
except (tk.TclError, OSError):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
if self.db is not None:
|
||||||
self.db.close()
|
self.db.close()
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -1164,11 +1545,20 @@ def main():
|
|||||||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
dateien = [a for a in sys.argv[1:] if Path(a).exists()]
|
||||||
|
erster = True
|
||||||
|
while True:
|
||||||
root = tk.Tk(className="Provisionsmanager95")
|
root = tk.Tk(className="Provisionsmanager95")
|
||||||
app = App(root)
|
app = App(root)
|
||||||
if len(sys.argv) > 1:
|
if dateien:
|
||||||
root.after(200, lambda: app.importieren([a for a in sys.argv[1:] if Path(a).exists()]))
|
root.after(200, lambda: app.importieren(dateien))
|
||||||
|
dateien = []
|
||||||
|
if erster and app.settings.get("update_pruefen", True): # nur im echten Start, nicht in Tests
|
||||||
|
root.after(3000, lambda: app.updates_pruefen(still=True))
|
||||||
|
erster = False
|
||||||
root.mainloop()
|
root.mainloop()
|
||||||
|
if not app.neustart: # sonst: Design wurde umgeschaltet – Fenster neu aufbauen
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ class Einrichtungsassistent(w.Dialog):
|
|||||||
self._img = w.make_icon(self, "app", 7)
|
self._img = w.make_icon(self, "app", 7)
|
||||||
c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="")
|
c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="")
|
||||||
for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))):
|
for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))):
|
||||||
c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill=w.WHITE, outline="")
|
c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill="#ffffff", outline="")
|
||||||
c.create_image(77, self.HOEHE // 2 - 10, image=self._img)
|
c.create_image(77, self.HOEHE // 2 - 10, image=self._img)
|
||||||
c.create_text(77, self.HOEHE - 38, text=w.APP_NAME.replace("-", "-\n", 1), fill=w.WHITE, font=w.FONT_BOLD,
|
c.create_text(77, self.HOEHE - 38, text=w.APP_NAME.replace("-", "-\n", 1), fill="#ffffff", font=w.FONT_BOLD,
|
||||||
justify="center")
|
justify="center")
|
||||||
|
|
||||||
def _leeren(self):
|
def _leeren(self):
|
||||||
|
|||||||
+53
-18
@@ -14,7 +14,6 @@ from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
|
|||||||
|
|
||||||
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige
|
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige
|
||||||
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
|
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
|
||||||
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
|
|
||||||
CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
|
CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
|
||||||
|
|
||||||
|
|
||||||
@@ -295,6 +294,12 @@ class HaendlerDialog(w.Dialog):
|
|||||||
return
|
return
|
||||||
if self.v_kwert.get().strip():
|
if self.v_kwert.get().strip():
|
||||||
self._kennung_dazu()
|
self._kennung_dazu()
|
||||||
|
if self.aktuell and self.aktuell.aktiv and not self.v_aktiv.get() and not w.frage(
|
||||||
|
self, "Händler deaktivieren",
|
||||||
|
f"„{name}“ inaktiv setzen?\n\nSeine noch offenen Positionen gelten dann als „nicht zugeordnet“ "
|
||||||
|
"und zeigen wieder die volle Provision (100 %); exportiert werden sie nicht. Bereits "
|
||||||
|
"abgerechnete Positionen bleiben, wie sie sind.", ja="Inaktiv setzen", default_ja=False):
|
||||||
|
return
|
||||||
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
|
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
|
||||||
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air,
|
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air,
|
||||||
self._eigene_staffel if self.v_eigen_staffel.get() else None)
|
self._eigene_staffel if self.v_eigen_staffel.get() else None)
|
||||||
@@ -558,10 +563,13 @@ class ProvisionsartenDialog(w.Dialog):
|
|||||||
# =========================================================================== Abrechnungen
|
# =========================================================================== Abrechnungen
|
||||||
|
|
||||||
class AbrechnungenDialog(w.Dialog):
|
class AbrechnungenDialog(w.Dialog):
|
||||||
def __init__(self, master, db: Datenbank, aktuelle_id: int | None):
|
def __init__(self, master, db: Datenbank, aktuelle_id: int | None, fuer_import: bool = False):
|
||||||
super().__init__(master, "Abrechnung öffnen", resizable=True)
|
"""fuer_import: Ziel eines Imports wählen (Ergebnis ("oeffnen", id) oder ("neu",))."""
|
||||||
|
super().__init__(master, "Import – in welche Abrechnung?" if fuer_import else "Abrechnung öffnen",
|
||||||
|
resizable=True)
|
||||||
self.db = db
|
self.db = db
|
||||||
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
tk.Label(self, text="In welche Abrechnung sollen die Dateien importiert werden?" if fuer_import
|
||||||
|
else "Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||||
r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
|
r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
|
||||||
show="headings", height=12, selectmode="browse")
|
show="headings", height=12, selectmode="browse")
|
||||||
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
|
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
|
||||||
@@ -572,6 +580,10 @@ class AbrechnungenDialog(w.Dialog):
|
|||||||
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
|
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
|
||||||
k = tk.Frame(self, bg=w.FACE)
|
k = tk.Frame(self, bg=w.FACE)
|
||||||
k.pack(fill="x", padx=10, pady=10)
|
k.pack(fill="x", padx=10, pady=10)
|
||||||
|
if fuer_import:
|
||||||
|
w.knopf(k, "Hierhin importieren", self._oeffnen, default="active").pack(side="left")
|
||||||
|
w.knopf(k, "Neue Abrechnung...", self._neu).pack(side="left", padx=6)
|
||||||
|
else:
|
||||||
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
|
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
|
||||||
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
|
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
|
||||||
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
|
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
|
||||||
@@ -600,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:
|
||||||
@@ -617,7 +633,11 @@ class AbrechnungenDialog(w.Dialog):
|
|||||||
titel = self.db.abrechnung(aid)["titel"]
|
titel = self.db.abrechnung(aid)["titel"]
|
||||||
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
|
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
|
||||||
default_ja=False):
|
default_ja=False):
|
||||||
|
try:
|
||||||
self.db.abrechnung_loeschen(aid)
|
self.db.abrechnung_loeschen(aid)
|
||||||
|
except AbrechnungErledigt as exc:
|
||||||
|
w.info(self, "Löschen", str(exc))
|
||||||
|
return
|
||||||
self._laden()
|
self._laden()
|
||||||
self.result = ("geloescht", aid)
|
self.result = ("geloescht", aid)
|
||||||
|
|
||||||
@@ -857,10 +877,15 @@ class ExportDialog(w.Dialog):
|
|||||||
"""haendler: (id, name, anzahl, summe_cent)"""
|
"""haendler: (id, name, anzahl, summe_cent)"""
|
||||||
super().__init__(master, "Abrechnungen exportieren")
|
super().__init__(master, "Abrechnungen exportieren")
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
f = tk.Frame(self, bg=w.FACE)
|
rumpf = tk.Frame(self, bg=w.FACE)
|
||||||
f.pack(fill="both", expand=True, padx=10, pady=10)
|
rumpf.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
# zwei Spalten (links Händler, rechts Zeitraum und Optionen) – passt auch auf kleine Bildschirme
|
||||||
|
f = tk.Frame(rumpf, bg=w.FACE)
|
||||||
|
f.pack(side="left", fill="both", expand=True)
|
||||||
|
rechts = tk.Frame(rumpf, bg=w.FACE)
|
||||||
|
rechts.pack(side="left", fill="y", padx=(14, 0))
|
||||||
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
|
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
|
||||||
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8,
|
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=12,
|
||||||
selectmode="extended")
|
selectmode="extended")
|
||||||
self.tree.heading("#0", text="")
|
self.tree.heading("#0", text="")
|
||||||
self.tree.column("#0", width=40, stretch=False)
|
self.tree.column("#0", width=40, stretch=False)
|
||||||
@@ -880,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)
|
||||||
@@ -902,13 +927,14 @@ class ExportDialog(w.Dialog):
|
|||||||
("Summenzeile unter der Tabelle", self.v_sum),
|
("Summenzeile unter der Tabelle", self.v_sum),
|
||||||
("Ordner nach dem Export öffnen", self.v_auf)), start=2):
|
("Ordner nach dem Export öffnen", self.v_auf)), start=2):
|
||||||
tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w")
|
tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w")
|
||||||
tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine\n"
|
tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine "
|
||||||
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid(
|
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w",
|
||||||
|
wraplength=420).grid(
|
||||||
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
|
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
|
||||||
g.columnconfigure(1, weight=1)
|
g.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
z = w.gruppe(f, "Zeitraum und Abrechnung")
|
z = w.gruppe(rechts, "Zeitraum und Abrechnung")
|
||||||
z.pack(fill="x", pady=(8, 0))
|
z.pack(fill="x", before=g)
|
||||||
zf = tk.Frame(z, bg=w.FACE)
|
zf = tk.Frame(z, bg=w.FACE)
|
||||||
zf.pack(fill="x")
|
zf.pack(fill="x")
|
||||||
tk.Label(zf, text="Nur von:").pack(side="left")
|
tk.Label(zf, text="Nur von:").pack(side="left")
|
||||||
@@ -917,14 +943,18 @@ class ExportDialog(w.Dialog):
|
|||||||
tk.Label(zf, text="bis:").pack(side="left")
|
tk.Label(zf, text="bis:").pack(side="left")
|
||||||
tk.Entry(zf, textvariable=self.v_bis, width=11).pack(side="left", padx=4)
|
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))
|
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)])
|
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",
|
ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly",
|
||||||
width=40).pack(anchor="w", pady=(4, 0))
|
width=40).pack(anchor="w", pady=(4, 0))
|
||||||
tk.Label(z, text="(leer = alle offenen Positionen; Datum als TT.MM.JJJJ)", fg=w.SHADOW, anchor="w").pack(
|
tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)",
|
||||||
fill="x")
|
fg=w.SHADOW, anchor="w", justify="left", wraplength=420).pack(fill="x")
|
||||||
self.v_kennz = tk.BooleanVar(value=True)
|
self.v_kennz = tk.BooleanVar(value=True)
|
||||||
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
|
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
|
||||||
"einmal abgerechnet)", variable=self.v_kennz, anchor="w").pack(fill="x", pady=(4, 0))
|
"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):
|
def _vormonat(self):
|
||||||
@@ -1000,10 +1030,15 @@ class EinstellungenDialog(w.Dialog):
|
|||||||
g2 = w.gruppe(self, "Darstellung")
|
g2 = w.gruppe(self, "Darstellung")
|
||||||
g2.pack(fill="x", padx=10, pady=(0, 10))
|
g2.pack(fill="x", padx=10, pady=(0, 10))
|
||||||
self.v_design = tk.StringVar(value=settings.get("design", "modern"))
|
self.v_design = tk.StringVar(value=settings.get("design", "modern"))
|
||||||
tk.Radiobutton(g2, text="Modern (wie Windows 11)", variable=self.v_design, value="modern").pack(anchor="w")
|
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(
|
tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack(
|
||||||
anchor="w")
|
anchor="w")
|
||||||
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).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):
|
||||||
@@ -1014,7 +1049,7 @@ class EinstellungenDialog(w.Dialog):
|
|||||||
self.v.set(p)
|
self.v.set(p)
|
||||||
|
|
||||||
def _ok(self):
|
def _ok(self):
|
||||||
self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get())
|
self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get(), update=self.v_update.get())
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ class Menueleiste(tk.Frame):
|
|||||||
self.offener_knopf: tk.Label | None = None
|
self.offener_knopf: tk.Label | None = None
|
||||||
|
|
||||||
def menue(self, label: str, underline: int | None = 0) -> Popup:
|
def menue(self, label: str, underline: int | None = 0) -> Popup:
|
||||||
knopf = tk.Label(self, text=label, bg=w.FACE, padx=7, pady=2, bd=1, relief="flat",
|
knopf = tk.Label(self, text=label, bg=w.FACE, padx=10 if w.MODERN else 7, pady=6 if w.MODERN else 2,
|
||||||
|
bd=1, relief="flat",
|
||||||
underline=-1 if underline is None else underline)
|
underline=-1 if underline is None else underline)
|
||||||
knopf.pack(side="left")
|
knopf.pack(side="left")
|
||||||
popup = Popup(self)
|
popup = Popup(self)
|
||||||
|
|||||||
@@ -414,6 +414,11 @@ class Hauptrahmen:
|
|||||||
self._nativ_einrichten()
|
self._nativ_einrichten()
|
||||||
if maximiert and self.klassisch:
|
if maximiert and self.klassisch:
|
||||||
self.root.after(100, self.maximieren)
|
self.root.after(100, self.maximieren)
|
||||||
|
elif maximiert: # normaler Windows-Rahmen: Windows maximiert selbst
|
||||||
|
try:
|
||||||
|
self.root.state("zoomed")
|
||||||
|
except tk.TclError:
|
||||||
|
self.root.attributes("-zoomed", True) # Linux/X11
|
||||||
|
|
||||||
def normale_geometrie(self) -> str:
|
def normale_geometrie(self) -> str:
|
||||||
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
|
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
+13
-5
@@ -80,6 +80,19 @@ def hwnd(fenster: tk.Misc) -> int | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def dunkle_titelleiste(fenster: tk.Misc) -> None:
|
||||||
|
"""Windows-Titelleiste dunkel (Design „dunkel“, ab Windows 10 20H1)."""
|
||||||
|
h = hwnd(fenster)
|
||||||
|
if not h:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
an = ctypes.c_int(1)
|
||||||
|
ctypes.windll.dwmapi.DwmSetWindowAttribute(h, 20, ctypes.byref(an), ctypes.sizeof(an)) # IMMERSIVE_DARK_MODE
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def stapel(*fenster: tk.Misc) -> list[int]:
|
def stapel(*fenster: tk.Misc) -> list[int]:
|
||||||
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
|
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
|
||||||
ziele = [hwnd(f) for f in fenster]
|
ziele = [hwnd(f) for f in fenster]
|
||||||
@@ -178,11 +191,6 @@ def ist_bedienbar(fenster: tk.Misc) -> bool:
|
|||||||
return bool(user32().IsWindowEnabled(h)) if h else True
|
return bool(user32().IsWindowEnabled(h)) if h else True
|
||||||
|
|
||||||
|
|
||||||
def letztes_popup(fenster: tk.Misc) -> int | None:
|
|
||||||
h = hwnd(fenster)
|
|
||||||
return user32().GetLastActivePopup(h) if h else None
|
|
||||||
|
|
||||||
|
|
||||||
class Modal:
|
class Modal:
|
||||||
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
|
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
|
||||||
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
|
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
|
||||||
|
|||||||
+239
-92
@@ -22,17 +22,35 @@ INFO_BG = "#ffffe1"
|
|||||||
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
|
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
|
||||||
RAND = SHADOW # dünne Rahmenlinien (nur modern)
|
RAND = SHADOW # dünne Rahmenlinien (nur modern)
|
||||||
DESIGN = "win95"
|
DESIGN = "win95"
|
||||||
MODERN = False
|
MODERN = False # „modern“ oder „dunkel“
|
||||||
|
DUNKEL = False
|
||||||
APP_NAME = "Provisions-Manager 95" # modern: ohne „95“
|
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 = {
|
_PALETTEN = {
|
||||||
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
|
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
|
||||||
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
|
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
|
||||||
SEL_FG="#ffffff", RAND="#808080"),
|
SEL_FG="#ffffff", RAND="#808080", BG="#008080", RAND2="#808080", TEXT2="#000000",
|
||||||
"modern": dict(FACE="#f3f3f3", HILITE="#ffffff", LIGHT="#ebebeb", SHADOW="#8a8a8a", DARK="#1b1b1b",
|
PRIMAER="#c0c0c0", AUF_PRIMAER="#000000", ZEBRA="#ffffff", MANUELL="#0000c0", ROT="#a00000",
|
||||||
NAVY="#005fb8", TEAL="#e6eef6", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#cce4f7",
|
ROT_SOFT="#c0c0c0", GRUEN="#008000", GRUEN_SOFT="#c0c0c0", BLAU="#0000ff", BLAU_SOFT="#c0c0c0",
|
||||||
SEL_FG="#1b1b1b", RAND="#d1d1d1"),
|
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"
|
||||||
@@ -40,26 +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:
|
def design_setzen(name: str) -> None:
|
||||||
"""„modern“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
|
"""„modern“ (hell), „dunkel“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
|
||||||
global DESIGN, MODERN, FONT_FAMILY, FONT_SIZE, APP_NAME
|
global DESIGN, MODERN, DUNKEL, FONT_FAMILY, FONT_SIZE, APP_NAME, MONO_FAMILY
|
||||||
DESIGN = name if name in _PALETTEN else "modern"
|
DESIGN = name if name in _PALETTEN else "modern"
|
||||||
MODERN = DESIGN == "modern"
|
MODERN = DESIGN in ("modern", "dunkel")
|
||||||
|
DUNKEL = DESIGN == "dunkel"
|
||||||
APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95"
|
APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95"
|
||||||
globals().update(_PALETTEN[DESIGN])
|
globals().update(_PALETTEN[DESIGN])
|
||||||
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
|
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
|
||||||
FONT_SIZE = 9 if (MODERN or not IS_WIN) else 8
|
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()
|
_schriften()
|
||||||
|
|
||||||
|
|
||||||
def _schriften():
|
def _schriften():
|
||||||
global FONT, FONT_BOLD, FONT_BIG
|
global FONT, FONT_BOLD, FONT_BIG, FONT_MONO, FONT_KLEIN_FETT, FONT_TITEL, FONT_ZAHL
|
||||||
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 = (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 = {
|
||||||
@@ -332,12 +381,15 @@ class Icons:
|
|||||||
# --------------------------------------------------------------------------- Theme
|
# --------------------------------------------------------------------------- Theme
|
||||||
|
|
||||||
def apply(root: tk.Tk) -> None:
|
def apply(root: tk.Tk) -> None:
|
||||||
global FONT_FAMILY
|
global FONT_FAMILY, MONO_FAMILY
|
||||||
if MODERN and not IS_WIN: # Linux: die erste vorhandene moderne Schrift
|
if MODERN: # die erste vorhandene moderne Schrift, am liebsten IBM Plex
|
||||||
from tkinter import font as tkfont
|
from tkinter import font as tkfont
|
||||||
|
_schriften_laden()
|
||||||
vorhanden = set(tkfont.families(root))
|
vorhanden = set(tkfont.families(root))
|
||||||
FONT_FAMILY = next((f for f in ("Segoe UI", "Noto Sans", "Cantarell", "Ubuntu", "DejaVu Sans")
|
FONT_FAMILY = next((f for f in ("IBM Plex Sans", "Segoe UI", "Noto Sans", "Cantarell", "Ubuntu",
|
||||||
if f in vorhanden), FONT_FAMILY)
|
"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()
|
_schriften()
|
||||||
root.configure(bg=FACE)
|
root.configure(bg=FACE)
|
||||||
o = root.option_add
|
o = root.option_add
|
||||||
@@ -399,40 +451,61 @@ def apply(root: tk.Tk) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
|
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
|
||||||
"""Flach und hell: dünne graue Rahmen statt 3D-Kanten, Akzentfarbe beim Fokus."""
|
"""Flach wie die Handy-Insel-Webtools: weiße Flächen, dünne Linien, Lila als Akzent."""
|
||||||
o = root.option_add
|
o = root.option_add
|
||||||
for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
|
for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
|
||||||
o(f"*{klasse}.relief", "flat")
|
o(f"*{klasse}.relief", "flat")
|
||||||
o(f"*{klasse}.borderWidth", 3)
|
o(f"*{klasse}.borderWidth", 4)
|
||||||
o(f"*{klasse}.highlightThickness", 1)
|
o(f"*{klasse}.highlightThickness", 1)
|
||||||
o(f"*{klasse}.highlightBackground", RAND)
|
o(f"*{klasse}.highlightBackground", RAND2)
|
||||||
o(f"*{klasse}.highlightColor", NAVY)
|
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.relief", "flat")
|
||||||
o("*Button.borderWidth", 0)
|
o("*Button.borderWidth", 0)
|
||||||
o("*Button.highlightThickness", 1)
|
o("*Button.highlightThickness", 1)
|
||||||
o("*Button.highlightBackground", RAND)
|
o("*Button.highlightBackground", RAND2)
|
||||||
o("*Button.background", WHITE)
|
o("*Button.background", FACE)
|
||||||
o("*Button.activeBackground", SEL_BG)
|
o("*Button.activeBackground", LIGHT)
|
||||||
o("*Button.padY", 3)
|
o("*Button.padX", 12)
|
||||||
o("*Checkbutton.activeBackground", FACE)
|
o("*Button.padY", 4)
|
||||||
o("*Radiobutton.activeBackground", FACE)
|
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("*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.theme_use("clam")
|
||||||
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
|
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
|
||||||
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=WHITE,
|
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=FACE,
|
||||||
darkcolor=RAND, focuscolor=NAVY)
|
darkcolor=RAND, focuscolor=NAVY, insertcolor=DARK)
|
||||||
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile + 4, borderwidth=0, font=FONT)
|
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.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)])
|
||||||
st.configure("Treeview.Heading", background=WHITE, relief="flat", borderwidth=1, font=FONT, padding=(6, 3),
|
st.configure("Treeview.Heading", background=FACE, foreground=SHADOW, relief="flat", borderwidth=1,
|
||||||
bordercolor=RAND, lightcolor=WHITE, darkcolor=RAND)
|
font=FONT_KLEIN_FETT, padding=(10, 7), bordercolor=RAND, lightcolor=FACE, darkcolor=RAND)
|
||||||
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
|
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
|
||||||
st.configure("TScrollbar", background=LIGHT, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE,
|
# Seitenleiste: ohne Kopf, hohe Zeilen, Auswahl lila wie im VVL-Tool
|
||||||
lightcolor=LIGHT, darkcolor=LIGHT, relief="flat", arrowsize=12, width=12)
|
st.configure("Seite.Treeview", background=FACE, fieldbackground=FACE, rowheight=zeile + 16, font=FONT,
|
||||||
st.map("TScrollbar", background=[("active", RAND)])
|
borderwidth=0)
|
||||||
st.configure("TCombobox", fieldbackground=WHITE, background=WHITE, arrowcolor=DARK, padding=3,
|
st.layout("Seite.Treeview", [("Treeview.treearea", {"sticky": "nswe"})]) # ohne Rahmen
|
||||||
bordercolor=RAND, lightcolor=WHITE, darkcolor=WHITE)
|
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)],
|
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
|
||||||
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
|
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
|
||||||
|
st.configure("TPanedwindow", background=BG)
|
||||||
|
st.configure("Sash", sashthickness=6, background=BG)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- Wort löschen
|
# --------------------------------------------------------------------------- Wort löschen
|
||||||
@@ -520,34 +593,6 @@ class Sunken(tk.Frame):
|
|||||||
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):
|
|
||||||
if MODERN: # schlichte Überschrift statt blauer Titelleiste
|
|
||||||
super().__init__(master, bd=0, bg=FACE, **kw)
|
|
||||||
self.titel_var = tk.StringVar(value=titel)
|
|
||||||
self.titelleiste = tk.Label(self, textvariable=self.titel_var, bg=FACE, fg=DARK, font=FONT_BOLD,
|
|
||||||
anchor="w")
|
|
||||||
self.titelleiste.pack(fill="x", pady=(0, 4))
|
|
||||||
self.inhalt = tk.Frame(self, bg=FACE)
|
|
||||||
self.inhalt.pack(fill="both", expand=True)
|
|
||||||
return
|
|
||||||
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
|
|
||||||
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
|
|
||||||
self.titelleiste.pack(fill="x", padx=1, pady=1)
|
|
||||||
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)
|
||||||
@@ -566,12 +611,19 @@ class Statusleiste(tk.Frame):
|
|||||||
self.vars[key].set(text)
|
self.vars[key].set(text)
|
||||||
|
|
||||||
|
|
||||||
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
|
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None,
|
||||||
|
primaer: bool = False, **kw):
|
||||||
|
"""primaer: der eine Hauptknopf einer Fläche (modern dunkel-lila gefüllt, Win95 mit dickem Rahmen)."""
|
||||||
if MODERN:
|
if 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()
|
b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern()
|
||||||
else:
|
else:
|
||||||
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
|
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
|
||||||
activebackground=FACE, **kw)
|
activebackground=FACE, **kw)
|
||||||
|
if primaer:
|
||||||
|
b.configure(default="active")
|
||||||
if icon is not None:
|
if icon is not None:
|
||||||
b.configure(image=icon, compound="left", padx=6)
|
b.configure(image=icon, compound="left", padx=6)
|
||||||
if breite:
|
if breite:
|
||||||
@@ -579,34 +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:
|
||||||
if MODERN: # Hervorhebung durch Farbe statt 3D-Kante
|
kw = dict(bg=FACE, bd=2, relief="raised") | kw
|
||||||
self.configure(bd=0, padx=8, pady=4)
|
super().__init__(master, **kw)
|
||||||
self.bind("<Enter>", lambda e: self.configure(bg=LIGHT))
|
|
||||||
self.bind("<Leave>", lambda e: self.configure(bg=FACE))
|
|
||||||
self.bind("<ButtonPress-1>", lambda e: self.configure(bg=SEL_BG))
|
|
||||||
self.bind("<ButtonRelease-1>", self._release)
|
|
||||||
if tooltip:
|
|
||||||
Tooltip(self, tooltip)
|
|
||||||
return
|
|
||||||
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
|
|
||||||
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
|
|
||||||
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
|
|
||||||
self.bind("<ButtonRelease-1>", self._release)
|
|
||||||
if tooltip:
|
|
||||||
Tooltip(self, tooltip)
|
|
||||||
|
|
||||||
def _release(self, e):
|
|
||||||
self.configure(bg=LIGHT) if MODERN else 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):
|
||||||
@@ -660,15 +792,28 @@ def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
|
|||||||
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
|
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
|
||||||
|
|
||||||
|
|
||||||
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]:
|
def _bei_bedarf(leiste: ttk.Scrollbar):
|
||||||
rahmen = Sunken(master, bg=WHITE)
|
"""Bildlaufleiste nur zeigen, wenn nicht alles hineinpasst."""
|
||||||
|
def setzen(lo, hi):
|
||||||
|
leiste.grid() if float(lo) > 0 or float(hi) < 1 else leiste.grid_remove()
|
||||||
|
leiste.set(lo, hi)
|
||||||
|
return setzen
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_tree(master, auto: bool = False, **kw) -> tuple[tk.Frame, ttk.Treeview]:
|
||||||
|
"""Treeview mit Bildlaufleisten (modern nur bei Bedarf). auto: keine waagrechte Leiste (Seitenleiste)."""
|
||||||
|
rahmen = Sunken(master, bg=WHITE if MODERN else FACE) # Win95: graue Ecke zwischen den Leisten
|
||||||
tree = ttk.Treeview(rahmen, **kw)
|
tree = ttk.Treeview(rahmen, **kw)
|
||||||
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
|
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
|
||||||
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
|
|
||||||
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
|
|
||||||
tree.grid(row=0, column=0, sticky="nsew")
|
tree.grid(row=0, column=0, sticky="nsew")
|
||||||
vs.grid(row=0, column=1, sticky="ns")
|
vs.grid(row=0, column=1, sticky="ns")
|
||||||
|
if auto and MODERN:
|
||||||
|
rahmen.configure(highlightthickness=0, bg=FACE)
|
||||||
|
if not auto:
|
||||||
|
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
|
||||||
hs.grid(row=1, column=0, sticky="ew")
|
hs.grid(row=1, column=0, sticky="ew")
|
||||||
|
tree.configure(xscrollcommand=_bei_bedarf(hs) if MODERN else hs.set)
|
||||||
|
tree.configure(yscrollcommand=_bei_bedarf(vs) if auto or MODERN else vs.set)
|
||||||
rahmen.rowconfigure(0, weight=1)
|
rahmen.rowconfigure(0, weight=1)
|
||||||
rahmen.columnconfigure(0, weight=1)
|
rahmen.columnconfigure(0, weight=1)
|
||||||
return rahmen, tree
|
return rahmen, tree
|
||||||
@@ -747,6 +892,8 @@ class Dialog(tk.Toplevel):
|
|||||||
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
|
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
|
||||||
self.deiconify()
|
self.deiconify()
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
|
if DUNKEL:
|
||||||
|
win32.dunkle_titelleiste(self)
|
||||||
if KLASSISCH and win32.IS_WIN:
|
if KLASSISCH and win32.IS_WIN:
|
||||||
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
|
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
|
||||||
if win32.taskleisteneintrag(self):
|
if win32.taskleisteneintrag(self):
|
||||||
@@ -796,7 +943,7 @@ class Dialog(tk.Toplevel):
|
|||||||
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
|
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
|
||||||
f = tk.Frame(self, bg=FACE)
|
f = tk.Frame(self, bg=FACE)
|
||||||
for i, (text, cmd) in enumerate(knoepfe):
|
for i, (text, cmd) in enumerate(knoepfe):
|
||||||
b = knopf(f, text, cmd, breite=10)
|
b = knopf(f, text, cmd, breite=10, primaer=MODERN and i == default)
|
||||||
if i == default:
|
if i == default:
|
||||||
b.configure(default="active")
|
b.configure(default="active")
|
||||||
b.pack(side="left", padx=4)
|
b.pack(side="left", padx=4)
|
||||||
|
|||||||
+52
-24
@@ -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)}")
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ def ausfuehren(ergebnisdatei: str) -> int:
|
|||||||
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
|
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
|
||||||
csv = basis / "freenet.csv"
|
csv = basis / "freenet.csv"
|
||||||
_beispiel_csv(csv)
|
_beispiel_csv(csv)
|
||||||
# Win95-Design: prüft auch den nativen Fensterrahmen (Szenario 1 läuft im modernen Standard)
|
# 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"}')
|
s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95", "vollbild": false}')
|
||||||
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
|
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
|
||||||
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
|
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+152
-2
@@ -29,7 +29,8 @@ class Lauf:
|
|||||||
|
|
||||||
def __init__(self, home, einstellungen: dict | None = None):
|
def __init__(self, home, einstellungen: dict | None = None):
|
||||||
self.home = home
|
self.home = home
|
||||||
if einstellungen is not None:
|
# Tests starten im normalen Fenster (die App sonst maximiert)
|
||||||
|
einstellungen = {"vollbild": False} | (einstellungen or {})
|
||||||
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
|
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
|
||||||
self.root = None
|
self.root = None
|
||||||
for versuch in range(3):
|
for versuch in range(3):
|
||||||
@@ -1098,11 +1099,18 @@ def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkey
|
|||||||
d.v_auf.set(False)
|
d.v_auf.set(False)
|
||||||
d._ok()
|
d._ok()
|
||||||
|
|
||||||
|
def ok_offen():
|
||||||
|
d = lauf.dialog(ExportDialog)
|
||||||
|
d.v_von.set("01.10.2026")
|
||||||
|
d.v_bis.set("02.10.2026")
|
||||||
|
next(k for k in _alle(d) if isinstance(k, tk.Button) and k.cget("text") == "Noch nicht abgerechnet").invoke()
|
||||||
|
ok(d.v_von.get(), d.v_bis.get())
|
||||||
|
|
||||||
lauf.ablauf([
|
lauf.ablauf([
|
||||||
(IMMER, app.exportieren, "Export Oktober 6."),
|
(IMMER, app.exportieren, "Export Oktober 6."),
|
||||||
(lambda: lauf.dialog(ExportDialog) is not None, lambda: ok("01.10.2026", "05.10.2026"), "nur bis 5.10."),
|
(lambda: lauf.dialog(ExportDialog) is not None, lambda: ok("01.10.2026", "05.10.2026"), "nur bis 5.10."),
|
||||||
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "Export Rest"),
|
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "Export Rest"),
|
||||||
(lambda: lauf.dialog(ExportDialog) is not None, ok, "alles Offene"),
|
(lambda: lauf.dialog(ExportDialog) is not None, ok_offen, "Noch nicht abgerechnet"),
|
||||||
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "nochmal"),
|
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "nochmal"),
|
||||||
])
|
])
|
||||||
assert not lauf.uebrig, lauf.diagnose()
|
assert not lauf.uebrig, lauf.diagnose()
|
||||||
@@ -1115,4 +1123,146 @@ def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkey
|
|||||||
app.ptree.selection_set(app.ptree.get_children()[0])
|
app.ptree.selection_set(app.ptree.get_children()[0])
|
||||||
app.prozent()
|
app.prozent()
|
||||||
assert "erledigt" in meldungen[-1]
|
assert "erledigt" in meldungen[-1]
|
||||||
|
# ... auch der Export (würde sonst offene Positionen abrechnen)
|
||||||
|
n = len(meldungen)
|
||||||
|
app.exportieren()
|
||||||
|
assert lauf.dialog(ExportDialog) is None and len(meldungen) == n + 1 and "erledigt" in meldungen[-1]
|
||||||
lauf.pruefen()
|
lauf.pruefen()
|
||||||
|
|
||||||
|
|
||||||
|
def _alle(widget):
|
||||||
|
for kind in widget.winfo_children():
|
||||||
|
yield kind
|
||||||
|
yield from _alle(kind)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abrechnungen_links_und_import_fragt_nach_ziel(lauf_modern, home, monkeypatch):
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
from provtool.gui.dialogs import AbrechnungenDialog, ImportBericht
|
||||||
|
monkeypatch.setattr(w, "frage", lambda *a, **k: True) # „bereits importiert – trotzdem?“ (Testdatei ist schon drin)
|
||||||
|
lauf = lauf_modern
|
||||||
|
app = lauf.app
|
||||||
|
alt = app.aid
|
||||||
|
neu = app.db.abrechnung_neu("November 2026")
|
||||||
|
app._aktualisieren()
|
||||||
|
assert set(app.atree.get_children()) == {str(alt), str(neu)}, "Abrechnungen fehlen links"
|
||||||
|
app.atree.selection_set(str(neu))
|
||||||
|
lauf.root.update()
|
||||||
|
assert app.aid == neu and not app.positionen, "Klick links öffnet die Abrechnung nicht"
|
||||||
|
app.atree.selection_set(str(alt))
|
||||||
|
lauf.root.update()
|
||||||
|
|
||||||
|
def ziel_waehlen():
|
||||||
|
d = lauf.dialog(AbrechnungenDialog)
|
||||||
|
d.tree.selection_set(str(neu))
|
||||||
|
d._oeffnen()
|
||||||
|
|
||||||
|
lauf.ablauf([
|
||||||
|
(IMMER, lambda: app.importieren([str(home / "freenet.csv")]), "Import"),
|
||||||
|
(lambda: lauf.dialog(AbrechnungenDialog) is not None, ziel_waehlen, "Ziel: November"),
|
||||||
|
(lambda: lauf.dialog(ImportBericht) is not None, lambda: lauf.dialog(ImportBericht).abbrechen(), "Bericht zu"),
|
||||||
|
])
|
||||||
|
assert not lauf.uebrig, lauf.diagnose()
|
||||||
|
assert app.aid == neu and len(app.positionen) == 3, "Import nicht in der gewählten Abrechnung"
|
||||||
|
assert app.atree.selection() == (str(neu),)
|
||||||
|
lauf.pruefen()
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_kennzahlen_und_hell_dunkel(lauf_modern):
|
||||||
|
"""Filter-Knöpfe über der Tabelle, Kennzahl-Kacheln und der Hell/Dunkel-Schalter in der Kopfzeile."""
|
||||||
|
from provtool import config
|
||||||
|
from provtool.db import EIGEN
|
||||||
|
lauf = lauf_modern
|
||||||
|
app = lauf.app
|
||||||
|
alle = [p for p in app.positionen if p["haendler_id"] != EIGEN]
|
||||||
|
assert app.kpi["pos"][0].cget("text") == str(len(alle))
|
||||||
|
p = alle[0]
|
||||||
|
app.db.betrag_setzen(p["id"], 123)
|
||||||
|
app._aktualisieren()
|
||||||
|
app.pills["manuell"]._ausloesen() # wie ein Klick (event_generate kommt unter Wine nicht an)
|
||||||
|
lauf.root.update()
|
||||||
|
manuell = [x for x in app.positionen if x["manuell"] and x["haendler_id"] != EIGEN]
|
||||||
|
assert len(manuell) == 1
|
||||||
|
zeilen = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
|
||||||
|
assert len(zeilen) == len(manuell), "Filter „Von Hand geändert“ wirkt nicht"
|
||||||
|
assert app.kpi["pos"][0].cget("text") == str(len(manuell))
|
||||||
|
assert app.pills["manuell"].aktiv and not app.pills["alle"].aktiv
|
||||||
|
app._filter_setzen("alle")
|
||||||
|
assert len([i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]) == len(alle)
|
||||||
|
|
||||||
|
app.design_umschalten() # schließt das Fenster; main() baut es danach dunkel neu auf
|
||||||
|
for _ in range(50):
|
||||||
|
if app._beendet:
|
||||||
|
break
|
||||||
|
lauf.root.update()
|
||||||
|
assert app.neustart and app._beendet
|
||||||
|
assert config.load()["design"] == "dunkel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_startet_maximiert(home):
|
||||||
|
_profil_mit_daten(home)
|
||||||
|
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "vollbild": True, "design": "win95"})
|
||||||
|
try:
|
||||||
|
for _ in range(30):
|
||||||
|
lauf.root.update()
|
||||||
|
time.sleep(0.02)
|
||||||
|
assert lauf.app.rahmen.maximiert, "Programm startet nicht maximiert"
|
||||||
|
finally:
|
||||||
|
lauf.ende()
|
||||||
|
|
||||||
|
|
||||||
|
def test_suche_und_filter_blenden_nur_aus(lauf_modern):
|
||||||
|
"""Suche/Filter bauen die Tabelle nicht neu auf: Zeilen werden aus- und wieder eingehängt."""
|
||||||
|
lauf = lauf_modern
|
||||||
|
app = lauf.app
|
||||||
|
alle = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
|
||||||
|
assert len(alle) == 3
|
||||||
|
app.v_suche.set("Beispiel")
|
||||||
|
app._suche_ausfuehren()
|
||||||
|
sichtbar = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
|
||||||
|
assert len(sichtbar) == 1 and app.ptree.item(sichtbar[0], "text") == "Beispiel KG"
|
||||||
|
assert all(app.ptree.exists(i) for i in alle), "ausgeblendete Zeilen bleiben erhalten (nur ausgehängt)"
|
||||||
|
assert app.kpi["pos"][0].cget("text") == "1"
|
||||||
|
gruppe = app.ptree.parent(sichtbar[0])
|
||||||
|
assert "(1)" in app.ptree.item(gruppe, "text") and app.ptree.item(gruppe, "open")
|
||||||
|
app.ptree.selection_set(gruppe)
|
||||||
|
assert app._ausgewaehlte_pids() == [int(sichtbar[0][1:])], "Gruppenauswahl = nur sichtbare Zeilen"
|
||||||
|
app.v_suche.set("")
|
||||||
|
app._suche_ausfuehren()
|
||||||
|
assert [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)] == alle
|
||||||
|
assert app.kpi["pos"][0].cget("text") == "3"
|
||||||
|
lauf.pruefen()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_mit_unerreichbarer_datenbank_fragt(home, monkeypatch):
|
||||||
|
"""Eingestellter (Netz-)Pfad fehlt: Rückfrage statt Absturz – und es wird dort keine leere DB angelegt."""
|
||||||
|
from provtool import config
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
_profil_mit_daten(home)
|
||||||
|
weg = home / "netz" / "provisionen.db"
|
||||||
|
fragen = []
|
||||||
|
monkeypatch.setattr(w, "_meldung", lambda m, t, text, icon, knoepfe, default=0: fragen.append(text) or "standard")
|
||||||
|
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "db_pfad": str(weg)})
|
||||||
|
try:
|
||||||
|
assert len(fragen) == 1 and str(weg) in fragen[0]
|
||||||
|
assert not weg.exists() and lauf.app.db.path == config.app_dir() / "provisionen.db"
|
||||||
|
assert config.load()["db_pfad"] is None, "Wahl „Standard“ wird gespeichert"
|
||||||
|
assert len(lauf.app.positionen) == 3
|
||||||
|
finally:
|
||||||
|
lauf.ende()
|
||||||
|
lauf.pruefen()
|
||||||
|
# „Beenden“ gewählt: Programm endet sauber
|
||||||
|
monkeypatch.setattr(w, "_meldung", lambda *a, **k: "ende")
|
||||||
|
(home / "einstellungen.json").write_text(json.dumps({"db_pfad": str(weg), "vollbild": False}), encoding="utf-8")
|
||||||
|
from provtool.gui import app as A
|
||||||
|
root = tk.Tk()
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
A.App(root)
|
||||||
|
assert not weg.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inhalt_erbt_nicht_den_zeiger_der_trennlinie(lauf_modern):
|
||||||
|
"""Der Resize-Zeiger der Trennlinie blieb unter Windows im ganzen Inhalt hängen (Kinder erben den Zeiger
|
||||||
|
der Trennlinie). Beide Bereiche haben daher einen eigenen Zeiger."""
|
||||||
|
app = lauf_modern.app
|
||||||
|
assert [str(app.root.nametowidget(str(p)).cget("cursor")) for p in app.pw.panes()] == ["arrow", "arrow"]
|
||||||
+248
-2
@@ -468,12 +468,16 @@ def test_abgerechnet_einfrieren_und_doppelt_erkennen(db, freenet_csv):
|
|||||||
db.haendler_speichern(db.haendler(hid), [("freenet", "111")])
|
db.haendler_speichern(db.haendler(hid), [("freenet", "111")])
|
||||||
a2 = db.abrechnung_neu("Nov")
|
a2 = db.abrechnung_neu("Nov")
|
||||||
db.importieren(a2, lies_datei(freenet_csv))
|
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"]]
|
doppelt = [p for p in db.positionen(a2) if p["doppelt"]]
|
||||||
assert len(doppelt) == 2 and all(p["doppelt"] == "Okt" for p in doppelt)
|
assert 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"
|
assert db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden"
|
||||||
|
|
||||||
# Kennzeichnung aufheben -> nicht mehr doppelt
|
# 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 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))
|
assert not any(p["doppelt"] for p in db.positionen(a2))
|
||||||
|
|
||||||
|
|
||||||
@@ -515,3 +519,245 @@ def test_migration_v3_fingerabdruck(tmp_path, freenet_csv):
|
|||||||
assert all(p["fp"] for p in d.positionen(aid))
|
assert all(p["fp"] for p in d.positionen(aid))
|
||||||
assert len({p["fp"] for p in d.positionen(aid)}) == 5
|
assert len({p["fp"] for p in d.positionen(aid)}) == 5
|
||||||
d.close()
|
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)
|
||||||
Reference in new issue
Block a user