Provisions-Manager 95: Import, Händler-DB, Auszahlungsregeln, Excel-Export, Win95-Oberfläche, Wine-Build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
da2e3c9ba1
25 files changed
+3366
No files matched your search
@@ -0,0 +1,59 @@
|
|||||||
|
# Baut die Windows-Version (Provisionsmanager95.exe) mit Windows-Python unter Wine.
|
||||||
|
# - jeder Push / PR: Tests + .exe als Build-Artefakt
|
||||||
|
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip
|
||||||
|
name: Windows-Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows-exe:
|
||||||
|
# Label ggf. an euren Runner anpassen (Runner braucht Docker; Wine steckt im Container-Image).
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: tobix/pywine:3.12
|
||||||
|
env:
|
||||||
|
WINEPREFIX: /opt/wineprefix
|
||||||
|
steps:
|
||||||
|
- name: Werkzeuge für Checkout/Upload installieren
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates >/dev/null
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Tests + Build (.exe)
|
||||||
|
run: ./build/build_windows.sh
|
||||||
|
|
||||||
|
- name: Build-Artefakt hochladen
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: Provisionsmanager95
|
||||||
|
path: |
|
||||||
|
dist/Provisionsmanager95.exe
|
||||||
|
dist/Provisionsmanager95-*.zip
|
||||||
|
|
||||||
|
- name: Release erstellen
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
VERSION=$(cat dist/VERSION | tr -d '\r\n')
|
||||||
|
if [ "v$VERSION" != "$TAG" ]; then
|
||||||
|
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1
|
||||||
|
fi
|
||||||
|
resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}")
|
||||||
|
id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do
|
||||||
|
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
|
||||||
|
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
|
||||||
|
echo "hochgeladen: $f"
|
||||||
|
done
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# Echte Abrechnungen enthalten Kundendaten – niemals einchecken!
|
||||||
|
beispieldaten/
|
||||||
|
*.csv
|
||||||
|
/*.xlsx
|
||||||
|
/*.xls
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/tmp/
|
||||||
|
build/version_info.txt
|
||||||
|
*.db
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Provisions-Manager 95
|
||||||
|
|
||||||
|
Windows-Programm der Handy-Insel Miesbach: liest die monatlichen Provisionsabrechnungen
|
||||||
|
von **freenet** (CSV/Excel) und **Vodafone/Euronics** (Excel) ein, ordnet die Positionen den
|
||||||
|
Unterhändlern zu, erlaubt freie Anpassung der Auszahlung und exportiert je Händler eine
|
||||||
|
fertige Excel-Abrechnung. Optik: Windows 95.
|
||||||
|
|
||||||
|
## Bedienung in Kürze
|
||||||
|
|
||||||
|
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*:
|
||||||
|
- freenet → Spalte **Unterhaendler** (z. B. `11111102`)
|
||||||
|
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`)
|
||||||
|
Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern.
|
||||||
|
2. **Neue Abrechnung** (`Strg+N`), z. B. „Oktober 2026“.
|
||||||
|
3. **Import** (`Strg+I`): beliebig viele Dateien auf einmal, z. B. alle freenet-Wochenlisten
|
||||||
|
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
|
||||||
|
Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter
|
||||||
|
„(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen.
|
||||||
|
4. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach
|
||||||
|
Provisionsart gruppiert.
|
||||||
|
- Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch
|
||||||
|
als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe).
|
||||||
|
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
|
||||||
|
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
|
||||||
|
- Rechtsklick: u. a. „Auszahlung 0 €“.
|
||||||
|
5. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage:
|
||||||
|
|
||||||
|
| A | B | C | D | E | F | G | H | I | J | K | L | M | N |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Zeitraum | Provisionsart | Unterhändler-/VO-Nr | Datum | Rufnummer | IMEI | Gerät | Vorname | Name | Tarif | **Auszahlung** | Netz | Dienst | Aktionscode |
|
||||||
|
|
||||||
|
Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile.
|
||||||
|
Kopfzeile optional zuschaltbar.
|
||||||
|
|
||||||
|
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
|
||||||
|
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
|
||||||
|
|
||||||
|
### Zuordnung der Vodafone-Spalten
|
||||||
|
|
||||||
|
| Export | Vodafone/Euronics |
|
||||||
|
|---|---|
|
||||||
|
| Zeitraum | Abrechnungslauf (`VF2609`, `VF2609P8`, …) |
|
||||||
|
| Provisionsart | Artikelbezeichnung |
|
||||||
|
| Unterhändler | Vo-Nr |
|
||||||
|
| Datum | Anschaltdatum |
|
||||||
|
| Name | Kunde |
|
||||||
|
| Tarif | Tarif |
|
||||||
|
| Netz | VF / otelo / Cable (aus der Artikelbezeichnung) |
|
||||||
|
| Dienst | TarifKey02 |
|
||||||
|
| Aktionscode | Text |
|
||||||
|
|
||||||
|
Bei Vodafone wird nur das Blatt **All** gelesen (die Einzelblätter je Abrechnungslauf würden
|
||||||
|
sonst doppelt zählen).
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
pytest # Tests (nur erfundene Testdaten)
|
||||||
|
python -m provtool # Programm starten (benötigt Tkinter)
|
||||||
|
```
|
||||||
|
|
||||||
|
Aufbau:
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `provtool/importer.py` | Formaterkennung und Einlesen (CSV, xlsx, xls) |
|
||||||
|
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
|
||||||
|
| `provtool/export.py` | Excel-Export im Zielformat |
|
||||||
|
| `provtool/money.py` | Beträge (intern immer in Cent) |
|
||||||
|
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) |
|
||||||
|
|
||||||
|
**Datenschutz:** Echte Abrechnungen enthalten Kundendaten und gehören nicht ins Repository
|
||||||
|
(`.gitignore` schließt `*.csv`, `*.xlsx` im Hauptordner und `beispieldaten/` aus).
|
||||||
|
|
||||||
|
## Windows-Build
|
||||||
|
|
||||||
|
Gitea Actions (`.gitea/workflows/windows-build.yml`) baut die `.exe` im Container
|
||||||
|
`tobix/pywine:3.12` (Windows-Python unter Wine) mit PyInstaller:
|
||||||
|
|
||||||
|
- jeder Push: Tests unter Windows-Python + `Provisionsmanager95.exe` als Artefakt
|
||||||
|
- Tag `vX.Y.Z` (passend zu `provtool/__init__.py`): zusätzlich ein Gitea-Release mit `.exe` und `.zip`
|
||||||
|
|
||||||
|
Lokal geht dasselbe mit Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --entrypoint bash -v "$PWD":/src -w /src tobix/pywine:3.12 ./build/build_windows.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Neue Version: `__version__` in `provtool/__init__.py` erhöhen, committen, `git tag v1.0.1 && git push --tags`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 980 B |
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut die Windows-.exe mit Windows-Python unter Wine (z. B. im Container tobix/pywine:3.12).
|
||||||
|
# Ergebnis: dist/Provisionsmanager95.exe und dist/Provisionsmanager95-<version>.zip
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
export WINEDEBUG=-all
|
||||||
|
export LANG=C.UTF-8 LC_ALL=C.UTF-8 # sonst kann Wine keine Umlaute in Dateinamen abbilden
|
||||||
|
export WINEPREFIX="${WINEPREFIX:-/opt/wineprefix}"
|
||||||
|
PY="wine python"
|
||||||
|
|
||||||
|
$PY --version
|
||||||
|
$PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt
|
||||||
|
|
||||||
|
echo "== Tests (Windows-Python) =="
|
||||||
|
$PY -m pytest -q
|
||||||
|
|
||||||
|
echo "== Version =="
|
||||||
|
VERSION=$($PY build/version_info.py | tr -d '\r')
|
||||||
|
echo "Version $VERSION"
|
||||||
|
|
||||||
|
echo "== PyInstaller =="
|
||||||
|
$PY -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
|
||||||
|
|
||||||
|
test -f dist/Provisionsmanager95.exe
|
||||||
|
cp README.md dist/LIESMICH.md 2>/dev/null || true
|
||||||
|
( cd dist && rm -f Provisionsmanager95-*.zip && \
|
||||||
|
python3 -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
|
||||||
|
"Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md 2>/dev/null \
|
||||||
|
|| $PY -c "import zipfile,sys; z=zipfile.ZipFile(sys.argv[1],'w',zipfile.ZIP_DEFLATED); [z.write(f) for f in sys.argv[2:]]; z.close()" \
|
||||||
|
"Provisionsmanager95-${VERSION}.zip" Provisionsmanager95.exe LIESMICH.md )
|
||||||
|
echo "$VERSION" > dist/VERSION
|
||||||
|
ls -la dist
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Erzeugt assets/app.ico aus der Pixel-Grafik in provtool/gui/win95.py (benötigt Pillow, nur zum Entwickeln)."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from provtool.gui.win95 import PALETTE, _icon_rows # noqa: E402
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
|
||||||
|
for y, row in enumerate(_icon_rows("app")):
|
||||||
|
for x, ch in enumerate(row):
|
||||||
|
if ch != ".":
|
||||||
|
img.putpixel((x, y), tuple(int(PALETTE[ch][i:i + 2], 16) for i in (1, 3, 5)) + (255,))
|
||||||
|
groessen = [16, 32, 48, 64, 128, 256]
|
||||||
|
big = img.resize((256, 256), Image.NEAREST)
|
||||||
|
big.save(ROOT / "assets" / "app.ico", sizes=[(s, s) for s in groessen])
|
||||||
|
big.save(ROOT / "assets" / "app.png")
|
||||||
|
print("assets/app.ico geschrieben")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# PyInstaller-Spezifikation – erzeugt eine einzelne Provisionsmanager95.exe
|
||||||
|
# Aufruf (unter Windows oder Wine): pyinstaller --noconfirm build/provisionsmanager.spec
|
||||||
|
import os
|
||||||
|
|
||||||
|
ROOT = os.path.abspath(os.path.join(SPECPATH, ".."))
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
[os.path.join(ROOT, "build", "start.py")],
|
||||||
|
pathex=[ROOT],
|
||||||
|
hiddenimports=["openpyxl", "xlrd"],
|
||||||
|
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name="Provisionsmanager95",
|
||||||
|
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,
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from provtool.gui.app import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Schreibt build/version_info.txt (Windows-Dateieigenschaften der .exe) aus provtool.__version__."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from provtool import APP_NAME, APP_ORG, __version__ # noqa: E402
|
||||||
|
|
||||||
|
v = tuple(int(x) for x in (__version__.split(".") + ["0", "0", "0"])[:4])
|
||||||
|
text = f"""VSVersionInfo(
|
||||||
|
ffi=FixedFileInfo(filevers={v}, prodvers={v}, mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0),
|
||||||
|
kids=[
|
||||||
|
StringFileInfo([StringTable('040704b0', [
|
||||||
|
StringStruct('CompanyName', '{APP_ORG}'),
|
||||||
|
StringStruct('FileDescription', '{APP_NAME}'),
|
||||||
|
StringStruct('FileVersion', '{__version__}'),
|
||||||
|
StringStruct('InternalName', 'Provisionsmanager95'),
|
||||||
|
StringStruct('OriginalFilename', 'Provisionsmanager95.exe'),
|
||||||
|
StringStruct('ProductName', '{APP_NAME}'),
|
||||||
|
StringStruct('ProductVersion', '{__version__}')])]),
|
||||||
|
VarFileInfo([VarStruct('Translation', [0x0407, 1200])])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
(ROOT / "build" / "version_info.txt").write_text(text, encoding="utf-8")
|
||||||
|
print(__version__)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
APP_NAME = "Provisions-Manager 95"
|
||||||
|
APP_ORG = "Handy-Insel Miesbach"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from provtool.gui.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Speicherorte und Einstellungen.
|
||||||
|
|
||||||
|
Die Einstellungen liegen als JSON in %APPDATA%\\Provisionsmanager95\\einstellungen.json.
|
||||||
|
Darin steht u. a. der Pfad zur Datenbank – der kann bewusst auf ein Netzlaufwerk
|
||||||
|
zeigen, wenn mehrere PCs (nacheinander) mit denselben Händlerdaten arbeiten sollen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_DIR_NAME = "Provisionsmanager95"
|
||||||
|
|
||||||
|
|
||||||
|
def app_dir() -> Path:
|
||||||
|
base = os.environ.get("PROVTOOL_HOME")
|
||||||
|
if base:
|
||||||
|
p = Path(base)
|
||||||
|
elif os.name == "nt" and os.environ.get("APPDATA"):
|
||||||
|
p = Path(os.environ["APPDATA"]) / APP_DIR_NAME
|
||||||
|
else:
|
||||||
|
p = Path.home() / ".config" / APP_DIR_NAME
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_file() -> Path:
|
||||||
|
return app_dir() / "einstellungen.json"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
"db_pfad": None, # None = Standard im App-Verzeichnis
|
||||||
|
"export_ordner": None,
|
||||||
|
"import_ordner": None,
|
||||||
|
"export_kopfzeile": False,
|
||||||
|
"export_summe": True,
|
||||||
|
"export_pro_anbieter": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> dict:
|
||||||
|
data = dict(DEFAULTS)
|
||||||
|
f = _settings_file()
|
||||||
|
if f.exists():
|
||||||
|
try:
|
||||||
|
data.update(json.loads(f.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def save(data: dict) -> None:
|
||||||
|
_settings_file().write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def db_path(settings: dict | None = None) -> Path:
|
||||||
|
settings = settings or load()
|
||||||
|
if settings.get("db_pfad"):
|
||||||
|
return Path(settings["db_pfad"])
|
||||||
|
return app_dir() / "provisionen.db"
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from provtool.importer import ImportErgebnis
|
||||||
|
from provtool.money import apply_percent
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS haendler (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
ansprechpartner TEXT DEFAULT '',
|
||||||
|
email TEXT DEFAULT '',
|
||||||
|
notiz TEXT DEFAULT '',
|
||||||
|
standard_prozent REAL NOT NULL DEFAULT 100,
|
||||||
|
aktiv INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu
|
||||||
|
-- (freenet: Unterhändlernummer, Vodafone: VO-Nummer).
|
||||||
|
CREATE TABLE IF NOT EXISTS kennung (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
haendler_id INTEGER NOT NULL REFERENCES haendler(id) ON DELETE CASCADE,
|
||||||
|
anbieter TEXT NOT NULL,
|
||||||
|
wert TEXT NOT NULL,
|
||||||
|
UNIQUE (anbieter, wert)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Abweichender Auszahlungs-Prozentsatz je Händler und Provisionsart.
|
||||||
|
CREATE TABLE IF NOT EXISTS regel (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
haendler_id INTEGER NOT NULL REFERENCES haendler(id) ON DELETE CASCADE,
|
||||||
|
provisionsart TEXT NOT NULL,
|
||||||
|
prozent REAL NOT NULL,
|
||||||
|
UNIQUE (haendler_id, provisionsart)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS abrechnung (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
erstellt TEXT NOT NULL,
|
||||||
|
geaendert TEXT NOT NULL,
|
||||||
|
notiz TEXT DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS abrechnung_datei (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
abrechnung_id INTEGER NOT NULL REFERENCES abrechnung(id) ON DELETE CASCADE,
|
||||||
|
dateiname TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL,
|
||||||
|
importiert TEXT NOT NULL,
|
||||||
|
anzahl INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS position (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
abrechnung_id INTEGER NOT NULL REFERENCES abrechnung(id) ON DELETE CASCADE,
|
||||||
|
datei_id INTEGER REFERENCES abrechnung_datei(id) ON DELETE CASCADE,
|
||||||
|
reihenfolge INTEGER NOT NULL,
|
||||||
|
anbieter TEXT NOT NULL,
|
||||||
|
zeitraum TEXT, provisionsart TEXT, kennung TEXT, datum TEXT,
|
||||||
|
rufnummer TEXT, imei TEXT, geraet TEXT, vorname TEXT, name TEXT, tarif TEXT,
|
||||||
|
netz TEXT, dienst TEXT, aktionscode TEXT, quelle TEXT,
|
||||||
|
betrag_orig INTEGER NOT NULL, -- Cent, laut Abrechnung des Anbieters
|
||||||
|
betrag INTEGER NOT NULL, -- Cent, Auszahlung an den Händler
|
||||||
|
prozent REAL, -- zuletzt angewendeter Prozentsatz (NULL = manuell)
|
||||||
|
manuell INTEGER NOT NULL DEFAULT 0,
|
||||||
|
roh TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Haendler:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
ansprechpartner: str = ""
|
||||||
|
email: str = ""
|
||||||
|
notiz: str = ""
|
||||||
|
standard_prozent: float = 100.0
|
||||||
|
aktiv: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
class Datenbank:
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.con = sqlite3.connect(str(self.path))
|
||||||
|
self.con.row_factory = sqlite3.Row
|
||||||
|
self.con.execute("PRAGMA foreign_keys = ON")
|
||||||
|
self.con.executescript(SCHEMA)
|
||||||
|
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.con.close()
|
||||||
|
|
||||||
|
def backup(self, ziel: str | Path) -> None:
|
||||||
|
self.con.commit()
|
||||||
|
dst = sqlite3.connect(str(ziel))
|
||||||
|
with dst:
|
||||||
|
self.con.backup(dst)
|
||||||
|
dst.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def restore(quelle: str | Path, ziel: str | Path) -> None:
|
||||||
|
test = sqlite3.connect(str(quelle))
|
||||||
|
try:
|
||||||
|
test.execute("SELECT count(*) FROM haendler").fetchone()
|
||||||
|
finally:
|
||||||
|
test.close()
|
||||||
|
shutil.copyfile(quelle, ziel)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Händler
|
||||||
|
def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]:
|
||||||
|
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name"
|
||||||
|
return [Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
|
||||||
|
r["standard_prozent"], bool(r["aktiv"])) for r in self.con.execute(sql)]
|
||||||
|
|
||||||
|
def haendler(self, hid: int) -> Haendler | None:
|
||||||
|
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
|
||||||
|
r["standard_prozent"], bool(r["aktiv"]))
|
||||||
|
|
||||||
|
def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
|
||||||
|
"""Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]"""
|
||||||
|
with self.con:
|
||||||
|
if h.id:
|
||||||
|
self.con.execute(
|
||||||
|
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=? WHERE id=?",
|
||||||
|
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv), h.id))
|
||||||
|
hid = h.id
|
||||||
|
else:
|
||||||
|
cur = self.con.execute(
|
||||||
|
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv) VALUES (?,?,?,?,?,?)",
|
||||||
|
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv)))
|
||||||
|
hid = cur.lastrowid
|
||||||
|
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
|
||||||
|
for anbieter, wert in kennungen:
|
||||||
|
wert = wert.strip()
|
||||||
|
if not wert:
|
||||||
|
continue
|
||||||
|
besitzer = self.con.execute("SELECT h.name FROM kennung k JOIN haendler h ON h.id=k.haendler_id "
|
||||||
|
"WHERE k.anbieter=? AND k.wert=?", (anbieter, wert)).fetchone()
|
||||||
|
if besitzer:
|
||||||
|
raise ValueError(f"Die Kennung {wert} ({anbieter}) ist bereits dem Händler „{besitzer[0]}“ zugeordnet.")
|
||||||
|
self.con.execute("INSERT INTO kennung (haendler_id, anbieter, wert) VALUES (?,?,?)", (hid, anbieter, wert))
|
||||||
|
return hid
|
||||||
|
|
||||||
|
def haendler_loeschen(self, hid: int) -> None:
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("DELETE FROM haendler WHERE id=?", (hid,))
|
||||||
|
|
||||||
|
def kennungen(self, hid: int) -> list[tuple[str, str]]:
|
||||||
|
return [(r[0], r[1]) for r in self.con.execute(
|
||||||
|
"SELECT anbieter, wert FROM kennung WHERE haendler_id=? ORDER BY anbieter, wert", (hid,))]
|
||||||
|
|
||||||
|
def kennung_map(self) -> dict[tuple[str, str], int]:
|
||||||
|
return {(r[0], r[1]): r[2] for r in self.con.execute(
|
||||||
|
"SELECT k.anbieter, k.wert, k.haendler_id FROM kennung k JOIN haendler h ON h.id=k.haendler_id WHERE h.aktiv=1")}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Regeln
|
||||||
|
def regeln(self, hid: int) -> dict[str, float]:
|
||||||
|
return {r[0]: r[1] for r in self.con.execute(
|
||||||
|
"SELECT provisionsart, prozent FROM regel WHERE haendler_id=?", (hid,))}
|
||||||
|
|
||||||
|
def regel_setzen(self, hid: int, provisionsart: str, prozent: float | None) -> None:
|
||||||
|
with self.con:
|
||||||
|
if prozent is None:
|
||||||
|
self.con.execute("DELETE FROM regel WHERE haendler_id=? AND provisionsart=?", (hid, provisionsart))
|
||||||
|
else:
|
||||||
|
self.con.execute("INSERT INTO regel (haendler_id, provisionsart, prozent) VALUES (?,?,?) "
|
||||||
|
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent",
|
||||||
|
(hid, provisionsart, prozent))
|
||||||
|
|
||||||
|
def prozent_fuer(self, hid: int, provisionsart: str) -> float:
|
||||||
|
if hid == UNZUGEORDNET:
|
||||||
|
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
|
||||||
|
def abrechnung_neu(self, titel: str) -> int:
|
||||||
|
with self.con:
|
||||||
|
cur = self.con.execute("INSERT INTO abrechnung (titel, erstellt, geaendert) VALUES (?,?,?)",
|
||||||
|
(titel, _now(), _now()))
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
def abrechnungen(self) -> list[sqlite3.Row]:
|
||||||
|
return list(self.con.execute(
|
||||||
|
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
|
||||||
|
"(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien "
|
||||||
|
"FROM abrechnung a ORDER BY a.geaendert DESC"))
|
||||||
|
|
||||||
|
def abrechnung(self, aid: int) -> sqlite3.Row | None:
|
||||||
|
return self.con.execute("SELECT * FROM abrechnung WHERE id=?", (aid,)).fetchone()
|
||||||
|
|
||||||
|
def abrechnung_umbenennen(self, aid: int, titel: str) -> None:
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid))
|
||||||
|
|
||||||
|
def abrechnung_loeschen(self, aid: int) -> None:
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("DELETE FROM abrechnung WHERE id=?", (aid,))
|
||||||
|
|
||||||
|
def dateien(self, aid: int) -> list[sqlite3.Row]:
|
||||||
|
return list(self.con.execute("SELECT * FROM abrechnung_datei WHERE abrechnung_id=? ORDER BY id", (aid,)))
|
||||||
|
|
||||||
|
def datei_schon_importiert(self, sha256: str) -> list[str]:
|
||||||
|
return [r[0] for r in self.con.execute(
|
||||||
|
"SELECT a.titel FROM abrechnung_datei d JOIN abrechnung a ON a.id=d.abrechnung_id WHERE d.sha256=?",
|
||||||
|
(sha256,))]
|
||||||
|
|
||||||
|
def datei_entfernen(self, datei_id: int) -> None:
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
|
||||||
|
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
||||||
|
|
||||||
|
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
||||||
|
kmap = self.kennung_map()
|
||||||
|
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
|
||||||
|
(aid,)).fetchone()[0]
|
||||||
|
with self.con:
|
||||||
|
cur = self.con.execute(
|
||||||
|
"INSERT INTO abrechnung_datei (abrechnung_id, dateiname, sha256, format, importiert, anzahl) VALUES (?,?,?,?,?,?)",
|
||||||
|
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
|
||||||
|
did = cur.lastrowid
|
||||||
|
for i, p in enumerate(erg.positionen, start=start + 1):
|
||||||
|
hid = kmap.get((p.anbieter, p.kennung), UNZUGEORDNET)
|
||||||
|
proz = self.prozent_fuer(hid, p.provisionsart)
|
||||||
|
self.con.execute(
|
||||||
|
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
|
||||||
|
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
|
||||||
|
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)",
|
||||||
|
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung,
|
||||||
|
p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name,
|
||||||
|
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent,
|
||||||
|
apply_percent(p.betrag_cent, proz), proz, json.dumps(p.roh, ensure_ascii=False, default=str)))
|
||||||
|
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
||||||
|
return len(erg.positionen)
|
||||||
|
|
||||||
|
def positionen(self, aid: int) -> list[dict]:
|
||||||
|
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen)."""
|
||||||
|
kmap = self.kennung_map()
|
||||||
|
out = []
|
||||||
|
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
|
||||||
|
d = dict(r)
|
||||||
|
d["haendler_id"] = kmap.get((d["anbieter"], d["kennung"]), UNZUGEORDNET)
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def betrag_setzen(self, pid: int, cent: int) -> None:
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
|
||||||
|
|
||||||
|
def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
|
||||||
|
n = 0
|
||||||
|
with self.con:
|
||||||
|
for pid in pids:
|
||||||
|
r = self.con.execute("SELECT betrag_orig, manuell FROM position WHERE id=?", (pid,)).fetchone()
|
||||||
|
if not r or (r["manuell"] and not auch_manuelle):
|
||||||
|
continue
|
||||||
|
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?",
|
||||||
|
(apply_percent(r["betrag_orig"], prozent), prozent, pid))
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
|
||||||
|
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an.
|
||||||
|
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
|
||||||
|
n = 0
|
||||||
|
with self.con:
|
||||||
|
for p in self.positionen(aid):
|
||||||
|
if p["manuell"] and not auch_manuelle:
|
||||||
|
continue
|
||||||
|
proz = self.prozent_fuer(p["haendler_id"], p["provisionsart"])
|
||||||
|
neu = apply_percent(p["betrag_orig"], proz)
|
||||||
|
if neu != p["betrag"] or proz != p["prozent"] or p["manuell"]:
|
||||||
|
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?", (neu, proz, p["id"]))
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
def touch(self, aid: int) -> None:
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Export der Händler-Abrechnung als Excel – Aufbau wie „so soll es zum schluss ausschauen.xlsx“.
|
||||||
|
|
||||||
|
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN |
|
||||||
|
G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode
|
||||||
|
Sortierung: Zeitraum → Provisionsart → Datum (sonst Reihenfolge der Abrechnung).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from provtool.importer import ANBIETER
|
||||||
|
from provtool.money import to_euro
|
||||||
|
|
||||||
|
SPALTEN = [
|
||||||
|
("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13),
|
||||||
|
("IMEI/SN", 16), ("Gerät", 16), ("Vorname", 14), ("Name", 30), ("Tarif", 34), ("Betrag", 12),
|
||||||
|
("Netz", 7), ("Dienst", 22), ("Aktionscode", 12),
|
||||||
|
]
|
||||||
|
EURO_FORMAT = '_-* #,##0.00\\ "€"_-;\\-* #,##0.00\\ "€"_-;_-* "-"??\\ "€"_-;_-@_-'
|
||||||
|
|
||||||
|
|
||||||
|
def sortiert(positionen: list[dict]) -> list[dict]:
|
||||||
|
return sorted(positionen, key=lambda p: (p.get("zeitraum") or "", p.get("provisionsart") or "",
|
||||||
|
p.get("datum") or "", p.get("reihenfolge") or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _zahl_oder_text(s: str):
|
||||||
|
"""Rufnummern/IMEI als Zahl (Format „0“) wie in der Vorlage – führende Nullen bleiben als Text erhalten."""
|
||||||
|
s = (s or "").strip()
|
||||||
|
if s.isdigit() and not s.startswith("0") and len(s) <= 15:
|
||||||
|
return int(s)
|
||||||
|
return s or None
|
||||||
|
|
||||||
|
|
||||||
|
def dateiname(text: str) -> str:
|
||||||
|
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export"
|
||||||
|
|
||||||
|
|
||||||
|
def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True,
|
||||||
|
blattname: str = "Tabelle1") -> Path:
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl.styles import Font
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
ziel = Path(ziel)
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = blattname[:31] or "Tabelle1"
|
||||||
|
font = Font(name="Aptos Narrow", size=10)
|
||||||
|
bold = Font(name="Aptos Narrow", size=10, bold=True)
|
||||||
|
|
||||||
|
zeile = 1
|
||||||
|
if kopfzeile:
|
||||||
|
for c, (titel, _) in enumerate(SPALTEN, start=1):
|
||||||
|
ws.cell(zeile, c, titel).font = bold
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
zeile += 1
|
||||||
|
|
||||||
|
erste = zeile
|
||||||
|
for p in sortiert(positionen):
|
||||||
|
werte = [
|
||||||
|
p.get("zeitraum") or None,
|
||||||
|
p.get("provisionsart") or None,
|
||||||
|
_zahl_oder_text(p.get("kennung")),
|
||||||
|
date.fromisoformat(p["datum"]) if p.get("datum") else None,
|
||||||
|
_zahl_oder_text(p.get("rufnummer")),
|
||||||
|
_zahl_oder_text(p.get("imei")),
|
||||||
|
p.get("geraet") or None,
|
||||||
|
p.get("vorname") or None,
|
||||||
|
p.get("name") or None,
|
||||||
|
p.get("tarif") or None,
|
||||||
|
to_euro(p["betrag"]),
|
||||||
|
p.get("netz") or None,
|
||||||
|
p.get("dienst") or None,
|
||||||
|
p.get("aktionscode") or None,
|
||||||
|
]
|
||||||
|
for c, v in enumerate(werte, start=1):
|
||||||
|
cell = ws.cell(zeile, c, v)
|
||||||
|
cell.font = font
|
||||||
|
ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
|
||||||
|
ws.cell(zeile, 5).number_format = "0"
|
||||||
|
ws.cell(zeile, 6).number_format = "0"
|
||||||
|
ws.cell(zeile, 11).number_format = EURO_FORMAT
|
||||||
|
zeile += 1
|
||||||
|
letzte = zeile - 1
|
||||||
|
|
||||||
|
if summe and letzte >= erste:
|
||||||
|
s = ws.cell(zeile + 1, 11, f"=SUM(K{erste}:K{letzte})")
|
||||||
|
s.number_format = EURO_FORMAT
|
||||||
|
s.font = bold
|
||||||
|
ws.cell(zeile + 1, 10, "Summe").font = bold
|
||||||
|
|
||||||
|
for c, (_, breite) in enumerate(SPALTEN, start=1):
|
||||||
|
ws.column_dimensions[get_column_letter(c)].width = breite
|
||||||
|
ws.page_setup.orientation = "landscape"
|
||||||
|
ws.page_setup.fitToWidth = 1
|
||||||
|
ws.page_setup.fitToHeight = 0
|
||||||
|
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
||||||
|
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
wb.save(ziel)
|
||||||
|
return ziel
|
||||||
|
|
||||||
|
|
||||||
|
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
|
||||||
|
pro_anbieter: bool = True, kopfzeile: bool = False, summe: bool = True) -> list[Path]:
|
||||||
|
"""Schreibt eine (oder je Anbieter eine) Datei für einen Händler. Gibt die erzeugten Pfade zurück."""
|
||||||
|
ordner = Path(ordner)
|
||||||
|
if not positionen:
|
||||||
|
return []
|
||||||
|
gruppen: dict[str, list[dict]] = {}
|
||||||
|
if pro_anbieter:
|
||||||
|
for p in positionen:
|
||||||
|
gruppen.setdefault(p["anbieter"], []).append(p)
|
||||||
|
else:
|
||||||
|
gruppen[""] = positionen
|
||||||
|
out = []
|
||||||
|
for anbieter, pos in sorted(gruppen.items()):
|
||||||
|
teile = [haendler_name] + ([ANBIETER.get(anbieter, anbieter)] if anbieter else []) + [titel]
|
||||||
|
name = dateiname(" - ".join(t for t in teile if t)) + ".xlsx"
|
||||||
|
out.append(schreibe_xlsx(ordner / name, pos, kopfzeile=kopfzeile, summe=summe))
|
||||||
|
return out
|
||||||
Whitespace-only changes.
@@ -0,0 +1,803 @@
|
|||||||
|
"""Hauptfenster des Provisions-Managers 95."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tkinter as tk
|
||||||
|
import traceback
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import filedialog
|
||||||
|
|
||||||
|
from provtool import APP_NAME, config
|
||||||
|
from provtool import export as exp
|
||||||
|
from provtool.db import UNZUGEORDNET, Datenbank
|
||||||
|
from provtool.gui import dialogs as dlg
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
from provtool.importer import ANBIETER, ImportFehler, lies_datei
|
||||||
|
from provtool.money import fmt, fmt_percent, parse_cent
|
||||||
|
|
||||||
|
ALLE = -1 # Ansicht „Alle Positionen“
|
||||||
|
MONATE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober",
|
||||||
|
"November", "Dezember"]
|
||||||
|
|
||||||
|
|
||||||
|
def _datum_de(iso: str | None) -> str:
|
||||||
|
if not iso:
|
||||||
|
return ""
|
||||||
|
y, m, d = iso.split("-")
|
||||||
|
return f"{d}.{m}.{y}"
|
||||||
|
|
||||||
|
|
||||||
|
def _zeitraum_kurz(z: str | None) -> str:
|
||||||
|
"""'2026.10.01-2026.10.07' → '01.10.–07.10.'"""
|
||||||
|
m = re.fullmatch(r"(\d{4})\.(\d{2})\.(\d{2})\s*-\s*(\d{4})\.(\d{2})\.(\d{2})", (z or "").strip())
|
||||||
|
if m:
|
||||||
|
return f"{m[3]}.{m[2]}.–{m[6]}.{m[5]}."
|
||||||
|
return z or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _oeffne_ordner(pfad: str):
|
||||||
|
try:
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
os.startfile(pfad) # type: ignore[attr-defined]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.Popen(["open", pfad])
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", pfad])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
def __init__(self, root: tk.Tk):
|
||||||
|
self.root = root
|
||||||
|
self.settings = config.load()
|
||||||
|
self.db = Datenbank(config.db_path(self.settings))
|
||||||
|
self.aid: int | None = None
|
||||||
|
self.positionen: list[dict] = []
|
||||||
|
self.haendler_namen: dict[int, str] = {}
|
||||||
|
self.ansicht: int = ALLE
|
||||||
|
self._edit: tk.Entry | None = None
|
||||||
|
|
||||||
|
w.apply(root)
|
||||||
|
self.icons = w.Icons(root)
|
||||||
|
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
|
||||||
|
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
|
||||||
|
root.title(APP_NAME)
|
||||||
|
root.geometry(self.settings.get("fenster", "1200x720"))
|
||||||
|
root.minsize(900, 520)
|
||||||
|
root.protocol("WM_DELETE_WINDOW", self.beenden)
|
||||||
|
root.report_callback_exception = self._fehler_handler
|
||||||
|
|
||||||
|
self._menue()
|
||||||
|
self._toolbar()
|
||||||
|
self._body()
|
||||||
|
self.status = w.Statusleiste(root, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
|
||||||
|
self.status.pack(side="bottom", fill="x")
|
||||||
|
self._tasten()
|
||||||
|
|
||||||
|
abr = self.db.abrechnungen()
|
||||||
|
if abr:
|
||||||
|
self.oeffnen(abr[0]["id"])
|
||||||
|
else:
|
||||||
|
self._aktualisieren()
|
||||||
|
root.after(300, self._willkommen)
|
||||||
|
|
||||||
|
# ================================================================= Aufbau
|
||||||
|
def _menue(self):
|
||||||
|
m = tk.Menu(self.root, tearoff=0)
|
||||||
|
d = tk.Menu(m, tearoff=0)
|
||||||
|
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_separator()
|
||||||
|
d.add_command(label="Dateien importieren…", accelerator="Strg+I", command=self.importieren)
|
||||||
|
d.add_command(label="Importierte Dateien…", command=self.dateien)
|
||||||
|
d.add_command(label="Exportieren…", accelerator="Strg+E", command=self.exportieren)
|
||||||
|
d.add_separator()
|
||||||
|
d.add_command(label="Datensicherung erstellen…", command=self.sicherung)
|
||||||
|
d.add_command(label="Datensicherung einspielen…", command=self.wiederherstellen)
|
||||||
|
d.add_command(label="Einstellungen…", command=self.einstellungen)
|
||||||
|
d.add_separator()
|
||||||
|
d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden)
|
||||||
|
m.add_cascade(label="Datei", menu=d, underline=0)
|
||||||
|
|
||||||
|
b = tk.Menu(m, tearoff=0)
|
||||||
|
b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_bearbeiten)
|
||||||
|
b.add_command(label="Auszahlung in Prozent…", accelerator="F4", command=self.prozent)
|
||||||
|
b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
|
||||||
|
b.add_separator()
|
||||||
|
b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen)
|
||||||
|
m.add_cascade(label="Bearbeiten", menu=b, underline=0)
|
||||||
|
|
||||||
|
h = tk.Menu(m, tearoff=0)
|
||||||
|
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler)
|
||||||
|
h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||||||
|
m.add_cascade(label="Händler", menu=h, underline=0)
|
||||||
|
|
||||||
|
hi = tk.Menu(m, tearoff=0)
|
||||||
|
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
|
||||||
|
hi.add_separator()
|
||||||
|
hi.add_command(label=f"Info über {APP_NAME}…", command=lambda: dlg.InfoDialog(self.root).zeigen())
|
||||||
|
m.add_cascade(label="?", menu=hi)
|
||||||
|
self.root.configure(menu=m)
|
||||||
|
|
||||||
|
def _toolbar(self):
|
||||||
|
tb = tk.Frame(self.root, bg=w.FACE, bd=1, relief="raised")
|
||||||
|
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
|
||||||
|
w.ToolbarButton(tb, text, self.icons(icon), cmd, tip).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.v_suche.trace_add("write", lambda *_: self._tabelle())
|
||||||
|
self._suchfeld = e
|
||||||
|
|
||||||
|
def _body(self):
|
||||||
|
desk = tk.Frame(self.root, bg=w.TEAL)
|
||||||
|
desk.pack(side="top", fill="both", expand=True)
|
||||||
|
pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0)
|
||||||
|
pw.pack(fill="both", expand=True, padx=6, pady=6)
|
||||||
|
|
||||||
|
# --- Händler-Fenster
|
||||||
|
self.f_h = w.Fenster(pw, "Händler", self.icons("haendler"))
|
||||||
|
r, self.htree = w.scroll_tree(self.f_h.inhalt, columns=("anzahl", "summe"), show="tree headings",
|
||||||
|
selectmode="browse")
|
||||||
|
self.htree.heading("#0", text="Händler", anchor="w")
|
||||||
|
self.htree.heading("anzahl", text="Pos.")
|
||||||
|
self.htree.heading("summe", text="Auszahlung")
|
||||||
|
self.htree.column("#0", width=150, minwidth=80, stretch=False)
|
||||||
|
self.htree.column("anzahl", width=38, anchor="e", stretch=False)
|
||||||
|
self.htree.column("summe", width=84, anchor="e", stretch=False)
|
||||||
|
w.auto_breite(self.htree, {"#0": 1})
|
||||||
|
self.htree.tag_configure("grau", foreground=w.SHADOW)
|
||||||
|
self.htree.tag_configure("fett", font=w.FONT_BOLD)
|
||||||
|
r.pack(fill="both", expand=True)
|
||||||
|
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
|
||||||
|
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
|
||||||
|
pw.add(self.f_h, minsize=240, width=310)
|
||||||
|
|
||||||
|
# --- Positionen-Fenster
|
||||||
|
self.f_p = w.Fenster(pw, "Positionen", self.icons("neu"))
|
||||||
|
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz")
|
||||||
|
r2, self.ptree = w.scroll_tree(self.f_p.inhalt, columns=cols, show="tree headings", selectmode="extended")
|
||||||
|
self.ptree.heading("#0", text="Provisionsart / Kunde", anchor="w")
|
||||||
|
self.ptree.column("#0", width=200, minwidth=140, stretch=False)
|
||||||
|
for c, t, b, a in (("datum", "Datum", 76, "w"), ("rufnummer", "Rufnummer", 100, "w"),
|
||||||
|
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 84, "w"),
|
||||||
|
("kennung", "Kennung", 70, "w"),
|
||||||
|
("orig", "Provision", 78, "e"), ("proz", "%", 52, "e"), ("ausz", "Auszahlung", 84, "e")):
|
||||||
|
self.ptree.heading(c, text=t, anchor=a)
|
||||||
|
self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False)
|
||||||
|
w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4})
|
||||||
|
self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD)
|
||||||
|
self.ptree.tag_configure("manuell", foreground="#0000c0")
|
||||||
|
self.ptree.tag_configure("abw", foreground="#a00000")
|
||||||
|
self.ptree.tag_configure("negativ", foreground="#a00000")
|
||||||
|
r2.pack(fill="both", expand=True)
|
||||||
|
self.ptree.bind("<Double-1>", self._doppelklick)
|
||||||
|
self.ptree.bind("<Button-3>", self._kontextmenue)
|
||||||
|
self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl())
|
||||||
|
|
||||||
|
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
|
||||||
|
leiste.pack(fill="x", pady=(4, 0))
|
||||||
|
w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent")).pack(side="left")
|
||||||
|
w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
|
||||||
|
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
|
||||||
|
self.v_alle_auf = tk.BooleanVar(value=True)
|
||||||
|
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
|
||||||
|
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
|
||||||
|
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack(
|
||||||
|
side="left", padx=8)
|
||||||
|
pw.add(self.f_p, minsize=500)
|
||||||
|
|
||||||
|
def _tasten(self):
|
||||||
|
r = self.root
|
||||||
|
r.bind_all("<Control-n>", lambda e: self.neu())
|
||||||
|
r.bind_all("<Control-o>", lambda e: self.abrechnung_waehlen())
|
||||||
|
r.bind_all("<Control-i>", lambda e: self.importieren())
|
||||||
|
r.bind_all("<Control-e>", lambda e: self.exportieren())
|
||||||
|
r.bind_all("<Control-h>", lambda e: self.haendler())
|
||||||
|
r.bind_all("<Control-f>", lambda e: self._suchfeld.focus_set())
|
||||||
|
r.bind_all("<F1>", lambda e: self.hilfe())
|
||||||
|
self.ptree.bind("<F2>", lambda e: self.betrag_bearbeiten())
|
||||||
|
self.ptree.bind("<Return>", lambda e: self.betrag_bearbeiten())
|
||||||
|
self.ptree.bind("<F4>", lambda e: self.prozent())
|
||||||
|
|
||||||
|
# ================================================================= Daten & Anzeige
|
||||||
|
def oeffnen(self, aid: int):
|
||||||
|
self.aid = aid
|
||||||
|
self._aktualisieren()
|
||||||
|
|
||||||
|
def _aktualisieren(self, behalte_auswahl: bool = True):
|
||||||
|
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
|
||||||
|
if self.aid and self.db.abrechnung(self.aid):
|
||||||
|
self.positionen = self.db.positionen(self.aid)
|
||||||
|
titel = self.db.abrechnung(self.aid)["titel"]
|
||||||
|
self.root.title(f"{APP_NAME} - [{titel}]")
|
||||||
|
self.status.set("info", f" Abrechnung: {titel}")
|
||||||
|
else:
|
||||||
|
self.aid = None
|
||||||
|
self.positionen = []
|
||||||
|
self.root.title(APP_NAME)
|
||||||
|
self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import")
|
||||||
|
self._haendlerliste(behalte_auswahl)
|
||||||
|
self._tabelle()
|
||||||
|
|
||||||
|
def _gefiltert(self) -> list[dict]:
|
||||||
|
if self.ansicht == ALLE:
|
||||||
|
return self.positionen
|
||||||
|
return [p for p in self.positionen if p["haendler_id"] == self.ansicht]
|
||||||
|
|
||||||
|
def _haendlerliste(self, behalte_auswahl=True):
|
||||||
|
t = self.htree
|
||||||
|
t.delete(*t.get_children())
|
||||||
|
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0, 0])
|
||||||
|
for p in self.positionen:
|
||||||
|
s = stat[p["haendler_id"]]
|
||||||
|
s[0] += 1
|
||||||
|
s[1] += p["betrag"]
|
||||||
|
s[2] += p["betrag_orig"]
|
||||||
|
ges = [sum(s[0] for s in stat.values()), sum(s[1] for s in stat.values())]
|
||||||
|
t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",))
|
||||||
|
for h in self.db.haendler_liste():
|
||||||
|
s = stat.get(h.id, [0, 0, 0])
|
||||||
|
tags = () if s[0] else ("grau",)
|
||||||
|
t.insert("", "end", iid=str(h.id), text=h.name, values=(s[0] or "", fmt(s[1]) if s[0] else ""), tags=tags)
|
||||||
|
s = stat.get(UNZUGEORDNET)
|
||||||
|
if s:
|
||||||
|
t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])),
|
||||||
|
tags=("grau",))
|
||||||
|
ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE)
|
||||||
|
self.ansicht = int(ziel)
|
||||||
|
t.selection_set(ziel)
|
||||||
|
t.see(ziel)
|
||||||
|
|
||||||
|
def _haendler_gewaehlt(self, _e=None):
|
||||||
|
sel = self.htree.selection()
|
||||||
|
if sel and int(sel[0]) != self.ansicht:
|
||||||
|
self.ansicht = int(sel[0])
|
||||||
|
self._tabelle()
|
||||||
|
|
||||||
|
def _haendler_doppelklick(self):
|
||||||
|
if self.ansicht > 0:
|
||||||
|
self.haendler(self.ansicht)
|
||||||
|
elif self.ansicht == UNZUGEORDNET:
|
||||||
|
self.kennung_zuordnen_auswahl()
|
||||||
|
|
||||||
|
def _gruppe_von(self, p: dict) -> str:
|
||||||
|
if self.ansicht == UNZUGEORDNET:
|
||||||
|
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}"
|
||||||
|
if self.ansicht == ALLE:
|
||||||
|
name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)")
|
||||||
|
return f"{name} · {p['provisionsart'] or '(ohne)'}"
|
||||||
|
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}"
|
||||||
|
|
||||||
|
def _tabelle(self):
|
||||||
|
if self._edit:
|
||||||
|
self._edit.destroy()
|
||||||
|
self._edit = None
|
||||||
|
t = self.ptree
|
||||||
|
offen = {iid for iid in t.get_children() if t.item(iid, "open")}
|
||||||
|
war_leer = not 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)
|
||||||
|
for p in exp.sortiert(pos):
|
||||||
|
gruppen[self._gruppe_von(p)].append(p)
|
||||||
|
self._gruppen = {}
|
||||||
|
for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
|
||||||
|
giid = f"g{gi}"
|
||||||
|
self._gruppen[giid] = (gname, plist)
|
||||||
|
so = sum(p["betrag_orig"] for p in plist)
|
||||||
|
sa = sum(p["betrag"] for p in plist)
|
||||||
|
proz = {p["prozent"] for p in plist}
|
||||||
|
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
|
||||||
|
t.insert("", "end", iid=giid, text=f"{gname} ({len(plist)})", open=(war_leer or giid in offen or bool(such)),
|
||||||
|
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
|
||||||
|
for p in plist:
|
||||||
|
tags = []
|
||||||
|
if p["manuell"]:
|
||||||
|
tags.append("manuell")
|
||||||
|
elif p["betrag"] != p["betrag_orig"]:
|
||||||
|
tags.append("abw")
|
||||||
|
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,
|
||||||
|
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
||||||
|
p["kennung"], fmt(p["betrag_orig"]),
|
||||||
|
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
|
||||||
|
tags=tuple(tags))
|
||||||
|
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
|
||||||
|
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
|
||||||
|
self.f_p.set_titel(f"Positionen – {name}")
|
||||||
|
so = sum(p["betrag_orig"] for p in pos)
|
||||||
|
sa = sum(p["betrag"] for p in pos)
|
||||||
|
self.status.set("pos", f" {len(pos)} Positionen")
|
||||||
|
self.status.set("orig", f" Provision: {fmt(so)}")
|
||||||
|
self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
|
||||||
|
|
||||||
|
def _status_auswahl(self):
|
||||||
|
pids = self._ausgewaehlte_pids()
|
||||||
|
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())):
|
||||||
|
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
||||||
|
self.status.set("pos", f" {len(ps)} ausgewählt")
|
||||||
|
self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}")
|
||||||
|
self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
|
||||||
|
|
||||||
|
def _klappen(self, auf: bool):
|
||||||
|
for iid in self.ptree.get_children():
|
||||||
|
self.ptree.item(iid, open=auf)
|
||||||
|
|
||||||
|
def _ausgewaehlte_pids(self) -> list[int]:
|
||||||
|
pids: list[int] = []
|
||||||
|
for iid in self.ptree.selection():
|
||||||
|
if iid.startswith("g"):
|
||||||
|
pids.extend(p["id"] for p in self._gruppen[iid][1])
|
||||||
|
elif iid.startswith("p"):
|
||||||
|
pids.append(int(iid[1:]))
|
||||||
|
return list(dict.fromkeys(pids))
|
||||||
|
|
||||||
|
# ================================================================= 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:
|
||||||
|
heute = date.today()
|
||||||
|
m = heute.month - 1 or 12
|
||||||
|
y = heute.year if heute.month > 1 else heute.year - 1
|
||||||
|
return f"{MONATE[m - 1]} {y}"
|
||||||
|
|
||||||
|
def neu(self) -> bool:
|
||||||
|
t = w.eingabe(self.root, "Neue Abrechnung", "Bezeichnung der Abrechnung (z. B. Abrechnungsmonat):",
|
||||||
|
self._vorschlag_titel())
|
||||||
|
if not t or not t.strip():
|
||||||
|
return False
|
||||||
|
self.aid = self.db.abrechnung_neu(t.strip())
|
||||||
|
self.ansicht = ALLE
|
||||||
|
self._aktualisieren()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def abrechnung_waehlen(self):
|
||||||
|
if not self.db.abrechnungen():
|
||||||
|
w.info(self.root, "Abrechnung öffnen", "Es gibt noch keine gespeicherten Abrechnungen.")
|
||||||
|
return
|
||||||
|
d = dlg.AbrechnungenDialog(self.root, self.db, self.aid)
|
||||||
|
res = d.zeigen()
|
||||||
|
if res and res[0] == "oeffnen":
|
||||||
|
self.ansicht = ALLE
|
||||||
|
self.oeffnen(res[1])
|
||||||
|
else:
|
||||||
|
self._aktualisieren()
|
||||||
|
|
||||||
|
def importieren(self, dateien: list[str] | None = None):
|
||||||
|
if not dateien:
|
||||||
|
dateien = list(filedialog.askopenfilenames(
|
||||||
|
parent=self.root, title="Provisionsabrechnungen importieren",
|
||||||
|
initialdir=self.settings.get("import_ordner") or None,
|
||||||
|
filetypes=[("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
|
||||||
|
("CSV-Dateien", "*.csv"), ("Excel-Dateien", "*.xlsx *.xlsm *.xls"),
|
||||||
|
("Alle Dateien", "*.*")]))
|
||||||
|
if not dateien:
|
||||||
|
return
|
||||||
|
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
|
||||||
|
if not self._brauche_abrechnung():
|
||||||
|
return
|
||||||
|
bericht = []
|
||||||
|
self.root.configure(cursor="watch")
|
||||||
|
self.root.update()
|
||||||
|
try:
|
||||||
|
for f in dateien:
|
||||||
|
name = Path(f).name
|
||||||
|
try:
|
||||||
|
erg = lies_datei(f)
|
||||||
|
except ImportFehler as exc:
|
||||||
|
bericht.append((name, "–", 0, 0, str(exc).split("\n")[0]))
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
bericht.append((name, "–", 0, 0, f"Fehler: {exc}"))
|
||||||
|
continue
|
||||||
|
schon = self.db.datei_schon_importiert(erg.sha256)
|
||||||
|
if schon and not w.frage(self.root, "Bereits importiert",
|
||||||
|
f"„{name}“ wurde bereits importiert (in: {', '.join(schon)}).\n\n"
|
||||||
|
"Trotzdem noch einmal importieren? Die Positionen wären dann doppelt.",
|
||||||
|
default_ja=False):
|
||||||
|
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
|
||||||
|
continue
|
||||||
|
n = self.db.importieren(self.aid, erg)
|
||||||
|
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
|
||||||
|
"; ".join(erg.warnungen) or "OK"))
|
||||||
|
finally:
|
||||||
|
self.root.configure(cursor="")
|
||||||
|
self._aktualisieren()
|
||||||
|
unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
||||||
|
for p in self.positionen:
|
||||||
|
if p["haendler_id"] == UNZUGEORDNET:
|
||||||
|
u = unbekannt[(p["anbieter"], p["kennung"])]
|
||||||
|
u[0] += 1
|
||||||
|
u[1] += p["betrag_orig"]
|
||||||
|
res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen()
|
||||||
|
if res:
|
||||||
|
self.kennung_zuordnen(*res)
|
||||||
|
|
||||||
|
def dateien(self):
|
||||||
|
if not self.aid:
|
||||||
|
return
|
||||||
|
if dlg.DateienDialog(self.root, self.db, self.aid).zeigen():
|
||||||
|
self._aktualisieren()
|
||||||
|
|
||||||
|
def haendler(self, hid: int | None = None, neue_kennung: tuple[str, str] | None = None):
|
||||||
|
geaendert = dlg.HaendlerDialog(self.root, self.db, hid, neue_kennung).zeigen()
|
||||||
|
if geaendert:
|
||||||
|
self._nach_haendler_aenderung()
|
||||||
|
|
||||||
|
def _nach_haendler_aenderung(self):
|
||||||
|
if self.aid:
|
||||||
|
n = self.db.neu_berechnen(self.aid)
|
||||||
|
if n:
|
||||||
|
self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet")
|
||||||
|
self._aktualisieren()
|
||||||
|
|
||||||
|
def kennung_zuordnen(self, anbieter: str, kennung: str):
|
||||||
|
res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen()
|
||||||
|
if not res:
|
||||||
|
return
|
||||||
|
if res[0] == "neu":
|
||||||
|
self.haendler(neue_kennung=(anbieter, kennung))
|
||||||
|
else:
|
||||||
|
self._nach_haendler_aenderung()
|
||||||
|
|
||||||
|
def kennung_zuordnen_auswahl(self):
|
||||||
|
pids = self._ausgewaehlte_pids()
|
||||||
|
p = next((x for x in self.positionen if pids and x["id"] == pids[0]), None)
|
||||||
|
if not p:
|
||||||
|
w.info(self.root, "Kennung zuordnen", "Bitte zuerst eine Position oder Gruppe auswählen.")
|
||||||
|
return
|
||||||
|
if p["haendler_id"] != UNZUGEORDNET:
|
||||||
|
w.info(self.root, "Kennung zuordnen",
|
||||||
|
f"Die Kennung {p['kennung']} gehört bereits zu „{self.haendler_namen.get(p['haendler_id'])}“.")
|
||||||
|
return
|
||||||
|
self.kennung_zuordnen(p["anbieter"], p["kennung"])
|
||||||
|
|
||||||
|
def _auswahl_info(self):
|
||||||
|
sel = self.ptree.selection()
|
||||||
|
pids = self._ausgewaehlte_pids()
|
||||||
|
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
||||||
|
return sel, pids, ps
|
||||||
|
|
||||||
|
def prozent(self):
|
||||||
|
sel, pids, ps = self._auswahl_info()
|
||||||
|
if not ps:
|
||||||
|
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
|
||||||
|
"in der Liste auswählen.")
|
||||||
|
return
|
||||||
|
hids = {p["haendler_id"] for p in ps}
|
||||||
|
arten = {p["provisionsart"] for p in ps}
|
||||||
|
hid = next(iter(hids)) if len(hids) == 1 else None
|
||||||
|
art = next(iter(arten)) if len(arten) == 1 else None
|
||||||
|
regel_moeglich = bool(hid and hid > 0 and art is not None)
|
||||||
|
hname = self.haendler_namen.get(hid, "(Nicht zugeordnet)") if hid is not None else "(mehrere)"
|
||||||
|
akt = {p["prozent"] for p in ps if p["prozent"] is not None}
|
||||||
|
vorschlag = next(iter(akt)) if len(akt) == 1 else 100.0
|
||||||
|
nur_gruppe = len(sel) == 1 and sel[0].startswith("g")
|
||||||
|
res = dlg.ProzentDialog(self.root, hname, art if nur_gruppe or len(ps) > 1 else None, len(ps),
|
||||||
|
sum(p["betrag_orig"] for p in ps), vorschlag, regel_moeglich).zeigen()
|
||||||
|
if not res:
|
||||||
|
return
|
||||||
|
proz, als_regel, manuelle = res
|
||||||
|
if als_regel and regel_moeglich:
|
||||||
|
self.db.regel_setzen(hid, art, proz)
|
||||||
|
self.db.prozent_anwenden(pids, proz, auch_manuelle=manuelle)
|
||||||
|
self.db.touch(self.aid)
|
||||||
|
self._aktualisieren()
|
||||||
|
self._auswahl_wiederherstellen(sel)
|
||||||
|
|
||||||
|
def _auswahl_wiederherstellen(self, sel):
|
||||||
|
vorhanden = [i for i in sel if self.ptree.exists(i)]
|
||||||
|
if vorhanden:
|
||||||
|
self.ptree.selection_set(vorhanden)
|
||||||
|
self.ptree.see(vorhanden[0])
|
||||||
|
|
||||||
|
def zuruecksetzen(self):
|
||||||
|
sel, pids, ps = self._auswahl_info()
|
||||||
|
if not ps:
|
||||||
|
return
|
||||||
|
for p in ps:
|
||||||
|
self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"]))
|
||||||
|
self.db.touch(self.aid)
|
||||||
|
self._aktualisieren()
|
||||||
|
self._auswahl_wiederherstellen(sel)
|
||||||
|
|
||||||
|
def neu_berechnen(self):
|
||||||
|
if not self.aid:
|
||||||
|
return
|
||||||
|
manuell = sum(1 for p in self.positionen if p["manuell"])
|
||||||
|
auch = False
|
||||||
|
if manuell:
|
||||||
|
auch = w.frage(self.root, "Neu berechnen",
|
||||||
|
f"{manuell} Position(en) wurden von Hand geändert.\n\nSollen diese ebenfalls nach den "
|
||||||
|
"Händler-Regeln neu berechnet werden?", ja="Ja, alle", nein="Nein, behalten",
|
||||||
|
default_ja=False)
|
||||||
|
n = self.db.neu_berechnen(self.aid, auch_manuelle=auch)
|
||||||
|
self._aktualisieren()
|
||||||
|
w.info(self.root, "Neu berechnen", f"{n} Position(en) wurden neu berechnet.")
|
||||||
|
|
||||||
|
# ----- Inline-Bearbeitung des Auszahlungsbetrags
|
||||||
|
def _doppelklick(self, e):
|
||||||
|
iid = self.ptree.identify_row(e.y)
|
||||||
|
if not iid:
|
||||||
|
return
|
||||||
|
if iid.startswith("g"):
|
||||||
|
self.ptree.selection_set(iid)
|
||||||
|
if self.ptree.identify_column(e.x) in ("#7", "#8"):
|
||||||
|
self.prozent()
|
||||||
|
return "break"
|
||||||
|
return
|
||||||
|
self.ptree.selection_set(iid)
|
||||||
|
self.betrag_bearbeiten(iid)
|
||||||
|
|
||||||
|
def betrag_bearbeiten(self, iid: str | None = None):
|
||||||
|
if iid is None:
|
||||||
|
sel = [i for i in self.ptree.selection() if i.startswith("p")]
|
||||||
|
if len(sel) != 1:
|
||||||
|
if any(i.startswith("g") for i in self.ptree.selection()) or len(sel) > 1:
|
||||||
|
self.prozent()
|
||||||
|
else:
|
||||||
|
w.info(self.root, "Betrag ändern", "Bitte eine einzelne Position auswählen.")
|
||||||
|
return
|
||||||
|
iid = sel[0]
|
||||||
|
self.ptree.see(iid)
|
||||||
|
self.ptree.update_idletasks()
|
||||||
|
bbox = self.ptree.bbox(iid, "ausz")
|
||||||
|
if not bbox:
|
||||||
|
return
|
||||||
|
pid = int(iid[1:])
|
||||||
|
p = next(x for x in self.positionen if x["id"] == pid)
|
||||||
|
x, y, bw, bh = bbox
|
||||||
|
var = tk.StringVar(value=fmt(p["betrag"], symbol=False))
|
||||||
|
e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT)
|
||||||
|
e.place(x=x, y=y, width=bw, height=bh)
|
||||||
|
e.select_range(0, "end")
|
||||||
|
e.focus_set()
|
||||||
|
self._edit = e
|
||||||
|
|
||||||
|
def speichern(_ev=None, weiter: int = 0):
|
||||||
|
if self._edit is not e:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cent = parse_cent(var.get())
|
||||||
|
except ValueError:
|
||||||
|
e.configure(bg="#ffc0c0")
|
||||||
|
return "break"
|
||||||
|
self._edit = None
|
||||||
|
e.destroy()
|
||||||
|
if cent != p["betrag"]:
|
||||||
|
self.db.betrag_setzen(pid, cent)
|
||||||
|
self.db.touch(self.aid)
|
||||||
|
self._aktualisieren()
|
||||||
|
nxt = self.ptree.next(iid) if weiter > 0 else (self.ptree.prev(iid) if weiter < 0 else None)
|
||||||
|
if self.ptree.exists(iid):
|
||||||
|
self.ptree.selection_set(iid)
|
||||||
|
self.ptree.focus(iid)
|
||||||
|
if nxt and self.ptree.exists(nxt):
|
||||||
|
self.ptree.selection_set(nxt)
|
||||||
|
self.ptree.focus(nxt)
|
||||||
|
self.betrag_bearbeiten(nxt)
|
||||||
|
else:
|
||||||
|
self.ptree.focus_set()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def abbruch(_ev=None):
|
||||||
|
self._edit = None
|
||||||
|
e.destroy()
|
||||||
|
self.ptree.focus_set()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
e.bind("<Return>", speichern)
|
||||||
|
e.bind("<KP_Enter>", speichern)
|
||||||
|
e.bind("<Tab>", lambda ev: speichern(ev, 1))
|
||||||
|
e.bind("<Down>", lambda ev: speichern(ev, 1))
|
||||||
|
e.bind("<Up>", lambda ev: speichern(ev, -1))
|
||||||
|
e.bind("<Escape>", abbruch)
|
||||||
|
e.bind("<FocusOut>", speichern)
|
||||||
|
|
||||||
|
def _kontextmenue(self, e):
|
||||||
|
iid = self.ptree.identify_row(e.y)
|
||||||
|
if not iid:
|
||||||
|
return
|
||||||
|
if iid not in self.ptree.selection():
|
||||||
|
self.ptree.selection_set(iid)
|
||||||
|
m = tk.Menu(self.root, tearoff=0)
|
||||||
|
if iid.startswith("p"):
|
||||||
|
m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid))
|
||||||
|
m.add_command(label="Auszahlung in Prozent…", command=self.prozent)
|
||||||
|
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
|
||||||
|
m.add_separator()
|
||||||
|
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
|
||||||
|
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
|
||||||
|
m.add_separator()
|
||||||
|
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||||||
|
m.tk_popup(e.x_root, e.y_root)
|
||||||
|
|
||||||
|
def _null(self):
|
||||||
|
sel, pids, _ = self._auswahl_info()
|
||||||
|
self.db.prozent_anwenden(pids, 0.0)
|
||||||
|
self.db.touch(self.aid)
|
||||||
|
self._aktualisieren()
|
||||||
|
self._auswahl_wiederherstellen(sel)
|
||||||
|
|
||||||
|
# ----- Export
|
||||||
|
def exportieren(self):
|
||||||
|
if not self.aid or not self.positionen:
|
||||||
|
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
|
||||||
|
"Bitte zuerst Dateien importieren.")
|
||||||
|
return
|
||||||
|
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
||||||
|
for p in self.positionen:
|
||||||
|
if p["haendler_id"] != UNZUGEORDNET:
|
||||||
|
stat[p["haendler_id"]][0] += 1
|
||||||
|
stat[p["haendler_id"]][1] += p["betrag"]
|
||||||
|
if not stat:
|
||||||
|
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
|
||||||
|
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
|
||||||
|
return
|
||||||
|
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in
|
||||||
|
sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
|
||||||
|
vor = {self.ansicht} if self.ansicht in stat else set(stat)
|
||||||
|
titel = self.db.abrechnung(self.aid)["titel"]
|
||||||
|
res = dlg.ExportDialog(self.root, liste, vor, self.settings, titel).zeigen()
|
||||||
|
if not res:
|
||||||
|
return
|
||||||
|
config.save(self.settings)
|
||||||
|
erzeugt = []
|
||||||
|
try:
|
||||||
|
for hid in res["ids"]:
|
||||||
|
pos = [p for p in self.positionen if p["haendler_id"] == hid]
|
||||||
|
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
|
||||||
|
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
|
||||||
|
summe=res["summe"])
|
||||||
|
except PermissionError as exc:
|
||||||
|
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
|
||||||
|
"Ist sie vielleicht noch in Excel geöffnet?")
|
||||||
|
return
|
||||||
|
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
|
||||||
|
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n…" if len(erzeugt) > 15 else ""))
|
||||||
|
if res["oeffnen"]:
|
||||||
|
_oeffne_ordner(res["ordner"])
|
||||||
|
|
||||||
|
# ----- Datei-Menü
|
||||||
|
def sicherung(self):
|
||||||
|
p = filedialog.asksaveasfilename(parent=self.root, title="Datensicherung speichern", defaultextension=".db",
|
||||||
|
initialfile=f"Provisionen-Sicherung-{date.today().isoformat()}.db",
|
||||||
|
filetypes=[("Datenbank", "*.db")])
|
||||||
|
if p:
|
||||||
|
self.db.backup(p)
|
||||||
|
w.info(self.root, "Datensicherung", f"Sicherung gespeichert:\n{p}")
|
||||||
|
|
||||||
|
def wiederherstellen(self):
|
||||||
|
p = filedialog.askopenfilename(parent=self.root, title="Datensicherung einspielen",
|
||||||
|
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
|
||||||
|
if not p:
|
||||||
|
return
|
||||||
|
if not w.frage(self.root, "Datensicherung einspielen",
|
||||||
|
"Die aktuelle Datenbank wird durch die Sicherung ERSETZT.\n"
|
||||||
|
"Vorher wird automatisch eine Kopie (*.vor-wiederherstellung.db) angelegt.\n\nFortfahren?",
|
||||||
|
default_ja=False):
|
||||||
|
return
|
||||||
|
ziel = self.db.path
|
||||||
|
self.db.backup(ziel.with_suffix(".vor-wiederherstellung.db"))
|
||||||
|
self.db.close()
|
||||||
|
try:
|
||||||
|
Datenbank.restore(p, ziel)
|
||||||
|
except Exception as exc:
|
||||||
|
w.fehler(self.root, "Datensicherung", f"Die Datei ist keine gültige Sicherung:\n{exc}")
|
||||||
|
self.db = Datenbank(ziel)
|
||||||
|
self.aid = None
|
||||||
|
abr = self.db.abrechnungen()
|
||||||
|
if abr:
|
||||||
|
self.aid = abr[0]["id"]
|
||||||
|
self.ansicht = ALLE
|
||||||
|
self._aktualisieren()
|
||||||
|
|
||||||
|
def einstellungen(self):
|
||||||
|
neu = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
|
||||||
|
if neu and Path(neu) != self.db.path:
|
||||||
|
try:
|
||||||
|
db = Datenbank(neu)
|
||||||
|
except Exception as exc:
|
||||||
|
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
|
||||||
|
return
|
||||||
|
self.db.close()
|
||||||
|
self.db = db
|
||||||
|
self.settings["db_pfad"] = neu
|
||||||
|
config.save(self.settings)
|
||||||
|
abr = self.db.abrechnungen()
|
||||||
|
self.aid = abr[0]["id"] if abr else None
|
||||||
|
self.ansicht = ALLE
|
||||||
|
self._aktualisieren()
|
||||||
|
|
||||||
|
def hilfe(self):
|
||||||
|
w.info(self.root, "Kurzanleitung",
|
||||||
|
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
|
||||||
|
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n\n"
|
||||||
|
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
|
||||||
|
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
|
||||||
|
" auch alle Wochenlisten eines Monats.\n\n"
|
||||||
|
"4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n"
|
||||||
|
" • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n"
|
||||||
|
" optional als Standard für diesen Händler merken.\n"
|
||||||
|
" • Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
|
||||||
|
" (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n"
|
||||||
|
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
|
||||||
|
"Alles wird automatisch in der Datenbank gespeichert.")
|
||||||
|
|
||||||
|
def _willkommen(self):
|
||||||
|
if not self.db.haendler_liste():
|
||||||
|
if w.frage(self.root, "Willkommen",
|
||||||
|
f"Willkommen beim {APP_NAME}!\n\nEs sind noch keine Händler angelegt.\n"
|
||||||
|
"Jetzt die Unterhändler mit ihren Kennungen anlegen?"):
|
||||||
|
self.haendler()
|
||||||
|
|
||||||
|
def _fehler_handler(self, exc, val, tb):
|
||||||
|
text = "".join(traceback.format_exception(exc, val, tb))
|
||||||
|
try:
|
||||||
|
with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f:
|
||||||
|
f.write(text + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
w.fehler(self.root, "Allgemeine Schutzverletzung",
|
||||||
|
f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n"
|
||||||
|
f"Details stehen in {config.app_dir() / 'fehler.log'}")
|
||||||
|
|
||||||
|
def beenden(self):
|
||||||
|
try:
|
||||||
|
self.settings["fenster"] = self.root.geometry()
|
||||||
|
config.save(self.settings)
|
||||||
|
self.db.close()
|
||||||
|
finally:
|
||||||
|
self.root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
try: # scharfe Darstellung auf HiDPI-Bildschirmen
|
||||||
|
import ctypes
|
||||||
|
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
root = tk.Tk(className="Provisionsmanager95")
|
||||||
|
app = App(root)
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
root.after(200, lambda: app.importieren([a for a in sys.argv[1:] if Path(a).exists()]))
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
"""Dialogfenster des Provisions-Managers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog, ttk
|
||||||
|
|
||||||
|
from provtool import APP_NAME, APP_ORG, __version__
|
||||||
|
from provtool.db import Datenbank, Haendler
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
from provtool.importer import ANBIETER
|
||||||
|
from provtool.money import fmt, fmt_percent, parse_percent
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Händlerverwaltung
|
||||||
|
|
||||||
|
class HaendlerDialog(w.Dialog):
|
||||||
|
def __init__(self, master, db: Datenbank, auswahl_id: int | None = None,
|
||||||
|
neue_kennung: tuple[str, str] | None = None):
|
||||||
|
super().__init__(master, "Händler verwalten", resizable=True)
|
||||||
|
self.db = db
|
||||||
|
self.aktuell: Haendler | None = None
|
||||||
|
self.geaendert = False
|
||||||
|
|
||||||
|
haupt = tk.Frame(self, bg=w.FACE)
|
||||||
|
haupt.pack(fill="both", expand=True, padx=8, pady=8)
|
||||||
|
|
||||||
|
# --- links: Liste
|
||||||
|
links = tk.Frame(haupt, bg=w.FACE)
|
||||||
|
links.pack(side="left", fill="y")
|
||||||
|
tk.Label(links, text="Händler:", anchor="w").pack(fill="x")
|
||||||
|
rahmen, self.liste = w.scroll_tree(links, columns=("name",), show="tree", selectmode="browse", height=16)
|
||||||
|
self.liste.column("#0", width=0, stretch=False)
|
||||||
|
self.liste.column("name", width=190)
|
||||||
|
rahmen.pack(fill="y", expand=True)
|
||||||
|
self.liste.bind("<<TreeviewSelect>>", self._auswahl)
|
||||||
|
lk = tk.Frame(links, bg=w.FACE)
|
||||||
|
lk.pack(fill="x", pady=(6, 0))
|
||||||
|
w.knopf(lk, "Neu", self._neu, breite=8).pack(side="left")
|
||||||
|
w.knopf(lk, "Löschen", self._loeschen, breite=8).pack(side="left", padx=4)
|
||||||
|
|
||||||
|
# --- rechts: Formular
|
||||||
|
rechts = tk.Frame(haupt, bg=w.FACE)
|
||||||
|
rechts.pack(side="left", fill="both", expand=True, padx=(10, 0))
|
||||||
|
|
||||||
|
g = w.gruppe(rechts, "Stammdaten")
|
||||||
|
g.pack(fill="x")
|
||||||
|
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar()
|
||||||
|
self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
|
||||||
|
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
|
||||||
|
("E-Mail:", self.v_mail))):
|
||||||
|
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
|
||||||
|
tk.Entry(g, textvariable=var, width=36).grid(row=r, column=1, columnspan=2, sticky="we", pady=2)
|
||||||
|
tk.Label(g, text="Standard-Auszahlung:", anchor="w").grid(row=3, column=0, sticky="w", pady=2)
|
||||||
|
pf = tk.Frame(g, bg=w.FACE)
|
||||||
|
pf.grid(row=3, column=1, sticky="w")
|
||||||
|
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left")
|
||||||
|
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
|
||||||
|
tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e")
|
||||||
|
tk.Label(g, text="Notiz:", anchor="nw").grid(row=4, column=0, sticky="nw", pady=2)
|
||||||
|
self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT)
|
||||||
|
self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2)
|
||||||
|
g.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
|
||||||
|
g2.pack(fill="both", expand=True, pady=(8, 0))
|
||||||
|
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=4,
|
||||||
|
selectmode="browse")
|
||||||
|
self.kliste.heading("anbieter", text="Anbieter")
|
||||||
|
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
||||||
|
self.kliste.column("anbieter", width=90)
|
||||||
|
self.kliste.column("wert", width=200)
|
||||||
|
r2.pack(fill="both", expand=True)
|
||||||
|
kf = tk.Frame(g2, bg=w.FACE)
|
||||||
|
kf.pack(fill="x", pady=(4, 0))
|
||||||
|
self.v_kanb = tk.StringVar(value="freenet")
|
||||||
|
cb = ttk.Combobox(kf, textvariable=self.v_kanb, values=list(ANBIETER.values()), width=10, state="readonly")
|
||||||
|
cb.pack(side="left")
|
||||||
|
self.v_kwert = tk.StringVar()
|
||||||
|
ke = tk.Entry(kf, textvariable=self.v_kwert, width=18)
|
||||||
|
ke.pack(side="left", padx=4)
|
||||||
|
ke.bind("<Return>", lambda e: self._kennung_dazu())
|
||||||
|
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
|
||||||
|
w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4)
|
||||||
|
|
||||||
|
g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart")
|
||||||
|
g3.pack(fill="both", expand=True, pady=(8, 0))
|
||||||
|
r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=4, selectmode="browse")
|
||||||
|
self.rliste.heading("art", text="Provisionsart")
|
||||||
|
self.rliste.heading("proz", text="Auszahlung")
|
||||||
|
self.rliste.column("art", width=240)
|
||||||
|
self.rliste.column("proz", width=70, anchor="e")
|
||||||
|
r3.pack(fill="both", expand=True)
|
||||||
|
rf = tk.Frame(g3, bg=w.FACE)
|
||||||
|
rf.pack(fill="x", pady=(4, 0))
|
||||||
|
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent…“.", fg=w.SHADOW).pack(side="left")
|
||||||
|
w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
|
||||||
|
|
||||||
|
unten = tk.Frame(self, bg=w.FACE)
|
||||||
|
unten.pack(fill="x", padx=8, pady=(0, 8))
|
||||||
|
w.knopf(unten, "Schließen", self.abbrechen, breite=10).pack(side="right")
|
||||||
|
w.knopf(unten, "Speichern", self._speichern, breite=10, default="active").pack(side="right", padx=6)
|
||||||
|
|
||||||
|
self._kennungen: list[tuple[str, str]] = []
|
||||||
|
self._liste_laden(auswahl_id)
|
||||||
|
if neue_kennung:
|
||||||
|
self._neu()
|
||||||
|
self._kennungen = [neue_kennung]
|
||||||
|
self._kennungen_zeigen()
|
||||||
|
self.minsize(640, 460)
|
||||||
|
|
||||||
|
# ---- intern
|
||||||
|
@staticmethod
|
||||||
|
def _anbieter_key(anzeige: str) -> str:
|
||||||
|
return next((k for k, v in ANBIETER.items() if v == anzeige), anzeige)
|
||||||
|
|
||||||
|
def _liste_laden(self, auswahl_id=None):
|
||||||
|
self.liste.delete(*self.liste.get_children())
|
||||||
|
for h in self.db.haendler_liste():
|
||||||
|
self.liste.insert("", "end", iid=str(h.id), values=(h.name + ("" if h.aktiv else " (inaktiv)"),))
|
||||||
|
if auswahl_id and self.liste.exists(str(auswahl_id)):
|
||||||
|
self.liste.selection_set(str(auswahl_id))
|
||||||
|
self.liste.see(str(auswahl_id))
|
||||||
|
elif self.liste.get_children():
|
||||||
|
self.liste.selection_set(self.liste.get_children()[0])
|
||||||
|
else:
|
||||||
|
self._neu()
|
||||||
|
|
||||||
|
def _auswahl(self, _e=None):
|
||||||
|
sel = self.liste.selection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
h = self.db.haendler(int(sel[0]))
|
||||||
|
if not h:
|
||||||
|
return
|
||||||
|
self.aktuell = h
|
||||||
|
self.v_name.set(h.name)
|
||||||
|
self.v_ap.set(h.ansprechpartner)
|
||||||
|
self.v_mail.set(h.email)
|
||||||
|
self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", ""))
|
||||||
|
self.v_aktiv.set(h.aktiv)
|
||||||
|
self.t_notiz.delete("1.0", "end")
|
||||||
|
self.t_notiz.insert("1.0", h.notiz or "")
|
||||||
|
self._kennungen = self.db.kennungen(h.id)
|
||||||
|
self._kennungen_zeigen()
|
||||||
|
self._regeln_zeigen()
|
||||||
|
|
||||||
|
def _kennungen_zeigen(self):
|
||||||
|
self.kliste.delete(*self.kliste.get_children())
|
||||||
|
for i, (a, v) in enumerate(self._kennungen):
|
||||||
|
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
|
||||||
|
|
||||||
|
def _regeln_zeigen(self):
|
||||||
|
self.rliste.delete(*self.rliste.get_children())
|
||||||
|
if self.aktuell and self.aktuell.id:
|
||||||
|
for art, p in sorted(self.db.regeln(self.aktuell.id).items()):
|
||||||
|
self.rliste.insert("", "end", iid=art, values=(art, fmt_percent(p)))
|
||||||
|
|
||||||
|
def _neu(self):
|
||||||
|
self.liste.selection_remove(self.liste.selection())
|
||||||
|
self.aktuell = Haendler(0, "")
|
||||||
|
for v in (self.v_name, self.v_ap, self.v_mail):
|
||||||
|
v.set("")
|
||||||
|
self.v_proz.set("100")
|
||||||
|
self.v_aktiv.set(True)
|
||||||
|
self.t_notiz.delete("1.0", "end")
|
||||||
|
self._kennungen = []
|
||||||
|
self._kennungen_zeigen()
|
||||||
|
self._regeln_zeigen()
|
||||||
|
|
||||||
|
def _kennung_dazu(self):
|
||||||
|
wert = self.v_kwert.get().strip()
|
||||||
|
if not wert:
|
||||||
|
return
|
||||||
|
anb = self._anbieter_key(self.v_kanb.get())
|
||||||
|
if (anb, wert) not in self._kennungen:
|
||||||
|
self._kennungen.append((anb, wert))
|
||||||
|
self.v_kwert.set("")
|
||||||
|
self._kennungen_zeigen()
|
||||||
|
|
||||||
|
def _kennung_weg(self):
|
||||||
|
sel = self.kliste.selection()
|
||||||
|
if sel:
|
||||||
|
del self._kennungen[int(sel[0])]
|
||||||
|
self._kennungen_zeigen()
|
||||||
|
|
||||||
|
def _regel_weg(self):
|
||||||
|
sel = self.rliste.selection()
|
||||||
|
if sel and self.aktuell and self.aktuell.id:
|
||||||
|
self.db.regel_setzen(self.aktuell.id, sel[0], None)
|
||||||
|
self.geaendert = True
|
||||||
|
self._regeln_zeigen()
|
||||||
|
|
||||||
|
def _speichern(self):
|
||||||
|
name = self.v_name.get().strip()
|
||||||
|
if not name:
|
||||||
|
w.warnung(self, "Händler", "Bitte einen Namen eingeben.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proz = parse_percent(self.v_proz.get())
|
||||||
|
except ValueError:
|
||||||
|
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
|
||||||
|
return
|
||||||
|
if self.v_kwert.get().strip():
|
||||||
|
self._kennung_dazu()
|
||||||
|
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
|
||||||
|
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get())
|
||||||
|
try:
|
||||||
|
hid = self.db.haendler_speichern(h, self._kennungen)
|
||||||
|
except Exception as exc: # sqlite3.IntegrityError / ValueError
|
||||||
|
msg = str(exc)
|
||||||
|
if "UNIQUE" in msg and "haendler.name" in msg:
|
||||||
|
msg = f"Es gibt bereits einen Händler „{name}“."
|
||||||
|
w.fehler(self, "Händler speichern", msg)
|
||||||
|
return
|
||||||
|
self.geaendert = True
|
||||||
|
self._liste_laden(hid)
|
||||||
|
|
||||||
|
def _loeschen(self):
|
||||||
|
if not self.aktuell or not self.aktuell.id:
|
||||||
|
return
|
||||||
|
if w.frage(self, "Händler löschen",
|
||||||
|
f"Händler „{self.aktuell.name}“ mit allen Kennungen und Regeln löschen?\n\n"
|
||||||
|
"Positionen in Abrechnungen bleiben erhalten, gelten dann aber als „nicht zugeordnet“.",
|
||||||
|
default_ja=False):
|
||||||
|
self.db.haendler_loeschen(self.aktuell.id)
|
||||||
|
self.geaendert = True
|
||||||
|
self.aktuell = None
|
||||||
|
self._liste_laden()
|
||||||
|
|
||||||
|
def abbrechen(self):
|
||||||
|
self.result = self.geaendert
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Prozent anwenden
|
||||||
|
|
||||||
|
class ProzentDialog(w.Dialog):
|
||||||
|
def __init__(self, master, haendler_name: str, provisionsart: str | None, anzahl: int, summe_orig: int,
|
||||||
|
vorschlag: float, regel_moeglich: bool):
|
||||||
|
super().__init__(master, "Auszahlung festlegen")
|
||||||
|
self.summe_orig = summe_orig
|
||||||
|
f = tk.Frame(self, bg=w.FACE)
|
||||||
|
f.pack(fill="both", padx=12, pady=12)
|
||||||
|
ic = w.make_icon(self, "prozent", 2)
|
||||||
|
self._ic = ic
|
||||||
|
tk.Label(f, image=ic).grid(row=0, column=0, rowspan=4, sticky="n", padx=(0, 12))
|
||||||
|
was = f"Provisionsart „{provisionsart}“" if provisionsart else f"{anzahl} ausgewählte Position(en)"
|
||||||
|
tk.Label(f, text=f"Händler: {haendler_name}\n{was}\nProvision laut Abrechnung: {fmt(summe_orig)}",
|
||||||
|
justify="left", anchor="w").grid(row=0, column=1, columnspan=3, sticky="w")
|
||||||
|
tk.Label(f, text="Auszahlung:").grid(row=1, column=1, sticky="w", pady=(10, 2))
|
||||||
|
self.v = tk.StringVar(value=fmt_percent(vorschlag).replace(" %", ""))
|
||||||
|
ef = tk.Frame(f, bg=w.FACE)
|
||||||
|
ef.grid(row=1, column=2, columnspan=2, sticky="w", pady=(10, 2))
|
||||||
|
e = tk.Entry(ef, textvariable=self.v, width=8, justify="right")
|
||||||
|
e.pack(side="left")
|
||||||
|
tk.Label(ef, text="% der Provision").pack(side="left", padx=4)
|
||||||
|
self.vorschau = tk.Label(f, text="", fg=w.NAVY, anchor="w")
|
||||||
|
self.vorschau.grid(row=2, column=1, columnspan=3, sticky="w")
|
||||||
|
self.v_regel = tk.BooleanVar(value=bool(provisionsart and regel_moeglich))
|
||||||
|
self.v_manuell = tk.BooleanVar(value=False)
|
||||||
|
cbf = tk.Frame(f, bg=w.FACE)
|
||||||
|
cbf.grid(row=3, column=1, columnspan=3, sticky="w", pady=(8, 0))
|
||||||
|
if provisionsart and regel_moeglich:
|
||||||
|
tk.Checkbutton(cbf, text="Als Standard für diesen Händler und diese Provisionsart merken",
|
||||||
|
variable=self.v_regel).pack(anchor="w")
|
||||||
|
tk.Checkbutton(cbf, text="Auch von Hand geänderte Beträge überschreiben",
|
||||||
|
variable=self.v_manuell).pack(anchor="w")
|
||||||
|
self.v.trace_add("write", lambda *_: self._vorschau())
|
||||||
|
self._vorschau()
|
||||||
|
self.bind("<Return>", lambda _e: self._ok())
|
||||||
|
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 12))
|
||||||
|
e.select_range(0, "end")
|
||||||
|
self._e = e
|
||||||
|
|
||||||
|
def _vorschau(self):
|
||||||
|
from provtool.money import apply_percent
|
||||||
|
try:
|
||||||
|
p = parse_percent(self.v.get())
|
||||||
|
self.vorschau.configure(text=f"→ Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
|
||||||
|
except ValueError:
|
||||||
|
self.vorschau.configure(text="(ungültiger Wert)")
|
||||||
|
|
||||||
|
def _ok(self):
|
||||||
|
try:
|
||||||
|
p = parse_percent(self.v.get())
|
||||||
|
except ValueError:
|
||||||
|
w.warnung(self, "Auszahlung", "Bitte einen Prozentwert eingeben, z. B. 100, 80 oder 72,5.")
|
||||||
|
return
|
||||||
|
self.result = (p, self.v_regel.get(), self.v_manuell.get())
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def zeigen(self, fokus=None):
|
||||||
|
return super().zeigen(self._e)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Abrechnungen
|
||||||
|
|
||||||
|
class AbrechnungenDialog(w.Dialog):
|
||||||
|
def __init__(self, master, db: Datenbank, aktuelle_id: int | None):
|
||||||
|
super().__init__(master, "Abrechnung öffnen", resizable=True)
|
||||||
|
self.db = db
|
||||||
|
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||||
|
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings",
|
||||||
|
height=12, selectmode="browse")
|
||||||
|
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120),
|
||||||
|
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
|
||||||
|
self.tree.heading(c, text=t)
|
||||||
|
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
|
||||||
|
r.pack(fill="both", expand=True, padx=10)
|
||||||
|
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
|
||||||
|
k = tk.Frame(self, bg=w.FACE)
|
||||||
|
k.pack(fill="x", padx=10, pady=10)
|
||||||
|
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
|
||||||
|
w.knopf(k, "Umbenennen…", self._umbenennen, breite=12).pack(side="left", padx=6)
|
||||||
|
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
|
||||||
|
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
|
||||||
|
self.bind("<Return>", lambda e: self._oeffnen())
|
||||||
|
self._laden(aktuelle_id)
|
||||||
|
|
||||||
|
def _laden(self, sel=None):
|
||||||
|
self.tree.delete(*self.tree.get_children())
|
||||||
|
for a in self.db.abrechnungen():
|
||||||
|
self.tree.insert("", "end", iid=str(a["id"]),
|
||||||
|
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
|
||||||
|
kids = self.tree.get_children()
|
||||||
|
if sel and self.tree.exists(str(sel)):
|
||||||
|
self.tree.selection_set(str(sel))
|
||||||
|
elif kids:
|
||||||
|
self.tree.selection_set(kids[0])
|
||||||
|
|
||||||
|
def _sel(self) -> int | None:
|
||||||
|
s = self.tree.selection()
|
||||||
|
return int(s[0]) if s else None
|
||||||
|
|
||||||
|
def _oeffnen(self):
|
||||||
|
if self._sel():
|
||||||
|
self.result = ("oeffnen", self._sel())
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def _umbenennen(self):
|
||||||
|
aid = self._sel()
|
||||||
|
if not aid:
|
||||||
|
return
|
||||||
|
t = w.eingabe(self, "Umbenennen", "Neue Bezeichnung:", self.db.abrechnung(aid)["titel"])
|
||||||
|
if t and t.strip():
|
||||||
|
self.db.abrechnung_umbenennen(aid, t.strip())
|
||||||
|
self._laden(aid)
|
||||||
|
self.result = ("geaendert", aid)
|
||||||
|
|
||||||
|
def _loeschen(self):
|
||||||
|
aid = self._sel()
|
||||||
|
if not aid:
|
||||||
|
return
|
||||||
|
titel = self.db.abrechnung(aid)["titel"]
|
||||||
|
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
|
||||||
|
default_ja=False):
|
||||||
|
self.db.abrechnung_loeschen(aid)
|
||||||
|
self._laden()
|
||||||
|
self.result = ("geloescht", aid)
|
||||||
|
|
||||||
|
def abbrechen(self):
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class DateienDialog(w.Dialog):
|
||||||
|
"""Zeigt die importierten Dateien einer Abrechnung; einzelne Dateien können wieder entfernt werden."""
|
||||||
|
|
||||||
|
def __init__(self, master, db: Datenbank, aid: int):
|
||||||
|
super().__init__(master, "Importierte Dateien", resizable=True)
|
||||||
|
self.db, self.aid, self.geaendert = db, aid, False
|
||||||
|
r, self.tree = w.scroll_tree(self, columns=("datei", "format", "anzahl", "zeit"), show="headings", height=8,
|
||||||
|
selectmode="browse")
|
||||||
|
for c, t, b in (("datei", "Datei", 260), ("format", "Format", 80), ("anzahl", "Positionen", 70),
|
||||||
|
("zeit", "Importiert", 120)):
|
||||||
|
self.tree.heading(c, text=t)
|
||||||
|
self.tree.column(c, width=b)
|
||||||
|
r.pack(fill="both", expand=True, padx=10, pady=(10, 0))
|
||||||
|
k = tk.Frame(self, bg=w.FACE)
|
||||||
|
k.pack(fill="x", padx=10, pady=10)
|
||||||
|
w.knopf(k, "Datei entfernen", self._weg).pack(side="left")
|
||||||
|
w.knopf(k, "Schließen", self.abbrechen, breite=10).pack(side="right")
|
||||||
|
self._laden()
|
||||||
|
|
||||||
|
def _laden(self):
|
||||||
|
self.tree.delete(*self.tree.get_children())
|
||||||
|
for d in self.db.dateien(self.aid):
|
||||||
|
self.tree.insert("", "end", iid=str(d["id"]),
|
||||||
|
values=(d["dateiname"], d["format"], d["anzahl"], d["importiert"][:16]))
|
||||||
|
|
||||||
|
def _weg(self):
|
||||||
|
s = self.tree.selection()
|
||||||
|
if not s:
|
||||||
|
return
|
||||||
|
name = self.tree.item(s[0], "values")[0]
|
||||||
|
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
|
||||||
|
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
|
||||||
|
self.db.datei_entfernen(int(s[0]))
|
||||||
|
self.geaendert = True
|
||||||
|
self._laden()
|
||||||
|
|
||||||
|
def abbrechen(self):
|
||||||
|
self.result = self.geaendert
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Import-Bericht
|
||||||
|
|
||||||
|
class ImportBericht(w.Dialog):
|
||||||
|
def __init__(self, master, zeilen: list[tuple[str, str, int, int, str]], unbekannt: list[tuple[str, str, int, int]]):
|
||||||
|
"""zeilen: (datei, format, anzahl, summe_cent, hinweis); unbekannt: (anbieter, kennung, anzahl, summe)."""
|
||||||
|
super().__init__(master, "Import abgeschlossen", resizable=True)
|
||||||
|
tk.Label(self, text="Folgende Dateien wurden eingelesen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||||
|
r, t = w.scroll_tree(self, columns=("datei", "format", "anzahl", "summe", "hinweis"), show="headings",
|
||||||
|
height=min(6, max(2, len(zeilen))))
|
||||||
|
for c, h, b, a in (("datei", "Datei", 220, "w"), ("format", "Format", 70, "w"), ("anzahl", "Pos.", 50, "e"),
|
||||||
|
("summe", "Summe", 90, "e"), ("hinweis", "Hinweis", 260, "w")):
|
||||||
|
t.heading(c, text=h)
|
||||||
|
t.column(c, width=b, anchor=a)
|
||||||
|
for d, f, n, s, hw in zeilen:
|
||||||
|
t.insert("", "end", values=(d, f, n, fmt(s) if n else "", hw))
|
||||||
|
r.pack(fill="both", expand=True, padx=10)
|
||||||
|
self.zuordnen = None
|
||||||
|
if unbekannt:
|
||||||
|
tk.Label(self, text="Nicht zugeordnete Kennungen (kein Händler hinterlegt – z. B. eigene Filiale):",
|
||||||
|
anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||||
|
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings",
|
||||||
|
height=min(6, len(unbekannt)), selectmode="browse")
|
||||||
|
for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
|
||||||
|
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
|
||||||
|
self.ut.heading(c, text=h)
|
||||||
|
self.ut.column(c, width=b, anchor=a)
|
||||||
|
for a, k, n, s in unbekannt:
|
||||||
|
self.ut.insert("", "end", iid=f"{a}\t{k}", values=(ANBIETER.get(a, a), k or "(leer)", n, fmt(s)))
|
||||||
|
r2.pack(fill="both", expand=True, padx=10)
|
||||||
|
k = tk.Frame(self, bg=w.FACE)
|
||||||
|
k.pack(fill="x", padx=10, pady=10)
|
||||||
|
if unbekannt:
|
||||||
|
w.knopf(k, "Kennung einem Händler zuordnen…", self._zuordnen).pack(side="left")
|
||||||
|
w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
|
||||||
|
self.bind("<Return>", lambda e: self.abbrechen())
|
||||||
|
|
||||||
|
def _zuordnen(self):
|
||||||
|
s = self.ut.selection()
|
||||||
|
if s:
|
||||||
|
self.result = tuple(s[0].split("\t", 1))
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class KennungZuordnen(w.Dialog):
|
||||||
|
"""Fragt, ob eine Kennung einem bestehenden oder neuen Händler zugeordnet werden soll."""
|
||||||
|
|
||||||
|
def __init__(self, master, db: Datenbank, anbieter: str, kennung: str):
|
||||||
|
super().__init__(master, "Kennung zuordnen")
|
||||||
|
self.db, self.anbieter, self.kennung = db, anbieter, kennung
|
||||||
|
tk.Label(self, text=f"{ANBIETER.get(anbieter, anbieter)}-Kennung {kennung} zuordnen zu:",
|
||||||
|
anchor="w").pack(fill="x", padx=12, pady=(12, 6))
|
||||||
|
self.v = tk.StringVar(value="__neu__")
|
||||||
|
f = tk.Frame(self, bg=w.FACE)
|
||||||
|
f.pack(fill="x", padx=20)
|
||||||
|
tk.Radiobutton(f, text="Neuem Händler …", variable=self.v, value="__neu__").pack(anchor="w")
|
||||||
|
haendler = db.haendler_liste()
|
||||||
|
if haendler:
|
||||||
|
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w")
|
||||||
|
self.cb = ttk.Combobox(f, values=[h.name for h in haendler], state="readonly", width=34)
|
||||||
|
self.cb.pack(anchor="w", padx=22, pady=(0, 4))
|
||||||
|
self.cb.bind("<<ComboboxSelected>>", lambda e: self.v.set("__alt__"))
|
||||||
|
self._hmap = {h.name: h.id for h in haendler}
|
||||||
|
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
|
||||||
|
|
||||||
|
def _ok(self):
|
||||||
|
if self.v.get() == "__neu__":
|
||||||
|
self.result = ("neu", None)
|
||||||
|
else:
|
||||||
|
name = self.cb.get()
|
||||||
|
if not name:
|
||||||
|
w.warnung(self, "Kennung zuordnen", "Bitte einen Händler auswählen.")
|
||||||
|
return
|
||||||
|
hid = self._hmap[name]
|
||||||
|
kenn = self.db.kennungen(hid) + [(self.anbieter, self.kennung)]
|
||||||
|
try:
|
||||||
|
self.db.haendler_speichern(self.db.haendler(hid), kenn)
|
||||||
|
except ValueError as exc:
|
||||||
|
w.fehler(self, "Kennung zuordnen", str(exc))
|
||||||
|
return
|
||||||
|
self.result = ("alt", hid)
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Export
|
||||||
|
|
||||||
|
class ExportDialog(w.Dialog):
|
||||||
|
def __init__(self, master, haendler: list[tuple[int, str, int, int]], vorauswahl: set[int], settings: dict,
|
||||||
|
titel: str):
|
||||||
|
"""haendler: (id, name, anzahl, summe_cent)"""
|
||||||
|
super().__init__(master, "Abrechnungen exportieren")
|
||||||
|
self.settings = settings
|
||||||
|
f = tk.Frame(self, bg=w.FACE)
|
||||||
|
f.pack(fill="both", expand=True, padx=10, pady=10)
|
||||||
|
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,
|
||||||
|
selectmode="extended")
|
||||||
|
self.tree.heading("#0", text="")
|
||||||
|
self.tree.column("#0", width=30, stretch=False)
|
||||||
|
for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")):
|
||||||
|
self.tree.heading(c, text=t)
|
||||||
|
self.tree.column(c, width=b, anchor=a)
|
||||||
|
self.haken = {}
|
||||||
|
for hid, name, n, s in haendler:
|
||||||
|
self.haken[hid] = hid in vorauswahl
|
||||||
|
self.tree.insert("", "end", iid=str(hid), text="[x]" if self.haken[hid] else "[ ]",
|
||||||
|
values=(name, n, fmt(s)))
|
||||||
|
self.tree.bind("<Button-1>", self._klick)
|
||||||
|
self.tree.bind("<space>", lambda e: [self._toggle(int(i)) for i in self.tree.selection()])
|
||||||
|
r.pack(fill="both", expand=True, pady=(2, 4))
|
||||||
|
kk = tk.Frame(f, bg=w.FACE)
|
||||||
|
kk.pack(fill="x")
|
||||||
|
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
|
||||||
|
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
|
||||||
|
|
||||||
|
g = w.gruppe(f, "Optionen")
|
||||||
|
g.pack(fill="x", pady=(8, 0))
|
||||||
|
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
|
||||||
|
self.v_titel = tk.StringVar(value=titel)
|
||||||
|
tk.Entry(g, textvariable=self.v_titel, width=30).grid(row=0, column=1, columnspan=2, sticky="we", pady=2)
|
||||||
|
tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
|
||||||
|
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
|
||||||
|
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
|
||||||
|
w.knopf(g, "Durchsuchen…", self._ordner).grid(row=1, column=2, padx=(4, 0))
|
||||||
|
self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True))
|
||||||
|
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
|
||||||
|
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
|
||||||
|
self.v_auf = tk.BooleanVar(value=True)
|
||||||
|
tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei",
|
||||||
|
variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w")
|
||||||
|
tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3,
|
||||||
|
sticky="w")
|
||||||
|
tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0,
|
||||||
|
columnspan=3, sticky="w")
|
||||||
|
tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0,
|
||||||
|
columnspan=3, sticky="w")
|
||||||
|
g.columnconfigure(1, weight=1)
|
||||||
|
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||||
|
|
||||||
|
def _klick(self, e):
|
||||||
|
if self.tree.identify_column(e.x) == "#0":
|
||||||
|
iid = self.tree.identify_row(e.y)
|
||||||
|
if iid:
|
||||||
|
self._toggle(int(iid))
|
||||||
|
|
||||||
|
def _toggle(self, hid):
|
||||||
|
self.haken[hid] = not self.haken[hid]
|
||||||
|
self.tree.item(str(hid), text="[x]" if self.haken[hid] else "[ ]")
|
||||||
|
|
||||||
|
def _alle(self, an):
|
||||||
|
for hid in self.haken:
|
||||||
|
self.haken[hid] = an
|
||||||
|
self.tree.item(str(hid), text="[x]" if an else "[ ]")
|
||||||
|
|
||||||
|
def _ordner(self):
|
||||||
|
d = filedialog.askdirectory(parent=self, title="Zielordner wählen",
|
||||||
|
initialdir=self.v_ordner.get() or None)
|
||||||
|
if d:
|
||||||
|
self.v_ordner.set(d)
|
||||||
|
|
||||||
|
def _ok(self):
|
||||||
|
ids = [h for h, an in self.haken.items() if an]
|
||||||
|
if not ids:
|
||||||
|
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
|
||||||
|
return
|
||||||
|
if not self.v_ordner.get().strip():
|
||||||
|
self._ordner()
|
||||||
|
if not self.v_ordner.get().strip():
|
||||||
|
return
|
||||||
|
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(),
|
||||||
|
export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get())
|
||||||
|
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
|
||||||
|
pro_anbieter=self.v_anb.get(), kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(),
|
||||||
|
oeffnen=self.v_auf.get())
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Einstellungen
|
||||||
|
|
||||||
|
class EinstellungenDialog(w.Dialog):
|
||||||
|
def __init__(self, master, settings: dict, db_pfad: str):
|
||||||
|
super().__init__(master, "Einstellungen")
|
||||||
|
self.settings = settings
|
||||||
|
g = w.gruppe(self, "Datenbank")
|
||||||
|
g.pack(fill="x", padx=10, pady=10)
|
||||||
|
tk.Label(g, text="Speicherort der Datenbank (Händler, Regeln, Abrechnungen):", anchor="w").grid(
|
||||||
|
row=0, column=0, columnspan=2, sticky="w")
|
||||||
|
self.v = tk.StringVar(value=db_pfad)
|
||||||
|
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4)
|
||||||
|
w.knopf(g, "Ändern…", self._waehlen).grid(row=1, column=1, padx=(4, 0))
|
||||||
|
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
|
||||||
|
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
|
||||||
|
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
|
||||||
|
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||||
|
|
||||||
|
def _waehlen(self):
|
||||||
|
p = filedialog.asksaveasfilename(parent=self, title="Datenbank wählen oder neu anlegen",
|
||||||
|
defaultextension=".db", confirmoverwrite=False,
|
||||||
|
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
|
||||||
|
if p:
|
||||||
|
self.v.set(p)
|
||||||
|
|
||||||
|
def _ok(self):
|
||||||
|
self.result = self.v.get().strip()
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================== Info
|
||||||
|
|
||||||
|
class InfoDialog(w.Dialog):
|
||||||
|
def __init__(self, master):
|
||||||
|
super().__init__(master, f"Info über {APP_NAME}")
|
||||||
|
top = tk.Frame(self, bg=w.FACE)
|
||||||
|
top.pack(padx=14, pady=14)
|
||||||
|
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
|
||||||
|
c.pack(side="left", anchor="n")
|
||||||
|
self._img = w.make_icon(self, "app", 6)
|
||||||
|
c.create_image(57, 57, image=self._img)
|
||||||
|
rechts = tk.Frame(top, bg=w.FACE)
|
||||||
|
rechts.pack(side="left", padx=(14, 0), anchor="n")
|
||||||
|
tk.Label(rechts, text=APP_NAME, font=w.FONT_BIG).pack(anchor="w")
|
||||||
|
tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w")
|
||||||
|
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
|
||||||
|
tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
|
||||||
|
"freenet · Vodafone", justify="left").pack(anchor="w")
|
||||||
|
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
|
||||||
|
tk.Label(rechts, text="Arbeitsspeicher für Provisionen: reichlich", fg=w.SHADOW).pack(anchor="w")
|
||||||
|
self.knopfleiste([("OK", self.abbrechen)]).pack(pady=(0, 12))
|
||||||
|
self.bind("<Return>", lambda e: self.abbrechen())
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
|
|
||||||
|
FACE = "#c0c0c0"
|
||||||
|
HILITE = "#ffffff"
|
||||||
|
LIGHT = "#dfdfdf"
|
||||||
|
SHADOW = "#808080"
|
||||||
|
DARK = "#000000"
|
||||||
|
NAVY = "#000080"
|
||||||
|
TEAL = "#008080"
|
||||||
|
WHITE = "#ffffff"
|
||||||
|
INFO_BG = "#ffffe1"
|
||||||
|
|
||||||
|
IS_WIN = sys.platform.startswith("win")
|
||||||
|
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
|
||||||
|
FONT_SIZE = 8 if IS_WIN else 9
|
||||||
|
FONT = (FONT_FAMILY, FONT_SIZE)
|
||||||
|
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
|
||||||
|
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
|
||||||
|
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
|
||||||
|
PALETTE = {
|
||||||
|
"k": "#000000", "w": "#ffffff", "g": "#c0c0c0", "d": "#808080", "y": "#ffff00", "Y": "#808000",
|
||||||
|
"b": "#000080", "B": "#0000ff", "r": "#ff0000", "R": "#800000", "G": "#008000", "L": "#00ff00",
|
||||||
|
"t": "#008080", "c": "#00ffff", "s": "#ffcc99", "o": "#ff8000",
|
||||||
|
}
|
||||||
|
|
||||||
|
ICONS = {
|
||||||
|
"neu": """
|
||||||
|
..kkkkkkkkk.....
|
||||||
|
..kwwwwwwwkk....
|
||||||
|
..kwwwwwwwkwk...
|
||||||
|
..kwwwwwwwkkkk..
|
||||||
|
..kwwwwwwwwwwk..
|
||||||
|
..kwkkkkkkkwwk..
|
||||||
|
..kwwwwwwwwwwk..
|
||||||
|
..kwkkkkkkkkwk..
|
||||||
|
..kwwwwwwwwwwk..
|
||||||
|
..kwkkkkkkkkwk..
|
||||||
|
..kwwwwwwwwwwk..
|
||||||
|
..kwkkkkkwwwwk..
|
||||||
|
..kwwwwwwwwwwk..
|
||||||
|
..kwwwwwwwwwwk..
|
||||||
|
..kkkkkkkkkkkk..
|
||||||
|
................""",
|
||||||
|
"oeffnen": """
|
||||||
|
................
|
||||||
|
.........kkk....
|
||||||
|
........k...k.k.
|
||||||
|
.............kk.
|
||||||
|
.kkk........kkk.
|
||||||
|
kyYyk...........
|
||||||
|
kYyYykkkkkkk....
|
||||||
|
kyYyYyYyYyYk....
|
||||||
|
kYyYykkkkkkkkkk.
|
||||||
|
kyYykyyyyyyyyyk.
|
||||||
|
kYykyyyyyyyyyk..
|
||||||
|
kykyyyyyyyyyk...
|
||||||
|
kkyyyyyyyyyk....
|
||||||
|
kkkkkkkkkkk.....
|
||||||
|
................
|
||||||
|
................""",
|
||||||
|
"import": """
|
||||||
|
......kkkk......
|
||||||
|
......kGGk......
|
||||||
|
......kGGk......
|
||||||
|
......kGGk......
|
||||||
|
...kkkkGGkkkk...
|
||||||
|
....kGGGGGGk....
|
||||||
|
.....kGGGGk.....
|
||||||
|
......kGGk......
|
||||||
|
.......kk.......
|
||||||
|
.kkkkkkkkkkkkkk.
|
||||||
|
.kwwwwwwwwwwwdk.
|
||||||
|
.kwkkwkkwkkwwdk.
|
||||||
|
.kwwwwwwwwwwwdk.
|
||||||
|
.kddddddddddddk.
|
||||||
|
.kkkkkkkkkkkkkk.
|
||||||
|
................""",
|
||||||
|
"export": """
|
||||||
|
kkkkkkkkkkkkkkk.
|
||||||
|
kbbkwwwwwwwkbbk.
|
||||||
|
kbbkwwwwwwwkbbk.
|
||||||
|
kbbkwwwwwwwkbbk.
|
||||||
|
kbbkwwwwwwwkbbk.
|
||||||
|
kbbkkkkkkkkkbbk.
|
||||||
|
kbbbbbbbbbbbbbk.
|
||||||
|
kbbbbbbbbbbbbbk.
|
||||||
|
kbbkkkkkkkkkkbk.
|
||||||
|
kbbkgggkkgggkbk.
|
||||||
|
kbbkgggkkgggkbk.
|
||||||
|
kbbkgggkkgggkbk.
|
||||||
|
kbbkgggggggggbk.
|
||||||
|
.kbkkkkkkkkkkbk.
|
||||||
|
..kkkkkkkkkkkkk.
|
||||||
|
................""",
|
||||||
|
"haendler": """
|
||||||
|
................
|
||||||
|
....kkk.........
|
||||||
|
...kssskk.kkk...
|
||||||
|
...ksksk.kssk...
|
||||||
|
...kssskksksk...
|
||||||
|
....kskkkssk....
|
||||||
|
...kbbbkkksk....
|
||||||
|
..kbbbbbkRRRk...
|
||||||
|
..kbbbbbkRRRRk..
|
||||||
|
..kbkbkbRRRRRk..
|
||||||
|
..kbkbkbkRkRRk..
|
||||||
|
..kbkbkbkRkRRk..
|
||||||
|
..kbbbbbkRRRRk..
|
||||||
|
...kbkbkkRkRk...
|
||||||
|
...kkkkk.kkkk...
|
||||||
|
................""",
|
||||||
|
"prozent": """
|
||||||
|
................
|
||||||
|
..kkk......kk...
|
||||||
|
.kwwwk....kk....
|
||||||
|
.kwkwk...kk.....
|
||||||
|
.kwwwk..kk......
|
||||||
|
..kkk..kk.......
|
||||||
|
......kk........
|
||||||
|
.....kk.........
|
||||||
|
....kk..........
|
||||||
|
...kk...kkk.....
|
||||||
|
..kk...kwwwk....
|
||||||
|
.kk....kwkwk....
|
||||||
|
kk.....kwwwk....
|
||||||
|
........kkk.....
|
||||||
|
................
|
||||||
|
................""",
|
||||||
|
"rechner": """
|
||||||
|
.kkkkkkkkkkkkk..
|
||||||
|
.kddddddddddddk.
|
||||||
|
.kdkkkkkkkkkkdk.
|
||||||
|
.kdkLLLLLLLLkdk.
|
||||||
|
.kdkkkkkkkkkkdk.
|
||||||
|
.kddddddddddddk.
|
||||||
|
.kdwwdwwdwwdrrk.
|
||||||
|
.kdkkdkkdkkdkkk.
|
||||||
|
.kddddddddddddk.
|
||||||
|
.kdwwdwwdwwdwwk.
|
||||||
|
.kdkkdkkdkkdkkk.
|
||||||
|
.kddddddddddddk.
|
||||||
|
.kdwwdwwdwwdwwk.
|
||||||
|
.kdkkdkkdkkdkkk.
|
||||||
|
.kkkkkkkkkkkkkk.
|
||||||
|
................""",
|
||||||
|
"info": """
|
||||||
|
.....kkkkkk.....
|
||||||
|
...kkBBBBBBkk...
|
||||||
|
..kBBBBBBBBBBk..
|
||||||
|
.kBBBBBwwBBBBBk.
|
||||||
|
.kBBBBBwwBBBBBk.
|
||||||
|
kBBBBBBBBBBBBBBk
|
||||||
|
kBBBBBwwwBBBBBBk
|
||||||
|
kBBBBBBwwBBBBBBk
|
||||||
|
kBBBBBBwwBBBBBBk
|
||||||
|
kBBBBBBwwBBBBBBk
|
||||||
|
.kBBBBBwwBBBBBk.
|
||||||
|
.kBBBBwwwwBBBBk.
|
||||||
|
..kBBBBBBBBBBk..
|
||||||
|
...kkBBBBBBkk...
|
||||||
|
.....kkkkkk.....
|
||||||
|
................""",
|
||||||
|
"warnung": """
|
||||||
|
.......kk.......
|
||||||
|
......kyyk......
|
||||||
|
......kyyk......
|
||||||
|
.....kyyyyk.....
|
||||||
|
.....kykkyk.....
|
||||||
|
....kyykkyyk....
|
||||||
|
....kyykkyyk....
|
||||||
|
...kyyykkyyyk...
|
||||||
|
...kyyykkyyyk...
|
||||||
|
..kyyyyyyyyyyk..
|
||||||
|
..kyyyykkyyyyk..
|
||||||
|
.kyyyyykkyyyyyk.
|
||||||
|
.kyyyyyyyyyyyyk.
|
||||||
|
kkkkkkkkkkkkkkkk
|
||||||
|
................
|
||||||
|
................""",
|
||||||
|
"fehler": """
|
||||||
|
.....kkkkkk.....
|
||||||
|
...kkrrrrrrkk...
|
||||||
|
..krrrrrrrrrrk..
|
||||||
|
.krrwrrrrrrwrrk.
|
||||||
|
.krwwwrrrrwwwrk.
|
||||||
|
krrrwwwrrwwwrrrk
|
||||||
|
krrrrwwwwwwrrrrk
|
||||||
|
krrrrrwwwwrrrrrk
|
||||||
|
krrrrrwwwwrrrrrk
|
||||||
|
krrrrwwwwwwrrrrk
|
||||||
|
.krrwwwrrwwwrrk.
|
||||||
|
.krwwwrrrrwwwrk.
|
||||||
|
..krrrrrrrrrrk..
|
||||||
|
...kkrrrrrrkk...
|
||||||
|
.....kkkkkk.....
|
||||||
|
................""",
|
||||||
|
"frage": """
|
||||||
|
.....kkkkkk.....
|
||||||
|
...kkwwwwwwkk...
|
||||||
|
..kwwwwwwwwwwk..
|
||||||
|
.kwwwwBBBBwwwwk.
|
||||||
|
.kwwwBBwwBBwwwk.
|
||||||
|
kwwwwwwwwBBwwwwk
|
||||||
|
kwwwwwwwBBwwwwwk
|
||||||
|
kwwwwwwBBwwwwwwk
|
||||||
|
kwwwwwwBBwwwwwwk
|
||||||
|
kwwwwwwwwwwwwwwk
|
||||||
|
.kwwwwwBBwwwwwk.
|
||||||
|
.kwwwwwBBwwwwwk.
|
||||||
|
..kwwwwwwwwwwk..
|
||||||
|
...kkwwwwwwkk...
|
||||||
|
.....kkkkkkk....
|
||||||
|
................""",
|
||||||
|
"app": """
|
||||||
|
................
|
||||||
|
.....kkkkkkk....
|
||||||
|
....kdddddddk...
|
||||||
|
....kdkkkkkdk...
|
||||||
|
....kdkLLLkdk...
|
||||||
|
....kdkLLLkdk...
|
||||||
|
....kdkkkkkdk...
|
||||||
|
....kdwdwdwdk...
|
||||||
|
....kdddddddk...
|
||||||
|
....kdwdwdwdk...
|
||||||
|
....kdddddddk...
|
||||||
|
..ttkdwdwdwdktt.
|
||||||
|
.tcccckkkkkkccct
|
||||||
|
tcccccyyyyyyccct
|
||||||
|
.ttttttttttttttt
|
||||||
|
................""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _icon_rows(name: str) -> list[str]:
|
||||||
|
rows = [r.strip() for r in ICONS[name].strip("\n").splitlines() if r.strip()]
|
||||||
|
return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage:
|
||||||
|
img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale)
|
||||||
|
for y, row in enumerate(_icon_rows(name)):
|
||||||
|
for x, ch in enumerate(row):
|
||||||
|
if ch == ".":
|
||||||
|
continue
|
||||||
|
color = PALETTE[ch]
|
||||||
|
img.put(color, to=(x * scale, y * scale, (x + 1) * scale, (y + 1) * scale))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
class Icons:
|
||||||
|
"""Lazy-Cache, damit PhotoImages nicht vom Garbage Collector entsorgt werden."""
|
||||||
|
|
||||||
|
def __init__(self, master):
|
||||||
|
self.master = master
|
||||||
|
self._cache: dict[tuple[str, int], tk.PhotoImage] = {}
|
||||||
|
|
||||||
|
def __call__(self, name: str, scale: int = 1) -> tk.PhotoImage:
|
||||||
|
key = (name, scale)
|
||||||
|
if key not in self._cache:
|
||||||
|
self._cache[key] = make_icon(self.master, name, scale)
|
||||||
|
return self._cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Theme
|
||||||
|
|
||||||
|
def apply(root: tk.Tk) -> None:
|
||||||
|
root.configure(bg=FACE)
|
||||||
|
o = root.option_add
|
||||||
|
o("*Font", FONT)
|
||||||
|
o("*Background", FACE)
|
||||||
|
o("*Foreground", DARK)
|
||||||
|
o("*activeBackground", FACE)
|
||||||
|
o("*highlightThickness", 0)
|
||||||
|
o("*selectBackground", NAVY)
|
||||||
|
o("*selectForeground", WHITE)
|
||||||
|
o("*Button.relief", "raised")
|
||||||
|
o("*Button.borderWidth", 2)
|
||||||
|
o("*Button.padX", 8)
|
||||||
|
o("*Button.padY", 1)
|
||||||
|
o("*Entry.background", WHITE)
|
||||||
|
o("*Entry.relief", "sunken")
|
||||||
|
o("*Entry.borderWidth", 2)
|
||||||
|
o("*Entry.disabledBackground", FACE)
|
||||||
|
o("*Text.background", WHITE)
|
||||||
|
o("*Text.relief", "sunken")
|
||||||
|
o("*Text.borderWidth", 2)
|
||||||
|
o("*Listbox.background", WHITE)
|
||||||
|
o("*Listbox.relief", "sunken")
|
||||||
|
o("*Listbox.borderWidth", 2)
|
||||||
|
o("*Checkbutton.selectColor", WHITE)
|
||||||
|
o("*Radiobutton.selectColor", WHITE)
|
||||||
|
o("*Menu.relief", "raised")
|
||||||
|
o("*Menu.activeBackground", NAVY)
|
||||||
|
o("*Menu.activeForeground", WHITE)
|
||||||
|
o("*Menu.activeBorderWidth", 0)
|
||||||
|
o("*TCombobox*Listbox.background", WHITE)
|
||||||
|
o("*TCombobox*Listbox.selectBackground", NAVY)
|
||||||
|
|
||||||
|
from tkinter import font as tkfont
|
||||||
|
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
|
||||||
|
st = ttk.Style(root)
|
||||||
|
st.theme_use("classic" if "classic" in st.theme_names() else "default")
|
||||||
|
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT,
|
||||||
|
selectbackground=NAVY, selectforeground=WHITE, fieldbackground=WHITE,
|
||||||
|
bordercolor=FACE, lightcolor=HILITE, darkcolor=SHADOW, highlightthickness=0)
|
||||||
|
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile, borderwidth=0, font=FONT)
|
||||||
|
st.map("Treeview", background=[("selected", NAVY)], foreground=[("selected", WHITE)])
|
||||||
|
st.configure("Treeview.Heading", background=FACE, relief="raised", borderwidth=2, font=FONT, padding=(3, 1))
|
||||||
|
st.map("Treeview.Heading", relief=[("pressed", "sunken")], background=[("active", FACE)])
|
||||||
|
st.configure("TScrollbar", background=FACE, troughcolor=LIGHT, arrowcolor=DARK, relief="raised", borderwidth=2,
|
||||||
|
arrowsize=16, width=16)
|
||||||
|
st.map("TScrollbar", background=[("active", FACE)])
|
||||||
|
st.configure("TCombobox", fieldbackground=WHITE, background=FACE, arrowsize=14, padding=1)
|
||||||
|
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
|
||||||
|
selectforeground=[("readonly", DARK)])
|
||||||
|
st.configure("TPanedwindow", background=FACE)
|
||||||
|
st.configure("Sash", sashthickness=6, background=FACE)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Bausteine
|
||||||
|
|
||||||
|
class Sunken(tk.Frame):
|
||||||
|
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
|
||||||
|
|
||||||
|
def __init__(self, master, **kw):
|
||||||
|
kw.setdefault("bd", 2)
|
||||||
|
kw.setdefault("relief", "sunken")
|
||||||
|
super().__init__(master, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class Fenster(tk.Frame):
|
||||||
|
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
|
||||||
|
|
||||||
|
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
|
||||||
|
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
|
||||||
|
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
|
||||||
|
self.titelleiste.pack(fill="x", padx=1, pady=1)
|
||||||
|
if icon is not None:
|
||||||
|
tk.Label(self.titelleiste, image=icon, bg=NAVY).pack(side="left", padx=(2, 0))
|
||||||
|
self.titel_var = tk.StringVar(value=titel)
|
||||||
|
tk.Label(self.titelleiste, textvariable=self.titel_var, bg=NAVY, fg=WHITE, font=FONT_BOLD,
|
||||||
|
anchor="w").pack(side="left", fill="x", padx=3)
|
||||||
|
self.inhalt = tk.Frame(self, bg=FACE)
|
||||||
|
self.inhalt.pack(fill="both", expand=True, padx=2, pady=2)
|
||||||
|
|
||||||
|
def set_titel(self, t: str):
|
||||||
|
self.titel_var.set(t)
|
||||||
|
|
||||||
|
|
||||||
|
class Statusleiste(tk.Frame):
|
||||||
|
def __init__(self, master, felder: list[tuple[str, int]]):
|
||||||
|
super().__init__(master, bg=FACE)
|
||||||
|
self.vars: dict[str, tk.StringVar] = {}
|
||||||
|
for i, (key, breite) in enumerate(felder):
|
||||||
|
v = tk.StringVar()
|
||||||
|
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4)
|
||||||
|
if breite:
|
||||||
|
lbl.configure(width=breite)
|
||||||
|
lbl.pack(side="left", padx=(0, 2), pady=2)
|
||||||
|
else:
|
||||||
|
lbl.pack(side="left", fill="x", expand=True, padx=(0, 2), pady=2)
|
||||||
|
self.vars[key] = v
|
||||||
|
|
||||||
|
def set(self, key: str, text: str):
|
||||||
|
self.vars[key].set(text)
|
||||||
|
|
||||||
|
|
||||||
|
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
|
||||||
|
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
|
||||||
|
activebackground=FACE, **kw)
|
||||||
|
if icon is not None:
|
||||||
|
b.configure(image=icon, compound="left", padx=6)
|
||||||
|
if breite:
|
||||||
|
b.configure(width=breite)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
class ToolbarButton(tk.Label):
|
||||||
|
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick."""
|
||||||
|
|
||||||
|
def __init__(self, master, text: str, icon: tk.PhotoImage, command, tooltip: str = ""):
|
||||||
|
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
|
||||||
|
padx=6, pady=2, font=FONT)
|
||||||
|
self.command = command
|
||||||
|
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(relief="raised")
|
||||||
|
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
|
||||||
|
self.command()
|
||||||
|
|
||||||
|
|
||||||
|
class Tooltip:
|
||||||
|
def __init__(self, widget, text: str):
|
||||||
|
self.widget, self.text, self.tip, self._after = widget, text, None, None
|
||||||
|
widget.bind("<Enter>", self._plan, add="+")
|
||||||
|
widget.bind("<Leave>", self._weg, add="+")
|
||||||
|
widget.bind("<ButtonPress>", self._weg, add="+")
|
||||||
|
|
||||||
|
def _plan(self, _e):
|
||||||
|
self._after = self.widget.after(600, self._zeigen)
|
||||||
|
|
||||||
|
def _zeigen(self):
|
||||||
|
x = self.widget.winfo_rootx() + 10
|
||||||
|
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||||
|
self.tip = tw = tk.Toplevel(self.widget)
|
||||||
|
tw.wm_overrideredirect(True)
|
||||||
|
tw.wm_geometry(f"+{x}+{y}")
|
||||||
|
tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack()
|
||||||
|
|
||||||
|
def _weg(self, _e=None):
|
||||||
|
if self._after:
|
||||||
|
self.widget.after_cancel(self._after)
|
||||||
|
self._after = None
|
||||||
|
if self.tip:
|
||||||
|
self.tip.destroy()
|
||||||
|
self.tip = None
|
||||||
|
|
||||||
|
|
||||||
|
def trenner(master, vertikal: bool = True):
|
||||||
|
f = tk.Frame(master, bg=FACE)
|
||||||
|
if vertikal:
|
||||||
|
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
|
||||||
|
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
|
||||||
|
else:
|
||||||
|
tk.Frame(f, height=1, bg=SHADOW).pack(fill="x")
|
||||||
|
tk.Frame(f, height=1, bg=HILITE).pack(fill="x")
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]:
|
||||||
|
rahmen = Sunken(master, bg=WHITE)
|
||||||
|
tree = ttk.Treeview(rahmen, **kw)
|
||||||
|
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
|
||||||
|
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
|
||||||
|
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
|
||||||
|
tree.grid(row=0, column=0, sticky="nsew")
|
||||||
|
vs.grid(row=0, column=1, sticky="ns")
|
||||||
|
hs.grid(row=1, column=0, sticky="ew")
|
||||||
|
rahmen.rowconfigure(0, weight=1)
|
||||||
|
rahmen.columnconfigure(0, weight=1)
|
||||||
|
return rahmen, tree
|
||||||
|
|
||||||
|
|
||||||
|
def auto_breite(tree: ttk.Treeview, flex: dict[str, float], reserve: int = 4):
|
||||||
|
"""Feste Spalten behalten ihre Breite, die „flex“-Spalten teilen sich den Rest anteilig.
|
||||||
|
So bleiben z. B. die Betragsspalten immer sichtbar, egal wie breit das Fenster ist."""
|
||||||
|
|
||||||
|
def anpassen(_e=None):
|
||||||
|
verfuegbar = tree.winfo_width() - reserve
|
||||||
|
if verfuegbar < 50:
|
||||||
|
return
|
||||||
|
alle = ["#0"] + list(tree["columns"]) if "tree" in str(tree["show"]) else list(tree["columns"])
|
||||||
|
fest = sum(int(tree.column(c, "width")) for c in alle if c not in flex)
|
||||||
|
rest = max(0, verfuegbar - fest)
|
||||||
|
summe = sum(flex.values())
|
||||||
|
for c, anteil in flex.items():
|
||||||
|
minimum = int(tree.column(c, "minwidth"))
|
||||||
|
tree.column(c, width=max(minimum, int(rest * anteil / summe)))
|
||||||
|
|
||||||
|
tree.bind("<Configure>", anpassen, add="+")
|
||||||
|
return anpassen
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Dialoge
|
||||||
|
|
||||||
|
class Dialog(tk.Toplevel):
|
||||||
|
"""Modaler Dialog im Win95-Stil."""
|
||||||
|
|
||||||
|
def __init__(self, master, titel: str, resizable: bool = False):
|
||||||
|
super().__init__(master, bg=FACE)
|
||||||
|
self.withdraw()
|
||||||
|
self.title(titel)
|
||||||
|
self.transient(master.winfo_toplevel())
|
||||||
|
self.resizable(resizable, resizable)
|
||||||
|
self.result = None
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
|
||||||
|
self.bind("<Escape>", lambda e: self.abbrechen())
|
||||||
|
try:
|
||||||
|
self.iconphoto(False, master.winfo_toplevel()._app_icon) # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def zeigen(self, fokus=None):
|
||||||
|
self.update_idletasks()
|
||||||
|
m = self.master.winfo_toplevel()
|
||||||
|
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
|
||||||
|
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
|
||||||
|
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
|
||||||
|
self.geometry(f"+{x}+{y}")
|
||||||
|
self.deiconify()
|
||||||
|
self.lift()
|
||||||
|
try:
|
||||||
|
self.grab_set()
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
(fokus or self).focus_set()
|
||||||
|
self.wait_window()
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
def abbrechen(self):
|
||||||
|
self.result = None
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
|
||||||
|
f = tk.Frame(self, bg=FACE)
|
||||||
|
for i, (text, cmd) in enumerate(knoepfe):
|
||||||
|
b = knopf(f, text, cmd, breite=10)
|
||||||
|
if i == default:
|
||||||
|
b.configure(default="active")
|
||||||
|
b.pack(side="left", padx=4)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _meldung(master, titel: str, text: str, icon: str, knoepfe: list[tuple[str, object]], default=0):
|
||||||
|
d = Dialog(master, titel)
|
||||||
|
body = tk.Frame(d, bg=FACE)
|
||||||
|
body.pack(fill="both", expand=True, padx=12, pady=(14, 8))
|
||||||
|
ic = make_icon(d, icon, 2)
|
||||||
|
d._ic = ic
|
||||||
|
tk.Label(body, image=ic, bg=FACE).pack(side="left", anchor="n", padx=(0, 14))
|
||||||
|
tk.Label(body, text=text, bg=FACE, justify="left", wraplength=420, anchor="w").pack(side="left", fill="x")
|
||||||
|
|
||||||
|
def mk(val):
|
||||||
|
def f():
|
||||||
|
d.result = val
|
||||||
|
d.destroy()
|
||||||
|
return f
|
||||||
|
|
||||||
|
leiste = d.knopfleiste([(t, mk(v)) for t, v in knoepfe], default)
|
||||||
|
leiste.pack(pady=(4, 12))
|
||||||
|
d.bind("<Return>", lambda e: mk(knoepfe[default][1])())
|
||||||
|
return d.zeigen()
|
||||||
|
|
||||||
|
|
||||||
|
def info(master, titel: str, text: str):
|
||||||
|
_meldung(master, titel, text, "info", [("OK", True)])
|
||||||
|
|
||||||
|
|
||||||
|
def warnung(master, titel: str, text: str):
|
||||||
|
_meldung(master, titel, text, "warnung", [("OK", True)])
|
||||||
|
|
||||||
|
|
||||||
|
def fehler(master, titel: str, text: str):
|
||||||
|
_meldung(master, titel, text, "fehler", [("OK", True)])
|
||||||
|
|
||||||
|
|
||||||
|
def frage(master, titel: str, text: str, ja: str = "Ja", nein: str = "Nein", default_ja: bool = True) -> bool:
|
||||||
|
return bool(_meldung(master, titel, text, "frage", [(ja, True), (nein, False)], 0 if default_ja else 1))
|
||||||
|
|
||||||
|
|
||||||
|
def eingabe(master, titel: str, text: str, wert: str = "") -> str | None:
|
||||||
|
d = Dialog(master, titel)
|
||||||
|
tk.Label(d, text=text, bg=FACE, justify="left", anchor="w").pack(fill="x", padx=12, pady=(12, 4))
|
||||||
|
var = tk.StringVar(value=wert)
|
||||||
|
e = tk.Entry(d, textvariable=var, width=46)
|
||||||
|
e.pack(padx=12, pady=4)
|
||||||
|
e.select_range(0, "end")
|
||||||
|
|
||||||
|
def ok():
|
||||||
|
d.result = var.get()
|
||||||
|
d.destroy()
|
||||||
|
|
||||||
|
d.bind("<Return>", lambda _e: ok())
|
||||||
|
d.knopfleiste([("OK", ok), ("Abbrechen", d.abbrechen)]).pack(pady=(6, 12))
|
||||||
|
return d.zeigen(e)
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""Einlesen der Provisionsabrechnungen (freenet CSV/Excel, Vodafone/Euronics Excel).
|
||||||
|
|
||||||
|
Jedes Format wird über seine Kopfzeile erkannt – Dateiname und Blattname sind egal.
|
||||||
|
Alle Formate werden in dieselbe Struktur `Position` überführt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from provtool.money import parse_cent
|
||||||
|
|
||||||
|
FREENET = "freenet"
|
||||||
|
VODAFONE = "vodafone"
|
||||||
|
ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
anbieter: str
|
||||||
|
zeitraum: str = ""
|
||||||
|
provisionsart: str = ""
|
||||||
|
kennung: str = "" # Unterhändler-Nr. (freenet) bzw. VO-Nr. (Vodafone)
|
||||||
|
datum: date | None = None # Aktivierungs-/Anschaltdatum
|
||||||
|
rufnummer: str = ""
|
||||||
|
imei: str = ""
|
||||||
|
geraet: str = ""
|
||||||
|
vorname: str = ""
|
||||||
|
name: str = ""
|
||||||
|
tarif: str = ""
|
||||||
|
betrag_cent: int = 0
|
||||||
|
netz: str = ""
|
||||||
|
dienst: str = ""
|
||||||
|
aktionscode: str = ""
|
||||||
|
quelle: str = ""
|
||||||
|
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
|
||||||
|
class ImportErgebnis:
|
||||||
|
datei: str
|
||||||
|
sha256: str
|
||||||
|
format: str
|
||||||
|
positionen: list[Position]
|
||||||
|
warnungen: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFehler(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Hilfen
|
||||||
|
|
||||||
|
def _norm(s) -> str:
|
||||||
|
"""Kopfzeilen-Vergleich: klein, ohne Leer-/Sonderzeichen, Umlaute vereinheitlicht."""
|
||||||
|
s = str(s or "").strip().lower()
|
||||||
|
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss"), ("�", "")):
|
||||||
|
s = s.replace(a, b)
|
||||||
|
return "".join(ch for ch in s if ch.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
def _text(v) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(v, float) and v.is_integer():
|
||||||
|
v = int(v)
|
||||||
|
return str(v).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _datum(v) -> date | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return v.date()
|
||||||
|
if isinstance(v, date):
|
||||||
|
return v
|
||||||
|
if isinstance(v, (int, float)): # Excel-Seriennummer
|
||||||
|
return (datetime(1899, 12, 30) + timedelta(days=float(v))).date()
|
||||||
|
s = str(v).strip()
|
||||||
|
for f in ("%d.%m.%Y", "%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%d.%m.%y", "%Y.%m.%d", "%d/%m/%Y"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, f).date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Formate
|
||||||
|
|
||||||
|
class Format:
|
||||||
|
key = ""
|
||||||
|
anbieter = ""
|
||||||
|
pflicht: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def passt(self, header_norm: list[str]) -> bool:
|
||||||
|
return all(_norm(p) in header_norm for p in self.pflicht)
|
||||||
|
|
||||||
|
def zeile(self, row: dict, quelle: str) -> Position | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class FreenetFormat(Format):
|
||||||
|
key = "freenet"
|
||||||
|
anbieter = FREENET
|
||||||
|
pflicht = ("Zeitraum", "Verguetungsart", "Unterhaendler", "Betrag")
|
||||||
|
|
||||||
|
def zeile(self, r: dict, quelle: str) -> Position | None:
|
||||||
|
g = lambda *k: next((_text(r.get(_norm(x))) for x in k if _text(r.get(_norm(x)))), "")
|
||||||
|
if not g("Verguetungsart") and not g("Betrag"):
|
||||||
|
return None
|
||||||
|
return Position(
|
||||||
|
anbieter=FREENET,
|
||||||
|
zeitraum=g("Zeitraum"),
|
||||||
|
provisionsart=g("Verguetungsart"),
|
||||||
|
kennung=g("Unterhaendler") or g("Haendlernummer"),
|
||||||
|
datum=_datum(r.get(_norm("Aktiv"))),
|
||||||
|
rufnummer=g("Rufnummer"),
|
||||||
|
imei=g("IMEI_SN"),
|
||||||
|
geraet=g("Geraetename"),
|
||||||
|
vorname=g("Kundenvorname"),
|
||||||
|
name=g("Kundename"),
|
||||||
|
tarif=g("Tarif"),
|
||||||
|
betrag_cent=parse_cent(r.get(_norm("Betrag"))),
|
||||||
|
netz=g("Netz"),
|
||||||
|
dienst=g("Dienst"),
|
||||||
|
aktionscode=g("Aktionscode", "Couponcode"),
|
||||||
|
quelle=quelle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VodafoneFormat(Format):
|
||||||
|
"""Euronics-Provisionsclearing (Vodafone/otelo/Cable), Blatt 'All' bzw. Abrechnungslauf-Blätter."""
|
||||||
|
key = "vodafone"
|
||||||
|
anbieter = VODAFONE
|
||||||
|
pflicht = ("Anschaltdatum", "Artikelbezeichnung", "Vo-Nr")
|
||||||
|
|
||||||
|
def zeile(self, r: dict, quelle: str) -> Position | None:
|
||||||
|
g = lambda *k: next((_text(r.get(_norm(x))) for x in k if _text(r.get(_norm(x)))), "")
|
||||||
|
praemie = r.get(_norm("Prämie in EUR"), r.get(_norm("Praemie in EUR")))
|
||||||
|
if not g("Artikelbezeichnung") and praemie in (None, ""):
|
||||||
|
return None
|
||||||
|
art = g("Artikelbezeichnung")
|
||||||
|
netz = "otelo" if "otelo" in art.lower() else ("Cable" if "cable" in art.lower() else "VF")
|
||||||
|
return Position(
|
||||||
|
anbieter=VODAFONE,
|
||||||
|
zeitraum=g("Abrechnungslauf"),
|
||||||
|
provisionsart=art,
|
||||||
|
kennung=g("Vo-Nr"),
|
||||||
|
datum=_datum(r.get(_norm("Anschaltdatum"))),
|
||||||
|
rufnummer=g("Rufnummer"),
|
||||||
|
imei=g("IMEI", "Kartennummer"),
|
||||||
|
geraet="",
|
||||||
|
vorname="",
|
||||||
|
name=g("Kunde"),
|
||||||
|
tarif=g("Tarif"),
|
||||||
|
betrag_cent=parse_cent(praemie),
|
||||||
|
netz=netz,
|
||||||
|
dienst=g("TarifKey02", "Tarif Key02"),
|
||||||
|
aktionscode=g("Text"),
|
||||||
|
quelle=quelle,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FORMATE: list[Format] = [FreenetFormat(), VodafoneFormat()]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Tabellen lesen
|
||||||
|
|
||||||
|
def _tabellen_aus_rows(rows: list[list], quelle: str, blatt: str = "") -> tuple[Format, list[Position]] | None:
|
||||||
|
"""Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter."""
|
||||||
|
for idx, row in enumerate(rows[:40]):
|
||||||
|
header = [_norm(c) for c in row]
|
||||||
|
for fmt in FORMATE:
|
||||||
|
if fmt.passt(header):
|
||||||
|
pos: list[Position] = []
|
||||||
|
leer = 0
|
||||||
|
for data in rows[idx + 1:]:
|
||||||
|
if not any(_text(c) for c in data):
|
||||||
|
leer += 1
|
||||||
|
if leer > 20:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
leer = 0
|
||||||
|
rec = {}
|
||||||
|
for h, v in zip(header, data):
|
||||||
|
if h and h not in rec:
|
||||||
|
rec[h] = v
|
||||||
|
p = fmt.zeile(rec, quelle)
|
||||||
|
if p:
|
||||||
|
p.roh = {str(k): (v.isoformat() if isinstance(v, (date, datetime)) else v)
|
||||||
|
for k, v in zip(row, data) if k not in (None, "")}
|
||||||
|
if blatt:
|
||||||
|
p.roh["_blatt"] = blatt
|
||||||
|
pos.append(p)
|
||||||
|
return fmt, pos
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(raw: bytes) -> str:
|
||||||
|
for enc in ("utf-8-sig", "cp1252", "latin-1"):
|
||||||
|
try:
|
||||||
|
return raw.decode(enc)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
return raw.decode("latin-1", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _lies_csv(path: Path) -> list[tuple[str, list[list]]]:
|
||||||
|
text = _decode(path.read_bytes())
|
||||||
|
sample = text[:5000]
|
||||||
|
try:
|
||||||
|
dialect = csv.Sniffer().sniff(sample, delimiters=";,\t|")
|
||||||
|
delim = dialect.delimiter
|
||||||
|
except csv.Error:
|
||||||
|
delim = ";"
|
||||||
|
rows = list(csv.reader(io.StringIO(text), delimiter=delim))
|
||||||
|
return [("", rows)]
|
||||||
|
|
||||||
|
|
||||||
|
def _lies_xlsx(path: Path) -> list[tuple[str, list[list]]]:
|
||||||
|
import openpyxl
|
||||||
|
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||||
|
try:
|
||||||
|
return [(ws.title.strip(), [list(r) for r in ws.iter_rows(values_only=True)]) for ws in wb.worksheets]
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _lies_xls(path: Path) -> list[tuple[str, list[list]]]:
|
||||||
|
import xlrd
|
||||||
|
book = xlrd.open_workbook(str(path))
|
||||||
|
out = []
|
||||||
|
for sh in book.sheets():
|
||||||
|
rows = []
|
||||||
|
for r in range(sh.nrows):
|
||||||
|
row = []
|
||||||
|
for c in range(sh.ncols):
|
||||||
|
cell = sh.cell(r, c)
|
||||||
|
if cell.ctype == xlrd.XL_CELL_DATE:
|
||||||
|
row.append(xlrd.xldate.xldate_as_datetime(cell.value, book.datemode))
|
||||||
|
else:
|
||||||
|
row.append(cell.value)
|
||||||
|
rows.append(row)
|
||||||
|
out.append((sh.name.strip(), rows))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lies_datei(path: str | Path) -> ImportErgebnis:
|
||||||
|
path = Path(path)
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
if ext in (".csv", ".txt"):
|
||||||
|
blaetter = _lies_csv(path)
|
||||||
|
elif ext in (".xlsx", ".xlsm"):
|
||||||
|
blaetter = _lies_xlsx(path)
|
||||||
|
elif ext == ".xls":
|
||||||
|
blaetter = _lies_xls(path)
|
||||||
|
else:
|
||||||
|
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.")
|
||||||
|
|
||||||
|
gefunden: list[tuple[str, Format, list[Position]]] = []
|
||||||
|
for name, rows in blaetter:
|
||||||
|
res = _tabellen_aus_rows(rows, path.name, name)
|
||||||
|
if res and res[1]:
|
||||||
|
gefunden.append((name, res[0], res[1]))
|
||||||
|
|
||||||
|
if not gefunden:
|
||||||
|
raise ImportFehler(
|
||||||
|
f"In „{path.name}“ wurde keine bekannte Provisionsliste gefunden.\n"
|
||||||
|
"Erwartet wird eine freenet-Abrechnung (Spalten Verguetungsart/Unterhaendler/Betrag) "
|
||||||
|
"oder eine Vodafone/Euronics-Abrechnung (Spalten Anschaltdatum/Artikelbezeichnung/Vo-Nr)."
|
||||||
|
)
|
||||||
|
|
||||||
|
warnungen: list[str] = []
|
||||||
|
# Vodafone/Euronics: Das Blatt "All" enthält alle Abrechnungsläufe. Sind zusätzlich
|
||||||
|
# Einzelblätter befüllt, würden Positionen doppelt gezählt – daher nur "All" verwenden.
|
||||||
|
alle = [g for g in gefunden if g[0].lower() == "all"]
|
||||||
|
if alle and len(gefunden) > 1:
|
||||||
|
uebrig = [g[0] for g in gefunden if g is not alle[0]]
|
||||||
|
warnungen.append(f"Nur Blatt „All“ übernommen, Einzelblätter ignoriert: {', '.join(uebrig)}")
|
||||||
|
gefunden = alle
|
||||||
|
|
||||||
|
positionen = [p for _, _, ps in gefunden for p in ps]
|
||||||
|
formate = sorted({f.key for _, f, _ in gefunden})
|
||||||
|
ohne_kennung = sum(1 for p in positionen if not p.kennung)
|
||||||
|
if ohne_kennung:
|
||||||
|
warnungen.append(f"{ohne_kennung} Position(en) ohne Unterhändler-/VO-Nummer.")
|
||||||
|
return ImportErgebnis(path.name, _sha256(path), "+".join(formate), positionen, warnungen)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Geldbeträge werden intern immer als ganze Cent (int) geführt – keine Float-Rundungsfehler."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cent(value) -> int:
|
||||||
|
"""Wandelt '1.234,56', '-8', '30', 12.5, '12.50 €' usw. in Cent um."""
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError("Ungültiger Betrag")
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value * 100
|
||||||
|
if isinstance(value, float):
|
||||||
|
return int(Decimal(str(value)).scaleb(2).quantize(Decimal(1), ROUND_HALF_UP))
|
||||||
|
s = str(value).strip().replace("€", "").replace("EUR", "").replace(" ", "").replace("\xa0", "")
|
||||||
|
if not s:
|
||||||
|
return 0
|
||||||
|
neg = False
|
||||||
|
if s.endswith("-"): # SAP-Stil: '30,00-'
|
||||||
|
neg, s = True, s[:-1]
|
||||||
|
if "," in s and "." in s:
|
||||||
|
if s.rfind(",") > s.rfind("."):
|
||||||
|
s = s.replace(".", "").replace(",", ".") # 1.234,56
|
||||||
|
else:
|
||||||
|
s = s.replace(",", "") # 1,234.56
|
||||||
|
elif "," in s:
|
||||||
|
s = s.replace(",", ".")
|
||||||
|
try:
|
||||||
|
d = Decimal(s)
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise ValueError(f"Ungültiger Betrag: {value!r}") from exc
|
||||||
|
cent = int(d.scaleb(2).quantize(Decimal(1), ROUND_HALF_UP))
|
||||||
|
return -cent if neg else cent
|
||||||
|
|
||||||
|
|
||||||
|
def apply_percent(cent: int, prozent: float) -> int:
|
||||||
|
d = Decimal(cent) * Decimal(str(prozent)) / Decimal(100)
|
||||||
|
return int(d.quantize(Decimal(1), ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(cent: int | None, symbol: bool = True) -> str:
|
||||||
|
if cent is None:
|
||||||
|
return ""
|
||||||
|
neg = cent < 0
|
||||||
|
cent = abs(cent)
|
||||||
|
euro, rest = divmod(cent, 100)
|
||||||
|
s = f"{euro:,}".replace(",", ".") + f",{rest:02d}"
|
||||||
|
if neg:
|
||||||
|
s = "-" + s
|
||||||
|
return s + (" €" if symbol else "")
|
||||||
|
|
||||||
|
|
||||||
|
def to_euro(cent: int) -> float:
|
||||||
|
return float(Decimal(cent) / Decimal(100))
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_percent(p: float | None) -> str:
|
||||||
|
if p is None:
|
||||||
|
return ""
|
||||||
|
s = f"{p:.2f}".rstrip("0").rstrip(".")
|
||||||
|
return s.replace(".", ",") + " %"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_percent(text: str) -> float:
|
||||||
|
s = str(text).strip().replace("%", "").replace(" ", "").replace(",", ".")
|
||||||
|
p = float(s)
|
||||||
|
if p < 0 or p > 1000:
|
||||||
|
raise ValueError("Prozentwert außerhalb des gültigen Bereichs")
|
||||||
|
return p
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.3
|
||||||
|
pyinstaller==6.11.1
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
openpyxl==3.1.5
|
||||||
|
xlrd==2.0.1
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""Tests für Import, Datenbank und Export – ausschließlich mit erfundenen Testdaten."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from provtool import export
|
||||||
|
from provtool.db import UNZUGEORDNET, Datenbank, Haendler
|
||||||
|
from provtool.importer import ImportFehler, lies_datei
|
||||||
|
from provtool.money import apply_percent, fmt, parse_cent
|
||||||
|
|
||||||
|
FREENET_KOPF = ("Zeitraum;Verguetungsart;Haendlernummer;Unterhaendler;Vertragsnummer;Aktiv;Deaktiv;Rufnummer;"
|
||||||
|
"Kartennummer;IMEI_SN;Geraetename;Kundenvorname;Kundename;Tarif-Kurz;Tarif;Prozent;Betrag;Grund;"
|
||||||
|
"externe Vertragsnummer;Mitarbeiternummer;Abrechnungsnummer;Netz-Kurz;Netz;Dienst-Kurz;Dienst;"
|
||||||
|
"Aktionscode;Auftragserfassungsdatum;Detailausweisung;Teilzahlung Nr.;Event-Datum;FAQ;Bonusschreiben;"
|
||||||
|
"Leistungsdatum;Couponcode")
|
||||||
|
|
||||||
|
|
||||||
|
def freenet_zeile(art, unterh, aktiv, rufnr, name, tarif, betrag, netz="VF", aktion="", zeitraum="2026.10.01-2026.10.07"):
|
||||||
|
f = [""] * 34
|
||||||
|
f[0], f[1], f[2], f[3], f[4], f[5] = zeitraum, art, "28000000", unterh, "123", aktiv
|
||||||
|
f[7], f[12], f[14], f[16], f[22], f[24], f[25] = rufnr, name, tarif, betrag, netz, "Teilnehmerverhaeltnis", aktion
|
||||||
|
return ";".join(f)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def freenet_csv(tmp_path):
|
||||||
|
zeilen = [
|
||||||
|
FREENET_KOPF,
|
||||||
|
freenet_zeile("Grundvergütung Mobilfunk Credit freenet", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet Flat 5GB", "50"),
|
||||||
|
freenet_zeile("SIM-Karten", "111", "05.10.2026", "1601111111", "Muster GmbH", "Allnet Flat 5GB", "8"),
|
||||||
|
freenet_zeile("Sonderbonus Aktion", "111", "07.10.2026", "1602222222", "Beispiel KG", "Business Classic", "170", "D1", "A123"),
|
||||||
|
freenet_zeile("Grundvergütung Mobilfunk Credit freenet", "111", "06.10.2026", "1602222222", "Beispiel KG", "Business Classic", "12,50", "D1"),
|
||||||
|
freenet_zeile("SIM-Karten", "999", "01.10.2026", "1609999999", "Eigener Kunde", "Flex", "-8"),
|
||||||
|
]
|
||||||
|
p = tmp_path / "freenet.csv"
|
||||||
|
p.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vodafone_xlsx(tmp_path):
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "All"
|
||||||
|
ws["A1"] = "Provisionsclearing Euronics e.G."
|
||||||
|
ws["B3"], ws["C3"] = "Kundennummer:", 1
|
||||||
|
ws.append([])
|
||||||
|
ws.append([])
|
||||||
|
ws["A6"] = None
|
||||||
|
kopf = ["Anschaltdatum", "Kartennummer", "IMEI", "Rufnummer", "Kunde", "Artikelbezeichnung", "Tarif",
|
||||||
|
"TarifKey02", "Prämie in EUR", "Abrechnungslauf", "Vo-Nr", "Lieferantennr", "Text"]
|
||||||
|
for c, h in enumerate(kopf, 1):
|
||||||
|
ws.cell(6, c, h)
|
||||||
|
daten = [
|
||||||
|
(datetime(2026, 9, 8), "3225601 ", "", "491726400000 ", "Testkunde ", "Freischaltungsvergütung Vodafone",
|
||||||
|
"Aktivierung Smart L ", "Smart ", 345, "VF2609 ", "80000002 ", "700000", "SMART3L"),
|
||||||
|
(datetime(2026, 9, 8), "3225601 ", "", "491726400000 ", "Testkunde ", "Freischaltungsvergütung Vodafone",
|
||||||
|
"Basispreisrabatt", "Promotion", -40, "VF2609 ", "80000002 ", "700000", "SMART3L"),
|
||||||
|
(datetime(2026, 9, 3), "", "", "160000001", "Kabelkunde", "Freischaltungsvergütung Vodafone Cable",
|
||||||
|
"VF Cable/GigaZuhause 50", "VF Internet Cable", 120, "VF2609", "EU000001", "700000", "5BV:FPT"),
|
||||||
|
]
|
||||||
|
for r, row in enumerate(daten, 7):
|
||||||
|
for c, v in enumerate(row, 1):
|
||||||
|
ws.cell(r, c, v)
|
||||||
|
leer = wb.create_sheet("VF2609 Fil 0 Ref 1")
|
||||||
|
leer["A5"], leer["B5"] = "Anschaltdatum", "Kartennummer"
|
||||||
|
p = tmp_path / "vodafone.xlsx"
|
||||||
|
wb.save(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(tmp_path):
|
||||||
|
d = Datenbank(tmp_path / "test.db")
|
||||||
|
yield d
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Geld
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ein,cent", [("30", 3000), ("12,50", 1250), ("1.234,56", 123456), ("-8", -800),
|
||||||
|
(12.5, 1250), (8, 800), ("30,00-", -3000), ("", 0), (None, 0),
|
||||||
|
("1,234.56", 123456), ("0,1", 10)])
|
||||||
|
def test_parse_cent(ein, cent):
|
||||||
|
assert parse_cent(ein) == cent
|
||||||
|
|
||||||
|
|
||||||
|
def test_prozent_und_format():
|
||||||
|
assert apply_percent(17000, 80) == 13600
|
||||||
|
assert apply_percent(1250, 33.33) == 417
|
||||||
|
assert apply_percent(-800, 50) == -400
|
||||||
|
assert fmt(123456) == "1.234,56 €"
|
||||||
|
assert fmt(-5) == "-0,05 €"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Import
|
||||||
|
|
||||||
|
def test_freenet_import(freenet_csv):
|
||||||
|
erg = lies_datei(freenet_csv)
|
||||||
|
assert erg.format == "freenet"
|
||||||
|
assert len(erg.positionen) == 5
|
||||||
|
p = erg.positionen[0]
|
||||||
|
assert p.provisionsart == "Grundvergütung Mobilfunk Credit freenet"
|
||||||
|
assert p.kennung == "111"
|
||||||
|
assert p.datum.isoformat() == "2026-10-05"
|
||||||
|
assert p.betrag_cent == 5000
|
||||||
|
assert erg.positionen[3].betrag_cent == 1250
|
||||||
|
assert erg.positionen[2].aktionscode == "A123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vodafone_import(vodafone_xlsx):
|
||||||
|
erg = lies_datei(vodafone_xlsx)
|
||||||
|
assert erg.format == "vodafone"
|
||||||
|
assert len(erg.positionen) == 3
|
||||||
|
p = erg.positionen[0]
|
||||||
|
assert p.kennung == "80000002" # Leerzeichen entfernt
|
||||||
|
assert p.rufnummer == "491726400000"
|
||||||
|
assert p.betrag_cent == 34500
|
||||||
|
assert p.zeitraum == "VF2609"
|
||||||
|
assert erg.positionen[2].netz == "Cable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekanntes_format(tmp_path):
|
||||||
|
p = tmp_path / "x.csv"
|
||||||
|
p.write_text("a;b;c\n1;2;3\n")
|
||||||
|
with pytest.raises(ImportFehler):
|
||||||
|
lies_datei(p)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Datenbank
|
||||||
|
|
||||||
|
def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path):
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "Händler A", standard_prozent=100),
|
||||||
|
[("freenet", "111"), ("vodafone", "80000002")])
|
||||||
|
aid = db.abrechnung_neu("Oktober 2026")
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
db.importieren(aid, lies_datei(vodafone_xlsx))
|
||||||
|
pos = db.positionen(aid)
|
||||||
|
eigene = [p for p in pos if p["haendler_id"] == hid]
|
||||||
|
assert len(eigene) == 6
|
||||||
|
assert sum(1 for p in pos if p["haendler_id"] == UNZUGEORDNET) == 2
|
||||||
|
|
||||||
|
# Regel: Sonderbonus nur 80 %
|
||||||
|
db.regel_setzen(hid, "Sonderbonus Aktion", 80)
|
||||||
|
db.neu_berechnen(aid)
|
||||||
|
sb = next(p for p in db.positionen(aid) if p["provisionsart"] == "Sonderbonus Aktion")
|
||||||
|
assert sb["betrag"] == 13600 and sb["prozent"] == 80
|
||||||
|
|
||||||
|
# manuelle Änderung bleibt bei Neuberechnung erhalten
|
||||||
|
sim = next(p for p in db.positionen(aid) if p["provisionsart"] == "SIM-Karten" and p["haendler_id"] == hid)
|
||||||
|
db.betrag_setzen(sim["id"], 500)
|
||||||
|
db.neu_berechnen(aid)
|
||||||
|
sim = next(p for p in db.positionen(aid) if p["id"] == sim["id"])
|
||||||
|
assert sim["betrag"] == 500 and sim["manuell"] == 1
|
||||||
|
db.neu_berechnen(aid, auch_manuelle=True)
|
||||||
|
sim = next(p for p in db.positionen(aid) if p["id"] == sim["id"])
|
||||||
|
assert sim["betrag"] == 800 and sim["manuell"] == 0
|
||||||
|
|
||||||
|
# Export
|
||||||
|
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
|
||||||
|
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026")
|
||||||
|
assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx",
|
||||||
|
"Händler A - Vodafone - Oktober 2026.xlsx"]
|
||||||
|
ws = openpyxl.load_workbook(dateien[0]).active
|
||||||
|
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
|
||||||
|
# Sortierung Zeitraum → Provisionsart → Datum
|
||||||
|
assert [z[1] for z in zeilen] == ["Grundvergütung Mobilfunk Credit freenet"] * 2 + ["SIM-Karten", "Sonderbonus Aktion"]
|
||||||
|
assert zeilen[0][2] == 111 and zeilen[0][4] == 1601111111
|
||||||
|
assert zeilen[3][10] == 136.0 and zeilen[3][13] == "A123"
|
||||||
|
assert ws["K6"].value == "=SUM(K1:K4)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_doppelte_kennung_verboten(db):
|
||||||
|
db.haendler_speichern(Haendler(0, "A"), [("freenet", "1")])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
db.haendler_speichern(Haendler(0, "B"), [("freenet", "1")])
|
||||||
|
|
||||||
|
|
||||||
|
def test_datei_doppelt_erkannt_und_entfernbar(db, freenet_csv):
|
||||||
|
aid = db.abrechnung_neu("X")
|
||||||
|
erg = lies_datei(freenet_csv)
|
||||||
|
db.importieren(aid, erg)
|
||||||
|
assert db.datei_schon_importiert(erg.sha256) == ["X"]
|
||||||
|
did = db.dateien(aid)[0]["id"]
|
||||||
|
db.datei_entfernen(did)
|
||||||
|
assert db.positionen(aid) == []
|
||||||
Reference in new issue
Block a user