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

+70 -26
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import json
import os
import shutil
import sqlite3
from dataclasses import dataclass
@@ -134,7 +135,8 @@ def fingerabdruecke(zeilen: list[dict]) -> list[str]:
class AbrechnungErledigt(Exception):
"""Die Abrechnung ist als erledigt gekennzeichnet und darf nicht mehr verändert werden."""
"""Die Abrechnung ist als erledigt gekennzeichnet (oder enthält abgerechnete Positionen) und darf so
nicht verändert werden."""
def ist_grossgeschrieben(name: str | None) -> bool:
@@ -168,8 +170,6 @@ class Datenbank:
self.con.execute("PRAGMA foreign_keys = ON")
self.con.executescript(SCHEMA)
self._migrieren()
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
self.con.commit()
def _migrieren(self):
spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")}
@@ -191,7 +191,8 @@ class Datenbank:
if "erledigt" not in a_spalten:
self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT")
self._fingerabdruecke_nachtragen()
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
self.con.execute("INSERT INTO meta VALUES ('schema_version', ?) "
"ON CONFLICT(schluessel) DO UPDATE SET wert=excluded.wert", (str(SCHEMA_VERSION),))
self.con.commit()
def _fingerabdruecke_nachtragen(self):
@@ -215,12 +216,26 @@ class Datenbank:
@staticmethod
def restore(quelle: str | Path, ziel: str | Path) -> None:
test = sqlite3.connect(str(quelle))
"""Spielt eine Sicherung ein. Geprüft wird an einer Kopie (unversehrt, unsere Tabellen, lässt sich
öffnen und migrieren) – erst dann ersetzt sie die Datenbank. Schlägt die Prüfung fehl, bleibt alles."""
ziel = Path(ziel)
probe = ziel.with_suffix(".pruefung.db")
shutil.copyfile(quelle, probe)
try:
test.execute("SELECT count(*) FROM haendler").fetchone()
con = sqlite3.connect(str(probe))
try:
if con.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("Die Datei ist beschädigt (integrity_check).")
tabellen = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
fehlt = {"haendler", "abrechnung", "position"} - tabellen
if fehlt:
raise ValueError(f"Keine Sicherung des Provisions-Managers (Tabellen fehlen: {', '.join(sorted(fehlt))}).")
finally:
con.close()
Datenbank(probe).close() # öffnet und migriert – wirft, wenn das nicht geht
os.replace(probe, ziel)
finally:
test.close()
shutil.copyfile(quelle, ziel)
probe.unlink(missing_ok=True)
# ------------------------------------------------------------------ Händler
@staticmethod
@@ -368,16 +383,6 @@ class Datenbank:
h = self.haendler(hid) if hid > 0 else None
return h.staffel if h and h.staffel is not None else self.standard_staffel()
def prozent_fuer(self, hid: int, provisionsart: str) -> float:
if hid <= 0: # nicht zugeordnet / eigene Filiale
return 100.0
r = self.con.execute("SELECT prozent FROM regel WHERE haendler_id=? AND provisionsart=?",
(hid, provisionsart)).fetchone()
if r:
return r[0]
h = self.haendler(hid)
return h.standard_prozent if h else 100.0
# ------------------------------------------------------------------ Abrechnungen
def abrechnung_neu(self, titel: str) -> int:
with self.con:
@@ -400,9 +405,20 @@ class Datenbank:
self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid))
def abrechnung_loeschen(self, aid: int) -> None:
self._keine_abgerechneten("abrechnung_id=?", aid)
with self.con:
self.con.execute("DELETE FROM abrechnung WHERE id=?", (aid,))
def _keine_abgerechneten(self, wo: str, wert: int) -> None:
"""Abgerechnete Positionen dürfen nicht gelöscht werden: ihr Fingerabdruck bliebe in abgerechnet_fp
zurück (die Position gälte nach erneutem Import für immer als „doppelt“) – oder, würde man ihn
mitlöschen, könnte sie ein zweites Mal ausgezahlt werden. Zuerst die Kennzeichnung aufheben."""
n = self.con.execute(f"SELECT count(*) FROM position WHERE {wo} AND abgerechnet IS NOT NULL",
(wert,)).fetchone()[0]
if n:
raise AbrechnungErledigt(f"{n} Position(en) sind bereits abgerechnet. Zuerst die Kennzeichnung "
"„abgerechnet“ aufheben (Rechtsklick auf die Positionen), dann löschen.")
def dateien(self, aid: int) -> list[sqlite3.Row]:
return list(self.con.execute("SELECT * FROM abrechnung_datei WHERE abrechnung_id=? ORDER BY id", (aid,)))
@@ -413,11 +429,14 @@ class Datenbank:
def datei_entfernen(self, datei_id: int) -> None:
r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone()
if r:
self._nicht_erledigt(r[0])
if not r:
return
self._nicht_erledigt(r[0])
self._keine_abgerechneten("datei_id=?", datei_id)
with self.con:
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
self._regeln_anwenden(r[0], set()) # weniger Karten -> Jahresbonus-Staffel der übrigen neu
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
self._nicht_erledigt(aid)
@@ -450,7 +469,19 @@ class Datenbank:
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
finde = self.zuordner()
fest = self.art_typen()
schon = dict(self.con.execute("SELECT fp, abrechnung FROM abgerechnet_fp").fetchall())
erledigt = self.erledigt(aid)
# Fingerabdrücke dieser Abrechnung, die schon abgerechnet sind (in dieser oder einer anderen)
schon = dict(self.con.execute(
"SELECT f.fp, f.abrechnung FROM abgerechnet_fp f WHERE f.fp IN (SELECT fp FROM position WHERE abrechnung_id=?)",
(aid,)).fetchall())
# Offene Doppelte: dieselbe Position (Fingerabdruck) ein zweites Mal offen – z. B. Wochen- und
# Monatsdatei in derselben Abrechnung. Die zuerst importierte zählt, die anderen sind „doppelt“.
erste: dict[str, tuple[int, str]] = {}
for fp, pid, titel in self.con.execute(
"SELECT p.fp, p.id, a.titel FROM position p JOIN abrechnung a ON a.id=p.abrechnung_id "
"WHERE p.abgerechnet IS NULL AND p.fp IN (SELECT fp FROM position WHERE abrechnung_id=?) "
"ORDER BY p.id", (aid,)):
erste.setdefault(fp, (pid, titel))
out = []
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
d = dict(r)
@@ -458,9 +489,17 @@ class Datenbank:
d["haendler_id"] = d["abgerechnet_hid"] # abgerechnet: Zuordnung eingefroren
else:
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
if erledigt and d["haendler_id"] > 0:
# Erledigt: was dabei nicht abgerechnet wurde, bekommt nachträglich keinen Händler mehr
# (sonst stünde es bei einem später angelegten Händler mit 100 % zur Auszahlung)
d["haendler_id"] = UNZUGEORDNET
d["typ"] = regeln.typ_von(d, fest)
# schon anderswo abgerechnet (dieselbe Position in einer früheren Datei/Abrechnung)?
d["doppelt"] = schon.get(d["fp"]) if d["fp"] and not d["abgerechnet"] else None
d["doppelt"] = None
if d["fp"] and not d["abgerechnet"]:
if d["fp"] in schon:
d["doppelt"] = schon[d["fp"]]
elif erste.get(d["fp"], (d["id"],))[0] != d["id"]:
d["doppelt"] = erste[d["fp"]][1]
out.append(d)
return out
@@ -493,10 +532,10 @@ class Datenbank:
@staticmethod
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]:
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab)."""
"""Anzahl Karten je (Händler, Anbieter) für die Jahresbonus-Staffel (Stornos zählen ab, Doppelte nicht mit)."""
karten: dict[tuple[int, str], int] = {}
for p in positionen:
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"]:
if p["typ"] == regeln.JAHRESBONUS and p["betrag_orig"] and not p["doppelt"]:
k = (p["haendler_id"], p["anbieter"])
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1)
return {k: max(0, n) for k, n in karten.items()}
@@ -599,15 +638,20 @@ class Datenbank:
ids = set(pids)
n = 0
for aid in self._abrechnungen_von(list(ids)):
if self.erledigt(aid):
continue # erledigt: nichts wird nachträglich abgerechnet
titel = self.abrechnung(aid)["titel"]
with self.con:
for p in self.positionen(aid):
if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
continue
# innerhalb der Transaktion noch einmal: derselbe Fingerabdruck darf nur einmal abgerechnet sein
if p["fp"] and self.con.execute("SELECT 1 FROM abgerechnet_fp WHERE fp=?", (p["fp"],)).fetchone():
continue
self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE id=?",
(wann, p["haendler_id"], p["id"]))
if p["fp"]:
self.con.execute("INSERT OR REPLACE INTO abgerechnet_fp (fp, wann, abrechnung) VALUES (?,?,?)",
self.con.execute("INSERT INTO abgerechnet_fp (fp, wann, abrechnung) VALUES (?,?,?)",
(p["fp"], wann, titel))
n += 1
return n