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
+70
-26
@@ -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
|
||||
|
||||
Reference in new issue
Block a user