Modernes Design (Standard, Win95 als Legacy), Zeitraum abrechnen, Abrechnung erledigt, Linux-Build
- Design: „modern“ (hell, flach, wie Windows 11, normaler Windows-Rahmen) ist Standard; „win95“ bleibt unter Datei > Einstellungen wählbar (Einstellung „design“). - Export: Zeitraum von–bis (nach Aktivierungsdatum oder Abrechnungszeitraum des Anbieters), exportierte Positionen werden als abgerechnet gekennzeichnet (Betrag/Händler eingefroren) und per Fingerabdruck auch in späteren Dateien/Abrechnungen nicht noch einmal abgerechnet. - Datei > Abrechnung erledigt / wieder öffnen: erledigte Abrechnungen sind gegen Änderungen gesperrt. - DB-Schema v4 (Migration inkl. Fingerabdrücke für alte Positionen). - Linux-Build (python:3.12-bookworm) als zweiter Pipeline-Job mit Tests und Selbsttest; Release-Upload für beide Jobs über build/release_hochladen.sh. - Behoben: normaler Windows-Rahmen stürzte beim Start ab (Hauptrahmen.nativ fehlte). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a9d6d19a2
commit
da8a7138bd
17 files changed
+829
-80
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
# Baut die Windows-Version (Provisionsmanager95.exe) mit Windows-Python unter Wine.
|
# Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version.
|
||||||
# - jeder Push / PR: Tests + .exe als Build-Artefakt
|
# - jeder Push / PR: Tests + Programmdateien als Build-Artefakte
|
||||||
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip
|
# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip und Linux-.tar.gz
|
||||||
name: Windows-Build
|
name: Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -38,23 +38,40 @@ jobs:
|
|||||||
dist/Provisionsmanager95.exe
|
dist/Provisionsmanager95.exe
|
||||||
dist/Provisionsmanager95-*.zip
|
dist/Provisionsmanager95-*.zip
|
||||||
|
|
||||||
- name: Release erstellen
|
- name: Release erstellen / ergänzen
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$(tr -d '\r\n' < dist/VERSION).zip
|
||||||
|
|
||||||
|
linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
container:
|
||||||
|
image: python:3.12-bookworm
|
||||||
|
steps:
|
||||||
|
- name: Werkzeuge installieren
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
apt-get update -qq
|
||||||
VERSION=$(cat dist/VERSION | tr -d '\r\n')
|
apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xvfb xauth fonts-dejavu-core >/dev/null
|
||||||
if [ "v$VERSION" != "$TAG" ]; then
|
|
||||||
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1
|
- uses: actions/checkout@v4
|
||||||
fi
|
|
||||||
resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
- name: Tests + Build (Linux)
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}")
|
run: ./build/build_linux.sh
|
||||||
id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
|
||||||
for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do
|
- name: Build-Artefakt hochladen
|
||||||
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
|
uses: actions/upload-artifact@v3
|
||||||
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
|
with:
|
||||||
echo "hochgeladen: $f"
|
name: Provisionsmanager95-Linux
|
||||||
done
|
path: dist/Provisionsmanager95-linux-*.tar.gz
|
||||||
|
|
||||||
|
- name: Release ergänzen
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: build/release_hochladen.sh dist/Provisionsmanager95-linux-x86_64-*.tar.gz
|
||||||
@@ -70,6 +70,18 @@ Das Hauptfenster ist unter Windows ein echtes Programmfenster mit Win95-Optik: a
|
|||||||
ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
|
ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
|
||||||
wie gewohnt.
|
wie gewohnt.
|
||||||
|
|
||||||
|
**Zeitraum abrechnen:** Im Export-Dialog „Nur von … bis …“ eintragen (oder „Vormonat“), wahlweise nach
|
||||||
|
Aktivierungsdatum oder Abrechnungszeitraum des Anbieters. Exportierte Positionen werden als *abgerechnet*
|
||||||
|
gekennzeichnet (grau) und nie wieder exportiert – auch nicht, wenn dieselbe Position später in einer
|
||||||
|
anderen Datei oder Abrechnung auftaucht. Versehentlich? Rechtsklick ▸ *Kennzeichnung „abgerechnet“ aufheben*.
|
||||||
|
*Datei ▸ Abrechnung erledigt / wieder öffnen* sperrt eine fertige Abrechnung gegen Änderungen.
|
||||||
|
|
||||||
|
**Design:** Standard ist ein modernes, helles Design (wie Windows 11); das Windows-95-Design gibt es weiter
|
||||||
|
unter *Datei ▸ Einstellungen ▸ Darstellung* (wirkt nach Neustart).
|
||||||
|
|
||||||
|
**Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten
|
||||||
|
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
|
||||||
|
|
||||||
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
|
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
|
||||||
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
|
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
|
||||||
|
|
||||||
|
|||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut die Linux-Version (einzelne Programmdatei) – im Container python:3.12-bookworm (glibc 2.36:
|
||||||
|
# läuft ab Debian 12, Ubuntu 24.04, Linux Mint 22; Debian 11 ist ausgelaufen).
|
||||||
|
# Ergebnis: dist/Provisionsmanager95-linux-x86_64-<version>.tar.gz
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export LANG=C.UTF-8 LC_ALL=C.UTF-8
|
||||||
|
|
||||||
|
if ! command -v Xvfb >/dev/null; then
|
||||||
|
apt-get update -qq && apt-get install -y -qq --no-install-recommends xvfb xauth fonts-dejavu-core >/dev/null
|
||||||
|
fi
|
||||||
|
Xvfb :91 -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 &
|
||||||
|
XVFB_PID=$!
|
||||||
|
trap 'kill $XVFB_PID 2>/dev/null || true' EXIT
|
||||||
|
export DISPLAY=:91
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
python3 --version
|
||||||
|
python3 -m pip install --disable-pip-version-check -q -r requirements-dev.txt
|
||||||
|
|
||||||
|
echo "== Tests inkl. Oberfläche (Linux) =="
|
||||||
|
PROVTOOL_GUI_TESTS=1 timeout 900 python3 -m pytest -v -p no:cacheprovider
|
||||||
|
|
||||||
|
VERSION=$(python3 -c "import provtool; print(provtool.__version__)")
|
||||||
|
echo "== PyInstaller (Version $VERSION) =="
|
||||||
|
python3 -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec
|
||||||
|
test -x dist/Provisionsmanager95
|
||||||
|
|
||||||
|
echo "== Selbsttest des fertigen Programms =="
|
||||||
|
rm -f selbsttest.txt
|
||||||
|
set +e
|
||||||
|
timeout 300 dist/Provisionsmanager95 --selbsttest selbsttest.txt
|
||||||
|
CODE=$?
|
||||||
|
set -e
|
||||||
|
cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei)"
|
||||||
|
if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | grep -qx OK; then
|
||||||
|
echo "Selbsttest FEHLGESCHLAGEN (Exit-Code $CODE)"; exit 1
|
||||||
|
fi
|
||||||
|
echo "Selbsttest bestanden"
|
||||||
|
|
||||||
|
PAKET="Provisionsmanager95-linux-x86_64-$VERSION"
|
||||||
|
rm -rf "dist/$PAKET" && mkdir -p "dist/$PAKET"
|
||||||
|
cp dist/Provisionsmanager95 assets/app.png "dist/$PAKET/"
|
||||||
|
cp README.md "dist/$PAKET/LIESMICH.md"
|
||||||
|
cat > "dist/$PAKET/provisionsmanager95.desktop" <<DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Provisions-Manager 95
|
||||||
|
Comment=Provisionsabrechnung für Unterhändler
|
||||||
|
Exec=sh -c '"\$(dirname "%k")/Provisionsmanager95"'
|
||||||
|
Icon=app
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;Finance;
|
||||||
|
DESKTOP
|
||||||
|
tar -C dist -czf "dist/$PAKET.tar.gz" "$PAKET"
|
||||||
|
echo "$VERSION" > dist/VERSION
|
||||||
|
ls -la dist
|
||||||
+2
-1
@@ -15,7 +15,8 @@ faulthandler.enable(open(os.path.join(aus, "absturz.txt"), "w"))
|
|||||||
home = tempfile.mkdtemp()
|
home = tempfile.mkdtemp()
|
||||||
os.environ["PROVTOOL_HOME"] = home
|
os.environ["PROVTOOL_HOME"] = home
|
||||||
with open(os.path.join(home, "einstellungen.json"), "w") as f:
|
with open(os.path.join(home, "einstellungen.json"), "w") as f:
|
||||||
json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100"}, f)
|
json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100",
|
||||||
|
"design": "win95"}, f) # nur Win95 hat den eigenen Fensterrahmen
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
from provtool.gui import app as A # noqa: E402
|
from provtool.gui import app as A # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# PyInstaller-Spezifikation – erzeugt eine einzelne Provisionsmanager95.exe
|
# PyInstaller-Spezifikation – erzeugt eine einzelne Programmdatei (Windows: .exe, Linux: ELF)
|
||||||
# Aufruf (unter Windows oder Wine): pyinstaller --noconfirm build/provisionsmanager.spec
|
# Aufruf: pyinstaller --noconfirm build/provisionsmanager.spec
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
WIN = sys.platform.startswith("win")
|
||||||
|
|
||||||
ROOT = os.path.abspath(os.path.join(SPECPATH, ".."))
|
ROOT = os.path.abspath(os.path.join(SPECPATH, ".."))
|
||||||
|
|
||||||
@@ -20,8 +23,8 @@ exe = EXE(
|
|||||||
a.datas,
|
a.datas,
|
||||||
[],
|
[],
|
||||||
name="Provisionsmanager95",
|
name="Provisionsmanager95",
|
||||||
icon=os.path.join(ROOT, "assets", "app.ico"),
|
icon=os.path.join(ROOT, "assets", "app.ico") if WIN else None,
|
||||||
version=os.path.join(ROOT, "build", "version_info.txt"),
|
version=os.path.join(ROOT, "build", "version_info.txt") if WIN else None,
|
||||||
console=False,
|
console=False,
|
||||||
upx=False,
|
upx=False,
|
||||||
debug=False,
|
debug=False,
|
||||||
|
|||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Hängt Dateien an das Gitea-Release des Tags (legt es an, falls es noch fehlt – Windows- und
|
||||||
|
# Linux-Job laufen parallel, wer zuerst fertig ist, legt es an).
|
||||||
|
# Aufruf: TOKEN=… API=…/api/v1/repos/<owner>/<repo> TAG=v1.2.3 build/release_hochladen.sh datei…
|
||||||
|
set -eu
|
||||||
|
VERSION=$(tr -d '\r\n' < dist/VERSION)
|
||||||
|
if [ "v$VERSION" != "$TAG" ]; then
|
||||||
|
echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1
|
||||||
|
fi
|
||||||
|
release_id() { curl -sf "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2; }
|
||||||
|
id=$(release_id || true)
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows: .exe herunterladen und starten. Linux: tar.gz entpacken, Provisionsmanager95 starten (ab Debian 12 / Ubuntu 24.04).\"}" \
|
||||||
|
>/dev/null || sleep 5 # Gleichzeitig angelegt? Dann gibt es das Release jetzt.
|
||||||
|
id=$(release_id)
|
||||||
|
fi
|
||||||
|
for f in "$@"; do
|
||||||
|
curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \
|
||||||
|
-H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null
|
||||||
|
echo "hochgeladen: $f"
|
||||||
|
done
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
||||||
|
|
||||||
__version__ = "1.3.1"
|
__version__ = "1.4.0"
|
||||||
APP_NAME = "Provisions-Manager 95"
|
APP_NAME = "Provisions-Manager 95"
|
||||||
APP_ORG = "Handy-Insel Miesbach"
|
APP_ORG = "Handy-Insel Miesbach"
|
||||||
+147
-2
@@ -1,6 +1,7 @@
|
|||||||
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
|
"""SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -12,7 +13,7 @@ from provtool import regeln
|
|||||||
from provtool.importer import ImportErgebnis
|
from provtool.importer import ImportErgebnis
|
||||||
from provtool.money import apply_percent
|
from provtool.money import apply_percent
|
||||||
|
|
||||||
SCHEMA_VERSION = 3
|
SCHEMA_VERSION = 4
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
|
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
|
||||||
@@ -96,6 +97,14 @@ CREATE TABLE IF NOT EXISTS position (
|
|||||||
roh TEXT
|
roh TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
|
CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
|
||||||
|
|
||||||
|
-- Abgerechnete Positionen (Fingerabdruck), über alle Abrechnungen hinweg: taucht eine schon
|
||||||
|
-- ausgezahlte Position in einer späteren Datei/Abrechnung wieder auf, wird sie nicht erneut abgerechnet.
|
||||||
|
CREATE TABLE IF NOT EXISTS abgerechnet_fp (
|
||||||
|
fp TEXT PRIMARY KEY,
|
||||||
|
wann TEXT NOT NULL,
|
||||||
|
abrechnung TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
|
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
|
||||||
@@ -107,6 +116,27 @@ CAPS_ANBIETER = "vodafone_caps"
|
|||||||
CAPS_WERT = "*"
|
CAPS_WERT = "*"
|
||||||
|
|
||||||
|
|
||||||
|
FP_FELDER = ("anbieter", "zeitraum", "provisionsart", "kennung", "datum", "rufnummer", "imei", "vorname", "name",
|
||||||
|
"tarif", "dienst", "aktionscode", "betrag_orig")
|
||||||
|
|
||||||
|
|
||||||
|
def fingerabdruecke(zeilen: list[dict]) -> list[str]:
|
||||||
|
"""Fingerabdruck je Position einer Datei. Gleiche Zeilen innerhalb einer Datei (z. B. zwei gleiche
|
||||||
|
SIM-Karten-Gutschriften) werden durchgezählt, damit beide als eigene Positionen gelten."""
|
||||||
|
gesehen: dict[str, int] = {}
|
||||||
|
out = []
|
||||||
|
for z in zeilen:
|
||||||
|
roh = "\x1f".join(str(z.get(k) if z.get(k) is not None else "").strip() for k in FP_FELDER)
|
||||||
|
h = hashlib.sha1(roh.encode("utf-8")).hexdigest()[:24]
|
||||||
|
gesehen[h] = gesehen.get(h, 0) + 1
|
||||||
|
out.append(f"{h}#{gesehen[h]}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class AbrechnungErledigt(Exception):
|
||||||
|
"""Die Abrechnung ist als erledigt gekennzeichnet und darf nicht mehr verändert werden."""
|
||||||
|
|
||||||
|
|
||||||
def ist_grossgeschrieben(name: str | None) -> bool:
|
def ist_grossgeschrieben(name: str | None) -> bool:
|
||||||
buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"]
|
buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"]
|
||||||
return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben)
|
return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben)
|
||||||
@@ -151,9 +181,28 @@ class Datenbank:
|
|||||||
self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0")
|
self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0")
|
||||||
if "staffel" not in h_spalten:
|
if "staffel" not in h_spalten:
|
||||||
self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT")
|
self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT")
|
||||||
|
if "abgerechnet" not in spalten:
|
||||||
|
# Zeitpunkt der Abrechnung (NULL = offen), Händler zum Zeitpunkt der Abrechnung (eingefroren),
|
||||||
|
# Fingerabdruck (erkennt dieselbe Position in späteren Dateien)
|
||||||
|
self.con.execute("ALTER TABLE position ADD COLUMN abgerechnet TEXT")
|
||||||
|
self.con.execute("ALTER TABLE position ADD COLUMN abgerechnet_hid INTEGER")
|
||||||
|
self.con.execute("ALTER TABLE position ADD COLUMN fp TEXT")
|
||||||
|
a_spalten = {r[1] for r in self.con.execute("PRAGMA table_info(abrechnung)")}
|
||||||
|
if "erledigt" not in a_spalten:
|
||||||
|
self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT")
|
||||||
|
self._fingerabdruecke_nachtragen()
|
||||||
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
|
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
|
||||||
self.con.commit()
|
self.con.commit()
|
||||||
|
|
||||||
|
def _fingerabdruecke_nachtragen(self):
|
||||||
|
"""Für Positionen aus älteren Versionen den Fingerabdruck berechnen."""
|
||||||
|
dateien = [r[0] for r in self.con.execute("SELECT DISTINCT datei_id FROM position WHERE fp IS NULL")]
|
||||||
|
for did in dateien:
|
||||||
|
zeilen = [dict(r) for r in self.con.execute(
|
||||||
|
"SELECT * FROM position WHERE datei_id IS ? ORDER BY reihenfolge", (did,))]
|
||||||
|
for pid, fp in zip([z["id"] for z in zeilen], fingerabdruecke(zeilen)):
|
||||||
|
self.con.execute("UPDATE position SET fp=? WHERE id=? AND fp IS NULL", (fp, pid))
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.con.close()
|
self.con.close()
|
||||||
|
|
||||||
@@ -339,6 +388,7 @@ class Datenbank:
|
|||||||
def abrechnungen(self) -> list[sqlite3.Row]:
|
def abrechnungen(self) -> list[sqlite3.Row]:
|
||||||
return list(self.con.execute(
|
return list(self.con.execute(
|
||||||
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
|
"SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, "
|
||||||
|
"(SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id AND p.abgerechnet IS NOT NULL) AS abgerechnet, "
|
||||||
"(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien "
|
"(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien "
|
||||||
"FROM abrechnung a ORDER BY a.geaendert DESC"))
|
"FROM abrechnung a ORDER BY a.geaendert DESC"))
|
||||||
|
|
||||||
@@ -362,11 +412,15 @@ class Datenbank:
|
|||||||
(sha256,))]
|
(sha256,))]
|
||||||
|
|
||||||
def datei_entfernen(self, datei_id: int) -> None:
|
def datei_entfernen(self, datei_id: int) -> None:
|
||||||
|
r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone()
|
||||||
|
if r:
|
||||||
|
self._nicht_erledigt(r[0])
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
|
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
|
||||||
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
||||||
|
|
||||||
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
||||||
|
self._nicht_erledigt(aid)
|
||||||
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
|
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
|
||||||
(aid,)).fetchone()[0]
|
(aid,)).fetchone()[0]
|
||||||
with self.con:
|
with self.con:
|
||||||
@@ -384,6 +438,10 @@ class Datenbank:
|
|||||||
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent,
|
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent,
|
||||||
json.dumps(p.roh, ensure_ascii=False, default=str)))
|
json.dumps(p.roh, ensure_ascii=False, default=str)))
|
||||||
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
||||||
|
zeilen = [dict(r) for r in self.con.execute(
|
||||||
|
"SELECT * FROM position WHERE datei_id=? ORDER BY reihenfolge", (did,))]
|
||||||
|
for z, fp in zip(zeilen, fingerabdruecke(zeilen)):
|
||||||
|
self.con.execute("UPDATE position SET fp=? WHERE id=?", (fp, z["id"]))
|
||||||
neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))}
|
neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))}
|
||||||
self._regeln_anwenden(aid, neue, auch_manuelle=True)
|
self._regeln_anwenden(aid, neue, auch_manuelle=True)
|
||||||
return len(erg.positionen)
|
return len(erg.positionen)
|
||||||
@@ -392,11 +450,17 @@ class Datenbank:
|
|||||||
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
|
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
|
||||||
finde = self.zuordner()
|
finde = self.zuordner()
|
||||||
fest = self.art_typen()
|
fest = self.art_typen()
|
||||||
|
schon = dict(self.con.execute("SELECT fp, abrechnung FROM abgerechnet_fp").fetchall())
|
||||||
out = []
|
out = []
|
||||||
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
|
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
|
if d["abgerechnet"] and d["abgerechnet_hid"] is not None:
|
||||||
|
d["haendler_id"] = d["abgerechnet_hid"] # abgerechnet: Zuordnung eingefroren
|
||||||
|
else:
|
||||||
|
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
|
||||||
d["typ"] = regeln.typ_von(d, fest)
|
d["typ"] = regeln.typ_von(d, fest)
|
||||||
|
# schon anderswo abgerechnet (dieselbe Position in einer früheren Datei/Abrechnung)?
|
||||||
|
d["doppelt"] = schon.get(d["fp"]) if d["fp"] and not d["abgerechnet"] else None
|
||||||
out.append(d)
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -441,12 +505,16 @@ class Datenbank:
|
|||||||
"""Berechnet die Auszahlung nach den Händler-Regeln neu: für `pids` (None = alle) und immer für
|
"""Berechnet die Auszahlung nach den Händler-Regeln neu: für `pids` (None = alle) und immer für
|
||||||
alle Jahresbonus-Positionen (deren Staffel hängt von der Anzahl aller Karten ab).
|
alle Jahresbonus-Positionen (deren Staffel hängt von der Anzahl aller Karten ab).
|
||||||
Von Hand geänderte Beträge bleiben, außer auch_manuelle=True. Gibt die Anzahl Änderungen zurück."""
|
Von Hand geänderte Beträge bleiben, außer auch_manuelle=True. Gibt die Anzahl Änderungen zurück."""
|
||||||
|
if self.erledigt(aid):
|
||||||
|
return 0 # erledigte Abrechnungen bleiben, wie sie abgerechnet wurden
|
||||||
alle = self.positionen(aid)
|
alle = self.positionen(aid)
|
||||||
karten = self.karten_zaehlen(alle)
|
karten = self.karten_zaehlen(alle)
|
||||||
rechne = self._rechner()
|
rechne = self._rechner()
|
||||||
n = 0
|
n = 0
|
||||||
with self.con:
|
with self.con:
|
||||||
for p in alle:
|
for p in alle:
|
||||||
|
if p["abgerechnet"]:
|
||||||
|
continue # schon ausgezahlt: Betrag bleibt
|
||||||
if pids is not None and p["id"] not in pids and p["typ"] != regeln.JAHRESBONUS:
|
if pids is not None and p["id"] not in pids and p["typ"] != regeln.JAHRESBONUS:
|
||||||
continue
|
continue
|
||||||
if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)):
|
if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)):
|
||||||
@@ -468,6 +536,7 @@ class Datenbank:
|
|||||||
def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
|
def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
|
||||||
"""Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen).
|
"""Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen).
|
||||||
Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet."""
|
Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet."""
|
||||||
|
pids = self._offene(pids)
|
||||||
with self.con:
|
with self.con:
|
||||||
for pid in pids:
|
for pid in pids:
|
||||||
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
|
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
|
||||||
@@ -480,10 +549,13 @@ class Datenbank:
|
|||||||
self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
|
self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
|
||||||
|
|
||||||
def betrag_setzen(self, pid: int, cent: int) -> None:
|
def betrag_setzen(self, pid: int, cent: int) -> None:
|
||||||
|
if not self._offene([pid]):
|
||||||
|
raise AbrechnungErledigt("Die Position ist bereits abgerechnet.")
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
|
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
|
||||||
|
|
||||||
def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
|
def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int:
|
||||||
|
pids = self._offene(pids)
|
||||||
n = 0
|
n = 0
|
||||||
with self.con:
|
with self.con:
|
||||||
for pid in pids:
|
for pid in pids:
|
||||||
@@ -500,6 +572,79 @@ class Datenbank:
|
|||||||
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
|
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
|
||||||
return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle)
|
return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ abgerechnet / erledigt
|
||||||
|
def _offene(self, pids) -> list[int]:
|
||||||
|
"""Nur Positionen, die noch geändert werden dürfen (nicht abgerechnet, Abrechnung nicht erledigt)."""
|
||||||
|
pids = list(pids)
|
||||||
|
if not pids:
|
||||||
|
return []
|
||||||
|
marken = ",".join("?" * len(pids))
|
||||||
|
return [r[0] for r in self.con.execute(
|
||||||
|
f"SELECT p.id FROM position p JOIN abrechnung a ON a.id=p.abrechnung_id "
|
||||||
|
f"WHERE p.id IN ({marken}) AND p.abgerechnet IS NULL AND a.erledigt IS NULL", pids)]
|
||||||
|
|
||||||
|
def erledigt(self, aid: int) -> str | None:
|
||||||
|
r = self.con.execute("SELECT erledigt FROM abrechnung WHERE id=?", (aid,)).fetchone()
|
||||||
|
return r[0] if r else None
|
||||||
|
|
||||||
|
def _nicht_erledigt(self, aid: int) -> None:
|
||||||
|
if self.erledigt(aid):
|
||||||
|
raise AbrechnungErledigt("Diese Abrechnung ist als erledigt gekennzeichnet.")
|
||||||
|
|
||||||
|
def abrechnen(self, pids: list[int], wann: str | None = None) -> int:
|
||||||
|
"""Positionen als abgerechnet kennzeichnen: Betrag und Händler werden eingefroren, der
|
||||||
|
Fingerabdruck gemerkt (dieselbe Position wird später nicht noch einmal abgerechnet).
|
||||||
|
Nur Positionen echter Händler, die noch offen und nicht schon anderswo abgerechnet sind."""
|
||||||
|
wann = wann or _now()
|
||||||
|
ids = set(pids)
|
||||||
|
n = 0
|
||||||
|
for aid in self._abrechnungen_von(list(ids)):
|
||||||
|
titel = self.abrechnung(aid)["titel"]
|
||||||
|
with self.con:
|
||||||
|
for p in self.positionen(aid):
|
||||||
|
if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
|
||||||
|
continue
|
||||||
|
self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE id=?",
|
||||||
|
(wann, p["haendler_id"], p["id"]))
|
||||||
|
if p["fp"]:
|
||||||
|
self.con.execute("INSERT OR REPLACE INTO abgerechnet_fp (fp, wann, abrechnung) VALUES (?,?,?)",
|
||||||
|
(p["fp"], wann, titel))
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
def abrechnung_aufheben(self, pids: list[int]) -> int:
|
||||||
|
"""Kennzeichnung „abgerechnet“ zurücknehmen (z. B. versehentlich exportiert)."""
|
||||||
|
n = 0
|
||||||
|
for aid in self._abrechnungen_von(list(pids)):
|
||||||
|
self._nicht_erledigt(aid)
|
||||||
|
with self.con:
|
||||||
|
for pid in pids:
|
||||||
|
r = self.con.execute("SELECT fp, abgerechnet FROM position WHERE id=?", (pid,)).fetchone()
|
||||||
|
if not r or not r["abgerechnet"]:
|
||||||
|
continue
|
||||||
|
self.con.execute("UPDATE position SET abgerechnet=NULL, abgerechnet_hid=NULL WHERE id=?", (pid,))
|
||||||
|
if r["fp"]:
|
||||||
|
self.con.execute("DELETE FROM abgerechnet_fp WHERE fp=?", (r["fp"],))
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
def erledigt_setzen(self, aid: int, erledigt: bool, positionen_aufheben: bool = False) -> int:
|
||||||
|
"""Abrechnung als erledigt kennzeichnen (alle offenen Händler-Positionen werden dabei
|
||||||
|
abgerechnet) bzw. wieder öffnen (auf Wunsch samt Kennzeichnung der Positionen).
|
||||||
|
Gibt die Anzahl der dabei (ab)gerechneten Positionen zurück."""
|
||||||
|
n = 0
|
||||||
|
if erledigt:
|
||||||
|
wann = _now()
|
||||||
|
n = self.abrechnen([p["id"] for p in self.positionen(aid)], wann)
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("UPDATE abrechnung SET erledigt=?, geaendert=? WHERE id=?", (wann, wann, aid))
|
||||||
|
else:
|
||||||
|
with self.con:
|
||||||
|
self.con.execute("UPDATE abrechnung SET erledigt=NULL, geaendert=? WHERE id=?", (_now(), aid))
|
||||||
|
if positionen_aufheben:
|
||||||
|
n = self.abrechnung_aufheben([p["id"] for p in self.positionen(aid)])
|
||||||
|
return n
|
||||||
|
|
||||||
def touch(self, aid: int) -> None:
|
def touch(self, aid: int) -> None:
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
||||||
+104
-23
@@ -15,7 +15,7 @@ from tkinter import filedialog
|
|||||||
from provtool import APP_NAME, config
|
from provtool import APP_NAME, config
|
||||||
from provtool import export as exp
|
from provtool import export as exp
|
||||||
from provtool import regeln
|
from provtool import regeln
|
||||||
from provtool.db import EIGEN, UNZUGEORDNET, Datenbank
|
from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank
|
||||||
from provtool.gui import assistent as ass
|
from provtool.gui import assistent as ass
|
||||||
from provtool.gui import dialogs as dlg
|
from provtool.gui import dialogs as dlg
|
||||||
from provtool.gui import menue as mn
|
from provtool.gui import menue as mn
|
||||||
@@ -70,13 +70,14 @@ class App:
|
|||||||
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
|
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
|
||||||
self._beendet = False # Datenbank und Fenster sind zu
|
self._beendet = False # Datenbank und Fenster sind zu
|
||||||
|
|
||||||
|
w.design_setzen(self.settings.get("design", "modern"))
|
||||||
w.apply(root)
|
w.apply(root)
|
||||||
self.icons = w.Icons(root)
|
self.icons = w.Icons(root)
|
||||||
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
|
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
|
||||||
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
|
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
|
||||||
root.protocol("WM_DELETE_WINDOW", self.beenden)
|
root.protocol("WM_DELETE_WINDOW", self.beenden)
|
||||||
root.report_callback_exception = self._fehler_handler
|
root.report_callback_exception = self._fehler_handler
|
||||||
klassisch = bool(self.settings.get("klassischer_rahmen", True))
|
klassisch = w.DESIGN == "win95" # Win95: eigene Titelleiste; modern: normaler Windows-Rahmen
|
||||||
w.KLASSISCH = klassisch
|
w.KLASSISCH = klassisch
|
||||||
self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
|
self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
|
||||||
self.rahmen.set_titel(APP_NAME)
|
self.rahmen.set_titel(APP_NAME)
|
||||||
@@ -116,6 +117,7 @@ class App:
|
|||||||
d.add_command(label="Dateien importieren...", accelerator="Strg+I", command=self.importieren)
|
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="Importierte Dateien...", command=self.dateien)
|
||||||
d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren)
|
d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren)
|
||||||
|
d.add_command(label="Abrechnung erledigt / wieder öffnen...", command=self.erledigt_umschalten)
|
||||||
d.add_separator()
|
d.add_separator()
|
||||||
d.add_command(label="Datensicherung erstellen...", command=self.sicherung)
|
d.add_command(label="Datensicherung erstellen...", command=self.sicherung)
|
||||||
d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen)
|
d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen)
|
||||||
@@ -152,7 +154,7 @@ class App:
|
|||||||
hi.add_command(label=f"Info über {APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
|
hi.add_command(label=f"Info über {APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
|
||||||
|
|
||||||
def _toolbar(self):
|
def _toolbar(self):
|
||||||
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised")
|
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=0 if w.MODERN else 1, relief="raised")
|
||||||
self.tb: dict[str, w.ToolbarButton] = {}
|
self.tb: dict[str, w.ToolbarButton] = {}
|
||||||
tb.pack(side="top", fill="x")
|
tb.pack(side="top", fill="x")
|
||||||
for text, icon, cmd, tip in (
|
for text, icon, cmd, tip in (
|
||||||
@@ -188,7 +190,8 @@ class App:
|
|||||||
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar.
|
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar.
|
||||||
# Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim
|
# Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim
|
||||||
# Loslassen (flüssig, auch mit vielen Positionen).
|
# Loslassen (flüssig, auch mit vielen Positionen).
|
||||||
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised",
|
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7,
|
||||||
|
sashrelief="flat" if w.MODERN else "raised",
|
||||||
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
|
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
|
||||||
pw.pack(fill="both", expand=True, padx=6, pady=6)
|
pw.pack(fill="both", expand=True, padx=6, pady=6)
|
||||||
|
|
||||||
@@ -227,6 +230,7 @@ class App:
|
|||||||
self.ptree.tag_configure("manuell", foreground="#0000c0")
|
self.ptree.tag_configure("manuell", foreground="#0000c0")
|
||||||
self.ptree.tag_configure("abw", foreground="#a00000")
|
self.ptree.tag_configure("abw", foreground="#a00000")
|
||||||
self.ptree.tag_configure("negativ", foreground="#a00000")
|
self.ptree.tag_configure("negativ", foreground="#a00000")
|
||||||
|
self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW)
|
||||||
r2.pack(fill="both", expand=True)
|
r2.pack(fill="both", expand=True)
|
||||||
self.ptree.bind("<Double-1>", self._doppelklick)
|
self.ptree.bind("<Double-1>", self._doppelklick)
|
||||||
self.ptree.bind("<Button-3>", self._kontextmenue)
|
self.ptree.bind("<Button-3>", self._kontextmenue)
|
||||||
@@ -241,7 +245,7 @@ class App:
|
|||||||
self.v_alle_auf = tk.BooleanVar(value=True)
|
self.v_alle_auf = tk.BooleanVar(value=True)
|
||||||
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
|
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
|
||||||
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
|
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
|
||||||
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack(
|
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend · Grau = abgerechnet", fg=w.SHADOW).pack(
|
||||||
side="left", padx=8)
|
side="left", padx=8)
|
||||||
pw.add(self.f_p, minsize=500)
|
pw.add(self.f_p, minsize=500)
|
||||||
|
|
||||||
@@ -272,7 +276,7 @@ class App:
|
|||||||
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
|
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
|
||||||
if self.aid and self.db.abrechnung(self.aid):
|
if self.aid and self.db.abrechnung(self.aid):
|
||||||
self.positionen = self.db.positionen(self.aid)
|
self.positionen = self.db.positionen(self.aid)
|
||||||
titel = self.db.abrechnung(self.aid)["titel"]
|
titel = self.db.abrechnung(self.aid)["titel"] + (" – erledigt" if self.db.erledigt(self.aid) else "")
|
||||||
self.rahmen.set_titel(f"{APP_NAME} - [{titel}]")
|
self.rahmen.set_titel(f"{APP_NAME} - [{titel}]")
|
||||||
self.status.set("info", f" Abrechnung: {titel}")
|
self.status.set("info", f" Abrechnung: {titel}")
|
||||||
else:
|
else:
|
||||||
@@ -380,7 +384,9 @@ class App:
|
|||||||
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
|
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
|
||||||
for p in plist:
|
for p in plist:
|
||||||
tags = []
|
tags = []
|
||||||
if p["manuell"]:
|
if p["abgerechnet"] or p["doppelt"]:
|
||||||
|
tags.append("abgerechnet")
|
||||||
|
elif p["manuell"]:
|
||||||
tags.append("manuell")
|
tags.append("manuell")
|
||||||
elif p["betrag"] != p["betrag_orig"]:
|
elif p["betrag"] != p["betrag_orig"]:
|
||||||
tags.append("abw")
|
tags.append("abw")
|
||||||
@@ -389,6 +395,7 @@ class App:
|
|||||||
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
||||||
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
|
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
|
||||||
fmt(p["betrag_orig"]),
|
fmt(p["betrag_orig"]),
|
||||||
|
"abgerechnet" if p["abgerechnet"] or p["doppelt"] else
|
||||||
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
|
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
|
||||||
fmt(p["betrag"])),
|
fmt(p["betrag"])),
|
||||||
tags=tuple(tags))
|
tags=tuple(tags))
|
||||||
@@ -470,7 +477,7 @@ class App:
|
|||||||
if not dateien:
|
if not dateien:
|
||||||
return
|
return
|
||||||
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
|
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
|
||||||
if not self._brauche_abrechnung():
|
if not self._brauche_abrechnung() or self._gesperrt():
|
||||||
return
|
return
|
||||||
bericht = []
|
bericht = []
|
||||||
self.root.configure(cursor="watch")
|
self.root.configure(cursor="watch")
|
||||||
@@ -494,8 +501,12 @@ class App:
|
|||||||
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
|
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
|
||||||
continue
|
continue
|
||||||
n = self.db.importieren(self.aid, erg)
|
n = self.db.importieren(self.aid, erg)
|
||||||
|
did = self.db.dateien(self.aid)[-1]["id"]
|
||||||
|
doppelt = sum(1 for p in self.db.positionen(self.aid) if p["datei_id"] == did and p["doppelt"])
|
||||||
|
hinweise = list(erg.warnungen) + ([f"{doppelt} Pos. bereits abgerechnet (werden nicht erneut "
|
||||||
|
"exportiert)"] if doppelt else [])
|
||||||
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
|
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
|
||||||
"; ".join(erg.warnungen) or "OK"))
|
"; ".join(hinweise) or "OK"))
|
||||||
finally:
|
finally:
|
||||||
self.root.configure(cursor="")
|
self.root.configure(cursor="")
|
||||||
self._aktualisieren()
|
self._aktualisieren()
|
||||||
@@ -567,7 +578,45 @@ class App:
|
|||||||
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
||||||
return sel, pids, ps
|
return sel, pids, ps
|
||||||
|
|
||||||
|
def _gesperrt(self) -> bool:
|
||||||
|
"""Erledigte Abrechnung: keine Änderungen (Hinweis statt stillem Nichtstun)."""
|
||||||
|
if self.aid and self.db.erledigt(self.aid):
|
||||||
|
w.info(self.root, "Abrechnung erledigt", "Diese Abrechnung ist als erledigt gekennzeichnet und kann "
|
||||||
|
"nicht mehr geändert werden.\n\nZum Ändern: Datei > Abrechnung erledigt / wieder öffnen.")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def erledigt_umschalten(self):
|
||||||
|
if not self.aid:
|
||||||
|
return
|
||||||
|
titel = self.db.abrechnung(self.aid)["titel"]
|
||||||
|
if self.db.erledigt(self.aid):
|
||||||
|
if not w.frage(self.root, "Wieder öffnen", f"Abrechnung „{titel}“ wieder öffnen?"):
|
||||||
|
return
|
||||||
|
auch = w.frage(self.root, "Wieder öffnen", "Sollen die Positionen auch wieder als NICHT abgerechnet "
|
||||||
|
"gelten (z. B. um sie neu zu exportieren)?", ja="Ja, freigeben", nein="Nein",
|
||||||
|
default_ja=False)
|
||||||
|
self.db.erledigt_setzen(self.aid, False, positionen_aufheben=auch)
|
||||||
|
else:
|
||||||
|
if not w.frage(self.root, "Abrechnung erledigt",
|
||||||
|
f"Abrechnung „{titel}“ als erledigt kennzeichnen?\n\nAlle Händler-Positionen gelten dann "
|
||||||
|
"als abgerechnet und werden auch in späteren Dateien nicht noch einmal abgerechnet. "
|
||||||
|
"Die Abrechnung kann danach nicht mehr geändert werden."):
|
||||||
|
return
|
||||||
|
self.db.erledigt_setzen(self.aid, True)
|
||||||
|
self._aktualisieren()
|
||||||
|
|
||||||
|
def abgerechnet_aufheben(self):
|
||||||
|
if self._gesperrt():
|
||||||
|
return
|
||||||
|
_, pids, _ = self._auswahl_info()
|
||||||
|
n = self.db.abrechnung_aufheben(pids)
|
||||||
|
self._aktualisieren()
|
||||||
|
self.status.set("info", f" {n} Position(en) wieder offen")
|
||||||
|
|
||||||
def prozent(self):
|
def prozent(self):
|
||||||
|
if self._gesperrt():
|
||||||
|
return
|
||||||
sel, pids, ps = self._auswahl_info()
|
sel, pids, ps = self._auswahl_info()
|
||||||
if not ps:
|
if not ps:
|
||||||
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
|
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
|
||||||
@@ -601,6 +650,8 @@ class App:
|
|||||||
self.ptree.see(vorhanden[0])
|
self.ptree.see(vorhanden[0])
|
||||||
|
|
||||||
def zuruecksetzen(self):
|
def zuruecksetzen(self):
|
||||||
|
if self._gesperrt():
|
||||||
|
return
|
||||||
sel, pids, ps = self._auswahl_info()
|
sel, pids, ps = self._auswahl_info()
|
||||||
if not ps:
|
if not ps:
|
||||||
return
|
return
|
||||||
@@ -610,7 +661,7 @@ class App:
|
|||||||
self._auswahl_wiederherstellen(sel)
|
self._auswahl_wiederherstellen(sel)
|
||||||
|
|
||||||
def neu_berechnen(self):
|
def neu_berechnen(self):
|
||||||
if not self.aid:
|
if not self.aid or self._gesperrt():
|
||||||
return
|
return
|
||||||
manuell = sum(1 for p in self.positionen if p["manuell"])
|
manuell = sum(1 for p in self.positionen if p["manuell"])
|
||||||
auch = False
|
auch = False
|
||||||
@@ -638,6 +689,8 @@ class App:
|
|||||||
self.betrag_bearbeiten(iid)
|
self.betrag_bearbeiten(iid)
|
||||||
|
|
||||||
def betrag_bearbeiten(self, iid: str | None = None):
|
def betrag_bearbeiten(self, iid: str | None = None):
|
||||||
|
if self._gesperrt():
|
||||||
|
return
|
||||||
if iid is None:
|
if iid is None:
|
||||||
sel = [i for i in self.ptree.selection() if i.startswith("p")]
|
sel = [i for i in self.ptree.selection() if i.startswith("p")]
|
||||||
if len(sel) != 1:
|
if len(sel) != 1:
|
||||||
@@ -652,6 +705,10 @@ class App:
|
|||||||
bbox = self.ptree.bbox(iid, "ausz")
|
bbox = self.ptree.bbox(iid, "ausz")
|
||||||
pid = int(iid[1:])
|
pid = int(iid[1:])
|
||||||
p = next(x for x in self.positionen if x["id"] == pid)
|
p = next(x for x in self.positionen if x["id"] == pid)
|
||||||
|
if p["abgerechnet"]:
|
||||||
|
w.info(self.root, "Betrag ändern", "Diese Position ist bereits abgerechnet.\n\n"
|
||||||
|
"Zum Ändern: Rechtsklick > Kennzeichnung „abgerechnet“ aufheben.")
|
||||||
|
return
|
||||||
if not bbox:
|
if not bbox:
|
||||||
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld
|
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld
|
||||||
self._betrag_dialog(p)
|
self._betrag_dialog(p)
|
||||||
@@ -748,6 +805,9 @@ class App:
|
|||||||
if any(p["haendler_id"] == EIGEN for p in ps):
|
if any(p["haendler_id"] == EIGEN for p in ps):
|
||||||
m.add_separator()
|
m.add_separator()
|
||||||
m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden)
|
m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden)
|
||||||
|
if any(p["abgerechnet"] for p in ps):
|
||||||
|
m.add_separator()
|
||||||
|
m.add_command(label="Kennzeichnung „abgerechnet“ aufheben", command=self.abgerechnet_aufheben)
|
||||||
m.tk_popup(e.x_root, e.y_root)
|
m.tk_popup(e.x_root, e.y_root)
|
||||||
|
|
||||||
# ----- Eigene Kennungen (eigene Filiale) ausblenden
|
# ----- Eigene Kennungen (eigene Filiale) ausblenden
|
||||||
@@ -780,6 +840,8 @@ class App:
|
|||||||
self._aktualisieren()
|
self._aktualisieren()
|
||||||
|
|
||||||
def haendler_zuweisen(self, automatisch: bool = False):
|
def haendler_zuweisen(self, automatisch: bool = False):
|
||||||
|
if self._gesperrt():
|
||||||
|
return
|
||||||
sel, pids, _ = self._auswahl_info()
|
sel, pids, _ = self._auswahl_info()
|
||||||
if not pids:
|
if not pids:
|
||||||
w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.")
|
w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.")
|
||||||
@@ -795,6 +857,8 @@ class App:
|
|||||||
self._aktualisieren()
|
self._aktualisieren()
|
||||||
|
|
||||||
def _null(self):
|
def _null(self):
|
||||||
|
if self._gesperrt():
|
||||||
|
return
|
||||||
sel, pids, _ = self._auswahl_info()
|
sel, pids, _ = self._auswahl_info()
|
||||||
self.db.prozent_anwenden(pids, 0.0)
|
self.db.prozent_anwenden(pids, 0.0)
|
||||||
self.db.touch(self.aid)
|
self.db.touch(self.aid)
|
||||||
@@ -807,14 +871,19 @@ class App:
|
|||||||
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
|
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
|
||||||
"Bitte zuerst Dateien importieren.")
|
"Bitte zuerst Dateien importieren.")
|
||||||
return
|
return
|
||||||
|
# nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet)
|
||||||
|
offen = [p for p in self.positionen if p["haendler_id"] > 0 and not p["abgerechnet"] and not p["doppelt"]]
|
||||||
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
||||||
for p in self.positionen:
|
for p in offen:
|
||||||
if p["haendler_id"] > 0: # nicht: nicht zugeordnet / eigene Kennungen
|
stat[p["haendler_id"]][0] += 1
|
||||||
stat[p["haendler_id"]][0] += 1
|
stat[p["haendler_id"]][1] += p["betrag"]
|
||||||
stat[p["haendler_id"]][1] += p["betrag"]
|
|
||||||
if not stat:
|
if not stat:
|
||||||
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
|
if any(p["haendler_id"] > 0 for p in self.positionen):
|
||||||
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
|
w.info(self.root, "Export", "Alle Positionen dieser Abrechnung sind bereits abgerechnet.\n\n"
|
||||||
|
"Zum erneuten Export: Datei > Abrechnung erledigt / wieder öffnen (Positionen freigeben).")
|
||||||
|
else:
|
||||||
|
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
|
||||||
|
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
|
||||||
return
|
return
|
||||||
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in
|
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())]
|
sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
|
||||||
@@ -824,19 +893,27 @@ class App:
|
|||||||
if not res:
|
if not res:
|
||||||
return
|
return
|
||||||
config.save(self.settings)
|
config.save(self.settings)
|
||||||
erzeugt = []
|
erzeugt, exportiert = [], []
|
||||||
|
zeit = res["zeitraum"]
|
||||||
try:
|
try:
|
||||||
for hid in res["ids"]:
|
for hid in res["ids"]:
|
||||||
pos = [p for p in self.positionen if p["haendler_id"] == hid]
|
pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))]
|
||||||
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
|
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
|
||||||
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
|
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
|
||||||
summe=res["summe"], null_weglassen=res["null_weglassen"])
|
summe=res["summe"], null_weglassen=res["null_weglassen"])
|
||||||
|
exportiert += pos
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
|
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
|
||||||
"Ist sie vielleicht noch in Excel geöffnet?")
|
"Ist sie vielleicht noch in Excel geöffnet?")
|
||||||
return
|
return
|
||||||
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
|
hinweis = ""
|
||||||
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else ""))
|
if res["kennzeichnen"] and exportiert:
|
||||||
|
n = self.db.abrechnen([p["id"] for p in exportiert])
|
||||||
|
hinweis = f"\n\n{n} Position(en) als abgerechnet gekennzeichnet."
|
||||||
|
self._aktualisieren()
|
||||||
|
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}"
|
||||||
|
+ (f"\nZeitraum: {zeit.text()}" if zeit else "") + "\n\n"
|
||||||
|
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "") + hinweis)
|
||||||
if res["oeffnen"]:
|
if res["oeffnen"]:
|
||||||
_oeffne_ordner(res["ordner"])
|
_oeffne_ordner(res["ordner"])
|
||||||
|
|
||||||
@@ -891,10 +968,10 @@ class App:
|
|||||||
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
|
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
|
||||||
if not res:
|
if not res:
|
||||||
return
|
return
|
||||||
if res["klassisch"] != bool(self.settings.get("klassischer_rahmen", True)):
|
if res["design"] != w.DESIGN:
|
||||||
self.settings["klassischer_rahmen"] = res["klassisch"]
|
self.settings["design"] = res["design"]
|
||||||
config.save(self.settings)
|
config.save(self.settings)
|
||||||
w.info(self.root, "Einstellungen", "Die Änderung der Titelleiste wird beim nächsten Programmstart wirksam.")
|
w.info(self.root, "Einstellungen", "Das neue Design wird beim nächsten Programmstart wirksam.")
|
||||||
if res["pfad"] and Path(res["pfad"]) != self.db.path:
|
if res["pfad"] and Path(res["pfad"]) != self.db.path:
|
||||||
try:
|
try:
|
||||||
self.db_wechseln(res["pfad"])
|
self.db_wechseln(res["pfad"])
|
||||||
@@ -919,6 +996,10 @@ class App:
|
|||||||
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat (freenet\n"
|
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat (freenet\n"
|
||||||
" und Vodafone zusammen, Airtime als eine Summenzeile) und eine Airtime-Liste.\n"
|
" und Vodafone zusammen, Airtime als eine Summenzeile) und eine Airtime-Liste.\n"
|
||||||
" Positionen mit 0,00 € werden weggelassen.\n\n"
|
" Positionen mit 0,00 € werden weggelassen.\n\n"
|
||||||
|
"Zeitraum: Im Export-Dialog „Nur von … bis …“ (oder „Vormonat“). Exportierte Positionen\n"
|
||||||
|
"gelten als abgerechnet (grau) und werden nicht noch einmal abgerechnet – auch nicht, wenn\n"
|
||||||
|
"sie in einer späteren Datei wieder auftauchen. Datei > Abrechnung erledigt sperrt eine\n"
|
||||||
|
"fertige Abrechnung.\n\n"
|
||||||
"Welche Provisionsart Airtime bzw. Jahresbonus ist, wird automatisch erkannt –\n"
|
"Welche Provisionsart Airtime bzw. Jahresbonus ist, wird automatisch erkannt –\n"
|
||||||
"umstellbar unter Händler > Provisionsarten.\n\n"
|
"umstellbar unter Händler > Provisionsarten.\n\n"
|
||||||
"Alles wird automatisch in der Datenbank gespeichert.")
|
"Alles wird automatisch in der Datenbank gespeichert.")
|
||||||
|
|||||||
+51
-11
@@ -6,7 +6,8 @@ from tkinter import filedialog, ttk
|
|||||||
|
|
||||||
from provtool import APP_NAME, APP_ORG, __version__
|
from provtool import APP_NAME, APP_ORG, __version__
|
||||||
from provtool import regeln
|
from provtool import regeln
|
||||||
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler
|
from provtool import zeitraum as zr
|
||||||
|
from provtool.db import CAPS_ANBIETER, CAPS_WERT, AbrechnungErledigt, Datenbank, Haendler
|
||||||
from provtool.gui import win95 as w
|
from provtool.gui import win95 as w
|
||||||
from provtool.importer import ANBIETER
|
from provtool.importer import ANBIETER
|
||||||
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
|
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
|
||||||
@@ -561,9 +562,9 @@ class AbrechnungenDialog(w.Dialog):
|
|||||||
super().__init__(master, "Abrechnung öffnen", resizable=True)
|
super().__init__(master, "Abrechnung öffnen", resizable=True)
|
||||||
self.db = db
|
self.db = db
|
||||||
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
tk.Label(self, text="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",
|
r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
|
||||||
height=12, selectmode="browse")
|
show="headings", height=12, selectmode="browse")
|
||||||
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120),
|
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
|
||||||
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
|
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
|
||||||
self.tree.heading(c, text=t)
|
self.tree.heading(c, text=t)
|
||||||
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
|
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
|
||||||
@@ -581,8 +582,9 @@ class AbrechnungenDialog(w.Dialog):
|
|||||||
def _laden(self, sel=None):
|
def _laden(self, sel=None):
|
||||||
self.tree.delete(*self.tree.get_children())
|
self.tree.delete(*self.tree.get_children())
|
||||||
for a in self.db.abrechnungen():
|
for a in self.db.abrechnungen():
|
||||||
|
status = "erledigt" if a["erledigt"] else (f"{a['abgerechnet']} abgerechnet" if a["abgerechnet"] else "offen")
|
||||||
self.tree.insert("", "end", iid=str(a["id"]),
|
self.tree.insert("", "end", iid=str(a["id"]),
|
||||||
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
|
values=(a["titel"], status, a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
|
||||||
kids = self.tree.get_children()
|
kids = self.tree.get_children()
|
||||||
if sel and self.tree.exists(str(sel)):
|
if sel and self.tree.exists(str(sel)):
|
||||||
self.tree.selection_set(str(sel))
|
self.tree.selection_set(str(sel))
|
||||||
@@ -655,7 +657,11 @@ class DateienDialog(w.Dialog):
|
|||||||
name = self.tree.item(s[0], "values")[0]
|
name = self.tree.item(s[0], "values")[0]
|
||||||
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
|
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
|
||||||
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
|
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
|
||||||
self.db.datei_entfernen(int(s[0]))
|
try:
|
||||||
|
self.db.datei_entfernen(int(s[0]))
|
||||||
|
except AbrechnungErledigt as exc:
|
||||||
|
w.info(self, "Datei entfernen", str(exc))
|
||||||
|
return
|
||||||
self.geaendert = True
|
self.geaendert = True
|
||||||
self._laden()
|
self._laden()
|
||||||
|
|
||||||
@@ -900,8 +906,32 @@ class ExportDialog(w.Dialog):
|
|||||||
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid(
|
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid(
|
||||||
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
|
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
|
||||||
g.columnconfigure(1, weight=1)
|
g.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
z = w.gruppe(f, "Zeitraum und Abrechnung")
|
||||||
|
z.pack(fill="x", pady=(8, 0))
|
||||||
|
zf = tk.Frame(z, bg=w.FACE)
|
||||||
|
zf.pack(fill="x")
|
||||||
|
tk.Label(zf, text="Nur von:").pack(side="left")
|
||||||
|
self.v_von, self.v_bis = tk.StringVar(), tk.StringVar()
|
||||||
|
tk.Entry(zf, textvariable=self.v_von, width=11).pack(side="left", padx=4)
|
||||||
|
tk.Label(zf, text="bis:").pack(side="left")
|
||||||
|
tk.Entry(zf, textvariable=self.v_bis, width=11).pack(side="left", padx=4)
|
||||||
|
w.knopf(zf, "Vormonat", self._vormonat).pack(side="left", padx=(4, 0))
|
||||||
|
self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)])
|
||||||
|
ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly",
|
||||||
|
width=40).pack(anchor="w", pady=(4, 0))
|
||||||
|
tk.Label(z, text="(leer = alle offenen Positionen; Datum als TT.MM.JJJJ)", fg=w.SHADOW, anchor="w").pack(
|
||||||
|
fill="x")
|
||||||
|
self.v_kennz = tk.BooleanVar(value=True)
|
||||||
|
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
|
||||||
|
"einmal abgerechnet)", variable=self.v_kennz, anchor="w").pack(fill="x", pady=(4, 0))
|
||||||
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||||
|
|
||||||
|
def _vormonat(self):
|
||||||
|
von, bis = zr.vormonat()
|
||||||
|
self.v_von.set(von.strftime("%d.%m.%Y"))
|
||||||
|
self.v_bis.set(bis.strftime("%d.%m.%Y"))
|
||||||
|
|
||||||
def _klick(self, e):
|
def _klick(self, e):
|
||||||
if self.tree.identify_column(e.x) == "#0":
|
if self.tree.identify_column(e.x) == "#0":
|
||||||
iid = self.tree.identify_row(e.y)
|
iid = self.tree.identify_row(e.y)
|
||||||
@@ -928,6 +958,12 @@ class ExportDialog(w.Dialog):
|
|||||||
if not ids:
|
if not ids:
|
||||||
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
|
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
|
von, bis = zr.datum_lesen(self.v_von.get()), zr.datum_lesen(self.v_bis.get())
|
||||||
|
except ValueError as exc:
|
||||||
|
w.warnung(self, "Zeitraum", str(exc))
|
||||||
|
return
|
||||||
|
basis = next(k for k, v in zr.BASEN.items() if v == self.v_basis.get())
|
||||||
if not self.v_ordner.get().strip():
|
if not self.v_ordner.get().strip():
|
||||||
self._ordner()
|
self._ordner()
|
||||||
if not self.v_ordner.get().strip():
|
if not self.v_ordner.get().strip():
|
||||||
@@ -938,7 +974,10 @@ class ExportDialog(w.Dialog):
|
|||||||
self.settings.pop("export_pro_anbieter", None)
|
self.settings.pop("export_pro_anbieter", None)
|
||||||
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
|
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
|
||||||
pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.get(),
|
pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.get(),
|
||||||
kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get())
|
kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get(),
|
||||||
|
zeitraum=zr.Zeitraum(von, bis, basis) if (von or bis) else None,
|
||||||
|
kennzeichnen=self.v_kennz.get())
|
||||||
|
self.settings["export_basis"] = basis
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
@@ -960,9 +999,10 @@ class EinstellungenDialog(w.Dialog):
|
|||||||
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
|
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
|
||||||
g2 = w.gruppe(self, "Darstellung")
|
g2 = w.gruppe(self, "Darstellung")
|
||||||
g2.pack(fill="x", padx=10, pady=(0, 10))
|
g2.pack(fill="x", padx=10, pady=(0, 10))
|
||||||
self.v_klassisch = tk.BooleanVar(value=bool(settings.get("klassischer_rahmen", True)))
|
self.v_design = tk.StringVar(value=settings.get("design", "modern"))
|
||||||
tk.Checkbutton(g2, text="Windows-95-Titelleiste verwenden (sonst die normale von Windows)",
|
tk.Radiobutton(g2, text="Modern (wie Windows 11)", variable=self.v_design, value="modern").pack(anchor="w")
|
||||||
variable=self.v_klassisch).pack(anchor="w")
|
tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack(
|
||||||
|
anchor="w")
|
||||||
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w")
|
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w")
|
||||||
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||||
|
|
||||||
@@ -974,7 +1014,7 @@ class EinstellungenDialog(w.Dialog):
|
|||||||
self.v.set(p)
|
self.v.set(p)
|
||||||
|
|
||||||
def _ok(self):
|
def _ok(self):
|
||||||
self.result = dict(pfad=self.v.get().strip(), klassisch=self.v_klassisch.get())
|
self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get())
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-8
@@ -51,25 +51,28 @@ class Popup:
|
|||||||
self.leiste = leiste
|
self.leiste = leiste
|
||||||
root = self.master._root()
|
root = self.master._root()
|
||||||
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
|
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
|
||||||
|
if w.MODERN:
|
||||||
|
f.configure(bg=w.WHITE, bd=0, relief="flat", highlightthickness=1, highlightbackground=w.RAND)
|
||||||
f.withdraw()
|
f.withdraw()
|
||||||
f.overrideredirect(True)
|
f.overrideredirect(True)
|
||||||
self.zeilen = []
|
self.zeilen = []
|
||||||
|
grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2)
|
||||||
for e in self.eintraege:
|
for e in self.eintraege:
|
||||||
if e["art"] == "trenner":
|
if e["art"] == "trenner":
|
||||||
t = tk.Frame(f, bg=w.FACE, height=8)
|
t = tk.Frame(f, bg=grund, height=8)
|
||||||
t.pack(fill="x")
|
t.pack(fill="x")
|
||||||
linie = w.trenner(t, vertikal=False)
|
linie = w.trenner(t, vertikal=False)
|
||||||
linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
|
linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
|
||||||
continue
|
continue
|
||||||
zeile = tk.Frame(f, bg=w.FACE)
|
zeile = tk.Frame(f, bg=grund)
|
||||||
zeile.pack(fill="x")
|
zeile.pack(fill="x")
|
||||||
grau = e["state"] == "disabled"
|
grau = e["state"] == "disabled"
|
||||||
text = tk.Label(zeile, text=e["label"], bg=w.FACE, anchor="w", padx=18, pady=2,
|
text = tk.Label(zeile, text=e["label"], bg=grund, anchor="w", padx=18, pady=py,
|
||||||
fg=w.SHADOW if grau else w.DARK)
|
fg=w.SHADOW if grau else w.DARK)
|
||||||
text.pack(side="left", fill="x", expand=True)
|
text.pack(side="left", fill="x", expand=True)
|
||||||
labels = [text]
|
labels = [text]
|
||||||
if e["accelerator"]:
|
if e["accelerator"]:
|
||||||
acc = tk.Label(zeile, text=e["accelerator"], bg=w.FACE, anchor="e", padx=12, pady=2,
|
acc = tk.Label(zeile, text=e["accelerator"], bg=grund, anchor="e", padx=12, pady=py,
|
||||||
fg=w.SHADOW if grau else w.DARK)
|
fg=w.SHADOW if grau else w.DARK)
|
||||||
acc.pack(side="right")
|
acc.pack(side="right")
|
||||||
labels.append(acc)
|
labels.append(acc)
|
||||||
@@ -170,8 +173,8 @@ class Popup:
|
|||||||
self.aktiv = i
|
self.aktiv = i
|
||||||
for j, (e, zeile, labels) in enumerate(self.zeilen):
|
for j, (e, zeile, labels) in enumerate(self.zeilen):
|
||||||
an = j == i and e.get("state") != "disabled"
|
an = j == i and e.get("state") != "disabled"
|
||||||
bg = w.NAVY if an else w.FACE
|
bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE)
|
||||||
fg = w.WHITE if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
|
fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
|
||||||
zeile.configure(bg=bg)
|
zeile.configure(bg=bg)
|
||||||
for lbl in labels:
|
for lbl in labels:
|
||||||
lbl.configure(bg=bg, fg=fg)
|
lbl.configure(bg=bg, fg=fg)
|
||||||
@@ -248,7 +251,7 @@ class Menueleiste(tk.Frame):
|
|||||||
def _darstellen(self):
|
def _darstellen(self):
|
||||||
for k in self.knoepfe.values():
|
for k in self.knoepfe.values():
|
||||||
if k is self.offener_knopf:
|
if k is self.offener_knopf:
|
||||||
k.configure(bg=w.NAVY, fg=w.WHITE, relief="flat")
|
k.configure(bg=w.SEL_BG, fg=w.SEL_FG, relief="flat")
|
||||||
else:
|
else:
|
||||||
k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
|
k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
|
||||||
|
|
||||||
@@ -289,7 +292,10 @@ class Menueleiste(tk.Frame):
|
|||||||
|
|
||||||
def _hover(self, knopf, drin: bool):
|
def _hover(self, knopf, drin: bool):
|
||||||
if self.offener_knopf is None:
|
if self.offener_knopf is None:
|
||||||
knopf.configure(relief="raised" if drin else "flat")
|
if w.MODERN:
|
||||||
|
knopf.configure(bg=w.LIGHT if drin else w.FACE)
|
||||||
|
else:
|
||||||
|
knopf.configure(relief="raised" if drin else "flat")
|
||||||
|
|
||||||
def _alt(self, knopf):
|
def _alt(self, knopf):
|
||||||
self.oeffnen(knopf)
|
self.oeffnen(knopf)
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ class Hauptrahmen:
|
|||||||
self.maximiert = False
|
self.maximiert = False
|
||||||
self._normal_geo: str | None = None
|
self._normal_geo: str | None = None
|
||||||
self._schliessen = schliessen
|
self._schliessen = schliessen
|
||||||
|
self.nativ = None
|
||||||
root.minsize(minbreite, minhoehe)
|
root.minsize(minbreite, minhoehe)
|
||||||
if not klassisch:
|
if not klassisch:
|
||||||
self.aussen = tk.Frame(root, bg=w.FACE)
|
self.aussen = tk.Frame(root, bg=w.FACE)
|
||||||
@@ -266,7 +267,6 @@ class Hauptrahmen:
|
|||||||
self.aussen.bind("<B1-Motion>", self._resize)
|
self.aussen.bind("<B1-Motion>", self._resize)
|
||||||
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
|
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
|
||||||
|
|
||||||
self.nativ = None
|
|
||||||
if IS_WIN and NATIVER_RAHMEN:
|
if IS_WIN and NATIVER_RAHMEN:
|
||||||
from provtool.gui.nativ import NativerRahmen
|
from provtool.gui.nativ import NativerRahmen
|
||||||
self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
|
self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
|
||||||
|
|||||||
+115
-5
@@ -1,4 +1,8 @@
|
|||||||
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine."""
|
"""Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.
|
||||||
|
|
||||||
|
Zwei Designs: „modern“ (hell und flach, wie Windows 11 – Standard) und „win95“ (klassisch, Legacy).
|
||||||
|
design_setzen() muss VOR dem Aufbau der Oberfläche laufen; alle Module lesen die Farben als w.FACE usw.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
@@ -14,6 +18,19 @@ NAVY = "#000080"
|
|||||||
TEAL = "#008080"
|
TEAL = "#008080"
|
||||||
WHITE = "#ffffff"
|
WHITE = "#ffffff"
|
||||||
INFO_BG = "#ffffe1"
|
INFO_BG = "#ffffe1"
|
||||||
|
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
|
||||||
|
RAND = SHADOW # dünne Rahmenlinien (nur modern)
|
||||||
|
DESIGN = "win95"
|
||||||
|
MODERN = False
|
||||||
|
|
||||||
|
_PALETTEN = {
|
||||||
|
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
|
||||||
|
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
|
||||||
|
SEL_FG="#ffffff", RAND="#808080"),
|
||||||
|
"modern": dict(FACE="#f3f3f3", HILITE="#ffffff", LIGHT="#ebebeb", SHADOW="#8a8a8a", DARK="#1b1b1b",
|
||||||
|
NAVY="#005fb8", TEAL="#e6eef6", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#cce4f7",
|
||||||
|
SEL_FG="#1b1b1b", RAND="#d1d1d1"),
|
||||||
|
}
|
||||||
|
|
||||||
IS_WIN = sys.platform.startswith("win")
|
IS_WIN = sys.platform.startswith("win")
|
||||||
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
|
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
|
||||||
@@ -23,6 +40,24 @@ FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
|
|||||||
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
|
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
|
||||||
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
|
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def design_setzen(name: str) -> None:
|
||||||
|
"""„modern“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
|
||||||
|
global DESIGN, MODERN, FONT_FAMILY, FONT_SIZE
|
||||||
|
DESIGN = name if name in _PALETTEN else "modern"
|
||||||
|
MODERN = DESIGN == "modern"
|
||||||
|
globals().update(_PALETTEN[DESIGN])
|
||||||
|
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
|
||||||
|
FONT_SIZE = 9 if (MODERN or not IS_WIN) else 8
|
||||||
|
_schriften()
|
||||||
|
|
||||||
|
|
||||||
|
def _schriften():
|
||||||
|
global FONT, FONT_BOLD, FONT_BIG
|
||||||
|
FONT = (FONT_FAMILY, FONT_SIZE)
|
||||||
|
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
|
||||||
|
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
|
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
|
||||||
PALETTE = {
|
PALETTE = {
|
||||||
"k": "#000000", "w": "#ffffff", "g": "#c0c0c0", "d": "#808080", "y": "#ffff00", "Y": "#808000",
|
"k": "#000000", "w": "#ffffff", "g": "#c0c0c0", "d": "#808080", "y": "#ffff00", "Y": "#808000",
|
||||||
@@ -288,6 +323,13 @@ class Icons:
|
|||||||
# --------------------------------------------------------------------------- Theme
|
# --------------------------------------------------------------------------- Theme
|
||||||
|
|
||||||
def apply(root: tk.Tk) -> None:
|
def apply(root: tk.Tk) -> None:
|
||||||
|
global FONT_FAMILY
|
||||||
|
if MODERN and not IS_WIN: # Linux: die erste vorhandene moderne Schrift
|
||||||
|
from tkinter import font as tkfont
|
||||||
|
vorhanden = set(tkfont.families(root))
|
||||||
|
FONT_FAMILY = next((f for f in ("Segoe UI", "Noto Sans", "Cantarell", "Ubuntu", "DejaVu Sans")
|
||||||
|
if f in vorhanden), FONT_FAMILY)
|
||||||
|
_schriften()
|
||||||
root.configure(bg=FACE)
|
root.configure(bg=FACE)
|
||||||
o = root.option_add
|
o = root.option_add
|
||||||
o("*Font", FONT)
|
o("*Font", FONT)
|
||||||
@@ -343,6 +385,45 @@ def apply(root: tk.Tk) -> None:
|
|||||||
selectforeground=[("readonly", DARK)])
|
selectforeground=[("readonly", DARK)])
|
||||||
st.configure("TPanedwindow", background=FACE)
|
st.configure("TPanedwindow", background=FACE)
|
||||||
st.configure("Sash", sashthickness=6, background=FACE)
|
st.configure("Sash", sashthickness=6, background=FACE)
|
||||||
|
if MODERN:
|
||||||
|
_modern(root, st, zeile)
|
||||||
|
|
||||||
|
|
||||||
|
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
|
||||||
|
"""Flach und hell: dünne graue Rahmen statt 3D-Kanten, Akzentfarbe beim Fokus."""
|
||||||
|
o = root.option_add
|
||||||
|
for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
|
||||||
|
o(f"*{klasse}.relief", "flat")
|
||||||
|
o(f"*{klasse}.borderWidth", 3)
|
||||||
|
o(f"*{klasse}.highlightThickness", 1)
|
||||||
|
o(f"*{klasse}.highlightBackground", RAND)
|
||||||
|
o(f"*{klasse}.highlightColor", NAVY)
|
||||||
|
o("*Button.relief", "flat")
|
||||||
|
o("*Button.borderWidth", 0)
|
||||||
|
o("*Button.highlightThickness", 1)
|
||||||
|
o("*Button.highlightBackground", RAND)
|
||||||
|
o("*Button.background", WHITE)
|
||||||
|
o("*Button.activeBackground", SEL_BG)
|
||||||
|
o("*Button.padY", 3)
|
||||||
|
o("*Checkbutton.activeBackground", FACE)
|
||||||
|
o("*Radiobutton.activeBackground", FACE)
|
||||||
|
o("*Menu.relief", "flat")
|
||||||
|
st.theme_use("clam")
|
||||||
|
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
|
||||||
|
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=WHITE,
|
||||||
|
darkcolor=RAND, focuscolor=NAVY)
|
||||||
|
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile + 4, borderwidth=0, font=FONT)
|
||||||
|
st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)])
|
||||||
|
st.configure("Treeview.Heading", background=WHITE, relief="flat", borderwidth=1, font=FONT, padding=(6, 3),
|
||||||
|
bordercolor=RAND, lightcolor=WHITE, darkcolor=RAND)
|
||||||
|
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
|
||||||
|
st.configure("TScrollbar", background=LIGHT, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE,
|
||||||
|
lightcolor=LIGHT, darkcolor=LIGHT, relief="flat", arrowsize=12, width=12)
|
||||||
|
st.map("TScrollbar", background=[("active", RAND)])
|
||||||
|
st.configure("TCombobox", fieldbackground=WHITE, background=WHITE, arrowcolor=DARK, padding=3,
|
||||||
|
bordercolor=RAND, lightcolor=WHITE, darkcolor=WHITE)
|
||||||
|
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
|
||||||
|
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- Wort löschen
|
# --------------------------------------------------------------------------- Wort löschen
|
||||||
@@ -423,6 +504,8 @@ class Sunken(tk.Frame):
|
|||||||
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
|
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
|
||||||
|
|
||||||
def __init__(self, master, **kw):
|
def __init__(self, master, **kw):
|
||||||
|
if MODERN:
|
||||||
|
kw.update(bd=0, relief="flat", highlightthickness=1, highlightbackground=RAND)
|
||||||
kw.setdefault("bd", 2)
|
kw.setdefault("bd", 2)
|
||||||
kw.setdefault("relief", "sunken")
|
kw.setdefault("relief", "sunken")
|
||||||
super().__init__(master, **kw)
|
super().__init__(master, **kw)
|
||||||
@@ -432,6 +515,15 @@ class Fenster(tk.Frame):
|
|||||||
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
|
"""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):
|
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
|
||||||
|
if MODERN: # schlichte Überschrift statt blauer Titelleiste
|
||||||
|
super().__init__(master, bd=0, bg=FACE, **kw)
|
||||||
|
self.titel_var = tk.StringVar(value=titel)
|
||||||
|
self.titelleiste = tk.Label(self, textvariable=self.titel_var, bg=FACE, fg=DARK, font=FONT_BOLD,
|
||||||
|
anchor="w")
|
||||||
|
self.titelleiste.pack(fill="x", pady=(0, 4))
|
||||||
|
self.inhalt = tk.Frame(self, bg=FACE)
|
||||||
|
self.inhalt.pack(fill="both", expand=True)
|
||||||
|
return
|
||||||
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
|
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
|
||||||
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
|
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
|
||||||
self.titelleiste.pack(fill="x", padx=1, pady=1)
|
self.titelleiste.pack(fill="x", padx=1, pady=1)
|
||||||
@@ -453,7 +545,7 @@ class Statusleiste(tk.Frame):
|
|||||||
self.vars: dict[str, tk.StringVar] = {}
|
self.vars: dict[str, tk.StringVar] = {}
|
||||||
for i, (key, breite) in enumerate(felder):
|
for i, (key, breite) in enumerate(felder):
|
||||||
v = tk.StringVar()
|
v = tk.StringVar()
|
||||||
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4)
|
lbl = tk.Label(self, textvariable=v, bd=1, relief="flat" if MODERN else "sunken", anchor="w", padx=4)
|
||||||
if breite:
|
if breite:
|
||||||
lbl.configure(width=breite)
|
lbl.configure(width=breite)
|
||||||
lbl.pack(side="left", padx=(0, 2), pady=2)
|
lbl.pack(side="left", padx=(0, 2), pady=2)
|
||||||
@@ -466,8 +558,11 @@ class Statusleiste(tk.Frame):
|
|||||||
|
|
||||||
|
|
||||||
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
|
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
|
||||||
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
|
if MODERN:
|
||||||
activebackground=FACE, **kw)
|
b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern()
|
||||||
|
else:
|
||||||
|
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
|
||||||
|
activebackground=FACE, **kw)
|
||||||
if icon is not None:
|
if icon is not None:
|
||||||
b.configure(image=icon, compound="left", padx=6)
|
b.configure(image=icon, compound="left", padx=6)
|
||||||
if breite:
|
if breite:
|
||||||
@@ -482,6 +577,15 @@ class ToolbarButton(tk.Label):
|
|||||||
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
|
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
|
||||||
padx=6, pady=2, font=FONT)
|
padx=6, pady=2, font=FONT)
|
||||||
self.command = command
|
self.command = command
|
||||||
|
if MODERN: # Hervorhebung durch Farbe statt 3D-Kante
|
||||||
|
self.configure(bd=0, padx=8, pady=4)
|
||||||
|
self.bind("<Enter>", lambda e: self.configure(bg=LIGHT))
|
||||||
|
self.bind("<Leave>", lambda e: self.configure(bg=FACE))
|
||||||
|
self.bind("<ButtonPress-1>", lambda e: self.configure(bg=SEL_BG))
|
||||||
|
self.bind("<ButtonRelease-1>", self._release)
|
||||||
|
if tooltip:
|
||||||
|
Tooltip(self, tooltip)
|
||||||
|
return
|
||||||
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
|
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
|
||||||
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
|
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
|
||||||
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
|
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
|
||||||
@@ -490,7 +594,7 @@ class ToolbarButton(tk.Label):
|
|||||||
Tooltip(self, tooltip)
|
Tooltip(self, tooltip)
|
||||||
|
|
||||||
def _release(self, e):
|
def _release(self, e):
|
||||||
self.configure(relief="raised")
|
self.configure(bg=LIGHT) if MODERN else self.configure(relief="raised")
|
||||||
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
|
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
|
||||||
self.command()
|
self.command()
|
||||||
|
|
||||||
@@ -528,6 +632,10 @@ class Tooltip:
|
|||||||
|
|
||||||
def trenner(master, vertikal: bool = True):
|
def trenner(master, vertikal: bool = True):
|
||||||
f = tk.Frame(master, bg=FACE)
|
f = tk.Frame(master, bg=FACE)
|
||||||
|
if MODERN:
|
||||||
|
tk.Frame(f, width=1, height=1, bg=RAND).pack(side="left", fill="y") if vertikal else \
|
||||||
|
tk.Frame(f, height=1, bg=RAND).pack(fill="x")
|
||||||
|
return f
|
||||||
if vertikal:
|
if vertikal:
|
||||||
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
|
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
|
||||||
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
|
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
|
||||||
@@ -538,6 +646,8 @@ def trenner(master, vertikal: bool = True):
|
|||||||
|
|
||||||
|
|
||||||
def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
|
def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
|
||||||
|
if MODERN:
|
||||||
|
return tk.LabelFrame(master, text=titel, bd=0, relief="flat", bg=FACE, padx=4, pady=2, font=FONT_BOLD, **kw)
|
||||||
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
|
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ def ausfuehren(ergebnisdatei: str) -> int:
|
|||||||
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
|
# 2) Assistent komplett, Import, Export, Rundgang, Beenden
|
||||||
csv = basis / "freenet.csv"
|
csv = basis / "freenet.csv"
|
||||||
_beispiel_csv(csv)
|
_beispiel_csv(csv)
|
||||||
s = _Szenario("Assistent, Import, Export", basis / "b")
|
# Win95-Design: prüft auch den nativen Fensterrahmen (Szenario 1 läuft im modernen Standard)
|
||||||
|
s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95"}')
|
||||||
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
|
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
|
||||||
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
|
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Zeitraum für Ansicht und Export: nur Positionen von–bis abrechnen.
|
||||||
|
|
||||||
|
Bezugsdatum einer Position („basis“):
|
||||||
|
- ``aktivierung`` – Aktivierungs-/Anschaltdatum der Position (Spalte „Datum“)
|
||||||
|
- ``abrechnung`` – Abrechnungszeitraum des Anbieters: bei freenet der Beginn der Abrechnungswoche
|
||||||
|
(„2026.10.01-2026.10.07“), bei Vodafone der Monat des Abrechnungslaufs („VF2609“,
|
||||||
|
„CY202609UM“ -> 1.9.2026). Ist er nicht lesbar, gilt das Aktivierungsdatum.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
AKTIVIERUNG, ABRECHNUNG = "aktivierung", "abrechnung"
|
||||||
|
BASEN = {AKTIVIERUNG: "nach Aktivierungsdatum", ABRECHNUNG: "nach Abrechnungszeitraum des Anbieters"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Zeitraum:
|
||||||
|
von: date | None = None
|
||||||
|
bis: date | None = None
|
||||||
|
basis: str = AKTIVIERUNG
|
||||||
|
|
||||||
|
def enthaelt(self, p: dict) -> bool:
|
||||||
|
d = bezugsdatum(p, self.basis)
|
||||||
|
if d is None:
|
||||||
|
return False # ohne Datum lässt sich nicht sagen, ob sie dazugehört
|
||||||
|
return (self.von is None or d >= self.von) and (self.bis is None or d <= self.bis)
|
||||||
|
|
||||||
|
def text(self) -> str:
|
||||||
|
von = self.von.strftime("%d.%m.%Y") if self.von else "Anfang"
|
||||||
|
bis = self.bis.strftime("%d.%m.%Y") if self.bis else "heute"
|
||||||
|
return f"{von} – {bis} ({BASEN[self.basis]})"
|
||||||
|
|
||||||
|
|
||||||
|
def datum_lesen(text: str) -> date | None:
|
||||||
|
"""'1.10.2026', '01.10.26', '2026-10-01' -> date; leer -> None; sonst ValueError."""
|
||||||
|
s = (text or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
m = re.fullmatch(r"(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})", s)
|
||||||
|
if m:
|
||||||
|
t, mo, j = (int(x) for x in m.groups())
|
||||||
|
if j < 100:
|
||||||
|
j += 2000
|
||||||
|
return date(j, mo, t)
|
||||||
|
m = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", s)
|
||||||
|
if m:
|
||||||
|
return date(*(int(x) for x in m.groups()))
|
||||||
|
raise ValueError(f"Kein gültiges Datum: {text!r} (bitte TT.MM.JJJJ)")
|
||||||
|
|
||||||
|
|
||||||
|
def abrechnungsdatum(zeitraum: str | None) -> date | None:
|
||||||
|
s = (zeitraum or "").strip()
|
||||||
|
m = re.match(r"(\d{4})\.(\d{2})\.(\d{2})", s) # freenet: 2026.10.01-2026.10.07
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return date(*(int(x) for x in m.groups()))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
m = re.match(r"(?:VF|CY)(\d{4}|\d{2})(\d{2})", s.upper()) # Vodafone: VF2609, CY202609UM
|
||||||
|
if m:
|
||||||
|
j, mo = int(m.group(1)), int(m.group(2))
|
||||||
|
if j < 100:
|
||||||
|
j += 2000
|
||||||
|
if 1 <= mo <= 12:
|
||||||
|
return date(j, mo, 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bezugsdatum(p: dict, basis: str) -> date | None:
|
||||||
|
if basis == ABRECHNUNG:
|
||||||
|
d = abrechnungsdatum(p.get("zeitraum"))
|
||||||
|
if d is not None:
|
||||||
|
return d
|
||||||
|
if p.get("datum"):
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(p["datum"])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def vormonat(heute: date | None = None) -> tuple[date, date]:
|
||||||
|
heute = heute or date.today()
|
||||||
|
ende = heute.replace(day=1) - timedelta(days=1)
|
||||||
|
return ende.replace(day=1), ende
|
||||||
|
|
||||||
|
|
||||||
|
def dieser_monat(heute: date | None = None) -> tuple[date, date]:
|
||||||
|
heute = heute or date.today()
|
||||||
|
naechster = (heute.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||||
|
return heute.replace(day=1), naechster - timedelta(days=1)
|
||||||
+69
-5
@@ -203,9 +203,7 @@ def home(tmp_path, monkeypatch):
|
|||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def _profil_mit_daten(home):
|
||||||
def lauf_mit_daten(home):
|
|
||||||
"""App mit einem Händler und einer Abrechnung (erfundene Daten)."""
|
|
||||||
from tests.test_kern import FREENET_KOPF, freenet_zeile
|
from tests.test_kern import FREENET_KOPF, freenet_zeile
|
||||||
csv = home / "freenet.csv"
|
csv = home / "freenet.csv"
|
||||||
csv.write_bytes(("\r\n".join([
|
csv.write_bytes(("\r\n".join([
|
||||||
@@ -219,6 +217,21 @@ def lauf_mit_daten(home):
|
|||||||
from provtool.importer import lies_datei
|
from provtool.importer import lies_datei
|
||||||
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
|
db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv))
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lauf_mit_daten(home):
|
||||||
|
"""App mit einem Händler und einer Abrechnung (erfundene Daten), klassisches Win95-Design."""
|
||||||
|
_profil_mit_daten(home)
|
||||||
|
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": "win95"})
|
||||||
|
yield lauf
|
||||||
|
lauf.ende()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lauf_modern(home):
|
||||||
|
"""Wie lauf_mit_daten, aber im modernen Design (Standard)."""
|
||||||
|
_profil_mit_daten(home)
|
||||||
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
|
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False})
|
||||||
yield lauf
|
yield lauf
|
||||||
lauf.ende()
|
lauf.ende()
|
||||||
@@ -324,8 +337,10 @@ def _dialog_oeffner():
|
|||||||
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
|
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
|
||||||
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
|
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
|
||||||
"staffel"])
|
"staffel"])
|
||||||
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
|
@pytest.mark.parametrize("design", ["win95", "modern"])
|
||||||
lauf = lauf_mit_daten
|
def test_schliessen_waehrend_dialog_offen(home, name, design):
|
||||||
|
_profil_mit_daten(home)
|
||||||
|
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": design})
|
||||||
oeffnen = _dialog_oeffner()[name]
|
oeffnen = _dialog_oeffner()[name]
|
||||||
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
|
lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"),
|
||||||
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
|
(lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")])
|
||||||
@@ -1052,3 +1067,52 @@ def test_rand_druecken_startet_natives_ziehen(lauf_mit_daten, monkeypatch):
|
|||||||
r._resize_start(ev)
|
r._resize_start(ev)
|
||||||
assert gezogen == ["w", "se"]
|
assert gezogen == ["w", "se"]
|
||||||
lauf.pruefen()
|
lauf.pruefen()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modern_ist_standard(lauf_modern):
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
lauf = lauf_modern
|
||||||
|
assert w.DESIGN == "modern" and not lauf.app.rahmen.klassisch and lauf.app.rahmen.titelleiste is None
|
||||||
|
lauf.ablauf([(IMMER, lambda: lauf.app.menueleiste._klick(lauf.app.menueleiste.knoepfe["Datei"]), "Menü"),
|
||||||
|
(lambda: lauf.app.menueleiste.offener_knopf is not None,
|
||||||
|
lambda: lauf.app.menueleiste.schliessen(), "zu")])
|
||||||
|
assert not lauf.uebrig, lauf.diagnose()
|
||||||
|
lauf.pruefen()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkeypatch):
|
||||||
|
"""Zeitraum abrechnen, exportierte Positionen nicht erneut exportieren, Abrechnung erledigt sperrt."""
|
||||||
|
from provtool.gui import win95 as w
|
||||||
|
from provtool.gui.dialogs import ExportDialog
|
||||||
|
lauf = lauf_modern
|
||||||
|
app = lauf.app
|
||||||
|
app.settings["export_ordner"] = str(tmp_path / "x")
|
||||||
|
meldungen = []
|
||||||
|
monkeypatch.setattr(w, "info", lambda m, t, text: meldungen.append(text))
|
||||||
|
monkeypatch.setattr(w, "frage", lambda *a, **k: True)
|
||||||
|
|
||||||
|
def ok(von="", bis=""):
|
||||||
|
d = lauf.dialog(ExportDialog)
|
||||||
|
d.v_von.set(von)
|
||||||
|
d.v_bis.set(bis)
|
||||||
|
d.v_auf.set(False)
|
||||||
|
d._ok()
|
||||||
|
|
||||||
|
lauf.ablauf([
|
||||||
|
(IMMER, app.exportieren, "Export Oktober 6."),
|
||||||
|
(lambda: lauf.dialog(ExportDialog) is not None, lambda: ok("01.10.2026", "05.10.2026"), "nur bis 5.10."),
|
||||||
|
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "Export Rest"),
|
||||||
|
(lambda: lauf.dialog(ExportDialog) is not None, ok, "alles Offene"),
|
||||||
|
(lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "nochmal"),
|
||||||
|
])
|
||||||
|
assert not lauf.uebrig, lauf.diagnose()
|
||||||
|
assert sum(1 for p in app.positionen if p["abgerechnet"]) == 2
|
||||||
|
assert "1 Position(en) als abgerechnet" in meldungen[0] and "1 Position(en) als abgerechnet" in meldungen[1]
|
||||||
|
assert "bereits abgerechnet" in meldungen[2], meldungen
|
||||||
|
# erledigt: Änderungen gesperrt
|
||||||
|
app.erledigt_umschalten()
|
||||||
|
assert app.db.erledigt(app.aid)
|
||||||
|
app.ptree.selection_set(app.ptree.get_children()[0])
|
||||||
|
app.prozent()
|
||||||
|
assert "erledigt" in meldungen[-1]
|
||||||
|
lauf.pruefen()
|
||||||
@@ -419,3 +419,99 @@ def test_migration_v2_haendler_ohne_airtime(tmp_path):
|
|||||||
d.standard_staffel_setzen([(1, 100), (5, None)])
|
d.standard_staffel_setzen([(1, 100), (5, None)])
|
||||||
assert d.staffel_fuer(h.id) == [(1, 100), (5, None)]
|
assert d.staffel_fuer(h.id) == [(1, 100), (5, None)]
|
||||||
d.close()
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Zeitraum / abgerechnet / erledigt
|
||||||
|
|
||||||
|
def test_zeitraum_lesen_und_bezugsdatum():
|
||||||
|
from datetime import date
|
||||||
|
from provtool import zeitraum as zr
|
||||||
|
assert zr.datum_lesen("1.10.2026") == date(2026, 10, 1) and zr.datum_lesen("01.10.26") == date(2026, 10, 1)
|
||||||
|
assert zr.datum_lesen("") is None
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
zr.datum_lesen("32.13.2026")
|
||||||
|
assert zr.abrechnungsdatum("2026.10.08-2026.10.15") == date(2026, 10, 8)
|
||||||
|
assert zr.abrechnungsdatum("VF2609") == date(2026, 9, 1) == zr.abrechnungsdatum("CY202609UM")
|
||||||
|
assert zr.abrechnungsdatum("VF2609P6 Fil 0") == date(2026, 9, 1)
|
||||||
|
p = {"zeitraum": "VF2609", "datum": "2026-08-30"}
|
||||||
|
assert zr.bezugsdatum(p, zr.AKTIVIERUNG) == date(2026, 8, 30)
|
||||||
|
assert zr.bezugsdatum(p, zr.ABRECHNUNG) == date(2026, 9, 1)
|
||||||
|
okt = zr.Zeitraum(date(2026, 10, 1), date(2026, 10, 31))
|
||||||
|
assert okt.enthaelt({"datum": "2026-10-31"}) and not okt.enthaelt({"datum": "2026-11-01"})
|
||||||
|
assert not okt.enthaelt({"datum": None})
|
||||||
|
assert zr.vormonat(date(2026, 3, 15)) == (date(2026, 2, 1), date(2026, 2, 28))
|
||||||
|
assert zr.dieser_monat(date(2026, 12, 5)) == (date(2026, 12, 1), date(2026, 12, 31))
|
||||||
|
|
||||||
|
|
||||||
|
def test_abgerechnet_einfrieren_und_doppelt_erkennen(db, freenet_csv):
|
||||||
|
from provtool.db import AbrechnungErledigt
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
|
||||||
|
a1 = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(a1, lies_datei(freenet_csv))
|
||||||
|
pos = [p for p in db.positionen(a1) if p["haendler_id"] == hid]
|
||||||
|
assert len(pos) == 4 and all(p["fp"] for p in pos)
|
||||||
|
erste = pos[:2]
|
||||||
|
assert db.abrechnen([p["id"] for p in erste]) == 2
|
||||||
|
# abgerechnet: Betrag und Händler eingefroren
|
||||||
|
h = db.haendler(hid)
|
||||||
|
h.standard_prozent = 50
|
||||||
|
db.haendler_speichern(h, []) # Kennung weg, 50 %
|
||||||
|
db.neu_berechnen(a1)
|
||||||
|
nach = {p["id"]: p for p in db.positionen(a1)}
|
||||||
|
for p in erste:
|
||||||
|
assert nach[p["id"]]["haendler_id"] == hid and nach[p["id"]]["betrag"] == p["betrag"]
|
||||||
|
with pytest.raises(AbrechnungErledigt):
|
||||||
|
db.betrag_setzen(erste[0]["id"], 1)
|
||||||
|
assert db.prozent_anwenden([erste[0]["id"]], 10) == 0
|
||||||
|
|
||||||
|
# dieselbe Datei in einer neuen Abrechnung: die beiden abgerechneten Positionen sind „doppelt“
|
||||||
|
db.haendler_speichern(db.haendler(hid), [("freenet", "111")])
|
||||||
|
a2 = db.abrechnung_neu("Nov")
|
||||||
|
db.importieren(a2, lies_datei(freenet_csv))
|
||||||
|
doppelt = [p for p in db.positionen(a2) if p["doppelt"]]
|
||||||
|
assert len(doppelt) == 2 and all(p["doppelt"] == "Okt" for p in doppelt)
|
||||||
|
assert db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden"
|
||||||
|
|
||||||
|
# Kennzeichnung aufheben -> nicht mehr doppelt
|
||||||
|
assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2
|
||||||
|
assert not any(p["doppelt"] for p in db.positionen(a2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_abrechnung_erledigt_sperrt(db, freenet_csv):
|
||||||
|
from provtool.db import AbrechnungErledigt
|
||||||
|
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
|
||||||
|
aid = db.abrechnung_neu("Okt")
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
assert db.erledigt_setzen(aid, True) == 4 # nur die 4 Händler-Positionen, nicht die unzugeordnete
|
||||||
|
assert db.erledigt(aid)
|
||||||
|
assert all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid)
|
||||||
|
with pytest.raises(AbrechnungErledigt):
|
||||||
|
db.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
with pytest.raises(AbrechnungErledigt):
|
||||||
|
db.datei_entfernen(db.dateien(aid)[0]["id"])
|
||||||
|
unz = next(p for p in db.positionen(aid) if p["haendler_id"] <= 0)
|
||||||
|
db.haendler_zuweisen([unz["id"]], hid)
|
||||||
|
assert next(p for p in db.positionen(aid) if p["id"] == unz["id"])["haendler_id"] <= 0, "erledigt: keine Änderung"
|
||||||
|
assert db.abrechnungen()[0]["abgerechnet"] == 4
|
||||||
|
# wieder öffnen, Positionen bleiben abgerechnet bzw. werden auf Wunsch freigegeben
|
||||||
|
db.erledigt_setzen(aid, False)
|
||||||
|
assert not db.erledigt(aid) and all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid)
|
||||||
|
db.erledigt_setzen(aid, True)
|
||||||
|
db.erledigt_setzen(aid, False, positionen_aufheben=True)
|
||||||
|
assert not any(p["abgerechnet"] for p in db.positionen(aid))
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_v3_fingerabdruck(tmp_path, freenet_csv):
|
||||||
|
import sqlite3
|
||||||
|
d = Datenbank(tmp_path / "x.db")
|
||||||
|
aid = d.abrechnung_neu("Alt")
|
||||||
|
d.importieren(aid, lies_datei(freenet_csv))
|
||||||
|
d.close()
|
||||||
|
con = sqlite3.connect(tmp_path / "x.db") # wie eine v3-Datenbank: ohne Fingerabdrücke
|
||||||
|
con.execute("UPDATE position SET fp=NULL")
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
d = Datenbank(tmp_path / "x.db")
|
||||||
|
assert all(p["fp"] for p in d.positionen(aid))
|
||||||
|
assert len({p["fp"] for p in d.positionen(aid)}) == 5
|
||||||
|
d.close()
|
||||||
Reference in new issue
Block a user