Airtime je Händler, Jahresbonus-Staffel, Export überarbeitet, natives Windows-Fenster
Windows-Build / windows-exe (push) Successful in 5m7s

- Airtime: Auszahlung in % je Händler (0 % = keine). Im Export als eine Summenzeile,
  die Einzelposten in einer eigenen Airtime-Liste (zweite Excel-Datei).
- Jahresbonus Neuaktivierung: Betrag je Karte nach Staffel (Standard 1-9: 5 € ... ab 40: voll),
  Standard-Staffel änderbar, eigene Staffel je Händler möglich.
- Provisionsarten: Airtime/Jahresbonus werden erkannt, Behandlung je Provisionsart umstellbar.
- Export: freenet und Vodafone standardmäßig in einer Datei, Positionen mit 0,00 € weglassen.
- Händler-Dialog: GROSSBUCHSTABEN-Regel als Häkchen (ging verloren, wenn „Hinzufügen“ vergessen wurde).
- Windows: Hauptfenster ist ein echtes Programmfenster mit unsichtbarem Rahmen – Andocken am
  Bildschirmrand, Win+Pfeiltasten und natives Größe-Ziehen; Trennsteg baut erst beim Loslassen neu auf.
- DB-Schema v3 (Migration), Tests für alles Neue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kerim AtilganandClaude Opus 5.5 committed 2026-11-02 17:23:48 +01:00
1 parent 20256d266c
commit 966379293a
14 files changed
+1395 -115

No files matched your search

