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
+130
-56
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user