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

+158 -40
View File
@@ -8,10 +8,11 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from provtool import regeln
from provtool.importer import ImportErgebnis
from provtool.money import apply_percent
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
@@ -23,7 +24,15 @@ CREATE TABLE IF NOT EXISTS haendler (
email TEXT DEFAULT '',
notiz TEXT DEFAULT '',
standard_prozent REAL NOT NULL DEFAULT 100,
aktiv INTEGER NOT NULL DEFAULT 1
aktiv INTEGER NOT NULL DEFAULT 1,
airtime_prozent REAL NOT NULL DEFAULT 0, -- 0 = keine Airtime
staffel TEXT -- Jahresbonus-Staffel (JSON), NULL = Standard-Staffel
);
-- Fest eingestellter Typ einer Provisionsart (sonst automatisch erkannt, siehe regeln.py).
CREATE TABLE IF NOT EXISTS art_typ (
provisionsart TEXT PRIMARY KEY,
typ TEXT NOT NULL
);
-- Eine Kennung ordnet Positionen eines Anbieters einem Händler zu
@@ -112,6 +121,8 @@ class Haendler:
notiz: str = ""
standard_prozent: float = 100.0
aktiv: bool = True
airtime_prozent: float = 0.0
staffel: list | None = None # None = Standard-Staffel
def _now() -> str:
@@ -135,6 +146,11 @@ class Datenbank:
if "haendler_fest" not in spalten:
# NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner)
self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER")
h_spalten = {r[1] for r in self.con.execute("PRAGMA table_info(haendler)")}
if "airtime_prozent" not in h_spalten:
self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0")
if "staffel" not in h_spalten:
self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT")
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
self.con.commit()
@@ -158,30 +174,34 @@ class Datenbank:
shutil.copyfile(quelle, ziel)
# ------------------------------------------------------------------ Händler
@staticmethod
def _haendler_aus(r) -> Haendler:
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"], r["standard_prozent"],
bool(r["aktiv"]), r["airtime_prozent"] or 0.0, regeln.staffel_laden(r["staffel"]))
def haendler_liste(self, nur_aktive: bool = False) -> list[Haendler]:
sql = "SELECT * FROM haendler" + (" WHERE aktiv=1" if nur_aktive else "") + " ORDER BY name"
return [Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
r["standard_prozent"], bool(r["aktiv"])) for r in self.con.execute(sql)]
return [self._haendler_aus(r) for r in self.con.execute(sql)]
def haendler(self, hid: int) -> Haendler | None:
r = self.con.execute("SELECT * FROM haendler WHERE id=?", (hid,)).fetchone()
if not r:
return None
return Haendler(r["id"], r["name"], r["ansprechpartner"], r["email"], r["notiz"],
r["standard_prozent"], bool(r["aktiv"]))
return self._haendler_aus(r) if r else None
def haendler_speichern(self, h: Haendler, kennungen: list[tuple[str, str]]) -> int:
"""Legt an (h.id == 0) oder aktualisiert. kennungen = [(anbieter, wert), ...]"""
staffel = regeln.staffel_text(regeln.staffel_pruefen(h.staffel)) if h.staffel is not None else None
werte = (h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv),
h.airtime_prozent or 0.0, staffel)
with self.con:
if h.id:
self.con.execute(
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=? WHERE id=?",
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv), h.id))
"UPDATE haendler SET name=?, ansprechpartner=?, email=?, notiz=?, standard_prozent=?, aktiv=?, "
"airtime_prozent=?, staffel=? WHERE id=?", werte + (h.id,))
hid = h.id
else:
cur = self.con.execute(
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv) VALUES (?,?,?,?,?,?)",
(h.name, h.ansprechpartner, h.email, h.notiz, h.standard_prozent, int(h.aktiv)))
"INSERT INTO haendler (name, ansprechpartner, email, notiz, standard_prozent, aktiv, "
"airtime_prozent, staffel) VALUES (?,?,?,?,?,?,?,?)", werte)
hid = cur.lastrowid
self.con.execute("DELETE FROM kennung WHERE haendler_id=?", (hid,))
for anbieter, wert in kennungen:
@@ -254,6 +274,51 @@ class Datenbank:
"ON CONFLICT(haendler_id, provisionsart) DO UPDATE SET prozent=excluded.prozent",
(hid, provisionsart, prozent))
# ------------------------------------------------------------------ Typen der Provisionsarten
def art_typen(self) -> dict[str, str]:
"""Fest eingestellte Typen je Provisionsart (alles andere wird automatisch erkannt)."""
return {r[0]: r[1] for r in self.con.execute("SELECT provisionsart, typ FROM art_typ")}
def art_typ_setzen(self, provisionsart: str, typ: str | None) -> None:
"""typ=None: wieder automatisch erkennen."""
if typ is not None and typ not in regeln.TYPEN:
raise ValueError(f"Unbekannter Typ: {typ}")
with self.con:
if typ is None:
self.con.execute("DELETE FROM art_typ WHERE provisionsart=?", (provisionsart,))
else:
self.con.execute("INSERT INTO art_typ (provisionsart, typ) VALUES (?,?) "
"ON CONFLICT(provisionsart) DO UPDATE SET typ=excluded.typ", (provisionsart, typ))
def provisionsarten(self) -> list[tuple[str, str, int]]:
"""Alle bekannten Provisionsarten: (Provisionsart, automatisch erkannter Typ, Anzahl Positionen)."""
arten: dict[str, list] = {}
for r in self.con.execute("SELECT provisionsart, dienst, tarif FROM position"):
art = r["provisionsart"] or ""
e = arten.setdefault(art, [regeln.NORMAL, 0])
erkannt = regeln.erkennen(art, r["dienst"], r["tarif"])
if erkannt != regeln.NORMAL:
e[0] = erkannt
e[1] += 1
for art in self.art_typen():
arten.setdefault(art, [regeln.erkennen(art), 0])
return sorted(((a, t, n) for a, (t, n) in arten.items()), key=lambda x: x[0].lower())
# ------------------------------------------------------------------ Jahresbonus-Staffel
def standard_staffel(self) -> regeln.Staffel:
r = self.con.execute("SELECT wert FROM meta WHERE schluessel='staffel_standard'").fetchone()
return (regeln.staffel_laden(r[0]) if r else None) or list(regeln.STANDARD_STAFFEL)
def standard_staffel_setzen(self, staffel) -> None:
text = regeln.staffel_text(regeln.staffel_pruefen(staffel))
with self.con:
self.con.execute("INSERT INTO meta VALUES ('staffel_standard', ?) "
"ON CONFLICT(schluessel) DO UPDATE SET wert=excluded.wert", (text,))
def staffel_fuer(self, hid: int) -> regeln.Staffel:
h = self.haendler(hid) if hid > 0 else None
return h.staffel if h and h.staffel is not None else self.standard_staffel()
def prozent_fuer(self, hid: int, provisionsart: str) -> float:
if hid <= 0: # nicht zugeordnet / eigene Filiale
return 100.0
@@ -302,7 +367,6 @@ class Datenbank:
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
finde = self.zuordner()
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
(aid,)).fetchone()[0]
with self.con:
@@ -311,45 +375,109 @@ class Datenbank:
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
did = cur.lastrowid
for i, p in enumerate(erg.positionen, start=start + 1):
hid = finde(p.anbieter, p.kennung, p.name)
proz = self.prozent_fuer(hid, p.provisionsart)
self.con.execute(
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
"datum, rufnummer, imei, geraet, vorname, name, tarif, netz, dienst, aktionscode, quelle, "
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?)",
"betrag_orig, betrag, prozent, manuell, roh) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,0,?)",
(aid, did, i, p.anbieter, p.zeitraum, p.provisionsart, p.kennung,
p.datum.isoformat() if p.datum else None, p.rufnummer, p.imei, p.geraet, p.vorname, p.name,
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent,
apply_percent(p.betrag_cent, proz), proz, json.dumps(p.roh, ensure_ascii=False, default=str)))
p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent,
json.dumps(p.roh, ensure_ascii=False, default=str)))
self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid))
neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))}
self._regeln_anwenden(aid, neue, auch_manuelle=True)
return len(erg.positionen)
def positionen(self, aid: int) -> list[dict]:
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen)."""
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
finde = self.zuordner()
fest = self.art_typen()
out = []
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
d = dict(r)
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
d["typ"] = regeln.typ_von(d, fest)
out.append(d)
return out
# ------------------------------------------------------------------ Berechnung nach Regeln
def _rechner(self):
"""Funktion (position, händler_id, typ, karten) -> (auszahlung_cent, prozent).
prozent ist None, wenn der Betrag aus der Jahresbonus-Staffel kommt."""
haendler = {h.id: h for h in self.haendler_liste()}
art_regeln: dict[int, dict[str, float]] = {}
for r in self.con.execute("SELECT haendler_id, provisionsart, prozent FROM regel"):
art_regeln.setdefault(r[0], {})[r[1]] = r[2]
std_staffel = self.standard_staffel()
def rechne(p: dict, hid: int, typ: str, karten: int) -> tuple[int, float | None]:
orig = p["betrag_orig"]
h = haendler.get(hid) if hid > 0 else None
if h is None: # nicht zugeordnet / eigene Filiale: unverändert
return orig, 100.0
regel = art_regeln.get(hid, {}).get(p["provisionsart"])
if regel is not None: # ausdrücklich je Provisionsart festgelegt -> hat Vorrang
proz = regel
elif typ == regeln.AIRTIME:
proz = h.airtime_prozent
elif typ == regeln.JAHRESBONUS:
return regeln.bonus_betrag(h.staffel if h.staffel is not None else std_staffel, karten, orig), None
else:
proz = h.standard_prozent
return apply_percent(orig, proz), proz
return rechne
@staticmethod
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]:
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab)."""
karten: dict[tuple[int, str], int] = {}
for p in positionen:
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"]:
k = (p["haendler_id"], p["anbieter"])
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1)
return {k: max(0, n) for k, n in karten.items()}
def _regeln_anwenden(self, aid: int, pids: set[int] | None = None, auch_manuelle: bool = False) -> int:
"""Berechnet die Auszahlung nach den Händler-Regeln neu: für `pids` (None = alle) und immer für
alle Jahresbonus-Positionen (deren Staffel hängt von der Anzahl aller Karten ab).
Von Hand geänderte Beträge bleiben, außer auch_manuelle=True. Gibt die Anzahl Änderungen zurück."""
alle = self.positionen(aid)
karten = self.karten_zaehlen(alle)
rechne = self._rechner()
n = 0
with self.con:
for p in alle:
if pids is not None and p["id"] not in pids and p["typ"] != regeln.JAHRESBONUS:
continue
if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)):
continue
betrag, proz = rechne(p, p["haendler_id"], p["typ"], karten.get((p["haendler_id"], p["anbieter"]), 0))
if betrag != p["betrag"] or proz != p["prozent"] or p["manuell"]:
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?",
(betrag, proz, p["id"]))
n += 1
return n
def _abrechnungen_von(self, pids: list[int]) -> list[int]:
if not pids:
return []
marken = ",".join("?" * len(pids))
return [r[0] for r in self.con.execute(
f"SELECT DISTINCT abrechnung_id FROM position WHERE id IN ({marken})", list(pids))]
def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None:
"""Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen).
Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet."""
with self.con:
for pid in pids:
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
finde = self.zuordner()
with self.con:
for pid in pids:
r = self.con.execute("SELECT * FROM position WHERE id=?", (pid,)).fetchone()
if r is None or r["manuell"]:
continue
neu_hid = finde(r["anbieter"], r["kennung"], r["name"], r["haendler_fest"])
proz = self.prozent_fuer(neu_hid, r["provisionsart"])
self.con.execute("UPDATE position SET betrag=?, prozent=? WHERE id=?",
(apply_percent(r["betrag_orig"], proz), proz, pid))
for aid in self._abrechnungen_von(pids):
self._regeln_anwenden(aid, set(pids))
def regeln_wiederherstellen(self, pids: list[int]) -> None:
"""Ausgewählte Positionen wieder nach den Händler-Regeln berechnen (auch von Hand geänderte)."""
for aid in self._abrechnungen_von(pids):
self._regeln_anwenden(aid, set(pids), auch_manuelle=True)
def betrag_setzen(self, pid: int, cent: int) -> None:
with self.con:
@@ -370,17 +498,7 @@ class Datenbank:
def neu_berechnen(self, aid: int, auch_manuelle: bool = False) -> int:
"""Wendet die aktuell hinterlegten Händler-Regeln auf alle (nicht manuellen) Positionen an.
Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde."""
n = 0
with self.con:
for p in self.positionen(aid):
if p["manuell"] and not auch_manuelle:
continue
proz = self.prozent_fuer(p["haendler_id"], p["provisionsart"])
neu = apply_percent(p["betrag_orig"], proz)
if neu != p["betrag"] or proz != p["prozent"] or p["manuell"]:
self.con.execute("UPDATE position SET betrag=?, prozent=?, manuell=0 WHERE id=?", (neu, proz, p["id"]))
n += 1
return n
return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle)
def touch(self, aid: int) -> None:
with self.con: