"""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