Modernes Design (Standard, Win95 als Legacy), Zeitraum abrechnen, Abrechnung erledigt, Linux-Build
Build / linux (push) Successful in 1m48s
Build / windows-exe (push) Successful in 7m4s

- Design: „modern“ (hell, flach, wie Windows 11, normaler Windows-Rahmen) ist Standard;
  „win95“ bleibt unter Datei > Einstellungen wählbar (Einstellung „design“).
- Export: Zeitraum von–bis (nach Aktivierungsdatum oder Abrechnungszeitraum des Anbieters),
  exportierte Positionen werden als abgerechnet gekennzeichnet (Betrag/Händler eingefroren) und
  per Fingerabdruck auch in späteren Dateien/Abrechnungen nicht noch einmal abgerechnet.
- Datei > Abrechnung erledigt / wieder öffnen: erledigte Abrechnungen sind gegen Änderungen gesperrt.
- DB-Schema v4 (Migration inkl. Fingerabdrücke für alte Positionen).
- Linux-Build (python:3.12-bookworm) als zweiter Pipeline-Job mit Tests und Selbsttest;
  Release-Upload für beide Jobs über build/release_hochladen.sh.
- Behoben: normaler Windows-Rahmen stürzte beim Start ab (Hauptrahmen.nativ fehlte).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kerim AtilganandClaude Opus 5.5 committed 2026-11-02 20:47:00 +01:00
1 parent 5a9d6d19a2
commit da8a7138bd
17 files changed
+829 -80

No files matched your search

