Files

175 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
if isinstance(v, str) and v.startswith("="):
cell.data_type = "s" # Text aus der Abrechnung bleibt Text – nie eine Formel
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, vergeben: set[str] | None = None) -> 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.
vergeben: Dateinamen (klein geschrieben), die in diesem Export schon geschrieben wurden – gleich
lautende Namen (z. B. Händler „A/B“ und „A:B“) bekommen „ (2)“ statt die Datei zu überschreiben.
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)
vergeben = set() if vergeben is None else vergeben
def name(*teile) -> str:
basis = dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t))
for n in range(1, 1000):
kandidat = basis + (f" ({n})" if n > 1 else "") + ".xlsx"
if kandidat.lower() not in vergeben:
vergeben.add(kandidat.lower())
return kandidat
raise ValueError(f"Kein freier Dateiname für {basis}")
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