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:
1 parent
72677ebed7
commit
968c68ac1a
12 files changed
+536
-178
No files matched your search
+52
-24
@@ -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)}")
|
||||
|
||||
Reference in new issue
Block a user