- Verschieben/Größe ziehen per PostMessage statt SendMessage: Die Windows-Verschiebeschleife lief innerhalb eines Tk-Ereignisses; Tk führte darin Python-Ereignisse (<Configure>) aus -> harter Absturz „PyEval_RestoreThread … thread state is NULL“ (normal und aus maximiert). - Größen-Mauszeiger am Rand wieder von Tk (blieb sonst im Fenster stehen); Windows zieht beim Drücken. - Neuer Maustest im Build (xdotool, echte Maus): Titelleiste/Rand ziehen, maximieren, herausziehen. - Export: Bei Vodafone steht in Spalte A das Aktivierungsdatum (statt Abrechnungslauf). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
6.8 KiB
Python
164 lines
6.8 KiB
Python
"""Export der Händler-Abrechnung als Excel – Aufbau wie „so soll es zum schluss ausschauen.xlsx“.
|
||
|
||
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
|
||
|
||
import re
|
||
from datetime import date
|
||
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),
|
||
("IMEI/SN", 16), ("Gerät", 16), ("Vorname", 14), ("Name", 30), ("Tarif", 34), ("Betrag", 12),
|
||
("Netz", 7), ("Dienst", 22), ("Aktionscode", 12),
|
||
]
|
||
EURO_FORMAT = '_-* #,##0.00\\ "€"_-;\\-* #,##0.00\\ "€"_-;_-* "-"??\\ "€"_-;_-@_-'
|
||
|
||
|
||
def sortiert(positionen: list[dict]) -> list[dict]:
|
||
return sorted(positionen, key=lambda p: (p.get("zeitraum") or "", p.get("provisionsart") or "",
|
||
p.get("datum") or "", p.get("reihenfolge") or 0))
|
||
|
||
|
||
def _zahl_oder_text(s: str):
|
||
"""Rufnummern/IMEI als Zahl (Format „0“) wie in der Vorlage – führende Nullen bleiben als Text erhalten."""
|
||
s = (s or "").strip()
|
||
if s.isdigit() and not s.startswith("0") and len(s) <= 15:
|
||
return int(s)
|
||
return s or None
|
||
|
||
|
||
def dateiname(text: str) -> str:
|
||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", text).strip(" .") or "Export"
|
||
|
||
|
||
AIRTIME_TEXT = "Airtime"
|
||
|
||
|
||
def spalte_a(p: dict):
|
||
"""Spalte A: bei freenet der Zeitraum, bei Vodafone das Aktivierungsdatum (dort steht als Zeitraum
|
||
nur der Abrechnungslauf wie „VF2609“ – kein Datum)."""
|
||
if p.get("anbieter") == "vodafone" and p.get("datum"):
|
||
return date.fromisoformat(p["datum"])
|
||
return p.get("zeitraum") or None
|
||
|
||
|
||
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", 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
|
||
|
||
ziel = Path(ziel)
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = blattname[:31] or "Tabelle1"
|
||
font = Font(name="Aptos Narrow", size=10)
|
||
bold = Font(name="Aptos Narrow", size=10, bold=True)
|
||
|
||
zeile = 1
|
||
if kopfzeile:
|
||
for c, (titel, _) in enumerate(SPALTEN, start=1):
|
||
ws.cell(zeile, c, titel).font = bold
|
||
ws.freeze_panes = "A2"
|
||
zeile += 1
|
||
|
||
erste = zeile
|
||
for p in sortiert(positionen) + list(schluss or []):
|
||
werte = [
|
||
spalte_a(p),
|
||
p.get("provisionsart") or None,
|
||
_zahl_oder_text(p.get("kennung")),
|
||
date.fromisoformat(p["datum"]) if p.get("datum") else None,
|
||
_zahl_oder_text(p.get("rufnummer")),
|
||
_zahl_oder_text(p.get("imei")),
|
||
p.get("geraet") or None,
|
||
p.get("vorname") or None,
|
||
p.get("name") or None,
|
||
p.get("tarif") or None,
|
||
to_euro(p["betrag"]),
|
||
p.get("netz") or None,
|
||
p.get("dienst") or None,
|
||
p.get("aktionscode") or None,
|
||
]
|
||
for c, v in enumerate(werte, start=1):
|
||
cell = ws.cell(zeile, c, v)
|
||
cell.font = bold if p.get("_summe") and c in (2, 11) else font
|
||
ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
|
||
if isinstance(werte[0], date):
|
||
ws.cell(zeile, 1).number_format = "DD.MM.YYYY"
|
||
ws.cell(zeile, 5).number_format = "0"
|
||
ws.cell(zeile, 6).number_format = "0"
|
||
ws.cell(zeile, 11).number_format = EURO_FORMAT
|
||
zeile += 1
|
||
letzte = zeile - 1
|
||
|
||
if summe and letzte >= erste:
|
||
s = ws.cell(zeile + 1, 11, f"=SUM(K{erste}:K{letzte})")
|
||
s.number_format = EURO_FORMAT
|
||
s.font = bold
|
||
ws.cell(zeile + 1, 10, "Summe").font = bold
|
||
|
||
for c, (_, breite) in enumerate(SPALTEN, start=1):
|
||
ws.column_dimensions[get_column_letter(c)].width = breite
|
||
ws.page_setup.orientation = "landscape"
|
||
ws.page_setup.fitToWidth = 1
|
||
ws.page_setup.fitToHeight = 0
|
||
ws.sheet_properties.pageSetUpPr.fitToPage = True
|
||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||
wb.save(ziel)
|
||
return ziel
|
||
|
||
|
||
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
|
||
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]] = {}
|
||
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()):
|
||
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
|