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

+183 -2
View File
@@ -468,12 +468,16 @@ def test_abgerechnet_einfrieren_und_doppelt_erkennen(db, freenet_csv):
db.haendler_speichern(db.haendler(hid), [("freenet", "111")])
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
# (2 davon schon abgerechnet, die 3 anderen sind in „Okt“ noch offen – auch die zählen nur einmal)
doppelt = [p for p in db.positionen(a2) if p["doppelt"]]
assert len(doppelt) == 2 and all(p["doppelt"] == "Okt" for p in doppelt)
assert len(doppelt) == 5 and all(p["doppelt"] == "Okt" for p in doppelt)
assert db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden"
# Kennzeichnung aufheben -> nicht mehr doppelt
# Kennzeichnung aufheben -> die Positionen in „Okt“ sind wieder offen und bleiben die zuerst importierten;
# „Nov“ bleibt doppelt. Erst wenn „Okt“ weg ist, ist „Nov“ frei.
assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2
assert all(p["doppelt"] == "Okt" for p in db.positionen(a2))
db.abrechnung_loeschen(a1)
assert not any(p["doppelt"] for p in db.positionen(a2))
@@ -515,3 +519,180 @@ def test_migration_v3_fingerabdruck(tmp_path, freenet_csv):
assert all(p["fp"] for p in d.positionen(aid))
assert len({p["fp"] for p in d.positionen(aid)}) == 5
d.close()
# --------------------------------------------------------------------------- Audit-Befunde (Nachtests)
def test_offene_doppelte_nur_einmal_exportieren_und_abrechnen(db, freenet_csv, tmp_path):
"""Wochen- und Monatsdatei mit denselben Zeilen in EINER Abrechnung: die zweite ist „doppelt“ und
wird weder exportiert noch abgerechnet. Zwei gleiche Zeilen in EINER Datei bleiben zwei Positionen."""
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
monat = tmp_path / "monat.csv"
monat.write_bytes(freenet_csv.read_bytes() + freenet_csv.read_bytes().split(b"\r\n", 1)[1]) # Zeilen 2x
db.importieren(aid, lies_datei(monat))
pos = db.positionen(aid)
assert len(pos) == 15
erste_datei = [p for p in pos if p["datei_id"] == db.dateien(aid)[0]["id"]]
assert not any(p["doppelt"] for p in erste_datei), "die zuerst importierte Datei zählt"
zweite = [p for p in pos if p["datei_id"] != db.dateien(aid)[0]["id"]]
assert sum(1 for p in zweite if p["doppelt"] == "Okt") == 5, "je Zeile ist nur die erste Wiederholung doppelt"
assert sum(1 for p in zweite if not p["doppelt"]) == 5, "zweite Vorkommen innerhalb der Datei (#2) sind eigene Positionen"
# abrechnen aller Händler-Positionen: Doppelte bleiben offen
n = db.abrechnen([p["id"] for p in pos])
offen_haendler = [p for p in db.positionen(aid) if p["haendler_id"] == hid and not p["abgerechnet"]]
assert n == 8 and all(p["doppelt"] for p in offen_haendler)
# und noch einmal: derselbe Fingerabdruck wird in der Transaktion erneut geprüft
assert db.abrechnen([p["id"] for p in db.positionen(aid)]) == 0
def test_staffel_ohne_doppelte_und_datei_entfernen_rechnet_neu(db, bonus_csv, tmp_path):
from provtool import regeln
hid = db.haendler_speichern(Haendler(0, "Sub"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(bonus_csv))
kopie = tmp_path / "bonus2.csv"
kopie.write_bytes(bonus_csv.read_bytes())
db.importieren(aid, lies_datei(kopie))
bonus = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and p["haendler_id"] == hid]
assert len(bonus) == 26 and sum(1 for p in bonus if p["doppelt"]) == 13
echte = [p for p in bonus if not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1000] * 12, "Doppelte zählen nicht als Karten (13, nicht 26)"
# zweite Datei (3 Zeilen) zusätzlich mit anderen Rufnummern -> 16 Karten -> Stufe 15: 15 € je Karte
extra = tmp_path / "extra.csv"
extra.write_bytes(("\r\n".join([FREENET_KOPF] + [_pos("Jahresbonus Neuaktivierung", "40", rufnr=f"17000000{i:02d}")
for i in range(3)]) + "\r\n").encode("cp1252"))
db.importieren(aid, lies_datei(extra))
echte = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1500] * 15
# Datei entfernen -> Staffel der übrigen wieder 10 € je Karte
db.datei_entfernen(db.dateien(aid)[-1]["id"])
echte = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1000] * 12
def test_abgerechnete_positionen_nicht_loeschbar(db, freenet_csv):
"""Datei/Abrechnung mit abgerechneten Positionen: Löschen verweigert (sonst Fingerabdruck-Leichen
oder doppelte Auszahlung). Nach Aufheben der Kennzeichnung geht es – und hinterlässt keine Fingerabdrücke."""
from provtool.db import AbrechnungErledigt
db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
pos = db.positionen(aid)
assert db.abrechnen([pos[0]["id"]]) == 1
with pytest.raises(AbrechnungErledigt):
db.datei_entfernen(db.dateien(aid)[0]["id"])
with pytest.raises(AbrechnungErledigt):
db.abrechnung_loeschen(aid)
assert len(db.positionen(aid)) == 5
db.abrechnung_aufheben([pos[0]["id"]])
db.abrechnung_loeschen(aid)
assert db.con.execute("SELECT count(*) FROM abgerechnet_fp").fetchone()[0] == 0
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
assert not any(p["doppelt"] for p in db.positionen(a2))
def test_erledigt_offene_positionen_bekommen_keinen_haendler_mehr(db, freenet_csv):
"""Erledigt: eine damals nicht zugeordnete Position darf einem später angelegten Händler nicht mit
100 % zur Auszahlung erscheinen; abrechnen() fasst erledigte Abrechnungen nicht an."""
db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
db.erledigt_setzen(aid, True)
hid_b = db.haendler_speichern(Haendler(0, "B", standard_prozent=50), [("freenet", "999")])
p = next(p for p in db.positionen(aid) if p["kennung"] == "999")
assert p["haendler_id"] == UNZUGEORDNET and not p["abgerechnet"]
assert db.abrechnen([p["id"]]) == 0
# in einer offenen Abrechnung gehört dieselbe Kennung sehr wohl zu B (mit 50 %)
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
q = next(p for p in db.positionen(a2) if p["kennung"] == "999")
assert q["haendler_id"] == hid_b and q["betrag"] == -400
def test_export_text_mit_gleichheitszeichen_bleibt_text(tmp_path):
pos = [{"anbieter": "freenet", "zeitraum": "2026.10.01-2026.10.07", "provisionsart": "=1+1", "kennung": "111",
"datum": "2026-10-05", "rufnummer": "1601", "imei": "", "geraet": "", "vorname": "",
"name": '=HYPERLINK("http://böse.example","Klick")', "tarif": "T", "betrag": 800, "betrag_orig": 800,
"netz": "", "dienst": "", "aktionscode": "=cmd|' /C calc'!A0", "typ": "normal", "reihenfolge": 1}]
datei = export.exportiere_haendler(tmp_path, "A", pos, "Okt")[0]
ws = openpyxl.load_workbook(datei).active
zeile = [c for c in ws[1]]
assert zeile[1].value == "=1+1" and zeile[1].data_type == "s"
assert zeile[8].value.startswith("=HYPERLINK") and zeile[8].data_type == "s"
assert zeile[13].data_type == "s"
summe = next(c for row in ws.iter_rows() for c in row if isinstance(c.value, str) and c.value.startswith("=SUM"))
assert summe.data_type == "f", "unsere Summenformel bleibt eine Formel"
def test_export_gleiche_dateinamen_ueberschreiben_sich_nicht(tmp_path):
p = {"anbieter": "freenet", "zeitraum": "", "provisionsart": "X", "kennung": "1", "datum": None, "rufnummer": "",
"imei": "", "geraet": "", "vorname": "", "name": "K", "tarif": "", "betrag": 100, "betrag_orig": 100,
"netz": "", "dienst": "", "aktionscode": "", "typ": "normal", "reihenfolge": 1}
vergeben: set[str] = set()
a = export.exportiere_haendler(tmp_path, "A/B", [p], "Okt", vergeben=vergeben)
b = export.exportiere_haendler(tmp_path, "A:B", [dict(p, betrag=200)], "Okt", vergeben=vergeben)
assert a[0].name == "A_B - Okt.xlsx" and b[0].name == "A_B - Okt (2).xlsx"
assert openpyxl.load_workbook(a[0]).active["K1"].value == 1.0
assert openpyxl.load_workbook(b[0]).active["K1"].value == 2.0
def test_restore_prueft_sicherung(db, freenet_csv, tmp_path):
import sqlite3
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
db.close()
ziel = db.path
vorher = ziel.read_bytes()
# keine SQLite-Datei
muell = tmp_path / "muell.db"
muell.write_bytes(b"kein sqlite")
with pytest.raises(Exception):
Datenbank.restore(muell, ziel)
# SQLite, aber nicht unsere Tabellen
fremd = tmp_path / "fremd.db"
con = sqlite3.connect(fremd)
con.execute("CREATE TABLE haendler (id INTEGER)")
con.commit()
con.close()
with pytest.raises(ValueError):
Datenbank.restore(fremd, ziel)
assert ziel.read_bytes() == vorher and not ziel.with_suffix(".pruefung.db").exists()
# echte Sicherung
d = Datenbank(ziel)
sicherung = tmp_path / "sicherung.db"
d.backup(sicherung)
d.abrechnung_loeschen(aid)
d.close()
Datenbank.restore(sicherung, ziel)
d = Datenbank(ziel)
assert len(d.positionen(aid)) == 5
d.close()
def test_import_grenzen(tmp_path, freenet_csv, vodafone_xlsx, monkeypatch):
from provtool import importer
monkeypatch.setattr(importer, "MAX_ZEILEN", 3)
with pytest.raises(ImportFehler, match="Zeilen"):
lies_datei(freenet_csv)
monkeypatch.setattr(importer, "MAX_ZEILEN", 200_000)
monkeypatch.setattr(importer, "MAX_BYTES", 10)
with pytest.raises(ImportFehler, match="MB"):
lies_datei(freenet_csv)
monkeypatch.setattr(importer, "MAX_BYTES", vodafone_xlsx.stat().st_size + 1) # gepackt klein, entpackt zu groß
with pytest.raises(ImportFehler, match="entpackt"):
lies_datei(vodafone_xlsx)
def test_einstellungen_atomar_und_defekt(tmp_path, monkeypatch):
from provtool import config
monkeypatch.setenv("PROVTOOL_HOME", str(tmp_path))
config.save({"db_pfad": "Z:/x.db", "defekt": "weg"})
assert not (tmp_path / "einstellungen.json.tmp").exists()
assert config.load()["db_pfad"] == "Z:/x.db" and "defekt" not in config.load()
(tmp_path / "einstellungen.json").write_text("{ kaputt", encoding="utf-8")
daten = config.load()
assert daten["db_pfad"] is None and daten["defekt"].endswith("einstellungen.defekt.json")
assert (tmp_path / "einstellungen.defekt.json").read_text(encoding="utf-8") == "{ kaputt"