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
@@ -1123,6 +1123,10 @@ def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkey
|
||||
app.ptree.selection_set(app.ptree.get_children()[0])
|
||||
app.prozent()
|
||||
assert "erledigt" in meldungen[-1]
|
||||
# ... auch der Export (würde sonst offene Positionen abrechnen)
|
||||
n = len(meldungen)
|
||||
app.exportieren()
|
||||
assert lauf.dialog(ExportDialog) is None and len(meldungen) == n + 1 and "erledigt" in meldungen[-1]
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
@@ -1205,3 +1209,53 @@ def test_startet_maximiert(home):
|
||||
assert lauf.app.rahmen.maximiert, "Programm startet nicht maximiert"
|
||||
finally:
|
||||
lauf.ende()
|
||||
|
||||
|
||||
def test_suche_und_filter_blenden_nur_aus(lauf_modern):
|
||||
"""Suche/Filter bauen die Tabelle nicht neu auf: Zeilen werden aus- und wieder eingehängt."""
|
||||
lauf = lauf_modern
|
||||
app = lauf.app
|
||||
alle = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
|
||||
assert len(alle) == 3
|
||||
app.v_suche.set("Beispiel")
|
||||
app._suche_ausfuehren()
|
||||
sichtbar = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
|
||||
assert len(sichtbar) == 1 and app.ptree.item(sichtbar[0], "text") == "Beispiel KG"
|
||||
assert all(app.ptree.exists(i) for i in alle), "ausgeblendete Zeilen bleiben erhalten (nur ausgehängt)"
|
||||
assert app.kpi["pos"][0].cget("text") == "1"
|
||||
gruppe = app.ptree.parent(sichtbar[0])
|
||||
assert "(1)" in app.ptree.item(gruppe, "text") and app.ptree.item(gruppe, "open")
|
||||
app.ptree.selection_set(gruppe)
|
||||
assert app._ausgewaehlte_pids() == [int(sichtbar[0][1:])], "Gruppenauswahl = nur sichtbare Zeilen"
|
||||
app.v_suche.set("")
|
||||
app._suche_ausfuehren()
|
||||
assert [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)] == alle
|
||||
assert app.kpi["pos"][0].cget("text") == "3"
|
||||
lauf.pruefen()
|
||||
|
||||
|
||||
def test_start_mit_unerreichbarer_datenbank_fragt(home, monkeypatch):
|
||||
"""Eingestellter (Netz-)Pfad fehlt: Rückfrage statt Absturz – und es wird dort keine leere DB angelegt."""
|
||||
from provtool import config
|
||||
from provtool.gui import win95 as w
|
||||
_profil_mit_daten(home)
|
||||
weg = home / "netz" / "provisionen.db"
|
||||
fragen = []
|
||||
monkeypatch.setattr(w, "_meldung", lambda m, t, text, icon, knoepfe, default=0: fragen.append(text) or "standard")
|
||||
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "db_pfad": str(weg)})
|
||||
try:
|
||||
assert len(fragen) == 1 and str(weg) in fragen[0]
|
||||
assert not weg.exists() and lauf.app.db.path == config.app_dir() / "provisionen.db"
|
||||
assert config.load()["db_pfad"] is None, "Wahl „Standard“ wird gespeichert"
|
||||
assert len(lauf.app.positionen) == 3
|
||||
finally:
|
||||
lauf.ende()
|
||||
lauf.pruefen()
|
||||
# „Beenden“ gewählt: Programm endet sauber
|
||||
monkeypatch.setattr(w, "_meldung", lambda *a, **k: "ende")
|
||||
(home / "einstellungen.json").write_text(json.dumps({"db_pfad": str(weg), "vollbild": False}), encoding="utf-8")
|
||||
from provtool.gui import app as A
|
||||
root = tk.Tk()
|
||||
with pytest.raises(SystemExit):
|
||||
A.App(root)
|
||||
assert not weg.exists()
|
||||
+183
-2
@@ -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"
|
||||
Reference in new issue
Block a user