Fixes aus den Audits (Fable/Codex): Doppelte, Staffel, Export-Sperre, Netz-DB, atomare Einstellungen, Formeln, Wiederherstellung, Importgrenzen, schnellere Suche; toter Code entfernt; Version 1.8.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kerim AtilganandClaude Opus 5.5 committed 2026-11-03 14:58:51 +01:00
1 parent 72677ebed7
commit 968c68ac1a
12 files changed
+536 -178

No files matched your search

+52 -24
View File
@@ -8,7 +8,9 @@ from __future__ import annotations
import csv
import hashlib
import io
from dataclasses import asdict, dataclass, field
import itertools
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
@@ -18,6 +20,10 @@ FREENET = "freenet"
VODAFONE = "vodafone"
ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"}
# Grenzen gegen versehentlich falsche oder kaputte Dateien (echte Abrechnungen: wenige MB, einige tausend Zeilen)
MAX_BYTES = 100 * 1024 * 1024 # Dateigröße, bei .xlsx auch entpackt
MAX_ZEILEN = 200_000 # Datenzeilen je Tabelle
@dataclass
class Position:
@@ -39,11 +45,6 @@ class Position:
quelle: str = ""
roh: dict = field(default_factory=dict)
def as_dict(self) -> dict:
d = asdict(self)
d["datum"] = self.datum.isoformat() if self.datum else None
return d
@dataclass
class ImportErgebnis:
@@ -183,15 +184,24 @@ FORMATE: list[Format] = [FreenetFormat(), VodafoneFormat()]
# --------------------------------------------------------------------------- Tabellen lesen
def _tabellen_aus_rows(rows: list[list], quelle: str, blatt: str = "") -> tuple[Format, list[Position]] | None:
"""Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter."""
for idx, row in enumerate(rows[:40]):
def _tabellen_aus_rows(rows: Iterable[list], quelle: str, blatt: str = "",
nur_kopf: bool = False) -> tuple[Format, list[Position]] | None:
"""Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter.
Ohne Kopfzeile (oder mit nur_kopf) wird der Rest der Tabelle gar nicht gelesen."""
rows = iter(rows)
anfang = list(itertools.islice(rows, 40))
for idx, row in enumerate(anfang):
header = [_norm(c) for c in row]
for fmt in FORMATE:
if fmt.passt(header):
pos: list[Position] = []
if nur_kopf:
return fmt, pos
leer = 0
for data in rows[idx + 1:]:
for nr, data in enumerate(itertools.chain(anfang[idx + 1:], rows)):
if nr >= MAX_ZEILEN:
raise ImportFehler(f"„{quelle}“ hat mehr als {MAX_ZEILEN // 1000}.000 Zeilen – das ist "
"keine Provisionsabrechnung.")
if not any(_text(c) for c in data):
leer += 1
if leer > 20:
@@ -222,7 +232,7 @@ def _decode(raw: bytes) -> str:
return raw.decode("latin-1", errors="replace")
def _lies_csv(path: Path) -> list[tuple[str, list[list]]]:
def _lies_csv(path: Path) -> Iterator[tuple[str, Iterable[list]]]:
text = _decode(path.read_bytes())
sample = text[:5000]
try:
@@ -230,15 +240,20 @@ def _lies_csv(path: Path) -> list[tuple[str, list[list]]]:
delim = dialect.delimiter
except csv.Error:
delim = ";"
rows = list(csv.reader(io.StringIO(text), delimiter=delim))
return [("", rows)]
yield "", csv.reader(io.StringIO(text), delimiter=delim)
def _lies_xlsx(path: Path) -> list[tuple[str, list[list]]]:
def _lies_xlsx(path: Path) -> Iterator[tuple[str, Iterable[list]]]:
import zipfile
import openpyxl
with zipfile.ZipFile(path) as z: # entpackte Größe prüfen (eine .xlsx ist ein Zip-Archiv)
if sum(i.file_size for i in z.infolist()) > MAX_BYTES:
raise ImportFehler(f"„{path.name}“ ist entpackt größer als {MAX_BYTES // 1024 // 1024} MB.")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
try:
return [(ws.title.strip(), [list(r) for r in ws.iter_rows(values_only=True)]) for ws in wb.worksheets]
for ws in wb.worksheets: # Zeilen werden erst beim Durchlaufen gelesen (nur Blätter mit Kopfzeile ganz)
yield ws.title.strip(), (list(r) for r in ws.iter_rows(values_only=True))
finally:
wb.close()
@@ -265,19 +280,33 @@ def _lies_xls(path: Path) -> list[tuple[str, list[list]]]:
def lies_datei(path: str | Path) -> ImportErgebnis:
path = Path(path)
ext = path.suffix.lower()
if ext not in (".csv", ".txt", ".xlsx", ".xlsm", ".xls"):
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.")
if path.stat().st_size > MAX_BYTES:
raise ImportFehler(f"„{path.name}“ ist größer als {MAX_BYTES // 1024 // 1024} MB – das ist keine "
"Provisionsabrechnung.")
if ext in (".csv", ".txt"):
blaetter = _lies_csv(path)
elif ext in (".xlsx", ".xlsm"):
blaetter = _lies_xlsx(path)
elif ext == ".xls":
blaetter = _lies_xls(path)
else:
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.")
blaetter = _lies_xls(path)
gefunden: list[tuple[str, Format, list[Position]]] = []
ignoriert: list[str] = []
erstes = None
for name, rows in blaetter:
res = _tabellen_aus_rows(rows, path.name, name)
if res and res[1]:
if erstes is None:
erstes = name
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – weitere Blätter mit Abrechnung
# werden nur erkannt (für den Hinweis), aber nicht mehr gelesen
vodafone_schon = any(f.key == VodafoneFormat.key for _, f, _ in gefunden)
res = _tabellen_aus_rows(rows, path.name, name, nur_kopf=vodafone_schon)
if not res:
continue
if vodafone_schon:
ignoriert.append(name)
elif res[1]:
gefunden.append((name, res[0], res[1]))
if not gefunden:
@@ -288,11 +317,10 @@ def lies_datei(path: str | Path) -> ImportErgebnis:
)
warnungen: list[str] = []
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – sie enthält alle
# Abrechnungsläufe. Weitere Blätter würden Positionen doppelt zählen.
# Vodafone/Euronics: die erste Seite ("All") enthält alle Abrechnungsläufe, weitere Blätter würden
# Positionen doppelt zählen.
if any(f.key == VodafoneFormat.key for _, f, _ in gefunden):
erstes = blaetter[0][0]
uebrig = [g[0] for g in gefunden if g[0] != erstes]
uebrig = [g[0] for g in gefunden if g[0] != erstes] + ignoriert
gefunden = [g for g in gefunden if g[0] == erstes]
if uebrig:
warnungen.append(f"Nur die erste Seite „{erstes}“ übernommen, ignoriert: {', '.join(uebrig)}")