diff --git a/README.md b/README.md index dbb3df0..67618e7 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,18 @@ normale Windows-Titelleiste verwendet. ## 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`) - - Vodafone → Spalte **Vo-Nr** (z. B. `80211721`, `EU133301`) **oder** - „Vodafone (Name in GROSSBUCHSTABEN)“: alle Vodafone-Positionen, deren Kundenname komplett - großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur ein - Händler haben und hat Vorrang vor der Vo-Nr. + - Vodafone → Spalte **Vo-Nr** (z. B. `80211721`, `EU133301`) **oder** das Häkchen + „Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN“: alle Vodafone-Positionen, deren Kundenname + komplett großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur + 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. Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…** (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 (`Enter` speichern, `↓`/`Tab` nächste Zeile, `Esc` abbrechen). Blau = von Hand geändert. - 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** (` - Airtime - .xlsx`). Positionen mit 0,00 € werden + weggelassen (abschaltbar): | 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. 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`). Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*. diff --git a/provtool/__init__.py b/provtool/__init__.py index ffcb358..32e1165 100644 --- a/provtool/__init__.py +++ b/provtool/__init__.py @@ -1,5 +1,5 @@ """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_ORG = "Handy-Insel Miesbach" diff --git a/provtool/config.py b/provtool/config.py index c5b505b..b0b0e5a 100644 --- a/provtool/config.py +++ b/provtool/config.py @@ -35,7 +35,8 @@ DEFAULTS = { "import_ordner": None, "export_kopfzeile": False, "export_summe": True, - "export_pro_anbieter": True, + "export_anbieter_getrennt": False, # freenet + Vodafone in einer Datei + "export_null_weglassen": True, } diff --git a/provtool/db.py b/provtool/db.py index 00459c9..d150181 100644 --- a/provtool/db.py +++ b/provtool/db.py @@ -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: diff --git a/provtool/export.py b/provtool/export.py index 4b44211..7b32a96 100644 --- a/provtool/export.py +++ b/provtool/export.py @@ -3,6 +3,9 @@ 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 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 @@ -12,6 +15,7 @@ from pathlib import Path from provtool.importer import ANBIETER from provtool.money import to_euro +from provtool.regeln import AIRTIME SPALTEN = [ ("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" +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, - 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 from openpyxl.styles import Font from openpyxl.utils import get_column_letter @@ -59,7 +74,7 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool = zeile += 1 erste = zeile - for p in sortiert(positionen): + for p in sortiert(positionen) + list(schluss or []): werte = [ p.get("zeitraum") 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): 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, 5).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, *, - pro_anbieter: bool = True, kopfzeile: bool = False, summe: bool = True) -> list[Path]: - """Schreibt eine (oder je Anbieter eine) Datei für einen Händler. Gibt die erzeugten Pfade zurück.""" + pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True, + 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) + if null_weglassen: + positionen = [p for p in positionen if p["betrag"]] if not positionen: return [] gruppen: dict[str, list[dict]] = {} - if pro_anbieter: - for p in positionen: - gruppen.setdefault(p["anbieter"], []).append(p) - else: - gruppen[""] = positionen + for p in positionen: + gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p) + + def name(*teile) -> str: + return dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t)) + ".xlsx" + out = [] for anbieter, pos in sorted(gruppen.items()): - teile = [haendler_name] + ([ANBIETER.get(anbieter, anbieter)] if anbieter else []) + [titel] - name = dateiname(" - ".join(t for t in teile if t)) + ".xlsx" - out.append(schreibe_xlsx(ordner / name, pos, kopfzeile=kopfzeile, summe=summe)) + provision = [p for p in pos if p.get("typ") != AIRTIME] + airtime = [p for p in pos if p.get("typ") == AIRTIME] + 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 diff --git a/provtool/gui/app.py b/provtool/gui/app.py index 6ec8912..c3df9ca 100644 --- a/provtool/gui/app.py +++ b/provtool/gui/app.py @@ -14,6 +14,7 @@ from tkinter import filedialog from provtool import APP_NAME, config from provtool import export as exp +from provtool import regeln from provtool.db import EIGEN, UNZUGEORDNET, Datenbank from provtool.gui import assistent as ass from provtool.gui import dialogs as dlg @@ -137,6 +138,9 @@ class App: h.add_separator() 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_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.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) @@ -181,9 +185,11 @@ class App: def _body(self): desk = tk.Frame(self.inhalt, bg=w.TEAL) 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", - 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) # --- Händler-Fenster @@ -366,7 +372,10 @@ class App: so = sum(p["betrag_orig"] for p in plist) sa = sum(p["betrag"] for p in plist) proz = {p["prozent"] for p in plist} - pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "") + 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 "") 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",)) for p in plist: @@ -380,7 +389,8 @@ class App: values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]), p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""), 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)) name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet", 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._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): res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen() if not res: @@ -583,8 +604,7 @@ class App: sel, pids, ps = self._auswahl_info() if not ps: return - for p in ps: - self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"])) + self.db.regeln_wiederherstellen([p["id"] for p in ps]) self.db.touch(self.aid) self._aktualisieren() self._auswahl_wiederherstellen(sel) @@ -789,7 +809,7 @@ class App: return stat: dict[int, list[int]] = defaultdict(lambda: [0, 0]) 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"]][1] += p["betrag"] if not stat: @@ -810,7 +830,7 @@ class App: 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"], pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"], - summe=res["summe"]) + summe=res["summe"], null_weglassen=res["null_weglassen"]) except PermissionError as exc: 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?") @@ -883,9 +903,11 @@ class App: def hilfe(self): w.info(self.root, "Kurzanleitung", - "1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n" - " bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr. oder die Regel\n" - " „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick > Händler zuweisen.\n\n" + "1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in %, Airtime in % (0 % = keine)\n" + " und die Kennungen – bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\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" "3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n" " auch alle Wochenlisten eines Monats.\n\n" @@ -894,7 +916,11 @@ class App: " optional als Standard für diesen Händler merken.\n" " - Doppelklick / F2 auf eine Position: Betrag frei ändern\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.") def assistent(self): @@ -945,7 +971,8 @@ class App: "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."), (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", "Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: " "Provision laut Anbieter und Ihre Auszahlung."), @@ -1041,6 +1068,11 @@ class App: 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(): if len(sys.argv) > 1 and sys.argv[1] == "--selbsttest": from provtool.selbsttest import ausfuehren diff --git a/provtool/gui/assistent.py b/provtool/gui/assistent.py index 89f04dd..5a6f417 100644 --- a/provtool/gui/assistent.py +++ b/provtool/gui/assistent.py @@ -537,6 +537,10 @@ class Rundgang: # =========================================================================== Tipp des Tages 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 " "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 % " diff --git a/provtool/gui/dialogs.py b/provtool/gui/dialogs.py index 6e35311..d4acc49 100644 --- a/provtool/gui/dialogs.py +++ b/provtool/gui/dialogs.py @@ -5,15 +5,16 @@ import tkinter as tk from tkinter import filedialog, ttk from provtool import APP_NAME, APP_ORG, __version__ +from provtool import regeln from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler from provtool.gui import win95 as w from provtool.importer import ANBIETER +from provtool.money import fmt, fmt_percent, parse_cent, parse_percent # Auswahl im Händler-Dialog: Schlüssel -> Anzeige -KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)", - CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"} +KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"} 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 @@ -51,6 +52,7 @@ class HaendlerDialog(w.Dialog): g.pack(fill="x") 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_air = tk.StringVar(value="0") for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap), ("E-Mail:", self.v_mail))): 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.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.Label(g, text="Notiz:", anchor="nw").grid(row=4, column=0, sticky="nw", pady=2) - self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT) - self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2) + tk.Label(g, text="Airtime:", anchor="w").grid(row=4, column=0, sticky="w", pady=2) + af = tk.Frame(g, bg=w.FACE) + 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) g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)") 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") self.kliste.heading("anbieter", text="Anbieter") self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.") @@ -89,10 +97,27 @@ class HaendlerDialog(w.Dialog): ke.bind("", lambda e: self._kennung_dazu()) w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left") 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.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("proz", text="Auszahlung") self.rliste.column("art", width=240) @@ -112,9 +137,14 @@ class HaendlerDialog(w.Dialog): self._liste_laden(auswahl_id) if neue_kennung: self._neu() - self._kennungen = [neue_kennung] + if neue_kennung[0] == CAPS_ANBIETER: + self.v_caps.set(True) + else: + self._kennungen = [neue_kennung] 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 @staticmethod @@ -122,9 +152,42 @@ class HaendlerDialog(w.Dialog): return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige) def _art_gewaehlt(self): - caps = self._anbieter_key(self.v_kanb.get()) == CAPS_ANBIETER - self.v_kwert.set("") - self._ke.configure(state="disabled" if caps else "normal") + self._ke.focus_set() + + 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): self.liste.delete(*self.liste.get_children()) @@ -150,20 +213,23 @@ class HaendlerDialog(w.Dialog): self.v_ap.set(h.ansprechpartner) self.v_mail.set(h.email) 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.t_notiz.delete("1.0", "end") 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._regeln_zeigen() def _kennungen_zeigen(self): self.kliste.delete(*self.kliste.get_children()) 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): self.rliste.delete(*self.rliste.get_children()) @@ -177,15 +243,20 @@ class HaendlerDialog(w.Dialog): for v in (self.v_name, self.v_ap, self.v_mail): v.set("") self.v_proz.set("100") + self.v_air.set("0") self.v_aktiv.set(True) self.t_notiz.delete("1.0", "end") self._kennungen = [] + self._caps_einstellen(0, False) + self._eigene_staffel = None + self.v_eigen_staffel.set(False) + self._staffel_zeigen() self._kennungen_zeigen() self._regeln_zeigen() def _kennung_dazu(self): 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: return if (anb, wert) not in self._kennungen: @@ -216,12 +287,21 @@ class HaendlerDialog(w.Dialog): except ValueError: w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).") 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(): self._kennung_dazu() 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: - hid = self.db.haendler_speichern(h, self._kennungen) + hid = self.db.haendler_speichern(h, kennungen) except Exception as exc: # sqlite3.IntegrityError / ValueError msg = str(exc) if "UNIQUE" in msg and "haendler.name" in msg: @@ -309,6 +389,171 @@ class ProzentDialog(w.Dialog): 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("<>", 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 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, selectmode="extended") 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")): self.tree.heading(c, text=t) 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 "") 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)) - 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_sum = tk.BooleanVar(value=settings.get("export_summe", True)) self.v_auf = tk.BooleanVar(value=True) - tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei", - variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w") - tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3, - sticky="w") - tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0, - columnspan=3, sticky="w") - tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0, - columnspan=3, sticky="w") + for zeile, (text, var) in enumerate(( + ("freenet und Vodafone in getrennte Dateien (sonst beide in einer)", self.v_anb), + ("Positionen mit 0,00 € weglassen", self.v_null), + ("Kopfzeile mit Spaltennamen", self.v_kopf), + ("Summenzeile unter der Tabelle", self.v_sum), + ("Ordner nach dem Export öffnen", self.v_auf)), start=2): + tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, 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) self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) @@ -683,11 +932,13 @@ class ExportDialog(w.Dialog): self._ordner() if not self.v_ordner.get().strip(): return - self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(), - export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get()) + self.settings.update(export_ordner=self.v_ordner.get().strip(), export_anbieter_getrennt=self.v_anb.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(), - pro_anbieter=self.v_anb.get(), kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), - oeffnen=self.v_auf.get()) + pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.get(), + kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get()) self.destroy() diff --git a/provtool/gui/nativ.py b/provtool/gui/nativ.py new file mode 100644 index 0000000..a653fca --- /dev/null +++ b/provtool/gui/nativ.py @@ -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 = [] diff --git a/provtool/gui/rahmen.py b/provtool/gui/rahmen.py index b2d7c26..5a7583f 100644 --- a/provtool/gui/rahmen.py +++ b/provtool/gui/rahmen.py @@ -3,8 +3,11 @@ Windows erlaubt Tk nicht, die echte Titelleiste umzufärben. Deshalb wird sie ausgeblendet (overrideredirect) und hier nachgebaut: blaue Titelleiste mit Icon und _ □ x, Verschieben, 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 -trotzdem in der Taskleiste erscheint, per Alt+Tab erreichbar ist und minimiert werden kann. +graue Menüleiste. + +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 @@ -210,6 +213,7 @@ class Titelleiste(tk.Frame): 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 @@ -243,7 +247,8 @@ class Hauptrahmen: schliessen=schliessen, minimieren=self.minimieren, maximieren=self.umschalten) 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.pack(fill="both", expand=True) # Gesperrt wird bei offenem Dialog nur der Inhalt – Titelleiste und Rand bleiben bedienbar @@ -260,7 +265,66 @@ class Hauptrahmen: self.aussen.bind("", self._resize_start) self.aussen.bind("", self._resize) root.bind_all("", 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("", self._fenster_geaendert, add="+") + root.bind("", 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: """Alt+F4 wie unter Windows: schließt nur das Fenster, in dem man gerade ist.""" @@ -323,10 +387,16 @@ class Hauptrahmen: return None 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.bind("", lambda e: self._resize_start(e, "se")) + g.bind("", self._griff_start) g.bind("", self._resize) 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): self.root.update_idletasks() if not geo or "+" not in geo: @@ -340,6 +410,8 @@ class Hauptrahmen: breite, hoehe = min(breite, bw), min(hoehe, bh) geo = f"{breite}x{hoehe}+{x0 + (bw - breite) // 2}+{y0 + (bh - hoehe) // 2}" self.root.geometry(geo) + self._normal_geo = geo + self._nativ_einrichten() if maximiert and self.klassisch: self.root.after(100, self.maximieren) @@ -371,6 +443,13 @@ class Hauptrahmen: def maximieren(self): if self.maximiert: 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() x, y, bw, bh = arbeitsbereich(self.root) self.root.geometry(f"{bw}x{bh}+{x}+{y}") @@ -380,6 +459,12 @@ class Hauptrahmen: def wiederherstellen(self): if not self.maximiert: 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 if self._normal_geo: self.root.geometry(self._normal_geo) @@ -417,7 +502,7 @@ class Hauptrahmen: "sw": "bottom_left_corner"} def _rand_cursor(self, e): - if self.maximiert: + if self.maximiert or self.nativ_an: # nativ: Windows zeigt den Zeiger selbst return rand = self._welcher_rand(e.x, e.y) 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): 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.root.winfo_width(), self.root.winfo_height()) diff --git a/provtool/regeln.py b/provtool/regeln.py new file mode 100644 index 0000000..c665b86 --- /dev/null +++ b/provtool/regeln.py @@ -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) diff --git a/provtool/selbsttest.py b/provtool/selbsttest.py index 6fd6e57..1821477 100644 --- a/provtool/selbsttest.py +++ b/provtool/selbsttest.py @@ -151,6 +151,15 @@ def ausfuehren(ergebnisdatei: str) -> int: from provtool.gui.assistent import TippDesTages 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(): d = s.dialog(TippDesTages) if not d.bild_ok: @@ -166,6 +175,7 @@ def ausfuehren(ergebnisdatei: str) -> int: (seite(4), lambda: wiz()._weiter(), "Fertig stellen"), (lambda: s.dialog(ImportBericht) is not None, import_bericht_zu, "Import-Bericht schließen"), (lambda: len(s.app.positionen) == 2, export, "Export"), + (immer, fenster_pruefen, "Fensterrahmen"), (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 None, s.app.rundgang, "Rundgang"), diff --git a/tests/test_gui.py b/tests/test_gui.py index c078190..7d3312b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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("") 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() diff --git a/tests/test_kern.py b/tests/test_kern.py index d5c1af4..852e5e9 100644 --- a/tests/test_kern.py +++ b/tests/test_kern.py @@ -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()