Airtime je Händler, Jahresbonus-Staffel, Export überarbeitet, natives Windows-Fenster

- 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 c0b740db06
commit 0880ab2d42
14 files changed
+1399 -119

No files matched your search

+174 -3
View File
@@ -70,14 +70,19 @@ class Lauf:
denn ein modaler Dialog (z. B. der Assistent beim Erststart) würde die festhalten."""
try:
sichtbar = bool(self.root.winfo_viewable()) and self.app.inhalt.winfo_height() > 50
lage = (self.root.winfo_rootx(), self.root.winfo_rooty(), self.root.winfo_width(),
self.root.winfo_height())
except tk.TclError:
return True
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
return False
if self._sichtbar_seit is None:
self._sichtbar_seit = time.time()
return time.time() - self._sichtbar_seit >= 0.1
return time.time() - self._sichtbar_seit >= 0.15
# ---------------------------------------------------------------- Zustand
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()),
"neu_berechnen": lambda a: (a.db.betrag_setzen(a.positionen[0]["id"], 1), a._aktualisieren(),
a.neu_berechnen()),
"provisionsarten": lambda a: a.provisionsarten(),
"staffel": lambda a: a.standard_staffel(),
}
@pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp",
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen"])
"dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten",
"staffel"])
def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name):
lauf = lauf_mit_daten
oeffnen = _dialog_oeffner()[name]
@@ -825,8 +833,16 @@ def test_rand_zeiger_wird_zurueckgesetzt(lauf_mit_daten):
r = lauf.app.rahmen
if not r.klassisch:
pytest.skip("nur im Win95-Rahmen")
lauf.ablauf([]) # erst wenn das Hauptfenster zu sehen ist (Größen stimmen dann)
ev = type("E", (), {"x": 2, "y": lauf.root.winfo_height() // 2})()
r._rand_cursor(ev)
if r.nativ_an:
# Windows: den Zeiger setzt Windows selbst (WM_SETCURSOR am äußeren Fenster, siehe
# test_windows_echtes_fenster_andocken_und_rand) – Tk darf keinen eigenen setzen, der hängen bleibt.
assert r.aussen.cget("cursor") == "", "Tk-Zeiger trotz nativem Rahmen gesetzt"
assert r._welcher_rand(2, 10) == "nw" and r._welcher_rand(r.aussen.winfo_width() - 3, 10) == "ne"
lauf.pruefen()
return
assert r.aussen.cget("cursor"), "am Rand muss der Größenänderungs-Zeiger erscheinen"
r.inhalt.event_generate("<Enter>")
lauf.root.update()
@@ -875,3 +891,158 @@ def test_windows_minimieren_mit_offenem_dialog(lauf_mit_daten):
assert m["minimiert"], "Hauptfenster wurde nicht minimiert"
assert m["dialog_da"], "Dialog nach dem Minimieren verschwunden"
lauf.pruefen()
@nur_windows
def test_windows_echtes_fenster_andocken_und_rand(lauf_mit_daten):
"""Gemeldet: Win+Hoch/Runter und Andocken am Bildschirmrand gingen nicht, Größe ziehen war zäh.
Das Hauptfenster muss für Windows ein echtes Programmfenster sein (Größenrahmen, Min/Max), dessen
Windows-Rahmen unsichtbar ist; am Rand meldet es Windows den Größenrahmen."""
from provtool.gui import nativ, rahmen
lauf = lauf_mit_daten
r = lauf.app.rahmen
if not r.klassisch:
pytest.skip("nur im Win95-Rahmen")
m = {}
def pruefen():
n = r.nativ
m["aktiv"] = r.nativ_an
if not r.nativ_an:
return
stil, ex = n.stil()
m["stil"] = {name: bool(stil & bit) for name, bit in (
("Größenrahmen", nativ.WS_THICKFRAME), ("Maximieren", nativ.WS_MAXIMIZEBOX),
("Minimieren", nativ.WS_MINIMIZEBOX), ("Systemmenü", nativ.WS_SYSMENU), ("Titel", nativ.WS_CAPTION))}
m["programmfenster"] = not (ex & nativ.WS_EX_TOOLWINDOW) and bool(ex & nativ.WS_EX_APPWINDOW)
m["rahmen_unsichtbar"] = n.innen_gleich_aussen()
x, y, bw, bh = n.aussenmass()
m["tk_gleich_aussen"] = (lauf.root.winfo_width(), lauf.root.winfo_height()) == (bw, bh)
my = y + bh // 2
m["links"] = n.trefferpruefung(n.hwnd, x + 2, my) == nativ.HT["w"]
m["ecke"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + bh - 3) == nativ.HT["se"]
m["ecke_lang"] = n.trefferpruefung(n.hwnd, x + bw - 3, y + 10) == nativ.HT["ne"]
m["innen"] = n.trefferpruefung(n.hwnd, x + bw // 2, y + bh // 2) == nativ.HTCLIENT
m["rand_durchlaessig"] = n.trefferpruefung(r.aussen.winfo_id(), x + 2, my) == nativ.HTTRANSPARENT
m["innen_nicht"] = n.trefferpruefung(r.aussen.winfo_id(), x + bw // 2, y + 3 + 30) != nativ.HTTRANSPARENT
# die Maus landet am Rand wirklich beim äußeren Fenster (durch Tk-Fenster hindurch)
m["maus_am_rand"] = n.fenster_bei(x + 2, my) == n.hwnd
m["maus_innen"] = n.fenster_bei(x + bw // 2, y + bh // 2) != n.hwnd
# Tk darf das Fenster bei internen Größenanfragen nicht um einen Rahmen vergrößern
n.setzen(x, y, bw - 37, bh - 23) # wie Größe ziehen durch den Benutzer
lauf.root.update()
vorher = n.aussenmass()
lauf.app.status.set("info", " " + "sehr langer Statustext " * 20)
lauf.root.update_idletasks()
lauf.root.update()
m["waechst_nicht"] = (n.aussenmass(), vorher)
# Maximieren über Windows (wie Win+Hoch / an den oberen Rand ziehen) und zurück
n.befehl(nativ.SC_MAXIMIZE)
lauf.root.update()
ax, ay, aw, ah = rahmen.arbeitsbereich(lauf.root)
m["max"] = (r.maximiert, n.maximiert())
m["max_inhalt"] = ((lauf.root.winfo_rootx(), lauf.root.winfo_rooty(), lauf.root.winfo_width(),
lauf.root.winfo_height()), (ax, ay, aw, ah))
m["max_symbol"] = str(r.titelleiste.knoepfe["max"].cget("image")) == str(r.titelleiste._bilder["restore"])
m["kein_rand_maximiert"] = n.randbereich(ax + 1, ay + ah // 2) == ""
n.befehl(nativ.SC_RESTORE)
lauf.root.update()
m["zurueck"] = (r.maximiert, n.aussenmass() == vorher)
m["normale_geometrie"] = r.normale_geometrie()
lauf.ablauf([(IMMER, lambda: lauf.root.after(300, pruefen), "prüfen"),
(lambda: "aktiv" in m and ("zurueck" in m or not m["aktiv"]), lambda: None, "fertig")],
sekunden=15)
assert not lauf.uebrig, lauf.diagnose()
assert m["aktiv"], "natives Fenster wurde nicht eingerichtet"
assert all(m["stil"].values()), f"Fensterstil unvollständig: {m['stil']}"
assert m["programmfenster"], "Fenster ist für Windows noch ein Werkzeugfenster (kein Andocken/Win+Pfeil)"
assert m["rahmen_unsichtbar"] and m["tk_gleich_aussen"], "Windows-Rahmen ist sichtbar / Tk-Größe passt nicht"
for k in ("links", "ecke", "ecke_lang", "innen", "rand_durchlaessig", "innen_nicht", "maus_am_rand",
"maus_innen"):
assert m[k], f"Trefferprüfung am Rand falsch: {k} ({m})"
assert m["waechst_nicht"][0] == m["waechst_nicht"][1], f"Fenster ist gewachsen: {m['waechst_nicht']}"
assert m["max"] == (True, True), f"Maximieren über Windows nicht erkannt: {m['max']}"
assert m["max_inhalt"][0] == m["max_inhalt"][1], f"maximiert nicht genau im Arbeitsbereich: {m['max_inhalt']}"
assert m["max_symbol"] and m["kein_rand_maximiert"]
assert m["zurueck"] == (False, True), f"Wiederherstellen: {m['zurueck']}"
assert m["normale_geometrie"].startswith(f"{m['waechst_nicht'][1][2]}x{m['waechst_nicht'][1][3]}+")
lauf.pruefen()
def test_haendler_dialog_airtime_staffel_grossbuchstaben(lauf_mit_daten):
"""Airtime-% und eigene Jahresbonus-Staffel je Händler; die GROSSBUCHSTABEN-Regel ist ein Häkchen
(gemeldet: Vodafone fehlte im Export – die Regel ging verloren, wenn man „Hinzufügen“ vergaß)."""
from provtool.db import CAPS_ANBIETER, CAPS_WERT
from provtool.gui.dialogs import HaendlerDialog, StaffelDialog
lauf = lauf_mit_daten
app = lauf.app
hid = app.db.haendler_liste()[0].id
hd = lambda: lauf.dialog(HaendlerDialog) # noqa: E731
sd = lambda: lauf.dialog(StaffelDialog) # noqa: E731
def eintragen():
d = hd()
d.v_air.set("40")
d.v_caps.set(True)
d.v_eigen_staffel.set(True)
d._staffel_zeigen()
lauf.root.after(10, d._staffel_bearbeiten)
def staffel_eingeben():
d = sd()
d._setzen([(1, 100), (3, None)])
d.zeilen[0][2].set("2,50")
d._ok()
lauf.ablauf([
(IMMER, lambda: app.haendler(hid), "Händler-Dialog"),
(lambda: hd() is not None, eintragen, "Airtime, Häkchen, eigene Staffel"),
(lambda: sd() is not None, staffel_eingeben, "Staffel eingeben"),
(lambda: sd() is None and "2,50" in hd().l_staffel.cget("text"), lambda: hd()._speichern(), "Speichern"),
(IMMER, lambda: hd().abbrechen(), "Schließen"),
(lambda: hd() is None, lambda: None, "zu"),
])
assert not lauf.uebrig, lauf.diagnose()
h = app.db.haendler(hid)
assert h.airtime_prozent == 40
assert h.staffel == [(1, 250), (3, None)]
assert (CAPS_ANBIETER, CAPS_WERT) in app.db.kennungen(hid)
assert ("freenet", "111") in app.db.kennungen(hid)
lauf.pruefen()
def test_export_dialog_standard_eine_datei_ohne_null(lauf_mit_daten, tmp_path):
from provtool.gui.dialogs import ExportDialog
lauf = lauf_mit_daten
app = lauf.app
app.db.betrag_setzen(next(p["id"] for p in app.positionen if p["provisionsart"] == "SIM-Karten"
and p["haendler_id"] > 0), 0)
app._aktualisieren()
app.settings["export_ordner"] = str(tmp_path / "x")
app.settings["export_pro_anbieter"] = True # alte Einstellung darf nicht mehr trennen
ergebnis = {}
def ok():
d = lauf.dialog(ExportDialog)
ergebnis.update(anb=d.v_anb.get(), null=d.v_null.get(), haendler=list(d.haken))
d.v_auf.set(False)
d._ok()
from provtool.gui import win95 as w
lauf.ablauf([
(IMMER, app.exportieren, "Export"),
(lambda: lauf.dialog(ExportDialog) is not None, ok, "Exportieren"),
(lambda: any(d.title() == "Export" for d in lauf.dialoge()),
lambda: [d.abbrechen() for d in lauf.dialoge() if isinstance(d, w.Dialog)], "Meldung schließen"),
])
assert not lauf.uebrig, lauf.diagnose()
assert ergebnis == {"anb": False, "null": True, "haendler": [app.db.haendler_liste()[0].id]}
import openpyxl
dateien = sorted((tmp_path / "x").glob("*.xlsx"))
assert [d.name for d in dateien] == ["Händler A - Oktober 2026.xlsx"]
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True) if r[1]]
assert [r[1] for r in zeilen] == ["Sonderbonus Aktion"], "0-€-Position wurde exportiert"
lauf.pruefen()
+131 -1
View File
@@ -159,9 +159,14 @@ def test_zuordnung_regeln_und_export(db, freenet_csv, vodafone_xlsx, tmp_path):
# Export
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026")
dateien = export.exportiere_haendler(tmp_path / "out", "Händler A", eigene, "Oktober 2026", pro_anbieter=True)
assert [d.name for d in dateien] == ["Händler A - freenet - Oktober 2026.xlsx",
"Händler A - Vodafone - Oktober 2026.xlsx"]
# Standard: freenet UND Vodafone in einer Datei (gemeldet: Vodafone fehlte im Export)
eine = export.exportiere_haendler(tmp_path / "out1", "Händler A", eigene, "Oktober 2026")
assert [d.name for d in eine] == ["Händler A - Oktober 2026.xlsx"]
arten = [r[1] for r in openpyxl.load_workbook(eine[0]).active.iter_rows(values_only=True) if r[1]]
assert "Freischaltungsvergütung Vodafone" in arten and "SIM-Karten" in arten
ws = openpyxl.load_workbook(dateien[0]).active
zeilen = [[c.value for c in r] for r in ws.iter_rows(min_row=1, max_row=4)]
# Sortierung Zeitraum → Provisionsart → Datum
@@ -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
db.eigene_setzen("freenet", "999", False)
assert {p["kennung"]: p["haendler_id"] for p in db.positionen(aid)}["999"] == UNZUGEORDNET
# --------------------------------------------------------------------------- Airtime / Jahresbonus
def _pos(art, betrag, unterh="111", rufnr="1601111111", name="Kunde", dienst="Teilnehmerverhaeltnis"):
z = freenet_zeile(art, unterh, "05.10.2026", rufnr, name, "Tarif", betrag).split(";")
z[24] = dienst
return ";".join(z)
@pytest.fixture
def bonus_csv(tmp_path):
zeilen = [FREENET_KOPF,
_pos("Grundvergütung Mobilfunk Credit freenet", "50"),
_pos("Grundvergütung Mobilfunk Credit freenet", "0"),
_pos("Prepaidvergütung", "20", dienst="AirTime Provision"),
_pos("Airtime Umsatzbeteiligung", "10,50")]
zeilen += [_pos("Jahresbonus Neuaktivierung", "40", rufnr=f"16000000{i:02d}") for i in range(12)]
zeilen += [_pos("Jahresbonus Neuaktivierung", "3", rufnr="1600000099")] # weniger als die Stufe
p = tmp_path / "bonus.csv"
p.write_bytes(("\r\n".join(zeilen) + "\r\n").encode("cp1252"))
return p
def test_typ_erkennung():
from provtool import regeln
assert regeln.erkennen("Prepaidvergütung Vodafone", "AirTime Provision") == regeln.AIRTIME
assert regeln.erkennen("Airtime freenet") == regeln.AIRTIME
assert regeln.erkennen("Jahresbonus Neuaktivierung") == regeln.JAHRESBONUS
assert regeln.erkennen("SIM-Karten", "Teilnehmerverhaeltnis") == regeln.NORMAL
def test_staffel_stufen():
from provtool import regeln
st = regeln.STANDARD_STAFFEL
assert regeln.bonus_betrag(st, 0, 4000) == 0
assert regeln.bonus_betrag(st, 1, 4000) == 500 and regeln.bonus_betrag(st, 9, 4000) == 500
assert regeln.bonus_betrag(st, 10, 4000) == 1000 and regeln.bonus_betrag(st, 14, 4000) == 1000
assert regeln.bonus_betrag(st, 39, 4000) == 3500
assert regeln.bonus_betrag(st, 40, 4000) == 4000 and regeln.bonus_betrag(st, 80, 6000) == 6000 # voll
assert regeln.bonus_betrag(st, 25, 1000) == 1000 # nie mehr als laut Abrechnung
assert regeln.bonus_betrag(st, 12, -4000) == -1000 # Storno mit derselben Stufe
with pytest.raises(ValueError):
regeln.staffel_pruefen([(1, 500), (1, 600)])
assert "1-9: 5,00 €" in regeln.staffel_beschreibung(st) and "ab 40: voll" in regeln.staffel_beschreibung(st)
def test_airtime_und_jahresbonus(db, bonus_csv, tmp_path):
from provtool import regeln
hid = db.haendler_speichern(Haendler(0, "Sub", standard_prozent=100), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(bonus_csv))
pos = db.positionen(aid)
air = [p for p in pos if p["typ"] == regeln.AIRTIME]
bonus = [p for p in pos if p["typ"] == regeln.JAHRESBONUS]
assert len(air) == 2 and len(bonus) == 13
assert all(p["betrag"] == 0 for p in air), "Standard: 0 % Airtime = keine Airtime"
# 13 Karten -> Stufe 10-14 = 10 € je Karte (die 3-€-Position bleibt bei 3 €)
assert sorted(p["betrag"] for p in bonus) == [300] + [1000] * 12
assert all(p["prozent"] is None for p in bonus)
# Airtime 50 %, eigene Staffel
h = db.haendler(hid)
h.airtime_prozent = 50
h.staffel = [(1, 200), (13, None)]
db.haendler_speichern(h, db.kennungen(hid))
db.neu_berechnen(aid)
pos = db.positionen(aid)
assert sorted(p["betrag"] for p in pos if p["typ"] == regeln.AIRTIME) == [525, 1000]
assert sorted(p["betrag"] for p in pos if p["typ"] == regeln.JAHRESBONUS) == [300] + [4000] * 12
# Zuweisung ändert die Anzahl Karten -> Staffel der übrigen wird neu berechnet
weg = [p["id"] for p in pos if p["typ"] == regeln.JAHRESBONUS][:2]
db.haendler_zuweisen(weg, UNZUGEORDNET)
rest = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and p["haendler_id"] == hid]
assert len(rest) == 11 and all(p["betrag"] == min(200, p["betrag_orig"]) for p in rest)
# Typ fest einstellen
db.art_typ_setzen("Airtime Umsatzbeteiligung", regeln.NORMAL)
assert any(a == "Airtime Umsatzbeteiligung" for a, _, _ in db.provisionsarten())
db.neu_berechnen(aid)
p = next(p for p in db.positionen(aid) if p["provisionsart"] == "Airtime Umsatzbeteiligung")
assert p["typ"] == regeln.NORMAL and p["betrag"] == 1050
db.art_typ_setzen("Airtime Umsatzbeteiligung", None)
# Export: Abrechnung mit Airtime-Summenzeile + eigene Airtime-Liste, 0-€-Positionen weg
db.neu_berechnen(aid)
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
dateien = export.exportiere_haendler(tmp_path / "out", "Sub", eigene, "Okt")
assert [d.name for d in dateien] == ["Sub - Okt.xlsx", "Sub - Airtime - Okt.xlsx"]
zeilen = [r for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True)]
daten = [r for r in zeilen if r[1]]
assert all(r[10] != 0 for r in daten), "0-€-Positionen dürfen nicht im Export stehen"
assert daten[-1][1] == "Airtime" and daten[-1][10] == 15.25
assert not any(r[1] == "Prepaidvergütung" for r in daten), "Airtime-Einzelposten gehören in die eigene Liste"
air_zeilen = [r for r in openpyxl.load_workbook(dateien[1]).active.iter_rows(values_only=True) if r[1]]
assert sorted(r[10] for r in air_zeilen) == [5.25, 10.0]
# 0 % Airtime: keine Airtime-Zeile, keine Airtime-Datei
h = db.haendler(hid)
h.airtime_prozent = 0
db.haendler_speichern(h, db.kennungen(hid))
db.neu_berechnen(aid)
eigene = [p for p in db.positionen(aid) if p["haendler_id"] == hid]
dateien = export.exportiere_haendler(tmp_path / "out0", "Sub", eigene, "Okt")
assert [d.name for d in dateien] == ["Sub - Okt.xlsx"]
assert not any(r[1] == "Airtime" for r in openpyxl.load_workbook(dateien[0]).active.iter_rows(values_only=True))
def test_migration_v2_haendler_ohne_airtime(tmp_path):
import sqlite3
pfad = tmp_path / "v2.db"
con = sqlite3.connect(pfad)
con.executescript("""CREATE TABLE haendler (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
ansprechpartner TEXT DEFAULT '', email TEXT DEFAULT '', notiz TEXT DEFAULT '',
standard_prozent REAL NOT NULL DEFAULT 100, aktiv INTEGER NOT NULL DEFAULT 1);
INSERT INTO haendler (name, standard_prozent) VALUES ('Alt', 90);""")
con.close()
d = Datenbank(pfad)
h = d.haendler_liste()[0]
assert h.name == "Alt" and h.airtime_prozent == 0 and h.staffel is None
assert d.staffel_fuer(h.id) == d.standard_staffel()
d.standard_staffel_setzen([(1, 100), (5, None)])
assert d.staffel_fuer(h.id) == [(1, 100), (5, None)]
d.close()