Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0880ab2d42 | ||
|
|
c0b740db06 | ||
|
|
03c5eaf5da | ||
|
|
dcb1d0378c | ||
|
|
2b2d5e8580 | ||
|
|
86192c853d | ||
|
|
bd45ffea17 | ||
|
|
26ac90bb0f |
No files matched your search
@@ -14,6 +14,7 @@ jobs:
|
||||
windows-exe:
|
||||
# Label ggf. an euren Runner anpassen (Runner braucht Docker; Wine steckt im Container-Image).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30 # normal ca. 5 Minuten – ein Hänger darf den Runner nicht stundenlang belegen
|
||||
container:
|
||||
image: tobix/pywine:3.12
|
||||
env:
|
||||
|
||||
@@ -18,12 +18,18 @@ normale Windows-Titelleiste verwendet.
|
||||
|
||||
## Bedienung in Kürze
|
||||
|
||||
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*:
|
||||
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in %, **Airtime in %** (0 % = keine
|
||||
Airtime) und die *Kennungen*:
|
||||
- freenet → Spalte **Unterhaendler** (z. B. `11111102`)
|
||||
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder**
|
||||
„Vodafone (Name in GROSSBUCHSTABEN)“: alle Vodafone-Positionen, deren Kundenname komplett
|
||||
großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur ein
|
||||
Händler haben und hat Vorrang vor der Vo-Nr.
|
||||
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder** das Häkchen
|
||||
„Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN“: alle Vodafone-Positionen, deren Kundenname
|
||||
komplett großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur
|
||||
ein Händler haben und hat Vorrang vor der Vo-Nr.
|
||||
- **Jahresbonus Neuaktivierung**: Betrag je Karte nach Staffel, abhängig von der Anzahl Karten des
|
||||
Händlers in der Abrechnung. Standard (änderbar unter *Händler ▸ Standard-Staffel Jahresbonus…*):
|
||||
1–9 Karten 5 €, 10–14 10 €, 15–19 15 €, 20–24 20 €, 25–29 25 €, 30–34 30 €, 35–39 35 € je Karte,
|
||||
ab 40 der volle Betrag. Je Händler kann eine eigene Staffel hinterlegt werden. Nie mehr als die
|
||||
Provision laut Abrechnung; Stornos zählen ab.
|
||||
Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern.
|
||||
Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…**
|
||||
(feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“).
|
||||
@@ -32,14 +38,22 @@ normale Windows-Titelleiste verwendet.
|
||||
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
|
||||
Doppelt importierte Dateien werden erkannt. Unbekannte Kennungen erscheinen unter
|
||||
„(Nicht zugeordnet)“ und lassen sich direkt einem Händler zuordnen.
|
||||
4. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach
|
||||
4. **Eigene Filiale ausblenden**: Positionen eurer eigenen Kennungen (z. B. freenet `11111100`,
|
||||
Vodafone `80000001`) müssen nicht verprovisioniert werden. Rechtsklick auf eine nicht zugeordnete
|
||||
Gruppe ▸ *Kennung als eigene ausblenden* (oder *Händler ▸ Eigene Kennungen…* bzw. direkt im
|
||||
Import-Bericht). Sie verschwinden aus Liste, Summen und Export und stehen grau unter
|
||||
„(Eigene – ausgeblendet)“. Händler-Zuordnungen (auch GROSSBUCHSTABEN) haben Vorrang.
|
||||
5. **Prüfen und anpassen**: links Händler wählen, rechts sind die Positionen nach
|
||||
Provisionsart gruppiert.
|
||||
- Gruppe markieren → **Prozent…** (`F4`): Auszahlung für die ganze Provisionsart; auf Wunsch
|
||||
als feste Regel für diesen Händler gespeichert (gilt dann für künftige Importe).
|
||||
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
|
||||
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
|
||||
- Rechtsklick: u. a. „Auszahlung 0 €“.
|
||||
5. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage:
|
||||
6. **Export** (`Strg+E`): je Händler eine `.xlsx` im Format der Vorlage – freenet und Vodafone
|
||||
zusammen (auf Wunsch getrennt), **Airtime nur als eine Summenzeile** „Airtime“ – plus eine
|
||||
eigene **Airtime-Liste** (`<Händler> - Airtime - <Titel>.xlsx`). Positionen mit 0,00 € werden
|
||||
weggelassen (abschaltbar):
|
||||
|
||||
| A | B | C | D | E | F | G | H | I | J | K | L | M | N |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
@@ -48,6 +62,14 @@ normale Windows-Titelleiste verwendet.
|
||||
Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile.
|
||||
Kopfzeile optional zuschaltbar.
|
||||
|
||||
Welche Provisionsart Airtime bzw. Jahresbonus ist, erkennt das Programm an den Texten (z. B.
|
||||
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
|
||||
*Händler ▸ Provisionsarten…*.
|
||||
|
||||
Das Hauptfenster ist unter Windows ein echtes Programmfenster mit Win95-Optik: an den Bildschirmrand
|
||||
ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
|
||||
wie gewohnt.
|
||||
|
||||
Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
|
||||
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ $PY -m pip install --disable-pip-version-check -q -r requirements-dev.txt
|
||||
|
||||
echo "== Tests inkl. Oberfläche (Windows-Python unter Wine) =="
|
||||
# PROVTOOL_GUI_TESTS=1: fehlt der Bildschirm, ist das ein Fehler (nicht „übersprungen“)
|
||||
PROVTOOL_GUI_TESTS=1 $PY -m pytest -q -p no:cacheprovider
|
||||
# -v: im Log ist live sichtbar, welcher Test läuft; timeout: Obergrenze für alle Tests zusammen
|
||||
PROVTOOL_GUI_TESTS=1 timeout 900 $PY -m pytest -v -p no:cacheprovider
|
||||
|
||||
echo "== Version =="
|
||||
VERSION=$($PY build/version_info.py | tr -d '\r')
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Erzeugt provtool/gui/bilder/tipp.png (freigestellt, verkleinert, ohne Metadaten) aus dem Foto
|
||||
für den „Tipp des Tages“. Nur zum Entwickeln nötig (Pillow): python build/make_tipp_bild.py <foto>"""
|
||||
import sys
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ZIEL_HOEHE = 170
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def freistellen(im: Image.Image, schwelle: int = 232, min_flaeche: int = 6000) -> Image.Image:
|
||||
"""Weißen Hintergrund transparent machen: alle hellen Flächen, die den Rand berühren ODER groß
|
||||
sind (z. B. das Innere der Büroklammer-Schlaufen). Kleine helle Flächen (Augenweiß) bleiben."""
|
||||
im = im.convert("RGBA")
|
||||
w, h = im.size
|
||||
px = im.load()
|
||||
hell = lambda p: p[0] >= schwelle and p[1] >= schwelle and p[2] >= schwelle # noqa: E731
|
||||
gesehen = bytearray(w * h)
|
||||
for sy in range(h):
|
||||
for sx in range(w):
|
||||
if gesehen[sy * w + sx] or not hell(px[sx, sy]):
|
||||
continue
|
||||
flaeche, rand, q = [], False, deque([(sx, sy)])
|
||||
gesehen[sy * w + sx] = 1
|
||||
while q:
|
||||
x, y = q.popleft()
|
||||
flaeche.append((x, y))
|
||||
if x in (0, w - 1) or y in (0, h - 1):
|
||||
rand = True
|
||||
for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
||||
if 0 <= nx < w and 0 <= ny < h and not gesehen[ny * w + nx] and hell(px[nx, ny]):
|
||||
gesehen[ny * w + nx] = 1
|
||||
q.append((nx, ny))
|
||||
if rand or len(flaeche) >= min_flaeche:
|
||||
for x, y in flaeche:
|
||||
px[x, y] = (255, 255, 255, 0)
|
||||
return im.crop(im.getbbox())
|
||||
|
||||
|
||||
def main(foto: str, ziel: Path, modus: str = "lanczos"):
|
||||
im = freistellen(Image.open(foto))
|
||||
faktor = ZIEL_HOEHE / im.height
|
||||
groesse = (max(1, round(im.width * faktor)), ZIEL_HOEHE)
|
||||
if modus == "raster": # erst auf das Pixel-Raster (ca. 8 px) mitteln, dann hochskalieren
|
||||
klein = im.resize((im.width // 8, im.height // 8), Image.BOX)
|
||||
im = klein.resize(groesse, Image.NEAREST)
|
||||
else:
|
||||
im = im.resize(groesse, Image.LANCZOS)
|
||||
# Halbtransparente Randpixel hart machen (Tk/Win95-Optik, keine weißen Säume)
|
||||
alpha = im.getchannel("A").point(lambda a: 255 if a >= 128 else 0)
|
||||
im.putalpha(alpha)
|
||||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||
im.save(ziel, optimize=True) # PNG ohne EXIF-Metadaten
|
||||
print(ziel, im.size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1], Path(sys.argv[2]) if len(sys.argv) > 2 else ROOT / "provtool" / "gui" / "bilder" / "tipp.png",
|
||||
sys.argv[3] if len(sys.argv) > 3 else "lanczos")
|
||||
@@ -8,6 +8,7 @@ a = Analysis(
|
||||
[os.path.join(ROOT, "build", "start.py")],
|
||||
pathex=[ROOT],
|
||||
hiddenimports=["openpyxl", "xlrd"],
|
||||
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))],
|
||||
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
|
||||
|
||||
__version__ = "1.1.1"
|
||||
__version__ = "1.3.0"
|
||||
APP_NAME = "Provisions-Manager 95"
|
||||
APP_ORG = "Handy-Insel Miesbach"
|
||||
+2
-1
@@ -35,7 +35,8 @@ DEFAULTS = {
|
||||
"import_ordner": None,
|
||||
"export_kopfzeile": False,
|
||||
"export_summe": True,
|
||||
"export_pro_anbieter": True,
|
||||
"export_anbieter_getrennt": False, # freenet + Vodafone in einer Datei
|
||||
"export_null_weglassen": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+185
-42
@@ -8,10 +8,11 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from provtool import regeln
|
||||
from provtool.importer import ImportErgebnis
|
||||
from provtool.money import apply_percent
|
||||
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
|
||||
@@ -23,7 +24,15 @@ CREATE TABLE IF NOT EXISTS haendler (
|
||||
email TEXT DEFAULT '',
|
||||
notiz TEXT DEFAULT '',
|
||||
standard_prozent REAL NOT NULL DEFAULT 100,
|
||||
aktiv INTEGER NOT NULL DEFAULT 1
|
||||
aktiv INTEGER NOT NULL DEFAULT 1,
|
||||
airtime_prozent REAL NOT NULL DEFAULT 0, -- 0 = keine Airtime
|
||||
staffel TEXT -- Jahresbonus-Staffel (JSON), NULL = Standard-Staffel
|
||||
);
|
||||
|
||||
-- Fest eingestellter Typ einer Provisionsart (sonst automatisch erkannt, siehe regeln.py).
|
||||
CREATE TABLE IF NOT EXISTS art_typ (
|
||||
provisionsart TEXT PRIMARY KEY,
|
||||
typ TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu
|
||||
@@ -45,6 +54,14 @@ CREATE TABLE IF NOT EXISTS regel (
|
||||
UNIQUE (haendler_id, provisionsart)
|
||||
);
|
||||
|
||||
-- Eigene Kennungen (eigene Filiale): diese Positionen werden ausgeblendet und nicht verprovisioniert.
|
||||
CREATE TABLE IF NOT EXISTS eigen (
|
||||
id INTEGER PRIMARY KEY,
|
||||
anbieter TEXT NOT NULL,
|
||||
wert TEXT NOT NULL,
|
||||
UNIQUE (anbieter, wert)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS abrechnung (
|
||||
id INTEGER PRIMARY KEY,
|
||||
titel TEXT NOT NULL,
|
||||
@@ -82,6 +99,7 @@ CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
|
||||
"""
|
||||
|
||||
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
|
||||
EIGEN = -2 # Pseudo-Händler-ID für Positionen eigener Kennungen (ausgeblendet)
|
||||
|
||||
# Sonder-Kennung: Vodafone-Positionen, deren Kundenname komplett in GROSSBUCHSTABEN steht
|
||||
# (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr.
|
||||
@@ -103,6 +121,8 @@ class Haendler:
|
||||
notiz: str = ""
|
||||
standard_prozent: float = 100.0
|
||||
aktiv: bool = True
|
||||
airtime_prozent: float = 0.0
|
||||
staffel: list | None = None # None = Standard-Staffel
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -126,6 +146,11 @@ class Datenbank:
|
||||
if "haendler_fest" not in spalten:
|
||||
# NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner)
|
||||
self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER")
|
||||
h_spalten = {r[1] for r in self.con.execute("PRAGMA table_info(haendler)")}
|
||||
if "airtime_prozent" not in h_spalten:
|
||||
self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0")
|
||||
if "staffel" not in h_spalten:
|
||||
self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT")
|
||||
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
|
||||
self.con.commit()
|
||||
|
||||
@@ -149,30 +174,34 @@ class Datenbank:
|
||||
shutil.copyfile(quelle, ziel)
|
||||
|
||||
# ------------------------------------------------------------------ Händler
|
||||
@staticmethod
|
||||
def _haendler_aus(r) -> Haendler:
|
||||
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"], r["standard_prozent"],
|
||||
bool(r["aktiv"]), r["airtime_prozent"] or 0.0, regeln.staffel_laden(r["staffel"]))
|
||||
|
||||
def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]:
|
||||
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name"
|
||||
return [Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
|
||||
r["standard_prozent"], bool(r["aktiv"])) for r in self.con.execute(sql)]
|
||||
return [self._haendler_aus(r) for r in self.con.execute(sql)]
|
||||
|
||||
def haendler(self, hid: int) -> Haendler | None:
|
||||
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
|
||||
r["standard_prozent"], bool(r["aktiv"]))
|
||||
return self._haendler_aus(r) if r else None
|
||||
|
||||
def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
|
||||
"""Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]"""
|
||||
staffel = regeln.staffel_text(regeln.staffel_pruefen(h.staffel)) if h.staffel is not None else None
|
||||
werte = (h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv),
|
||||
h.airtime_prozent or 0.0, staffel)
|
||||
with self.con:
|
||||
if h.id:
|
||||
self.con.execute(
|
||||
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=? WHERE id=?",
|
||||
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv), h.id))
|
||||
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=?, "
|
||||
"airtime_prozent=?, staffel=? WHERE id=?", werte + (h.id,))
|
||||
hid = h.id
|
||||
else:
|
||||
cur = self.con.execute(
|
||||
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv) VALUES (?,?,?,?,?,?)",
|
||||
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv)))
|
||||
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv, "
|
||||
"airtime_prozent, staffel) VALUES (?,?,?,?,?,?,?,?)", werte)
|
||||
hid = cur.lastrowid
|
||||
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
|
||||
for anbieter, wert in kennungen:
|
||||
@@ -206,15 +235,31 @@ class Datenbank:
|
||||
kmap = self.kennung_map()
|
||||
caps = kmap.get((CAPS_ANBIETER, CAPS_WERT))
|
||||
aktive = {h.id for h in self.haendler_liste(nur_aktive=True)}
|
||||
eigene = set(self.eigene_kennungen())
|
||||
|
||||
def finde(anbieter: str, kennung: str, name: str | None = None, fest: int | None = None) -> int:
|
||||
if fest is not None:
|
||||
return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET
|
||||
if caps and anbieter == "vodafone" and ist_grossgeschrieben(name):
|
||||
return caps
|
||||
return kmap.get((anbieter, kennung), UNZUGEORDNET)
|
||||
hid = kmap.get((anbieter, kennung), UNZUGEORDNET)
|
||||
if hid == UNZUGEORDNET and (anbieter, kennung) in eigene:
|
||||
return EIGEN
|
||||
return hid
|
||||
return finde
|
||||
|
||||
# ------------------------------------------------------------------ eigene Kennungen
|
||||
def eigene_kennungen(self) -> list[tuple[str, str]]:
|
||||
return [(r[0], r[1]) for r in self.con.execute("SELECT anbieter, wert FROM eigen ORDER BY anbieter, wert")]
|
||||
|
||||
def eigene_setzen(self, anbieter: str, wert: str, eigen: bool = True) -> None:
|
||||
wert = (wert or "").strip()
|
||||
with self.con:
|
||||
if eigen:
|
||||
self.con.execute("INSERT OR IGNORE INTO eigen (anbieter, wert) VALUES (?,?)", (anbieter, wert))
|
||||
else:
|
||||
self.con.execute("DELETE FROM eigen WHERE anbieter=? AND wert=?", (anbieter, wert))
|
||||
|
||||
# ------------------------------------------------------------------ Regeln
|
||||
def regeln(self, hid: int) -> dict[str, float]:
|
||||
return {r[0]: r[1] for r in self.con.execute(
|
||||
@@ -229,8 +274,53 @@ class Datenbank:
|
||||
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent",
|
||||
(hid, provisionsart, prozent))
|
||||
|
||||
# ------------------------------------------------------------------ Typen der Provisionsarten
|
||||
def art_typen(self) -> dict[str, str]:
|
||||
"""Fest eingestellte Typen je Provisionsart (alles andere wird automatisch erkannt)."""
|
||||
return {r[0]: r[1] for r in self.con.execute("SELECT provisionsart, typ FROM art_typ")}
|
||||
|
||||
def art_typ_setzen(self, provisionsart: str, typ: str | None) -> None:
|
||||
"""typ=None: wieder automatisch erkennen."""
|
||||
if typ is not None and typ not in regeln.TYPEN:
|
||||
raise ValueError(f"Unbekannter Typ: {typ}")
|
||||
with self.con:
|
||||
if typ is None:
|
||||
self.con.execute("DELETE FROM art_typ WHERE provisionsart=?", (provisionsart,))
|
||||
else:
|
||||
self.con.execute("INSERT INTO art_typ (provisionsart, typ) VALUES (?,?) "
|
||||
"ON CONFLICT(provisionsart) DO UPDATE SET typ=excluded.typ", (provisionsart, typ))
|
||||
|
||||
def provisionsarten(self) -> list[tuple[str, str, int]]:
|
||||
"""Alle bekannten Provisionsarten: (Provisionsart, automatisch erkannter Typ, Anzahl Positionen)."""
|
||||
arten: dict[str, list] = {}
|
||||
for r in self.con.execute("SELECT provisionsart, dienst, tarif FROM position"):
|
||||
art = r["provisionsart"] or ""
|
||||
e = arten.setdefault(art, [regeln.NORMAL, 0])
|
||||
erkannt = regeln.erkennen(art, r["dienst"], r["tarif"])
|
||||
if erkannt != regeln.NORMAL:
|
||||
e[0] = erkannt
|
||||
e[1] += 1
|
||||
for art in self.art_typen():
|
||||
arten.setdefault(art, [regeln.erkennen(art), 0])
|
||||
return sorted(((a, t, n) for a, (t, n) in arten.items()), key=lambda x: x[0].lower())
|
||||
|
||||
# ------------------------------------------------------------------ Jahresbonus-Staffel
|
||||
def standard_staffel(self) -> regeln.Staffel:
|
||||
r = self.con.execute("SELECT wert FROM meta WHERE schluessel='staffel_standard'").fetchone()
|
||||
return (regeln.staffel_laden(r[0]) if r else None) or list(regeln.STANDARD_STAFFEL)
|
||||
|
||||
def standard_staffel_setzen(self, staffel) -> None:
|
||||
text = regeln.staffel_text(regeln.staffel_pruefen(staffel))
|
||||
with self.con:
|
||||
self.con.execute("INSERT INTO meta VALUES ('staffel_standard', ?) "
|
||||
"ON CONFLICT(schluessel) DO UPDATE SET wert=excluded.wert", (text,))
|
||||
|
||||
def staffel_fuer(self, hid: int) -> regeln.Staffel:
|
||||
h = self.haendler(hid) if hid > 0 else None
|
||||
return h.staffel if h and h.staffel is not None else self.standard_staffel()
|
||||
|
||||
def prozent_fuer(self, hid: int, provisionsart: str) -> float:
|
||||
if hid == UNZUGEORDNET:
|
||||
if hid <= 0: # nicht zugeordnet / eigene Filiale
|
||||
return 100.0
|
||||
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
|
||||
(hid, provisionsart)).fetchone()
|
||||
@@ -277,7 +367,6 @@ class Datenbank:
|
||||
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
||||
|
||||
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
||||
finde = self.zuordner()
|
||||
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
|
||||
(aid,)).fetchone()[0]
|
||||
with self.con:
|
||||
@@ -286,45 +375,109 @@ class Datenbank:
|
||||
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
|
||||
did = cur.lastrowid
|
||||
for i, p in enumerate(erg.positionen, start=start + 1):
|
||||
hid = finde(p.anbieter, p.kennung, p.name)
|
||||
proz = self.prozent_fuer(hid, p.provisionsart)
|
||||
self.con.execute(
|
||||
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
|
||||
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
|
||||
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)",
|
||||
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,0,?)",
|
||||
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung,
|
||||
p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name,
|
||||
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent,
|
||||
apply_percent(p.betrag_cent, proz), proz, json.dumps(p.roh, ensure_ascii=False, default=str)))
|
||||
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent,
|
||||
json.dumps(p.roh, ensure_ascii=False, default=str)))
|
||||
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
|
||||
neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))}
|
||||
self._regeln_anwenden(aid, neue, auch_manuelle=True)
|
||||
return len(erg.positionen)
|
||||
|
||||
def positionen(self, aid: int) -> list[dict]:
|
||||
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen)."""
|
||||
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
|
||||
finde = self.zuordner()
|
||||
fest = self.art_typen()
|
||||
out = []
|
||||
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
|
||||
d = dict(r)
|
||||
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
|
||||
d["typ"] = regeln.typ_von(d, fest)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------ Berechnung nach Regeln
|
||||
def _rechner(self):
|
||||
"""Funktion (position, händler_id, typ, karten) -> (auszahlung_cent, prozent).
|
||||
prozent ist None, wenn der Betrag aus der Jahresbonus-Staffel kommt."""
|
||||
haendler = {h.id: h for h in self.haendler_liste()}
|
||||
art_regeln: dict[int, dict[str, float]] = {}
|
||||
for r in self.con.execute("SELECT haendler_id, provisionsart, prozent FROM regel"):
|
||||
art_regeln.setdefault(r[0], {})[r[1]] = r[2]
|
||||
std_staffel = self.standard_staffel()
|
||||
|
||||
def rechne(p: dict, hid: int, typ: str, karten: int) -> tuple[int, float | None]:
|
||||
orig = p["betrag_orig"]
|
||||
h = haendler.get(hid) if hid > 0 else None
|
||||
if h is None: # nicht zugeordnet / eigene Filiale: unverändert
|
||||
return orig, 100.0
|
||||
regel = art_regeln.get(hid, {}).get(p["provisionsart"])
|
||||
if regel is not None: # ausdrücklich je Provisionsart festgelegt -> hat Vorrang
|
||||
proz = regel
|
||||
elif typ == regeln.AIRTIME:
|
||||
proz = h.airtime_prozent
|
||||
elif typ == regeln.JAHRESBONUS:
|
||||
return regeln.bonus_betrag(h.staffel if h.staffel is not None else std_staffel, karten, orig), None
|
||||
else:
|
||||
proz = h.standard_prozent
|
||||
return apply_percent(orig, proz), proz
|
||||
return rechne
|
||||
|
||||
@staticmethod
|
||||
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]:
|
||||
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab)."""
|
||||
karten: dict[tuple[int, str], int] = {}
|
||||
for p in positionen:
|
||||
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"]:
|
||||
k = (p["haendler_id"], p["anbieter"])
|
||||
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1)
|
||||
return {k: max(0, n) for k, n in karten.items()}
|
||||
|
||||
def _regeln_anwenden(self, aid: int, pids: set[int] | None = None, auch_manuelle: bool = False) -> int:
|
||||
"""Berechnet die Auszahlung nach den Händler-Regeln neu: für `pids` (None = alle) und immer für
|
||||
alle Jahresbonus-Positionen (deren Staffel hängt von der Anzahl aller Karten ab).
|
||||
Von Hand geänderte Beträge bleiben, außer auch_manuelle=True. Gibt die Anzahl Änderungen zurück."""
|
||||
alle = self.positionen(aid)
|
||||
karten = self.karten_zaehlen(alle)
|
||||
rechne = self._rechner()
|
||||
n = 0
|
||||
with self.con:
|
||||
for p in alle:
|
||||
if pids is not None and p["id"] not in pids and p["typ"] != regeln.JAHRESBONUS:
|
||||
continue
|
||||
if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)):
|
||||
continue
|
||||
betrag, proz = rechne(p, p["haendler_id"], p["typ"], karten.get((p["haendler_id"], p["anbieter"]), 0))
|
||||
if betrag != p["betrag"] or proz != p["prozent"] or p["manuell"]:
|
||||
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?",
|
||||
(betrag, proz, p["id"]))
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def _abrechnungen_von(self, pids: list[int]) -> list[int]:
|
||||
if not pids:
|
||||
return []
|
||||
marken = ",".join("?" * len(pids))
|
||||
return [r[0] for r in self.con.execute(
|
||||
f"SELECT DISTINCT abrechnung_id FROM position WHERE id IN ({marken})", list(pids))]
|
||||
|
||||
def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
|
||||
"""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."""
|
||||
with self.con:
|
||||
for pid in pids:
|
||||
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
|
||||
finde = self.zuordner()
|
||||
with self.con:
|
||||
for pid in pids:
|
||||
r = self.con.execute("SELECT * FROM position WHERE id=?", (pid,)).fetchone()
|
||||
if r is None or r["manuell"]:
|
||||
continue
|
||||
neu_hid = finde(r["anbieter"], r["kennung"], r["name"], r["haendler_fest"])
|
||||
proz = self.prozent_fuer(neu_hid, r["provisionsart"])
|
||||
self.con.execute("UPDATE position SET betrag=?, prozent=? WHERE id=?",
|
||||
(apply_percent(r["betrag_orig"], proz), proz, pid))
|
||||
for aid in self._abrechnungen_von(pids):
|
||||
self._regeln_anwenden(aid, set(pids))
|
||||
|
||||
def regeln_wiederherstellen(self, pids: list[int]) -> None:
|
||||
"""Ausgewählte Positionen wieder nach den Händler-Regeln berechnen (auch von Hand geänderte)."""
|
||||
for aid in self._abrechnungen_von(pids):
|
||||
self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
|
||||
|
||||
def betrag_setzen(self, pid: int, cent: int) -> None:
|
||||
with self.con:
|
||||
@@ -345,17 +498,7 @@ class Datenbank:
|
||||
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
|
||||
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an.
|
||||
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
|
||||
n = 0
|
||||
with self.con:
|
||||
for p in self.positionen(aid):
|
||||
if p["manuell"] and not auch_manuelle:
|
||||
continue
|
||||
proz = self.prozent_fuer(p["haendler_id"], p["provisionsart"])
|
||||
neu = apply_percent(p["betrag_orig"], proz)
|
||||
if neu != p["betrag"] or proz != p["prozent"] or p["manuell"]:
|
||||
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?", (neu, proz, p["id"]))
|
||||
n += 1
|
||||
return n
|
||||
return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle)
|
||||
|
||||
def touch(self, aid: int) -> None:
|
||||
with self.con:
|
||||
|
||||
+44
-14
@@ -2,7 +2,10 @@
|
||||
|
||||
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN |
|
||||
G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode
|
||||
Sortierung: Zeitraum → Provisionsart → Datum (sonst Reihenfolge der Abrechnung).
|
||||
Sortierung: Zeitraum -> Provisionsart -> Datum (sonst Reihenfolge der Abrechnung).
|
||||
|
||||
Je Händler entstehen zwei Dateien: die Abrechnung (Airtime darin nur als eine Summenzeile) und die
|
||||
Airtime-Liste mit den Einzelposten. Positionen mit 0,00 € werden weggelassen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +15,7 @@ from pathlib import Path
|
||||
|
||||
from provtool.importer import ANBIETER
|
||||
from provtool.money import to_euro
|
||||
from provtool.regeln import AIRTIME
|
||||
|
||||
SPALTEN = [
|
||||
("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13),
|
||||
@@ -38,8 +42,19 @@ def dateiname(text: str) -> str:
|
||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export"
|
||||
|
||||
|
||||
AIRTIME_TEXT = "Airtime"
|
||||
|
||||
|
||||
def airtime_zeile(airtime: list[dict]) -> dict:
|
||||
"""Eine Zeile „Airtime = Summe“ für die Abrechnung."""
|
||||
zeitraeume = sorted({p.get("zeitraum") or "" for p in airtime} - {""})
|
||||
return {"zeitraum": zeitraeume[0] if len(zeitraeume) == 1 else "", "provisionsart": AIRTIME_TEXT,
|
||||
"tarif": "Summe laut Airtime-Liste", "betrag": sum(p["betrag"] for p in airtime), "_summe": True}
|
||||
|
||||
|
||||
def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True,
|
||||
blattname: str = "Tabelle1") -> Path:
|
||||
blattname: str = "Tabelle1", schluss: list[dict] | None = None) -> Path:
|
||||
"""schluss: Zeilen, die nach den sortierten Positionen kommen (z. B. die Airtime-Summe)."""
|
||||
import openpyxl
|
||||
from openpyxl.styles import Font
|
||||
from openpyxl.utils import get_column_letter
|
||||
@@ -59,7 +74,7 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
|
||||
zeile += 1
|
||||
|
||||
erste = zeile
|
||||
for p in sortiert(positionen):
|
||||
for p in sortiert(positionen) + list(schluss or []):
|
||||
werte = [
|
||||
p.get("zeitraum") or None,
|
||||
p.get("provisionsart") or None,
|
||||
@@ -78,7 +93,7 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
|
||||
]
|
||||
for c, v in enumerate(werte, start=1):
|
||||
cell = ws.cell(zeile, c, v)
|
||||
cell.font = font
|
||||
cell.font = bold if p.get("_summe") and c in (2, 11) else font
|
||||
ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
|
||||
ws.cell(zeile, 5).number_format = "0"
|
||||
ws.cell(zeile, 6).number_format = "0"
|
||||
@@ -104,20 +119,35 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
|
||||
|
||||
|
||||
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
|
||||
pro_anbieter: bool = True, kopfzeile: bool = False, summe: bool = True) -> list[Path]:
|
||||
"""Schreibt eine (oder je Anbieter eine) Datei für einen Händler. Gibt die erzeugten Pfade zurück."""
|
||||
pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
|
||||
null_weglassen: bool = True) -> list[Path]:
|
||||
"""Schreibt die Abrechnung eines Händlers (bei pro_anbieter je Anbieter eine) und – falls es Airtime
|
||||
gibt – die Airtime-Liste. In der Abrechnung steht Airtime nur als eine Summenzeile.
|
||||
Gibt die erzeugten Pfade zurück."""
|
||||
ordner = Path(ordner)
|
||||
if null_weglassen:
|
||||
positionen = [p for p in positionen if p["betrag"]]
|
||||
if not positionen:
|
||||
return []
|
||||
gruppen: dict[str, list[dict]] = {}
|
||||
if pro_anbieter:
|
||||
for p in positionen:
|
||||
gruppen.setdefault(p["anbieter"], []).append(p)
|
||||
else:
|
||||
gruppen[""] = positionen
|
||||
for p in positionen:
|
||||
gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
|
||||
|
||||
def name(*teile) -> str:
|
||||
return dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t)) + ".xlsx"
|
||||
|
||||
out = []
|
||||
for anbieter, pos in sorted(gruppen.items()):
|
||||
teile = [haendler_name] + ([ANBIETER.get(anbieter, anbieter)] if anbieter else []) + [titel]
|
||||
name = dateiname(" - ".join(t for t in teile if t)) + ".xlsx"
|
||||
out.append(schreibe_xlsx(ordner / name, pos, kopfzeile=kopfzeile, summe=summe))
|
||||
provision = [p for p in pos if p.get("typ") != AIRTIME]
|
||||
airtime = [p for p in pos if p.get("typ") == AIRTIME]
|
||||
schluss = [airtime_zeile(airtime)] if airtime else []
|
||||
if schluss and null_weglassen and not schluss[0]["betrag"]:
|
||||
schluss = []
|
||||
if provision or schluss:
|
||||
out.append(schreibe_xlsx(ordner / name(ANBIETER.get(anbieter, anbieter) if anbieter else ""),
|
||||
provision, kopfzeile=kopfzeile, summe=summe, schluss=schluss))
|
||||
airtime = [p for p in positionen if p.get("typ") == AIRTIME]
|
||||
if airtime:
|
||||
out.append(schreibe_xlsx(ordner / name(AIRTIME_TEXT), airtime, kopfzeile=kopfzeile, summe=summe,
|
||||
blattname=AIRTIME_TEXT))
|
||||
return out
|
||||
+164
-61
@@ -14,9 +14,12 @@ from tkinter import filedialog
|
||||
|
||||
from provtool import APP_NAME, config
|
||||
from provtool import export as exp
|
||||
from provtool.db import UNZUGEORDNET, Datenbank
|
||||
from provtool import regeln
|
||||
from provtool.db import EIGEN, UNZUGEORDNET, Datenbank
|
||||
from provtool.gui import assistent as ass
|
||||
from provtool.gui import dialogs as dlg
|
||||
from provtool.gui import menue as mn
|
||||
from provtool.gui import modal
|
||||
from provtool.gui import rahmen as rh
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.importer import ANBIETER, ImportFehler, lies_datei
|
||||
@@ -35,7 +38,7 @@ def _datum_de(iso: str | None) -> str:
|
||||
|
||||
|
||||
def _zeitraum_kurz(z: str | None) -> str:
|
||||
"""'2026.10.01-2026.10.07' → '01.10.–07.10.'"""
|
||||
"""'2026.10.01-2026.10.07' -> '01.10.–07.10.'"""
|
||||
m = re.fullmatch(r"(\d{4})\.(\d{2})\.(\d{2})\s*-\s*(\d{4})\.(\d{2})\.(\d{2})", (z or "").strip())
|
||||
if m:
|
||||
return f"{m[3]}.{m[2]}.–{m[6]}.{m[5]}."
|
||||
@@ -89,6 +92,8 @@ class App:
|
||||
self.status.pack(side="bottom", fill="x")
|
||||
self._tasten()
|
||||
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False))
|
||||
if self.settings.get("trenner"):
|
||||
root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0))
|
||||
|
||||
abr = self.db.abrechnungen()
|
||||
if abr:
|
||||
@@ -102,43 +107,49 @@ class App:
|
||||
|
||||
# ================================================================= Aufbau
|
||||
def _menue(self):
|
||||
self.menueleiste = rh.Menueleiste(self.inhalt, self.root)
|
||||
self.menueleiste = mn.Menueleiste(self.inhalt, self.root)
|
||||
self.menueleiste.pack(side="top", fill="x")
|
||||
d = self.menueleiste.menue("Datei")
|
||||
d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu)
|
||||
d.add_command(label="Abrechnung öffnen…", accelerator="Strg+O", command=self.abrechnung_waehlen)
|
||||
d.add_command(label="Neue Abrechnung...", accelerator="Strg+N", command=self.neu)
|
||||
d.add_command(label="Abrechnung öffnen...", accelerator="Strg+O", command=self.abrechnung_waehlen)
|
||||
d.add_separator()
|
||||
d.add_command(label="Dateien importieren…", accelerator="Strg+I", command=self.importieren)
|
||||
d.add_command(label="Importierte Dateien…", command=self.dateien)
|
||||
d.add_command(label="Exportieren…", accelerator="Strg+E", command=self.exportieren)
|
||||
d.add_command(label="Dateien importieren...", accelerator="Strg+I", command=self.importieren)
|
||||
d.add_command(label="Importierte Dateien...", command=self.dateien)
|
||||
d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren)
|
||||
d.add_separator()
|
||||
d.add_command(label="Datensicherung erstellen…", command=self.sicherung)
|
||||
d.add_command(label="Datensicherung einspielen…", command=self.wiederherstellen)
|
||||
d.add_command(label="Einstellungen…", command=self.einstellungen)
|
||||
d.add_command(label="Datensicherung erstellen...", command=self.sicherung)
|
||||
d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen)
|
||||
d.add_command(label="Einstellungen...", command=self.einstellungen)
|
||||
d.add_separator()
|
||||
d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden)
|
||||
|
||||
b = self.menueleiste.menue("Bearbeiten")
|
||||
b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_bearbeiten)
|
||||
b.add_command(label="Auszahlung in Prozent…", accelerator="F4", command=self.prozent)
|
||||
b.add_command(label="Betrag ändern...", accelerator="F2", command=self.betrag_bearbeiten)
|
||||
b.add_command(label="Auszahlung in Prozent...", accelerator="F4", command=self.prozent)
|
||||
b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
|
||||
b.add_separator()
|
||||
b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen)
|
||||
|
||||
h = self.menueleiste.menue("Händler")
|
||||
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler)
|
||||
h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||||
h.add_command(label="Auswahl fest einem Händler zuweisen…", command=self.haendler_zuweisen)
|
||||
h.add_command(label="Händler verwalten...", accelerator="Strg+H", command=self.haendler)
|
||||
h.add_command(label="Kennung des Eintrags zuordnen...", command=self.kennung_zuordnen_auswahl)
|
||||
h.add_command(label="Auswahl fest einem Händler zuweisen...", command=self.haendler_zuweisen)
|
||||
h.add_command(label="Auswahl automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
|
||||
h.add_separator()
|
||||
h.add_command(label="Eigene Kennungen (ausblenden)...", command=self.eigene_verwalten)
|
||||
h.add_command(label="Kennung der Auswahl als eigene ausblenden", command=self.eigene_ausblenden)
|
||||
h.add_separator()
|
||||
h.add_command(label="Provisionsarten (Airtime / Jahresbonus)...", command=self.provisionsarten)
|
||||
h.add_command(label="Standard-Staffel Jahresbonus...", command=self.standard_staffel)
|
||||
|
||||
hi = self.menueleiste.menue("?", underline=None)
|
||||
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
|
||||
hi.add_command(label="Rundgang durch das Programm", command=self.rundgang)
|
||||
hi.add_command(label="Tipp des Tages…", command=self.tipp_des_tages)
|
||||
hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages)
|
||||
hi.add_separator()
|
||||
hi.add_command(label="Einrichtungs-Assistent…", command=self.assistent)
|
||||
hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent)
|
||||
hi.add_separator()
|
||||
hi.add_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):
|
||||
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised")
|
||||
@@ -166,14 +177,19 @@ class App:
|
||||
tk.Label(sf, text="Suchen:").pack(side="left")
|
||||
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
|
||||
e.pack(side="left", padx=4)
|
||||
self.v_suche.trace_add("write", lambda *_: self._tabelle())
|
||||
self._suche_geplant = None
|
||||
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
|
||||
self._suchfeld = e
|
||||
self._suchrahmen = sf
|
||||
|
||||
def _body(self):
|
||||
desk = tk.Frame(self.inhalt, bg=w.TEAL)
|
||||
desk.pack(side="top", fill="both", expand=True)
|
||||
pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0)
|
||||
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar.
|
||||
# Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim
|
||||
# Loslassen (flüssig, auch mit vielen Positionen).
|
||||
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised",
|
||||
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
|
||||
pw.pack(fill="both", expand=True, padx=6, pady=6)
|
||||
|
||||
# --- Händler-Fenster
|
||||
@@ -218,9 +234,9 @@ class App:
|
||||
|
||||
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
|
||||
leiste.pack(fill="x", pady=(4, 0))
|
||||
self.b_prozent = w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent"))
|
||||
self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, self.icons("prozent"))
|
||||
self.b_prozent.pack(side="left")
|
||||
w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
|
||||
w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
|
||||
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
|
||||
self.v_alle_auf = tk.BooleanVar(value=True)
|
||||
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
|
||||
@@ -231,13 +247,18 @@ class App:
|
||||
|
||||
def _tasten(self):
|
||||
r = self.root
|
||||
r.bind_all("<Control-n>", lambda e: self.neu())
|
||||
r.bind_all("<Control-o>", lambda e: self.abrechnung_waehlen())
|
||||
r.bind_all("<Control-i>", lambda e: self.importieren())
|
||||
r.bind_all("<Control-e>", lambda e: self.exportieren())
|
||||
r.bind_all("<Control-h>", lambda e: self.haendler())
|
||||
r.bind_all("<Control-f>", lambda e: self._suchfeld.focus_set())
|
||||
r.bind_all("<F1>", lambda e: self.hilfe())
|
||||
|
||||
def frei(befehl):
|
||||
# Tastenkürzel des Hauptfensters nicht ausführen, solange ein Dialog offen ist
|
||||
# (sonst öffnete z. B. Strg+N im Dialog einen zweiten Dialog darüber)
|
||||
return lambda e: None if modal.offen() else befehl()
|
||||
r.bind_all("<Control-n>", frei(self.neu))
|
||||
r.bind_all("<Control-o>", frei(self.abrechnung_waehlen))
|
||||
r.bind_all("<Control-i>", frei(self.importieren))
|
||||
r.bind_all("<Control-e>", frei(self.exportieren))
|
||||
r.bind_all("<Control-h>", frei(self.haendler))
|
||||
r.bind_all("<Control-f>", frei(self._suchfeld.focus_set))
|
||||
r.bind_all("<F1>", frei(self.hilfe))
|
||||
self.ptree.bind("<F2>", lambda e: self.betrag_bearbeiten())
|
||||
self.ptree.bind("<Return>", lambda e: self.betrag_bearbeiten())
|
||||
self.ptree.bind("<F4>", lambda e: self.prozent())
|
||||
@@ -258,13 +279,23 @@ class App:
|
||||
self.aid = None
|
||||
self.positionen = []
|
||||
self.rahmen.set_titel(APP_NAME)
|
||||
self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import")
|
||||
self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import")
|
||||
self._haendlerliste(behalte_auswahl)
|
||||
self._tabelle()
|
||||
|
||||
def _suche_planen(self):
|
||||
"""Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen)."""
|
||||
if self._suche_geplant is not None:
|
||||
self.root.after_cancel(self._suche_geplant)
|
||||
self._suche_geplant = self.root.after(150, self._suche_ausfuehren)
|
||||
|
||||
def _suche_ausfuehren(self):
|
||||
self._suche_geplant = None
|
||||
self._tabelle()
|
||||
|
||||
def _gefiltert(self) -> list[dict]:
|
||||
if self.ansicht == ALLE:
|
||||
return self.positionen
|
||||
return [p for p in self.positionen if p["haendler_id"] != EIGEN]
|
||||
return [p for p in self.positionen if p["haendler_id"] == self.ansicht]
|
||||
|
||||
def _haendlerliste(self, behalte_auswahl=True):
|
||||
@@ -276,7 +307,7 @@ class App:
|
||||
s[0] += 1
|
||||
s[1] += p["betrag"]
|
||||
s[2] += p["betrag_orig"]
|
||||
ges = [sum(s[0] for s in stat.values()), sum(s[1] for s in stat.values())]
|
||||
ges = [sum(s[0] for k, s in stat.items() if k != EIGEN), sum(s[1] for k, s in stat.items() if k != EIGEN)]
|
||||
t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",))
|
||||
for h in self.db.haendler_liste():
|
||||
s = stat.get(h.id, [0, 0, 0])
|
||||
@@ -286,6 +317,10 @@ class App:
|
||||
if s:
|
||||
t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])),
|
||||
tags=("grau",))
|
||||
s = stat.get(EIGEN)
|
||||
if s:
|
||||
t.insert("", "end", iid=str(EIGEN), text="(Eigene – ausgeblendet)", values=(s[0], fmt(s[2])),
|
||||
tags=("grau",))
|
||||
ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE)
|
||||
self.ansicht = int(ziel)
|
||||
t.selection_set(ziel)
|
||||
@@ -302,9 +337,11 @@ class App:
|
||||
self.haendler(self.ansicht)
|
||||
elif self.ansicht == UNZUGEORDNET:
|
||||
self.kennung_zuordnen_auswahl()
|
||||
elif self.ansicht == EIGEN:
|
||||
self.eigene_verwalten()
|
||||
|
||||
def _gruppe_von(self, p: dict) -> str:
|
||||
if self.ansicht == UNZUGEORDNET:
|
||||
if self.ansicht in (UNZUGEORDNET, EIGEN):
|
||||
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}"
|
||||
if self.ansicht == ALLE:
|
||||
name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)")
|
||||
@@ -335,7 +372,10 @@ class App:
|
||||
so = sum(p["betrag_orig"] for p in plist)
|
||||
sa = sum(p["betrag"] for p in plist)
|
||||
proz = {p["prozent"] for p in plist}
|
||||
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
|
||||
if all(_ist_staffel(p) for p in plist):
|
||||
pz = "Staffel"
|
||||
else:
|
||||
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
|
||||
t.insert("", "end", iid=giid, text=f"{gname} ({len(plist)})", open=(war_leer or giid in offen or bool(such)),
|
||||
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
|
||||
for p in plist:
|
||||
@@ -349,9 +389,11 @@ class App:
|
||||
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
||||
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
|
||||
fmt(p["betrag_orig"]),
|
||||
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
|
||||
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
|
||||
fmt(p["betrag"])),
|
||||
tags=tuple(tags))
|
||||
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
|
||||
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
|
||||
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
|
||||
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
|
||||
self.f_p.set_titel(f"Positionen – {name}")
|
||||
so = sum(p["betrag_orig"] for p in pos)
|
||||
@@ -464,8 +506,10 @@ class App:
|
||||
u[0] += 1
|
||||
u[1] += p["betrag_orig"]
|
||||
res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen()
|
||||
if res:
|
||||
self.kennung_zuordnen(*res)
|
||||
if res and res[0] == "zuordnen":
|
||||
self.kennung_zuordnen(res[1], res[2])
|
||||
elif res and res[0] == "eigen":
|
||||
self.eigene_ausblenden(res[1])
|
||||
|
||||
def dateien(self):
|
||||
if not self.aid:
|
||||
@@ -485,6 +529,17 @@ class App:
|
||||
self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet")
|
||||
self._aktualisieren()
|
||||
|
||||
def provisionsarten(self):
|
||||
if dlg.ProvisionsartenDialog(self.root, self.db).zeigen():
|
||||
self._nach_haendler_aenderung()
|
||||
|
||||
def standard_staffel(self):
|
||||
neu = dlg.StaffelDialog(self.root, self.db.standard_staffel(), "Standard-Staffel Jahresbonus Neuaktivierung",
|
||||
standard=list(regeln.STANDARD_STAFFEL)).zeigen()
|
||||
if neu is not None:
|
||||
self.db.standard_staffel_setzen(neu)
|
||||
self._nach_haendler_aenderung()
|
||||
|
||||
def kennung_zuordnen(self, anbieter: str, kennung: str):
|
||||
res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen()
|
||||
if not res:
|
||||
@@ -549,8 +604,7 @@ class App:
|
||||
sel, pids, ps = self._auswahl_info()
|
||||
if not ps:
|
||||
return
|
||||
for p in ps:
|
||||
self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"]))
|
||||
self.db.regeln_wiederherstellen([p["id"] for p in ps])
|
||||
self.db.touch(self.aid)
|
||||
self._aktualisieren()
|
||||
self._auswahl_wiederherstellen(sel)
|
||||
@@ -599,7 +653,7 @@ class App:
|
||||
pid = int(iid[1:])
|
||||
p = next(x for x in self.positionen if x["id"] == pid)
|
||||
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)
|
||||
return
|
||||
x, y, bw, bh = bbox
|
||||
@@ -675,20 +729,56 @@ class App:
|
||||
return
|
||||
if iid not in self.ptree.selection():
|
||||
self.ptree.selection_set(iid)
|
||||
m = tk.Menu(self.root, tearoff=0)
|
||||
m = mn.Popup(self.root)
|
||||
if iid.startswith("p"):
|
||||
m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid))
|
||||
m.add_command(label="Auszahlung in Prozent…", command=self.prozent)
|
||||
m.add_command(label="Betrag ändern...", command=lambda: self.betrag_bearbeiten(iid))
|
||||
m.add_command(label="Auszahlung in Prozent...", command=self.prozent)
|
||||
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
|
||||
m.add_separator()
|
||||
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
|
||||
m.add_separator()
|
||||
m.add_command(label="Händler zuweisen…", command=self.haendler_zuweisen)
|
||||
m.add_command(label="Händler zuweisen...", command=self.haendler_zuweisen)
|
||||
m.add_command(label="Automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
|
||||
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
|
||||
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||||
m.add_command(label="Kennung einem Händler zuordnen...", command=self.kennung_zuordnen_auswahl)
|
||||
_, _, ps = self._auswahl_info()
|
||||
if any(p["haendler_id"] == UNZUGEORDNET for p in ps):
|
||||
m.add_separator()
|
||||
m.add_command(label="Kennung als eigene ausblenden", command=self.eigene_ausblenden)
|
||||
if any(p["haendler_id"] == EIGEN for p in ps):
|
||||
m.add_separator()
|
||||
m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden)
|
||||
m.tk_popup(e.x_root, e.y_root)
|
||||
|
||||
# ----- Eigene Kennungen (eigene Filiale) ausblenden
|
||||
def _kennungen_der_auswahl(self, nur: int) -> list[tuple[str, str]]:
|
||||
_, _, ps = self._auswahl_info()
|
||||
return sorted({(p["anbieter"], p["kennung"]) for p in ps if p["haendler_id"] == nur})
|
||||
|
||||
def eigene_ausblenden(self, kennungen: list[tuple[str, str]] | None = None):
|
||||
kennungen = kennungen if kennungen is not None else self._kennungen_der_auswahl(UNZUGEORDNET)
|
||||
if not kennungen:
|
||||
w.info(self.root, "Eigene Kennung", "Bitte zuerst nicht zugeordnete Positionen auswählen.")
|
||||
return
|
||||
liste = "\n".join(f" {ANBIETER.get(a, a)}: {k or '(leer)'}" for a, k in kennungen)
|
||||
if not w.frage(self.root, "Eigene Kennung ausblenden",
|
||||
f"Diese Kennung(en) gehören zur eigenen Filiale und werden künftig ausgeblendet "
|
||||
f"(nicht angezeigt, nicht mitgerechnet, nicht exportiert):\n\n{liste}\n\n"
|
||||
"Rückgängig jederzeit unter Händler > Eigene Kennungen."):
|
||||
return
|
||||
for a, k in kennungen:
|
||||
self.db.eigene_setzen(a, k, True)
|
||||
self._aktualisieren()
|
||||
|
||||
def eigene_einblenden(self):
|
||||
for a, k in self._kennungen_der_auswahl(EIGEN):
|
||||
self.db.eigene_setzen(a, k, False)
|
||||
self._aktualisieren()
|
||||
|
||||
def eigene_verwalten(self):
|
||||
if dlg.EigeneKennungenDialog(self.root, self.db).zeigen():
|
||||
self._aktualisieren()
|
||||
|
||||
def haendler_zuweisen(self, automatisch: bool = False):
|
||||
sel, pids, _ = self._auswahl_info()
|
||||
if not pids:
|
||||
@@ -719,7 +809,7 @@ class App:
|
||||
return
|
||||
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
||||
for p in self.positionen:
|
||||
if p["haendler_id"] != UNZUGEORDNET:
|
||||
if p["haendler_id"] > 0: # nicht: nicht zugeordnet / eigene Kennungen
|
||||
stat[p["haendler_id"]][0] += 1
|
||||
stat[p["haendler_id"]][1] += p["betrag"]
|
||||
if not stat:
|
||||
@@ -740,13 +830,13 @@ class App:
|
||||
pos = [p for p in self.positionen if p["haendler_id"] == hid]
|
||||
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
|
||||
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
|
||||
summe=res["summe"])
|
||||
summe=res["summe"], null_weglassen=res["null_weglassen"])
|
||||
except PermissionError as exc:
|
||||
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
|
||||
"Ist sie vielleicht noch in Excel geöffnet?")
|
||||
return
|
||||
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
|
||||
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n…" if len(erzeugt) > 15 else ""))
|
||||
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else ""))
|
||||
if res["oeffnen"]:
|
||||
_oeffne_ordner(res["ordner"])
|
||||
|
||||
@@ -813,18 +903,24 @@ class App:
|
||||
|
||||
def hilfe(self):
|
||||
w.info(self.root, "Kurzanleitung",
|
||||
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
|
||||
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr. oder die Regel\n"
|
||||
" „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick ▸ Händler zuweisen.\n\n"
|
||||
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in %, Airtime in % (0 % = keine)\n"
|
||||
" und die Kennungen – bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n"
|
||||
" oder das Häkchen „Nachnamen in GROSSBUCHSTABEN“. Jahresbonus Neuaktivierung:\n"
|
||||
" Standard-Staffel oder eigene Staffel je Händler.\n"
|
||||
" Einzelne Positionen: Rechtsklick > Händler zuweisen.\n\n"
|
||||
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
|
||||
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
|
||||
" auch alle Wochenlisten eines Monats.\n\n"
|
||||
"4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n"
|
||||
" • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n"
|
||||
" - Gruppe wählen + „Prozent...“ (F4): Auszahlung für die ganze Provisionsart,\n"
|
||||
" optional als Standard für diesen Händler merken.\n"
|
||||
" • Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
|
||||
" (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n"
|
||||
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
|
||||
" - Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
|
||||
" (Enter = speichern, Pfeil runter/Tab = nächste Zeile, Esc = abbrechen).\n\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"
|
||||
" Positionen mit 0,00 € werden weggelassen.\n\n"
|
||||
"Welche Provisionsart Airtime bzw. Jahresbonus ist, wird automatisch erkannt –\n"
|
||||
"umstellbar unter Händler > Provisionsarten.\n\n"
|
||||
"Alles wird automatisch in der Datenbank gespeichert.")
|
||||
|
||||
def assistent(self):
|
||||
@@ -868,14 +964,15 @@ class App:
|
||||
"Das wird nicht exportiert."),
|
||||
(self.f_p, "Positionen prüfen",
|
||||
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
|
||||
"• Doppelklick auf eine Position: Betrag frei ändern\n"
|
||||
"• Rechtsklick: Auszahlung 0 €, Händler zuweisen, …\n"
|
||||
"• Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
|
||||
"- Doppelklick auf eine Position: Betrag frei ändern\n"
|
||||
"- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n"
|
||||
"- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
|
||||
(self.b_prozent, "Auszahlung in Prozent",
|
||||
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. "
|
||||
"Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
|
||||
(tb["Export"], "Exportieren",
|
||||
"Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat."),
|
||||
"Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat "
|
||||
"(Airtime darin als eine Summenzeile) und – falls vorhanden – die Airtime-Liste."),
|
||||
(self.status, "Summen",
|
||||
"Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: "
|
||||
"Provision laut Anbieter und Ihre Auszahlung."),
|
||||
@@ -955,6 +1052,7 @@ class App:
|
||||
return
|
||||
self._beendet = True
|
||||
try:
|
||||
self.settings["trenner"] = self.pw.sash_coord(0)[0]
|
||||
self.settings["fenster"] = self.rahmen.normale_geometrie()
|
||||
self.settings["maximiert"] = self.rahmen.maximiert
|
||||
config.save(self.settings)
|
||||
@@ -970,6 +1068,11 @@ class App:
|
||||
pass
|
||||
|
||||
|
||||
def _ist_staffel(p: dict) -> bool:
|
||||
"""Betrag kommt aus der Jahresbonus-Staffel (nicht aus einem Prozentsatz)."""
|
||||
return p.get("typ") == regeln.JAHRESBONUS and p["prozent"] is None and not p["manuell"] and p["haendler_id"] > 0
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest":
|
||||
from provtool.selbsttest import ausfuehren
|
||||
|
||||
+79
-41
@@ -9,6 +9,7 @@ from tkinter import filedialog, ttk
|
||||
|
||||
from provtool import APP_NAME
|
||||
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Haendler, ist_grossgeschrieben
|
||||
from provtool.gui import win32
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.importer import lies_datei
|
||||
from provtool.money import fmt, parse_percent
|
||||
@@ -17,7 +18,7 @@ DATEITYPEN = [("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
|
||||
|
||||
|
||||
def _nummern(text: str) -> list[str]:
|
||||
"""'11111102 (6 Pos.), 27518999' → ['11111102', '27518999']"""
|
||||
"""'11111102 (6 Pos.), 27518999' -> ['11111102', '27518999']"""
|
||||
out = []
|
||||
for teil in text.split(","):
|
||||
m = re.match(r"\s*([A-Za-z0-9]+)", teil)
|
||||
@@ -121,7 +122,7 @@ class Einrichtungsassistent(w.Dialog):
|
||||
if w.frage(self, "Einrichtung abbrechen",
|
||||
"Möchten Sie die Einrichtung wirklich abbrechen?\n\n"
|
||||
"Bereits angelegte Händler bleiben gespeichert. Den Assistenten finden Sie jederzeit "
|
||||
"unter ? ▸ Einrichtungs-Assistent.", default_ja=False):
|
||||
"unter ? > Einrichtungs-Assistent.", default_ja=False):
|
||||
self.result = None
|
||||
self.destroy()
|
||||
|
||||
@@ -132,7 +133,7 @@ class Einrichtungsassistent(w.Dialog):
|
||||
for punkt in ("Speicherort der Daten festlegen",
|
||||
"Unterhändler mit ihren Kennungen anlegen",
|
||||
"die erste Abrechnung anlegen und Dateien importieren"):
|
||||
self._text(f" • {punkt}")
|
||||
self._text(f" - {punkt}")
|
||||
self._text("\nDanach zeigt Ihnen ein kurzer Rundgang, wo Sie was finden.\n\n"
|
||||
"Alle Angaben lassen sich später jederzeit ändern.")
|
||||
self._text("Klicken Sie auf „Weiter“, um fortzufahren.", fg=w.NAVY)
|
||||
@@ -157,7 +158,7 @@ class Einrichtungsassistent(w.Dialog):
|
||||
e = tk.Entry(zeile, textvariable=self.v_db_pfad)
|
||||
e.pack(side="left", fill="x", expand=True)
|
||||
e.bind("<Key>", lambda ev: self.v_db_eigen.set(True))
|
||||
w.knopf(zeile, "Durchsuchen…", self._db_waehlen).pack(side="left", padx=(4, 0))
|
||||
w.knopf(zeile, "Durchsuchen...", self._db_waehlen).pack(side="left", padx=(4, 0))
|
||||
self._text("\nTipp: Auf einem Netzlaufwerk können mehrere PCs dieselben Händler nutzen – "
|
||||
"bitte aber nicht gleichzeitig damit arbeiten.", fg=w.SHADOW)
|
||||
|
||||
@@ -221,7 +222,7 @@ class Einrichtungsassistent(w.Dialog):
|
||||
k = tk.Frame(self.seite, bg=w.FACE)
|
||||
k.pack(fill="x", pady=(4, 4))
|
||||
w.knopf(k, "Händler hinzufügen", self._haendler_dazu, self.app.icons("haendler")).pack(side="left")
|
||||
w.knopf(k, "Nummern aus Abrechnung vorschlagen…", self._vorschlagen).pack(side="left", padx=6)
|
||||
w.knopf(k, "Nummern aus Abrechnung vorschlagen...", self._vorschlagen).pack(side="left", padx=6)
|
||||
|
||||
tk.Label(self.seite, text="Bereits angelegt:", anchor="w").pack(fill="x")
|
||||
lf = tk.Frame(self.seite, bg=w.FACE)
|
||||
@@ -312,7 +313,7 @@ class Einrichtungsassistent(w.Dialog):
|
||||
w.warnung(self, "Nummern vorschlagen", "Nicht lesbar:\n" + "\n".join(fehler))
|
||||
else:
|
||||
w.info(self, "Nummern vorschlagen",
|
||||
"Die gefundenen Nummern stehen jetzt in den Auswahllisten (▼) bei freenet und Vodafone – "
|
||||
"Die gefundenen Nummern stehen jetzt in den Auswahllisten (Pfeil-Knopf rechts) bei freenet und Vodafone – "
|
||||
"sortiert nach Anzahl der Positionen.\n\nDie Nummer mit den meisten Positionen ist meist "
|
||||
"Ihre eigene Filiale.\n\nDie Dateien werden im nächsten Schritt gleich mit importiert.")
|
||||
|
||||
@@ -348,7 +349,7 @@ class Einrichtungsassistent(w.Dialog):
|
||||
self.lb_d.pack(side="left", fill="both", expand=True)
|
||||
kf = tk.Frame(lf, bg=w.FACE)
|
||||
kf.pack(side="left", anchor="n", padx=(6, 0))
|
||||
self._b_dazu = w.knopf(kf, "Hinzufügen…", self._datei_dazu, breite=11)
|
||||
self._b_dazu = w.knopf(kf, "Hinzufügen...", self._datei_dazu, breite=11)
|
||||
self._b_dazu.pack(pady=(0, 4))
|
||||
self._b_weg = w.knopf(kf, "Entfernen", self._datei_weg, breite=11)
|
||||
self._b_weg.pack()
|
||||
@@ -384,12 +385,12 @@ class Einrichtungsassistent(w.Dialog):
|
||||
self._ueberschrift("Fertig!")
|
||||
n = len(self.app.db.haendler_liste())
|
||||
self._text(f"Der {APP_NAME} ist eingerichtet.")
|
||||
self._text(f" • {n} Händler angelegt")
|
||||
self._text(f" - {n} Händler angelegt")
|
||||
if self.v_abrechnung.get():
|
||||
self._text(f" • Abrechnung „{self.v_titel.get().strip()}“"
|
||||
self._text(f" - Abrechnung „{self.v_titel.get().strip()}“"
|
||||
+ (f" mit {len(self.dateien)} Datei(en)" if self.dateien else " (noch ohne Dateien)"))
|
||||
self._text("\nSo geht es danach jeden Monat weiter:\n"
|
||||
" Neu → Import → prüfen & anpassen → Export")
|
||||
" Neu > Import > prüfen & anpassen > Export")
|
||||
tk.Checkbutton(self.seite, text="Kurzen Rundgang durch das Programm starten", variable=self.v_rundgang
|
||||
).pack(anchor="w", pady=(12, 0))
|
||||
self._text("\nKlicken Sie auf „Fertig stellen“.", fg=w.NAVY)
|
||||
@@ -406,41 +407,54 @@ class Rundgang:
|
||||
self.nr = 0
|
||||
self.blase: tk.Toplevel | None = None
|
||||
self.rahmen: list[tk.Toplevel] = []
|
||||
self._sperre = None # Windows: Hauptfenster während des Rundgangs gesperrt (win32.Modal)
|
||||
self._zeige(0)
|
||||
|
||||
def _weg(self):
|
||||
for r in self.rahmen:
|
||||
@staticmethod
|
||||
def _entsorgen(blase, rahmen):
|
||||
for r in rahmen:
|
||||
try:
|
||||
r.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
if blase is not None:
|
||||
try:
|
||||
blase.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
blase.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _weg(self):
|
||||
self._entsorgen(self.blase, self.rahmen)
|
||||
self.rahmen = []
|
||||
if self.blase is not None:
|
||||
try:
|
||||
self.blase.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
self.blase.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.blase = None
|
||||
self.blase = None
|
||||
|
||||
def beenden(self):
|
||||
if self._sperre is not None: # vor dem Schließen freigeben, sonst springt Windows woandershin
|
||||
self._sperre.beenden()
|
||||
self._sperre = None
|
||||
self._weg()
|
||||
|
||||
def _markieren(self, x, y, bw, bh, dicke=3, farbe="#ff0000"):
|
||||
for gx, gy, gw, gh in ((x - dicke, y - dicke, bw + 2 * dicke, dicke), (x - dicke, y + bh, bw + 2 * dicke, dicke),
|
||||
(x - dicke, y, dicke, bh), (x + bw, y, dicke, bh)):
|
||||
t = tk.Toplevel(self.root, bg=farbe)
|
||||
t.overrideredirect(True)
|
||||
t.transient(self.root)
|
||||
t.overrideredirect(True) # kein transient() – siehe win32.py
|
||||
t.geometry(f"{max(1, gw)}x{max(1, gh)}+{gx}+{gy}")
|
||||
t.update_idletasks()
|
||||
win32.besitzer_setzen(t, self.root) # bleibt über dem Hauptfenster, auch nach Alt+Tab
|
||||
self.rahmen.append(t)
|
||||
|
||||
def _zeige(self, nr: int):
|
||||
self._weg()
|
||||
# Die alte Sprechblase verschwindet erst, wenn die neue steht: Wäre kurz kein Popup da,
|
||||
# während das Hauptfenster gesperrt ist, würde Windows ein fremdes Programm aktivieren.
|
||||
alt_blase, alt_rahmen = self.blase, self.rahmen
|
||||
self.blase, self.rahmen = None, []
|
||||
if getattr(self.root, "_provtool_schliesst", False):
|
||||
self._entsorgen(alt_blase, alt_rahmen)
|
||||
return
|
||||
self.nr = nr
|
||||
ziel, titel, text = self.schritte[nr]
|
||||
@@ -454,8 +468,7 @@ class Rundgang:
|
||||
b = self.blase = tk.Toplevel(self.root, bg=w.DARK)
|
||||
b.abbrechen = self.beenden # Alt+F4 auf der Sprechblase beendet den Rundgang sauber
|
||||
b.withdraw()
|
||||
b.overrideredirect(True)
|
||||
b.transient(self.root)
|
||||
b.overrideredirect(True) # kein transient() – Besitzer setzt win32.Modal
|
||||
innen = tk.Frame(b, bg=w.INFO_BG, padx=10, pady=8)
|
||||
innen.pack(padx=1, pady=1)
|
||||
kopf = tk.Frame(innen, bg=w.INFO_BG)
|
||||
@@ -497,6 +510,13 @@ class Rundgang:
|
||||
b.geometry(f"{pw}x{ph}+{px}+{py}")
|
||||
b.deiconify()
|
||||
b.lift()
|
||||
b.update_idletasks()
|
||||
b.focus_force()
|
||||
if self._sperre is None:
|
||||
self._sperre = win32.Modal(b, self.root)
|
||||
else:
|
||||
win32.besitzer_setzen(b, self.root)
|
||||
self._entsorgen(alt_blase, alt_rahmen)
|
||||
try:
|
||||
b.grab_set()
|
||||
except tk.TclError:
|
||||
@@ -517,28 +537,43 @@ class Rundgang:
|
||||
# =========================================================================== Tipp des Tages
|
||||
|
||||
TIPPS = [
|
||||
"Airtime wird pro Händler in Prozent ausgezahlt (0 % = keine Airtime). Im Export steht sie als eine "
|
||||
"Summenzeile, die Einzelposten kommen in eine eigene Airtime-Liste.",
|
||||
"Der Jahresbonus Neuaktivierung richtet sich nach der Anzahl Karten: Die Staffel stellen Sie unter "
|
||||
"Händler > Standard-Staffel ein – oder im Händler-Dialog eine eigene für einzelne Händler.",
|
||||
"Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats "
|
||||
"und die Vodafone-Datei in einem Rutsch.",
|
||||
"Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent…“, um z. B. nur 80 % "
|
||||
"Markieren Sie eine ganze Provisionsart (die graue Zeile) und klicken Sie auf „Prozent...“, um z. B. nur 80 % "
|
||||
"auszuzahlen. Mit dem Häkchen „Als Standard merken“ gilt das automatisch auch in den nächsten Monaten.",
|
||||
"Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit ↓ geht es "
|
||||
"Doppelklick auf eine Position – und Sie können den Auszahlungsbetrag direkt überschreiben. Mit Pfeil runter geht es "
|
||||
"gleich in die nächste Zeile.",
|
||||
"Blau dargestellte Beträge wurden von Hand geändert, rote weichen durch eine Prozent-Regel von der "
|
||||
"Provision ab.",
|
||||
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick ▸ „Auszahlung 0 €“.",
|
||||
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick ▸ „Händler zuweisen…“ – diese "
|
||||
"Eine Position soll gar nicht ausgezahlt werden? Rechtsklick > „Auszahlung 0 €“.",
|
||||
"Die GROSSBUCHSTABEN-Regel hat einen Kunden falsch erwischt? Rechtsklick > „Händler zuweisen...“ – diese "
|
||||
"Zuordnung hat immer Vorrang.",
|
||||
"Unter „(Nicht zugeordnet)“ sehen Sie alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
|
||||
"Diese Positionen werden nicht exportiert.",
|
||||
"Mit Strg+F springen Sie ins Suchfeld. Gesucht wird in Rufnummer, Name, Tarif, Provisionsart und mehr.",
|
||||
"Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei ▸ Importierte Dateien… – dort lässt "
|
||||
"Wurde eine Datei versehentlich doppelt oder falsch importiert? Datei > Importierte Dateien... – dort lässt "
|
||||
"sie sich wieder entfernen.",
|
||||
"Erstellen Sie ab und zu eine Datensicherung: Datei ▸ Datensicherung erstellen…",
|
||||
"Erstellen Sie ab und zu eine Datensicherung: Datei > Datensicherung erstellen...",
|
||||
"Alle Änderungen werden sofort gespeichert. Ein „Speichern“-Knopf ist daher nicht nötig.",
|
||||
"Ein Händler wurde erst nach dem Import angelegt? Kein Problem – die Zuordnung passt sich sofort an.",
|
||||
]
|
||||
|
||||
|
||||
BILDER = Path(__file__).resolve().parent / "bilder"
|
||||
|
||||
|
||||
def tipp_bild(master) -> tk.PhotoImage | None:
|
||||
"""Der Büroklammer-Assistent für den Tipp des Tages (liegt auch in der .exe, siehe .spec)."""
|
||||
try:
|
||||
return tk.PhotoImage(master=master, file=str(BILDER / "tipp.png"))
|
||||
except tk.TclError:
|
||||
return None
|
||||
|
||||
|
||||
class TippDesTages(w.Dialog):
|
||||
def __init__(self, app):
|
||||
super().__init__(app.root, "Tipp des Tages")
|
||||
@@ -547,17 +582,20 @@ class TippDesTages(w.Dialog):
|
||||
haupt.pack(padx=12, pady=12)
|
||||
box = tk.Frame(haupt, bg=w.WHITE, bd=2, relief="sunken")
|
||||
box.pack(side="left", fill="both")
|
||||
links = tk.Frame(box, bg=w.SHADOW, width=60)
|
||||
links.pack(side="left", fill="y")
|
||||
self._ic = w.make_icon(self, "birne", 3)
|
||||
tk.Label(links, image=self._ic, bg=w.SHADOW).pack(padx=6, pady=10)
|
||||
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10)
|
||||
self._bild = tipp_bild(self)
|
||||
self.bild_ok = self._bild is not None
|
||||
if self._bild is None: # Ersatz, falls das Bild fehlt
|
||||
self._bild = w.make_icon(self, "birne", 3)
|
||||
tk.Label(box, image=self._bild, bg=w.WHITE).pack(side="left", anchor="n", padx=(12, 4), pady=10)
|
||||
# fester Textbereich: der Tipp bricht darin um und kann nicht mehr abgeschnitten werden
|
||||
rechts = tk.Frame(box, bg=w.WHITE, padx=12, pady=10, width=380, height=self._bild.height() + 20)
|
||||
rechts.pack(side="left", fill="both")
|
||||
tk.Label(rechts, text="Wussten Sie schon…", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
|
||||
rechts.pack_propagate(False)
|
||||
tk.Label(rechts, text="Wussten Sie schon...", bg=w.WHITE, font=(w.FONT_FAMILY, w.FONT_SIZE + 6, "bold"),
|
||||
anchor="w").pack(fill="x")
|
||||
tk.Frame(rechts, bg=w.DARK, height=1).pack(fill="x", pady=6)
|
||||
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=330, width=48, height=6)
|
||||
self.text.pack(fill="both")
|
||||
self.text = tk.Label(rechts, bg=w.WHITE, justify="left", anchor="nw", wraplength=380 - 2 * 12)
|
||||
self.text.pack(fill="both", expand=True)
|
||||
knoepfe = tk.Frame(haupt, bg=w.FACE)
|
||||
knoepfe.pack(side="left", anchor="n", padx=(10, 0))
|
||||
w.knopf(knoepfe, "Schließen", self.abbrechen, breite=12, default="active").pack(pady=(0, 6))
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+363
-46
@@ -5,15 +5,16 @@ import tkinter as tk
|
||||
from tkinter import filedialog, ttk
|
||||
|
||||
from provtool import APP_NAME, APP_ORG, __version__
|
||||
from provtool import regeln
|
||||
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.importer import ANBIETER
|
||||
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
|
||||
|
||||
# Auswahl im Händler-Dialog: Schlüssel → Anzeige
|
||||
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)",
|
||||
CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"}
|
||||
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige
|
||||
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
|
||||
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
|
||||
from provtool.money import fmt, fmt_percent, parse_percent
|
||||
CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
|
||||
|
||||
|
||||
# =========================================================================== Händlerverwaltung
|
||||
@@ -51,6 +52,7 @@ class HaendlerDialog(w.Dialog):
|
||||
g.pack(fill="x")
|
||||
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar()
|
||||
self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
|
||||
self.v_air = tk.StringVar(value="0")
|
||||
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
|
||||
("E-Mail:", self.v_mail))):
|
||||
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
|
||||
@@ -61,14 +63,20 @@ class HaendlerDialog(w.Dialog):
|
||||
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left")
|
||||
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
|
||||
tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e")
|
||||
tk.Label(g, text="Notiz:", anchor="nw").grid(row=4, column=0, sticky="nw", pady=2)
|
||||
self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT)
|
||||
self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2)
|
||||
tk.Label(g, text="Airtime:", anchor="w").grid(row=4, column=0, sticky="w", pady=2)
|
||||
af = tk.Frame(g, bg=w.FACE)
|
||||
af.grid(row=4, column=1, columnspan=2, sticky="w")
|
||||
self.e_air = tk.Entry(af, textvariable=self.v_air, width=7, justify="right")
|
||||
self.e_air.pack(side="left")
|
||||
tk.Label(af, text="% der Airtime (0 % = keine Airtime)").pack(side="left", padx=4)
|
||||
tk.Label(g, text="Notiz:", anchor="nw").grid(row=5, column=0, sticky="nw", pady=2)
|
||||
self.t_notiz = tk.Text(g, width=36, height=2, font=w.FONT)
|
||||
self.t_notiz.grid(row=5, column=1, columnspan=2, sticky="we", pady=2)
|
||||
g.columnconfigure(1, weight=1)
|
||||
|
||||
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
|
||||
g2.pack(fill="both", expand=True, pady=(8, 0))
|
||||
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=4,
|
||||
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=3,
|
||||
selectmode="browse")
|
||||
self.kliste.heading("anbieter", text="Anbieter")
|
||||
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
||||
@@ -89,10 +97,27 @@ class HaendlerDialog(w.Dialog):
|
||||
ke.bind("<Return>", lambda e: self._kennung_dazu())
|
||||
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
|
||||
w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4)
|
||||
self.v_caps = tk.BooleanVar(value=False)
|
||||
self.cb_caps = tk.Checkbutton(g2, text=CAPS_HAKEN, variable=self.v_caps, anchor="w")
|
||||
self.cb_caps.pack(fill="x", pady=(4, 0))
|
||||
|
||||
g4 = w.gruppe(rechts, "Jahresbonus Neuaktivierung (Betrag je Karte nach Staffel)")
|
||||
g4.pack(fill="x", pady=(8, 0))
|
||||
self.v_eigen_staffel = tk.BooleanVar(value=False)
|
||||
sf = tk.Frame(g4, bg=w.FACE)
|
||||
sf.pack(fill="x")
|
||||
tk.Radiobutton(sf, text="Standard-Staffel", variable=self.v_eigen_staffel, value=False,
|
||||
command=self._staffel_zeigen).pack(side="left")
|
||||
tk.Radiobutton(sf, text="Eigene Staffel für diesen Händler", variable=self.v_eigen_staffel, value=True,
|
||||
command=self._staffel_zeigen).pack(side="left", padx=(8, 0))
|
||||
w.knopf(sf, "Bearbeiten...", self._staffel_bearbeiten).pack(side="right")
|
||||
self.l_staffel = tk.Label(g4, text="", anchor="w", justify="left", fg=w.NAVY, wraplength=560)
|
||||
self.l_staffel.pack(fill="x", pady=(2, 0))
|
||||
self._eigene_staffel: regeln.Staffel | None = None
|
||||
|
||||
g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart")
|
||||
g3.pack(fill="both", expand=True, pady=(8, 0))
|
||||
r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=4, selectmode="browse")
|
||||
r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=3, selectmode="browse")
|
||||
self.rliste.heading("art", text="Provisionsart")
|
||||
self.rliste.heading("proz", text="Auszahlung")
|
||||
self.rliste.column("art", width=240)
|
||||
@@ -100,7 +125,7 @@ class HaendlerDialog(w.Dialog):
|
||||
r3.pack(fill="both", expand=True)
|
||||
rf = tk.Frame(g3, bg=w.FACE)
|
||||
rf.pack(fill="x", pady=(4, 0))
|
||||
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent…“.", fg=w.SHADOW).pack(side="left")
|
||||
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent...“.", fg=w.SHADOW).pack(side="left")
|
||||
w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
|
||||
|
||||
unten = tk.Frame(self, bg=w.FACE)
|
||||
@@ -112,9 +137,14 @@ class HaendlerDialog(w.Dialog):
|
||||
self._liste_laden(auswahl_id)
|
||||
if neue_kennung:
|
||||
self._neu()
|
||||
self._kennungen = [neue_kennung]
|
||||
if neue_kennung[0] == CAPS_ANBIETER:
|
||||
self.v_caps.set(True)
|
||||
else:
|
||||
self._kennungen = [neue_kennung]
|
||||
self._kennungen_zeigen()
|
||||
self.minsize(640, 460)
|
||||
elif self.liste.selection():
|
||||
self._auswahl() # sofort füllen – sonst wächst der Dialog erst nach der Größenberechnung
|
||||
self.minsize(660, 560)
|
||||
|
||||
# ---- intern
|
||||
@staticmethod
|
||||
@@ -122,9 +152,42 @@ class HaendlerDialog(w.Dialog):
|
||||
return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige)
|
||||
|
||||
def _art_gewaehlt(self):
|
||||
caps = self._anbieter_key(self.v_kanb.get()) == CAPS_ANBIETER
|
||||
self.v_kwert.set("")
|
||||
self._ke.configure(state="disabled" if caps else "normal")
|
||||
self._ke.focus_set()
|
||||
|
||||
def _caps_einstellen(self, hid: int, an: bool):
|
||||
"""GROSSBUCHSTABEN-Häkchen: gesperrt, wenn die Regel schon einem anderen Händler gehört."""
|
||||
self.v_caps.set(an)
|
||||
besitzer = self.db.kennung_map().get((CAPS_ANBIETER, CAPS_WERT))
|
||||
if besitzer and besitzer != hid:
|
||||
h = self.db.haendler(besitzer)
|
||||
self.cb_caps.configure(state="disabled", text=f"GROSSBUCHSTABEN-Regel: gehört zu „{h.name if h else '?'}“")
|
||||
else:
|
||||
self.cb_caps.configure(state="normal", text=CAPS_HAKEN)
|
||||
|
||||
def _staffel_zeigen(self):
|
||||
eigen = self.v_eigen_staffel.get()
|
||||
if eigen and self._eigene_staffel is None:
|
||||
self._eigene_staffel = list(self.db.standard_staffel())
|
||||
st = self._eigene_staffel if eigen else self.db.standard_staffel()
|
||||
self.l_staffel.configure(text=("Eigene Staffel: " if eigen else "Standard: ")
|
||||
+ regeln.staffel_beschreibung(st, kurz=True))
|
||||
|
||||
def _staffel_bearbeiten(self):
|
||||
if not self.v_eigen_staffel.get():
|
||||
if w.frage(self, "Jahresbonus-Staffel",
|
||||
"Die Standard-Staffel gilt für alle Händler ohne eigene Staffel.\n\n"
|
||||
"Soll dieser Händler eine eigene Staffel bekommen?\n"
|
||||
"(Die Standard-Staffel ändern Sie unter Händler > Standard-Staffel Jahresbonus...)",
|
||||
ja="Eigene Staffel", nein="Abbrechen"):
|
||||
self.v_eigen_staffel.set(True)
|
||||
else:
|
||||
return
|
||||
neu = StaffelDialog(self, self._eigene_staffel or self.db.standard_staffel(),
|
||||
f"Jahresbonus-Staffel – {self.v_name.get().strip() or 'neuer Händler'}",
|
||||
standard=self.db.standard_staffel()).zeigen()
|
||||
if neu is not None:
|
||||
self._eigene_staffel = neu
|
||||
self._staffel_zeigen()
|
||||
|
||||
def _liste_laden(self, auswahl_id=None):
|
||||
self.liste.delete(*self.liste.get_children())
|
||||
@@ -150,20 +213,23 @@ class HaendlerDialog(w.Dialog):
|
||||
self.v_ap.set(h.ansprechpartner)
|
||||
self.v_mail.set(h.email)
|
||||
self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", ""))
|
||||
self.v_air.set(fmt_percent(h.airtime_prozent).replace(" %", ""))
|
||||
self.v_aktiv.set(h.aktiv)
|
||||
self.t_notiz.delete("1.0", "end")
|
||||
self.t_notiz.insert("1.0", h.notiz or "")
|
||||
self._kennungen = self.db.kennungen(h.id)
|
||||
kenn = self.db.kennungen(h.id)
|
||||
self._kennungen = [k for k in kenn if k[0] != CAPS_ANBIETER]
|
||||
self._caps_einstellen(h.id, (CAPS_ANBIETER, CAPS_WERT) in kenn)
|
||||
self._eigene_staffel = list(h.staffel) if h.staffel is not None else None
|
||||
self.v_eigen_staffel.set(h.staffel is not None)
|
||||
self._staffel_zeigen()
|
||||
self._kennungen_zeigen()
|
||||
self._regeln_zeigen()
|
||||
|
||||
def _kennungen_zeigen(self):
|
||||
self.kliste.delete(*self.kliste.get_children())
|
||||
for i, (a, v) in enumerate(self._kennungen):
|
||||
if a == CAPS_ANBIETER:
|
||||
self.kliste.insert("", "end", iid=str(i), values=("Vodafone", CAPS_TEXT))
|
||||
else:
|
||||
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
|
||||
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
|
||||
|
||||
def _regeln_zeigen(self):
|
||||
self.rliste.delete(*self.rliste.get_children())
|
||||
@@ -177,15 +243,20 @@ class HaendlerDialog(w.Dialog):
|
||||
for v in (self.v_name, self.v_ap, self.v_mail):
|
||||
v.set("")
|
||||
self.v_proz.set("100")
|
||||
self.v_air.set("0")
|
||||
self.v_aktiv.set(True)
|
||||
self.t_notiz.delete("1.0", "end")
|
||||
self._kennungen = []
|
||||
self._caps_einstellen(0, False)
|
||||
self._eigene_staffel = None
|
||||
self.v_eigen_staffel.set(False)
|
||||
self._staffel_zeigen()
|
||||
self._kennungen_zeigen()
|
||||
self._regeln_zeigen()
|
||||
|
||||
def _kennung_dazu(self):
|
||||
anb = self._anbieter_key(self.v_kanb.get())
|
||||
wert = CAPS_WERT if anb == CAPS_ANBIETER else self.v_kwert.get().strip()
|
||||
wert = self.v_kwert.get().strip()
|
||||
if not wert:
|
||||
return
|
||||
if (anb, wert) not in self._kennungen:
|
||||
@@ -216,12 +287,21 @@ class HaendlerDialog(w.Dialog):
|
||||
except ValueError:
|
||||
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
|
||||
return
|
||||
try:
|
||||
air = parse_percent(self.v_air.get() or "0")
|
||||
except ValueError:
|
||||
w.warnung(self, "Händler", "Die Airtime-Auszahlung muss eine Zahl sein (z. B. 0, 50 oder 100).")
|
||||
return
|
||||
if self.v_kwert.get().strip():
|
||||
self._kennung_dazu()
|
||||
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
|
||||
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get())
|
||||
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air,
|
||||
self._eigene_staffel if self.v_eigen_staffel.get() else None)
|
||||
kennungen = list(self._kennungen)
|
||||
if self.v_caps.get() and str(self.cb_caps.cget("state")) != "disabled":
|
||||
kennungen.append((CAPS_ANBIETER, CAPS_WERT))
|
||||
try:
|
||||
hid = self.db.haendler_speichern(h, self._kennungen)
|
||||
hid = self.db.haendler_speichern(h, kennungen)
|
||||
except Exception as exc: # sqlite3.IntegrityError / ValueError
|
||||
msg = str(exc)
|
||||
if "UNIQUE" in msg and "haendler.name" in msg:
|
||||
@@ -292,7 +372,7 @@ class ProzentDialog(w.Dialog):
|
||||
from provtool.money import apply_percent
|
||||
try:
|
||||
p = parse_percent(self.v.get())
|
||||
self.vorschau.configure(text=f"→ Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
|
||||
self.vorschau.configure(text=f"-> Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
|
||||
except ValueError:
|
||||
self.vorschau.configure(text="(ungültiger Wert)")
|
||||
|
||||
@@ -309,6 +389,171 @@ class ProzentDialog(w.Dialog):
|
||||
return super().zeigen(self._e)
|
||||
|
||||
|
||||
# =========================================================================== Jahresbonus-Staffel
|
||||
|
||||
class StaffelDialog(w.Dialog):
|
||||
"""Staffel bearbeiten: je Stufe „ab N Karten“ und Betrag je Karte (oder „voll“)."""
|
||||
|
||||
def __init__(self, master, staffel: regeln.Staffel, titel: str = "Jahresbonus-Staffel",
|
||||
standard: regeln.Staffel | None = None):
|
||||
super().__init__(master, titel)
|
||||
self.standard = standard
|
||||
f = tk.Frame(self, bg=w.FACE)
|
||||
f.pack(fill="both", expand=True, padx=12, pady=(10, 4))
|
||||
tk.Label(f, justify="left", anchor="w", wraplength=380, text=(
|
||||
"Jahresbonus Neuaktivierung: Der Händler bekommt je Karte den Betrag der Stufe, die zu seiner "
|
||||
"Anzahl Karten in der Abrechnung passt. „voll“ = ganzer Betrag laut Abrechnung. Nie mehr als "
|
||||
"die Provision laut Abrechnung; Stornos zählen ab.")).pack(fill="x")
|
||||
kopf = tk.Frame(f, bg=w.FACE)
|
||||
kopf.pack(fill="x", pady=(8, 2))
|
||||
tk.Label(kopf, text="ab Karten", width=9, anchor="w").pack(side="left")
|
||||
tk.Label(kopf, text="Betrag je Karte (oder „voll“)", anchor="w").pack(side="left", padx=(8, 0))
|
||||
self.tabelle = tk.Frame(f, bg=w.FACE)
|
||||
self.tabelle.pack(fill="x")
|
||||
self.zeilen: list[tuple[tk.Frame, tk.StringVar, tk.StringVar]] = []
|
||||
for ab, cent in staffel:
|
||||
self._zeile(ab, cent)
|
||||
kk = tk.Frame(f, bg=w.FACE)
|
||||
kk.pack(fill="x", pady=(6, 0))
|
||||
w.knopf(kk, "Stufe hinzufügen", self._dazu).pack(side="left")
|
||||
w.knopf(kk, "Letzte entfernen", self._weg).pack(side="left", padx=4)
|
||||
if standard is not None:
|
||||
w.knopf(kk, "Wie Standard", lambda: self._setzen(standard)).pack(side="right")
|
||||
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(6, 10))
|
||||
|
||||
def _zeile(self, ab: int | str = "", cent: int | None | str = ""):
|
||||
z = tk.Frame(self.tabelle, bg=w.FACE)
|
||||
z.pack(fill="x", pady=1)
|
||||
v_ab = tk.StringVar(value=str(ab))
|
||||
v_bt = tk.StringVar(value="voll" if cent is None else (fmt(cent, symbol=False) if isinstance(cent, int)
|
||||
else str(cent)))
|
||||
tk.Entry(z, textvariable=v_ab, width=8, justify="right").pack(side="left")
|
||||
tk.Entry(z, textvariable=v_bt, width=10, justify="right").pack(side="left", padx=(12, 0))
|
||||
tk.Label(z, text="€ je Karte").pack(side="left", padx=4)
|
||||
self.zeilen.append((z, v_ab, v_bt))
|
||||
|
||||
def _dazu(self):
|
||||
letzte = self.zeilen[-1][1].get() if self.zeilen else "0"
|
||||
try:
|
||||
ab = int(letzte) + 5
|
||||
except ValueError:
|
||||
ab = ""
|
||||
self._zeile(ab, "")
|
||||
|
||||
def _weg(self):
|
||||
if len(self.zeilen) > 1:
|
||||
self.zeilen.pop()[0].destroy()
|
||||
|
||||
def _setzen(self, staffel):
|
||||
for z, _, _ in self.zeilen:
|
||||
z.destroy()
|
||||
self.zeilen = []
|
||||
for ab, cent in staffel:
|
||||
self._zeile(ab, cent)
|
||||
|
||||
def lesen(self) -> regeln.Staffel:
|
||||
out = []
|
||||
for i, (_, v_ab, v_bt) in enumerate(self.zeilen, start=1):
|
||||
try:
|
||||
ab = int(v_ab.get().strip())
|
||||
except ValueError:
|
||||
raise ValueError(f"Stufe {i}: „ab Karten“ muss eine ganze Zahl sein.") from None
|
||||
text = v_bt.get().strip()
|
||||
if text.lower() == "voll":
|
||||
cent = None
|
||||
else:
|
||||
try:
|
||||
cent = parse_cent(text)
|
||||
except ValueError:
|
||||
raise ValueError(f"Stufe {i}: Bitte einen Betrag (z. B. 5 oder 7,50) oder „voll“ eingeben.") \
|
||||
from None
|
||||
out.append((ab, cent))
|
||||
return regeln.staffel_pruefen(out)
|
||||
|
||||
def _ok(self):
|
||||
try:
|
||||
self.result = self.lesen()
|
||||
except ValueError as exc:
|
||||
w.warnung(self, "Staffel", str(exc))
|
||||
return
|
||||
self.destroy()
|
||||
|
||||
|
||||
# =========================================================================== Provisionsarten
|
||||
|
||||
TYP_AUTO = "Automatisch"
|
||||
|
||||
|
||||
class ProvisionsartenDialog(w.Dialog):
|
||||
"""Welche Provisionsart ist Airtime bzw. Jahresbonus? Erkannt wird automatisch, umstellbar je Art."""
|
||||
|
||||
def __init__(self, master, db: Datenbank):
|
||||
super().__init__(master, "Provisionsarten", resizable=True)
|
||||
self.db = db
|
||||
self.geaendert = False
|
||||
f = tk.Frame(self, bg=w.FACE)
|
||||
f.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
tk.Label(f, anchor="w", justify="left", text=(
|
||||
"Airtime wird pro Händler mit dem Airtime-Prozentsatz ausgezahlt und im Export als eigene Liste\n"
|
||||
"geführt. Der Jahresbonus Neuaktivierung wird nach der Staffel berechnet.")).pack(fill="x")
|
||||
r, self.tree = w.scroll_tree(f, columns=("art", "anzahl", "typ"), show="headings", height=12,
|
||||
selectmode="browse")
|
||||
for c, t, b, a in (("art", "Provisionsart", 300, "w"), ("anzahl", "Pos.", 50, "e"),
|
||||
("typ", "Behandlung", 230, "w")):
|
||||
self.tree.heading(c, text=t)
|
||||
self.tree.column(c, width=b, anchor=a)
|
||||
r.pack(fill="both", expand=True, pady=(6, 4))
|
||||
self.tree.bind("<<TreeviewSelect>>", lambda e: self._auswahl())
|
||||
u = tk.Frame(f, bg=w.FACE)
|
||||
u.pack(fill="x")
|
||||
tk.Label(u, text="Behandlung:").pack(side="left")
|
||||
self.v_typ = tk.StringVar(value=TYP_AUTO)
|
||||
self.cb = ttk.Combobox(u, textvariable=self.v_typ, state="readonly", width=36,
|
||||
values=[TYP_AUTO] + list(regeln.TYPEN.values()))
|
||||
self.cb.pack(side="left", padx=4)
|
||||
w.knopf(u, "Übernehmen", self._setzen).pack(side="left")
|
||||
self.knopfleiste([("Schließen", self.abbrechen)]).pack(pady=(0, 10))
|
||||
self._laden()
|
||||
self.minsize(620, 380)
|
||||
|
||||
def _laden(self, auswahl: str | None = None):
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
fest = self.db.art_typen()
|
||||
self._arten = {}
|
||||
for i, (art, erkannt, n) in enumerate(self.db.provisionsarten()):
|
||||
typ = fest.get(art)
|
||||
text = regeln.TYPEN[typ] if typ else f"{TYP_AUTO}: {regeln.TYPEN[erkannt]}"
|
||||
iid = str(i)
|
||||
self._arten[iid] = art
|
||||
self.tree.insert("", "end", iid=iid, values=(art or "(ohne)", n, text))
|
||||
if art == auswahl:
|
||||
self.tree.selection_set(iid)
|
||||
self.tree.see(iid)
|
||||
|
||||
def _auswahl(self):
|
||||
sel = self.tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
typ = self.db.art_typen().get(self._arten[sel[0]])
|
||||
self.v_typ.set(regeln.TYPEN[typ] if typ else TYP_AUTO)
|
||||
|
||||
def _setzen(self):
|
||||
sel = self.tree.selection()
|
||||
if not sel:
|
||||
w.info(self, "Provisionsarten", "Bitte zuerst eine Provisionsart in der Liste auswählen.")
|
||||
return
|
||||
art = self._arten[sel[0]]
|
||||
wahl = self.v_typ.get()
|
||||
typ = next((k for k, v in regeln.TYPEN.items() if v == wahl), None)
|
||||
self.db.art_typ_setzen(art, typ)
|
||||
self.geaendert = True
|
||||
self._laden(art)
|
||||
|
||||
def abbrechen(self):
|
||||
self.result = self.geaendert
|
||||
self.destroy()
|
||||
|
||||
|
||||
# =========================================================================== Abrechnungen
|
||||
|
||||
class AbrechnungenDialog(w.Dialog):
|
||||
@@ -327,7 +572,7 @@ class AbrechnungenDialog(w.Dialog):
|
||||
k = tk.Frame(self, bg=w.FACE)
|
||||
k.pack(fill="x", padx=10, pady=10)
|
||||
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
|
||||
w.knopf(k, "Umbenennen…", self._umbenennen, breite=12).pack(side="left", padx=6)
|
||||
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
|
||||
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
|
||||
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
|
||||
self.bind("<Return>", lambda e: self._oeffnen())
|
||||
@@ -440,7 +685,7 @@ class ImportBericht(w.Dialog):
|
||||
tk.Label(self, text="Nicht zugeordnete Kennungen (kein Händler hinterlegt – z. B. eigene Filiale):",
|
||||
anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings",
|
||||
height=min(6, len(unbekannt)), selectmode="browse")
|
||||
height=min(6, len(unbekannt)), selectmode="extended")
|
||||
for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
|
||||
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
|
||||
self.ut.heading(c, text=h)
|
||||
@@ -451,16 +696,82 @@ class ImportBericht(w.Dialog):
|
||||
k = tk.Frame(self, bg=w.FACE)
|
||||
k.pack(fill="x", padx=10, pady=10)
|
||||
if unbekannt:
|
||||
w.knopf(k, "Kennung einem Händler zuordnen…", self._zuordnen).pack(side="left")
|
||||
w.knopf(k, "Kennung einem Händler zuordnen...", self._zuordnen).pack(side="left")
|
||||
w.knopf(k, "Als eigene ausblenden...", self._eigen).pack(side="left", padx=6)
|
||||
w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
|
||||
self.bind("<Return>", lambda e: self.abbrechen())
|
||||
|
||||
def _zuordnen(self):
|
||||
s = self.ut.selection()
|
||||
if s:
|
||||
self.result = tuple(s[0].split("\t", 1))
|
||||
a, k = s[0].split("\t", 1)
|
||||
self.result = ("zuordnen", a, k)
|
||||
self.destroy()
|
||||
|
||||
def _eigen(self):
|
||||
s = self.ut.selection()
|
||||
if s:
|
||||
self.result = ("eigen", [tuple(x.split("\t", 1)) for x in s])
|
||||
self.destroy()
|
||||
|
||||
|
||||
class EigeneKennungenDialog(w.Dialog):
|
||||
"""Kennungen der eigenen Filiale: Positionen damit werden ausgeblendet und nicht exportiert."""
|
||||
|
||||
def __init__(self, master, db: Datenbank):
|
||||
super().__init__(master, "Eigene Kennungen")
|
||||
self.db, self.geaendert = db, False
|
||||
tk.Label(self, text="Positionen mit diesen Kennungen gehören zur eigenen Filiale. Sie werden\n"
|
||||
"ausgeblendet, nicht mitgerechnet und nicht exportiert.\n"
|
||||
"(Ist eine Kennung einem Händler zugeordnet, hat der Händler Vorrang.)",
|
||||
justify="left", anchor="w").pack(fill="x", padx=10, pady=(10, 4))
|
||||
r, self.tree = w.scroll_tree(self, columns=("anbieter", "wert"), show="headings", height=7,
|
||||
selectmode="extended")
|
||||
self.tree.heading("anbieter", text="Anbieter")
|
||||
self.tree.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
||||
self.tree.column("anbieter", width=100)
|
||||
self.tree.column("wert", width=200)
|
||||
r.pack(fill="both", expand=True, padx=10)
|
||||
z = tk.Frame(self, bg=w.FACE)
|
||||
z.pack(fill="x", padx=10, pady=6)
|
||||
self.v_anb = tk.StringVar(value="freenet")
|
||||
ttk.Combobox(z, textvariable=self.v_anb, values=list(ANBIETER.values()), width=10,
|
||||
state="readonly").pack(side="left")
|
||||
self.v_wert = tk.StringVar()
|
||||
e = tk.Entry(z, textvariable=self.v_wert, width=18)
|
||||
e.pack(side="left", padx=4)
|
||||
e.bind("<Return>", lambda ev: self._dazu())
|
||||
w.knopf(z, "Hinzufügen", self._dazu).pack(side="left")
|
||||
w.knopf(z, "Entfernen", self._weg).pack(side="left", padx=4)
|
||||
self.knopfleiste([("Schließen", self.abbrechen)]).pack(pady=(0, 10))
|
||||
self._laden()
|
||||
|
||||
def _laden(self):
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
for a, k in self.db.eigene_kennungen():
|
||||
self.tree.insert("", "end", iid=f"{a}\t{k}", values=(ANBIETER.get(a, a), k))
|
||||
|
||||
def _dazu(self):
|
||||
wert = self.v_wert.get().strip()
|
||||
if not wert:
|
||||
return
|
||||
anb = next((k for k, v in ANBIETER.items() if v == self.v_anb.get()), self.v_anb.get())
|
||||
self.db.eigene_setzen(anb, wert, True)
|
||||
self.v_wert.set("")
|
||||
self.geaendert = True
|
||||
self._laden()
|
||||
|
||||
def _weg(self):
|
||||
for iid in self.tree.selection():
|
||||
a, k = iid.split("\t", 1)
|
||||
self.db.eigene_setzen(a, k, False)
|
||||
self.geaendert = True
|
||||
self._laden()
|
||||
|
||||
def abbrechen(self):
|
||||
self.result = self.geaendert
|
||||
self.destroy()
|
||||
|
||||
|
||||
class KennungZuordnen(w.Dialog):
|
||||
"""Fragt, ob eine Kennung einem bestehenden oder neuen Händler zugeordnet werden soll."""
|
||||
@@ -473,7 +784,7 @@ class KennungZuordnen(w.Dialog):
|
||||
self.v = tk.StringVar(value="__neu__")
|
||||
f = tk.Frame(self, bg=w.FACE)
|
||||
f.pack(fill="x", padx=20)
|
||||
tk.Radiobutton(f, text="Neuem Händler …", variable=self.v, value="__neu__").pack(anchor="w")
|
||||
tk.Radiobutton(f, text="Neuem Händler ...", variable=self.v, value="__neu__").pack(anchor="w")
|
||||
haendler = db.haendler_liste()
|
||||
if haendler:
|
||||
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w")
|
||||
@@ -515,7 +826,7 @@ class HaendlerWaehlen(w.Dialog):
|
||||
self.cb = ttk.Combobox(self, values=list(self._hmap) + [self.KEINER], state="readonly", width=40)
|
||||
self.cb.pack(padx=12, pady=4)
|
||||
tk.Label(self, text="Die feste Zuweisung hat Vorrang vor Kennungen und der GROSSBUCHSTABEN-Regel.\n"
|
||||
"Rückgängig: Rechtsklick ▸ „Automatisch zuordnen“.", justify="left",
|
||||
"Rückgängig: Rechtsklick > „Automatisch zuordnen“.", justify="left",
|
||||
fg=w.SHADOW).pack(fill="x", padx=12)
|
||||
self.bind("<Return>", lambda e: self._ok())
|
||||
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
|
||||
@@ -546,7 +857,7 @@ class ExportDialog(w.Dialog):
|
||||
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8,
|
||||
selectmode="extended")
|
||||
self.tree.heading("#0", text="")
|
||||
self.tree.column("#0", width=30, stretch=False)
|
||||
self.tree.column("#0", width=40, stretch=False)
|
||||
for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")):
|
||||
self.tree.heading(c, text=t)
|
||||
self.tree.column(c, width=b, anchor=a)
|
||||
@@ -571,19 +882,23 @@ class ExportDialog(w.Dialog):
|
||||
tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
|
||||
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
|
||||
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
|
||||
w.knopf(g, "Durchsuchen…", self._ordner).grid(row=1, column=2, padx=(4, 0))
|
||||
self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True))
|
||||
w.knopf(g, "Durchsuchen...", self._ordner).grid(row=1, column=2, padx=(4, 0))
|
||||
# Neuer Schlüssel: freenet und Vodafone stehen jetzt standardmäßig in EINER Datei
|
||||
self.v_anb = tk.BooleanVar(value=settings.get("export_anbieter_getrennt", False))
|
||||
self.v_null = tk.BooleanVar(value=settings.get("export_null_weglassen", True))
|
||||
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
|
||||
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
|
||||
self.v_auf = tk.BooleanVar(value=True)
|
||||
tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei",
|
||||
variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w")
|
||||
tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3,
|
||||
sticky="w")
|
||||
tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0,
|
||||
columnspan=3, sticky="w")
|
||||
tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0,
|
||||
columnspan=3, sticky="w")
|
||||
for zeile, (text, var) in enumerate((
|
||||
("freenet und Vodafone in getrennte Dateien (sonst beide in einer)", self.v_anb),
|
||||
("Positionen mit 0,00 € weglassen", self.v_null),
|
||||
("Kopfzeile mit Spaltennamen", self.v_kopf),
|
||||
("Summenzeile unter der Tabelle", self.v_sum),
|
||||
("Ordner nach dem Export öffnen", self.v_auf)), start=2):
|
||||
tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w")
|
||||
tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine\n"
|
||||
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid(
|
||||
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
|
||||
g.columnconfigure(1, weight=1)
|
||||
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||
|
||||
@@ -617,11 +932,13 @@ class ExportDialog(w.Dialog):
|
||||
self._ordner()
|
||||
if not self.v_ordner.get().strip():
|
||||
return
|
||||
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(),
|
||||
export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get())
|
||||
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_anbieter_getrennt=self.v_anb.get(),
|
||||
export_null_weglassen=self.v_null.get(), export_kopfzeile=self.v_kopf.get(),
|
||||
export_summe=self.v_sum.get())
|
||||
self.settings.pop("export_pro_anbieter", None)
|
||||
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
|
||||
pro_anbieter=self.v_anb.get(), kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(),
|
||||
oeffnen=self.v_auf.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())
|
||||
self.destroy()
|
||||
|
||||
|
||||
@@ -637,7 +954,7 @@ class EinstellungenDialog(w.Dialog):
|
||||
row=0, column=0, columnspan=2, sticky="w")
|
||||
self.v = tk.StringVar(value=db_pfad)
|
||||
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4)
|
||||
w.knopf(g, "Ändern…", self._waehlen).grid(row=1, column=1, padx=(4, 0))
|
||||
w.knopf(g, "Ändern...", self._waehlen).grid(row=1, column=1, padx=(4, 0))
|
||||
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
|
||||
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
|
||||
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Menüs im Windows-95-Stil (Menüleiste, Aufklapp- und Kontextmenüs).
|
||||
|
||||
Die nativen Windows-Menüs sind weiß und wechseln nicht mit, wenn man bei offenem Menü mit der
|
||||
Maus zum nächsten Menüleisten-Eintrag fährt. Diese Menüs sind selbst gezeichnet:
|
||||
- grau, Markierung in Navy, Tastenkürzel rechtsbündig, Trennlinien eingelassen
|
||||
- bei offenem Menü wechselt das Menü beim Überfahren der Menüleiste
|
||||
- Tastatur: Pfeil hoch/runter wählen, Enter ausführen, Esc schließen, Pfeil links/rechts benachbartes Menü
|
||||
- Klick daneben oder Wechsel in ein anderes Programm schließt das Menü
|
||||
Die Schnittstelle (add_command, add_separator, tk_popup) entspricht tk.Menu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
from provtool.gui import win95 as w
|
||||
|
||||
|
||||
class Popup:
|
||||
"""Ein Aufklapp- bzw. Kontextmenü."""
|
||||
|
||||
def __init__(self, master: tk.Misc):
|
||||
self.master = master
|
||||
self.eintraege: list[dict] = []
|
||||
self.fenster: tk.Toplevel | None = None
|
||||
self.zeilen: list[tuple[dict, tk.Frame, list[tk.Label]]] = []
|
||||
self.aktiv = -1
|
||||
self.leiste: Menueleiste | None = None
|
||||
self._pruefer = None
|
||||
|
||||
# ------------------------------------------------------------- tk.Menu-kompatibel
|
||||
def add_command(self, label: str, command=None, accelerator: str = "", state: str = "normal"):
|
||||
self.eintraege.append(dict(art="befehl", label=label, command=command, accelerator=accelerator,
|
||||
state=state))
|
||||
|
||||
def add_separator(self):
|
||||
self.eintraege.append(dict(art="trenner"))
|
||||
|
||||
def tk_popup(self, x: int, y: int):
|
||||
self.oeffnen(x, y)
|
||||
|
||||
# ------------------------------------------------------------- Anzeige
|
||||
@property
|
||||
def offen(self) -> bool:
|
||||
try:
|
||||
return self.fenster is not None and bool(self.fenster.winfo_exists())
|
||||
except tk.TclError:
|
||||
return False
|
||||
|
||||
def oeffnen(self, x: int, y: int, leiste: Menueleiste | None = None):
|
||||
self.schliessen()
|
||||
self.leiste = leiste
|
||||
root = self.master._root()
|
||||
f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised")
|
||||
f.withdraw()
|
||||
f.overrideredirect(True)
|
||||
self.zeilen = []
|
||||
for e in self.eintraege:
|
||||
if e["art"] == "trenner":
|
||||
t = tk.Frame(f, bg=w.FACE, height=8)
|
||||
t.pack(fill="x")
|
||||
linie = w.trenner(t, vertikal=False)
|
||||
linie.place(relx=0, rely=0.5, relwidth=1, y=-1)
|
||||
continue
|
||||
zeile = tk.Frame(f, bg=w.FACE)
|
||||
zeile.pack(fill="x")
|
||||
grau = e["state"] == "disabled"
|
||||
text = tk.Label(zeile, text=e["label"], bg=w.FACE, anchor="w", padx=18, pady=2,
|
||||
fg=w.SHADOW if grau else w.DARK)
|
||||
text.pack(side="left", fill="x", expand=True)
|
||||
labels = [text]
|
||||
if e["accelerator"]:
|
||||
acc = tk.Label(zeile, text=e["accelerator"], bg=w.FACE, anchor="e", padx=12, pady=2,
|
||||
fg=w.SHADOW if grau else w.DARK)
|
||||
acc.pack(side="right")
|
||||
labels.append(acc)
|
||||
idx = len(self.zeilen)
|
||||
for wdg in [zeile] + labels:
|
||||
wdg.bind("<Enter>", lambda ev, i=idx: self._markieren(i))
|
||||
wdg.bind("<ButtonRelease-1>", lambda ev, i=idx: self._ausfuehren(i))
|
||||
self.zeilen.append((e, zeile, labels))
|
||||
|
||||
f.update_idletasks()
|
||||
bw, bh = f.winfo_reqwidth(), f.winfo_reqheight()
|
||||
sw, sh = f.winfo_screenwidth(), f.winfo_screenheight()
|
||||
x = max(0, min(x, sw - bw))
|
||||
y = max(0, min(y, sh - bh))
|
||||
# Größe UND Position zusammen setzen: eine reine Positionsangabe ignoriert Tk unter Windows bei
|
||||
# rahmenlosen Fenstern – das Menü landete sonst oben links in der Bildschirmecke.
|
||||
f.geometry(f"{bw}x{bh}+{x}+{y}")
|
||||
f.deiconify()
|
||||
f.lift()
|
||||
f.update_idletasks()
|
||||
try:
|
||||
from provtool.gui import win32
|
||||
win32.besitzer_setzen(f, root) # bleibt über dem Hauptfenster
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Alle Maus-Ereignisse der Anwendung an das Menü (lokale Tk-Sperre): Klick daneben schließt,
|
||||
# Bewegung über die Menüleiste wechselt das Menü.
|
||||
try:
|
||||
f.grab_set()
|
||||
except tk.TclError:
|
||||
pass
|
||||
f.focus_force()
|
||||
f.bind("<ButtonPress>", self._klick)
|
||||
f.bind("<Motion>", self._bewegung)
|
||||
f.bind("<Escape>", lambda e: self.schliessen())
|
||||
f.bind("<Up>", lambda e: self._schritt(-1))
|
||||
f.bind("<Down>", lambda e: self._schritt(1))
|
||||
f.bind("<Return>", lambda e: self._ausfuehren(self.aktiv))
|
||||
f.bind("<KP_Enter>", lambda e: self._ausfuehren(self.aktiv))
|
||||
f.bind("<Left>", lambda e: self.leiste and self.leiste.nachbar(-1))
|
||||
f.bind("<Right>", lambda e: self.leiste and self.leiste.nachbar(1))
|
||||
self.aktiv = -1
|
||||
self._pruefer = f.after(250, self._fokus_pruefen)
|
||||
|
||||
def schliessen(self):
|
||||
if self._pruefer is not None and self.fenster is not None:
|
||||
try:
|
||||
self.fenster.after_cancel(self._pruefer)
|
||||
except tk.TclError:
|
||||
pass
|
||||
self._pruefer = None
|
||||
if self.offen:
|
||||
try:
|
||||
self.fenster.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.fenster.destroy()
|
||||
self.fenster = None
|
||||
if self.leiste is not None:
|
||||
self.leiste._geschlossen(self)
|
||||
|
||||
# ------------------------------------------------------------- Bedienung
|
||||
def _innerhalb(self, x_root: int, y_root: int) -> bool:
|
||||
"""Liegt der Punkt über diesem Menü? Fragt das Fenstersystem, welches Fenster dort liegt –
|
||||
die Geometrie rahmenloser Fenster meldet Tk unter Windows nicht zuverlässig."""
|
||||
f = self.fenster
|
||||
try:
|
||||
unter = f.winfo_containing(x_root, y_root)
|
||||
except (tk.TclError, KeyError):
|
||||
unter = None
|
||||
if unter is not None:
|
||||
return unter.winfo_toplevel() is f
|
||||
return (f.winfo_rootx() <= x_root < f.winfo_rootx() + f.winfo_width()
|
||||
and f.winfo_rooty() <= y_root < f.winfo_rooty() + f.winfo_height())
|
||||
|
||||
def _klick(self, e):
|
||||
if self._innerhalb(e.x_root, e.y_root):
|
||||
return
|
||||
if self.leiste is not None and self.leiste.knopf_bei(e.x_root, e.y_root) is not None:
|
||||
knopf = self.leiste.knopf_bei(e.x_root, e.y_root)
|
||||
if knopf is self.leiste.offener_knopf:
|
||||
self.schliessen() # Klick auf den eigenen Menütitel klappt zu
|
||||
else:
|
||||
self.leiste.oeffnen(knopf)
|
||||
return "break"
|
||||
self.schliessen()
|
||||
return "break"
|
||||
|
||||
def _bewegung(self, e):
|
||||
if self.leiste is None or self._innerhalb(e.x_root, e.y_root):
|
||||
return
|
||||
knopf = self.leiste.knopf_bei(e.x_root, e.y_root)
|
||||
if knopf is not None and knopf is not self.leiste.offener_knopf:
|
||||
self.leiste.oeffnen(knopf)
|
||||
|
||||
def _markieren(self, i: int):
|
||||
self.aktiv = i
|
||||
for j, (e, zeile, labels) in enumerate(self.zeilen):
|
||||
an = j == i and e.get("state") != "disabled"
|
||||
bg = w.NAVY if an else w.FACE
|
||||
fg = w.WHITE if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK)
|
||||
zeile.configure(bg=bg)
|
||||
for lbl in labels:
|
||||
lbl.configure(bg=bg, fg=fg)
|
||||
|
||||
def _schritt(self, richtung: int):
|
||||
if not self.zeilen:
|
||||
return
|
||||
i = self.aktiv
|
||||
for _ in range(len(self.zeilen)):
|
||||
i = (i + richtung) % len(self.zeilen)
|
||||
if self.zeilen[i][0].get("state") != "disabled":
|
||||
break
|
||||
self._markieren(i)
|
||||
|
||||
def _ausfuehren(self, i: int):
|
||||
if not (0 <= i < len(self.zeilen)):
|
||||
return
|
||||
e = self.zeilen[i][0]
|
||||
if e.get("state") == "disabled":
|
||||
return
|
||||
self.schliessen()
|
||||
if e.get("command"):
|
||||
# nach dem Schließen ausführen (der Befehl öffnet evtl. einen Dialog)
|
||||
self.master.after_idle(e["command"])
|
||||
|
||||
def _fokus_pruefen(self):
|
||||
"""In ein anderes Programm gewechselt -> Menü schließen."""
|
||||
self._pruefer = None
|
||||
if not self.offen:
|
||||
return
|
||||
try:
|
||||
weg = self.fenster.focus_get() is None
|
||||
except (tk.TclError, KeyError):
|
||||
weg = False
|
||||
if weg:
|
||||
self.schliessen()
|
||||
else:
|
||||
self._pruefer = self.fenster.after(250, self._fokus_pruefen)
|
||||
|
||||
|
||||
class Menueleiste(tk.Frame):
|
||||
"""Graue Menüleiste mit Popup-Menüs (die native von Windows 10/11 ist weiß)."""
|
||||
|
||||
def __init__(self, master, root: tk.Misc):
|
||||
super().__init__(master, bg=w.FACE)
|
||||
self.root = root
|
||||
self.knoepfe: dict[str, tk.Label] = {}
|
||||
self.menues: dict[tk.Label, Popup] = {}
|
||||
self.offener_knopf: tk.Label | None = None
|
||||
|
||||
def menue(self, label: str, underline: int | None = 0) -> Popup:
|
||||
knopf = tk.Label(self, text=label, bg=w.FACE, padx=7, pady=2, bd=1, relief="flat",
|
||||
underline=-1 if underline is None else underline)
|
||||
knopf.pack(side="left")
|
||||
popup = Popup(self)
|
||||
self.knoepfe[label] = knopf
|
||||
self.menues[knopf] = popup
|
||||
knopf.bind("<ButtonPress-1>", lambda e, k=knopf: self._klick(k))
|
||||
knopf.bind("<Enter>", lambda e, k=knopf: self._hover(k, True))
|
||||
knopf.bind("<Leave>", lambda e, k=knopf: self._hover(k, False))
|
||||
if underline is not None:
|
||||
taste = label[underline].lower()
|
||||
self.root.bind_all(f"<Alt-KeyPress-{taste}>", lambda e, k=knopf: self._alt(k))
|
||||
return popup
|
||||
|
||||
# ---- Zustand
|
||||
def knopf_bei(self, x_root: int, y_root: int) -> tk.Label | None:
|
||||
for k in self.knoepfe.values():
|
||||
if (k.winfo_rootx() <= x_root < k.winfo_rootx() + k.winfo_width()
|
||||
and k.winfo_rooty() <= y_root < k.winfo_rooty() + k.winfo_height()):
|
||||
return k
|
||||
return None
|
||||
|
||||
def _darstellen(self):
|
||||
for k in self.knoepfe.values():
|
||||
if k is self.offener_knopf:
|
||||
k.configure(bg=w.NAVY, fg=w.WHITE, relief="flat")
|
||||
else:
|
||||
k.configure(bg=w.FACE, fg=w.DARK, relief="flat")
|
||||
|
||||
# ---- Bedienung
|
||||
def oeffnen(self, knopf: tk.Label):
|
||||
from provtool.gui import modal
|
||||
if modal.offen():
|
||||
return
|
||||
alt = self.offener_knopf
|
||||
if alt is not None and alt is not knopf:
|
||||
self.menues[alt].leiste = None # ohne Rückmeldung schließen, wir öffnen gleich das nächste
|
||||
self.menues[alt].schliessen()
|
||||
self.offener_knopf = knopf
|
||||
self._darstellen()
|
||||
self.menues[knopf].oeffnen(knopf.winfo_rootx(), knopf.winfo_rooty() + knopf.winfo_height(), leiste=self)
|
||||
|
||||
def schliessen(self):
|
||||
if self.offener_knopf is not None:
|
||||
self.menues[self.offener_knopf].schliessen()
|
||||
|
||||
def _geschlossen(self, popup: Popup):
|
||||
if self.offener_knopf is not None and self.menues.get(self.offener_knopf) is popup:
|
||||
self.offener_knopf = None
|
||||
self._darstellen()
|
||||
|
||||
def nachbar(self, richtung: int):
|
||||
liste = list(self.knoepfe.values())
|
||||
if self.offener_knopf in liste:
|
||||
i = (liste.index(self.offener_knopf) + richtung) % len(liste)
|
||||
self.oeffnen(liste[i])
|
||||
|
||||
def _klick(self, knopf):
|
||||
if self.offener_knopf is knopf:
|
||||
self.schliessen()
|
||||
else:
|
||||
self.oeffnen(knopf)
|
||||
return "break"
|
||||
|
||||
def _hover(self, knopf, drin: bool):
|
||||
if self.offener_knopf is None:
|
||||
knopf.configure(relief="raised" if drin else "flat")
|
||||
|
||||
def _alt(self, knopf):
|
||||
self.oeffnen(knopf)
|
||||
if self.offener_knopf is knopf:
|
||||
self.menues[knopf]._schritt(1)
|
||||
return "break"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Modale Sperre ohne Tk-„grab“: Nur der INHALT des aufrufenden Fensters wird gesperrt.
|
||||
|
||||
Titelleiste und Rand bleiben bedienbar – das Hauptfenster lässt sich also verschieben, maximieren
|
||||
und in der Größe ändern, während ein Dialog offen ist. Klickt man auf den gesperrten Inhalt, kommt
|
||||
der Dialog nach vorn und blinkt kurz (wie bei Windows).
|
||||
|
||||
Technik: Jedes Widget im gesperrten Bereich bekommt vorn das Bindungs-Tag ``Pm95Gesperrt``. Dessen
|
||||
Bindungen laufen vor allen anderen und beenden Klicks/Tastendrücke mit „break“.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
TAG = "Pm95Gesperrt"
|
||||
_stapel: list[tk.Toplevel] = [] # offene modale Fenster, innerstes zuletzt
|
||||
|
||||
GESPERRTE_EREIGNISSE = ("<ButtonPress>", "<ButtonRelease>", "<Double-ButtonPress>", "<Triple-ButtonPress>",
|
||||
"<B1-Motion>", "<KeyPress>", "<KeyRelease>", "<MouseWheel>")
|
||||
|
||||
|
||||
def oberstes() -> tk.Toplevel | None:
|
||||
"""Das zuletzt geöffnete, noch offene modale Fenster (oder None)."""
|
||||
while _stapel:
|
||||
d = _stapel[-1]
|
||||
try:
|
||||
if d.winfo_exists():
|
||||
return d
|
||||
except tk.TclError:
|
||||
pass
|
||||
_stapel.pop()
|
||||
return None
|
||||
|
||||
|
||||
def offen() -> bool:
|
||||
return oberstes() is not None
|
||||
|
||||
|
||||
def _gesperrt_geklickt(event):
|
||||
d = oberstes()
|
||||
if d is not None and hasattr(d, "nach_vorn"):
|
||||
d.nach_vorn(blinken=str(event.type) in ("ButtonPress", "4"))
|
||||
return "break"
|
||||
|
||||
|
||||
def _einrichten(root: tk.Misc):
|
||||
# Merker am Tk-Objekt selbst (nicht am Namen: das Hauptfenster heißt in jeder Tk-Instanz „.“)
|
||||
if getattr(root, "_pm95_sperre_eingerichtet", False):
|
||||
return
|
||||
for seq in GESPERRTE_EREIGNISSE:
|
||||
root.bind_class(TAG, seq, _gesperrt_geklickt)
|
||||
root._pm95_sperre_eingerichtet = True
|
||||
|
||||
|
||||
def sperrbereiche(fenster: tk.Misc) -> list[tk.Misc]:
|
||||
"""Welche Widgets eines Fensters gesperrt werden: alles außer Titelleiste (und Rand)."""
|
||||
eigene = getattr(fenster, "sperrbereich", None)
|
||||
if callable(eigene):
|
||||
return list(eigene())
|
||||
titel = getattr(fenster, "titelleiste", None)
|
||||
return [k for k in fenster.winfo_children() if k is not titel and not isinstance(k, tk.Toplevel)]
|
||||
|
||||
|
||||
class Sperre:
|
||||
"""Sperrt den Inhalt von `fenster`, solange `dialog` offen ist."""
|
||||
|
||||
def __init__(self, fenster: tk.Misc, dialog: tk.Toplevel):
|
||||
_einrichten(fenster._root())
|
||||
self.widgets: list[tk.Misc] = []
|
||||
for bereich in sperrbereiche(fenster):
|
||||
self._sperren(bereich)
|
||||
_stapel.append(dialog)
|
||||
self.dialog = dialog
|
||||
|
||||
def _sperren(self, w: tk.Misc):
|
||||
if isinstance(w, tk.Toplevel):
|
||||
return # eigene Fenster (z. B. andere Dialoge) nicht anfassen
|
||||
tags = w.bindtags()
|
||||
if TAG not in tags:
|
||||
w.bindtags((TAG,) + tuple(tags))
|
||||
self.widgets.append(w)
|
||||
for kind in w.winfo_children():
|
||||
self._sperren(kind)
|
||||
|
||||
def aufheben(self):
|
||||
for w in self.widgets:
|
||||
try:
|
||||
if w.winfo_exists():
|
||||
w.bindtags(tuple(t for t in w.bindtags() if t != TAG))
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.widgets = []
|
||||
if self.dialog in _stapel:
|
||||
_stapel.remove(self.dialog)
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Windows: echtes Programmfenster mit selbst gezeichnetem Win95-Rahmen.
|
||||
|
||||
Das Hauptfenster ist für Tk rahmenlos („overrideredirect“). Für Windows war es damit ein Popup ohne
|
||||
Größenrahmen – deshalb gingen Andocken (Fenster an den Rand ziehen), Win+Hoch/Runter/Links/Rechts und
|
||||
das native Ziehen der Fenstergröße nicht.
|
||||
|
||||
Jetzt bekommt das äußere Windows-Fenster die Stile eines normalen Programmfensters (Titelleiste,
|
||||
Größenrahmen, Min/Max, Taskleiste), deren Rahmen aber *unsichtbar* bleibt: Bei WM_NCCALCSIZE melden
|
||||
wir „der Innenbereich ist das ganze Fenster“. Für Tk bleibt es rahmenlos – Tk rechnet bei
|
||||
Größenänderungen also weiter ohne Rahmenaufschlag (sonst würde das Fenster bei jeder internen
|
||||
Größenanpassung um die Rahmenbreite wachsen). Windows erledigt alles Weitere selbst:
|
||||
- Ziehen an der Titelleiste (WM_NCLBUTTONDOWN/HTCAPTION) mit Andocken links/rechts/oben (Maximieren)
|
||||
- Win+Pfeiltasten, Doppelklick, Systemmenü (Alt+Leertaste), Taskleiste, Alt+Tab
|
||||
- Größe ändern: Am Rand meldet das Fenster per WM_NCHITTEST „linker Rand“ usw. -> Windows zeigt den
|
||||
passenden Mauszeiger und zieht die Größe selbst (flüssig, kein hängender Zeiger).
|
||||
|
||||
WICHTIG: Die Fensterprozeduren hier laufen mitten in Tk. Sie dürfen Tk NICHT aufrufen (Tk-Sperre ->
|
||||
Verklemmung), nur Win32-Funktionen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from ctypes import wintypes
|
||||
|
||||
IS_WIN = sys.platform.startswith("win")
|
||||
|
||||
WM_NCDESTROY, WM_NCCALCSIZE, WM_NCHITTEST, WM_NCACTIVATE = 0x0082, 0x0083, 0x0084, 0x0086
|
||||
WM_NCLBUTTONDOWN, WM_SYSCOMMAND, WM_SETCURSOR = 0x00A1, 0x0112, 0x0020
|
||||
HTCLIENT, HTCAPTION, HTTRANSPARENT = 1, 2, -1
|
||||
HT = {"w": 10, "e": 11, "n": 12, "nw": 13, "ne": 14, "s": 15, "sw": 16, "se": 17}
|
||||
GWL_STYLE, GWL_EXSTYLE = -16, -20
|
||||
WS_CAPTION, WS_THICKFRAME, WS_SYSMENU = 0x00C00000, 0x00040000, 0x00080000
|
||||
WS_MINIMIZEBOX, WS_MAXIMIZEBOX = 0x00020000, 0x00010000
|
||||
WS_EX_TOOLWINDOW, WS_EX_APPWINDOW = 0x00000080, 0x00040000
|
||||
ZEIGER = {"n": 32645, "s": 32645, "w": 32644, "e": 32644, "nw": 32642, "se": 32642, "ne": 32643, "sw": 32643}
|
||||
HT_RAND = {v: k for k, v in HT.items()}
|
||||
SWP_NOSIZE, SWP_NOMOVE, SWP_NOZORDER, SWP_NOACTIVATE, SWP_FRAMECHANGED = 0x1, 0x2, 0x4, 0x10, 0x20
|
||||
SC_MAXIMIZE, SC_RESTORE, SC_MINIMIZE = 0xF030, 0xF120, 0xF020
|
||||
|
||||
LRESULT = ctypes.c_ssize_t
|
||||
SUBCLASSPROC = ctypes.WINFUNCTYPE(LRESULT, wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM,
|
||||
ctypes.c_size_t, ctypes.c_size_t) if IS_WIN else None
|
||||
|
||||
|
||||
class NCCALCSIZE_PARAMS(ctypes.Structure):
|
||||
_fields_ = [("rgrc", wintypes.RECT * 3), ("lppos", ctypes.c_void_p)]
|
||||
|
||||
|
||||
class MONITORINFO(ctypes.Structure):
|
||||
_fields_ = [("cbSize", wintypes.DWORD), ("rcMonitor", wintypes.RECT), ("rcWork", wintypes.RECT),
|
||||
("dwFlags", wintypes.DWORD)]
|
||||
|
||||
|
||||
_dll = {}
|
||||
|
||||
|
||||
def _api():
|
||||
if _dll:
|
||||
return _dll["u"], _dll["c"]
|
||||
u = ctypes.WinDLL("user32", use_last_error=True)
|
||||
c = ctypes.WinDLL("comctl32", use_last_error=True)
|
||||
H, U, W, L = wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM
|
||||
for name, args, res in (
|
||||
("GetAncestor", [H, U], H), ("GetWindowRect", [H, ctypes.POINTER(wintypes.RECT)], wintypes.BOOL),
|
||||
("GetClientRect", [H, ctypes.POINTER(wintypes.RECT)], wintypes.BOOL),
|
||||
("IsZoomed", [H], wintypes.BOOL), ("IsIconic", [H], wintypes.BOOL),
|
||||
("GetWindowLongW", [H, ctypes.c_int], ctypes.c_long),
|
||||
("SetWindowLongW", [H, ctypes.c_int, ctypes.c_long], ctypes.c_long),
|
||||
("SetWindowPos", [H, H, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, U], wintypes.BOOL),
|
||||
("MonitorFromRect", [ctypes.POINTER(wintypes.RECT), wintypes.DWORD], wintypes.HMONITOR),
|
||||
("GetMonitorInfoW", [wintypes.HMONITOR, ctypes.POINTER(MONITORINFO)], wintypes.BOOL),
|
||||
("ReleaseCapture", [], wintypes.BOOL), ("SendMessageW", [H, U, W, L], LRESULT),
|
||||
("PostMessageW", [H, U, W, L], wintypes.BOOL), ("ShowWindow", [H, ctypes.c_int], wintypes.BOOL),
|
||||
("WindowFromPoint", [wintypes.POINT], H),
|
||||
("LoadCursorW", [wintypes.HINSTANCE, ctypes.c_void_p], wintypes.HANDLE),
|
||||
("SetCursor", [wintypes.HANDLE], wintypes.HANDLE)):
|
||||
f = getattr(u, name)
|
||||
f.argtypes, f.restype = args, res
|
||||
c.SetWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t, ctypes.c_size_t]
|
||||
c.SetWindowSubclass.restype = wintypes.BOOL
|
||||
c.RemoveWindowSubclass.argtypes = [H, SUBCLASSPROC, ctypes.c_size_t]
|
||||
c.RemoveWindowSubclass.restype = wintypes.BOOL
|
||||
c.DefSubclassProc.argtypes = [H, U, W, L]
|
||||
c.DefSubclassProc.restype = LRESULT
|
||||
_dll.update(u=u, c=c)
|
||||
return u, c
|
||||
|
||||
|
||||
def _rect(h) -> wintypes.RECT:
|
||||
r = wintypes.RECT()
|
||||
_api()[0].GetWindowRect(h, ctypes.byref(r))
|
||||
return r
|
||||
|
||||
|
||||
def _xy(lparam: int) -> tuple[int, int]:
|
||||
"""Bildschirmkoordinaten aus lParam (vorzeichenbehaftet, mehrere Monitore)."""
|
||||
x, y = lparam & 0xFFFF, (lparam >> 16) & 0xFFFF
|
||||
return (x - 0x10000 if x & 0x8000 else x), (y - 0x10000 if y & 0x8000 else y)
|
||||
|
||||
|
||||
def arbeitsbereich_von(rect: wintypes.RECT) -> wintypes.RECT | None:
|
||||
u, _ = _api()
|
||||
mon = u.MonitorFromRect(ctypes.byref(rect), 2) # MONITOR_DEFAULTTONEAREST
|
||||
mi = MONITORINFO()
|
||||
mi.cbSize = ctypes.sizeof(MONITORINFO)
|
||||
return mi.rcWork if mon and u.GetMonitorInfoW(mon, ctypes.byref(mi)) else None
|
||||
|
||||
|
||||
class NativerRahmen:
|
||||
"""Macht das Tk-Hauptfenster zu einem echten Windows-Fenster ohne sichtbaren Windows-Rahmen.
|
||||
|
||||
rand_widgets: Tk-Widgets, die am Fensterrand liegen (deren Randbereich wird für die Maus
|
||||
„durchsichtig“, damit Windows dort den Größenrahmen erkennt).
|
||||
"""
|
||||
|
||||
def __init__(self, root: tk.Tk, rand: int, ecke: int):
|
||||
self.root = root
|
||||
self.rand, self.ecke = rand, ecke
|
||||
self.hwnd = None
|
||||
self.aktiv = False
|
||||
self._procs: dict[int, object] = {} # Referenzen halten, sonst räumt Python die Callbacks weg
|
||||
self._rand_hwnds: set[int] = set()
|
||||
|
||||
# ------------------------------------------------------------------ Einrichten
|
||||
def einrichten(self, rand_widgets: list[tk.Misc]) -> bool:
|
||||
"""Nach dem ersten Anzeigen aufrufen (vorher gibt es das äußere Windows-Fenster noch nicht)."""
|
||||
if self.aktiv:
|
||||
return True
|
||||
try:
|
||||
u, c = _api()
|
||||
self.root.update_idletasks()
|
||||
h = self.aeusseres_fenster()
|
||||
if not h:
|
||||
return False
|
||||
self.hwnd = h
|
||||
stil = u.GetWindowLongW(h, GWL_STYLE)
|
||||
u.SetWindowLongW(h, GWL_STYLE, stil | WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX
|
||||
| WS_MAXIMIZEBOX)
|
||||
ex = u.GetWindowLongW(h, GWL_EXSTYLE)
|
||||
u.SetWindowLongW(h, GWL_EXSTYLE, (ex & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW)
|
||||
if not self._unterklasse(h, self._aussen_proc):
|
||||
return False
|
||||
for wdg in rand_widgets:
|
||||
hw = wdg.winfo_id()
|
||||
if self._unterklasse(hw, self._rand_proc):
|
||||
self._rand_hwnds.add(hw)
|
||||
self.aktiv = True
|
||||
# Rahmen neu berechnen lassen (jetzt: unsichtbar)
|
||||
u.SetWindowPos(h, None, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
|
||||
| SWP_FRAMECHANGED)
|
||||
self._ecken_nicht_runden(h)
|
||||
return True
|
||||
except (OSError, AttributeError, tk.TclError):
|
||||
return False
|
||||
|
||||
def aeusseres_fenster(self) -> int | None:
|
||||
"""Das äußere Windows-Fenster („wm frame“) – None, solange Tk es noch nicht angelegt hat."""
|
||||
try:
|
||||
h = int(self.root.wm_frame(), 16)
|
||||
except (tk.TclError, ValueError):
|
||||
return None
|
||||
return h if h and h != self.root.winfo_id() else None
|
||||
|
||||
def veraltet(self) -> bool:
|
||||
"""Hat Tk das äußere Fenster neu angelegt (z. B. nach „wm resizable“)? Dann neu einrichten."""
|
||||
return self.aktiv and self.aeusseres_fenster() not in (None, self.hwnd)
|
||||
|
||||
def neu_einrichten(self, rand_widgets: list[tk.Misc]) -> bool:
|
||||
self.aktiv = False
|
||||
self._rand_hwnds.clear()
|
||||
return self.einrichten(rand_widgets)
|
||||
|
||||
def _unterklasse(self, h: int, fn) -> bool:
|
||||
_, c = _api()
|
||||
proc = SUBCLASSPROC(fn)
|
||||
if not c.SetWindowSubclass(h, proc, 1, 0):
|
||||
return False
|
||||
self._procs[h] = proc
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _ecken_nicht_runden(h):
|
||||
"""Windows 11 rundet Fensterecken – passt nicht zum Win95-Look."""
|
||||
try:
|
||||
wert = ctypes.c_int(1) # DWMWCP_DONOTROUND
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(wintypes.HWND(h), 33, ctypes.byref(wert), 4)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------ Fensterprozeduren (KEIN Tk!)
|
||||
def randbereich(self, x: int, y: int) -> str:
|
||||
"""Welcher Rand liegt am Bildschirmpunkt? '' = keiner (oder maximiert)."""
|
||||
u, _ = _api()
|
||||
h = self.hwnd
|
||||
if not h or u.IsZoomed(h) or u.IsIconic(h):
|
||||
return ""
|
||||
r = _rect(h)
|
||||
if not (r.left <= x < r.right and r.top <= y < r.bottom):
|
||||
return ""
|
||||
d, k = self.rand, self.ecke
|
||||
ns = "n" if y < r.top + d else "s" if y >= r.bottom - d else ""
|
||||
we = "w" if x < r.left + d else "e" if x >= r.right - d else ""
|
||||
if ns and not we:
|
||||
we = "w" if x < r.left + k else "e" if x >= r.right - k else ""
|
||||
elif we and not ns:
|
||||
ns = "n" if y < r.top + k else "s" if y >= r.bottom - k else ""
|
||||
return ns + we
|
||||
|
||||
def _aussen_proc(self, hwnd, msg, wparam, lparam, _id, _ref):
|
||||
_, c = _api()
|
||||
try:
|
||||
if msg == WM_NCCALCSIZE:
|
||||
if wparam:
|
||||
# Innenbereich = ganzes Fenster. Maximiert legt Windows das Fenster um die (jetzt
|
||||
# unsichtbare) Rahmenbreite über den Bildschirmrand -> auf den Arbeitsbereich begrenzen.
|
||||
if _api()[0].IsZoomed(hwnd):
|
||||
p = ctypes.cast(lparam, ctypes.POINTER(NCCALCSIZE_PARAMS)).contents
|
||||
arbeit = arbeitsbereich_von(p.rgrc[0])
|
||||
if arbeit is not None:
|
||||
p.rgrc[0] = arbeit
|
||||
return 0
|
||||
if msg == WM_NCACTIVATE:
|
||||
# Nicht den (unsichtbaren) Windows-Rahmen neu zeichnen lassen (klassisches Design)
|
||||
return c.DefSubclassProc(hwnd, msg, wparam, -1)
|
||||
if msg == WM_NCHITTEST:
|
||||
rand = self.randbereich(*_xy(lparam))
|
||||
if rand:
|
||||
return HT[rand]
|
||||
return HTCLIENT
|
||||
if msg == WM_SETCURSOR and (lparam & 0xFFFF) in HT_RAND:
|
||||
u = _api()[0]
|
||||
u.SetCursor(u.LoadCursorW(None, ZEIGER[HT_RAND[lparam & 0xFFFF]]))
|
||||
return 1
|
||||
if msg == WM_NCDESTROY:
|
||||
self._entfernen(hwnd)
|
||||
except Exception: # niemals eine Ausnahme in die Fensterprozedur durchlassen
|
||||
pass
|
||||
return c.DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||
|
||||
def _rand_proc(self, hwnd, msg, wparam, lparam, _id, _ref):
|
||||
_, c = _api()
|
||||
try:
|
||||
if msg == WM_NCHITTEST and self.randbereich(*_xy(lparam)):
|
||||
return HTTRANSPARENT # Maus „fällt durch“ zum äußeren Fenster -> Größenrahmen
|
||||
if msg == WM_NCDESTROY:
|
||||
self._entfernen(hwnd)
|
||||
except Exception:
|
||||
pass
|
||||
return c.DefSubclassProc(hwnd, msg, wparam, lparam)
|
||||
|
||||
def _entfernen(self, hwnd):
|
||||
proc = self._procs.pop(hwnd, None)
|
||||
if proc is not None:
|
||||
try:
|
||||
_api()[1].RemoveWindowSubclass(hwnd, proc, 1)
|
||||
except OSError:
|
||||
pass
|
||||
# Callback-Objekt bleibt bis Programmende referenziert (Windows könnte ihn noch einmal rufen)
|
||||
_BEHALTEN.append(proc)
|
||||
|
||||
# ------------------------------------------------------------------ Bedienung
|
||||
def verschieben(self) -> None:
|
||||
"""Titelleiste gedrückt: Windows zieht das Fenster (mit Andocken an den Bildschirmrand)."""
|
||||
u, _ = _api()
|
||||
u.ReleaseCapture()
|
||||
u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0)
|
||||
|
||||
def groesse_ziehen(self, rand: str) -> None:
|
||||
u, _ = _api()
|
||||
u.ReleaseCapture()
|
||||
u.SendMessageW(self.hwnd, WM_NCLBUTTONDOWN, HT[rand], 0)
|
||||
|
||||
def maximiert(self) -> bool:
|
||||
return bool(self.hwnd and _api()[0].IsZoomed(self.hwnd))
|
||||
|
||||
def befehl(self, sc: int) -> None:
|
||||
"""SC_MAXIMIZE / SC_RESTORE / SC_MINIMIZE – wie über das Systemmenü (mit Windows-Animation)."""
|
||||
_api()[0].SendMessageW(self.hwnd, WM_SYSCOMMAND, sc, 0)
|
||||
|
||||
def aussenmass(self) -> tuple[int, int, int, int]:
|
||||
r = _rect(self.hwnd)
|
||||
return r.left, r.top, r.right - r.left, r.bottom - r.top
|
||||
|
||||
def setzen(self, x: int, y: int, breite: int, hoehe: int) -> None:
|
||||
"""Position und Größe des Fensters genau so setzen (ohne Tk-Rahmenaufschlag)."""
|
||||
_api()[0].SetWindowPos(self.hwnd, None, x, y, breite, hoehe, SWP_NOZORDER | SWP_NOACTIVATE)
|
||||
|
||||
def innen_gleich_aussen(self) -> bool:
|
||||
"""Für Tests: Ist der Windows-Rahmen wirklich unsichtbar?"""
|
||||
u, _ = _api()
|
||||
a, i = _rect(self.hwnd), wintypes.RECT()
|
||||
u.GetClientRect(self.hwnd, ctypes.byref(i))
|
||||
return (i.right - i.left, i.bottom - i.top) == (a.right - a.left, a.bottom - a.top)
|
||||
|
||||
def stil(self) -> tuple[int, int]:
|
||||
u, _ = _api()
|
||||
return u.GetWindowLongW(self.hwnd, GWL_STYLE), u.GetWindowLongW(self.hwnd, GWL_EXSTYLE)
|
||||
|
||||
def fenster_bei(self, x: int, y: int) -> int | None:
|
||||
"""Für Tests: Welches Fenster bekommt die Maus am Bildschirmpunkt (berücksichtigt HTTRANSPARENT)?"""
|
||||
return _api()[0].WindowFromPoint(wintypes.POINT(x, y))
|
||||
|
||||
def trefferpruefung(self, hwnd: int, x: int, y: int) -> int:
|
||||
"""Für Tests: Was meldet das Fenster `hwnd` für den Bildschirmpunkt (WM_NCHITTEST)?"""
|
||||
lp = ((y & 0xFFFF) << 16) | (x & 0xFFFF)
|
||||
return _api()[0].SendMessageW(hwnd, WM_NCHITTEST, 0, lp)
|
||||
|
||||
|
||||
_BEHALTEN: list = []
|
||||
+197
-40
@@ -3,8 +3,11 @@
|
||||
Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet
|
||||
(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben,
|
||||
Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts,
|
||||
graue Menüleiste. Unter Windows sorgen ein paar Win32-Aufrufe dafür, dass das Fenster
|
||||
trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann.
|
||||
graue Menüleiste.
|
||||
|
||||
Unter Windows ist das Hauptfenster trotzdem ein echtes Programmfenster (siehe nativ.py): Windows
|
||||
übernimmt Verschieben, Größe ändern, Andocken am Bildschirmrand, Win+Pfeiltasten, Taskleiste und
|
||||
Alt+Tab. Auf anderen Systemen (Entwicklung/Tests) macht Tk das selbst.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -183,38 +186,34 @@ class Titelleiste(tk.Frame):
|
||||
|
||||
def _start(self, e):
|
||||
self._ab = (e.x_root - self.fenster.winfo_rootx(), e.y_root - self.fenster.winfo_rooty())
|
||||
self._nativ = False
|
||||
if NATIV_VERSCHIEBEN and IS_WIN and self.ziehen_erlaubt():
|
||||
# Windows übernimmt das Ziehen: flüssig und mit Andocken am Bildschirmrand
|
||||
from provtool.gui import win32
|
||||
self._nativ = win32.nativ_verschieben(self.fenster)
|
||||
if self._nativ:
|
||||
return "break"
|
||||
|
||||
def _ziehen(self, e):
|
||||
if not self.ziehen_erlaubt():
|
||||
if self._nativ or not self.ziehen_erlaubt():
|
||||
return
|
||||
self.fenster.geometry(f"+{e.x_root - self._ab[0]}+{e.y_root - self._ab[1]}")
|
||||
|
||||
def blinken(self, mal: int = 3):
|
||||
"""Titelleiste kurz grau/blau blinken lassen (wie Windows bei gesperrtem Fenster)."""
|
||||
farben = [w.SHADOW, w.NAVY] * mal
|
||||
|
||||
class Menueleiste(tk.Frame):
|
||||
"""Graue Menüleiste (die native von Windows 10/11 ist weiß und lässt sich nicht umfärben)."""
|
||||
def schritt(i=0):
|
||||
if i >= len(farben) or not self.winfo_exists():
|
||||
return
|
||||
for widget in (self, self.titel_lbl):
|
||||
widget.configure(bg=farben[i])
|
||||
self.after(70, lambda: schritt(i + 1))
|
||||
schritt()
|
||||
|
||||
def __init__(self, master, root: tk.Misc):
|
||||
super().__init__(master, bg=w.FACE)
|
||||
self.root = root
|
||||
self.knoepfe: dict[str, tk.Menubutton] = {}
|
||||
|
||||
def menue(self, label: str, underline: int | None = 0) -> tk.Menu:
|
||||
mb = tk.Menubutton(self, text=label, bg=w.FACE, relief="flat", bd=1, padx=6, pady=1,
|
||||
activebackground=w.NAVY, activeforeground=w.WHITE,
|
||||
underline=-1 if underline is None else underline)
|
||||
menu = tk.Menu(mb, tearoff=0)
|
||||
mb.configure(menu=menu)
|
||||
mb.pack(side="left")
|
||||
self.knoepfe[label] = mb
|
||||
if underline is not None:
|
||||
taste = label[underline].lower()
|
||||
self.root.bind_all(f"<Alt-KeyPress-{taste}>", lambda e, m=mb: self.oeffnen(m))
|
||||
return menu
|
||||
|
||||
def oeffnen(self, mb: tk.Menubutton):
|
||||
menu = mb.nametowidget(mb.cget("menu"))
|
||||
menu.tk_popup(mb.winfo_rootx(), mb.winfo_rooty() + mb.winfo_height())
|
||||
return "break"
|
||||
NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen
|
||||
NATIVER_RAHMEN = True # Windows: echtes Fenster mit unsichtbarem Rahmen (nativ.py)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Hauptfenster
|
||||
@@ -222,7 +221,8 @@ class Menueleiste(tk.Frame):
|
||||
class Hauptrahmen:
|
||||
"""Umgibt das Hauptfenster. Im klassischen Modus mit eigener Titelleiste, sonst nur ein Container."""
|
||||
|
||||
RAND = 5 # Pixel am Rand, an denen man die Größe ziehen kann
|
||||
RAND = 6 # Pixel am Rand, an denen man die Größe ziehen kann (= sichtbarer Rahmen)
|
||||
ECKE = 18 # so weit vom Eck entfernt gilt der Rand noch als Ecke (diagonal ziehen)
|
||||
|
||||
def __init__(self, root: tk.Tk, titel: str, icon: tk.PhotoImage, schliessen, klassisch: bool = True,
|
||||
minbreite: int = 900, minhoehe: int = 520):
|
||||
@@ -237,26 +237,94 @@ class Hauptrahmen:
|
||||
self.aussen.pack(fill="both", expand=True)
|
||||
self.inhalt = self.aussen
|
||||
self.titelleiste = None
|
||||
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined] # für modal.py
|
||||
return
|
||||
|
||||
root.overrideredirect(True)
|
||||
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=2, pady=2)
|
||||
self.aussen = tk.Frame(root, bg=w.FACE, bd=2, relief="raised", padx=self.RAND - 2, pady=self.RAND - 2)
|
||||
self.aussen.pack(fill="both", expand=True)
|
||||
self.titelleiste = Titelleiste(self.aussen, root, titel, icon, ("min", "max", "close"),
|
||||
schliessen=schliessen, minimieren=self.minimieren,
|
||||
maximieren=self.umschalten)
|
||||
self.titelleiste.pack(fill="x")
|
||||
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert
|
||||
# Maximiert darf man nur nativ ziehen (Windows stellt das Fenster dabei wieder her)
|
||||
self.titelleiste.ziehen_erlaubt = lambda: not self.maximiert or self.nativ_an
|
||||
self.inhalt = tk.Frame(self.aussen, bg=w.FACE)
|
||||
self.inhalt.pack(fill="both", expand=True)
|
||||
# Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar
|
||||
root.sperrbereich = lambda: [self.inhalt] # type: ignore[attr-defined]
|
||||
|
||||
self._rand: str = ""
|
||||
self._resize_geplant = None
|
||||
self.aussen.bind("<Motion>", self._rand_cursor)
|
||||
self.aussen.bind("<Leave>", lambda e: self.aussen.configure(cursor=""))
|
||||
# Mauszeiger zurücksetzen, sobald der Rand verlassen wird – Kindfenster erben sonst den
|
||||
# Größenänderungs-Zeiger, wenn das letzte Motion-Ereignis am Rand verpasst wurde.
|
||||
self.aussen.bind("<Leave>", self._cursor_zurueck)
|
||||
self.inhalt.bind("<Enter>", self._cursor_zurueck)
|
||||
self.titelleiste.bind("<Enter>", self._cursor_zurueck, add="+")
|
||||
self.aussen.bind("<ButtonPress-1>", self._resize_start)
|
||||
self.aussen.bind("<B1-Motion>", self._resize)
|
||||
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
|
||||
root.after(50, lambda: taskleiste_anzeigen(root))
|
||||
|
||||
self.nativ = None
|
||||
if IS_WIN and NATIVER_RAHMEN:
|
||||
from provtool.gui.nativ import NativerRahmen
|
||||
self.nativ = NativerRahmen(root, self.RAND, self.ECKE)
|
||||
root.bind("<Configure>", self._fenster_geaendert, add="+")
|
||||
root.bind("<Map>", lambda e: e.widget is root and self._nativ_einrichten(), add="+")
|
||||
self._nativ_einrichten() # möglichst schon vor dem ersten Anzeigen
|
||||
root.after(50, self._fenster_einrichten)
|
||||
|
||||
# ---- natives Windows-Fenster
|
||||
@property
|
||||
def nativ_an(self) -> bool:
|
||||
return self.nativ is not None and self.nativ.aktiv
|
||||
|
||||
def _nativ_einrichten(self) -> bool:
|
||||
if self.nativ is None:
|
||||
return False
|
||||
if self.nativ.aktiv and not self.nativ.veraltet():
|
||||
return True
|
||||
try:
|
||||
sichtbar = bool(self.root.winfo_viewable())
|
||||
except tk.TclError:
|
||||
sichtbar = True
|
||||
if self.nativ.veraltet(): # Tk hat das äußere Fenster neu angelegt
|
||||
ok = self.nativ.neu_einrichten([self.root, self.aussen])
|
||||
else:
|
||||
ok = self.nativ.einrichten([self.root, self.aussen])
|
||||
if ok:
|
||||
# Erst eingerichtet, als das Fenster schon zu sehen war -> Taskleiste muss es neu erfassen
|
||||
self._taskleiste_neu = sichtbar
|
||||
return ok
|
||||
|
||||
_taskleiste_neu = True
|
||||
|
||||
def _fenster_einrichten(self):
|
||||
self._nativ_einrichten()
|
||||
if self.nativ_an and not self._taskleiste_neu:
|
||||
return # vor dem ersten Anzeigen eingerichtet: Windows kennt den Taskleisten-Stil schon
|
||||
taskleiste_anzeigen(self.root)
|
||||
|
||||
def _fenster_geaendert(self, e):
|
||||
"""Zustand nachführen – maximieren/wiederherstellen geht jetzt auch über Windows selbst
|
||||
(Win+Hoch, an den oberen Rand ziehen, Doppelklick, Taskleiste)."""
|
||||
if e.widget is not self.root or self.nativ is None:
|
||||
return
|
||||
if self.nativ.veraltet():
|
||||
self._nativ_einrichten()
|
||||
if not self.nativ_an:
|
||||
return
|
||||
try:
|
||||
maximiert = self.nativ.maximiert()
|
||||
if maximiert != self.maximiert:
|
||||
self.maximiert = maximiert
|
||||
self.titelleiste.max_symbol(maximiert)
|
||||
self._cursor_zurueck()
|
||||
if not maximiert and self.root.wm_state() == "normal":
|
||||
self._normal_geo = self.root.geometry()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def alt_f4(self, widget) -> str:
|
||||
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
|
||||
@@ -264,14 +332,23 @@ class Hauptrahmen:
|
||||
# Ist ein modaler Dialog offen, gilt Alt+F4 immer ihm – egal wo der Tastaturfokus liegt
|
||||
# (unter Windows liefert Tk Tastendrücke an das Fenster mit Fokus, das kann noch das
|
||||
# Hauptfenster sein).
|
||||
sperre = self.root.grab_current()
|
||||
from provtool.gui import modal
|
||||
sperre = self.root.grab_current() or modal.oberstes()
|
||||
popup = self._offenes_popup()
|
||||
if sperre is not None and sperre.winfo_toplevel() is not self.root:
|
||||
fenster = sperre.winfo_toplevel()
|
||||
elif popup is not None: # Sperre (noch) nicht gesetzt – trotzdem gilt Alt+F4 dem Popup
|
||||
fenster = popup
|
||||
else:
|
||||
fenster = widget.winfo_toplevel() if isinstance(widget, tk.Misc) else self.root
|
||||
except tk.TclError:
|
||||
fenster = self.root
|
||||
if fenster is self.root:
|
||||
from provtool.gui import modal
|
||||
offen = modal.oberstes()
|
||||
if offen is not None: # Alt+F4 bei offenem Dialog schließt den Dialog
|
||||
offen.abbrechen()
|
||||
return "break"
|
||||
self._schliessen()
|
||||
elif hasattr(fenster, "abbrechen"):
|
||||
fenster.abbrechen()
|
||||
@@ -279,6 +356,25 @@ class Hauptrahmen:
|
||||
fenster.destroy()
|
||||
return "break"
|
||||
|
||||
def _offenes_popup(self):
|
||||
"""Innerstes sichtbares Popup mit abbrechen() (Dialog, Rückfrage, Rundgang-Sprechblase)."""
|
||||
gefunden = []
|
||||
|
||||
def suche(widget):
|
||||
for kind in widget.winfo_children():
|
||||
# Fensterstatus statt winfo_ismapped(): direkt nach dem Einblenden ist der Status schon
|
||||
# „normal“, gezeichnet ist das Fenster aber evtl. noch nicht (langsamer PC, Wine).
|
||||
if isinstance(kind, tk.Toplevel) and hasattr(kind, "abbrechen"):
|
||||
try:
|
||||
offen = kind.winfo_exists() and kind.wm_state() != "withdrawn"
|
||||
except tk.TclError:
|
||||
offen = False
|
||||
if offen:
|
||||
gefunden.append(kind)
|
||||
suche(kind)
|
||||
suche(self.root)
|
||||
return gefunden[-1] if gefunden else None
|
||||
|
||||
# ---- öffentliche Helfer
|
||||
def set_titel(self, text: str):
|
||||
self.root.title(text)
|
||||
@@ -291,10 +387,16 @@ class Hauptrahmen:
|
||||
return None
|
||||
self._griff_bild = griff_bild(master)
|
||||
g = tk.Label(master, image=self._griff_bild, bg=w.FACE, cursor="sizing" if not IS_WIN else "size_nw_se")
|
||||
g.bind("<ButtonPress-1>", lambda e: self._resize_start(e, "se"))
|
||||
g.bind("<ButtonPress-1>", self._griff_start)
|
||||
g.bind("<B1-Motion>", self._resize)
|
||||
return g
|
||||
|
||||
def _griff_start(self, e):
|
||||
if self.nativ_an and not self.maximiert:
|
||||
self.nativ.groesse_ziehen("se") # Windows zieht die Größe (flüssig)
|
||||
return "break"
|
||||
self._resize_start(e, "se")
|
||||
|
||||
def geometrie_setzen(self, geo: str | None, maximiert: bool = False):
|
||||
self.root.update_idletasks()
|
||||
if not geo or "+" not in geo:
|
||||
@@ -308,6 +410,8 @@ class Hauptrahmen:
|
||||
breite, hoehe = min(breite, bw), min(hoehe, bh)
|
||||
geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}"
|
||||
self.root.geometry(geo)
|
||||
self._normal_geo = geo
|
||||
self._nativ_einrichten()
|
||||
if maximiert and self.klassisch:
|
||||
self.root.after(100, self.maximieren)
|
||||
|
||||
@@ -316,7 +420,14 @@ class Hauptrahmen:
|
||||
|
||||
# ---- Fensterfunktionen
|
||||
def minimieren(self):
|
||||
# Windows minimiert keine overrideredirect-Fenster → kurz normalen Rahmen aktivieren
|
||||
if IS_WIN:
|
||||
# Direkt über Windows: offene Dialoge (gehören dem Hauptfenster) werden mit aus- und
|
||||
# wieder eingeblendet. NICHT overrideredirect umschalten – Tk baut dabei das Fenster neu
|
||||
# und Windows würde die zugehörigen Dialoge zerstören.
|
||||
from provtool.gui import win32
|
||||
if win32.minimieren(self.root):
|
||||
return
|
||||
# Tk minimiert keine overrideredirect-Fenster -> kurz normalen Rahmen aktivieren
|
||||
geo = self.root.geometry()
|
||||
self.root.overrideredirect(False)
|
||||
self.root.iconify()
|
||||
@@ -332,6 +443,13 @@ class Hauptrahmen:
|
||||
def maximieren(self):
|
||||
if self.maximiert:
|
||||
return
|
||||
if self.nativ_an:
|
||||
from provtool.gui.nativ import SC_MAXIMIZE
|
||||
self._normal_geo = self.root.geometry()
|
||||
self.nativ.befehl(SC_MAXIMIZE)
|
||||
self.maximiert = self.nativ.maximiert()
|
||||
self.titelleiste.max_symbol(self.maximiert)
|
||||
return
|
||||
self._normal_geo = self.root.geometry()
|
||||
x, y, bw, bh = arbeitsbereich(self.root)
|
||||
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
|
||||
@@ -341,6 +459,12 @@ class Hauptrahmen:
|
||||
def wiederherstellen(self):
|
||||
if not self.maximiert:
|
||||
return
|
||||
if self.nativ_an:
|
||||
from provtool.gui.nativ import SC_RESTORE
|
||||
self.nativ.befehl(SC_RESTORE)
|
||||
self.maximiert = self.nativ.maximiert()
|
||||
self.titelleiste.max_symbol(self.maximiert)
|
||||
return
|
||||
self.maximiert = False
|
||||
if self._normal_geo:
|
||||
self.root.geometry(self._normal_geo)
|
||||
@@ -351,9 +475,25 @@ class Hauptrahmen:
|
||||
|
||||
# ---- Größe ändern
|
||||
def _welcher_rand(self, x, y) -> str:
|
||||
bw, bh, r = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND
|
||||
rand = ("n" if y < r else "s" if y >= bh - r else "") + ("w" if x < r else "e" if x >= bw - r else "")
|
||||
return rand
|
||||
bw, bh, r, k = self.aussen.winfo_width(), self.aussen.winfo_height(), self.RAND, self.ECKE
|
||||
ns = "n" if y < r else "s" if y >= bh - r else ""
|
||||
we = "w" if x < r else "e" if x >= bw - r else ""
|
||||
# an einer Kante nahe am Eck -> Ecke (diagonal), wie bei Windows
|
||||
if ns and not we:
|
||||
we = "w" if x < k else "e" if x >= bw - k else ""
|
||||
elif we and not ns:
|
||||
ns = "n" if y < k else "s" if y >= bh - k else ""
|
||||
return ns + we
|
||||
|
||||
def _cursor_zurueck(self, _e=None):
|
||||
if self._rand_zeiger:
|
||||
self._rand_zeiger = False
|
||||
try:
|
||||
self.aussen.configure(cursor="")
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
_rand_zeiger = False
|
||||
|
||||
CURSOR_WIN = {"n": "size_ns", "s": "size_ns", "e": "size_we", "w": "size_we",
|
||||
"nw": "size_nw_se", "se": "size_nw_se", "ne": "size_ne_sw", "sw": "size_ne_sw"}
|
||||
@@ -362,16 +502,23 @@ class Hauptrahmen:
|
||||
"sw": "bottom_left_corner"}
|
||||
|
||||
def _rand_cursor(self, e):
|
||||
if self.maximiert:
|
||||
if self.maximiert or self.nativ_an: # nativ: Windows zeigt den Zeiger selbst
|
||||
return
|
||||
rand = self._welcher_rand(e.x, e.y)
|
||||
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
|
||||
try:
|
||||
self.aussen.configure(cursor=(self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, ""))
|
||||
self.aussen.configure(cursor=zeiger)
|
||||
self._rand_zeiger = bool(zeiger)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _resize_start(self, e, rand: str | None = None):
|
||||
self._rand = rand or self._welcher_rand(e.x, e.y)
|
||||
if self._rand and self.nativ_an and not self.maximiert:
|
||||
# sollte nicht vorkommen (der Rand ist für Windows durchlässig) – dann eben so
|
||||
self.nativ.groesse_ziehen(self._rand)
|
||||
self._rand = ""
|
||||
return "break"
|
||||
self._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(),
|
||||
self.root.winfo_width(), self.root.winfo_height())
|
||||
|
||||
@@ -390,4 +537,14 @@ class Hauptrahmen:
|
||||
if "n" in self._rand:
|
||||
neu = max(self.minhoehe, bh - dy)
|
||||
y, bh = y + (bh - neu), neu
|
||||
self.root.geometry(f"{bw}x{bh}+{x}+{y}")
|
||||
# Mausbewegungen bündeln: nur die jeweils letzte Größe anwenden (flüssiger unter Windows)
|
||||
self._resize_ziel = f"{bw}x{bh}+{x}+{y}"
|
||||
if self._resize_geplant is None:
|
||||
self._resize_geplant = self.root.after(15, self._resize_anwenden)
|
||||
|
||||
def _resize_anwenden(self):
|
||||
self._resize_geplant = None
|
||||
try:
|
||||
self.root.geometry(self._resize_ziel)
|
||||
except tk.TclError:
|
||||
pass
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Win32-Hilfen für modale Fenster (nur unter Windows wirksam, sonst ohne Funktion).
|
||||
|
||||
Wichtig: Rahmenlose Fenster NICHT zusätzlich mit Tk `transient()` versehen – Tk legt sie dann als
|
||||
Kindfenster des Desktops (WS_CHILD) an, und denen kann Windows keinen Besitzer geben.
|
||||
|
||||
Rahmenlose Tk-Fenster (overrideredirect) gehören für Windows zu keinem anderen Fenster. Holt man das
|
||||
Programm per Taskleiste oder Alt+Tab zurück, kommt dann nur das Hauptfenster nach vorn – ein offener
|
||||
Dialog bliebe dahinter verborgen. Deshalb machen wir es wie Windows bei echten modalen Dialogen:
|
||||
der Dialog bekommt das aufrufende Fenster als *Besitzer*, und der Besitzer wird solange *deaktiviert*.
|
||||
Windows bringt dann bei Klick aufs Hauptfenster, Taskleiste und Alt+Tab automatisch den Dialog nach vorn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tkinter as tk
|
||||
|
||||
IS_WIN = sys.platform.startswith("win")
|
||||
GWLP_HWNDPARENT = -8
|
||||
GW_OWNER = 4
|
||||
|
||||
_u32 = None
|
||||
|
||||
|
||||
def user32():
|
||||
"""user32 mit korrekten 64-Bit-Signaturen (Fensterhandles dürfen nicht abgeschnitten werden)."""
|
||||
global _u32
|
||||
if _u32 is None:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
u = ctypes.WinDLL("user32", use_last_error=True)
|
||||
u.GetParent.argtypes = [wintypes.HWND]
|
||||
u.GetParent.restype = wintypes.HWND
|
||||
u.SetWindowLongPtrW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_void_p]
|
||||
u.SetWindowLongPtrW.restype = ctypes.c_void_p
|
||||
u.EnableWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
|
||||
u.EnableWindow.restype = wintypes.BOOL
|
||||
u.IsWindowEnabled.argtypes = [wintypes.HWND]
|
||||
u.IsWindowEnabled.restype = wintypes.BOOL
|
||||
u.GetWindow.argtypes = [wintypes.HWND, wintypes.UINT]
|
||||
u.GetWindow.restype = wintypes.HWND
|
||||
u.GetLastActivePopup.argtypes = [wintypes.HWND]
|
||||
u.GetLastActivePopup.restype = wintypes.HWND
|
||||
u.SetForegroundWindow.argtypes = [wintypes.HWND]
|
||||
u.SetForegroundWindow.restype = wintypes.BOOL
|
||||
u.IsWindow.argtypes = [wintypes.HWND]
|
||||
u.IsWindow.restype = wintypes.BOOL
|
||||
u.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
|
||||
u.GetAncestor.restype = wintypes.HWND
|
||||
u.GetTopWindow.argtypes = [wintypes.HWND]
|
||||
u.GetTopWindow.restype = wintypes.HWND
|
||||
u.BringWindowToTop.argtypes = [wintypes.HWND]
|
||||
u.BringWindowToTop.restype = wintypes.BOOL
|
||||
u.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
|
||||
u.GetWindowLongW.restype = ctypes.c_long
|
||||
u.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
|
||||
u.SetWindowLongW.restype = ctypes.c_long
|
||||
u.ReleaseCapture.argtypes = []
|
||||
u.ReleaseCapture.restype = wintypes.BOOL
|
||||
u.SendMessageW.argtypes = [wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM]
|
||||
u.SendMessageW.restype = wintypes.LPARAM
|
||||
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
|
||||
u.ShowWindow.restype = wintypes.BOOL
|
||||
u.IsIconic.argtypes = [wintypes.HWND]
|
||||
u.IsIconic.restype = wintypes.BOOL
|
||||
u.FlashWindow.argtypes = [wintypes.HWND, wintypes.BOOL]
|
||||
u.FlashWindow.restype = wintypes.BOOL
|
||||
_u32 = u
|
||||
return _u32
|
||||
|
||||
|
||||
def hwnd(fenster: tk.Misc) -> int | None:
|
||||
"""Handle des äußeren (obersten) Windows-Fensters eines Tk-Toplevels."""
|
||||
if not IS_WIN:
|
||||
return None
|
||||
try:
|
||||
return user32().GetAncestor(fenster.winfo_toplevel().winfo_id(), 2) # GA_ROOT
|
||||
except (tk.TclError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def stapel(*fenster: tk.Misc) -> list[int]:
|
||||
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
|
||||
ziele = [hwnd(f) for f in fenster]
|
||||
out, w = [], user32().GetTopWindow(None)
|
||||
while w:
|
||||
if w in ziele:
|
||||
out.append(ziele.index(w))
|
||||
w = user32().GetWindow(w, 2) # GW_HWNDNEXT
|
||||
return out
|
||||
|
||||
|
||||
def nach_vorn(fenster: tk.Misc) -> None:
|
||||
h = hwnd(fenster)
|
||||
if h:
|
||||
user32().SetForegroundWindow(h)
|
||||
user32().BringWindowToTop(h)
|
||||
|
||||
|
||||
GWL_EXSTYLE, WS_EX_APPWINDOW, WS_EX_TOOLWINDOW = -20, 0x00040000, 0x00000080
|
||||
|
||||
|
||||
def hat_taskleisteneintrag(fenster: tk.Misc) -> bool:
|
||||
h = hwnd(fenster)
|
||||
if not h:
|
||||
return False
|
||||
stil = user32().GetWindowLongW(h, GWL_EXSTYLE)
|
||||
return bool(stil & WS_EX_APPWINDOW) and not (stil & WS_EX_TOOLWINDOW)
|
||||
|
||||
|
||||
def taskleisteneintrag(fenster: tk.Misc) -> bool:
|
||||
"""Eigener Eintrag in Taskleiste/Alt+Tab (rahmenlose Tk-Fenster haben sonst keinen).
|
||||
Wirkt erst nach erneutem Einblenden – gibt True zurück, wenn das nötig ist."""
|
||||
h = hwnd(fenster)
|
||||
if not h:
|
||||
return False
|
||||
stil = user32().GetWindowLongW(h, GWL_EXSTYLE)
|
||||
neu = (stil & ~WS_EX_TOOLWINDOW) | WS_EX_APPWINDOW
|
||||
if neu != stil:
|
||||
user32().SetWindowLongW(h, GWL_EXSTYLE, neu)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def nativ_verschieben(fenster: tk.Misc) -> bool:
|
||||
"""Windows übernimmt das Ziehen (flüssig, mit Andocken am Bildschirmrand). True bei Erfolg."""
|
||||
h = hwnd(fenster)
|
||||
if not h:
|
||||
return False
|
||||
user32().ReleaseCapture()
|
||||
user32().SendMessageW(h, 0x00A1, 2, 0) # WM_NCLBUTTONDOWN, HTCAPTION
|
||||
return True
|
||||
|
||||
|
||||
def minimieren(fenster: tk.Misc) -> bool:
|
||||
"""Direkt über Windows minimieren – Tk verweigert das bei rahmenlosen Fenstern. Eigene Popups
|
||||
(Dialoge) werden dabei von Windows mit ausgeblendet und beim Wiederherstellen mit eingeblendet."""
|
||||
h = hwnd(fenster)
|
||||
if not h:
|
||||
return False
|
||||
user32().ShowWindow(h, 6) # SW_MINIMIZE
|
||||
return True
|
||||
|
||||
|
||||
def ist_minimiert(fenster: tk.Misc) -> bool:
|
||||
h = hwnd(fenster)
|
||||
return bool(user32().IsIconic(h)) if h else False
|
||||
|
||||
|
||||
def blinken(fenster: tk.Misc) -> None:
|
||||
h = hwnd(fenster)
|
||||
if h:
|
||||
user32().FlashWindow(h, True)
|
||||
|
||||
|
||||
def besitzer_setzen(fenster: tk.Misc, besitzer: tk.Misc) -> None:
|
||||
h, b = hwnd(fenster), hwnd(besitzer)
|
||||
if h and b:
|
||||
user32().SetWindowLongPtrW(h, GWLP_HWNDPARENT, b)
|
||||
|
||||
|
||||
def besitzer_von(fenster: tk.Misc) -> int | None:
|
||||
h = hwnd(fenster)
|
||||
return user32().GetWindow(h, GW_OWNER) if h else None
|
||||
|
||||
|
||||
def bedienbar(fenster: tk.Misc, an: bool) -> None:
|
||||
h = hwnd(fenster)
|
||||
if h and user32().IsWindow(h):
|
||||
user32().EnableWindow(h, bool(an))
|
||||
|
||||
|
||||
def ist_bedienbar(fenster: tk.Misc) -> bool:
|
||||
h = hwnd(fenster)
|
||||
return bool(user32().IsWindowEnabled(h)) if h else True
|
||||
|
||||
|
||||
def letztes_popup(fenster: tk.Misc) -> int | None:
|
||||
h = hwnd(fenster)
|
||||
return user32().GetLastActivePopup(h) if h else None
|
||||
|
||||
|
||||
class Modal:
|
||||
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
|
||||
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
|
||||
beim Schließen ein fremdes Programm statt unseres Hauptfensters."""
|
||||
|
||||
def __init__(self, fenster: tk.Misc, besitzer: tk.Misc, sperren: bool = True):
|
||||
self.besitzer = besitzer
|
||||
self.gesperrt = False
|
||||
if not IS_WIN:
|
||||
return
|
||||
besitzer_setzen(fenster, besitzer)
|
||||
if sperren and ist_bedienbar(besitzer):
|
||||
bedienbar(besitzer, False)
|
||||
self.gesperrt = True
|
||||
|
||||
def beenden(self) -> None:
|
||||
if self.gesperrt:
|
||||
self.gesperrt = False
|
||||
try:
|
||||
bedienbar(self.besitzer, True)
|
||||
except tk.TclError:
|
||||
pass
|
||||
+131
-24
@@ -294,6 +294,9 @@ def apply(root: tk.Tk) -> None:
|
||||
o("*Background", FACE)
|
||||
o("*Foreground", DARK)
|
||||
o("*activeBackground", FACE)
|
||||
# Tk-Eigenheit: „activeForeground“ gehört zur Optionsklasse „Background“ – ohne diese Zeile
|
||||
# wäre die Schrift gedrückter Knöpfe grau auf grau (unsichtbar).
|
||||
o("*activeForeground", DARK)
|
||||
o("*highlightThickness", 0)
|
||||
o("*selectBackground", NAVY)
|
||||
o("*selectForeground", WHITE)
|
||||
@@ -322,6 +325,7 @@ def apply(root: tk.Tk) -> None:
|
||||
|
||||
from tkinter import font as tkfont
|
||||
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
|
||||
wort_loeschen_einrichten(root)
|
||||
st = ttk.Style(root)
|
||||
st.theme_use("classic" if "classic" in st.theme_names() else "default")
|
||||
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT,
|
||||
@@ -341,6 +345,78 @@ def apply(root: tk.Tk) -> None:
|
||||
st.configure("Sash", sashthickness=6, background=FACE)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Wort löschen
|
||||
|
||||
def _wortgrenze(text: str, pos: int, richtung: int) -> int:
|
||||
"""Position der nächsten Wortgrenze von pos aus (richtung -1 = links, +1 = rechts),
|
||||
wie Strg+Rücktaste / Strg+Entf unter Windows: erst Leerzeichen, dann das Wort."""
|
||||
if richtung < 0:
|
||||
i = pos
|
||||
while i > 0 and text[i - 1].isspace():
|
||||
i -= 1
|
||||
if i > 0 and text[i - 1].isalnum():
|
||||
while i > 0 and text[i - 1].isalnum():
|
||||
i -= 1
|
||||
elif i > 0:
|
||||
i -= 1 # Satzzeichen einzeln
|
||||
return i
|
||||
i = pos
|
||||
if i < len(text) and text[i].isalnum():
|
||||
while i < len(text) and text[i].isalnum():
|
||||
i += 1
|
||||
elif i < len(text):
|
||||
i += 1
|
||||
while i < len(text) and text[i].isspace():
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _wort_weg(event, richtung: int):
|
||||
e = event.widget
|
||||
try:
|
||||
if str(e.cget("state")) in ("disabled", "readonly"):
|
||||
return "break"
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
if e.selection_present():
|
||||
e.delete("sel.first", "sel.last")
|
||||
return "break"
|
||||
except (tk.TclError, AttributeError):
|
||||
pass
|
||||
text, pos = e.get(), e.index("insert")
|
||||
grenze = _wortgrenze(text, pos, richtung)
|
||||
if richtung < 0 and grenze < pos:
|
||||
e.delete(grenze, pos)
|
||||
elif richtung > 0 and grenze > pos:
|
||||
e.delete(pos, grenze)
|
||||
return "break"
|
||||
|
||||
|
||||
def _text_wort_weg(event, richtung: int):
|
||||
t = event.widget
|
||||
if str(t.cget("state")) == "disabled":
|
||||
return "break"
|
||||
if t.tag_ranges("sel"):
|
||||
t.delete("sel.first", "sel.last")
|
||||
return "break"
|
||||
if richtung < 0:
|
||||
t.delete("insert -1c wordstart" if t.get("insert -1c").isspace() else "insert wordstart", "insert")
|
||||
else:
|
||||
t.delete("insert", "insert wordend")
|
||||
return "break"
|
||||
|
||||
|
||||
def wort_loeschen_einrichten(root: tk.Misc):
|
||||
"""Strg+Rücktaste / Strg+Entf löschen das ganze Wort – in allen Eingabefeldern (Tk kann das
|
||||
unter Windows von Haus aus nicht, dort wird nur ein Zeichen gelöscht)."""
|
||||
for klasse in ("Entry", "TEntry", "TCombobox", "Spinbox", "TSpinbox"):
|
||||
root.bind_class(klasse, "<Control-BackSpace>", lambda e: _wort_weg(e, -1))
|
||||
root.bind_class(klasse, "<Control-Delete>", lambda e: _wort_weg(e, 1))
|
||||
root.bind_class("Text", "<Control-BackSpace>", lambda e: _text_wort_weg(e, -1))
|
||||
root.bind_class("Text", "<Control-Delete>", lambda e: _text_wort_weg(e, 1))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Bausteine
|
||||
|
||||
class Sunken(tk.Frame):
|
||||
@@ -434,8 +510,12 @@ class Tooltip:
|
||||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||
self.tip = tw = tk.Toplevel(self.widget)
|
||||
tw.wm_overrideredirect(True)
|
||||
tw.wm_geometry(f"+{x}+{y}")
|
||||
tw.withdraw()
|
||||
tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack()
|
||||
tw.update_idletasks()
|
||||
# Größe + Position zusammen (reine Position ignoriert Tk unter Windows bei rahmenlosen Fenstern)
|
||||
tw.wm_geometry(f"{tw.winfo_reqwidth()}x{tw.winfo_reqheight()}+{x}+{y}")
|
||||
tw.deiconify()
|
||||
|
||||
def _weg(self, _e=None):
|
||||
if self._after:
|
||||
@@ -502,15 +582,24 @@ KLASSISCH = False
|
||||
|
||||
|
||||
class Dialog(tk.Toplevel):
|
||||
"""Modaler Dialog im Win95-Stil."""
|
||||
"""Modaler Dialog im Win95-Stil.
|
||||
|
||||
Modal heißt hier: Der INHALT des aufrufenden Fensters ist gesperrt (siehe modal.py), dessen
|
||||
Titelleiste und Rand bleiben bedienbar. Unter Windows gehört der Dialog dem aufrufenden Fenster
|
||||
(bleibt darüber, kommt mit Alt+Tab nach vorn) und hat einen eigenen Taskleisten-Eintrag.
|
||||
"""
|
||||
|
||||
def __init__(self, master, titel: str, resizable: bool = False):
|
||||
super().__init__(master, bg=FACE)
|
||||
self.withdraw()
|
||||
self.title(titel)
|
||||
self.transient(master.winfo_toplevel())
|
||||
if not KLASSISCH:
|
||||
# Rahmenlose Dialoge bekommen ihren Besitzer über win32 – mit transient() würde Tk sie
|
||||
# unter Windows als Kindfenster anlegen, die dann hinter anderen Programmen hängen.
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(resizable, resizable)
|
||||
self.result = None
|
||||
self._sperre = None
|
||||
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
|
||||
self.bind("<Escape>", lambda e: self.abbrechen())
|
||||
try:
|
||||
@@ -526,42 +615,60 @@ class Dialog(tk.Toplevel):
|
||||
self.titelleiste.pack(side="top", fill="x")
|
||||
|
||||
def zeigen(self, fokus=None):
|
||||
from provtool.gui import modal, win32
|
||||
if getattr(self._root(), "_provtool_schliesst", False):
|
||||
self.destroy() # Programm wird beendet – keine neuen Dialoge mehr
|
||||
return None
|
||||
self.update_idletasks()
|
||||
m = self.master.winfo_toplevel()
|
||||
self._aufrufer = m
|
||||
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
|
||||
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
|
||||
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
|
||||
self.geometry(f"+{max(0, x)}+{max(0, y)}")
|
||||
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
|
||||
self.deiconify()
|
||||
self.update_idletasks()
|
||||
if KLASSISCH and win32.IS_WIN:
|
||||
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
|
||||
if win32.taskleisteneintrag(self):
|
||||
self.withdraw()
|
||||
self.deiconify()
|
||||
self.update_idletasks()
|
||||
win32.besitzer_setzen(self, m)
|
||||
self._sperre = modal.Sperre(m, self)
|
||||
self.lift()
|
||||
vorher = self.grab_current() # z. B. der Assistent, wenn hier eine Rückfrage aufgeht
|
||||
try:
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
pass
|
||||
bind_id = None
|
||||
if KLASSISCH:
|
||||
# Ein rahmenloser Dialog darf nicht hinter dem Hauptfenster verschwinden
|
||||
bind_id = m.bind("<FocusIn>", lambda e: self.winfo_exists() and self.lift(), add="+")
|
||||
self.focus_force()
|
||||
self.focus_force()
|
||||
(fokus or self).focus_set()
|
||||
self.wait_window()
|
||||
if bind_id:
|
||||
try:
|
||||
m.unbind("<FocusIn>", bind_id)
|
||||
except tk.TclError:
|
||||
pass
|
||||
# Tk gibt beim Schließen die Sperre frei, stellt die vorige aber nicht wieder her
|
||||
return self.result
|
||||
|
||||
def nach_vorn(self, blinken: bool = False):
|
||||
"""Dialog nach vorn holen (z. B. nach Klick auf das gesperrte Hauptfenster)."""
|
||||
from provtool.gui import win32
|
||||
try:
|
||||
if vorher is not None and vorher.winfo_exists():
|
||||
vorher.grab_set()
|
||||
vorher.focus_force()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
win32.nach_vorn(self)
|
||||
except tk.TclError:
|
||||
return
|
||||
if blinken:
|
||||
self.bell()
|
||||
win32.blinken(self)
|
||||
if self.titelleiste is not None:
|
||||
self.titelleiste.blinken()
|
||||
|
||||
def destroy(self):
|
||||
sperre = getattr(self, "_sperre", None)
|
||||
if sperre is not None:
|
||||
sperre.aufheben()
|
||||
self._sperre = None
|
||||
aufrufer = getattr(self, "_aufrufer", None)
|
||||
super().destroy()
|
||||
try: # Fokus zurück an das aufrufende Fenster
|
||||
if aufrufer is not None and aufrufer.winfo_exists():
|
||||
aufrufer.focus_force()
|
||||
except tk.TclError:
|
||||
pass
|
||||
return self.result
|
||||
|
||||
def abbrechen(self):
|
||||
self.result = None
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Besondere Provisionsarten: Airtime und Jahresbonus Neuaktivierung (Staffel).
|
||||
|
||||
Typ einer Position
|
||||
------------------
|
||||
- ``normal`` – Auszahlung = Prozent der Provision (Händler-Standard bzw. Regel je Provisionsart)
|
||||
- ``airtime`` – Auszahlung = Airtime-Prozent des Händlers (0 % = keine Airtime). Im Export als eine
|
||||
Summenzeile in der Abrechnung, die Einzelposten in einer eigenen Airtime-Liste.
|
||||
- ``jahresbonus`` – Jahresbonus Neuaktivierung: Betrag je Karte nach Staffel, abhängig davon, wie viele
|
||||
Karten der Händler in der Abrechnung hat (z. B. 1–9 Karten: 5 € je Karte ... ab 40: voll).
|
||||
|
||||
Der Typ wird aus Provisionsart/Dienst/Tarif erkannt und kann je Provisionsart fest eingestellt werden.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
NORMAL, AIRTIME, JAHRESBONUS = "normal", "airtime", "jahresbonus"
|
||||
TYPEN = {NORMAL: "Normale Provision", AIRTIME: "Airtime", JAHRESBONUS: "Jahresbonus Neuaktivierung (Staffel)"}
|
||||
|
||||
# Staffel: [(ab Anzahl Karten, Cent je Karte oder None = voller Betrag laut Abrechnung), ...]
|
||||
Staffel = list[tuple[int, "int | None"]]
|
||||
STANDARD_STAFFEL: Staffel = [(1, 500), (10, 1000), (15, 1500), (20, 2000), (25, 2500), (30, 3000), (35, 3500),
|
||||
(40, None)]
|
||||
|
||||
_AIRTIME_WOERTER = ("airtime", "air time", "air-time", "umsatzbeteiligung")
|
||||
|
||||
|
||||
def _text(*teile) -> str:
|
||||
return " ".join(str(t or "") for t in teile).lower()
|
||||
|
||||
|
||||
def erkennen(provisionsart: str | None, dienst: str | None = None, tarif: str | None = None) -> str:
|
||||
"""Typ aus den Texten der Position erkennen (ohne feste Einstellung)."""
|
||||
art = _text(provisionsart)
|
||||
if "jahresbonus" in art or ("bonus" in art and "neuaktiv" in art):
|
||||
return JAHRESBONUS
|
||||
if any(wort in _text(provisionsart, dienst, tarif) for wort in _AIRTIME_WOERTER):
|
||||
return AIRTIME
|
||||
return NORMAL
|
||||
|
||||
|
||||
def typ_von(p: dict, fest: dict[str, str]) -> str:
|
||||
"""Typ einer Position: feste Einstellung der Provisionsart, sonst automatisch erkannt."""
|
||||
art = p.get("provisionsart") or ""
|
||||
if art in fest:
|
||||
return fest[art]
|
||||
return erkennen(art, p.get("dienst"), p.get("tarif"))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Staffel
|
||||
|
||||
def staffel_pruefen(staffel) -> Staffel:
|
||||
"""Normalisiert und prüft eine Staffel (aufsteigend, ab >= 1, Beträge >= 0)."""
|
||||
out: Staffel = []
|
||||
for ab, cent in staffel:
|
||||
ab = int(ab)
|
||||
if ab < 1:
|
||||
raise ValueError("Die Anzahl Karten muss mindestens 1 sein.")
|
||||
if cent is not None:
|
||||
cent = int(cent)
|
||||
if cent < 0:
|
||||
raise ValueError("Der Betrag je Karte darf nicht negativ sein.")
|
||||
out.append((ab, cent))
|
||||
out.sort(key=lambda s: s[0])
|
||||
if len({ab for ab, _ in out}) != len(out):
|
||||
raise ValueError("Jede Stufe braucht eine andere Anzahl Karten.")
|
||||
if not out:
|
||||
raise ValueError("Die Staffel braucht mindestens eine Stufe.")
|
||||
return out
|
||||
|
||||
|
||||
def staffel_laden(text: str | None) -> Staffel | None:
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return staffel_pruefen(json.loads(text))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def staffel_text(staffel: Staffel) -> str:
|
||||
return json.dumps([[ab, cent] for ab, cent in staffel])
|
||||
|
||||
|
||||
def stufe(staffel: Staffel, karten: int) -> tuple[bool, int | None]:
|
||||
"""(gefunden, Cent je Karte oder None = voll) für die Anzahl Karten."""
|
||||
treffer = None
|
||||
for ab, cent in staffel:
|
||||
if karten >= ab:
|
||||
treffer = (True, cent)
|
||||
return treffer or (False, 0)
|
||||
|
||||
|
||||
def bonus_betrag(staffel: Staffel, karten: int, betrag_orig: int) -> int:
|
||||
"""Auszahlung einer Jahresbonus-Position. Nie mehr als die Provision laut Abrechnung; Stornos
|
||||
(negative Beträge) werden mit derselben Stufe abgezogen."""
|
||||
gefunden, cent = stufe(staffel, karten)
|
||||
if not gefunden:
|
||||
return 0
|
||||
if cent is None:
|
||||
return betrag_orig
|
||||
if betrag_orig >= 0:
|
||||
return min(cent, betrag_orig)
|
||||
return -min(cent, -betrag_orig)
|
||||
|
||||
|
||||
def staffel_beschreibung(staffel: Staffel, kurz: bool = False) -> str:
|
||||
from provtool.money import fmt
|
||||
teile = []
|
||||
for i, (ab, cent) in enumerate(staffel):
|
||||
bis = staffel[i + 1][0] - 1 if i + 1 < len(staffel) else None
|
||||
bereich = f"ab {ab}" if bis is None else (f"{ab}" if bis == ab else f"{ab}-{bis}")
|
||||
wert = "voll" if cent is None else fmt(cent)
|
||||
teile.append(f"{bereich}: {wert}")
|
||||
return (", " if kurz else "\n").join(teile)
|
||||
+22
-2
@@ -148,17 +148,37 @@ def ausfuehren(ergebnisdatei: str) -> int:
|
||||
if len(pos) != 2 or len(dateien) != 1 or not dateien[0].exists():
|
||||
s.fehler.append(f"Export fehlerhaft: {len(pos)} Positionen, Dateien {dateien}")
|
||||
|
||||
from provtool.gui.assistent import TippDesTages
|
||||
from provtool.gui.dialogs import ImportBericht
|
||||
|
||||
def fenster_pruefen():
|
||||
"""Windows: echtes Programmfenster (Andocken, Win+Pfeiltasten) mit unsichtbarem Rahmen."""
|
||||
r = s.app.rahmen
|
||||
if sys.platform.startswith("win") and r.klassisch:
|
||||
if not r.nativ_an:
|
||||
s.fehler.append("Natives Fenster (Andocken/Win+Pfeiltasten) wurde nicht eingerichtet")
|
||||
elif not r.nativ.innen_gleich_aussen():
|
||||
s.fehler.append("Windows-Rahmen des Hauptfensters ist sichtbar")
|
||||
|
||||
def tipp_pruefen():
|
||||
d = s.dialog(TippDesTages)
|
||||
if not d.bild_ok:
|
||||
s.fehler.append("Bild für den Tipp des Tages fehlt in der .exe (provtool/gui/bilder/tipp.png)")
|
||||
d.abbrechen()
|
||||
|
||||
fehler += s.ablauf([
|
||||
(seite(0), lambda: wiz()._weiter(), "Willkommen"),
|
||||
(seite(1), lambda: wiz()._weiter(), "Speicherort"),
|
||||
(seite(2), haendler, "Händler anlegen"),
|
||||
(lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler → weiter"),
|
||||
(lambda: len(s.app.db.haendler_liste()) == 1, lambda: wiz()._weiter(), "Händler -> weiter"),
|
||||
(seite(3), abrechnung, "Abrechnung mit Datei"),
|
||||
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
|
||||
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
|
||||
(lambda: len(s.app.positionen) == 2, export, "Export"),
|
||||
(immer, s.app.rundgang, "Rundgang"),
|
||||
(immer, fenster_pruefen, "Fensterrahmen"),
|
||||
(immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"),
|
||||
(lambda: s.dialog(TippDesTages) is not None, tipp_pruefen, "Tipp-Bild in der .exe"),
|
||||
(lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"),
|
||||
(lambda: getattr(s.app, "_rundgang", None) is not None, s.app.beenden, "Beenden während Rundgang"),
|
||||
])
|
||||
except Exception:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
# Kein Test darf den Build blockieren: nach 90 s wird abgebrochen und der Aufrufstapel aller
|
||||
# Threads ausgegeben (zeigt, wo es hängt – auch in Windows-Systemaufrufen, wo der Tk-Watchdog
|
||||
# der GUI-Tests nicht greift).
|
||||
timeout = 90
|
||||
timeout_method = thread
|
||||
@@ -1,3 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.3
|
||||
pyinstaller==6.11.1
|
||||
pytest-timeout==2.3.1
|
||||
+611
-13
@@ -47,6 +47,7 @@ class Lauf:
|
||||
raise
|
||||
pytest.skip(f"kein Bildschirm: {exc}")
|
||||
self.fehler: list[str] = []
|
||||
self.watchdog = False # True = der TEST hat wegen Zeitüberschreitung abgebrochen
|
||||
from provtool.gui import app as A
|
||||
self.app = A.App(self.root)
|
||||
echt = self.root.report_callback_exception
|
||||
@@ -60,8 +61,37 @@ class Lauf:
|
||||
# eine evtl. geöffnete Fehlermeldung schließen, damit der Test nicht hängt
|
||||
self.root.after(100, self._meldungen_zu)
|
||||
self.root.report_callback_exception = melden
|
||||
self._sichtbar_seit: float | None = None
|
||||
|
||||
def hauptfenster_sichtbar(self) -> bool:
|
||||
"""Wie ein Mensch erst bedienen, wenn das Hauptfenster zu sehen ist: Unter Windows wird es beim
|
||||
Start für den Taskleisten-Eintrag kurz aus- und wieder eingeblendet (unter Wine langsamer).
|
||||
Wird als erster Schritt jedes Ablaufs geprüft – NICHT mit einer eigenen update()-Schleife,
|
||||
denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten."""
|
||||
try:
|
||||
sichtbar = bool(self.root.winfo_viewable()) and self.app.inhalt.winfo_height() > 50
|
||||
lage = (self.root.winfo_rootx(), self.root.winfo_rooty(), self.root.winfo_width(),
|
||||
self.root.winfo_height())
|
||||
except tk.TclError:
|
||||
return True
|
||||
# erst wenn Lage und Größe einen Moment stabil sind (beim ersten Anzeigen gibt es unter Wine
|
||||
# kurz einen Zwischenstand)
|
||||
if not sichtbar or lage != getattr(self, "_lage", None):
|
||||
self._lage = lage
|
||||
self._sichtbar_seit = None
|
||||
return False
|
||||
if self._sichtbar_seit is None:
|
||||
self._sichtbar_seit = time.time()
|
||||
return time.time() - self._sichtbar_seit >= 0.15
|
||||
|
||||
# ---------------------------------------------------------------- Zustand
|
||||
def lebt_sonst(self, text: str) -> str:
|
||||
"""Fehlertext für „Programm ist zu“, der Watchdog-Abbrüche klar unterscheidet."""
|
||||
if self.watchdog:
|
||||
return "Zeitüberschreitung – der Test hat abgebrochen (nicht das Programm hat sich beendet):\n" \
|
||||
+ "\n".join(self.fehler)
|
||||
return text
|
||||
|
||||
@property
|
||||
def lebt(self) -> bool:
|
||||
try:
|
||||
@@ -95,6 +125,7 @@ class Lauf:
|
||||
d.destroy()
|
||||
|
||||
def _notaus(self):
|
||||
self.watchdog = True
|
||||
for d in reversed(self.dialoge()):
|
||||
try:
|
||||
d.destroy()
|
||||
@@ -109,7 +140,7 @@ class Lauf:
|
||||
def ablauf(self, schritte: list[tuple], sekunden: float = 20.0, nachlauf: float = 0.3):
|
||||
"""schritte: [(bedingung, aktion, beschreibung), ...] – jede Aktion läuft als eigener
|
||||
Tk-Callback, sobald ihre Bedingung erfüllt ist (auch während ein modaler Dialog offen ist)."""
|
||||
offen = list(schritte)
|
||||
offen = [(self.hauptfenster_sichtbar, lambda: None, "Hauptfenster ist sichtbar")] + list(schritte)
|
||||
start = time.time()
|
||||
zustand = {"fertig_seit": None}
|
||||
|
||||
@@ -146,6 +177,10 @@ class Lauf:
|
||||
time.sleep(0.01)
|
||||
self.uebrig = [s[2] for s in offen]
|
||||
|
||||
def diagnose(self) -> str:
|
||||
"""Für Fehlermeldungen: welche Schritte nicht liefen + alle gesammelten Fehler."""
|
||||
return f"nicht ausgeführt: {self.uebrig}\n" + "\n".join(self.fehler)
|
||||
|
||||
def schliessen_ueber_fenster(self):
|
||||
"""Wie Klick auf × / Taskleiste ▸ Fenster schließen (WM_DELETE_WINDOW)."""
|
||||
cmd = self.root.tk.call("wm", "protocol", self.root._w, "WM_DELETE_WINDOW")
|
||||
@@ -219,29 +254,49 @@ def test_alt_f4_im_assistent_fragt_und_schliesst_nur_assistent(home):
|
||||
d.destroy()
|
||||
|
||||
lauf.ablauf([
|
||||
(_assistent_offen(lauf), lambda: lauf.dialog(Einrichtungsassistent).event_generate("<Alt-F4>"), "Alt+F4"),
|
||||
(_assistent_offen(lauf), lambda: lauf.app.rahmen.alt_f4(lauf.dialog(Einrichtungsassistent)), "Alt+F4"),
|
||||
(lambda: rueckfrage() is not None, ja_klicken, "Rückfrage mit Ja beantworten"),
|
||||
(lambda: lauf.dialog(Einrichtungsassistent) is None, lambda: None, "Assistent ist zu"),
|
||||
])
|
||||
assert lauf.lebt, "Programm darf nicht beendet werden"
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
assert lauf.lebt, lauf.lebt_sonst("Programm darf nicht beendet werden")
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert lauf.app.settings.get("assistent_erledigt") is True
|
||||
lauf.pruefen()
|
||||
lauf.ende()
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_alt_f4_im_dialog_schliesst_nur_den_dialog(lauf_mit_daten):
|
||||
def test_alt_f4_im_dialog_schliesst_nur_den_dialog(lauf_mit_daten, monkeypatch):
|
||||
from provtool.gui import rahmen
|
||||
from provtool.gui.dialogs import InfoDialog
|
||||
lauf = lauf_mit_daten
|
||||
protokoll: list[str] = [] # Diagnose: wer hat Alt+F4 bzw. Beenden ausgelöst?
|
||||
alt_f4, beenden = rahmen.Hauptrahmen.alt_f4, type(lauf.app).beenden
|
||||
|
||||
def alt_f4_spion(self, widget):
|
||||
protokoll.append(f"alt_f4(widget={widget}, sperre={self.root.grab_current()}, "
|
||||
f"popup={self._offenes_popup()}, fokus={self.root.focus_get()})\n"
|
||||
+ "".join(traceback.format_stack(limit=8)))
|
||||
return alt_f4(self, widget)
|
||||
|
||||
def beenden_spion(self):
|
||||
protokoll.append("beenden()\n" + "".join(traceback.format_stack(limit=10)))
|
||||
return beenden(self)
|
||||
monkeypatch.setattr(rahmen.Hauptrahmen, "alt_f4", alt_f4_spion)
|
||||
monkeypatch.setattr(type(lauf.app), "beenden", beenden_spion)
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
|
||||
# Handler direkt mit dem Dialog als Empfänger aufrufen: Erzeugte Tastendrücke stellt Tk unter
|
||||
# Windows nur dem Fenster mit Tastaturfokus zu – auf dem Build-Rechner gingen sie verloren.
|
||||
# Dass Alt+F4 an diesen Handler gebunden ist, prüft test_alt_f4_ist_gebunden.
|
||||
(lambda: lauf.dialog(InfoDialog) is not None,
|
||||
lambda: lauf.dialog(InfoDialog).event_generate("<Alt-F4>"), "Alt+F4 im Dialog"),
|
||||
lambda: lauf.app.rahmen.alt_f4(lauf.dialog(InfoDialog)), "Alt+F4 im Dialog"),
|
||||
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
|
||||
], sekunden=8)
|
||||
assert lauf.lebt, "Alt+F4 im Dialog darf nicht das ganze Programm beenden"
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
diagnose = "\n---\n".join(protokoll) + "\nFehler:\n" + "\n".join(lauf.fehler)
|
||||
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Dialog darf nicht das ganze Programm beenden\n" + diagnose)
|
||||
assert not lauf.uebrig, f"{lauf.uebrig}\n{diagnose}"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
@@ -261,11 +316,14 @@ def _dialog_oeffner():
|
||||
"prozent": lambda a: (a.ptree.selection_set(a.ptree.get_children()[0]), a.prozent()),
|
||||
"neu_berechnen": lambda a: (a.db.betrag_setzen(a.positionen[0]["id"], 1), a._aktualisieren(),
|
||||
a.neu_berechnen()),
|
||||
"provisionsarten": lambda a: a.provisionsarten(),
|
||||
"staffel": lambda a: a.standard_staffel(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
|
||||
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen"])
|
||||
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
|
||||
"staffel"])
|
||||
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
|
||||
lauf = lauf_mit_daten
|
||||
oeffnen = _dialog_oeffner()[name]
|
||||
@@ -298,17 +356,46 @@ def test_schliessen_waehrend_rundgang(lauf_mit_daten):
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_alt_f4_ist_gebunden(lauf_mit_daten):
|
||||
"""Alt+F4 muss im Win95-Rahmen an den eigenen Handler gebunden sein (für alle Fenster)."""
|
||||
lauf = lauf_mit_daten
|
||||
if not lauf.app.rahmen.klassisch:
|
||||
pytest.skip("nur im Win95-Rahmen")
|
||||
skript = lauf.root.bind_all("<Alt-F4>")
|
||||
assert skript, "Alt+F4 ist nicht gebunden"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_alt_f4_bevor_die_sperre_steht(lauf_mit_daten, monkeypatch):
|
||||
"""Erzwingt den Zustand vom langsamen Build-Rechner: Dialog ist offen, aber Tk-Sperre und
|
||||
Zeichnen sind noch nicht fertig. Alt+F4 muss trotzdem nur den Dialog schließen."""
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.gui.dialogs import InfoDialog
|
||||
monkeypatch.setattr(w.Dialog, "winfo_ismapped", lambda self: 0) # „noch nicht gezeichnet“
|
||||
lauf = lauf_mit_daten
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
|
||||
# wie unter Windows: Tk liefert den Tastendruck an das Fenster mit Fokus – hier noch das Hauptfenster
|
||||
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.app.rahmen.alt_f4(lauf.root),
|
||||
"Alt+F4 (Fokus noch beim Hauptfenster)"),
|
||||
(lambda: lauf.dialog(InfoDialog) is None, lambda: None, "Dialog geschlossen"),
|
||||
], sekunden=8)
|
||||
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 darf bei offenem Dialog nicht das ganze Programm beenden")
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_alt_f4_im_rundgang_beendet_nur_den_rundgang(lauf_mit_daten):
|
||||
lauf = lauf_mit_daten
|
||||
rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731
|
||||
lauf.ablauf([
|
||||
(IMMER, lauf.app.rundgang, "Rundgang starten"),
|
||||
(lambda: rg() is not None and rg().blase is not None,
|
||||
lambda: rg().blase.event_generate("<Alt-F4>"), "Alt+F4"),
|
||||
lambda: lauf.app.rahmen.alt_f4(rg().blase), "Alt+F4"),
|
||||
(lambda: rg().blase is None and not rg().rahmen, lambda: None, "Rundgang samt Rahmen weg"),
|
||||
], sekunden=8)
|
||||
assert lauf.lebt, "Alt+F4 im Rundgang darf nicht das Programm beenden"
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
assert lauf.lebt, lauf.lebt_sonst("Alt+F4 im Rundgang darf nicht das Programm beenden")
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
@@ -337,7 +424,7 @@ def test_betrag_dialog_wenn_zelle_unsichtbar(lauf_mit_daten):
|
||||
(lambda: bool(app.ptree.get_children()), oeffnen, "Betrag ändern bei unsichtbarer Zelle"),
|
||||
(lambda: eingabe() is not None, tippen, "Betrag im Dialog eingeben"),
|
||||
])
|
||||
assert not lauf.uebrig, lauf.uebrig
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert any(p["betrag"] == 777 and p["manuell"] for p in app.positionen)
|
||||
del app.ptree.bbox
|
||||
lauf.pruefen()
|
||||
@@ -448,3 +535,514 @@ def test_hauptfenster_ablaeufe(lauf_mit_daten):
|
||||
assert not lauf.uebrig, f"nicht ausgeführt: {lauf.uebrig}"
|
||||
assert any(p["prozent"] == 80 for p in app.positionen)
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
# =========================================================================== Windows: Popups bleiben erreichbar
|
||||
|
||||
import sys # noqa: E402
|
||||
|
||||
nur_windows = pytest.mark.skipif(not sys.platform.startswith("win"),
|
||||
reason="Win32-Fensterbesitz gibt es nur unter Windows (im Build: Wine)")
|
||||
|
||||
|
||||
@nur_windows
|
||||
def test_windows_popup_kommt_nach_alt_tab_wieder_nach_vorn(lauf_mit_daten):
|
||||
"""Gemeldet: Popup offen, woanders hingeklickt – danach kam das Popup nicht mehr nach vorn
|
||||
(auch nicht per Alt+Tab). Geprüft wird: Popup gehört dem Hauptfenster, das Hauptfenster ist
|
||||
gesperrt, und holt man das Hauptfenster zurück, liegt das Popup darüber."""
|
||||
from provtool.gui import win32
|
||||
from provtool.gui.dialogs import InfoDialog
|
||||
lauf = lauf_mit_daten
|
||||
m = {}
|
||||
|
||||
def messen():
|
||||
d = lauf.dialog(InfoDialog)
|
||||
fremd = tk.Toplevel(lauf.root) # steht für ein anderes Programm
|
||||
fremd.geometry("400x300+50+50")
|
||||
fremd.update()
|
||||
m.update(besitzer=win32.besitzer_von(d), haupt=win32.hwnd(lauf.root),
|
||||
haupt_bedienbar=win32.ist_bedienbar(lauf.root), taskleiste=win32.hat_taskleisteneintrag(d))
|
||||
win32.nach_vorn(fremd) # „woanders hingeklickt“
|
||||
lauf.root.update()
|
||||
m["woanders"] = win32.stapel(d, lauf.root, fremd)
|
||||
win32.nach_vorn(lauf.root) # Alt+Tab / Taskleiste zurück zu uns
|
||||
lauf.root.update()
|
||||
m["zurueck"] = win32.stapel(d, lauf.root, fremd)
|
||||
fremd.destroy()
|
||||
d.abbrechen()
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Info öffnen"),
|
||||
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, messen), "messen"),
|
||||
(lambda: "zurueck" in m and lauf.dialog(InfoDialog) is None, lambda: None, "geschlossen"),
|
||||
], sekunden=10)
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert m["besitzer"] == m["haupt"], "Popup gehört nicht dem Hauptfenster"
|
||||
assert m["haupt_bedienbar"] is True, "Hauptfenster soll verschiebbar bleiben (nur der Inhalt ist gesperrt)"
|
||||
assert m["taskleiste"] is True, "Popup braucht einen eigenen Eintrag in der Taskleiste"
|
||||
assert m["woanders"][0] == 2, f"Testaufbau: das fremde Fenster sollte oben liegen ({m['woanders']})"
|
||||
assert m["zurueck"][:2] == [0, 1], f"Popup liegt nach dem Zurückholen nicht über dem Hauptfenster: {m['zurueck']}"
|
||||
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_verschachtelte_rueckfrage_sperrt_der_reihe_nach(lauf_mit_daten):
|
||||
"""Händler-Dialog -> Rückfrage: gesperrt sind Hauptfenster-Inhalt und Händler-Dialog (nicht dessen
|
||||
Titelleiste); nach der Rückfrage ist der Händler-Dialog wieder frei, das Hauptfenster noch nicht."""
|
||||
from provtool.gui import modal, win32
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.gui.dialogs import HaendlerDialog
|
||||
lauf = lauf_mit_daten
|
||||
m = {}
|
||||
rueckfrage = lambda: next((d for d in lauf.dialoge() if d.title() == "Test-Rückfrage"), None) # noqa: E731
|
||||
gesperrt = lambda wdg: modal.TAG in wdg.bindtags() # noqa: E731
|
||||
|
||||
def messen_innen():
|
||||
h, r = lauf.dialog(HaendlerDialog), rueckfrage()
|
||||
m.update(h_inhalt=gesperrt(h.liste), h_titel=gesperrt(h.titelleiste) if h.titelleiste else False,
|
||||
haupt_inhalt=gesperrt(lauf.app.ptree), haupt_titel=gesperrt(lauf.app.rahmen.titelleiste)
|
||||
if lauf.app.rahmen.titelleiste else False,
|
||||
r_besitzer=win32.besitzer_von(r), h=win32.hwnd(h))
|
||||
r.destroy()
|
||||
|
||||
def messen_aussen():
|
||||
h = lauf.dialog(HaendlerDialog)
|
||||
m.update(h_inhalt_danach=gesperrt(h.liste), haupt_inhalt_danach=gesperrt(lauf.app.ptree))
|
||||
h.abbrechen()
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lauf.app.haendler, "Händler-Dialog öffnen"),
|
||||
(lambda: lauf.dialog(HaendlerDialog) is not None,
|
||||
lambda: w.info(lauf.dialog(HaendlerDialog), "Test-Rückfrage", "?"), "Rückfrage öffnen"),
|
||||
(lambda: rueckfrage() is not None, lambda: lauf.root.after(100, messen_innen), "innen messen"),
|
||||
(lambda: "h" in m and rueckfrage() is None, messen_aussen, "außen messen"),
|
||||
(lambda: lauf.dialog(HaendlerDialog) is None, lambda: None, "alles zu"),
|
||||
], sekunden=12)
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert m["h_inhalt"] and m["haupt_inhalt"], "Inhalte müssen gesperrt sein, solange die Rückfrage offen ist"
|
||||
assert not m["h_titel"] and not m["haupt_titel"], "Titelleisten (verschieben) dürfen nicht gesperrt sein"
|
||||
assert not m["h_inhalt_danach"] and m["haupt_inhalt_danach"]
|
||||
assert not gesperrt(lauf.app.ptree), "Hauptfenster nach dem Schließen nicht wieder freigegeben"
|
||||
if sys.platform.startswith("win"):
|
||||
assert m["r_besitzer"] == m["h"], "Rückfrage gehört nicht dem Händler-Dialog"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
@nur_windows
|
||||
def test_windows_rundgang_sperrt_und_gibt_frei(lauf_mit_daten):
|
||||
from provtool.gui import win32
|
||||
lauf = lauf_mit_daten
|
||||
rg = lambda: getattr(lauf.app, "_rundgang", None) # noqa: E731
|
||||
m = {}
|
||||
|
||||
def messen_und_weiter():
|
||||
m.setdefault("gesperrt", []).append(not win32.ist_bedienbar(lauf.root))
|
||||
m.setdefault("besitzer", []).append(win32.besitzer_von(rg().blase) == win32.hwnd(lauf.root))
|
||||
rg()._weiter()
|
||||
|
||||
schritte = [(IMMER, lauf.app.rundgang, "Rundgang starten")]
|
||||
for i in range(3):
|
||||
schritte.append((lambda i=i: rg() is not None and rg().blase is not None and rg().nr == i,
|
||||
messen_und_weiter, f"Schritt {i + 1}"))
|
||||
schritte.append((IMMER, lambda: rg().beenden(), "Rundgang beenden"))
|
||||
lauf.ablauf(schritte, sekunden=12)
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert all(m["gesperrt"]), "Hauptfenster während des Rundgangs nicht gesperrt"
|
||||
assert all(m["besitzer"]), "Sprechblase gehört nicht dem Hauptfenster"
|
||||
assert win32.ist_bedienbar(lauf.root), "Hauptfenster nach dem Rundgang nicht freigegeben"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
# =========================================================================== Rückmeldungen vom 2.11. (Teil 2)
|
||||
|
||||
def test_gedrueckte_knoepfe_behalten_ihre_schrift(lauf_mit_daten):
|
||||
"""Gemeldet: Im gedrückten Zustand war die Beschriftung weg (activeforeground = Grau)."""
|
||||
from provtool.gui import win95 as w
|
||||
lauf = lauf_mit_daten
|
||||
for b in (w.knopf(lauf.root, "Test"), tk.Button(lauf.root, text="roh")):
|
||||
assert b.cget("activeforeground") != b.cget("activebackground"), "Schrift gedrückter Knöpfe unsichtbar"
|
||||
b.destroy()
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_tipp_text_bleibt_im_sichtbaren_bereich(lauf_mit_daten):
|
||||
from provtool.gui.assistent import TIPPS, TippDesTages
|
||||
lauf = lauf_mit_daten
|
||||
m = {}
|
||||
|
||||
def messen():
|
||||
d = lauf.dialog(TippDesTages)
|
||||
rahmen = d.text.master
|
||||
for t in TIPPS: # jeden Tipp einmal anzeigen
|
||||
d.text.configure(text=t)
|
||||
d.update_idletasks()
|
||||
m.setdefault("breiten", []).append((d.text.winfo_reqwidth(), rahmen.winfo_width()))
|
||||
m.setdefault("hoehen", []).append((d.text.winfo_reqheight(), rahmen.winfo_height()))
|
||||
d.abbrechen()
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lauf.app.tipp_des_tages, "Tipp öffnen"),
|
||||
(lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.root.after(100, messen), "messen"),
|
||||
(lambda: "breiten" in m and lauf.dialog(TippDesTages) is None, lambda: None, "zu"),
|
||||
])
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
for text_b, rahmen_b in m["breiten"]:
|
||||
assert text_b <= rahmen_b, f"Tipp-Text ({text_b}px) breiter als sein Bereich ({rahmen_b}px)"
|
||||
for text_h, rahmen_h in m["hoehen"]:
|
||||
assert text_h <= rahmen_h, f"Tipp-Text ({text_h}px) höher als sein Bereich ({rahmen_h}px)"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_strg_ruecktaste_loescht_ganzes_wort(lauf_mit_daten):
|
||||
from provtool.gui import win95 as w
|
||||
lauf = lauf_mit_daten
|
||||
e = tk.Entry(lauf.root)
|
||||
e.insert(0, "Mobilfunk Schmid GmbH")
|
||||
ev = type("E", (), {"widget": e})()
|
||||
e.icursor("end")
|
||||
w._wort_weg(ev, -1)
|
||||
assert e.get() == "Mobilfunk Schmid "
|
||||
w._wort_weg(ev, -1)
|
||||
assert e.get() == "Mobilfunk "
|
||||
e.icursor(0)
|
||||
w._wort_weg(ev, 1)
|
||||
assert e.get() == ""
|
||||
# Bindung ist für alle Eingabefelder aktiv
|
||||
for klasse in ("Entry", "TEntry", "TCombobox"):
|
||||
assert lauf.root.bind_class(klasse, "<Control-BackSpace>"), klasse
|
||||
assert lauf.root.bind_class(klasse, "<Control-Delete>"), klasse
|
||||
e.destroy()
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_menue_wechselt_beim_ueberfahren(lauf_mit_daten):
|
||||
"""Gemeldet: Bei offenem Menü wechselte es nicht mit, wenn man zum nächsten Eintrag fuhr."""
|
||||
lauf = lauf_mit_daten
|
||||
leiste = lauf.app.menueleiste
|
||||
datei, haendler = leiste.knoepfe["Datei"], leiste.knoepfe["Händler"]
|
||||
m = {}
|
||||
|
||||
def ueberfahren():
|
||||
popup = leiste.menues[datei]
|
||||
ev = type("E", (), {"x_root": haendler.winfo_rootx() + 3, "y_root": haendler.winfo_rooty() + 3})()
|
||||
popup._bewegung(ev)
|
||||
|
||||
def pruefen_und_esc():
|
||||
m["offen"] = leiste.offener_knopf
|
||||
m["popup_offen"] = leiste.menues[haendler].offen
|
||||
leiste.menues[haendler].fenster.event_generate("<Escape>")
|
||||
leiste.menues[haendler].schliessen()
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: leiste._klick(datei), "Datei öffnen"),
|
||||
(lambda: leiste.menues[datei].offen, ueberfahren, "zu „Händler“ fahren"),
|
||||
(lambda: leiste.menues[haendler].offen, pruefen_und_esc, "prüfen + schließen"),
|
||||
(lambda: leiste.offener_knopf is None, lambda: None, "alles zu"),
|
||||
])
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert m["offen"] is haendler and m["popup_offen"]
|
||||
assert not leiste.menues[datei].offen
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_menue_geht_unter_seinem_menuepunkt_auf(lauf_mit_daten):
|
||||
"""Unter Windows landete das Menü zunächst oben links in der Bildschirmecke."""
|
||||
lauf = lauf_mit_daten
|
||||
leiste = lauf.app.menueleiste
|
||||
m = {}
|
||||
for name in ("Datei", "Händler"):
|
||||
knopf = leiste.knoepfe[name]
|
||||
|
||||
def messen(knopf=knopf, name=name):
|
||||
f = leiste.menues[knopf].fenster
|
||||
m[name] = (f.winfo_rootx() - knopf.winfo_rootx(),
|
||||
f.winfo_rooty() - (knopf.winfo_rooty() + knopf.winfo_height()))
|
||||
leiste.schliessen()
|
||||
lauf.ablauf([(IMMER, lambda knopf=knopf: leiste._klick(knopf), f"{name} öffnen"),
|
||||
(lambda knopf=knopf: leiste.menues[knopf].offen, lambda: lauf.root.after(100, messen), "messen"),
|
||||
(lambda name=name: name in m, lambda: None, "fertig")])
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
for name, (dx, dy) in m.items():
|
||||
assert abs(dx) <= 3 and abs(dy) <= 3, f"Menü „{name}“ nicht unter seinem Menüpunkt (Abstand {dx}, {dy})"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_menue_befehl_wird_ausgefuehrt(lauf_mit_daten):
|
||||
from provtool.gui.assistent import TippDesTages
|
||||
lauf = lauf_mit_daten
|
||||
leiste = lauf.app.menueleiste
|
||||
hilfe = leiste.knoepfe["?"]
|
||||
|
||||
def tipp_waehlen():
|
||||
popup = leiste.menues[hilfe]
|
||||
i = next(i for i, (e, _, _) in enumerate(popup.zeilen) if e["label"].startswith("Tipp des Tages"))
|
||||
popup._ausfuehren(i)
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: leiste._klick(hilfe), "? öffnen"),
|
||||
(lambda: leiste.menues[hilfe].offen, tipp_waehlen, "Tipp des Tages wählen"),
|
||||
(lambda: lauf.dialog(TippDesTages) is not None, lambda: lauf.dialog(TippDesTages).abbrechen(), "zu"),
|
||||
])
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert not leiste.menues[hilfe].offen
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_hauptfenster_bei_offenem_dialog_verschiebbar_inhalt_gesperrt(lauf_mit_daten, monkeypatch):
|
||||
"""Gemeldet: Bei offenem Dialog ließ sich das Hauptfenster nicht verschieben."""
|
||||
from provtool.gui import modal, rahmen
|
||||
from provtool.gui.dialogs import InfoDialog
|
||||
monkeypatch.setattr(rahmen, "NATIV_VERSCHIEBEN", False) # Tk-Weg prüfen (nativ macht Windows selbst)
|
||||
lauf = lauf_mit_daten
|
||||
app = lauf.app
|
||||
if not app.rahmen.klassisch:
|
||||
pytest.skip("nur im Win95-Rahmen")
|
||||
m = {"neu_aufgerufen": False}
|
||||
monkeypatch.setattr(type(app), "neu", lambda self: m.__setitem__("neu_aufgerufen", True))
|
||||
titel = app.rahmen.titelleiste.titel_lbl
|
||||
|
||||
def pruefen():
|
||||
x0 = lauf.root.winfo_x()
|
||||
rx, ry = titel.winfo_rootx() + 30, titel.winfo_rooty() + 5
|
||||
titel.event_generate("<ButtonPress-1>", x=30, y=5, rootx=rx, rooty=ry)
|
||||
titel.event_generate("<B1-Motion>", x=70, y=5, rootx=rx + 40, rooty=ry)
|
||||
titel.event_generate("<ButtonRelease-1>", x=70, y=5, rootx=rx + 40, rooty=ry)
|
||||
lauf.root.update()
|
||||
m["verschoben"] = lauf.root.winfo_x() - x0
|
||||
knopf = app.tb["Neu"] # Klick auf den gesperrten Inhalt
|
||||
knopf.event_generate("<ButtonPress-1>", x=5, y=5)
|
||||
knopf.event_generate("<ButtonRelease-1>", x=5, y=5)
|
||||
m["gesperrt"] = modal.TAG in app.ptree.bindtags()
|
||||
lauf.dialog(InfoDialog).abbrechen()
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Dialog öffnen"),
|
||||
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(100, pruefen), "prüfen"),
|
||||
(lambda: "verschoben" in m and lauf.dialog(InfoDialog) is None, lambda: None, "zu"),
|
||||
])
|
||||
assert not lauf.uebrig, f"{lauf.uebrig} {m}\n" + "\n".join(lauf.fehler)
|
||||
assert m["verschoben"] == 40, f"Hauptfenster ließ sich nicht verschieben ({m['verschoben']} px)"
|
||||
assert m["gesperrt"] and not m["neu_aufgerufen"], "Inhalt des Hauptfensters war nicht gesperrt"
|
||||
assert modal.TAG not in app.ptree.bindtags(), "Sperre nach dem Schließen nicht aufgehoben"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten):
|
||||
"""Gemeldet: Der Größenänderungs-Zeiger blieb manchmal stehen."""
|
||||
lauf = lauf_mit_daten
|
||||
r = lauf.app.rahmen
|
||||
if not r.klassisch:
|
||||
pytest.skip("nur im Win95-Rahmen")
|
||||
lauf.ablauf([]) # erst wenn das Hauptfenster zu sehen ist (Größen stimmen dann)
|
||||
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})()
|
||||
r._rand_cursor(ev)
|
||||
if r.nativ_an:
|
||||
# Windows: den Zeiger setzt Windows selbst (WM_SETCURSOR am äußeren Fenster, siehe
|
||||
# test_windows_echtes_fenster_andocken_und_rand) – Tk darf keinen eigenen setzen, der hängen bleibt.
|
||||
assert r.aussen.cget("cursor") == "", "Tk-Zeiger trotz nativem Rahmen gesetzt"
|
||||
assert r._welcher_rand(2, 10) == "nw" and r._welcher_rand(r.aussen.winfo_width() - 3, 10) == "ne"
|
||||
lauf.pruefen()
|
||||
return
|
||||
assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen"
|
||||
r.inhalt.event_generate("<Enter>")
|
||||
lauf.root.update()
|
||||
assert r.aussen.cget("cursor") == "", "Zeiger wurde beim Betreten des Inhalts nicht zurückgesetzt"
|
||||
# Ecken: in der Nähe des Ecks diagonal
|
||||
assert r._welcher_rand(2, 10) == "nw" and r._welcher_rand(r.aussen.winfo_width() - 3, 10) == "ne"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_eigene_kennungen_ausblenden_in_der_oberflaeche(lauf_mit_daten):
|
||||
from provtool.db import EIGEN
|
||||
lauf = lauf_mit_daten
|
||||
app = lauf.app
|
||||
alle_vorher = int(app.htree.item("-1", "values")[0])
|
||||
app.db.eigene_setzen("freenet", "999")
|
||||
app._aktualisieren()
|
||||
assert app.htree.exists(str(EIGEN)), "Eintrag „(Eigene – ausgeblendet)“ fehlt"
|
||||
assert int(app.htree.item("-1", "values")[0]) == alle_vorher - 1, "eigene Positionen zählen noch mit"
|
||||
assert all(p["haendler_id"] != EIGEN for p in app._gefiltert())
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
@nur_windows
|
||||
def test_windows_minimieren_mit_offenem_dialog(lauf_mit_daten):
|
||||
"""Minimieren bei offenem Dialog darf den Dialog nicht zerstören; nach dem Wiederherstellen ist er da."""
|
||||
from provtool.gui import win32
|
||||
from provtool.gui.dialogs import InfoDialog
|
||||
lauf = lauf_mit_daten
|
||||
m = {}
|
||||
|
||||
def minimieren():
|
||||
lauf.app.rahmen.minimieren()
|
||||
lauf.root.update()
|
||||
m["minimiert"] = win32.ist_minimiert(lauf.root)
|
||||
win32.user32().ShowWindow(win32.hwnd(lauf.root), 9) # SW_RESTORE (wie Klick in der Taskleiste)
|
||||
lauf.root.update()
|
||||
m["dialog_da"] = lauf.dialog(InfoDialog) is not None
|
||||
lauf.dialog(InfoDialog).abbrechen()
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: InfoDialog(lauf.root).zeigen(), "Dialog öffnen"),
|
||||
(lambda: lauf.dialog(InfoDialog) is not None, lambda: lauf.root.after(150, minimieren), "minimieren"),
|
||||
(lambda: "dialog_da" in m, lambda: None, "fertig"),
|
||||
], sekunden=12)
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert m["minimiert"], "Hauptfenster wurde nicht minimiert"
|
||||
assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
@nur_windows
|
||||
def test_windows_echtes_fenster_andocken_und_rand(lauf_mit_daten):
|
||||
"""Gemeldet: Win+Hoch/Runter und Andocken am Bildschirmrand gingen nicht, Größe ziehen war zäh.
|
||||
Das Hauptfenster muss für Windows ein echtes Programmfenster sein (Größenrahmen, Min/Max), dessen
|
||||
Windows-Rahmen unsichtbar ist; am Rand meldet es Windows den Größenrahmen."""
|
||||
from provtool.gui import nativ, rahmen
|
||||
lauf = lauf_mit_daten
|
||||
r = lauf.app.rahmen
|
||||
if not r.klassisch:
|
||||
pytest.skip("nur im Win95-Rahmen")
|
||||
m = {}
|
||||
|
||||
def pruefen():
|
||||
n = r.nativ
|
||||
m["aktiv"] = r.nativ_an
|
||||
if not r.nativ_an:
|
||||
return
|
||||
stil, ex = n.stil()
|
||||
m["stil"] = {name: bool(stil & bit) for name, bit in (
|
||||
("Größenrahmen", nativ.WS_THICKFRAME), ("Maximieren", nativ.WS_MAXIMIZEBOX),
|
||||
("Minimieren", nativ.WS_MINIMIZEBOX), ("Systemmenü", nativ.WS_SYSMENU), ("Titel", nativ.WS_CAPTION))}
|
||||
m["programmfenster"] = not (ex & nativ.WS_EX_TOOLWINDOW) and bool(ex & nativ.WS_EX_APPWINDOW)
|
||||
m["rahmen_unsichtbar"] = n.innen_gleich_aussen()
|
||||
x, y, bw, bh = n.aussenmass()
|
||||
m["tk_gleich_aussen"] = (lauf.root.winfo_width(), lauf.root.winfo_height()) == (bw, bh)
|
||||
my = y + bh // 2
|
||||
m["links"] = n.trefferpruefung(n.hwnd, x + 2, my) == nativ.HT["w"]
|
||||
m["ecke"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + bh - 3) == nativ.HT["se"]
|
||||
m["ecke_lang"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + 10) == nativ.HT["ne"]
|
||||
m["innen"] = n.trefferpruefung(n.hwnd, x + bw // 2, y + bh // 2) == nativ.HTCLIENT
|
||||
m["rand_durchlaessig"] = n.trefferpruefung(r.aussen.winfo_id(), x + 2, my) == nativ.HTTRANSPARENT
|
||||
m["innen_nicht"] = n.trefferpruefung(r.aussen.winfo_id(), x + bw // 2, y + 3 + 30) != nativ.HTTRANSPARENT
|
||||
# die Maus landet am Rand wirklich beim äußeren Fenster (durch Tk-Fenster hindurch)
|
||||
m["maus_am_rand"] = n.fenster_bei(x + 2, my) == n.hwnd
|
||||
m["maus_innen"] = n.fenster_bei(x + bw // 2, y + bh // 2) != n.hwnd
|
||||
|
||||
# Tk darf das Fenster bei internen Größenanfragen nicht um einen Rahmen vergrößern
|
||||
n.setzen(x, y, bw - 37, bh - 23) # wie Größe ziehen durch den Benutzer
|
||||
lauf.root.update()
|
||||
vorher = n.aussenmass()
|
||||
lauf.app.status.set("info", " " + "sehr langer Statustext " * 20)
|
||||
lauf.root.update_idletasks()
|
||||
lauf.root.update()
|
||||
m["waechst_nicht"] = (n.aussenmass(), vorher)
|
||||
|
||||
# Maximieren über Windows (wie Win+Hoch / an den oberen Rand ziehen) und zurück
|
||||
n.befehl(nativ.SC_MAXIMIZE)
|
||||
lauf.root.update()
|
||||
ax, ay, aw, ah = rahmen.arbeitsbereich(lauf.root)
|
||||
m["max"] = (r.maximiert, n.maximiert())
|
||||
m["max_inhalt"] = ((lauf.root.winfo_rootx(), lauf.root.winfo_rooty(), lauf.root.winfo_width(),
|
||||
lauf.root.winfo_height()), (ax, ay, aw, ah))
|
||||
m["max_symbol"] = str(r.titelleiste.knoepfe["max"].cget("image")) == str(r.titelleiste._bilder["restore"])
|
||||
m["kein_rand_maximiert"] = n.randbereich(ax + 1, ay + ah // 2) == ""
|
||||
n.befehl(nativ.SC_RESTORE)
|
||||
lauf.root.update()
|
||||
m["zurueck"] = (r.maximiert, n.aussenmass() == vorher)
|
||||
m["normale_geometrie"] = r.normale_geometrie()
|
||||
|
||||
lauf.ablauf([(IMMER, lambda: lauf.root.after(300, pruefen), "prüfen"),
|
||||
(lambda: "aktiv" in m and ("zurueck" in m or not m["aktiv"]), lambda: None, "fertig")],
|
||||
sekunden=15)
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert m["aktiv"], "natives Fenster wurde nicht eingerichtet"
|
||||
assert all(m["stil"].values()), f"Fensterstil unvollständig: {m['stil']}"
|
||||
assert m["programmfenster"], "Fenster ist für Windows noch ein Werkzeugfenster (kein Andocken/Win+Pfeil)"
|
||||
assert m["rahmen_unsichtbar"] and m["tk_gleich_aussen"], "Windows-Rahmen ist sichtbar / Tk-Größe passt nicht"
|
||||
for k in ("links", "ecke", "ecke_lang", "innen", "rand_durchlaessig", "innen_nicht", "maus_am_rand",
|
||||
"maus_innen"):
|
||||
assert m[k], f"Trefferprüfung am Rand falsch: {k} ({m})"
|
||||
assert m["waechst_nicht"][0] == m["waechst_nicht"][1], f"Fenster ist gewachsen: {m['waechst_nicht']}"
|
||||
assert m["max"] == (True, True), f"Maximieren über Windows nicht erkannt: {m['max']}"
|
||||
assert m["max_inhalt"][0] == m["max_inhalt"][1], f"maximiert nicht genau im Arbeitsbereich: {m['max_inhalt']}"
|
||||
assert m["max_symbol"] and m["kein_rand_maximiert"]
|
||||
assert m["zurueck"] == (False, True), f"Wiederherstellen: {m['zurueck']}"
|
||||
assert m["normale_geometrie"].startswith(f"{m['waechst_nicht'][1][2]}x{m['waechst_nicht'][1][3]}+")
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_haendler_dialog_airtime_staffel_grossbuchstaben(lauf_mit_daten):
|
||||
"""Airtime-% und eigene Jahresbonus-Staffel je Händler; die GROSSBUCHSTABEN-Regel ist ein Häkchen
|
||||
(gemeldet: Vodafone fehlte im Export – die Regel ging verloren, wenn man „Hinzufügen“ vergaß)."""
|
||||
from provtool.db import CAPS_ANBIETER, CAPS_WERT
|
||||
from provtool.gui.dialogs import HaendlerDialog, StaffelDialog
|
||||
lauf = lauf_mit_daten
|
||||
app = lauf.app
|
||||
hid = app.db.haendler_liste()[0].id
|
||||
hd = lambda: lauf.dialog(HaendlerDialog) # noqa: E731
|
||||
sd = lambda: lauf.dialog(StaffelDialog) # noqa: E731
|
||||
|
||||
def eintragen():
|
||||
d = hd()
|
||||
d.v_air.set("40")
|
||||
d.v_caps.set(True)
|
||||
d.v_eigen_staffel.set(True)
|
||||
d._staffel_zeigen()
|
||||
lauf.root.after(10, d._staffel_bearbeiten)
|
||||
|
||||
def staffel_eingeben():
|
||||
d = sd()
|
||||
d._setzen([(1, 100), (3, None)])
|
||||
d.zeilen[0][2].set("2,50")
|
||||
d._ok()
|
||||
|
||||
lauf.ablauf([
|
||||
(IMMER, lambda: app.haendler(hid), "Händler-Dialog"),
|
||||
(lambda: hd() is not None, eintragen, "Airtime, Häkchen, eigene Staffel"),
|
||||
(lambda: sd() is not None, staffel_eingeben, "Staffel eingeben"),
|
||||
(lambda: sd() is None and "2,50" in hd().l_staffel.cget("text"), lambda: hd()._speichern(), "Speichern"),
|
||||
(IMMER, lambda: hd().abbrechen(), "Schließen"),
|
||||
(lambda: hd() is None, lambda: None, "zu"),
|
||||
])
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
h = app.db.haendler(hid)
|
||||
assert h.airtime_prozent == 40
|
||||
assert h.staffel == [(1, 250), (3, None)]
|
||||
assert (CAPS_ANBIETER, CAPS_WERT) in app.db.kennungen(hid)
|
||||
assert ("freenet", "111") in app.db.kennungen(hid)
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_export_dialog_standard_eine_datei_ohne_null(lauf_mit_daten, tmp_path):
|
||||
from provtool.gui.dialogs import ExportDialog
|
||||
lauf = lauf_mit_daten
|
||||
app = lauf.app
|
||||
app.db.betrag_setzen(next(p["id"] for p in app.positionen if p["provisionsart"] == "SIM-Karten"
|
||||
and p["haendler_id"] > 0), 0)
|
||||
app._aktualisieren()
|
||||
app.settings["export_ordner"] = str(tmp_path / "x")
|
||||
app.settings["export_pro_anbieter"] = True # alte Einstellung darf nicht mehr trennen
|
||||
ergebnis = {}
|
||||
|
||||
def ok():
|
||||
d = lauf.dialog(ExportDialog)
|
||||
ergebnis.update(anb=d.v_anb.get(), null=d.v_null.get(), haendler=list(d.haken))
|
||||
d.v_auf.set(False)
|
||||
d._ok()
|
||||
|
||||
from provtool.gui import win95 as w
|
||||
lauf.ablauf([
|
||||
(IMMER, app.exportieren, "Export"),
|
||||
(lambda: lauf.dialog(ExportDialog) is not None, ok, "Exportieren"),
|
||||
(lambda: any(d.title() == "Export" for d in lauf.dialoge()),
|
||||
lambda: [d.abbrechen() for d in lauf.dialoge() if isinstance(d, w.Dialog)], "Meldung schließen"),
|
||||
])
|
||||
assert not lauf.uebrig, lauf.diagnose()
|
||||
assert ergebnis == {"anb": False, "null": True, "haendler": [app.db.haendler_liste()[0].id]}
|
||||
import openpyxl
|
||||
dateien = sorted((tmp_path / "x").glob("*.xlsx"))
|
||||
assert [d.name for d in dateien] == ["Händler A - Oktober 2026.xlsx"]
|
||||
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True) if r[1]]
|
||||
assert [r[1] for r in zeilen] == ["Sonderbonus Aktion"], "0-€-Position wurde exportiert"
|
||||
lauf.pruefen()
|
||||
+147
-1
@@ -159,9 +159,14 @@ def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path):
|
||||
|
||||
# Export
|
||||
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
|
||||
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026")
|
||||
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026", pro_anbieter=True)
|
||||
assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx",
|
||||
"Händler A - Vodafone - Oktober 2026.xlsx"]
|
||||
# Standard: freenet UND Vodafone in einer Datei (gemeldet: Vodafone fehlte im Export)
|
||||
eine = export.exportiere_haendler(tmp_path / "out1", "Händler A", eigene, "Oktober 2026")
|
||||
assert [d.name for d in eine] == ["Händler A - Oktober 2026.xlsx"]
|
||||
arten = [r[1] for r in openpyxl.load_workbook(eine[0]).active.iter_rows(values_only=True) if r[1]]
|
||||
assert "Freischaltungsvergütung Vodafone" in arten and "SIM-Karten" in arten
|
||||
ws = openpyxl.load_workbook(dateien[0]).active
|
||||
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
|
||||
# Sortierung Zeitraum → Provisionsart → Datum
|
||||
@@ -265,3 +270,144 @@ def test_migration_alter_datenbank(tmp_path):
|
||||
spalten = {r[1] for r in d.con.execute("PRAGMA table_info(position)")}
|
||||
assert "haendler_fest" in spalten
|
||||
d.close()
|
||||
|
||||
|
||||
def test_eigene_kennungen_ausblenden(db, freenet_csv):
|
||||
from provtool.db import EIGEN
|
||||
hid = db.haendler_speichern(Haendler(0, "Händler A"), [("freenet", "111")])
|
||||
aid = db.abrechnung_neu("X")
|
||||
db.importieren(aid, lies_datei(freenet_csv))
|
||||
assert {p["haendler_id"] for p in db.positionen(aid)} == {hid, UNZUGEORDNET}
|
||||
db.eigene_setzen("freenet", "999")
|
||||
ids = {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}
|
||||
assert ids["999"] == EIGEN and ids["111"] == hid
|
||||
# Händler-Kennung hat Vorrang vor „eigen“
|
||||
db.eigene_setzen("freenet", "111")
|
||||
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid
|
||||
db.eigene_setzen("freenet", "999", False)
|
||||
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["999"] == UNZUGEORDNET
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Airtime / Jahresbonus
|
||||
|
||||
def _pos(art, betrag, unterh="111", rufnr="1601111111", name="Kunde", dienst="Teilnehmerverhaeltnis"):
|
||||
z = freenet_zeile(art, unterh, "05.10.2026", rufnr, name, "Tarif", betrag).split(";")
|
||||
z[24] = dienst
|
||||
return ";".join(z)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bonus_csv(tmp_path):
|
||||
zeilen = [FREENET_KOPF,
|
||||
_pos("Grundvergütung Mobilfunk Credit freenet", "50"),
|
||||
_pos("Grundvergütung Mobilfunk Credit freenet", "0"),
|
||||
_pos("Prepaidvergütung", "20", dienst="AirTime Provision"),
|
||||
_pos("Airtime Umsatzbeteiligung", "10,50")]
|
||||
zeilen += [_pos("Jahresbonus Neuaktivierung", "40", rufnr=f"16000000{i:02d}") for i in range(12)]
|
||||
zeilen += [_pos("Jahresbonus Neuaktivierung", "3", rufnr="1600000099")] # weniger als die Stufe
|
||||
p = tmp_path / "bonus.csv"
|
||||
p.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
|
||||
return p
|
||||
|
||||
|
||||
def test_typ_erkennung():
|
||||
from provtool import regeln
|
||||
assert regeln.erkennen("Prepaidvergütung Vodafone", "AirTime Provision") == regeln.AIRTIME
|
||||
assert regeln.erkennen("Airtime freenet") == regeln.AIRTIME
|
||||
assert regeln.erkennen("Jahresbonus Neuaktivierung") == regeln.JAHRESBONUS
|
||||
assert regeln.erkennen("SIM-Karten", "Teilnehmerverhaeltnis") == regeln.NORMAL
|
||||
|
||||
|
||||
def test_staffel_stufen():
|
||||
from provtool import regeln
|
||||
st = regeln.STANDARD_STAFFEL
|
||||
assert regeln.bonus_betrag(st, 0, 4000) == 0
|
||||
assert regeln.bonus_betrag(st, 1, 4000) == 500 and regeln.bonus_betrag(st, 9, 4000) == 500
|
||||
assert regeln.bonus_betrag(st, 10, 4000) == 1000 and regeln.bonus_betrag(st, 14, 4000) == 1000
|
||||
assert regeln.bonus_betrag(st, 39, 4000) == 3500
|
||||
assert regeln.bonus_betrag(st, 40, 4000) == 4000 and regeln.bonus_betrag(st, 80, 6000) == 6000 # voll
|
||||
assert regeln.bonus_betrag(st, 25, 1000) == 1000 # nie mehr als laut Abrechnung
|
||||
assert regeln.bonus_betrag(st, 12, -4000) == -1000 # Storno mit derselben Stufe
|
||||
with pytest.raises(ValueError):
|
||||
regeln.staffel_pruefen([(1, 500), (1, 600)])
|
||||
assert "1-9: 5,00 €" in regeln.staffel_beschreibung(st) and "ab 40: voll" in regeln.staffel_beschreibung(st)
|
||||
|
||||
|
||||
def test_airtime_und_jahresbonus(db, bonus_csv, tmp_path):
|
||||
from provtool import regeln
|
||||
hid = db.haendler_speichern(Haendler(0, "Sub", standard_prozent=100), [("freenet", "111")])
|
||||
aid = db.abrechnung_neu("Okt")
|
||||
db.importieren(aid, lies_datei(bonus_csv))
|
||||
pos = db.positionen(aid)
|
||||
air = [p for p in pos if p["typ"] == regeln.AIRTIME]
|
||||
bonus = [p for p in pos if p["typ"] == regeln.JAHRESBONUS]
|
||||
assert len(air) == 2 and len(bonus) == 13
|
||||
assert all(p["betrag"] == 0 for p in air), "Standard: 0 % Airtime = keine Airtime"
|
||||
# 13 Karten -> Stufe 10-14 = 10 € je Karte (die 3-€-Position bleibt bei 3 €)
|
||||
assert sorted(p["betrag"] for p in bonus) == [300] + [1000] * 12
|
||||
assert all(p["prozent"] is None for p in bonus)
|
||||
|
||||
# Airtime 50 %, eigene Staffel
|
||||
h = db.haendler(hid)
|
||||
h.airtime_prozent = 50
|
||||
h.staffel = [(1, 200), (13, None)]
|
||||
db.haendler_speichern(h, db.kennungen(hid))
|
||||
db.neu_berechnen(aid)
|
||||
pos = db.positionen(aid)
|
||||
assert sorted(p["betrag"] for p in pos if p["typ"] == regeln.AIRTIME) == [525, 1000]
|
||||
assert sorted(p["betrag"] for p in pos if p["typ"] == regeln.JAHRESBONUS) == [300] + [4000] * 12
|
||||
|
||||
# Zuweisung ändert die Anzahl Karten -> Staffel der übrigen wird neu berechnet
|
||||
weg = [p["id"] for p in pos if p["typ"] == regeln.JAHRESBONUS][:2]
|
||||
db.haendler_zuweisen(weg, UNZUGEORDNET)
|
||||
rest = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and p["haendler_id"] == hid]
|
||||
assert len(rest) == 11 and all(p["betrag"] == min(200, p["betrag_orig"]) for p in rest)
|
||||
|
||||
# Typ fest einstellen
|
||||
db.art_typ_setzen("Airtime Umsatzbeteiligung", regeln.NORMAL)
|
||||
assert any(a == "Airtime Umsatzbeteiligung" for a, _, _ in db.provisionsarten())
|
||||
db.neu_berechnen(aid)
|
||||
p = next(p for p in db.positionen(aid) if p["provisionsart"] == "Airtime Umsatzbeteiligung")
|
||||
assert p["typ"] == regeln.NORMAL and p["betrag"] == 1050
|
||||
db.art_typ_setzen("Airtime Umsatzbeteiligung", None)
|
||||
|
||||
# Export: Abrechnung mit Airtime-Summenzeile + eigene Airtime-Liste, 0-€-Positionen weg
|
||||
db.neu_berechnen(aid)
|
||||
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
|
||||
dateien = export.exportiere_haendler(tmp_path / "out", "Sub", eigene, "Okt")
|
||||
assert [d.name for d in dateien] == ["Sub - Okt.xlsx", "Sub - Airtime - Okt.xlsx"]
|
||||
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True)]
|
||||
daten = [r for r in zeilen if r[1]]
|
||||
assert all(r[10] != 0 for r in daten), "0-€-Positionen dürfen nicht im Export stehen"
|
||||
assert daten[-1][1] == "Airtime" and daten[-1][10] == 15.25
|
||||
assert not any(r[1] == "Prepaidvergütung" for r in daten), "Airtime-Einzelposten gehören in die eigene Liste"
|
||||
air_zeilen = [r for r in openpyxl.load_workbook(dateien[1]).active.iter_rows(values_only=True) if r[1]]
|
||||
assert sorted(r[10] for r in air_zeilen) == [5.25, 10.0]
|
||||
|
||||
# 0 % Airtime: keine Airtime-Zeile, keine Airtime-Datei
|
||||
h = db.haendler(hid)
|
||||
h.airtime_prozent = 0
|
||||
db.haendler_speichern(h, db.kennungen(hid))
|
||||
db.neu_berechnen(aid)
|
||||
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
|
||||
dateien = export.exportiere_haendler(tmp_path / "out0", "Sub", eigene, "Okt")
|
||||
assert [d.name for d in dateien] == ["Sub - Okt.xlsx"]
|
||||
assert not any(r[1] == "Airtime" for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True))
|
||||
|
||||
|
||||
def test_migration_v2_haendler_ohne_airtime(tmp_path):
|
||||
import sqlite3
|
||||
pfad = tmp_path / "v2.db"
|
||||
con = sqlite3.connect(pfad)
|
||||
con.executescript("""CREATE TABLE haendler (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
ansprechpartner TEXT DEFAULT '', email TEXT DEFAULT '', notiz TEXT DEFAULT '',
|
||||
standard_prozent REAL NOT NULL DEFAULT 100, aktiv INTEGER NOT NULL DEFAULT 1);
|
||||
INSERT INTO haendler (name, standard_prozent) VALUES ('Alt', 90);""")
|
||||
con.close()
|
||||
d = Datenbank(pfad)
|
||||
h = d.haendler_liste()[0]
|
||||
assert h.name == "Alt" and h.airtime_prozent == 0 and h.staffel is None
|
||||
assert d.staffel_fuer(h.id) == d.standard_staffel()
|
||||
d.standard_staffel_setzen([(1, 100), (5, None)])
|
||||
assert d.staffel_fuer(h.id) == [(1, 100), (5, None)]
|
||||
d.close()
|
||||
Reference in new issue
Block a user