Modernes Design (Standard, Win95 als Legacy), Zeitraum abrechnen, Abrechnung erledigt, Linux-Build
- 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:
1 parent
97c73b82c7
commit
851fd6600a
17 files changed
+829
-80
No files matched your search
+104
-23
@@ -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.")
|
||||
|
||||
Reference in new issue
Block a user