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

+130 -56
View File
@@ -61,11 +61,14 @@ class App:
def __init__(self, root: tk.Tk):
self.root = root
self.settings = config.load()
self.db = Datenbank(config.db_path(self.settings))
self.db: Datenbank | None = None # wird nach dem Aufbau des Fensters geöffnet (_db_oeffnen)
self.aid: int | None = None
self.positionen: list[dict] = []
self.haendler_namen: dict[int, str] = {}
self.ansicht: int = ALLE
self._alle_gruppen: dict[str, tuple[str, list[dict]]] = {}
self._gruppen: dict[str, tuple[str, list[dict]]] = {}
self._zeilen_tags: dict[str, tuple[str, ...]] = {}
self._edit: tk.Entry | None = None
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu
@@ -101,16 +104,51 @@ class App:
if self.settings.get("trenner"):
root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0))
self.db = self._db_oeffnen()
abr = self.db.abrechnungen()
if abr:
self.oeffnen(abr[0]["id"])
else:
self._aktualisieren()
if not self.settings.get("assistent_erledigt") and not self.db.haendler_liste():
if self.settings.pop("defekt", None):
root.after(300, lambda: w.warnung(
root, "Einstellungen",
"Die Einstellungsdatei war unlesbar und wurde durch Standardwerte ersetzt (Kopie: "
f"{config.app_dir() / 'einstellungen.defekt.json'}).\n\nBitte unter Datei > Einstellungen "
"prüfen, ob der Speicherort der Datenbank noch stimmt."))
elif not self.settings.get("assistent_erledigt") and not self.db.haendler_liste():
root.after(400, self.assistent)
elif self.settings.get("tipps_beim_start", True):
root.after(500, self.tipp_des_tages)
def _db_oeffnen(self) -> Datenbank:
"""Datenbank laut Einstellungen öffnen. Ist ein eigener Speicherort eingestellt (Netzlaufwerk) und
nicht erreichbar, wird gefragt – statt abzustürzen oder dort still eine leere Datenbank anzulegen."""
while True:
pfad = config.db_path(self.settings)
try:
if self.settings.get("db_pfad") and not pfad.exists():
raise FileNotFoundError("Die Datei gibt es nicht (Netzlaufwerk nicht verbunden?)")
return Datenbank(pfad)
except Exception as exc:
wahl = w._meldung(self.root, "Datenbank nicht erreichbar",
f"Die Datenbank konnte nicht geöffnet werden:\n{pfad}\n\n{exc}\n\n"
"Standard: die lokale Datenbank dieses PCs verwenden.",
"fehler", [("Standard", "standard"), ("Andere wählen...", "waehlen"),
("Beenden", "ende")], default=0)
if wahl == "standard":
self.settings["db_pfad"] = None
config.save(self.settings)
elif wahl == "waehlen":
p = filedialog.askopenfilename(parent=self.root, title="Datenbank öffnen",
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
if p:
self.settings["db_pfad"] = p
config.save(self.settings)
else:
self.root.destroy()
raise SystemExit(1) from exc
# ================================================================= Aufbau
def _kopf(self):
"""Kopfzeile wie in den Webtools: Logo, Menüs, rechts Suche (modern auch Hell/Dunkel)."""
@@ -378,7 +416,7 @@ class App:
self.filter = key
for k, pill in self.pills.items():
pill.setzen(k == key)
self._tabelle()
self._filtern()
def _tasten(self):
r = self.root
@@ -405,9 +443,10 @@ class App:
def _aktualisieren(self, behalte_auswahl: bool = True):
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
if self.aid and self.db.abrechnung(self.aid):
a = self.db.abrechnung(self.aid) if self.aid else None
if a:
self.positionen = self.db.positionen(self.aid)
titel = self.db.abrechnung(self.aid)["titel"] + (" – erledigt" if self.db.erledigt(self.aid) else "")
titel = a["titel"] + (" – erledigt" if a["erledigt"] else "")
self.rahmen.set_titel(f"{w.APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}")
else:
@@ -416,20 +455,19 @@ class App:
self.rahmen.set_titel(w.APP_NAME)
self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import")
self._abrechnungsliste()
self._seitenkopf()
self._seitenkopf(a)
self._haendlerliste(behalte_auswahl)
self._tabelle()
def _seitenkopf(self):
"""Titel, Status und Knöpfe über den Kennzahlen."""
a = self.db.abrechnung(self.aid) if self.aid else None
def _seitenkopf(self, a):
"""Titel, Status und Knöpfe über den Kennzahlen (a: Zeile der Abrechnung oder None)."""
if not a:
self.l_status.configure(text="Keine Abrechnung geöffnet".upper() if w.MODERN else "Keine Abrechnung")
self.l_titel.configure(text="Willkommen")
self.l_info.configure(text="Links „+ Neu“ anlegen oder gleich Dateien importieren.")
self.b_erledigt.configure(text="Als erledigt markieren", state="disabled")
return
erledigt = self.db.erledigt(self.aid)
erledigt = a["erledigt"]
dateien = self.db.dateien(self.aid)
status = "erledigt" if erledigt else "offen"
self.l_status.configure(text=f"Abrechnung · {status}".upper() if w.MODERN else f"Abrechnung ({status})")
@@ -463,7 +501,7 @@ class App:
def _suche_ausfuehren(self):
self._suche_geplant = None
self._tabelle()
self._filtern()
def _gefiltert(self) -> list[dict]:
if self.ansicht == ALLE:
@@ -521,6 +559,8 @@ class App:
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}"
def _tabelle(self):
"""Baut die Tabelle für die gewählte Ansicht komplett neu auf (nach Datenänderungen).
Suche und Filter blenden danach nur noch Zeilen aus und ein (_filtern) – kein Neuaufbau."""
if self._edit:
self._edit.destroy()
self._edit = None
@@ -528,49 +568,80 @@ class App:
offen = {iid for iid in t.get_children() if t.item(iid, "open")}
war_leer = not t.get_children()
t.delete(*t.get_children())
such = self.v_suche.get().strip().lower()
pos = self._gefiltert()
if such:
pos = [p for p in pos if such in " ".join(str(p.get(k) or "") for k in (
"rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
"imei")).lower()]
pruef = {"unzugeordnet": lambda p: p["haendler_id"] == UNZUGEORDNET, "manuell": lambda p: p["manuell"],
"doppelt": lambda p: p["doppelt"], "abgerechnet": lambda p: p["abgerechnet"]}.get(self.filter)
if pruef:
pos = [p for p in pos if pruef(p)]
gruppen: dict[str, list[dict]] = defaultdict(list)
for p in exp.sortiert(pos):
for p in exp.sortiert(self._gefiltert()):
gruppen[self._gruppe_von(p)].append(p)
self._gruppen = {}
self._alle_gruppen = {} # giid -> (Name, alle Positionen der Gruppe)
self._zeilen_tags = {}
for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
giid = f"g{gi}"
self._gruppen[giid] = (gname, plist)
so = sum(p["betrag_orig"] for p in plist)
sa = sum(p["betrag"] for p in plist)
proz = {p["prozent"] for p in plist}
if all(_ist_staffel(p) for p in plist):
pz = "Staffel"
else:
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
t.insert("", "end", iid=giid, text=f"{gname} ({len(plist)})", open=(war_leer or giid in offen or bool(such)),
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
for i, p in enumerate(plist):
tags = ["zebra"] if i % 2 else []
self._alle_gruppen[giid] = (gname, plist)
t.insert("", "end", iid=giid, text="", open=(war_leer or giid in offen), tags=("gruppe",))
for p in plist:
tags: tuple[str, ...] = ()
if p["abgerechnet"] or p["doppelt"]:
tags.append("abgerechnet")
tags = ("abgerechnet",)
elif p["manuell"]:
tags.append("manuell")
tags = ("manuell",)
elif p["betrag"] != p["betrag_orig"]:
tags.append("abw")
tags = ("abw",)
self._zeilen_tags[f"p{p['id']}"] = tags
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
fmt(p["betrag_orig"]),
"abgerechnet" if p["abgerechnet"] or p["doppelt"] else
"abgerechnet" if p["abgerechnet"] else "doppelt" if p["doppelt"] else
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
fmt(p["betrag"])),
tags=tuple(tags))
tags=tags)
self._filtern()
def _filtern(self):
"""Suche und Filter anwenden: nicht passende Zeilen werden nur ausgehängt (detach), passende wieder
eingehängt – viel schneller als Löschen und Neuanlegen. Gruppenzeilen und Kennzahlen folgen."""
t = self.ptree
such = self.v_suche.get().strip().lower()
pruef = {"unzugeordnet": lambda p: p["haendler_id"] == UNZUGEORDNET, "manuell": lambda p: p["manuell"],
"doppelt": lambda p: p["doppelt"], "abgerechnet": lambda p: p["abgerechnet"]}.get(self.filter)
def passt(p: dict) -> bool:
if pruef and not pruef(p):
return False
return not such or such in " ".join(str(p.get(k) or "") for k in (
"rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
"imei")).lower()
self._gruppen = {} # giid -> (Name, sichtbare Positionen) – für Auswahl über die Gruppenzeile
pos: list[dict] = []
sichtbar_gi = 0
for giid, (gname, plist) in self._alle_gruppen.items():
treffer = []
for p in plist:
iid = f"p{p['id']}"
if passt(p):
t.move(iid, giid, len(treffer))
t.item(iid, tags=self._zeilen_tags[iid] + (("zebra",) if len(treffer) % 2 else ()))
treffer.append(p)
else:
t.detach(iid)
if not treffer:
t.detach(giid)
continue
t.move(giid, "", sichtbar_gi)
sichtbar_gi += 1
self._gruppen[giid] = (gname, treffer)
pos += treffer
so = sum(p["betrag_orig"] for p in treffer)
sa = sum(p["betrag"] for p in treffer)
proz = {p["prozent"] for p in treffer}
if all(_ist_staffel(p) for p in treffer):
pz = "Staffel"
else:
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
t.item(giid, text=f"{gname} ({len(treffer)})", values=("", "", "", "", "", fmt(so), pz, fmt(sa)))
if such:
t.item(giid, open=True)
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
@@ -616,13 +687,6 @@ class App:
return list(dict.fromkeys(pids))
# ================================================================= Aktionen
def _brauche_abrechnung(self, frage_neu=True) -> bool:
if self.aid:
return True
if frage_neu:
return self.neu()
return False
def _vorschlag_titel(self) -> str:
heute = date.today()
m = heute.month - 1 or 12
@@ -680,6 +744,7 @@ class App:
if self._gesperrt():
return
bericht = []
importiert: list[tuple[int, int, list[str]]] = [] # (Zeile im Bericht, datei_id, Hinweise)
self.root.configure(cursor="watch")
self.root.update()
try:
@@ -701,15 +766,20 @@ class App:
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
continue
n = self.db.importieren(self.aid, erg)
did = self.db.dateien(self.aid)[-1]["id"]
doppelt = sum(1 for p in self.db.positionen(self.aid) if p["datei_id"] == did and p["doppelt"])
hinweise = list(erg.warnungen) + ([f"{doppelt} Pos. bereits abgerechnet (werden nicht erneut "
"exportiert)"] if doppelt else [])
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
"; ".join(hinweise) or "OK"))
importiert.append((len(bericht), self.db.dateien(self.aid)[-1]["id"], list(erg.warnungen)))
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen), ""))
finally:
self.root.configure(cursor="")
self._aktualisieren()
# Doppelte (schon abgerechnet oder schon offen vorhanden) je Datei zählen – einmal für alle Dateien
doppelt: dict[int, int] = defaultdict(int)
for p in self.positionen:
if p["doppelt"]:
doppelt[p["datei_id"]] += 1
for zeile, did, hinweise in importiert:
if doppelt.get(did):
hinweise.append(f"{doppelt[did]} Pos. doppelt bzw. bereits abgerechnet (werden nicht exportiert)")
bericht[zeile] = bericht[zeile][:4] + ("; ".join(hinweise) or "OK",)
unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen:
if p["haendler_id"] == UNZUGEORDNET:
@@ -1071,6 +1141,8 @@ class App:
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
"Bitte zuerst Dateien importieren.")
return
if self._gesperrt(): # erledigt: nichts mehr exportieren (und damit abrechnen)
return
# nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet)
offen = [p for p in self.positionen if p["haendler_id"] > 0 and not p["abgerechnet"] and not p["doppelt"]]
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
@@ -1095,12 +1167,14 @@ class App:
config.save(self.settings)
erzeugt, exportiert = [], []
zeit = res["zeitraum"]
vergeben: set[str] = set() # Dateinamen dieses Exports – gleich lautende Händler überschreiben sich nicht
try:
for hid in res["ids"]:
pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))]
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
summe=res["summe"], null_weglassen=res["null_weglassen"])
summe=res["summe"], null_weglassen=res["null_weglassen"],
vergeben=vergeben)
exportiert += pos
except PermissionError as exc:
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
@@ -1346,13 +1420,13 @@ class App:
try:
self.settings["trenner"] = self.pw.sash_coord(0)[0]
self.settings["fenster"] = self.rahmen.normale_geometrie()
self.settings["maximiert"] = self.rahmen.maximiert
config.save(self.settings)
except (tk.TclError, OSError):
pass
finally:
try:
self.db.close()
if self.db is not None:
self.db.close()
finally:
try:
self.root.destroy()