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

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