+23 -6
View File
@@ -18,12 +18,18 @@ normale Windows-Titelleiste verwendet.
## Bedienung in Kürze ## Bedienung in Kürze
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*: 1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in %, **Airtime in %** (0 % = keine
Airtime) und die *Kennungen*:
- freenet → Spalte **Unterhaendler** (z. B. `27518902`) - freenet → Spalte **Unterhaendler** (z. B. `27518902`)
- Vodafone → Spalte **Vo-Nr** (z. B. `80211721`, `EU133301`) **oder** - Vodafone → Spalte **Vo-Nr** (z. B. `80211721`, `EU133301`) **oder** das Häkchen
„Vodafone (Name in GROSSBUCHSTABEN)“: alle Vodafone-Positionen, deren Kundenname komplett „Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN“: alle Vodafone-Positionen, deren Kundenname
großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur ein komplett großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur
Händler haben und hat Vorrang vor der Vo-Nr. ein Händler haben und hat Vorrang vor der Vo-Nr.
- **Jahresbonus Neuaktivierung**: Betrag je Karte nach Staffel, abhängig von der Anzahl Karten des
Händlers in der Abrechnung. Standard (änderbar unter *Händler ▸ Standard-Staffel Jahresbonus…*):
1–9 Karten 5 €, 10–14 10 €, 15–19 15 €, 20–24 20 €, 25–29 25 €, 30–34 30 €, 35–39 35 € je Karte,
ab 40 der volle Betrag. Je Händler kann eine eigene Staffel hinterlegt werden. Nie mehr als die
Provision laut Abrechnung; Stornos zählen ab.
Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern. Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern.
Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…** Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…**
(feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“). (feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“).
@@ -44,7 +50,10 @@ normale Windows-Titelleiste verwendet.
- Doppelklick / `F2` auf eine Position: Betrag frei überschreiben - Doppelklick / `F2` auf eine Position: Betrag frei überschreiben
(`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert. (`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert.
- Rechtsklick: u. a. „Auszahlung 0 €“. - Rechtsklick: u. a. „Auszahlung 0 €“.
6. **Export** (`Strg+E`): je Händler (und je Anbieter) eine `.xlsx` im Format der Vorlage: 6. **Export** (`Strg+E`): je Händler eine `.xlsx` im Format der Vorlage – freenet und Vodafone
zusammen (auf Wunsch getrennt), **Airtime nur als eine Summenzeile** „Airtime“ – plus eine
eigene **Airtime-Liste** (`<Händler> - Airtime - <Titel>.xlsx`). Positionen mit 0,00 € werden
weggelassen (abschaltbar):
| A | B | C | D | E | F | G | H | I | J | K | L | M | N | | A | B | C | D | E | F | G | H | I | J | K | L | M | N |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -53,6 +62,14 @@ normale Windows-Titelleiste verwendet.
Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile. Sortierung: Zeitraum → Provisionsart → Datum, darunter eine Summenzeile.
Kopfzeile optional zuschaltbar. Kopfzeile optional zuschaltbar.
Welche Provisionsart Airtime bzw. Jahresbonus ist, erkennt das Programm an den Texten (z. B.
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
*Händler ▸ Provisionsarten…*.
Das 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`). Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`).
Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*. Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*.
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.2.0" __version__ = "1.3.0"
APP_NAME = "Provisions-Manager 95" APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach" APP_ORG = "Handy-Insel Miesbach"
+2 -1
View File
@@ -35,7 +35,8 @@ DEFAULTS = {
"import_ordner": None, "import_ordner": None,
"export_kopfzeile": False, "export_kopfzeile": False,
"export_summe": True, "export_summe": True,
"export_pro_anbieter": True, "export_anbieter_getrennt": False, # freenet + Vodafone in einer Datei
"export_null_weglassen": True,
} }
+158 -40
View File
@@ -8,10 +8,11 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from provtool import regeln
from provtool.importer import ImportErgebnis from provtool.importer import ImportErgebnis
from provtool.money import apply_percent from provtool.money import apply_percent
SCHEMA_VERSION = 2 SCHEMA_VERSION = 3
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT); CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
@@ -23,7 +24,15 @@ CREATE TABLE IF NOT EXISTS haendler (
email TEXT DEFAULT '', email TEXT DEFAULT '',
notiz TEXT DEFAULT '', notiz TEXT DEFAULT '',
standard_prozent REAL NOT NULL DEFAULT 100, standard_prozent REAL NOT NULL DEFAULT 100,
aktiv INTEGER NOT NULL DEFAULT 1 aktiv INTEGER NOT NULL DEFAULT 1,
airtime_prozent REAL NOT NULL DEFAULT 0, -- 0 = keine Airtime
staffel TEXT -- Jahresbonus-Staffel (JSON), NULL = Standard-Staffel
);
-- Fest eingestellter Typ einer Provisionsart (sonst automatisch erkannt, siehe regeln.py).
CREATE TABLE IF NOT EXISTS art_typ (
provisionsart TEXT PRIMARY KEY,
typ TEXT NOT NULL
); );
-- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu -- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu
@@ -112,6 +121,8 @@ class Haendler:
notiz: str = "" notiz: str = ""
standard_prozent: float = 100.0 standard_prozent: float = 100.0
aktiv: bool = True aktiv: bool = True
airtime_prozent: float = 0.0
staffel: list | None = None # None = Standard-Staffel
def _now() -> str: def _now() -> str:
@@ -135,6 +146,11 @@ class Datenbank:
if "haendler_fest" not in spalten: if "haendler_fest" not in spalten:
# NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner) # NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner)
self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER") self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER")
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.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
self.con.commit() self.con.commit()
@@ -158,30 +174,34 @@ class Datenbank:
shutil.copyfile(quelle, ziel) shutil.copyfile(quelle, ziel)
# ------------------------------------------------------------------ Händler # ------------------------------------------------------------------ Händler
@staticmethod
def _haendler_aus(r) -> Haendler:
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"], r["standard_prozent"],
bool(r["aktiv"]), r["airtime_prozent"] or 0.0, regeln.staffel_laden(r["staffel"]))
def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]: def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]:
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name" sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name"
return [Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"], return [self._haendler_aus(r) for r in self.con.execute(sql)]
r["standard_prozent"], bool(r["aktiv"])) for r in self.con.execute(sql)]
def haendler(self, hid: int) -> Haendler | None: def haendler(self, hid: int) -> Haendler | None:
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone() r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
if not r: return self._haendler_aus(r) if r else None
return None
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
r["standard_prozent"], bool(r["aktiv"]))
def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int: def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
"""Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]""" """Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]"""
staffel = regeln.staffel_text(regeln.staffel_pruefen(h.staffel)) if h.staffel is not None else None
werte = (h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv),
h.airtime_prozent or 0.0, staffel)
with self.con: with self.con:
if h.id: if h.id:
self.con.execute( self.con.execute(
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=? WHERE id=?", "UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=?, "
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv), h.id)) "airtime_prozent=?, staffel=? WHERE id=?", werte + (h.id,))
hid = h.id hid = h.id
else: else:
cur = self.con.execute( cur = self.con.execute(
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv) VALUES (?,?,?,?,?,?)", "INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv, "
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv))) "airtime_prozent, staffel) VALUES (?,?,?,?,?,?,?,?)", werte)
hid = cur.lastrowid hid = cur.lastrowid
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,)) self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
for anbieter, wert in kennungen: for anbieter, wert in kennungen:
@@ -254,6 +274,51 @@ class Datenbank:
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent", "ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent",
(hid, provisionsart, prozent)) (hid, provisionsart, prozent))
# ------------------------------------------------------------------ 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: def prozent_fuer(self, hid: int, provisionsart: str) -> float:
if hid <= 0: # nicht zugeordnet / eigene Filiale if hid <= 0: # nicht zugeordnet / eigene Filiale
return 100.0 return 100.0
@@ -302,7 +367,6 @@ class Datenbank:
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,)) self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
def importieren(self, aid: int, erg: ImportErgebnis) -> int: def importieren(self, aid: int, erg: ImportErgebnis) -> int:
finde = self.zuordner()
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?", start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
(aid,)).fetchone()[0] (aid,)).fetchone()[0]
with self.con: with self.con:
@@ -311,45 +375,109 @@ class Datenbank:
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen))) (aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
did = cur.lastrowid did = cur.lastrowid
for i, p in enumerate(erg.positionen, start=start + 1): for i, p in enumerate(erg.positionen, start=start + 1):
hid = finde(p.anbieter, p.kennung, p.name)
proz = self.prozent_fuer(hid, p.provisionsart)
self.con.execute( self.con.execute(
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, " "INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, " "datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)", "betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,0,?)",
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung, (aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung,
p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name, p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name,
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent,
apply_percent(p.betrag_cent, proz), proz, json.dumps(p.roh, ensure_ascii=False, default=str))) json.dumps(p.roh, ensure_ascii=False, default=str)))
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid)) self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))}
self._regeln_anwenden(aid, neue, auch_manuelle=True)
return len(erg.positionen) return len(erg.positionen)
def positionen(self, aid: int) -> list[dict]: def positionen(self, aid: int) -> list[dict]:
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen).""" """Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
finde = self.zuordner() finde = self.zuordner()
fest = self.art_typen()
out = [] out = []
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)): for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
d = dict(r) d = dict(r)
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"]) d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
d["typ"] = regeln.typ_von(d, fest)
out.append(d) out.append(d)
return out return out
# ------------------------------------------------------------------ Berechnung nach Regeln
def _rechner(self):
"""Funktion (position, händler_id, typ, karten) -> (auszahlung_cent, prozent).
prozent ist None, wenn der Betrag aus der Jahresbonus-Staffel kommt."""
haendler = {h.id: h for h in self.haendler_liste()}
art_regeln: dict[int, dict[str, float]] = {}
for r in self.con.execute("SELECT haendler_id, provisionsart, prozent FROM regel"):
art_regeln.setdefault(r[0], {})[r[1]] = r[2]
std_staffel = self.standard_staffel()
def rechne(p: dict, hid: int, typ: str, karten: int) -> tuple[int, float | None]:
orig = p["betrag_orig"]
h = haendler.get(hid) if hid > 0 else None
if h is None: # nicht zugeordnet / eigene Filiale: unverändert
return orig, 100.0
regel = art_regeln.get(hid, {}).get(p["provisionsart"])
if regel is not None: # ausdrücklich je Provisionsart festgelegt -> hat Vorrang
proz = regel
elif typ == regeln.AIRTIME:
proz = h.airtime_prozent
elif typ == regeln.JAHRESBONUS:
return regeln.bonus_betrag(h.staffel if h.staffel is not None else std_staffel, karten, orig), None
else:
proz = h.standard_prozent
return apply_percent(orig, proz), proz
return rechne
@staticmethod
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]:
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab)."""
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: def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
"""Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen). """Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen).
Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet.""" Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet."""
with self.con: with self.con:
for pid in pids: for pid in pids:
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid)) self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
finde = self.zuordner() for aid in self._abrechnungen_von(pids):
with self.con: self._regeln_anwenden(aid, set(pids))
for pid in pids:
r = self.con.execute("SELECT * FROM position WHERE id=?", (pid,)).fetchone() def regeln_wiederherstellen(self, pids: list[int]) -> None:
if r is None or r["manuell"]: """Ausgewählte Positionen wieder nach den Händler-Regeln berechnen (auch von Hand geänderte)."""
continue for aid in self._abrechnungen_von(pids):
neu_hid = finde(r["anbieter"], r["kennung"], r["name"], r["haendler_fest"]) self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
proz = self.prozent_fuer(neu_hid, r["provisionsart"])
self.con.execute("UPDATE position SET betrag=?, prozent=? WHERE id=?",
(apply_percent(r["betrag_orig"], proz), proz, pid))
def betrag_setzen(self, pid: int, cent: int) -> None: def betrag_setzen(self, pid: int, cent: int) -> None:
with self.con: with self.con:
@@ -370,17 +498,7 @@ class Datenbank:
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int: def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an. """Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an.
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde.""" Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
n = 0 return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle)
with self.con:
for p in self.positionen(aid):
if p["manuell"] and not auch_manuelle:
continue
proz = self.prozent_fuer(p["haendler_id"], p["provisionsart"])
neu = apply_percent(p["betrag_orig"], proz)
if neu != p["betrag"] or proz != p["prozent"] or p["manuell"]:
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?", (neu, proz, p["id"]))
n += 1
return n
def touch(self, aid: int) -> None: def touch(self, aid: int) -> None:
with self.con: with self.con:
+42 -12
View File
@@ -3,6 +3,9 @@
Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN | Spalten: A Zeitraum | B Provisionsart | C Unterhändler-/VO-Nr | D Datum | E Rufnummer | F IMEI/SN |
G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode G Gerät | H Vorname | I Name | J Tarif | K Betrag (Auszahlung) | L Netz | M Dienst | N Aktionscode
Sortierung: Zeitraum -> Provisionsart -> Datum (sonst Reihenfolge der Abrechnung). Sortierung: Zeitraum -> Provisionsart -> Datum (sonst Reihenfolge der Abrechnung).
Je Händler entstehen zwei Dateien: die Abrechnung (Airtime darin nur als eine Summenzeile) und die
Airtime-Liste mit den Einzelposten. Positionen mit 0,00 € werden weggelassen.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,6 +15,7 @@ from pathlib import Path
from provtool.importer import ANBIETER from provtool.importer import ANBIETER
from provtool.money import to_euro from provtool.money import to_euro
from provtool.regeln import AIRTIME
SPALTEN = [ SPALTEN = [
("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13), ("Zeitraum", 20), ("Provisionsart", 38), ("Unterhändler", 11), ("Datum", 11), ("Rufnummer", 13),
@@ -38,8 +42,19 @@ def dateiname(text: str) -> str:
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export" return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export"
AIRTIME_TEXT = "Airtime"
def airtime_zeile(airtime: list[dict]) -> dict:
"""Eine Zeile „Airtime = Summe“ für die Abrechnung."""
zeitraeume = sorted({p.get("zeitraum") or "" for p in airtime} - {""})
return {"zeitraum": zeitraeume[0] if len(zeitraeume) == 1 else "", "provisionsart": AIRTIME_TEXT,
"tarif": "Summe laut Airtime-Liste", "betrag": sum(p["betrag"] for p in airtime), "_summe": True}
def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True, def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = False, summe: bool = True,
blattname: str = "Tabelle1") -> Path: blattname: str = "Tabelle1", schluss: list[dict] | None = None) -> Path:
"""schluss: Zeilen, die nach den sortierten Positionen kommen (z. B. die Airtime-Summe)."""
import openpyxl import openpyxl
from openpyxl.styles import Font from openpyxl.styles import Font
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -59,7 +74,7 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
zeile += 1 zeile += 1
erste = zeile erste = zeile
for p in sortiert(positionen): for p in sortiert(positionen) + list(schluss or []):
werte = [ werte = [
p.get("zeitraum") or None, p.get("zeitraum") or None,
p.get("provisionsart") 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): for c, v in enumerate(werte, start=1):
cell = ws.cell(zeile, c, v) 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, 4).number_format = "DD.MM.YYYY"
ws.cell(zeile, 5).number_format = "0" ws.cell(zeile, 5).number_format = "0"
ws.cell(zeile, 6).number_format = "0" ws.cell(zeile, 6).number_format = "0"
@@ -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, *, def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
pro_anbieter: bool = True, kopfzeile: bool = False, summe: bool = True) -> list[Path]: pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
"""Schreibt eine (oder je Anbieter eine) Datei für einen Händler. Gibt die erzeugten Pfade zurück.""" null_weglassen: bool = True) -> 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) ordner = Path(ordner)
if null_weglassen:
positionen = [p for p in positionen if p["betrag"]]
if not positionen: if not positionen:
return [] return []
gruppen: dict[str, list[dict]] = {} gruppen: dict[str, list[dict]] = {}
if pro_anbieter:
for p in positionen: for p in positionen:
gruppen.setdefault(p["anbieter"], []).append(p) gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
else:
gruppen[""] = positionen def name(*teile) -> str:
return dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t)) + ".xlsx"
out = [] out = []
for anbieter, pos in sorted(gruppen.items()): for anbieter, pos in sorted(gruppen.items()):
teile = [haendler_name] + ([ANBIETER.get(anbieter, anbieter)] if anbieter else []) + [titel] provision = [p for p in pos if p.get("typ") != AIRTIME]
name = dateiname(" - ".join(t for t in teile if t)) + ".xlsx" airtime = [p for p in pos if p.get("typ") == AIRTIME]
out.append(schreibe_xlsx(ordner / name, pos, kopfzeile=kopfzeile, summe=summe)) schluss = [airtime_zeile(airtime)] if airtime else []
if schluss and null_weglassen and not schluss[0]["betrag"]:
schluss = []
if provision or schluss:
out.append(schreibe_xlsx(ordner / name(ANBIETER.get(anbieter, anbieter) if anbieter else ""),
provision, kopfzeile=kopfzeile, summe=summe, schluss=schluss))
airtime = [p for p in positionen if p.get("typ") == AIRTIME]
if airtime:
out.append(schreibe_xlsx(ordner / name(AIRTIME_TEXT), airtime, kopfzeile=kopfzeile, summe=summe,
blattname=AIRTIME_TEXT))
return out return out
+44 -12
View File
@@ -14,6 +14,7 @@ from tkinter import filedialog
from provtool import APP_NAME, config from provtool import APP_NAME, config
from provtool import export as exp from provtool import export as exp
from provtool import regeln
from provtool.db import EIGEN, UNZUGEORDNET, Datenbank from provtool.db import EIGEN, UNZUGEORDNET, Datenbank
from provtool.gui import assistent as ass from provtool.gui import assistent as ass
from provtool.gui import dialogs as dlg from provtool.gui import dialogs as dlg
@@ -137,6 +138,9 @@ class App:
h.add_separator() h.add_separator()
h.add_command(label="Eigene Kennungen (ausblenden)...", command=self.eigene_verwalten) 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_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 = self.menueleiste.menue("?", underline=None)
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
@@ -181,9 +185,11 @@ class App:
def _body(self): def _body(self):
desk = tk.Frame(self.inhalt, bg=w.TEAL) desk = tk.Frame(self.inhalt, bg=w.TEAL)
desk.pack(side="top", fill="both", expand=True) desk.pack(side="top", fill="both", expand=True)
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar # Trennlinie zwischen den Spalten: 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", pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised",
bd=0, opaqueresize=True, sashcursor="sb_h_double_arrow") bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
pw.pack(fill="both", expand=True, padx=6, pady=6) pw.pack(fill="both", expand=True, padx=6, pady=6)
# --- Händler-Fenster # --- Händler-Fenster
@@ -366,6 +372,9 @@ class App:
so = sum(p["betrag_orig"] for p in plist) so = sum(p["betrag_orig"] for p in plist)
sa = sum(p["betrag"] for p in plist) sa = sum(p["betrag"] for p in plist)
proz = {p["prozent"] for p in plist} proz = {p["prozent"] for p in plist}
if all(_ist_staffel(p) for p in plist):
pz = "Staffel"
else:
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "") 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)), 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",)) values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
@@ -380,7 +389,8 @@ class App:
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]), values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""), p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
fmt(p["betrag_orig"]), fmt(p["betrag_orig"]),
"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)) tags=tuple(tags))
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet", name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get( EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
@@ -519,6 +529,17 @@ class App:
self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet") self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet")
self._aktualisieren() 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): def kennung_zuordnen(self, anbieter: str, kennung: str):
res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen() res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen()
if not res: if not res:
@@ -583,8 +604,7 @@ class App:
sel, pids, ps = self._auswahl_info() sel, pids, ps = self._auswahl_info()
if not ps: if not ps:
return return
for p in ps: self.db.regeln_wiederherstellen([p["id"] for p in ps])
self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"]))
self.db.touch(self.aid) self.db.touch(self.aid)
self._aktualisieren() self._aktualisieren()
self._auswahl_wiederherstellen(sel) self._auswahl_wiederherstellen(sel)
@@ -789,7 +809,7 @@ class App:
return return
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0]) stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen: for p in 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"]][0] += 1
stat[p["haendler_id"]][1] += p["betrag"] stat[p["haendler_id"]][1] += p["betrag"]
if not stat: if not stat:
@@ -810,7 +830,7 @@ class App:
pos = [p for p in self.positionen if p["haendler_id"] == hid] 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"], erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"], pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
summe=res["summe"]) summe=res["summe"], null_weglassen=res["null_weglassen"])
except PermissionError as exc: except PermissionError as exc:
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n" w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
"Ist sie vielleicht noch in Excel geöffnet?") "Ist sie vielleicht noch in Excel geöffnet?")
@@ -883,9 +903,11 @@ class App:
def hilfe(self): def hilfe(self):
w.info(self.root, "Kurzanleitung", w.info(self.root, "Kurzanleitung",
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n" "1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in %, Airtime in % (0 % = keine)\n"
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr. oder die Regel\n" " und die Kennungen – bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n"
" „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick > Händler zuweisen.\n\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" "2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n" "3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
" auch alle Wochenlisten eines Monats.\n\n" " auch alle Wochenlisten eines Monats.\n\n"
@@ -894,7 +916,11 @@ class App:
" optional als Standard für diesen Händler merken.\n" " optional als Standard für diesen Händler merken.\n"
" - Doppelklick / F2 auf eine Position: Betrag frei ändern\n" " - Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
" (Enter = speichern, Pfeil runter/Tab = nächste Zeile, Esc = abbrechen).\n\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.\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.") "Alles wird automatisch in der Datenbank gespeichert.")
def assistent(self): def assistent(self):
@@ -945,7 +971,8 @@ class App:
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. " "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."), "Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
(tb["Export"], "Exportieren", (tb["Export"], "Exportieren",
"Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat."), "Zum Schluss erstellt „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", (self.status, "Summen",
"Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: " "Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: "
"Provision laut Anbieter und Ihre Auszahlung."), "Provision laut Anbieter und Ihre Auszahlung."),
@@ -1041,6 +1068,11 @@ class App:
pass 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(): def main():
if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest": if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest":
from provtool.selbsttest import ausfuehren from provtool.selbsttest import ausfuehren
+4
View File
@@ -537,6 +537,10 @@ class Rundgang:
# =========================================================================== Tipp des Tages # =========================================================================== Tipp des Tages
TIPPS = [ 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 " "Sie können mehrere Dateien gleichzeitig importieren – zum Beispiel alle freenet-Wochenlisten eines Monats "
"und die Vodafone-Datei in einem Rutsch.", "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 % "
+284 -33
View File
@@ -5,15 +5,16 @@ import tkinter as tk
from tkinter import filedialog, ttk from tkinter import filedialog, ttk
from provtool import APP_NAME, APP_ORG, __version__ from provtool import APP_NAME, APP_ORG, __version__
from provtool import regeln
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler
from provtool.gui import win95 as w from provtool.gui import win95 as w
from provtool.importer import ANBIETER from provtool.importer import ANBIETER
from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige # Auswahl im Händler-Dialog: Schlüssel -> Anzeige
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)", KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"}
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN" 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 # =========================================================================== Händlerverwaltung
@@ -51,6 +52,7 @@ class HaendlerDialog(w.Dialog):
g.pack(fill="x") g.pack(fill="x")
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar() self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar()
self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True) self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
self.v_air = tk.StringVar(value="0")
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap), for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
("E-Mail:", self.v_mail))): ("E-Mail:", self.v_mail))):
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2) tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
@@ -61,14 +63,20 @@ class HaendlerDialog(w.Dialog):
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left") tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left")
tk.Label(pf, text="% der Provision").pack(side="left", padx=4) tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e") tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e")
tk.Label(g, text="Notiz:", anchor="nw").grid(row=4, column=0, sticky="nw", pady=2) tk.Label(g, text="Airtime:", anchor="w").grid(row=4, column=0, sticky="w", pady=2)
self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT) af = tk.Frame(g, bg=w.FACE)
self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2) af.grid(row=4, column=1, columnspan=2, sticky="w")
self.e_air = tk.Entry(af, textvariable=self.v_air, width=7, justify="right")
self.e_air.pack(side="left")
tk.Label(af, text="% der Airtime (0 % = keine Airtime)").pack(side="left", padx=4)
tk.Label(g, text="Notiz:", anchor="nw").grid(row=5, column=0, sticky="nw", pady=2)
self.t_notiz = tk.Text(g, width=36, height=2, font=w.FONT)
self.t_notiz.grid(row=5, column=1, columnspan=2, sticky="we", pady=2)
g.columnconfigure(1, weight=1) g.columnconfigure(1, weight=1)
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)") g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
g2.pack(fill="both", expand=True, pady=(8, 0)) g2.pack(fill="both", expand=True, pady=(8, 0))
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=4, r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=3,
selectmode="browse") selectmode="browse")
self.kliste.heading("anbieter", text="Anbieter") self.kliste.heading("anbieter", text="Anbieter")
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.") self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
@@ -89,10 +97,27 @@ class HaendlerDialog(w.Dialog):
ke.bind("<Return>", lambda e: self._kennung_dazu()) ke.bind("<Return>", lambda e: self._kennung_dazu())
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left") w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4) w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4)
self.v_caps = tk.BooleanVar(value=False)
self.cb_caps = tk.Checkbutton(g2, text=CAPS_HAKEN, variable=self.v_caps, anchor="w")
self.cb_caps.pack(fill="x", pady=(4, 0))
g4 = w.gruppe(rechts, "Jahresbonus Neuaktivierung (Betrag je Karte nach Staffel)")
g4.pack(fill="x", pady=(8, 0))
self.v_eigen_staffel = tk.BooleanVar(value=False)
sf = tk.Frame(g4, bg=w.FACE)
sf.pack(fill="x")
tk.Radiobutton(sf, text="Standard-Staffel", variable=self.v_eigen_staffel, value=False,
command=self._staffel_zeigen).pack(side="left")
tk.Radiobutton(sf, text="Eigene Staffel für diesen Händler", variable=self.v_eigen_staffel, value=True,
command=self._staffel_zeigen).pack(side="left", padx=(8, 0))
w.knopf(sf, "Bearbeiten...", self._staffel_bearbeiten).pack(side="right")
self.l_staffel = tk.Label(g4, text="", anchor="w", justify="left", fg=w.NAVY, wraplength=560)
self.l_staffel.pack(fill="x", pady=(2, 0))
self._eigene_staffel: regeln.Staffel | None = None
g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart") g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart")
g3.pack(fill="both", expand=True, pady=(8, 0)) g3.pack(fill="both", expand=True, pady=(8, 0))
r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=4, selectmode="browse") r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=3, selectmode="browse")
self.rliste.heading("art", text="Provisionsart") self.rliste.heading("art", text="Provisionsart")
self.rliste.heading("proz", text="Auszahlung") self.rliste.heading("proz", text="Auszahlung")
self.rliste.column("art", width=240) self.rliste.column("art", width=240)
@@ -112,9 +137,14 @@ class HaendlerDialog(w.Dialog):
self._liste_laden(auswahl_id) self._liste_laden(auswahl_id)
if neue_kennung: if neue_kennung:
self._neu() self._neu()
if neue_kennung[0] == CAPS_ANBIETER:
self.v_caps.set(True)
else:
self._kennungen = [neue_kennung] self._kennungen = [neue_kennung]
self._kennungen_zeigen() self._kennungen_zeigen()
self.minsize(640, 460) elif self.liste.selection():
self._auswahl() # sofort füllen – sonst wächst der Dialog erst nach der Größenberechnung
self.minsize(660, 560)
# ---- intern # ---- intern
@staticmethod @staticmethod
@@ -122,9 +152,42 @@ class HaendlerDialog(w.Dialog):
return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige) return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige)
def _art_gewaehlt(self): def _art_gewaehlt(self):
caps = self._anbieter_key(self.v_kanb.get()) == CAPS_ANBIETER self._ke.focus_set()
self.v_kwert.set("")
self._ke.configure(state="disabled" if caps else "normal") def _caps_einstellen(self, hid: int, an: bool):
"""GROSSBUCHSTABEN-Häkchen: gesperrt, wenn die Regel schon einem anderen Händler gehört."""
self.v_caps.set(an)
besitzer = self.db.kennung_map().get((CAPS_ANBIETER, CAPS_WERT))
if besitzer and besitzer != hid:
h = self.db.haendler(besitzer)
self.cb_caps.configure(state="disabled", text=f"GROSSBUCHSTABEN-Regel: gehört zu „{h.name if h else '?'}“")
else:
self.cb_caps.configure(state="normal", text=CAPS_HAKEN)
def _staffel_zeigen(self):
eigen = self.v_eigen_staffel.get()
if eigen and self._eigene_staffel is None:
self._eigene_staffel = list(self.db.standard_staffel())
st = self._eigene_staffel if eigen else self.db.standard_staffel()
self.l_staffel.configure(text=("Eigene Staffel: " if eigen else "Standard: ")
+ regeln.staffel_beschreibung(st, kurz=True))
def _staffel_bearbeiten(self):
if not self.v_eigen_staffel.get():
if w.frage(self, "Jahresbonus-Staffel",
"Die Standard-Staffel gilt für alle Händler ohne eigene Staffel.\n\n"
"Soll dieser Händler eine eigene Staffel bekommen?\n"
"(Die Standard-Staffel ändern Sie unter Händler > Standard-Staffel Jahresbonus...)",
ja="Eigene Staffel", nein="Abbrechen"):
self.v_eigen_staffel.set(True)
else:
return
neu = StaffelDialog(self, self._eigene_staffel or self.db.standard_staffel(),
f"Jahresbonus-Staffel – {self.v_name.get().strip() or 'neuer Händler'}",
standard=self.db.standard_staffel()).zeigen()
if neu is not None:
self._eigene_staffel = neu
self._staffel_zeigen()
def _liste_laden(self, auswahl_id=None): def _liste_laden(self, auswahl_id=None):
self.liste.delete(*self.liste.get_children()) self.liste.delete(*self.liste.get_children())
@@ -150,19 +213,22 @@ class HaendlerDialog(w.Dialog):
self.v_ap.set(h.ansprechpartner) self.v_ap.set(h.ansprechpartner)
self.v_mail.set(h.email) self.v_mail.set(h.email)
self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", "")) self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", ""))
self.v_air.set(fmt_percent(h.airtime_prozent).replace(" %", ""))
self.v_aktiv.set(h.aktiv) self.v_aktiv.set(h.aktiv)
self.t_notiz.delete("1.0", "end") self.t_notiz.delete("1.0", "end")
self.t_notiz.insert("1.0", h.notiz or "") self.t_notiz.insert("1.0", h.notiz or "")
self._kennungen = self.db.kennungen(h.id) kenn = self.db.kennungen(h.id)
self._kennungen = [k for k in kenn if k[0] != CAPS_ANBIETER]
self._caps_einstellen(h.id, (CAPS_ANBIETER, CAPS_WERT) in kenn)
self._eigene_staffel = list(h.staffel) if h.staffel is not None else None
self.v_eigen_staffel.set(h.staffel is not None)
self._staffel_zeigen()
self._kennungen_zeigen() self._kennungen_zeigen()
self._regeln_zeigen() self._regeln_zeigen()
def _kennungen_zeigen(self): def _kennungen_zeigen(self):
self.kliste.delete(*self.kliste.get_children()) self.kliste.delete(*self.kliste.get_children())
for i, (a, v) in enumerate(self._kennungen): for i, (a, v) in enumerate(self._kennungen):
if a == CAPS_ANBIETER:
self.kliste.insert("", "end", iid=str(i), values=("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): def _regeln_zeigen(self):
@@ -177,15 +243,20 @@ class HaendlerDialog(w.Dialog):
for v in (self.v_name, self.v_ap, self.v_mail): for v in (self.v_name, self.v_ap, self.v_mail):
v.set("") v.set("")
self.v_proz.set("100") self.v_proz.set("100")
self.v_air.set("0")
self.v_aktiv.set(True) self.v_aktiv.set(True)
self.t_notiz.delete("1.0", "end") self.t_notiz.delete("1.0", "end")
self._kennungen = [] self._kennungen = []
self._caps_einstellen(0, False)
self._eigene_staffel = None
self.v_eigen_staffel.set(False)
self._staffel_zeigen()
self._kennungen_zeigen() self._kennungen_zeigen()
self._regeln_zeigen() self._regeln_zeigen()
def _kennung_dazu(self): def _kennung_dazu(self):
anb = self._anbieter_key(self.v_kanb.get()) anb = self._anbieter_key(self.v_kanb.get())
wert = CAPS_WERT if anb == CAPS_ANBIETER else self.v_kwert.get().strip() wert = self.v_kwert.get().strip()
if not wert: if not wert:
return return
if (anb, wert) not in self._kennungen: if (anb, wert) not in self._kennungen:
@@ -216,12 +287,21 @@ class HaendlerDialog(w.Dialog):
except ValueError: except ValueError:
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).") w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
return return
try:
air = parse_percent(self.v_air.get() or "0")
except ValueError:
w.warnung(self, "Händler", "Die Airtime-Auszahlung muss eine Zahl sein (z. B. 0, 50 oder 100).")
return
if self.v_kwert.get().strip(): if self.v_kwert.get().strip():
self._kennung_dazu() self._kennung_dazu()
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(), h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get()) self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air,
self._eigene_staffel if self.v_eigen_staffel.get() else None)
kennungen = list(self._kennungen)
if self.v_caps.get() and str(self.cb_caps.cget("state")) != "disabled":
kennungen.append((CAPS_ANBIETER, CAPS_WERT))
try: try:
hid = self.db.haendler_speichern(h, self._kennungen) hid = self.db.haendler_speichern(h, kennungen)
except Exception as exc: # sqlite3.IntegrityError / ValueError except Exception as exc: # sqlite3.IntegrityError / ValueError
msg = str(exc) msg = str(exc)
if "UNIQUE" in msg and "haendler.name" in msg: if "UNIQUE" in msg and "haendler.name" in msg:
@@ -309,6 +389,171 @@ class ProzentDialog(w.Dialog):
return super().zeigen(self._e) return super().zeigen(self._e)
# =========================================================================== Jahresbonus-Staffel
class StaffelDialog(w.Dialog):
"""Staffel bearbeiten: je Stufe „ab N Karten“ und Betrag je Karte (oder „voll“)."""
def __init__(self, master, staffel: regeln.Staffel, titel: str = "Jahresbonus-Staffel",
standard: regeln.Staffel | None = None):
super().__init__(master, titel)
self.standard = standard
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=12, pady=(10, 4))
tk.Label(f, justify="left", anchor="w", wraplength=380, text=(
"Jahresbonus Neuaktivierung: Der Händler bekommt je Karte den Betrag der Stufe, die zu seiner "
"Anzahl Karten in der Abrechnung passt. „voll“ = ganzer Betrag laut Abrechnung. Nie mehr als "
"die Provision laut Abrechnung; Stornos zählen ab.")).pack(fill="x")
kopf = tk.Frame(f, bg=w.FACE)
kopf.pack(fill="x", pady=(8, 2))
tk.Label(kopf, text="ab Karten", width=9, anchor="w").pack(side="left")
tk.Label(kopf, text="Betrag je Karte (oder „voll“)", anchor="w").pack(side="left", padx=(8, 0))
self.tabelle = tk.Frame(f, bg=w.FACE)
self.tabelle.pack(fill="x")
self.zeilen: list[tuple[tk.Frame, tk.StringVar, tk.StringVar]] = []
for ab, cent in staffel:
self._zeile(ab, cent)
kk = tk.Frame(f, bg=w.FACE)
kk.pack(fill="x", pady=(6, 0))
w.knopf(kk, "Stufe hinzufügen", self._dazu).pack(side="left")
w.knopf(kk, "Letzte entfernen", self._weg).pack(side="left", padx=4)
if standard is not None:
w.knopf(kk, "Wie Standard", lambda: self._setzen(standard)).pack(side="right")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(6, 10))
def _zeile(self, ab: int | str = "", cent: int | None | str = ""):
z = tk.Frame(self.tabelle, bg=w.FACE)
z.pack(fill="x", pady=1)
v_ab = tk.StringVar(value=str(ab))
v_bt = tk.StringVar(value="voll" if cent is None else (fmt(cent, symbol=False) if isinstance(cent, int)
else str(cent)))
tk.Entry(z, textvariable=v_ab, width=8, justify="right").pack(side="left")
tk.Entry(z, textvariable=v_bt, width=10, justify="right").pack(side="left", padx=(12, 0))
tk.Label(z, text="€ je Karte").pack(side="left", padx=4)
self.zeilen.append((z, v_ab, v_bt))
def _dazu(self):
letzte = self.zeilen[-1][1].get() if self.zeilen else "0"
try:
ab = int(letzte) + 5
except ValueError:
ab = ""
self._zeile(ab, "")
def _weg(self):
if len(self.zeilen) > 1:
self.zeilen.pop()[0].destroy()
def _setzen(self, staffel):
for z, _, _ in self.zeilen:
z.destroy()
self.zeilen = []
for ab, cent in staffel:
self._zeile(ab, cent)
def lesen(self) -> regeln.Staffel:
out = []
for i, (_, v_ab, v_bt) in enumerate(self.zeilen, start=1):
try:
ab = int(v_ab.get().strip())
except ValueError:
raise ValueError(f"Stufe {i}: „ab Karten“ muss eine ganze Zahl sein.") from None
text = v_bt.get().strip()
if text.lower() == "voll":
cent = None
else:
try:
cent = parse_cent(text)
except ValueError:
raise ValueError(f"Stufe {i}: Bitte einen Betrag (z. B. 5 oder 7,50) oder „voll“ eingeben.") \
from None
out.append((ab, cent))
return regeln.staffel_pruefen(out)
def _ok(self):
try:
self.result = self.lesen()
except ValueError as exc:
w.warnung(self, "Staffel", str(exc))
return
self.destroy()
# =========================================================================== Provisionsarten
TYP_AUTO = "Automatisch"
class ProvisionsartenDialog(w.Dialog):
"""Welche Provisionsart ist Airtime bzw. Jahresbonus? Erkannt wird automatisch, umstellbar je Art."""
def __init__(self, master, db: Datenbank):
super().__init__(master, "Provisionsarten", resizable=True)
self.db = db
self.geaendert = False
f = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=10, pady=10)
tk.Label(f, anchor="w", justify="left", text=(
"Airtime wird pro Händler mit dem Airtime-Prozentsatz ausgezahlt und im Export als eigene Liste\n"
"geführt. Der Jahresbonus Neuaktivierung wird nach der Staffel berechnet.")).pack(fill="x")
r, self.tree = w.scroll_tree(f, columns=("art", "anzahl", "typ"), show="headings", height=12,
selectmode="browse")
for c, t, b, a in (("art", "Provisionsart", 300, "w"), ("anzahl", "Pos.", 50, "e"),
("typ", "Behandlung", 230, "w")):
self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor=a)
r.pack(fill="both", expand=True, pady=(6, 4))
self.tree.bind("<<TreeviewSelect>>", lambda e: self._auswahl())
u = tk.Frame(f, bg=w.FACE)
u.pack(fill="x")
tk.Label(u, text="Behandlung:").pack(side="left")
self.v_typ = tk.StringVar(value=TYP_AUTO)
self.cb = ttk.Combobox(u, textvariable=self.v_typ, state="readonly", width=36,
values=[TYP_AUTO] + list(regeln.TYPEN.values()))
self.cb.pack(side="left", padx=4)
w.knopf(u, "Übernehmen", self._setzen).pack(side="left")
self.knopfleiste([("Schließen", self.abbrechen)]).pack(pady=(0, 10))
self._laden()
self.minsize(620, 380)
def _laden(self, auswahl: str | None = None):
self.tree.delete(*self.tree.get_children())
fest = self.db.art_typen()
self._arten = {}
for i, (art, erkannt, n) in enumerate(self.db.provisionsarten()):
typ = fest.get(art)
text = regeln.TYPEN[typ] if typ else f"{TYP_AUTO}: {regeln.TYPEN[erkannt]}"
iid = str(i)
self._arten[iid] = art
self.tree.insert("", "end", iid=iid, values=(art or "(ohne)", n, text))
if art == auswahl:
self.tree.selection_set(iid)
self.tree.see(iid)
def _auswahl(self):
sel = self.tree.selection()
if not sel:
return
typ = self.db.art_typen().get(self._arten[sel[0]])
self.v_typ.set(regeln.TYPEN[typ] if typ else TYP_AUTO)
def _setzen(self):
sel = self.tree.selection()
if not sel:
w.info(self, "Provisionsarten", "Bitte zuerst eine Provisionsart in der Liste auswählen.")
return
art = self._arten[sel[0]]
wahl = self.v_typ.get()
typ = next((k for k, v in regeln.TYPEN.items() if v == wahl), None)
self.db.art_typ_setzen(art, typ)
self.geaendert = True
self._laden(art)
def abbrechen(self):
self.result = self.geaendert
self.destroy()
# =========================================================================== Abrechnungen # =========================================================================== Abrechnungen
class AbrechnungenDialog(w.Dialog): class AbrechnungenDialog(w.Dialog):
@@ -612,7 +857,7 @@ class ExportDialog(w.Dialog):
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8, r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8,
selectmode="extended") selectmode="extended")
self.tree.heading("#0", text="") self.tree.heading("#0", text="")
self.tree.column("#0", width=30, stretch=False) self.tree.column("#0", width=40, stretch=False)
for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")): for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")):
self.tree.heading(c, text=t) self.tree.heading(c, text=t)
self.tree.column(c, width=b, anchor=a) self.tree.column(c, width=b, anchor=a)
@@ -638,18 +883,22 @@ class ExportDialog(w.Dialog):
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "") self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2) tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
w.knopf(g, "Durchsuchen...", self._ordner).grid(row=1, column=2, padx=(4, 0)) w.knopf(g, "Durchsuchen...", self._ordner).grid(row=1, column=2, padx=(4, 0))
self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True)) # Neuer Schlüssel: freenet und Vodafone stehen jetzt standardmäßig in EINER Datei
self.v_anb = tk.BooleanVar(value=settings.get("export_anbieter_getrennt", False))
self.v_null = tk.BooleanVar(value=settings.get("export_null_weglassen", True))
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False)) self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True)) self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
self.v_auf = tk.BooleanVar(value=True) self.v_auf = tk.BooleanVar(value=True)
tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei", for zeile, (text, var) in enumerate((
variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w") ("freenet und Vodafone in getrennte Dateien (sonst beide in einer)", self.v_anb),
tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3, ("Positionen mit 0,00 € weglassen", self.v_null),
sticky="w") ("Kopfzeile mit Spaltennamen", self.v_kopf),
tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0, ("Summenzeile unter der Tabelle", self.v_sum),
columnspan=3, sticky="w") ("Ordner nach dem Export öffnen", self.v_auf)), start=2):
tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0, tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w")
columnspan=3, sticky="w") tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine\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) g.columnconfigure(1, weight=1)
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
@@ -683,11 +932,13 @@ class ExportDialog(w.Dialog):
self._ordner() self._ordner()
if not self.v_ordner.get().strip(): if not self.v_ordner.get().strip():
return return
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(), self.settings.update(export_ordner=self.v_ordner.get().strip(), export_anbieter_getrennt=self.v_anb.get(),
export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get()) export_null_weglassen=self.v_null.get(), export_kopfzeile=self.v_kopf.get(),
export_summe=self.v_sum.get())
self.settings.pop("export_pro_anbieter", None)
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(), self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
pro_anbieter=self.v_anb.get(), kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.get(),
oeffnen=self.v_auf.get()) kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get())
self.destroy() self.destroy()
+311
View File
@@ -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 = []
+96 -6
View File
@@ -3,8 +3,11 @@
Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet
(overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben, (overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben,
Doppelklick = Maximieren, Größenänderung über den Rand und den Anfasser unten rechts, 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 graue Menüleiste.
trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann.
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 from __future__ import annotations
@@ -210,6 +213,7 @@ class Titelleiste(tk.Frame):
NATIV_VERSCHIEBEN = True # Tests können das abschalten, um den Tk-Weg zu prüfen 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 # --------------------------------------------------------------------------- Hauptfenster
@@ -243,7 +247,8 @@ class Hauptrahmen:
schliessen=schliessen, minimieren=self.minimieren, schliessen=schliessen, minimieren=self.minimieren,
maximieren=self.umschalten) maximieren=self.umschalten)
self.titelleiste.pack(fill="x") 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 = tk.Frame(self.aussen, bg=w.FACE)
self.inhalt.pack(fill="both", expand=True) self.inhalt.pack(fill="both", expand=True)
# Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar # Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar
@@ -260,7 +265,66 @@ class Hauptrahmen:
self.aussen.bind("<ButtonPress-1>", self._resize_start) self.aussen.bind("<ButtonPress-1>", self._resize_start)
self.aussen.bind("<B1-Motion>", self._resize) self.aussen.bind("<B1-Motion>", self._resize)
root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget)) root.bind_all("<Alt-F4>", lambda e: self.alt_f4(e.widget))
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: def alt_f4(self, widget) -> str:
"""Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist.""" """Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist."""
@@ -323,10 +387,16 @@ class Hauptrahmen:
return None return None
self._griff_bild = griff_bild(master) 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 = 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) g.bind("<B1-Motion>", self._resize)
return g 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): def geometrie_setzen(self, geo: str | None, maximiert: bool = False):
self.root.update_idletasks() self.root.update_idletasks()
if not geo or "+" not in geo: if not geo or "+" not in geo:
@@ -340,6 +410,8 @@ class Hauptrahmen:
breite, hoehe = min(breite, bw), min(hoehe, bh) breite, hoehe = min(breite, bw), min(hoehe, bh)
geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}" geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}"
self.root.geometry(geo) self.root.geometry(geo)
self._normal_geo = geo
self._nativ_einrichten()
if maximiert and self.klassisch: if maximiert and self.klassisch:
self.root.after(100, self.maximieren) self.root.after(100, self.maximieren)
@@ -371,6 +443,13 @@ class Hauptrahmen:
def maximieren(self): def maximieren(self):
if self.maximiert: if self.maximiert:
return 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() self._normal_geo = self.root.geometry()
x, y, bw, bh = arbeitsbereich(self.root) x, y, bw, bh = arbeitsbereich(self.root)
self.root.geometry(f"{bw}x{bh}+{x}+{y}") self.root.geometry(f"{bw}x{bh}+{x}+{y}")
@@ -380,6 +459,12 @@ class Hauptrahmen:
def wiederherstellen(self): def wiederherstellen(self):
if not self.maximiert: if not self.maximiert:
return 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 self.maximiert = False
if self._normal_geo: if self._normal_geo:
self.root.geometry(self._normal_geo) self.root.geometry(self._normal_geo)
@@ -417,7 +502,7 @@ class Hauptrahmen:
"sw": "bottom_left_corner"} "sw": "bottom_left_corner"}
def _rand_cursor(self, e): def _rand_cursor(self, e):
if self.maximiert: if self.maximiert or self.nativ_an: # nativ: Windows zeigt den Zeiger selbst
return return
rand = self._welcher_rand(e.x, e.y) rand = self._welcher_rand(e.x, e.y)
zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "") zeiger = (self.CURSOR_WIN if IS_WIN else self.CURSOR_X11).get(rand, "")
@@ -429,6 +514,11 @@ class Hauptrahmen:
def _resize_start(self, e, rand: str | None = None): def _resize_start(self, e, rand: str | None = None):
self._rand = rand or self._welcher_rand(e.x, e.y) 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._start = (e.x_root, e.y_root, self.root.winfo_x(), self.root.winfo_y(),
self.root.winfo_width(), self.root.winfo_height()) self.root.winfo_width(), self.root.winfo_height())
+115
View File
@@ -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)
+10
View File
@@ -151,6 +151,15 @@ def ausfuehren(ergebnisdatei: str) -> int:
from provtool.gui.assistent import TippDesTages from provtool.gui.assistent import TippDesTages
from provtool.gui.dialogs import ImportBericht 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(): def tipp_pruefen():
d = s.dialog(TippDesTages) d = s.dialog(TippDesTages)
if not d.bild_ok: if not d.bild_ok:
@@ -166,6 +175,7 @@ def ausfuehren(ergebnisdatei: str) -> int:
(seite(4), lambda: wiz()._weiter(), "Fertig stellen"), (seite(4), lambda: wiz()._weiter(), "Fertig stellen"),
(lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"), (lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"),
(lambda: len(s.app.positionen) == 2, export, "Export"), (lambda: len(s.app.positionen) == 2, export, "Export"),
(immer, fenster_pruefen, "Fensterrahmen"),
(immer, s.app.tipp_des_tages, "Tipp des Tages öffnen"), (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 not None, tipp_pruefen, "Tipp-Bild in der .exe"),
(lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"), (lambda: s.dialog(TippDesTages) is None, s.app.rundgang, "Rundgang"),
+174 -3
View File
@@ -70,14 +70,19 @@ class Lauf:
denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten.""" denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten."""
try: try:
sichtbar = bool(self.root.winfo_viewable()) and self.app.inhalt.winfo_height() > 50 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: except tk.TclError:
return True return True
if not sichtbar: # 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 self._sichtbar_seit = None
return False return False
if self._sichtbar_seit is None: if self._sichtbar_seit is None:
self._sichtbar_seit = time.time() self._sichtbar_seit = time.time()
return time.time() - self._sichtbar_seit >= 0.1 return time.time() - self._sichtbar_seit >= 0.15
# ---------------------------------------------------------------- Zustand # ---------------------------------------------------------------- Zustand
def lebt_sonst(self, text: str) -> str: def lebt_sonst(self, text: str) -> str:
@@ -311,11 +316,14 @@ def _dialog_oeffner():
"prozent": lambda a: (a.ptree.selection_set(a.ptree.get_children()[0]), a.prozent()), "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(), "neu_berechnen": lambda a: (a.db.betrag_setzen(a.positionen[0]["id"], 1), a._aktualisieren(),
a.neu_berechnen()), 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", @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): def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
lauf = lauf_mit_daten lauf = lauf_mit_daten
oeffnen = _dialog_oeffner()[name] oeffnen = _dialog_oeffner()[name]
@@ -825,8 +833,16 @@ def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten):
r = lauf.app.rahmen r = lauf.app.rahmen
if not r.klassisch: if not r.klassisch:
pytest.skip("nur im Win95-Rahmen") 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})() ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})()
r._rand_cursor(ev) 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" assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen"
r.inhalt.event_generate("<Enter>") r.inhalt.event_generate("<Enter>")
lauf.root.update() lauf.root.update()
@@ -875,3 +891,158 @@ def test_windows_minimieren_mit_offenem_dialog(lauf_mit_daten):
assert m["minimiert"], "Hauptfenster wurde nicht minimiert" assert m["minimiert"], "Hauptfenster wurde nicht minimiert"
assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden" assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden"
lauf.pruefen() 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()
+131 -1
View File
@@ -159,9 +159,14 @@ def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path):
# Export # Export
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid] eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026") dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026", pro_anbieter=True)
assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx", assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx",
"Händler A - Vodafone - Oktober 2026.xlsx"] "Händler A - Vodafone - Oktober 2026.xlsx"]
# Standard: freenet UND Vodafone in einer Datei (gemeldet: Vodafone fehlte im Export)
eine = export.exportiere_haendler(tmp_path / "out1", "Händler A", eigene, "Oktober 2026")
assert [d.name for d in eine] == ["Händler A - Oktober 2026.xlsx"]
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 ws = openpyxl.load_workbook(dateien[0]).active
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)] zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
# Sortierung Zeitraum → Provisionsart → Datum # Sortierung Zeitraum → Provisionsart → Datum
@@ -281,3 +286,128 @@ def test_eigene_kennungen_ausblenden(db, freenet_csv):
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["111"] == hid
db.eigene_setzen("freenet", "999", False) db.eigene_setzen("freenet", "999", False)
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["999"] == UNZUGEORDNET 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()