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
+1399 -119

No files matched your search

+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()