+104 -23
View File
@@ -15,7 +15,7 @@ from tkinter import filedialog
from provtool import APP_NAME, config
from provtool import export as exp
from provtool import regeln
from provtool.db import EIGEN, UNZUGEORDNET, Datenbank
from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank
from provtool.gui import assistent as ass
from provtool.gui import dialogs as dlg
from provtool.gui import menue as mn
@@ -70,13 +70,14 @@ class App:
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu
w.design_setzen(self.settings.get("design", "modern"))
w.apply(root)
self.icons = w.Icons(root)
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
root.protocol("WM_DELETE_WINDOW", self.beenden)
root.report_callback_exception = self._fehler_handler
klassisch = bool(self.settings.get("klassischer_rahmen", True))
klassisch = w.DESIGN == "win95" # Win95: eigene Titelleiste; modern: normaler Windows-Rahmen
w.KLASSISCH = klassisch
self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
self.rahmen.set_titel(APP_NAME)
@@ -116,6 +117,7 @@ class App:
d.add_command(label="Dateien importieren...", accelerator="Strg+I", command=self.importieren)
d.add_command(label="Importierte Dateien...", command=self.dateien)
d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren)
d.add_command(label="Abrechnung erledigt / wieder öffnen...", command=self.erledigt_umschalten)
d.add_separator()
d.add_command(label="Datensicherung erstellen...", command=self.sicherung)
d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen)
@@ -152,7 +154,7 @@ class App:
hi.add_command(label=f"Info über {APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
def _toolbar(self):
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised")
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=0 if w.MODERN else 1, relief="raised")
self.tb: dict[str, w.ToolbarButton] = {}
tb.pack(side="top", fill="x")
for text, icon, cmd, tip in (
@@ -188,7 +190,8 @@ class App:
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar.
# Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim
# Loslassen (flüssig, auch mit vielen Positionen).
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised",
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7,
sashrelief="flat" if w.MODERN else "raised",
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
pw.pack(fill="both", expand=True, padx=6, pady=6)
@@ -227,6 +230,7 @@ class App:
self.ptree.tag_configure("manuell", foreground="#0000c0")
self.ptree.tag_configure("abw", foreground="#a00000")
self.ptree.tag_configure("negativ", foreground="#a00000")
self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW)
r2.pack(fill="both", expand=True)
self.ptree.bind("<Double-1>", self._doppelklick)
self.ptree.bind("<Button-3>", self._kontextmenue)
@@ -241,7 +245,7 @@ class App:
self.v_alle_auf = tk.BooleanVar(value=True)
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack(
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend · Grau = abgerechnet", fg=w.SHADOW).pack(
side="left", padx=8)
pw.add(self.f_p, minsize=500)
@@ -272,7 +276,7 @@ class App:
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
if self.aid and self.db.abrechnung(self.aid):
self.positionen = self.db.positionen(self.aid)
titel = self.db.abrechnung(self.aid)["titel"]
titel = self.db.abrechnung(self.aid)["titel"] + (" – erledigt" if self.db.erledigt(self.aid) else "")
self.rahmen.set_titel(f"{APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}")
else:
@@ -380,7 +384,9 @@ class App:
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
for p in plist:
tags = []
if p["manuell"]:
if p["abgerechnet"] or p["doppelt"]:
tags.append("abgerechnet")
elif p["manuell"]:
tags.append("manuell")
elif p["betrag"] != p["betrag_orig"]:
tags.append("abw")
@@ -389,6 +395,7 @@ class App:
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
"manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
fmt(p["betrag"])),
tags=tuple(tags))
@@ -470,7 +477,7 @@ class App:
if not dateien:
return
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
if not self._brauche_abrechnung():
if not self._brauche_abrechnung() or self._gesperrt():
return
bericht = []
self.root.configure(cursor="watch")
@@ -494,8 +501,12 @@ 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(erg.warnungen) or "OK"))
"; ".join(hinweise) or "OK"))
finally:
self.root.configure(cursor="")
self._aktualisieren()
@@ -567,7 +578,45 @@ class App:
ps = [p for p in self.positionen if p["id"] in set(pids)]
return sel, pids, ps
def _gesperrt(self) -> bool:
"""Erledigte Abrechnung: keine Änderungen (Hinweis statt stillem Nichtstun)."""
if self.aid and self.db.erledigt(self.aid):
w.info(self.root, "Abrechnung erledigt", "Diese Abrechnung ist als erledigt gekennzeichnet und kann "
"nicht mehr geändert werden.\n\nZum Ändern: Datei > Abrechnung erledigt / wieder öffnen.")
return True
return False
def erledigt_umschalten(self):
if not self.aid:
return
titel = self.db.abrechnung(self.aid)["titel"]
if self.db.erledigt(self.aid):
if not w.frage(self.root, "Wieder öffnen", f"Abrechnung „{titel}“ wieder öffnen?"):
return
auch = w.frage(self.root, "Wieder öffnen", "Sollen die Positionen auch wieder als NICHT abgerechnet "
"gelten (z. B. um sie neu zu exportieren)?", ja="Ja, freigeben", nein="Nein",
default_ja=False)
self.db.erledigt_setzen(self.aid, False, positionen_aufheben=auch)
else:
if not w.frage(self.root, "Abrechnung erledigt",
f"Abrechnung „{titel}“ als erledigt kennzeichnen?\n\nAlle Händler-Positionen gelten dann "
"als abgerechnet und werden auch in späteren Dateien nicht noch einmal abgerechnet. "
"Die Abrechnung kann danach nicht mehr geändert werden."):
return
self.db.erledigt_setzen(self.aid, True)
self._aktualisieren()
def abgerechnet_aufheben(self):
if self._gesperrt():
return
_, pids, _ = self._auswahl_info()
n = self.db.abrechnung_aufheben(pids)
self._aktualisieren()
self.status.set("info", f" {n} Position(en) wieder offen")
def prozent(self):
if self._gesperrt():
return
sel, pids, ps = self._auswahl_info()
if not ps:
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
@@ -601,6 +650,8 @@ class App:
self.ptree.see(vorhanden[0])
def zuruecksetzen(self):
if self._gesperrt():
return
sel, pids, ps = self._auswahl_info()
if not ps:
return
@@ -610,7 +661,7 @@ class App:
self._auswahl_wiederherstellen(sel)
def neu_berechnen(self):
if not self.aid:
if not self.aid or self._gesperrt():
return
manuell = sum(1 for p in self.positionen if p["manuell"])
auch = False
@@ -638,6 +689,8 @@ class App:
self.betrag_bearbeiten(iid)
def betrag_bearbeiten(self, iid: str | None = None):
if self._gesperrt():
return
if iid is None:
sel = [i for i in self.ptree.selection() if i.startswith("p")]
if len(sel) != 1:
@@ -652,6 +705,10 @@ class App:
bbox = self.ptree.bbox(iid, "ausz")
pid = int(iid[1:])
p = next(x for x in self.positionen if x["id"] == pid)
if p["abgerechnet"]:
w.info(self.root, "Betrag ändern", "Diese Position ist bereits abgerechnet.\n\n"
"Zum Ändern: Rechtsklick > Kennzeichnung „abgerechnet“ aufheben.")
return
if not bbox:
# Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld
self._betrag_dialog(p)
@@ -748,6 +805,9 @@ class App:
if any(p["haendler_id"] == EIGEN for p in ps):
m.add_separator()
m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden)
if any(p["abgerechnet"] for p in ps):
m.add_separator()
m.add_command(label="Kennzeichnung „abgerechnet“ aufheben", command=self.abgerechnet_aufheben)
m.tk_popup(e.x_root, e.y_root)
# ----- Eigene Kennungen (eigene Filiale) ausblenden
@@ -780,6 +840,8 @@ class App:
self._aktualisieren()
def haendler_zuweisen(self, automatisch: bool = False):
if self._gesperrt():
return
sel, pids, _ = self._auswahl_info()
if not pids:
w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.")
@@ -795,6 +857,8 @@ class App:
self._aktualisieren()
def _null(self):
if self._gesperrt():
return
sel, pids, _ = self._auswahl_info()
self.db.prozent_anwenden(pids, 0.0)
self.db.touch(self.aid)
@@ -807,14 +871,19 @@ class App:
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
"Bitte zuerst Dateien importieren.")
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])
for p in self.positionen:
if p["haendler_id"] > 0: # nicht: nicht zugeordnet / eigene Kennungen
stat[p["haendler_id"]][0] += 1
stat[p["haendler_id"]][1] += p["betrag"]
for p in offen:
stat[p["haendler_id"]][0] += 1
stat[p["haendler_id"]][1] += p["betrag"]
if not stat:
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
if any(p["haendler_id"] > 0 for p in self.positionen):
w.info(self.root, "Export", "Alle Positionen dieser Abrechnung sind bereits abgerechnet.\n\n"
"Zum erneuten Export: Datei > Abrechnung erledigt / wieder öffnen (Positionen freigeben).")
else:
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
return
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in
sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
@@ -824,19 +893,27 @@ class App:
if not res:
return
config.save(self.settings)
erzeugt = []
erzeugt, exportiert = [], []
zeit = res["zeitraum"]
try:
for hid in res["ids"]:
pos = [p for p in self.positionen if p["haendler_id"] == hid]
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"])
exportiert += pos
except PermissionError as exc:
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
"Ist sie vielleicht noch in Excel geöffnet?")
return
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else ""))
hinweis = ""
if res["kennzeichnen"] and exportiert:
n = self.db.abrechnen([p["id"] for p in exportiert])
hinweis = f"\n\n{n} Position(en) als abgerechnet gekennzeichnet."
self._aktualisieren()
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}"
+ (f"\nZeitraum: {zeit.text()}" if zeit else "") + "\n\n"
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "") + hinweis)
if res["oeffnen"]:
_oeffne_ordner(res["ordner"])
@@ -891,10 +968,10 @@ class App:
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
if not res:
return
if res["klassisch"] != bool(self.settings.get("klassischer_rahmen", True)):
self.settings["klassischer_rahmen"] = res["klassisch"]
if res["design"] != w.DESIGN:
self.settings["design"] = res["design"]
config.save(self.settings)
w.info(self.root, "Einstellungen", "Die Änderung der Titelleiste wird beim nächsten Programmstart wirksam.")
w.info(self.root, "Einstellungen", "Das neue Design wird beim nächsten Programmstart wirksam.")
if res["pfad"] and Path(res["pfad"]) != self.db.path:
try:
self.db_wechseln(res["pfad"])
@@ -919,6 +996,10 @@ class App:
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat (freenet\n"
" und Vodafone zusammen, Airtime als eine Summenzeile) und eine Airtime-Liste.\n"
" Positionen mit 0,00 € werden weggelassen.\n\n"
"Zeitraum: Im Export-Dialog „Nur von … bis …“ (oder „Vormonat“). Exportierte Positionen\n"
"gelten als abgerechnet (grau) und werden nicht noch einmal abgerechnet – auch nicht, wenn\n"
"sie in einer späteren Datei wieder auftauchen. Datei > Abrechnung erledigt sperrt eine\n"
"fertige Abrechnung.\n\n"
"Welche Provisionsart Airtime bzw. Jahresbonus ist, wird automatisch erkannt –\n"
"umstellbar unter Händler > Provisionsarten.\n\n"
"Alles wird automatisch in der Datenbank gespeichert.")