10 Commits
Author SHA1 Message Date
Kerim AtilganandClaude Opus 5.5 2275b92188 Resize-Zeiger blieb im Inhalt hängen (vererbt von der Trennlinie): Bereiche mit eigenem Zeiger; Version 1.8.1
Build / linux (push) Successful in 1m55s
Build / windows-exe (push) Successful in 8m7s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:33:09 +01:00
Kerim AtilganandClaude Opus 5.5 b85a75857e Updater: Prüfung beim Start und unter Hilfe, Setup herunterladen, DB sichern, still installieren, Neustart
Build / linux (push) Successful in 1m56s
Build / windows-exe (push) Successful in 8m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:18:22 +01:00
Kerim AtilganandClaude Opus 5.5 968c68ac1a 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>
2026-11-03 14:58:51 +01:00
Kerim AtilganandClaude Opus 5.5 72677ebed7 Selbsttest: Win95-Szenario im normalen Fenster (Rahmenprüfung gilt nicht maximiert)
Build / linux (push) Successful in 1m55s
Build / windows-exe (push) Successful in 8m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:45:38 +01:00
Kerim AtilganandClaude Opus 5.5 03b48f063c Maustest startet im normalen Fenster (Programm sonst maximiert)
Build / linux (push) Successful in 1m53s
Build / windows-exe (push) Failing after 7m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:36:43 +01:00
Kerim AtilganandClaude Opus 5.5 c332c5a487 Programm startet maximiert; Version 1.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:23:01 +01:00
Kerim AtilganandClaude Opus 5.5 c96cf1bd94 Test: Filter-Knopf direkt auslösen (Wine)
Build / linux (push) Successful in 1m51s
Build / windows-exe (push) Successful in 7m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:14:02 +01:00
Kerim AtilganandClaude Opus 5.5 c35356aeb9 Win95-Design an das neue Hauptfenster angepasst, Version 1.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:04:14 +01:00
Kerim AtilganandClaude Opus 5.5 f296483fdc Neues Design im Stil der Handy-Insel-Webtools: Kopfzeile, Seitenleiste, Kennzahlen, Filter, hell/dunkel, IBM Plex
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:55:17 +01:00
Kerim AtilganandClaude Opus 5.5 2600eea574 Abrechnungen links im Hauptfenster (Klick öffnet), Import fragt nach der Ziel-Abrechnung
Build / linux (push) Successful in 1m50s
Build / windows-exe (push) Successful in 7m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:35:16 +01:00
24 changed files with 1580 additions and 320 deletions

No files matched your search

+17 -4
View File
@@ -76,7 +76,7 @@ Welche Provisionsart Airtime bzw. Jahresbonus ist, erkennt das Programm an den T
„AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter „AirTime Provision“, „Umsatzbeteiligung“, „Jahresbonus“); umstellbar je Provisionsart unter
*Händler ▸ Provisionsarten…*. *Händler ▸ Provisionsarten…*.
Das Hauptfenster ist unter Windows ein echtes Programmfenster mit Win95-Optik: an den Bildschirmrand Das Programm startet maximiert. Das Hauptfenster ist unter Windows ein echtes Programmfenster: an den Bildschirmrand
ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren
wie gewohnt. wie gewohnt.
@@ -86,8 +86,20 @@ gekennzeichnet (grau) und nie wieder exportiert – auch nicht, wenn dieselbe Po
anderen Datei oder Abrechnung auftaucht. Versehentlich? Rechtsklick ▸ *Kennzeichnung „abgerechnet“ aufheben*. anderen Datei oder Abrechnung auftaucht. Versehentlich? Rechtsklick ▸ *Kennzeichnung „abgerechnet“ aufheben*.
*Datei ▸ Abrechnung erledigt / wieder öffnen* sperrt eine fertige Abrechnung gegen Änderungen. *Datei ▸ Abrechnung erledigt / wieder öffnen* sperrt eine fertige Abrechnung gegen Änderungen.
**Design:** Standard ist ein modernes, helles Design (wie Windows 11); das Windows-95-Design gibt es weiter **Doppelte Positionen:** Taucht dieselbe Position mehrfach auf (z. B. in Wochen- und Monatsdatei oder in zwei
unter *Datei ▸ Einstellungen ▸ Darstellung* (wirkt nach Neustart). Abrechnungen), zählt nur die zuerst importierte; die anderen stehen als *doppelt* in der Liste und werden
weder exportiert noch bei der Jahresbonus-Staffel mitgezählt. Abgerechnete Positionen lassen sich erst
löschen, wenn ihre Kennzeichnung „abgerechnet“ aufgehoben ist.
**Design:** Standard ist das Design der Handy-Insel-Webtools (Lila, Schrift IBM Plex – liegt dem Programm bei):
Kopfzeile mit Menüs und Suche, links Abrechnungen und Händler, oben Kennzahlen, über der Tabelle Filter
(nicht zugeordnet, von Hand geändert, doppelt, bereits abgerechnet). Oben rechts „Dunkel“/„Hell“ schaltet
das dunkle Design um. Das Windows-95-Design gibt es weiter unter *Datei ▸ Einstellungen ▸ Darstellung*.
**Updates:** Das Programm schaut beim Start auf Gitea nach einer neueren Version (abschaltbar unter
*Datei ▸ Einstellungen*, von Hand unter *Hilfe ▸ Nach Updates suchen*). Die per Setup installierte
Windows-Version lädt das neue Setup, sichert die Datenbank (`vor-update-<alte Version>.db` im Einstellungsordner),
installiert still und startet neu. Die einzelne .exe und Linux öffnen stattdessen die Download-Seite.
**Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten **Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`. (ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
@@ -129,7 +141,8 @@ Aufbau:
| `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen | | `provtool/db.py` | SQLite: Händler, Kennungen, Regeln, Abrechnungen, Positionen |
| `provtool/export.py` | Excel-Export im Zielformat | | `provtool/export.py` | Excel-Export im Zielformat |
| `provtool/money.py` | Beträge (intern immer in Cent) | | `provtool/money.py` | Beträge (intern immer in Cent) |
| `provtool/gui/` | Oberfläche (Tkinter im Win95-Stil) | | `provtool/gui/` | Oberfläche (Tkinter; Designs hell, dunkel, Win95) |
| `provtool/gui/schriften/` | IBM Plex (SIL Open Font License, siehe `OFL.txt`) |
| `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung | | `provtool/gui/rahmen.py` | selbst gezeichnete Titelleiste, Menüleiste, Größenänderung |
| `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages | | `provtool/gui/assistent.py` | Einrichtungs-Assistent, Rundgang, Tipp des Tages |
+8
View File
@@ -43,3 +43,11 @@ Name: "{autodesktop}\Provisions-Manager"; Filename: "{app}\Provisionsmanager.exe
[Run] [Run]
Filename: "{app}\Provisionsmanager.exe"; Description: "{cm:LaunchProgram,Provisions-Manager}"; Flags: nowait postinstall skipifsilent Filename: "{app}\Provisionsmanager.exe"; Description: "{cm:LaunchProgram,Provisions-Manager}"; Flags: nowait postinstall skipifsilent
; Update aus dem Programm (/VERYSILENT /NEUSTART=1): danach wieder starten
Filename: "{app}\Provisionsmanager.exe"; Flags: nowait; Check: NachUpdateStarten
[Code]
function NachUpdateStarten: Boolean;
begin
Result := ExpandConstant('{param:NEUSTART|0}') = '1';
end;
+1 -1
View File
@@ -16,7 +16,7 @@ home = tempfile.mkdtemp()
os.environ["PROVTOOL_HOME"] = home os.environ["PROVTOOL_HOME"] = home
with open(os.path.join(home, "einstellungen.json"), "w") as f: with open(os.path.join(home, "einstellungen.json"), "w") as f:
json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100", json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100",
"design": "win95"}, f) # nur Win95 hat den eigenen Fensterrahmen "design": "win95", "vollbild": False}, f) # nur Win95 hat den eigenen Fensterrahmen
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from provtool.gui import app as A # noqa: E402 from provtool.gui import app as A # noqa: E402
+2 -1
View File
@@ -12,7 +12,8 @@ a = Analysis(
[os.path.join(ROOT, "build", "start.py")], [os.path.join(ROOT, "build", "start.py")],
pathex=[ROOT], pathex=[ROOT],
hiddenimports=["openpyxl", "xlrd"], hiddenimports=["openpyxl", "xlrd"],
datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))], datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder")),
(os.path.join(ROOT, "provtool", "gui", "schriften"), os.path.join("provtool", "gui", "schriften"))],
excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"], excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"],
noarchive=False, noarchive=False,
) )
+1 -1
View File
@@ -1,5 +1,5 @@
"""Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach."""
__version__ = "1.5.0" __version__ = "1.8.1"
APP_NAME = "Provisions-Manager 95" APP_NAME = "Provisions-Manager 95"
APP_ORG = "Handy-Insel Miesbach" APP_ORG = "Handy-Insel Miesbach"
+15 -1
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import os import os
import shutil
from pathlib import Path from pathlib import Path
APP_DIR_NAME = "Provisionsmanager95" APP_DIR_NAME = "Provisionsmanager95"
@@ -41,18 +42,31 @@ DEFAULTS = {
def load() -> dict: def load() -> dict:
"""Einstellungen lesen. Ist die Datei unlesbar (z. B. Absturz beim Schreiben), wird sie als
*.defekt.json aufbewahrt und unter "defekt" gemeldet – statt still mit Standardwerten weiterzumachen
(dabei ginge z. B. der Pfad zur Datenbank auf dem Netzlaufwerk verloren)."""
data = dict(DEFAULTS) data = dict(DEFAULTS)
f = _settings_file() f = _settings_file()
if f.exists(): if f.exists():
try: try:
data.update(json.loads(f.read_text(encoding="utf-8"))) data.update(json.loads(f.read_text(encoding="utf-8")))
except (OSError, ValueError): except (OSError, ValueError):
sicherung = f.with_suffix(".defekt.json")
try:
shutil.copyfile(f, sicherung)
except OSError:
pass pass
data["defekt"] = str(sicherung)
return data return data
def save(data: dict) -> None: def save(data: dict) -> None:
_settings_file().write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") """Atomar: erst in eine Hilfsdatei, dann umbenennen – ein Absturz hinterlässt nie eine halbe Datei."""
data = {k: v for k, v in data.items() if k != "defekt"}
f = _settings_file()
tmp = f.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, f)
def db_path(settings: dict | None = None) -> Path: def db_path(settings: dict | None = None) -> Path:
+69 -25
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import os
import shutil import shutil
import sqlite3 import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
@@ -134,7 +135,8 @@ def fingerabdruecke(zeilen: list[dict]) -> list[str]:
class AbrechnungErledigt(Exception): 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: def ist_grossgeschrieben(name: str | None) -> bool:
@@ -168,8 +170,6 @@ class Datenbank:
self.con.execute("PRAGMA foreign_keys = ON") self.con.execute("PRAGMA foreign_keys = ON")
self.con.executescript(SCHEMA) self.con.executescript(SCHEMA)
self._migrieren() self._migrieren()
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
self.con.commit()
def _migrieren(self): def _migrieren(self):
spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")} 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: if "erledigt" not in a_spalten:
self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT") self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT")
self._fingerabdruecke_nachtragen() 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() self.con.commit()
def _fingerabdruecke_nachtragen(self): def _fingerabdruecke_nachtragen(self):
@@ -215,12 +216,26 @@ class Datenbank:
@staticmethod @staticmethod
def restore(quelle: str | Path, ziel: str | Path) -> None: 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: 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: finally:
test.close() con.close()
shutil.copyfile(quelle, ziel) Datenbank(probe).close() # öffnet und migriert – wirft, wenn das nicht geht
os.replace(probe, ziel)
finally:
probe.unlink(missing_ok=True)
# ------------------------------------------------------------------ Händler # ------------------------------------------------------------------ Händler
@staticmethod @staticmethod
@@ -368,16 +383,6 @@ class Datenbank:
h = self.haendler(hid) if hid > 0 else None h = self.haendler(hid) if hid > 0 else None
return h.staffel if h and h.staffel is not None else self.standard_staffel() 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 # ------------------------------------------------------------------ Abrechnungen
def abrechnung_neu(self, titel: str) -> int: def abrechnung_neu(self, titel: str) -> int:
with self.con: with self.con:
@@ -400,9 +405,20 @@ class Datenbank:
self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid)) self.con.execute("UPDATE abrechnung SET titel=?, geaendert=? WHERE id=?", (titel, _now(), aid))
def abrechnung_loeschen(self, aid: int) -> None: def abrechnung_loeschen(self, aid: int) -> None:
self._keine_abgerechneten("abrechnung_id=?", aid)
with self.con: with self.con:
self.con.execute("DELETE FROM abrechnung WHERE id=?", (aid,)) 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]: def dateien(self, aid: int) -> list[sqlite3.Row]:
return list(self.con.execute("SELECT * FROM abrechnung_datei WHERE abrechnung_id=? ORDER BY id", (aid,))) 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: def datei_entfernen(self, datei_id: int) -> None:
r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone() r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone()
if r: if not r:
return
self._nicht_erledigt(r[0]) self._nicht_erledigt(r[0])
self._keine_abgerechneten("datei_id=?", datei_id)
with self.con: with self.con:
self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,)) self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,))
self.con.execute("DELETE FROM abrechnung_datei WHERE 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: def importieren(self, aid: int, erg: ImportErgebnis) -> int:
self._nicht_erledigt(aid) self._nicht_erledigt(aid)
@@ -450,7 +469,19 @@ class Datenbank:
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py).""" """Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py)."""
finde = self.zuordner() finde = self.zuordner()
fest = self.art_typen() 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 = [] out = []
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)): for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
d = dict(r) d = dict(r)
@@ -458,9 +489,17 @@ class Datenbank:
d["haendler_id"] = d["abgerechnet_hid"] # abgerechnet: Zuordnung eingefroren d["haendler_id"] = d["abgerechnet_hid"] # abgerechnet: Zuordnung eingefroren
else: else:
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"]) 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) d["typ"] = regeln.typ_von(d, fest)
# schon anderswo abgerechnet (dieselbe Position in einer früheren Datei/Abrechnung)? d["doppelt"] = None
d["doppelt"] = schon.get(d["fp"]) if d["fp"] and not d["abgerechnet"] else 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) out.append(d)
return out return out
@@ -493,10 +532,10 @@ class Datenbank:
@staticmethod @staticmethod
def karten_zaehlen(positionen: list[dict]) -> dict[tuple[int, str], int]: 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] = {} karten: dict[tuple[int, str], int] = {}
for p in positionen: 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"]) k = (p["haendler_id"], p["anbieter"])
karten[k] = karten.get(k, 0) + (1 if p["betrag_orig"] > 0 else -1) 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()} return {k: max(0, n) for k, n in karten.items()}
@@ -599,15 +638,20 @@ class Datenbank:
ids = set(pids) ids = set(pids)
n = 0 n = 0
for aid in self._abrechnungen_von(list(ids)): for aid in self._abrechnungen_von(list(ids)):
if self.erledigt(aid):
continue # erledigt: nichts wird nachträglich abgerechnet
titel = self.abrechnung(aid)["titel"] titel = self.abrechnung(aid)["titel"]
with self.con: with self.con:
for p in self.positionen(aid): for p in self.positionen(aid):
if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0: if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0:
continue 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=?", self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE id=?",
(wann, p["haendler_id"], p["id"])) (wann, p["haendler_id"], p["id"]))
if p["fp"]: 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)) (p["fp"], wann, titel))
n += 1 n += 1
return n return n
+13 -2
View File
@@ -101,6 +101,8 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
] ]
for c, v in enumerate(werte, start=1): for c, v in enumerate(werte, start=1):
cell = ws.cell(zeile, c, v) cell = ws.cell(zeile, c, v)
if isinstance(v, str) and v.startswith("="):
cell.data_type = "s" # Text aus der Abrechnung bleibt Text – nie eine Formel
cell.font = bold if p.get("_summe") and c in (2, 11) else font cell.font = bold if p.get("_summe") and c in (2, 11) else font
ws.cell(zeile, 4).number_format = "DD.MM.YYYY" ws.cell(zeile, 4).number_format = "DD.MM.YYYY"
if isinstance(werte[0], date): if isinstance(werte[0], date):
@@ -130,9 +132,11 @@ def schreibe_xlsx(ziel: str | Path, positionen: list[dict], *, kopfzeile: bool =
def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *, def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list[dict], titel: str, *,
pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True, pro_anbieter: bool = False, kopfzeile: bool = False, summe: bool = True,
null_weglassen: bool = True) -> list[Path]: null_weglassen: bool = True, vergeben: set[str] | None = None) -> list[Path]:
"""Schreibt die Abrechnung eines Händlers (bei pro_anbieter je Anbieter eine) und – falls es Airtime """Schreibt die Abrechnung eines Händlers (bei pro_anbieter je Anbieter eine) und – falls es Airtime
gibt – die Airtime-Liste. In der Abrechnung steht Airtime nur als eine Summenzeile. gibt – die Airtime-Liste. In der Abrechnung steht Airtime nur als eine Summenzeile.
vergeben: Dateinamen (klein geschrieben), die in diesem Export schon geschrieben wurden – gleich
lautende Namen (z. B. Händler „A/B“ und „A:B“) bekommen „ (2)“ statt die Datei zu überschreiben.
Gibt die erzeugten Pfade zurück.""" Gibt die erzeugten Pfade zurück."""
ordner = Path(ordner) ordner = Path(ordner)
if null_weglassen: if null_weglassen:
@@ -142,9 +146,16 @@ def exportiere_haendler(ordner: str | Path, haendler_name: str, positionen: list
gruppen: dict[str, list[dict]] = {} gruppen: dict[str, list[dict]] = {}
for p in positionen: for p in positionen:
gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p) gruppen.setdefault(p["anbieter"] if pro_anbieter else "", []).append(p)
vergeben = set() if vergeben is None else vergeben
def name(*teile) -> str: def name(*teile) -> str:
return dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t)) + ".xlsx" basis = dateiname(" - ".join(t for t in (haendler_name,) + teile + (titel,) if t))
for n in range(1, 1000):
kandidat = basis + (f" ({n})" if n > 1 else "") + ".xlsx"
if kandidat.lower() not in vergeben:
vergeben.add(kandidat.lower())
return kandidat
raise ValueError(f"Kein freier Dateiname für {basis}")
out = [] out = []
for anbieter, pos in sorted(gruppen.items()): for anbieter, pos in sorted(gruppen.items()):
+528 -138
View File
@@ -5,14 +5,17 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import tempfile
import threading
import tkinter as tk import tkinter as tk
import traceback import traceback
import webbrowser
from collections import defaultdict from collections import defaultdict
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from tkinter import filedialog from tkinter import filedialog
from provtool import config from provtool import __version__, config, updater
from provtool import export as exp from provtool import export as exp
from provtool import regeln from provtool import regeln
from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank
@@ -61,11 +64,14 @@ class App:
def __init__(self, root: tk.Tk): def __init__(self, root: tk.Tk):
self.root = root self.root = root
self.settings = config.load() 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.aid: int | None = None
self.positionen: list[dict] = [] self.positionen: list[dict] = []
self.haendler_namen: dict[int, str] = {} self.haendler_namen: dict[int, str] = {}
self.ansicht: int = ALLE 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._edit: tk.Entry | None = None
self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen) self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen)
self._beendet = False # Datenbank und Fenster sind zu self._beendet = False # Datenbank und Fenster sind zu
@@ -82,9 +88,13 @@ class App:
self.rahmen = rh.Hauptrahmen(root, w.APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch) self.rahmen = rh.Hauptrahmen(root, w.APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch)
self.rahmen.set_titel(w.APP_NAME) self.rahmen.set_titel(w.APP_NAME)
self.inhalt = self.rahmen.inhalt self.inhalt = self.rahmen.inhalt
if w.DUNKEL:
from provtool.gui import win32
win32.dunkle_titelleiste(root)
self._menue() self.neustart = False # Design umgeschaltet: main() baut das Fenster neu auf
self._toolbar() self.filter = "alle"
self._kopf()
self._body() self._body()
self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)]) self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
griff = self.rahmen.griff(self.status) griff = self.rahmen.griff(self.status)
@@ -92,24 +102,103 @@ class App:
griff.pack(side="right", anchor="se") griff.pack(side="right", anchor="se")
self.status.pack(side="bottom", fill="x") self.status.pack(side="bottom", fill="x")
self._tasten() self._tasten()
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("maximiert", False)) # startet maximiert („vollbild“, abschaltbar nur für Tests); „fenster“ = Größe nach dem Wiederherstellen
self.rahmen.geometrie_setzen(self.settings.get("fenster"), self.settings.get("vollbild", True))
if self.settings.get("trenner"): if self.settings.get("trenner"):
root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0)) root.after(200, lambda: self.pw.sash_place(0, int(self.settings["trenner"]), 0))
self.db = self._db_oeffnen()
abr = self.db.abrechnungen() abr = self.db.abrechnungen()
if abr: if abr:
self.oeffnen(abr[0]["id"]) self.oeffnen(abr[0]["id"])
else: else:
self._aktualisieren() 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) root.after(400, self.assistent)
elif self.settings.get("tipps_beim_start", True): elif self.settings.get("tipps_beim_start", True):
root.after(500, self.tipp_des_tages) 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 # ================================================================= Aufbau
def _menue(self): def _kopf(self):
self.menueleiste = mn.Menueleiste(self.inhalt, self.root) """Kopfzeile wie in den Webtools: Logo, Menüs, rechts Suche (modern auch Hell/Dunkel)."""
self.menueleiste.pack(side="top", fill="x") kopf = tk.Frame(self.inhalt, bg=w.FACE)
kopf.pack(side="top", fill="x")
if w.MODERN:
w.trenner(self.inhalt, vertikal=False).pack(side="top", fill="x")
innen = tk.Frame(kopf, bg=w.FACE)
innen.pack(fill="x", padx=(18, 14) if w.MODERN else 0, pady=10 if w.MODERN else 0)
if w.MODERN:
w.logo(innen).pack(side="left")
namen = tk.Frame(innen, bg=w.FACE)
namen.pack(side="left", padx=(10, 22))
tk.Label(namen, text=w.APP_NAME, font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold")).pack(anchor="w")
tk.Label(namen, text="Handy-Insel", fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(anchor="w")
self._menue(innen)
if w.MODERN:
self.b_design = w.knopf(innen, "Hell" if w.DUNKEL else "Dunkel", self.design_umschalten)
self.b_design.pack(side="right", padx=(8, 0))
w.Tooltip(self.b_design, "Zwischen hellem und dunklem Design wechseln")
self.v_suche = tk.StringVar()
if w.MODERN: # Suchfeld mit Rahmen, Hinweis und Tastenkürzel
sf = tk.Frame(innen, bg=w.WHITE, highlightthickness=1, highlightbackground=w.RAND2,
highlightcolor=w.NAVY)
sf.pack(side="right")
tk.Label(sf, text="Suchen", bg=w.WHITE, fg=w.SHADOW).pack(side="left", padx=(10, 2))
e = tk.Entry(sf, textvariable=self.v_suche, width=28, highlightthickness=0, bd=4)
e.pack(side="left")
tk.Label(sf, text="Strg F", bg=w.WHITE, fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(
side="left", padx=(2, 10))
e.bind("<FocusIn>", lambda _e: sf.configure(highlightbackground=w.NAVY), add="+")
e.bind("<FocusOut>", lambda _e: sf.configure(highlightbackground=w.RAND2), add="+")
else:
sf = tk.Frame(innen, bg=w.FACE)
sf.pack(side="right", padx=8)
tk.Label(sf, text="Suchen:").pack(side="left")
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
e.pack(side="left", padx=4)
self._suche_geplant = None
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
self._suchfeld = e
self._suchrahmen = sf
def _menue(self, master):
self.menueleiste = mn.Menueleiste(master, self.root)
self.menueleiste.pack(side="left")
d = self.menueleiste.menue("Datei") d = self.menueleiste.menue("Datei")
d.add_command(label="Neue Abrechnung...", accelerator="Strg+N", command=self.neu) d.add_command(label="Neue Abrechnung...", accelerator="Strg+N", command=self.neu)
d.add_command(label="Abrechnung öffnen...", accelerator="Strg+O", command=self.abrechnung_waehlen) d.add_command(label="Abrechnung öffnen...", accelerator="Strg+O", command=self.abrechnung_waehlen)
@@ -144,110 +233,197 @@ class App:
h.add_command(label="Provisionsarten (Airtime / Jahresbonus)...", command=self.provisionsarten) h.add_command(label="Provisionsarten (Airtime / Jahresbonus)...", command=self.provisionsarten)
h.add_command(label="Standard-Staffel Jahresbonus...", command=self.standard_staffel) h.add_command(label="Standard-Staffel Jahresbonus...", command=self.standard_staffel)
hi = self.menueleiste.menue("?", underline=None) self.hilfe_menue = "Hilfe" if w.MODERN else "?"
hi = self.menueleiste.menue(self.hilfe_menue, underline=None)
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
hi.add_command(label="Rundgang durch das Programm", command=self.rundgang) hi.add_command(label="Rundgang durch das Programm", command=self.rundgang)
hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages) hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages)
hi.add_command(label="Nach Updates suchen...", command=self.updates_pruefen)
hi.add_separator() hi.add_separator()
hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent) hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent)
hi.add_separator() hi.add_separator()
hi.add_command(label=f"Info über {w.APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen()) hi.add_command(label=f"Info über {w.APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen())
def _toolbar(self): FILTER = (("alle", "Alle"), ("unzugeordnet", "Nicht zugeordnet"), ("manuell", "Von Hand geändert"),
tb = tk.Frame(self.inhalt, bg=w.FACE, bd=0 if w.MODERN else 1, relief="raised") ("doppelt", "Doppelt"), ("abgerechnet", "Bereits abgerechnet"))
self.tb: dict[str, w.ToolbarButton] = {}
tb.pack(side="top", fill="x")
for text, icon, cmd, tip in (
("Neu", "neu", self.neu, "Neue Abrechnung anlegen (Strg+N)"),
("Öffnen", "oeffnen", self.abrechnung_waehlen, "Gespeicherte Abrechnung öffnen (Strg+O)"),
(None, None, None, None),
("Import", "import", self.importieren, "CSV-/Excel-Dateien einlesen (Strg+I)"),
("Export", "export", self.exportieren, "Händler-Abrechnungen als Excel speichern (Strg+E)"),
(None, None, None, None),
("Händler", "haendler", self.haendler, "Händler und Kennungen verwalten (Strg+H)"),
("Prozent", "prozent", self.prozent, "Auszahlung für Auswahl in Prozent festlegen (F4)"),
("Betrag", "rechner", self.betrag_bearbeiten, "Betrag der Position ändern (F2)"),
):
if text is None:
w.trenner(tb).pack(side="left", fill="y", padx=4, pady=3)
continue
self.tb[text] = w.ToolbarButton(tb, text, self.icons(icon), cmd, tip)
self.tb[text].pack(side="left", padx=1, pady=1)
self.v_suche = tk.StringVar()
sf = tk.Frame(tb, bg=w.FACE)
sf.pack(side="right", padx=8)
tk.Label(sf, text="Suchen:").pack(side="left")
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
e.pack(side="left", padx=4)
self._suche_geplant = None
self.v_suche.trace_add("write", lambda *_: self._suche_planen())
self._suchfeld = e
self._suchrahmen = sf
def _body(self): def _body(self):
desk = tk.Frame(self.inhalt, bg=w.TEAL) m = w.MODERN
desk = tk.Frame(self.inhalt, bg=w.BG)
desk.pack(side="top", fill="both", expand=True) desk.pack(side="top", fill="both", expand=True)
# Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar. # Trennlinie zwischen den Spalten, mit der Maus ziehbar. Beim Ziehen wandert nur eine Linie mit;
# Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim # neu aufgebaut wird erst beim Loslassen (flüssig, auch mit vielen Positionen).
# Loslassen (flüssig, auch mit vielen Positionen). pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.BG, sashwidth=6 if m else 7,
pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="flat" if m else "raised",
sashrelief="flat" if w.MODERN else "raised",
bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow") bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow")
pw.pack(fill="both", expand=True, padx=6, pady=6) pw.pack(fill="both", expand=True, padx=0 if m else 6, pady=0 if m else 6)
# --- Händler-Fenster # ---------------- Seitenleiste: Abrechnungen und Händler
self.f_h = w.Fenster(pw, "Händler", self.icons("haendler")) # Win95: zwei eigene Flächen mit blauer Titelleiste auf dem Desktop
r, self.htree = w.scroll_tree(self.f_h.inhalt, columns=("anzahl", "summe"), show="tree headings", # Eigener Pfeil-Zeiger für beide Bereiche: Sonst erben sie den Zeiger der Trennlinie, und der
selectmode="browse") # bleibt unter Windows hängen (Resize-Zeiger mitten im Inhalt, v. a. maximiert).
self.htree.heading("#0", text="Händler", anchor="w") links = tk.Frame(pw, bg=w.FACE if m else w.BG, cursor="arrow")
self.htree.heading("anzahl", text="Pos.") innen = tk.Frame(links, bg=w.FACE if m else w.BG)
self.htree.heading("summe", text="Auszahlung") innen.pack(fill="both", expand=True, padx=12 if m else 0, pady=(16, 10) if m else 0)
self.htree.column("#0", width=150, minwidth=80, stretch=False) seite = dict(style="Seite.Treeview") if m else {}
self.htree.column("anzahl", width=38, anchor="e", stretch=False) rand = {} if m else dict(padx=2, pady=(0, 2)) # Abstand der Liste zum Win95-Rahmen
self.htree.column("summe", width=84, anchor="e", stretch=False)
self.f_a = tk.Frame(innen, bg=w.FACE) if m else w.Karte(innen)
self.f_a.pack(fill="x", pady=(0, 16 if m else 6))
zeile = self._abschnitt_zeile(self.f_a, "Abrechnungen")
self.b_neu = w.knopf(zeile, "+ Neu", self.neu, padx=8, pady=0, **self._link())
self.b_neu.pack(side="right", padx=0 if m else 1)
w.Tooltip(self.b_neu, "Neue Abrechnung anlegen (Strg+N)")
r, self.atree = w.scroll_tree(self.f_a, columns=("status",), show="tree", height=5, selectmode="browse",
auto=True, **seite)
self.atree.column("#0", width=150, minwidth=80, stretch=False)
self.atree.column("status", width=110, anchor="e", stretch=False)
w.auto_breite(self.atree, {"#0": 1})
self.atree.tag_configure("leise", foreground=w.SHADOW)
self._punkte = {f: w.punkt(self.root, getattr(w, f)) for f in ("BLAU", "GOLD", "GRUEN", "ROT", "SHADOW")}
r.pack(fill="both", expand=True, **rand)
self.atree.bind("<<TreeviewSelect>>", self._abrechnung_gewaehlt)
self.f_h = tk.Frame(innen, bg=w.FACE) if m else w.Karte(innen)
self.f_h.pack(fill="both", expand=True)
zeile = self._abschnitt_zeile(self.f_h, "Händler")
self.b_haendler = w.knopf(zeile, "Verwalten", self.haendler, padx=8, pady=0, **self._link())
self.b_haendler.pack(side="right", padx=0 if m else 1)
w.Tooltip(self.b_haendler, "Händler und Kennungen verwalten (Strg+H)")
r, self.htree = w.scroll_tree(self.f_h, columns=("anzahl", "summe"), show="tree", selectmode="browse",
auto=True, **seite)
self.htree.column("#0", width=140, minwidth=80, stretch=False)
self.htree.column("anzahl", width=40, anchor="e", stretch=False)
self.htree.column("summe", width=96, anchor="e", stretch=False)
w.auto_breite(self.htree, {"#0": 1}) w.auto_breite(self.htree, {"#0": 1})
self.htree.tag_configure("grau", foreground=w.SHADOW) self.htree.tag_configure("grau", foreground=w.SHADOW)
self.htree.tag_configure("fett", font=w.FONT_BOLD) self.htree.tag_configure("fett", font=w.FONT_BOLD)
r.pack(fill="both", expand=True) r.pack(fill="both", expand=True, **rand)
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt) self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick()) self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
pw.add(self.f_h, minsize=240, width=310) pw.add(links, minsize=240, width=320)
# ---------------- Inhalt: Kopf der Abrechnung, Kennzahlen, Positionen
rechts = tk.Frame(pw, bg=w.BG, cursor="arrow")
self._bereiche = (links, rechts)
innen = tk.Frame(rechts, bg=w.BG)
innen.pack(fill="both", expand=True, padx=(16, 20) if m else 0, pady=(16, 0) if m else 0)
kopf = tk.Frame(innen, bg=w.BG)
kopf.pack(fill="x", pady=(0, 14 if m else 6))
texte = tk.Frame(kopf, bg=w.BG)
texte.pack(side="left", fill="x", expand=True)
self.l_status = w.abschnitt(texte, "", bg=w.BG)
self.l_status.pack(anchor="w")
self.l_titel = tk.Label(texte, text="", bg=w.BG, font=w.FONT_TITEL, anchor="w")
self.l_titel.pack(anchor="w")
self.l_info = tk.Label(texte, text="", bg=w.BG, fg=w.TEXT2, anchor="w")
self.l_info.pack(anchor="w")
if not m: # Win95: weiße Schrift auf dem Desktop, wie die Symbolbeschriftungen
for lbl in (self.l_status, self.l_titel, self.l_info):
lbl.configure(fg=w.WHITE)
self.b_export = w.knopf(kopf, "Exportieren", self.exportieren, primaer=True)
self.b_export.pack(side="right", anchor="s")
w.Tooltip(self.b_export, "Händler-Abrechnungen als Excel speichern (Strg+E)")
self.b_import = w.knopf(kopf, "Importieren", self.importieren)
self.b_import.pack(side="right", anchor="s", padx=8)
w.Tooltip(self.b_import, "CSV-/Excel-Dateien einlesen (Strg+I)")
self.b_erledigt = w.knopf(kopf, "Als erledigt markieren", self.erledigt_umschalten)
self.b_erledigt.pack(side="right", anchor="s")
self.tb = {"Neu": self.b_neu, "Import": self.b_import, "Export": self.b_export, "Händler": self.b_haendler}
kacheln = tk.Frame(innen, bg=w.BG)
kacheln.pack(fill="x", pady=(0, 14 if m else 6))
self.kpi: dict[str, tuple[tk.Label, tk.Label]] = {}
for i, (key, titel) in enumerate((("pos", "Positionen"), ("orig", "Provision"), ("ausz", "Auszahlung"),
("air", "Airtime"))):
k = w.Karte(kacheln)
k.grid(row=0, column=i, sticky="nsew", padx=(0 if i == 0 else 7, 0 if i == 3 else 7))
kacheln.columnconfigure(i, weight=1, uniform="kpi")
ki = tk.Frame(k, bg=w.FACE)
ki.pack(fill="both", expand=True, padx=16 if m else 8, pady=12 if m else 6)
w.abschnitt(ki, titel).pack(anchor="w")
farbe = {"ausz": w.NAVY, "air": w.GRUEN}.get(key, w.DARK)
wert = tk.Label(ki, text="", font=w.FONT_ZAHL, fg=farbe, anchor="w")
wert.pack(anchor="w", pady=(4, 0))
info = tk.Label(ki, text="", fg=w.TEXT2, anchor="w")
info.pack(anchor="w")
self.kpi[key] = (wert, info)
self.f_p = w.Karte(innen)
self.f_p.pack(fill="both", expand=True, pady=(0, 12 if m else 0))
if not m: # Win95: Titel in der blauen Leiste, Filter darunter wie eine Werkzeugleiste
_, self.l_ptitel = w.titelleiste(self.f_p, "Positionen")
kopfzeile = tk.Frame(self.f_p, bg=w.FACE)
kopfzeile.pack(fill="x", padx=14 if m else 4, pady=10 if m else 4)
if m:
self.l_ptitel = tk.Label(kopfzeile, text="Positionen", font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold"))
self.l_ptitel.pack(side="left", padx=(0, 12))
self.pills: dict[str, w.Pill] = {}
for key, text in self.FILTER:
self.pills[key] = w.Pill(kopfzeile, text, lambda k=key: self._filter_setzen(k), aktiv=key == self.filter)
self.pills[key].pack(side="left", padx=(0, 6))
self.l_auswahl = tk.Label(kopfzeile, text="", fg=w.TEXT2)
self.l_auswahl.pack(side="right")
if m:
w.trenner(self.f_p, vertikal=False).pack(fill="x")
# --- Positionen-Fenster
self.f_p = w.Fenster(pw, "Positionen", self.icons("neu"))
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz") cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz")
r2, self.ptree = w.scroll_tree(self.f_p.inhalt, columns=cols, show="tree headings", selectmode="extended") r2, self.ptree = w.scroll_tree(self.f_p, columns=cols, show="tree headings", selectmode="extended")
self.ptree.heading("#0", text="Provisionsart / Kunde", anchor="w") if m:
r2.configure(highlightthickness=0)
kopf_text = (lambda t: t.upper()) if m else (lambda t: t)
self.ptree.heading("#0", text=kopf_text("Provisionsart / Kunde"), anchor="w")
self.ptree.column("#0", width=200, minwidth=140, stretch=False) self.ptree.column("#0", width=200, minwidth=140, stretch=False)
for c, t, b, a in (("datum", "Datum", 76, "w"), ("rufnummer", "Rufnummer", 100, "w"), for c, t, b, a in (("datum", "Datum", 86, "w"), ("rufnummer", "Rufnummer", 112, "w"),
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 84, "w"), ("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 94, "w"),
("kennung", "Kennung", 70, "w"), ("kennung", "Kennung", 80, "w"),
("orig", "Provision", 78, "e"), ("proz", "%", 52, "e"), ("ausz", "Auszahlung", 84, "e")): ("orig", "Provision", 100, "e"), ("proz", "%", 64, "e"), ("ausz", "Auszahlung", 112, "e")):
self.ptree.heading(c, text=t, anchor=a) self.ptree.heading(c, text=kopf_text(t), anchor=a)
self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False) self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False)
w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4}) w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4})
self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD) self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD)
self.ptree.tag_configure("manuell", foreground="#0000c0") self.ptree.tag_configure("zebra", background=w.ZEBRA)
self.ptree.tag_configure("abw", foreground="#a00000") self.ptree.tag_configure("manuell", foreground=w.MANUELL)
self.ptree.tag_configure("negativ", foreground="#a00000") self.ptree.tag_configure("abw", foreground=w.ROT)
self.ptree.tag_configure("negativ", foreground=w.ROT)
self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW) self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW)
r2.pack(fill="both", expand=True) r2.pack(fill="both", expand=True)
self.ptree.bind("<Double-1>", self._doppelklick) self.ptree.bind("<Double-1>", self._doppelklick)
self.ptree.bind("<Button-3>", self._kontextmenue) self.ptree.bind("<Button-3>", self._kontextmenue)
self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl()) self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl())
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE) leiste = tk.Frame(self.f_p, bg=w.FACE)
leiste.pack(fill="x", pady=(4, 0)) leiste.pack(fill="x", padx=14 if m else 2, pady=10 if m else (4, 2))
self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, self.icons("prozent")) self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, None if m else self.icons("prozent"))
self.b_prozent.pack(side="left") self.b_prozent.pack(side="left")
w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4) w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten,
None if m else self.icons("rechner")).pack(side="left", padx=6)
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left") w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
self.v_alle_auf = tk.BooleanVar(value=True)
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right") 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) w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=6)
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend · Grau = abgerechnet", fg=w.SHADOW).pack( pw.add(rechts, minsize=560)
side="left", padx=8)
pw.add(self.f_p, minsize=500) @staticmethod
def _abschnitt_zeile(master, titel: str) -> tk.Frame:
"""Überschrift eines Abschnitts der Seitenleiste; Win95 als blaue Titelleiste. Knöpfe kommen rechts hinein."""
if not w.MODERN:
return w.titelleiste(master, titel)[0]
zeile = tk.Frame(master, bg=w.FACE)
zeile.pack(fill="x", pady=(0, 4))
w.abschnitt(zeile, titel).pack(side="left", padx=(8, 0))
return zeile
@staticmethod
def _link() -> dict:
"""Kleine Textknöpfe in der Seitenleiste (modern ohne Rahmen, in Akzentfarbe)."""
return dict(fg=w.NAVY, highlightthickness=0, font=w.FONT_BOLD) if w.MODERN else {}
def _filter_setzen(self, key: str):
self.filter = key
for k, pill in self.pills.items():
pill.setzen(k == key)
self._filtern()
def _tasten(self): def _tasten(self):
r = self.root r = self.root
@@ -274,9 +450,10 @@ class App:
def _aktualisieren(self, behalte_auswahl: bool = True): def _aktualisieren(self, behalte_auswahl: bool = True):
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()} 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) 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.rahmen.set_titel(f"{w.APP_NAME} - [{titel}]")
self.status.set("info", f" Abrechnung: {titel}") self.status.set("info", f" Abrechnung: {titel}")
else: else:
@@ -284,9 +461,45 @@ class App:
self.positionen = [] self.positionen = []
self.rahmen.set_titel(w.APP_NAME) self.rahmen.set_titel(w.APP_NAME)
self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import") self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import")
self._abrechnungsliste()
self._seitenkopf(a)
self._haendlerliste(behalte_auswahl) self._haendlerliste(behalte_auswahl)
self._tabelle() self._tabelle()
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 = 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})")
self.l_titel.configure(text=a["titel"])
self.l_info.configure(text=f"{len(dateien)} {'Datei' if len(dateien) == 1 else 'Dateien'} importiert · "
f"{sum(d['anzahl'] for d in dateien)} Zeilen" if dateien else "Noch nichts importiert")
self.b_erledigt.configure(text="Wieder öffnen" if erledigt else "Als erledigt markieren", state="normal")
def _abrechnungsliste(self):
t = self.atree
t.delete(*t.get_children())
for a in self.db.abrechnungen():
status, farbe = (("erledigt", "GRUEN") if a["erledigt"] else
(f"{a['abgerechnet']} abgerechnet", "GOLD") if a["abgerechnet"] else ("offen", "BLAU"))
t.insert("", "end", iid=str(a["id"]), text=" " + a["titel"], values=(status,), image=self._punkte[farbe])
if self.aid and t.exists(str(self.aid)):
t.selection_set(str(self.aid))
t.see(str(self.aid))
def _abrechnung_gewaehlt(self, _e=None):
sel = self.atree.selection()
if sel and int(sel[0]) != self.aid:
self.ansicht = ALLE
self.oeffnen(int(sel[0]))
def _suche_planen(self): def _suche_planen(self):
"""Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen).""" """Suche erst kurz nach dem letzten Tastendruck ausführen (flüssiges Tippen)."""
if self._suche_geplant is not None: if self._suche_geplant is not None:
@@ -295,7 +508,7 @@ class App:
def _suche_ausfuehren(self): def _suche_ausfuehren(self):
self._suche_geplant = None self._suche_geplant = None
self._tabelle() self._filtern()
def _gefiltert(self) -> list[dict]: def _gefiltert(self) -> list[dict]:
if self.ansicht == ALLE: if self.ansicht == ALLE:
@@ -353,6 +566,8 @@ class App:
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}" return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}"
def _tabelle(self): 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: if self._edit:
self._edit.destroy() self._edit.destroy()
self._edit = None self._edit = None
@@ -360,59 +575,107 @@ class App:
offen = {iid for iid in t.get_children() if t.item(iid, "open")} offen = {iid for iid in t.get_children() if t.item(iid, "open")}
war_leer = not t.get_children() war_leer = not t.get_children()
t.delete(*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()]
gruppen: dict[str, list[dict]] = defaultdict(list) 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) 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())): for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
giid = f"g{gi}" giid = f"g{gi}"
self._gruppen[giid] = (gname, plist) self._alle_gruppen[giid] = (gname, plist)
so = sum(p["betrag_orig"] for p in plist) t.insert("", "end", iid=giid, text="", open=(war_leer or giid in offen), tags=("gruppe",))
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 p in plist: for p in plist:
tags = [] tags: tuple[str, ...] = ()
if p["abgerechnet"] or p["doppelt"]: if p["abgerechnet"] or p["doppelt"]:
tags.append("abgerechnet") tags = ("abgerechnet",)
elif p["manuell"]: elif p["manuell"]:
tags.append("manuell") tags = ("manuell",)
elif p["betrag"] != p["betrag_orig"]: 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)" 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, t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]), values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""), p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
fmt(p["betrag_orig"]), 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"])), "manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])),
fmt(p["betrag"])), 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", name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet",
EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get( EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get(
self.ansicht, self.haendler_namen.get(self.ansicht, "")) self.ansicht, self.haendler_namen.get(self.ansicht, ""))
self.f_p.set_titel(f"Positionen – {name}") self.l_ptitel.configure(text=f"Positionen – {name}")
so = sum(p["betrag_orig"] for p in pos) so = sum(p["betrag_orig"] for p in pos)
sa = sum(p["betrag"] for p in pos) sa = sum(p["betrag"] for p in pos)
air = [p for p in pos if p.get("typ") == regeln.AIRTIME]
self.status.set("pos", f" {len(pos)} Positionen") self.status.set("pos", f" {len(pos)} Positionen")
self.status.set("orig", f" Provision: {fmt(so)}") self.status.set("orig", f" Provision: {fmt(so)}")
self.status.set("ausz", f" Auszahlung: {fmt(sa)}") self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
self._kennzahl("pos", str(len(pos)), name)
self._kennzahl("orig", fmt(so), "laut Anbieter")
self._kennzahl("ausz", fmt(sa), f"{round(sa * 100 / so)} % im Schnitt" if so > 0 else "")
self._kennzahl("air", fmt(sum(p["betrag"] for p in air)),
(f"{len(air)} Posten · eigene Excel-Datei" if any(p["betrag"] for p in air) else f"{len(air)} Posten")
if air else "keine Airtime")
self.l_auswahl.configure(text="")
def _kennzahl(self, key: str, wert: str, info: str):
self.kpi[key][0].configure(text=wert)
self.kpi[key][1].configure(text=info)
def _status_auswahl(self): def _status_auswahl(self):
pids = self._ausgewaehlte_pids() pids = self._ausgewaehlte_pids()
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())): if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())):
ps = [p for p in self.positionen if p["id"] in set(pids)] ps = [p for p in self.positionen if p["id"] in set(pids)]
self.l_auswahl.configure(text=f"{len(ps)} ausgewählt · {fmt(sum(p['betrag'] for p in ps))}")
self.status.set("pos", f" {len(ps)} ausgewählt") self.status.set("pos", f" {len(ps)} ausgewählt")
self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}") self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}")
self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}") self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
@@ -431,13 +694,6 @@ class App:
return list(dict.fromkeys(pids)) return list(dict.fromkeys(pids))
# ================================================================= Aktionen # ================================================================= 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: def _vorschlag_titel(self) -> str:
heute = date.today() heute = date.today()
m = heute.month - 1 or 12 m = heute.month - 1 or 12
@@ -466,7 +722,7 @@ class App:
else: else:
self._aktualisieren() self._aktualisieren()
def importieren(self, dateien: list[str] | None = None): def importieren(self, dateien: list[str] | None = None, ziel: int | None = None):
if not dateien: if not dateien:
dateien = list(filedialog.askopenfilenames( dateien = list(filedialog.askopenfilenames(
parent=self.root, title="Provisionsabrechnungen importieren", parent=self.root, title="Provisionsabrechnungen importieren",
@@ -477,10 +733,27 @@ class App:
if not dateien: if not dateien:
return return
self.settings["import_ordner"] = str(Path(dateien[0]).parent) self.settings["import_ordner"] = str(Path(dateien[0]).parent)
if not self._brauche_abrechnung() or self._gesperrt(): if ziel is None: # jedes Mal fragen, in welche Abrechnung
if not self.db.abrechnungen():
if not self.neu():
return
else:
res = dlg.AbrechnungenDialog(self.root, self.db, self.aid, fuer_import=True).zeigen()
if not res:
return
if res[0] == "neu":
if not self.neu():
return
else:
self.aid, self.ansicht = res[1], ALLE
else:
self.aid = ziel
if self._gesperrt():
return return
bericht = [] bericht = []
self.root.configure(cursor="watch") importiert: list[tuple[int, int, list[str]]] = [] # (Zeile im Bericht, datei_id, Hinweise)
for f in (self.root, *self._bereiche):
f.configure(cursor="watch")
self.root.update() self.root.update()
try: try:
for f in dateien: for f in dateien:
@@ -501,15 +774,22 @@ class App:
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)")) bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
continue continue
n = self.db.importieren(self.aid, erg) n = self.db.importieren(self.aid, erg)
did = self.db.dateien(self.aid)[-1]["id"] importiert.append((len(bericht), self.db.dateien(self.aid)[-1]["id"], list(erg.warnungen)))
doppelt = sum(1 for p in self.db.positionen(self.aid) if p["datei_id"] == did and p["doppelt"]) bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen), ""))
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"))
finally: finally:
self.root.configure(cursor="") self.root.configure(cursor="")
for f in self._bereiche:
f.configure(cursor="arrow")
self._aktualisieren() 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]) unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
for p in self.positionen: for p in self.positionen:
if p["haendler_id"] == UNZUGEORDNET: if p["haendler_id"] == UNZUGEORDNET:
@@ -871,6 +1151,8 @@ class App:
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n" w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
"Bitte zuerst Dateien importieren.") "Bitte zuerst Dateien importieren.")
return return
if self._gesperrt(): # erledigt: nichts mehr exportieren (und damit abrechnen)
return
# nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet) # 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"]] 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]) stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
@@ -895,12 +1177,14 @@ class App:
config.save(self.settings) config.save(self.settings)
erzeugt, exportiert = [], [] erzeugt, exportiert = [], []
zeit = res["zeitraum"] zeit = res["zeitraum"]
vergeben: set[str] = set() # Dateinamen dieses Exports – gleich lautende Händler überschreiben sich nicht
try: try:
for hid in res["ids"]: for hid in res["ids"]:
pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))] 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"], erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"], 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 exportiert += pos
except PermissionError as exc: except PermissionError as exc:
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n" w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
@@ -968,15 +1252,108 @@ class App:
res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen() res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
if not res: if not res:
return return
if res["design"] != w.DESIGN:
self.settings["design"] = res["design"]
config.save(self.settings)
w.info(self.root, "Einstellungen", "Das neue Design wird beim nächsten Programmstart wirksam.")
if res["pfad"] and Path(res["pfad"]) != self.db.path: if res["pfad"] and Path(res["pfad"]) != self.db.path:
try: try:
self.db_wechseln(res["pfad"]) self.db_wechseln(res["pfad"])
except Exception as exc: except Exception as exc:
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}") w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
self.settings["update_pruefen"] = res["update"]
config.save(self.settings)
if res["design"] != w.DESIGN:
self.design_wechseln(res["design"])
# ================================================================= Updates
@staticmethod
def _installiert() -> bool:
"""Läuft die per Setup installierte Windows-Version? Nur die kann sich selbst aktualisieren."""
return (bool(getattr(sys, "frozen", False)) and sys.platform.startswith("win")
and Path(sys.executable).name.lower() == "provisionsmanager.exe")
def _im_hintergrund(self, arbeit, fertig):
"""arbeit() im Thread (nur Netzwerk/Dateien, kein Tk), danach fertig(ergebnis) im Tk-Thread."""
erg: dict = {}
def lauf():
try:
erg["wert"] = arbeit()
except Exception as exc:
erg["fehler"] = exc
threading.Thread(target=lauf, daemon=True).start()
def warten():
if self._beendet or self._schliesst:
return
if not erg:
self.root.after(200, warten)
else:
fertig(erg)
self.root.after(200, warten)
def updates_pruefen(self, still: bool = False):
"""Auf Gitea nach einer neueren Version suchen. still = automatisch beim Start: nur melden, wenn es
eine gibt (keine Meldung bei Fehler oder wenn aktuell)."""
if getattr(self, "_update_laeuft", False):
return
self._update_laeuft = True
def fertig(erg):
self._update_laeuft = False
self._update_melden(erg, still)
self._im_hintergrund(updater.neueste, fertig)
def _update_melden(self, erg: dict, still: bool):
if "fehler" in erg:
if not still:
w.warnung(self.root, "Updates", f"Konnte nicht nach Updates suchen:\n{erg['fehler']}")
return
info = erg["wert"]
if not updater.ist_neuer(info["version"], __version__):
if not still:
w.info(self.root, "Updates", f"Sie haben die neueste Version ({__version__}).")
return
if still and modal.offen(): # z. B. Tipp des Tages offen – später noch einmal
self.root.after(3000, lambda: self._update_melden(erg, still))
return
text = f"Version {info['version']} ist verfügbar (installiert: {__version__})."
if self._installiert() and info["setup_url"]:
if w.frage(self.root, "Update verfügbar",
text + "\n\nJetzt herunterladen und installieren? Die Datenbank wird vorher gesichert, das "
"Programm beendet sich kurz und startet danach von selbst wieder.",
ja="Jetzt installieren", nein="Später"):
self._update_installieren(info)
elif w.frage(self.root, "Update verfügbar", text + "\n\nDownload-Seite öffnen?",
ja="Seite öffnen", nein="Später"):
webbrowser.open(info["seite"])
def _update_installieren(self, info: dict):
self.status.set("info", f" Lade Version {info['version']} herunter ...")
ordner = Path(tempfile.gettempdir()) / "Provisionsmanager-Update"
def fertig(erg):
if "fehler" in erg:
self.status.set("info", "")
w.fehler(self.root, "Update", f"Download fehlgeschlagen:\n{erg['fehler']}")
return
try:
self.db.backup(config.app_dir() / f"vor-update-{__version__}.db")
except Exception as exc:
if not w.frage(self.root, "Update", f"Die Datenbank konnte nicht gesichert werden:\n{exc}\n\n"
"Trotzdem installieren?", ja="Installieren", nein="Abbrechen", default_ja=False):
return
# NEUSTART=1: das Setup startet das Programm danach wieder (installer.iss)
subprocess.Popen([str(erg["wert"]), "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART",
"/CLOSEAPPLICATIONS", "/NEUSTART=1"], close_fds=True)
self.beenden()
self._im_hintergrund(lambda: updater.herunterladen(info, ordner), fertig)
def design_umschalten(self):
self.design_wechseln("modern" if w.DUNKEL else "dunkel")
def design_wechseln(self, design: str):
"""Fenster mit dem neuen Design neu aufbauen (main() startet es nach dem Schließen wieder)."""
self.settings["design"] = design
self.neustart = True
self.root.after_idle(self.beenden)
def hilfe(self): def hilfe(self):
w.info(self.root, "Kurzanleitung", w.info(self.root, "Kurzanleitung",
@@ -1018,7 +1395,7 @@ class App:
self.ansicht = ALLE self.ansicht = ALLE
self._aktualisieren() self._aktualisieren()
if res["dateien"]: if res["dateien"]:
self.importieren(res["dateien"]) self.importieren(res["dateien"], ziel=self.aid)
if res["rundgang"]: if res["rundgang"]:
self.root.after(200, self.rundgang) self.root.after(200, self.rundgang)
@@ -1035,7 +1412,7 @@ class App:
"(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."), "(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."),
(tb["Neu"], "Neue Abrechnung", (tb["Neu"], "Neue Abrechnung",
"Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. " "Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. "
"Mit „Öffnen“ holen Sie frühere Abrechnungen wieder hervor."), "Frühere Abrechnungen öffnen Sie mit einem Klick in der Liste darunter."),
(tb["Import"], "Dateien importieren", (tb["Import"], "Dateien importieren",
"Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. " "Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. "
"Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."), "Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."),
@@ -1043,24 +1420,28 @@ class App:
"Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n" "Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n"
"„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. " "„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. "
"Das wird nicht exportiert."), "Das wird nicht exportiert."),
(self.pills["alle"], "Filter",
"Mit diesen Knöpfen zeigen Sie nur bestimmte Positionen: nicht zugeordnete, von Hand geänderte, "
"doppelte oder bereits abgerechnete."),
(self.f_p, "Positionen prüfen", (self.f_p, "Positionen prüfen",
"Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n" "Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n"
"- Doppelklick auf eine Position: Betrag frei ändern\n" "- Doppelklick auf eine Position: Betrag frei ändern\n"
"- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n" "- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n"
"- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"), "- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"),
(self.b_prozent, "Auszahlung in Prozent", (self.b_prozent, "Auszahlung in Prozent",
"Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. " "Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. "
"Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."), "Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."),
(tb["Export"], "Exportieren", (tb["Export"], "Exportieren",
"Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat " "Zum Schluss erstellt „Exportieren“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat "
"(Airtime darin als eine Summenzeile) und – falls vorhanden – die Airtime-Liste."), "(Airtime darin als eine Summenzeile) und – falls vorhanden – die Airtime-Liste."),
(self.status, "Summen", (self.status, "Summen",
"Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: " "Unten sehen Sie immer die Summe der angezeigten bzw. markierten Positionen: "
"Provision laut Anbieter und Ihre Auszahlung."), "Provision laut Anbieter und Ihre Auszahlung."),
(self._suchrahmen, "Suchen", (self._suchrahmen, "Suchen",
"Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."), "Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."),
(self.menueleiste.knoepfe["?"], "Das war's!", (self.menueleiste.knoepfe[self.hilfe_menue], "Das war's!",
"Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü „?“.\n\n" "Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü "
f"„{self.hilfe_menue}“.\n\n"
f"Viel Erfolg mit dem {w.APP_NAME}!"), f"Viel Erfolg mit dem {w.APP_NAME}!"),
] ]
self._rundgang = ass.Rundgang(self, schritte) self._rundgang = ass.Rundgang(self, schritte)
@@ -1135,12 +1516,12 @@ class App:
try: try:
self.settings["trenner"] = self.pw.sash_coord(0)[0] self.settings["trenner"] = self.pw.sash_coord(0)[0]
self.settings["fenster"] = self.rahmen.normale_geometrie() self.settings["fenster"] = self.rahmen.normale_geometrie()
self.settings["maximiert"] = self.rahmen.maximiert
config.save(self.settings) config.save(self.settings)
except (tk.TclError, OSError): except (tk.TclError, OSError):
pass pass
finally: finally:
try: try:
if self.db is not None:
self.db.close() self.db.close()
finally: finally:
try: try:
@@ -1164,11 +1545,20 @@ def main():
ctypes.windll.shcore.SetProcessDpiAwareness(1) ctypes.windll.shcore.SetProcessDpiAwareness(1)
except Exception: except Exception:
pass pass
dateien = [a for a in sys.argv[1:] if Path(a).exists()]
erster = True
while True:
root = tk.Tk(className="Provisionsmanager95") root = tk.Tk(className="Provisionsmanager95")
app = App(root) app = App(root)
if len(sys.argv) > 1: if dateien:
root.after(200, lambda: app.importieren([a for a in sys.argv[1:] if Path(a).exists()])) root.after(200, lambda: app.importieren(dateien))
dateien = []
if erster and app.settings.get("update_pruefen", True): # nur im echten Start, nicht in Tests
root.after(3000, lambda: app.updates_pruefen(still=True))
erster = False
root.mainloop() root.mainloop()
if not app.neustart: # sonst: Design wurde umgeschaltet – Fenster neu aufbauen
break
if __name__ == "__main__": if __name__ == "__main__":
+2 -2
View File
@@ -74,9 +74,9 @@ class Einrichtungsassistent(w.Dialog):
self._img = w.make_icon(self, "app", 7) self._img = w.make_icon(self, "app", 7)
c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="") c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="")
for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))): for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))):
c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill=w.WHITE, outline="") c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill="#ffffff", outline="")
c.create_image(77, self.HOEHE // 2 - 10, image=self._img) c.create_image(77, self.HOEHE // 2 - 10, image=self._img)
c.create_text(77, self.HOEHE - 38, text=w.APP_NAME.replace("-", "-\n", 1), fill=w.WHITE, font=w.FONT_BOLD, c.create_text(77, self.HOEHE - 38, text=w.APP_NAME.replace("-", "-\n", 1), fill="#ffffff", font=w.FONT_BOLD,
justify="center") justify="center")
def _leeren(self): def _leeren(self):
+50 -18
View File
@@ -14,7 +14,6 @@ from provtool.money import fmt, fmt_percent, parse_cent, parse_percent
# Auswahl im Händler-Dialog: Schlüssel -> Anzeige # Auswahl im Händler-Dialog: Schlüssel -> Anzeige
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"} KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)"}
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler" CAPS_HAKEN = "Vodafone-Kunden mit Nachnamen in GROSSBUCHSTABEN gehören zu diesem Händler"
@@ -295,6 +294,12 @@ class HaendlerDialog(w.Dialog):
return return
if self.v_kwert.get().strip(): if self.v_kwert.get().strip():
self._kennung_dazu() self._kennung_dazu()
if self.aktuell and self.aktuell.aktiv and not self.v_aktiv.get() and not w.frage(
self, "Händler deaktivieren",
f"„{name}“ inaktiv setzen?\n\nSeine noch offenen Positionen gelten dann als „nicht zugeordnet“ "
"und zeigen wieder die volle Provision (100 %); exportiert werden sie nicht. Bereits "
"abgerechnete Positionen bleiben, wie sie sind.", ja="Inaktiv setzen", default_ja=False):
return
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(), h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air, self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get(), air,
self._eigene_staffel if self.v_eigen_staffel.get() else None) self._eigene_staffel if self.v_eigen_staffel.get() else None)
@@ -558,10 +563,13 @@ class ProvisionsartenDialog(w.Dialog):
# =========================================================================== Abrechnungen # =========================================================================== Abrechnungen
class AbrechnungenDialog(w.Dialog): class AbrechnungenDialog(w.Dialog):
def __init__(self, master, db: Datenbank, aktuelle_id: int | None): def __init__(self, master, db: Datenbank, aktuelle_id: int | None, fuer_import: bool = False):
super().__init__(master, "Abrechnung öffnen", resizable=True) """fuer_import: Ziel eines Imports wählen (Ergebnis ("oeffnen", id) oder ("neu",))."""
super().__init__(master, "Import – in welche Abrechnung?" if fuer_import else "Abrechnung öffnen",
resizable=True)
self.db = db self.db = db
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2)) tk.Label(self, text="In welche Abrechnung sollen die Dateien importiert werden?" if fuer_import
else "Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"), r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"),
show="headings", height=12, selectmode="browse") show="headings", height=12, selectmode="browse")
for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120), for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120),
@@ -572,6 +580,10 @@ class AbrechnungenDialog(w.Dialog):
self.tree.bind("<Double-1>", lambda e: self._oeffnen()) self.tree.bind("<Double-1>", lambda e: self._oeffnen())
k = tk.Frame(self, bg=w.FACE) k = tk.Frame(self, bg=w.FACE)
k.pack(fill="x", padx=10, pady=10) k.pack(fill="x", padx=10, pady=10)
if fuer_import:
w.knopf(k, "Hierhin importieren", self._oeffnen, default="active").pack(side="left")
w.knopf(k, "Neue Abrechnung...", self._neu).pack(side="left", padx=6)
else:
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left") w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6) w.knopf(k, "Umbenennen...", self._umbenennen, breite=12).pack(side="left", padx=6)
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left") w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
@@ -600,6 +612,10 @@ class AbrechnungenDialog(w.Dialog):
self.result = ("oeffnen", self._sel()) self.result = ("oeffnen", self._sel())
self.destroy() self.destroy()
def _neu(self):
self.result = ("neu",)
self.destroy()
def _umbenennen(self): def _umbenennen(self):
aid = self._sel() aid = self._sel()
if not aid: if not aid:
@@ -617,7 +633,11 @@ class AbrechnungenDialog(w.Dialog):
titel = self.db.abrechnung(aid)["titel"] titel = self.db.abrechnung(aid)["titel"]
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?", if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
default_ja=False): default_ja=False):
try:
self.db.abrechnung_loeschen(aid) self.db.abrechnung_loeschen(aid)
except AbrechnungErledigt as exc:
w.info(self, "Löschen", str(exc))
return
self._laden() self._laden()
self.result = ("geloescht", aid) self.result = ("geloescht", aid)
@@ -857,10 +877,15 @@ class ExportDialog(w.Dialog):
"""haendler: (id, name, anzahl, summe_cent)""" """haendler: (id, name, anzahl, summe_cent)"""
super().__init__(master, "Abrechnungen exportieren") super().__init__(master, "Abrechnungen exportieren")
self.settings = settings self.settings = settings
f = tk.Frame(self, bg=w.FACE) rumpf = tk.Frame(self, bg=w.FACE)
f.pack(fill="both", expand=True, padx=10, pady=10) rumpf.pack(fill="both", expand=True, padx=10, pady=10)
# zwei Spalten (links Händler, rechts Zeitraum und Optionen) – passt auch auf kleine Bildschirme
f = tk.Frame(rumpf, bg=w.FACE)
f.pack(side="left", fill="both", expand=True)
rechts = tk.Frame(rumpf, bg=w.FACE)
rechts.pack(side="left", fill="y", padx=(14, 0))
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x") tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8, r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=12,
selectmode="extended") selectmode="extended")
self.tree.heading("#0", text="") self.tree.heading("#0", text="")
self.tree.column("#0", width=40, stretch=False) self.tree.column("#0", width=40, stretch=False)
@@ -880,7 +905,7 @@ class ExportDialog(w.Dialog):
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left") w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4) w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
g = w.gruppe(f, "Optionen") g = w.gruppe(rechts, "Optionen")
g.pack(fill="x", pady=(8, 0)) g.pack(fill="x", pady=(8, 0))
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w") tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
self.v_titel = tk.StringVar(value=titel) self.v_titel = tk.StringVar(value=titel)
@@ -902,13 +927,14 @@ class ExportDialog(w.Dialog):
("Summenzeile unter der Tabelle", self.v_sum), ("Summenzeile unter der Tabelle", self.v_sum),
("Ordner nach dem Export öffnen", self.v_auf)), start=2): ("Ordner nach dem Export öffnen", self.v_auf)), start=2):
tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w") tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w")
tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine\n" tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine "
"eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid( "eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w",
wraplength=420).grid(
row=7, column=0, columnspan=3, sticky="w", pady=(4, 0)) row=7, column=0, columnspan=3, sticky="w", pady=(4, 0))
g.columnconfigure(1, weight=1) g.columnconfigure(1, weight=1)
z = w.gruppe(f, "Zeitraum und Abrechnung") z = w.gruppe(rechts, "Zeitraum und Abrechnung")
z.pack(fill="x", pady=(8, 0)) z.pack(fill="x", before=g)
zf = tk.Frame(z, bg=w.FACE) zf = tk.Frame(z, bg=w.FACE)
zf.pack(fill="x") zf.pack(fill="x")
tk.Label(zf, text="Nur von:").pack(side="left") tk.Label(zf, text="Nur von:").pack(side="left")
@@ -923,11 +949,12 @@ class ExportDialog(w.Dialog):
self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)]) self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)])
ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly", ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly",
width=40).pack(anchor="w", pady=(4, 0)) width=40).pack(anchor="w", pady=(4, 0))
tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)", fg=w.SHADOW, anchor="w").pack( tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)",
fill="x") fg=w.SHADOW, anchor="w", justify="left", wraplength=420).pack(fill="x")
self.v_kennz = tk.BooleanVar(value=True) self.v_kennz = tk.BooleanVar(value=True)
tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch " tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch "
"einmal abgerechnet)", variable=self.v_kennz, anchor="w").pack(fill="x", pady=(4, 0)) "einmal abgerechnet)", variable=self.v_kennz, anchor="w", justify="left",
wraplength=400).pack(fill="x", pady=(4, 0))
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _vormonat(self): def _vormonat(self):
@@ -1003,10 +1030,15 @@ class EinstellungenDialog(w.Dialog):
g2 = w.gruppe(self, "Darstellung") g2 = w.gruppe(self, "Darstellung")
g2.pack(fill="x", padx=10, pady=(0, 10)) g2.pack(fill="x", padx=10, pady=(0, 10))
self.v_design = tk.StringVar(value=settings.get("design", "modern")) self.v_design = tk.StringVar(value=settings.get("design", "modern"))
tk.Radiobutton(g2, text="Modern (wie Windows 11)", variable=self.v_design, value="modern").pack(anchor="w") tk.Radiobutton(g2, text="Modern hell", variable=self.v_design, value="modern").pack(anchor="w")
tk.Radiobutton(g2, text="Modern dunkel", variable=self.v_design, value="dunkel").pack(anchor="w")
tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack( tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack(
anchor="w") anchor="w")
tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w") tk.Label(g2, text="Das Fenster wird nach OK mit dem neuen Design neu aufgebaut.", fg=w.SHADOW).pack(anchor="w")
g3 = w.gruppe(self, "Updates")
g3.pack(fill="x", padx=10, pady=(0, 10))
self.v_update = tk.BooleanVar(value=settings.get("update_pruefen", True))
tk.Checkbutton(g3, text="Beim Start nach neuen Versionen suchen", variable=self.v_update).pack(anchor="w")
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
def _waehlen(self): def _waehlen(self):
@@ -1017,7 +1049,7 @@ class EinstellungenDialog(w.Dialog):
self.v.set(p) self.v.set(p)
def _ok(self): def _ok(self):
self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get()) self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get(), update=self.v_update.get())
self.destroy() self.destroy()
+2 -1
View File
@@ -226,7 +226,8 @@ class Menueleiste(tk.Frame):
self.offener_knopf: tk.Label | None = None self.offener_knopf: tk.Label | None = None
def menue(self, label: str, underline: int | None = 0) -> Popup: def menue(self, label: str, underline: int | None = 0) -> Popup:
knopf = tk.Label(self, text=label, bg=w.FACE, padx=7, pady=2, bd=1, relief="flat", knopf = tk.Label(self, text=label, bg=w.FACE, padx=10 if w.MODERN else 7, pady=6 if w.MODERN else 2,
bd=1, relief="flat",
underline=-1 if underline is None else underline) underline=-1 if underline is None else underline)
knopf.pack(side="left") knopf.pack(side="left")
popup = Popup(self) popup = Popup(self)
+5
View File
@@ -414,6 +414,11 @@ class Hauptrahmen:
self._nativ_einrichten() self._nativ_einrichten()
if maximiert and self.klassisch: if maximiert and self.klassisch:
self.root.after(100, self.maximieren) self.root.after(100, self.maximieren)
elif maximiert: # normaler Windows-Rahmen: Windows maximiert selbst
try:
self.root.state("zoomed")
except tk.TclError:
self.root.attributes("-zoomed", True) # Linux/X11
def normale_geometrie(self) -> str: def normale_geometrie(self) -> str:
return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry() return self._normal_geo if self.maximiert and self._normal_geo else self.root.geometry()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+13 -5
View File
@@ -80,6 +80,19 @@ def hwnd(fenster: tk.Misc) -> int | None:
return None return None
def dunkle_titelleiste(fenster: tk.Misc) -> None:
"""Windows-Titelleiste dunkel (Design „dunkel“, ab Windows 10 20H1)."""
h = hwnd(fenster)
if not h:
return
try:
import ctypes
an = ctypes.c_int(1)
ctypes.windll.dwmapi.DwmSetWindowAttribute(h, 20, ctypes.byref(an), ctypes.sizeof(an)) # IMMERSIVE_DARK_MODE
except (OSError, AttributeError):
pass
def stapel(*fenster: tk.Misc) -> list[int]: def stapel(*fenster: tk.Misc) -> list[int]:
"""Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes).""" """Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes)."""
ziele = [hwnd(f) for f in fenster] ziele = [hwnd(f) for f in fenster]
@@ -178,11 +191,6 @@ def ist_bedienbar(fenster: tk.Misc) -> bool:
return bool(user32().IsWindowEnabled(h)) if h else True return bool(user32().IsWindowEnabled(h)) if h else True
def letztes_popup(fenster: tk.Misc) -> int | None:
h = hwnd(fenster)
return user32().GetLastActivePopup(h) if h else None
class Modal: class Modal:
"""Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des """Macht `fenster` modal zu `besitzer` (Windows-Art). beenden() muss VOR dem Zerstören des
Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows Fensters laufen: erst Besitzer wieder freigeben, dann schließen – sonst aktiviert Windows
+239 -92
View File
@@ -22,17 +22,35 @@ INFO_BG = "#ffffe1"
SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung
RAND = SHADOW # dünne Rahmenlinien (nur modern) RAND = SHADOW # dünne Rahmenlinien (nur modern)
DESIGN = "win95" DESIGN = "win95"
MODERN = False MODERN = False # „modern“ oder „dunkel“
DUNKEL = False
APP_NAME = "Provisions-Manager 95" # modern: ohne „95“ APP_NAME = "Provisions-Manager 95" # modern: ohne „95“
# Farbnamen: FACE = Fläche (Fenster, Karten, Dialoge), BG = Grund hinter den Karten, WHITE = Eingabefelder/Listen,
# DARK = Text, TEXT2 = Nebentext, SHADOW = leiser Text/Win95-Schatten, NAVY = Akzent, RAND/RAND2 = Linien,
# PRIMAER/AUF_PRIMAER = Hauptknopf, MANUELL = von Hand geänderte Beträge, *_SOFT = Grund der Status-Pills.
_PALETTEN = { _PALETTEN = {
"win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000", "win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000",
NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080", NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080",
SEL_FG="#ffffff", RAND="#808080"), SEL_FG="#ffffff", RAND="#808080", BG="#008080", RAND2="#808080", TEXT2="#000000",
"modern": dict(FACE="#f3f3f3", HILITE="#ffffff", LIGHT="#ebebeb", SHADOW="#8a8a8a", DARK="#1b1b1b", PRIMAER="#c0c0c0", AUF_PRIMAER="#000000", ZEBRA="#ffffff", MANUELL="#0000c0", ROT="#a00000",
NAVY="#005fb8", TEAL="#e6eef6", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#cce4f7", ROT_SOFT="#c0c0c0", GRUEN="#008000", GRUEN_SOFT="#c0c0c0", BLAU="#0000ff", BLAU_SOFT="#c0c0c0",
SEL_FG="#1b1b1b", RAND="#d1d1d1"), GOLD="#808000", GOLD_SOFT="#c0c0c0"),
# Hausfarben der Handy-Insel-Webtools (VVL-Tool): Lila, IBM Plex
"modern": dict(FACE="#ffffff", HILITE="#ffffff", LIGHT="#f1edf8", SHADOW="#6b6386", DARK="#211a38",
NAVY="#5e35b1", TEAL="#f1eef8", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#ede7fa",
SEL_FG="#2d1b69", RAND="#e5e0f0", BG="#f1eef8", RAND2="#d6cee8", TEXT2="#5b5372",
PRIMAER="#2d1b69", AUF_PRIMAER="#ffffff", ZEBRA="#faf8fd", MANUELL="#5e35b1", ROT="#b8223f",
ROT_SOFT="#fbe4e9", GRUEN="#11734d", GRUEN_SOFT="#def3ea", BLAU="#2a59c4", BLAU_SOFT="#e4ebfb",
GOLD="#8a6212", GOLD_SOFT="#f7eed8"),
"dunkel": dict(FACE="#1c1436", HILITE="#1c1436", LIGHT="#2a2049", SHADOW="#948daa", DARK="#ece8f6",
NAVY="#a88beb", TEAL="#120c22", WHITE="#221940", INFO_BG="#2a2049", SEL_BG="#3a2d66",
SEL_FG="#ece8f6", RAND="#312551", BG="#120c22", RAND2="#3f3167", TEXT2="#ada5c6",
PRIMAER="#c3adee", AUF_PRIMAER="#1c1436", ZEBRA="#201838", MANUELL="#c3adee", ROT="#f26982",
ROT_SOFT="#3a1e28", GRUEN="#4fc796", GRUEN_SOFT="#163025", BLAU="#7da0f0", BLAU_SOFT="#1e2743",
GOLD="#d8b058", GOLD_SOFT="#332a17"),
} }
globals().update(_PALETTEN["win95"])
IS_WIN = sys.platform.startswith("win") IS_WIN = sys.platform.startswith("win")
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans" FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
@@ -40,26 +58,57 @@ FONT_SIZE = 8 if IS_WIN else 9
FONT = (FONT_FAMILY, FONT_SIZE) FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold") FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold") FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1) MONO_FAMILY = "Courier New" if IS_WIN else "DejaVu Sans Mono"
FONT_MONO = (MONO_FAMILY, FONT_SIZE + 1)
FONT_KLEIN_FETT = FONT_TITEL = FONT_ZAHL = FONT_BOLD
def design_setzen(name: str) -> None: def design_setzen(name: str) -> None:
"""„modern“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply()).""" """„modern“ (hell), „dunkel“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply())."""
global DESIGN, MODERN, FONT_FAMILY, FONT_SIZE, APP_NAME global DESIGN, MODERN, DUNKEL, FONT_FAMILY, FONT_SIZE, APP_NAME, MONO_FAMILY
DESIGN = name if name in _PALETTEN else "modern" DESIGN = name if name in _PALETTEN else "modern"
MODERN = DESIGN == "modern" MODERN = DESIGN in ("modern", "dunkel")
DUNKEL = DESIGN == "dunkel"
APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95" APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95"
globals().update(_PALETTEN[DESIGN]) globals().update(_PALETTEN[DESIGN])
FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans" FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans"
FONT_SIZE = 9 if (MODERN or not IS_WIN) else 8 MONO_FAMILY = ("Consolas" if MODERN else "Courier New") if IS_WIN else "DejaVu Sans Mono"
FONT_SIZE = 10 if MODERN else (8 if IS_WIN else 9)
_schriften() _schriften()
def _schriften(): def _schriften():
global FONT, FONT_BOLD, FONT_BIG global FONT, FONT_BOLD, FONT_BIG, FONT_MONO, FONT_KLEIN_FETT, FONT_TITEL, FONT_ZAHL
FONT = (FONT_FAMILY, FONT_SIZE) FONT = (FONT_FAMILY, FONT_SIZE)
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold") FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold") FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
FONT_MONO = (MONO_FAMILY, FONT_SIZE + (0 if MODERN else 1))
FONT_KLEIN_FETT = (FONT_FAMILY, FONT_SIZE - 2, "bold") # Abschnitts-Überschriften (ABRECHNUNGEN …)
FONT_TITEL = (FONT_FAMILY, FONT_SIZE + 8, "bold") # Name der geöffneten Abrechnung
FONT_ZAHL = (MONO_FAMILY, FONT_SIZE + 6) if MODERN else (FONT_FAMILY, FONT_SIZE + 4, "bold") # Kennzahlen
SCHRIFTEN = Path(__file__).resolve().parent / "schriften"
def _schriften_laden() -> None:
"""IBM Plex (Hausschrift der Handy-Insel-Webtools) nur für dieses Programm laden – ohne Installation."""
import ctypes
import ctypes.util
dateien = sorted(SCHRIFTEN.glob("*.ttf"))
try:
if IS_WIN:
for f in dateien:
ctypes.windll.gdi32.AddFontResourceExW(str(f), 0x10, 0) # FR_PRIVATE
return
lib = ctypes.util.find_library("fontconfig")
if lib:
fc = ctypes.CDLL(lib)
fc.FcConfigAppFontAddFile.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
for f in dateien:
fc.FcConfigAppFontAddFile(None, str(f).encode())
except Exception:
pass # dann eben Segoe UI / Systemschrift
# --------------------------------------------------------------------------- Pixel-Icons (16x16) # --------------------------------------------------------------------------- Pixel-Icons (16x16)
PALETTE = { PALETTE = {
@@ -332,12 +381,15 @@ class Icons:
# --------------------------------------------------------------------------- Theme # --------------------------------------------------------------------------- Theme
def apply(root: tk.Tk) -> None: def apply(root: tk.Tk) -> None:
global FONT_FAMILY global FONT_FAMILY, MONO_FAMILY
if MODERN and not IS_WIN: # Linux: die erste vorhandene moderne Schrift if MODERN: # die erste vorhandene moderne Schrift, am liebsten IBM Plex
from tkinter import font as tkfont from tkinter import font as tkfont
_schriften_laden()
vorhanden = set(tkfont.families(root)) vorhanden = set(tkfont.families(root))
FONT_FAMILY = next((f for f in ("Segoe UI", "Noto Sans", "Cantarell", "Ubuntu", "DejaVu Sans") FONT_FAMILY = next((f for f in ("IBM Plex Sans", "Segoe UI", "Noto Sans", "Cantarell", "Ubuntu",
if f in vorhanden), FONT_FAMILY) "DejaVu Sans") if f in vorhanden), FONT_FAMILY)
MONO_FAMILY = next((f for f in ("IBM Plex Mono", "Consolas", "DejaVu Sans Mono") if f in vorhanden),
MONO_FAMILY)
_schriften() _schriften()
root.configure(bg=FACE) root.configure(bg=FACE)
o = root.option_add o = root.option_add
@@ -399,40 +451,61 @@ def apply(root: tk.Tk) -> None:
def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None: def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None:
"""Flach und hell: dünne graue Rahmen statt 3D-Kanten, Akzentfarbe beim Fokus.""" """Flach wie die Handy-Insel-Webtools: weiße Flächen, dünne Linien, Lila als Akzent."""
o = root.option_add o = root.option_add
for klasse in ("Entry", "Text", "Listbox", "Spinbox"): for klasse in ("Entry", "Text", "Listbox", "Spinbox"):
o(f"*{klasse}.relief", "flat") o(f"*{klasse}.relief", "flat")
o(f"*{klasse}.borderWidth", 3) o(f"*{klasse}.borderWidth", 4)
o(f"*{klasse}.highlightThickness", 1) o(f"*{klasse}.highlightThickness", 1)
o(f"*{klasse}.highlightBackground", RAND) o(f"*{klasse}.highlightBackground", RAND2)
o(f"*{klasse}.highlightColor", NAVY) o(f"*{klasse}.highlightColor", NAVY)
o(f"*{klasse}.background", WHITE)
o(f"*{klasse}.foreground", DARK)
o(f"*{klasse}.insertBackground", DARK)
o("*Entry.disabledBackground", LIGHT)
o("*Button.relief", "flat") o("*Button.relief", "flat")
o("*Button.borderWidth", 0) o("*Button.borderWidth", 0)
o("*Button.highlightThickness", 1) o("*Button.highlightThickness", 1)
o("*Button.highlightBackground", RAND) o("*Button.highlightBackground", RAND2)
o("*Button.background", WHITE) o("*Button.background", FACE)
o("*Button.activeBackground", SEL_BG) o("*Button.activeBackground", LIGHT)
o("*Button.padY", 3) o("*Button.padX", 12)
o("*Checkbutton.activeBackground", FACE) o("*Button.padY", 4)
o("*Radiobutton.activeBackground", FACE) o("*Button.cursor", "hand2")
for klasse in ("Checkbutton", "Radiobutton"):
o(f"*{klasse}.activeBackground", FACE)
o(f"*{klasse}.selectColor", WHITE)
o("*selectBackground", SEL_BG)
o("*selectForeground", SEL_FG)
o("*Menu.relief", "flat") o("*Menu.relief", "flat")
o("*TCombobox*Listbox.background", WHITE)
o("*TCombobox*Listbox.foreground", DARK)
o("*TCombobox*Listbox.selectBackground", SEL_BG)
o("*TCombobox*Listbox.selectForeground", SEL_FG)
st.theme_use("clam") st.theme_use("clam")
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG, st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG,
selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=WHITE, selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=FACE,
darkcolor=RAND, focuscolor=NAVY) darkcolor=RAND, focuscolor=NAVY, insertcolor=DARK)
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile + 4, borderwidth=0, font=FONT) st.configure("Treeview", background=WHITE, fieldbackground=WHITE, foreground=DARK, rowheight=zeile + 10,
borderwidth=0, font=FONT)
st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)]) st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)])
st.configure("Treeview.Heading", background=WHITE, relief="flat", borderwidth=1, font=FONT, padding=(6, 3), st.configure("Treeview.Heading", background=FACE, foreground=SHADOW, relief="flat", borderwidth=1,
bordercolor=RAND, lightcolor=WHITE, darkcolor=RAND) font=FONT_KLEIN_FETT, padding=(10, 7), bordercolor=RAND, lightcolor=FACE, darkcolor=RAND)
st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")]) st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")])
st.configure("TScrollbar", background=LIGHT, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE, # Seitenleiste: ohne Kopf, hohe Zeilen, Auswahl lila wie im VVL-Tool
lightcolor=LIGHT, darkcolor=LIGHT, relief="flat", arrowsize=12, width=12) st.configure("Seite.Treeview", background=FACE, fieldbackground=FACE, rowheight=zeile + 16, font=FONT,
st.map("TScrollbar", background=[("active", RAND)]) borderwidth=0)
st.configure("TCombobox", fieldbackground=WHITE, background=WHITE, arrowcolor=DARK, padding=3, st.layout("Seite.Treeview", [("Treeview.treearea", {"sticky": "nswe"})]) # ohne Rahmen
bordercolor=RAND, lightcolor=WHITE, darkcolor=WHITE) st.map("Seite.Treeview", background=[("selected", SEL_BG)], foreground=[("selected", NAVY)])
st.configure("TScrollbar", background=RAND2, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE,
lightcolor=RAND2, darkcolor=RAND2, relief="flat", arrowsize=10, width=10, gripcount=0)
st.map("TScrollbar", background=[("active", SHADOW)])
st.configure("TCombobox", fieldbackground=WHITE, background=FACE, foreground=DARK, arrowcolor=DARK, padding=4,
bordercolor=RAND2, lightcolor=WHITE, darkcolor=WHITE)
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)], st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)]) selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)])
st.configure("TPanedwindow", background=BG)
st.configure("Sash", sashthickness=6, background=BG)
# --------------------------------------------------------------------------- Wort löschen # --------------------------------------------------------------------------- Wort löschen
@@ -520,34 +593,6 @@ class Sunken(tk.Frame):
super().__init__(master, **kw) super().__init__(master, **kw)
class Fenster(tk.Frame):
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
if MODERN: # schlichte Überschrift statt blauer Titelleiste
super().__init__(master, bd=0, bg=FACE, **kw)
self.titel_var = tk.StringVar(value=titel)
self.titelleiste = tk.Label(self, textvariable=self.titel_var, bg=FACE, fg=DARK, font=FONT_BOLD,
anchor="w")
self.titelleiste.pack(fill="x", pady=(0, 4))
self.inhalt = tk.Frame(self, bg=FACE)
self.inhalt.pack(fill="both", expand=True)
return
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
self.titelleiste.pack(fill="x", padx=1, pady=1)
if icon is not None:
tk.Label(self.titelleiste, image=icon, bg=NAVY).pack(side="left", padx=(2, 0))
self.titel_var = tk.StringVar(value=titel)
tk.Label(self.titelleiste, textvariable=self.titel_var, bg=NAVY, fg=WHITE, font=FONT_BOLD,
anchor="w").pack(side="left", fill="x", padx=3)
self.inhalt = tk.Frame(self, bg=FACE)
self.inhalt.pack(fill="both", expand=True, padx=2, pady=2)
def set_titel(self, t: str):
self.titel_var.set(t)
class Statusleiste(tk.Frame): class Statusleiste(tk.Frame):
def __init__(self, master, felder: list[tuple[str, int]]): def __init__(self, master, felder: list[tuple[str, int]]):
super().__init__(master, bg=FACE) super().__init__(master, bg=FACE)
@@ -566,12 +611,19 @@ class Statusleiste(tk.Frame):
self.vars[key].set(text) self.vars[key].set(text)
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw): def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None,
primaer: bool = False, **kw):
"""primaer: der eine Hauptknopf einer Fläche (modern dunkel-lila gefüllt, Win95 mit dickem Rahmen)."""
if MODERN: if MODERN:
if primaer:
kw = dict(bg=PRIMAER, fg=AUF_PRIMAER, activebackground=NAVY, activeforeground=AUF_PRIMAER,
highlightbackground=PRIMAER, font=FONT_BOLD) | kw
b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern() b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern()
else: else:
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE, b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
activebackground=FACE, **kw) activebackground=FACE, **kw)
if primaer:
b.configure(default="active")
if icon is not None: if icon is not None:
b.configure(image=icon, compound="left", padx=6) b.configure(image=icon, compound="left", padx=6)
if breite: if breite:
@@ -579,34 +631,114 @@ def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, br
return b return b
class ToolbarButton(tk.Label): class Karte(tk.Frame):
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick.""" """Fläche, die Inhalte zusammenfasst (Kennzahl, Tabelle): modern weiß mit dünner Linie."""
def __init__(self, master, text: str, icon: tk.PhotoImage, command, tooltip: str = ""): def __init__(self, master, **kw):
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE, if MODERN:
padx=6, pady=2, font=FONT) kw = dict(bg=FACE, bd=0, highlightthickness=1, highlightbackground=RAND, highlightcolor=RAND) | kw
self.command = command else:
if MODERN: # Hervorhebung durch Farbe statt 3D-Kante kw = dict(bg=FACE, bd=2, relief="raised") | kw
self.configure(bd=0, padx=8, pady=4) super().__init__(master, **kw)
self.bind("<Enter>", lambda e: self.configure(bg=LIGHT))
self.bind("<Leave>", lambda e: self.configure(bg=FACE))
self.bind("<ButtonPress-1>", lambda e: self.configure(bg=SEL_BG))
self.bind("<ButtonRelease-1>", self._release)
if tooltip:
Tooltip(self, tooltip)
return
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
self.bind("<ButtonRelease-1>", self._release)
if tooltip:
Tooltip(self, tooltip)
def _release(self, e):
self.configure(bg=LIGHT) if MODERN else self.configure(relief="raised") def titelleiste(master, text: str = "") -> tuple[tk.Frame, tk.Label]:
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command: """Win95: blaue Titelleiste oben in einer Karte („Fenster im Fenster“); rechts passen kleine Knöpfe hinein."""
f = tk.Frame(master, bg=NAVY)
f.pack(fill="x", padx=1, pady=1)
lbl = tk.Label(f, text=text, bg=NAVY, fg=WHITE, font=FONT_BOLD, anchor="w", padx=3)
lbl.pack(side="left", pady=1)
return f, lbl
def abschnitt(master, text: str, **kw) -> tk.Label:
"""Kleine Überschrift (modern in Großbuchstaben und grau, wie die Tabellenköpfe der Webtools)."""
if MODERN:
return tk.Label(master, text=text.upper(), fg=SHADOW, font=FONT_KLEIN_FETT, anchor="w", **kw)
return tk.Label(master, text=text, font=FONT_BOLD, anchor="w", **kw)
def _rund(c: tk.Canvas, x0, y0, x1, y1, r, **kw):
"""Abgerundetes Rechteck auf einem Canvas."""
r = min(r, (x1 - x0) / 2, (y1 - y0) / 2)
pkt = [x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r, x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1, x0, y1,
x0, y1 - r, x0, y0 + r, x0, y0]
return c.create_polygon(pkt, smooth=True, **kw)
class Pill(tk.Canvas):
"""Umschalter für Filter („Alle“, „Von Hand geändert“ …): modern abgerundet, Win95 als Knopf,
der eingedrückt bleibt. command bekommt nichts übergeben; aktiv wird von außen gesetzt."""
def __init__(self, master, text: str, command=None, aktiv: bool = False, **kw):
from tkinter import font as tkfont
f = tkfont.Font(root=master, font=FONT)
breite, hoehe = f.measure(text) + (24 if MODERN else 16), f.metrics("linespace") + (10 if MODERN else 8)
bg = kw.pop("bg", master.cget("bg"))
super().__init__(master, width=breite, height=hoehe, bg=bg, highlightthickness=0, bd=0, takefocus=1,
cursor="hand2" if MODERN else "", **kw)
self.text, self.command, self.aktiv = text, command, aktiv
self._zeichnen()
self.bind("<Button-1>", lambda e: self._ausloesen())
self.bind("<space>", lambda e: self._ausloesen())
self.bind("<Return>", lambda e: self._ausloesen())
def _ausloesen(self):
self.focus_set()
if self.command:
self.command() self.command()
def setzen(self, aktiv: bool):
if aktiv != self.aktiv:
self.aktiv = aktiv
self._zeichnen()
def _zeichnen(self):
self.delete("all")
b, h = int(self["width"]), int(self["height"])
if MODERN:
fuell, linie, fg = (PRIMAER, PRIMAER, AUF_PRIMAER) if self.aktiv else (FACE, RAND2, TEXT2)
_rund(self, 1, 1, b - 2, h - 2, h, fill=fuell, outline=linie)
else:
oben, unten = (SHADOW, HILITE) if self.aktiv else (HILITE, SHADOW)
self.create_rectangle(0, 0, b - 1, h - 1, fill=LIGHT if self.aktiv else FACE, outline=DARK)
self.create_line(1, h - 2, 1, 1, b - 2, 1, fill=oben)
self.create_line(1, h - 2, b - 2, h - 2, b - 2, 1, fill=unten)
fg = DARK
self.create_text(b // 2 + (1 if self.aktiv and not MODERN else 0), h // 2, text=self.text, fill=fg, font=FONT)
def punkt(master, farbe: str, d: int = 9) -> tk.PhotoImage:
"""Farbiger Punkt (Status in Listen); Ecken bleiben durchsichtig."""
img = tk.PhotoImage(master=master, width=d + 4, height=d)
r = d / 2
# Win95: grauer Ring, damit der Punkt auf weißem Grund und auf der blauen Auswahl sichtbar ist
for f, rr in ((farbe, r),) if MODERN else ((SHADOW, r), (farbe, r - 1)):
for y in range(d):
xs = [x for x in range(d) if (x + .5 - r) ** 2 + (y + .5 - r) ** 2 <= rr * rr]
if xs:
img.put(f, to=(xs[0], y, xs[-1] + 1, y + 1))
return img
def logo(master, groesse: int = 34) -> tk.Canvas:
"""Logo-Kachel wie in den Webtools: Lila-Verlauf, abgerundet, weißes %."""
c = tk.Canvas(master, width=groesse, height=groesse, bg=master.cget("bg"), highlightthickness=0, bd=0)
img = tk.PhotoImage(master=master, width=groesse, height=groesse)
a, e = (0x2d, 0x1b, 0x69), (0x5e, 0x35, 0xb1)
r = groesse * 0.28
for y in range(groesse):
for x in range(groesse):
dx = max(r - x - .5, x + .5 - (groesse - r), 0)
dy = max(r - y - .5, y + .5 - (groesse - r), 0)
if dx * dx + dy * dy <= r * r:
t = (x + y) / (2 * groesse - 2)
img.put("#%02x%02x%02x" % tuple(round(a[i] + (e[i] - a[i]) * t) for i in range(3)), (x, y))
c.create_image(0, 0, image=img, anchor="nw")
c.bild = img # type: ignore[attr-defined] # Referenz halten
c.create_text(groesse / 2, groesse / 2, text="%", fill="#ffffff", font=(FONT_FAMILY, int(groesse * .4), "bold"))
return c
class Tooltip: class Tooltip:
def __init__(self, widget, text: str): def __init__(self, widget, text: str):
@@ -660,15 +792,28 @@ def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw) return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]: def _bei_bedarf(leiste: ttk.Scrollbar):
rahmen = Sunken(master, bg=WHITE) """Bildlaufleiste nur zeigen, wenn nicht alles hineinpasst."""
def setzen(lo, hi):
leiste.grid() if float(lo) > 0 or float(hi) < 1 else leiste.grid_remove()
leiste.set(lo, hi)
return setzen
def scroll_tree(master, auto: bool = False, **kw) -> tuple[tk.Frame, ttk.Treeview]:
"""Treeview mit Bildlaufleisten (modern nur bei Bedarf). auto: keine waagrechte Leiste (Seitenleiste)."""
rahmen = Sunken(master, bg=WHITE if MODERN else FACE) # Win95: graue Ecke zwischen den Leisten
tree = ttk.Treeview(rahmen, **kw) tree = ttk.Treeview(rahmen, **kw)
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview) vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
tree.grid(row=0, column=0, sticky="nsew") tree.grid(row=0, column=0, sticky="nsew")
vs.grid(row=0, column=1, sticky="ns") vs.grid(row=0, column=1, sticky="ns")
if auto and MODERN:
rahmen.configure(highlightthickness=0, bg=FACE)
if not auto:
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
hs.grid(row=1, column=0, sticky="ew") hs.grid(row=1, column=0, sticky="ew")
tree.configure(xscrollcommand=_bei_bedarf(hs) if MODERN else hs.set)
tree.configure(yscrollcommand=_bei_bedarf(vs) if auto or MODERN else vs.set)
rahmen.rowconfigure(0, weight=1) rahmen.rowconfigure(0, weight=1)
rahmen.columnconfigure(0, weight=1) rahmen.columnconfigure(0, weight=1)
return rahmen, tree return rahmen, tree
@@ -747,6 +892,8 @@ class Dialog(tk.Toplevel):
self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip) self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip)
self.deiconify() self.deiconify()
self.update_idletasks() self.update_idletasks()
if DUNKEL:
win32.dunkle_titelleiste(self)
if KLASSISCH and win32.IS_WIN: if KLASSISCH and win32.IS_WIN:
# eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam # eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam
if win32.taskleisteneintrag(self): if win32.taskleisteneintrag(self):
@@ -796,7 +943,7 @@ class Dialog(tk.Toplevel):
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame: def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
f = tk.Frame(self, bg=FACE) f = tk.Frame(self, bg=FACE)
for i, (text, cmd) in enumerate(knoepfe): for i, (text, cmd) in enumerate(knoepfe):
b = knopf(f, text, cmd, breite=10) b = knopf(f, text, cmd, breite=10, primaer=MODERN and i == default)
if i == default: if i == default:
b.configure(default="active") b.configure(default="active")
b.pack(side="left", padx=4) b.pack(side="left", padx=4)
+52 -24
View File
@@ -8,7 +8,9 @@ from __future__ import annotations
import csv import csv
import hashlib import hashlib
import io import io
from dataclasses import asdict, dataclass, field import itertools
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -18,6 +20,10 @@ FREENET = "freenet"
VODAFONE = "vodafone" VODAFONE = "vodafone"
ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"} ANBIETER = {FREENET: "freenet", VODAFONE: "Vodafone"}
# Grenzen gegen versehentlich falsche oder kaputte Dateien (echte Abrechnungen: wenige MB, einige tausend Zeilen)
MAX_BYTES = 100 * 1024 * 1024 # Dateigröße, bei .xlsx auch entpackt
MAX_ZEILEN = 200_000 # Datenzeilen je Tabelle
@dataclass @dataclass
class Position: class Position:
@@ -39,11 +45,6 @@ class Position:
quelle: str = "" quelle: str = ""
roh: dict = field(default_factory=dict) roh: dict = field(default_factory=dict)
def as_dict(self) -> dict:
d = asdict(self)
d["datum"] = self.datum.isoformat() if self.datum else None
return d
@dataclass @dataclass
class ImportErgebnis: class ImportErgebnis:
@@ -183,15 +184,24 @@ FORMATE: list[Format] = [FreenetFormat(), VodafoneFormat()]
# --------------------------------------------------------------------------- Tabellen lesen # --------------------------------------------------------------------------- Tabellen lesen
def _tabellen_aus_rows(rows: list[list], quelle: str, blatt: str = "") -> tuple[Format, list[Position]] | None: def _tabellen_aus_rows(rows: Iterable[list], quelle: str, blatt: str = "",
"""Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter.""" nur_kopf: bool = False) -> tuple[Format, list[Position]] | None:
for idx, row in enumerate(rows[:40]): """Sucht in den ersten 40 Zeilen eine bekannte Kopfzeile und liest alle Datenzeilen darunter.
Ohne Kopfzeile (oder mit nur_kopf) wird der Rest der Tabelle gar nicht gelesen."""
rows = iter(rows)
anfang = list(itertools.islice(rows, 40))
for idx, row in enumerate(anfang):
header = [_norm(c) for c in row] header = [_norm(c) for c in row]
for fmt in FORMATE: for fmt in FORMATE:
if fmt.passt(header): if fmt.passt(header):
pos: list[Position] = [] pos: list[Position] = []
if nur_kopf:
return fmt, pos
leer = 0 leer = 0
for data in rows[idx + 1:]: for nr, data in enumerate(itertools.chain(anfang[idx + 1:], rows)):
if nr >= MAX_ZEILEN:
raise ImportFehler(f"„{quelle}“ hat mehr als {MAX_ZEILEN // 1000}.000 Zeilen – das ist "
"keine Provisionsabrechnung.")
if not any(_text(c) for c in data): if not any(_text(c) for c in data):
leer += 1 leer += 1
if leer > 20: if leer > 20:
@@ -222,7 +232,7 @@ def _decode(raw: bytes) -> str:
return raw.decode("latin-1", errors="replace") return raw.decode("latin-1", errors="replace")
def _lies_csv(path: Path) -> list[tuple[str, list[list]]]: def _lies_csv(path: Path) -> Iterator[tuple[str, Iterable[list]]]:
text = _decode(path.read_bytes()) text = _decode(path.read_bytes())
sample = text[:5000] sample = text[:5000]
try: try:
@@ -230,15 +240,20 @@ def _lies_csv(path: Path) -> list[tuple[str, list[list]]]:
delim = dialect.delimiter delim = dialect.delimiter
except csv.Error: except csv.Error:
delim = ";" delim = ";"
rows = list(csv.reader(io.StringIO(text), delimiter=delim)) yield "", csv.reader(io.StringIO(text), delimiter=delim)
return [("", rows)]
def _lies_xlsx(path: Path) -> list[tuple[str, list[list]]]: def _lies_xlsx(path: Path) -> Iterator[tuple[str, Iterable[list]]]:
import zipfile
import openpyxl import openpyxl
with zipfile.ZipFile(path) as z: # entpackte Größe prüfen (eine .xlsx ist ein Zip-Archiv)
if sum(i.file_size for i in z.infolist()) > MAX_BYTES:
raise ImportFehler(f"„{path.name}“ ist entpackt größer als {MAX_BYTES // 1024 // 1024} MB.")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True) wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
try: try:
return [(ws.title.strip(), [list(r) for r in ws.iter_rows(values_only=True)]) for ws in wb.worksheets] for ws in wb.worksheets: # Zeilen werden erst beim Durchlaufen gelesen (nur Blätter mit Kopfzeile ganz)
yield ws.title.strip(), (list(r) for r in ws.iter_rows(values_only=True))
finally: finally:
wb.close() wb.close()
@@ -265,19 +280,33 @@ def _lies_xls(path: Path) -> list[tuple[str, list[list]]]:
def lies_datei(path: str | Path) -> ImportErgebnis: def lies_datei(path: str | Path) -> ImportErgebnis:
path = Path(path) path = Path(path)
ext = path.suffix.lower() ext = path.suffix.lower()
if ext not in (".csv", ".txt", ".xlsx", ".xlsm", ".xls"):
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.")
if path.stat().st_size > MAX_BYTES:
raise ImportFehler(f"„{path.name}“ ist größer als {MAX_BYTES // 1024 // 1024} MB – das ist keine "
"Provisionsabrechnung.")
if ext in (".csv", ".txt"): if ext in (".csv", ".txt"):
blaetter = _lies_csv(path) blaetter = _lies_csv(path)
elif ext in (".xlsx", ".xlsm"): elif ext in (".xlsx", ".xlsm"):
blaetter = _lies_xlsx(path) blaetter = _lies_xlsx(path)
elif ext == ".xls":
blaetter = _lies_xls(path)
else: else:
raise ImportFehler(f"Dateityp {ext or '(ohne Endung)'} wird nicht unterstützt.") blaetter = _lies_xls(path)
gefunden: list[tuple[str, Format, list[Position]]] = [] gefunden: list[tuple[str, Format, list[Position]]] = []
ignoriert: list[str] = []
erstes = None
for name, rows in blaetter: for name, rows in blaetter:
res = _tabellen_aus_rows(rows, path.name, name) if erstes is None:
if res and res[1]: erstes = name
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – weitere Blätter mit Abrechnung
# werden nur erkannt (für den Hinweis), aber nicht mehr gelesen
vodafone_schon = any(f.key == VodafoneFormat.key for _, f, _ in gefunden)
res = _tabellen_aus_rows(rows, path.name, name, nur_kopf=vodafone_schon)
if not res:
continue
if vodafone_schon:
ignoriert.append(name)
elif res[1]:
gefunden.append((name, res[0], res[1])) gefunden.append((name, res[0], res[1]))
if not gefunden: if not gefunden:
@@ -288,11 +317,10 @@ def lies_datei(path: str | Path) -> ImportErgebnis:
) )
warnungen: list[str] = [] warnungen: list[str] = []
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – sie enthält alle # Vodafone/Euronics: die erste Seite ("All") enthält alle Abrechnungsläufe, weitere Blätter würden
# Abrechnungsläufe. Weitere Blätter würden Positionen doppelt zählen. # Positionen doppelt zählen.
if any(f.key == VodafoneFormat.key for _, f, _ in gefunden): if any(f.key == VodafoneFormat.key for _, f, _ in gefunden):
erstes = blaetter[0][0] uebrig = [g[0] for g in gefunden if g[0] != erstes] + ignoriert
uebrig = [g[0] for g in gefunden if g[0] != erstes]
gefunden = [g for g in gefunden if g[0] == erstes] gefunden = [g for g in gefunden if g[0] == erstes]
if uebrig: if uebrig:
warnungen.append(f"Nur die erste Seite „{erstes}“ übernommen, ignoriert: {', '.join(uebrig)}") warnungen.append(f"Nur die erste Seite „{erstes}“ übernommen, ignoriert: {', '.join(uebrig)}")
+2 -2
View File
@@ -122,8 +122,8 @@ def ausfuehren(ergebnisdatei: str) -> int:
# 2) Assistent komplett, Import, Export, Rundgang, Beenden # 2) Assistent komplett, Import, Export, Rundgang, Beenden
csv = basis / "freenet.csv" csv = basis / "freenet.csv"
_beispiel_csv(csv) _beispiel_csv(csv)
# Win95-Design: prüft auch den nativen Fensterrahmen (Szenario 1 läuft im modernen Standard) # Win95-Design im normalen Fenster: prüft den nativen Rahmen (Szenario 1: modern, startet maximiert)
s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95"}') s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95", "vollbild": false}')
wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731 wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731
seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731 seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731
+82
View File
@@ -0,0 +1,82 @@
"""Updates: neuestes Release auf Gitea finden und das Windows-Setup herunterladen.
Nur Netzwerk und Dateien – kein Tk (läuft im Hintergrund-Thread). Das Installieren startet die App (gui/app.py).
"""
from __future__ import annotations
import json
import re
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
HOST = "git.handy-insel.de"
API = f"https://{HOST}/api/v1/repos/kerimatilgan/provisionsmanager95/releases/latest"
SETUP = re.compile(r"Provisionsmanager-Setup-[\d.]+\.exe")
MAX_BYTES = 200 * 1024 * 1024
class UpdateFehler(Exception):
pass
def version_tupel(v: str) -> tuple[int, ...]:
"""'v1.10.0' -> (1, 10, 0); Unlesbares -> ()"""
m = re.fullmatch(r"v?(\d+(?:\.\d+)*)", (v or "").strip())
return tuple(int(x) for x in m[1].split(".")) if m else ()
def ist_neuer(neu: str, alt: str) -> bool:
return version_tupel(neu) > version_tupel(alt) and bool(version_tupel(alt))
def _eigene_url(url: str) -> bool:
"""Nur über HTTPS vom eigenen Gitea laden (die Antwort des Servers ist hier die Vertrauensgrenze)."""
u = urlparse(url)
return u.scheme == "https" and u.hostname == HOST
def neueste(timeout: float = 8) -> dict:
"""Neuestes Release: {"version", "seite", "setup_url", "groesse"} (setup_url None, wenn kein Setup dabei)."""
with urllib.request.urlopen(API, timeout=timeout) as r:
daten = json.loads(r.read(1024 * 1024).decode("utf-8"))
setup = next((a for a in daten.get("assets", []) if SETUP.fullmatch(a.get("name", ""))), None)
url = setup.get("browser_download_url") if setup else None
if url and not _eigene_url(url):
url = None
seite = daten.get("html_url") or ""
return {"version": str(daten.get("tag_name", "")).lstrip("v"),
"seite": seite if _eigene_url(seite) else f"https://{HOST}/kerimatilgan/provisionsmanager95/releases",
"setup_url": url, "groesse": int(setup.get("size") or 0) if setup else 0}
def herunterladen(info: dict, ordner: Path, timeout: float = 60) -> Path:
"""Setup nach `ordner` laden und prüfen (Größe wie angekündigt, Windows-Programm). Gibt den Pfad zurück."""
url = info.get("setup_url")
if not url or not _eigene_url(url):
raise UpdateFehler("Für diese Version gibt es kein Setup zum Herunterladen.")
ordner.mkdir(parents=True, exist_ok=True)
ziel = ordner / Path(urlparse(url).path).name
if not SETUP.fullmatch(ziel.name):
raise UpdateFehler(f"Unerwarteter Dateiname: {ziel.name}")
teil = ziel.with_suffix(".teil")
n = 0
try:
with urllib.request.urlopen(url, timeout=timeout) as r, teil.open("wb") as f:
if not _eigene_url(r.geturl()): # Weiterleitung auf einen fremden Server
raise UpdateFehler("Download wurde auf einen fremden Server umgeleitet.")
while block := r.read(1 << 16):
n += len(block)
if n > MAX_BYTES:
raise UpdateFehler("Download ist unerwartet groß.")
f.write(block)
except BaseException:
teil.unlink(missing_ok=True)
raise
with teil.open("rb") as f:
kopf = f.read(2)
if kopf != b"MZ" or (info.get("groesse") and n != info["groesse"]):
teil.unlink(missing_ok=True)
raise UpdateFehler("Download ist unvollständig oder beschädigt – bitte später erneut versuchen.")
teil.replace(ziel)
return ziel
+138 -1
View File
@@ -29,7 +29,8 @@ class Lauf:
def __init__(self, home, einstellungen: dict | None = None): def __init__(self, home, einstellungen: dict | None = None):
self.home = home self.home = home
if einstellungen is not None: # Tests starten im normalen Fenster (die App sonst maximiert)
einstellungen = {"vollbild": False} | (einstellungen or {})
(home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8") (home / "einstellungen.json").write_text(json.dumps(einstellungen), encoding="utf-8")
self.root = None self.root = None
for versuch in range(3): for versuch in range(3):
@@ -1122,6 +1123,10 @@ def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkey
app.ptree.selection_set(app.ptree.get_children()[0]) app.ptree.selection_set(app.ptree.get_children()[0])
app.prozent() app.prozent()
assert "erledigt" in meldungen[-1] assert "erledigt" in meldungen[-1]
# ... auch der Export (würde sonst offene Positionen abrechnen)
n = len(meldungen)
app.exportieren()
assert lauf.dialog(ExportDialog) is None and len(meldungen) == n + 1 and "erledigt" in meldungen[-1]
lauf.pruefen() lauf.pruefen()
@@ -1129,3 +1134,135 @@ def _alle(widget):
for kind in widget.winfo_children(): for kind in widget.winfo_children():
yield kind yield kind
yield from _alle(kind) yield from _alle(kind)
def test_abrechnungen_links_und_import_fragt_nach_ziel(lauf_modern, home, monkeypatch):
from provtool.gui import win95 as w
from provtool.gui.dialogs import AbrechnungenDialog, ImportBericht
monkeypatch.setattr(w, "frage", lambda *a, **k: True) # „bereits importiert – trotzdem?“ (Testdatei ist schon drin)
lauf = lauf_modern
app = lauf.app
alt = app.aid
neu = app.db.abrechnung_neu("November 2026")
app._aktualisieren()
assert set(app.atree.get_children()) == {str(alt), str(neu)}, "Abrechnungen fehlen links"
app.atree.selection_set(str(neu))
lauf.root.update()
assert app.aid == neu and not app.positionen, "Klick links öffnet die Abrechnung nicht"
app.atree.selection_set(str(alt))
lauf.root.update()
def ziel_waehlen():
d = lauf.dialog(AbrechnungenDialog)
d.tree.selection_set(str(neu))
d._oeffnen()
lauf.ablauf([
(IMMER, lambda: app.importieren([str(home / "freenet.csv")]), "Import"),
(lambda: lauf.dialog(AbrechnungenDialog) is not None, ziel_waehlen, "Ziel: November"),
(lambda: lauf.dialog(ImportBericht) is not None, lambda: lauf.dialog(ImportBericht).abbrechen(), "Bericht zu"),
])
assert not lauf.uebrig, lauf.diagnose()
assert app.aid == neu and len(app.positionen) == 3, "Import nicht in der gewählten Abrechnung"
assert app.atree.selection() == (str(neu),)
lauf.pruefen()
def test_filter_kennzahlen_und_hell_dunkel(lauf_modern):
"""Filter-Knöpfe über der Tabelle, Kennzahl-Kacheln und der Hell/Dunkel-Schalter in der Kopfzeile."""
from provtool import config
from provtool.db import EIGEN
lauf = lauf_modern
app = lauf.app
alle = [p for p in app.positionen if p["haendler_id"] != EIGEN]
assert app.kpi["pos"][0].cget("text") == str(len(alle))
p = alle[0]
app.db.betrag_setzen(p["id"], 123)
app._aktualisieren()
app.pills["manuell"]._ausloesen() # wie ein Klick (event_generate kommt unter Wine nicht an)
lauf.root.update()
manuell = [x for x in app.positionen if x["manuell"] and x["haendler_id"] != EIGEN]
assert len(manuell) == 1
zeilen = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
assert len(zeilen) == len(manuell), "Filter „Von Hand geändert“ wirkt nicht"
assert app.kpi["pos"][0].cget("text") == str(len(manuell))
assert app.pills["manuell"].aktiv and not app.pills["alle"].aktiv
app._filter_setzen("alle")
assert len([i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]) == len(alle)
app.design_umschalten() # schließt das Fenster; main() baut es danach dunkel neu auf
for _ in range(50):
if app._beendet:
break
lauf.root.update()
assert app.neustart and app._beendet
assert config.load()["design"] == "dunkel"
def test_startet_maximiert(home):
_profil_mit_daten(home)
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "vollbild": True, "design": "win95"})
try:
for _ in range(30):
lauf.root.update()
time.sleep(0.02)
assert lauf.app.rahmen.maximiert, "Programm startet nicht maximiert"
finally:
lauf.ende()
def test_suche_und_filter_blenden_nur_aus(lauf_modern):
"""Suche/Filter bauen die Tabelle nicht neu auf: Zeilen werden aus- und wieder eingehängt."""
lauf = lauf_modern
app = lauf.app
alle = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
assert len(alle) == 3
app.v_suche.set("Beispiel")
app._suche_ausfuehren()
sichtbar = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]
assert len(sichtbar) == 1 and app.ptree.item(sichtbar[0], "text") == "Beispiel KG"
assert all(app.ptree.exists(i) for i in alle), "ausgeblendete Zeilen bleiben erhalten (nur ausgehängt)"
assert app.kpi["pos"][0].cget("text") == "1"
gruppe = app.ptree.parent(sichtbar[0])
assert "(1)" in app.ptree.item(gruppe, "text") and app.ptree.item(gruppe, "open")
app.ptree.selection_set(gruppe)
assert app._ausgewaehlte_pids() == [int(sichtbar[0][1:])], "Gruppenauswahl = nur sichtbare Zeilen"
app.v_suche.set("")
app._suche_ausfuehren()
assert [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)] == alle
assert app.kpi["pos"][0].cget("text") == "3"
lauf.pruefen()
def test_start_mit_unerreichbarer_datenbank_fragt(home, monkeypatch):
"""Eingestellter (Netz-)Pfad fehlt: Rückfrage statt Absturz – und es wird dort keine leere DB angelegt."""
from provtool import config
from provtool.gui import win95 as w
_profil_mit_daten(home)
weg = home / "netz" / "provisionen.db"
fragen = []
monkeypatch.setattr(w, "_meldung", lambda m, t, text, icon, knoepfe, default=0: fragen.append(text) or "standard")
lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "db_pfad": str(weg)})
try:
assert len(fragen) == 1 and str(weg) in fragen[0]
assert not weg.exists() and lauf.app.db.path == config.app_dir() / "provisionen.db"
assert config.load()["db_pfad"] is None, "Wahl „Standard“ wird gespeichert"
assert len(lauf.app.positionen) == 3
finally:
lauf.ende()
lauf.pruefen()
# „Beenden“ gewählt: Programm endet sauber
monkeypatch.setattr(w, "_meldung", lambda *a, **k: "ende")
(home / "einstellungen.json").write_text(json.dumps({"db_pfad": str(weg), "vollbild": False}), encoding="utf-8")
from provtool.gui import app as A
root = tk.Tk()
with pytest.raises(SystemExit):
A.App(root)
assert not weg.exists()
def test_inhalt_erbt_nicht_den_zeiger_der_trennlinie(lauf_modern):
"""Der Resize-Zeiger der Trennlinie blieb unter Windows im ganzen Inhalt hängen (Kinder erben den Zeiger
der Trennlinie). Beide Bereiche haben daher einen eigenen Zeiger."""
app = lauf_modern.app
assert [str(app.root.nametowidget(str(p)).cget("cursor")) for p in app.pw.panes()] == ["arrow", "arrow"]
+248 -2
View File
@@ -468,12 +468,16 @@ def test_abgerechnet_einfrieren_und_doppelt_erkennen(db, freenet_csv):
db.haendler_speichern(db.haendler(hid), [("freenet", "111")]) db.haendler_speichern(db.haendler(hid), [("freenet", "111")])
a2 = db.abrechnung_neu("Nov") a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv)) db.importieren(a2, lies_datei(freenet_csv))
# (2 davon schon abgerechnet, die 3 anderen sind in „Okt“ noch offen – auch die zählen nur einmal)
doppelt = [p for p in db.positionen(a2) if p["doppelt"]] doppelt = [p for p in db.positionen(a2) if p["doppelt"]]
assert len(doppelt) == 2 and all(p["doppelt"] == "Okt" for p in doppelt) assert len(doppelt) == 5 and all(p["doppelt"] == "Okt" for p in doppelt)
assert db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden" assert db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden"
# Kennzeichnung aufheben -> nicht mehr doppelt # Kennzeichnung aufheben -> die Positionen in „Okt“ sind wieder offen und bleiben die zuerst importierten;
# „Nov“ bleibt doppelt. Erst wenn „Okt“ weg ist, ist „Nov“ frei.
assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2 assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2
assert all(p["doppelt"] == "Okt" for p in db.positionen(a2))
db.abrechnung_loeschen(a1)
assert not any(p["doppelt"] for p in db.positionen(a2)) assert not any(p["doppelt"] for p in db.positionen(a2))
@@ -515,3 +519,245 @@ def test_migration_v3_fingerabdruck(tmp_path, freenet_csv):
assert all(p["fp"] for p in d.positionen(aid)) assert all(p["fp"] for p in d.positionen(aid))
assert len({p["fp"] for p in d.positionen(aid)}) == 5 assert len({p["fp"] for p in d.positionen(aid)}) == 5
d.close() d.close()
# --------------------------------------------------------------------------- Audit-Befunde (Nachtests)
def test_offene_doppelte_nur_einmal_exportieren_und_abrechnen(db, freenet_csv, tmp_path):
"""Wochen- und Monatsdatei mit denselben Zeilen in EINER Abrechnung: die zweite ist „doppelt“ und
wird weder exportiert noch abgerechnet. Zwei gleiche Zeilen in EINER Datei bleiben zwei Positionen."""
hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
monat = tmp_path / "monat.csv"
monat.write_bytes(freenet_csv.read_bytes() + freenet_csv.read_bytes().split(b"\r\n", 1)[1]) # Zeilen 2x
db.importieren(aid, lies_datei(monat))
pos = db.positionen(aid)
assert len(pos) == 15
erste_datei = [p for p in pos if p["datei_id"] == db.dateien(aid)[0]["id"]]
assert not any(p["doppelt"] for p in erste_datei), "die zuerst importierte Datei zählt"
zweite = [p for p in pos if p["datei_id"] != db.dateien(aid)[0]["id"]]
assert sum(1 for p in zweite if p["doppelt"] == "Okt") == 5, "je Zeile ist nur die erste Wiederholung doppelt"
assert sum(1 for p in zweite if not p["doppelt"]) == 5, "zweite Vorkommen innerhalb der Datei (#2) sind eigene Positionen"
# abrechnen aller Händler-Positionen: Doppelte bleiben offen
n = db.abrechnen([p["id"] for p in pos])
offen_haendler = [p for p in db.positionen(aid) if p["haendler_id"] == hid and not p["abgerechnet"]]
assert n == 8 and all(p["doppelt"] for p in offen_haendler)
# und noch einmal: derselbe Fingerabdruck wird in der Transaktion erneut geprüft
assert db.abrechnen([p["id"] for p in db.positionen(aid)]) == 0
def test_staffel_ohne_doppelte_und_datei_entfernen_rechnet_neu(db, bonus_csv, tmp_path):
from provtool import regeln
hid = db.haendler_speichern(Haendler(0, "Sub"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(bonus_csv))
kopie = tmp_path / "bonus2.csv"
kopie.write_bytes(bonus_csv.read_bytes())
db.importieren(aid, lies_datei(kopie))
bonus = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and p["haendler_id"] == hid]
assert len(bonus) == 26 and sum(1 for p in bonus if p["doppelt"]) == 13
echte = [p for p in bonus if not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1000] * 12, "Doppelte zählen nicht als Karten (13, nicht 26)"
# zweite Datei (3 Zeilen) zusätzlich mit anderen Rufnummern -> 16 Karten -> Stufe 15: 15 € je Karte
extra = tmp_path / "extra.csv"
extra.write_bytes(("\r\n".join([FREENET_KOPF] + [_pos("Jahresbonus Neuaktivierung", "40", rufnr=f"17000000{i:02d}")
for i in range(3)]) + "\r\n").encode("cp1252"))
db.importieren(aid, lies_datei(extra))
echte = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1500] * 15
# Datei entfernen -> Staffel der übrigen wieder 10 € je Karte
db.datei_entfernen(db.dateien(aid)[-1]["id"])
echte = [p for p in db.positionen(aid) if p["typ"] == regeln.JAHRESBONUS and not p["doppelt"]]
assert sorted(p["betrag"] for p in echte) == [300] + [1000] * 12
def test_abgerechnete_positionen_nicht_loeschbar(db, freenet_csv):
"""Datei/Abrechnung mit abgerechneten Positionen: Löschen verweigert (sonst Fingerabdruck-Leichen
oder doppelte Auszahlung). Nach Aufheben der Kennzeichnung geht es – und hinterlässt keine Fingerabdrücke."""
from provtool.db import AbrechnungErledigt
db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
pos = db.positionen(aid)
assert db.abrechnen([pos[0]["id"]]) == 1
with pytest.raises(AbrechnungErledigt):
db.datei_entfernen(db.dateien(aid)[0]["id"])
with pytest.raises(AbrechnungErledigt):
db.abrechnung_loeschen(aid)
assert len(db.positionen(aid)) == 5
db.abrechnung_aufheben([pos[0]["id"]])
db.abrechnung_loeschen(aid)
assert db.con.execute("SELECT count(*) FROM abgerechnet_fp").fetchone()[0] == 0
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
assert not any(p["doppelt"] for p in db.positionen(a2))
def test_erledigt_offene_positionen_bekommen_keinen_haendler_mehr(db, freenet_csv):
"""Erledigt: eine damals nicht zugeordnete Position darf einem später angelegten Händler nicht mit
100 % zur Auszahlung erscheinen; abrechnen() fasst erledigte Abrechnungen nicht an."""
db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")])
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
db.erledigt_setzen(aid, True)
hid_b = db.haendler_speichern(Haendler(0, "B", standard_prozent=50), [("freenet", "999")])
p = next(p for p in db.positionen(aid) if p["kennung"] == "999")
assert p["haendler_id"] == UNZUGEORDNET and not p["abgerechnet"]
assert db.abrechnen([p["id"]]) == 0
# in einer offenen Abrechnung gehört dieselbe Kennung sehr wohl zu B (mit 50 %)
a2 = db.abrechnung_neu("Nov")
db.importieren(a2, lies_datei(freenet_csv))
q = next(p for p in db.positionen(a2) if p["kennung"] == "999")
assert q["haendler_id"] == hid_b and q["betrag"] == -400
def test_export_text_mit_gleichheitszeichen_bleibt_text(tmp_path):
pos = [{"anbieter": "freenet", "zeitraum": "2026.10.01-2026.10.07", "provisionsart": "=1+1", "kennung": "111",
"datum": "2026-10-05", "rufnummer": "1601", "imei": "", "geraet": "", "vorname": "",
"name": '=HYPERLINK("http://böse.example","Klick")', "tarif": "T", "betrag": 800, "betrag_orig": 800,
"netz": "", "dienst": "", "aktionscode": "=cmd|' /C calc'!A0", "typ": "normal", "reihenfolge": 1}]
datei = export.exportiere_haendler(tmp_path, "A", pos, "Okt")[0]
ws = openpyxl.load_workbook(datei).active
zeile = [c for c in ws[1]]
assert zeile[1].value == "=1+1" and zeile[1].data_type == "s"
assert zeile[8].value.startswith("=HYPERLINK") and zeile[8].data_type == "s"
assert zeile[13].data_type == "s"
summe = next(c for row in ws.iter_rows() for c in row if isinstance(c.value, str) and c.value.startswith("=SUM"))
assert summe.data_type == "f", "unsere Summenformel bleibt eine Formel"
def test_export_gleiche_dateinamen_ueberschreiben_sich_nicht(tmp_path):
p = {"anbieter": "freenet", "zeitraum": "", "provisionsart": "X", "kennung": "1", "datum": None, "rufnummer": "",
"imei": "", "geraet": "", "vorname": "", "name": "K", "tarif": "", "betrag": 100, "betrag_orig": 100,
"netz": "", "dienst": "", "aktionscode": "", "typ": "normal", "reihenfolge": 1}
vergeben: set[str] = set()
a = export.exportiere_haendler(tmp_path, "A/B", [p], "Okt", vergeben=vergeben)
b = export.exportiere_haendler(tmp_path, "A:B", [dict(p, betrag=200)], "Okt", vergeben=vergeben)
assert a[0].name == "A_B - Okt.xlsx" and b[0].name == "A_B - Okt (2).xlsx"
assert openpyxl.load_workbook(a[0]).active["K1"].value == 1.0
assert openpyxl.load_workbook(b[0]).active["K1"].value == 2.0
def test_restore_prueft_sicherung(db, freenet_csv, tmp_path):
import sqlite3
aid = db.abrechnung_neu("Okt")
db.importieren(aid, lies_datei(freenet_csv))
db.close()
ziel = db.path
vorher = ziel.read_bytes()
# keine SQLite-Datei
muell = tmp_path / "muell.db"
muell.write_bytes(b"kein sqlite")
with pytest.raises(Exception):
Datenbank.restore(muell, ziel)
# SQLite, aber nicht unsere Tabellen
fremd = tmp_path / "fremd.db"
con = sqlite3.connect(fremd)
con.execute("CREATE TABLE haendler (id INTEGER)")
con.commit()
con.close()
with pytest.raises(ValueError):
Datenbank.restore(fremd, ziel)
assert ziel.read_bytes() == vorher and not ziel.with_suffix(".pruefung.db").exists()
# echte Sicherung
d = Datenbank(ziel)
sicherung = tmp_path / "sicherung.db"
d.backup(sicherung)
d.abrechnung_loeschen(aid)
d.close()
Datenbank.restore(sicherung, ziel)
d = Datenbank(ziel)
assert len(d.positionen(aid)) == 5
d.close()
def test_import_grenzen(tmp_path, freenet_csv, vodafone_xlsx, monkeypatch):
from provtool import importer
monkeypatch.setattr(importer, "MAX_ZEILEN", 3)
with pytest.raises(ImportFehler, match="Zeilen"):
lies_datei(freenet_csv)
monkeypatch.setattr(importer, "MAX_ZEILEN", 200_000)
monkeypatch.setattr(importer, "MAX_BYTES", 10)
with pytest.raises(ImportFehler, match="MB"):
lies_datei(freenet_csv)
monkeypatch.setattr(importer, "MAX_BYTES", vodafone_xlsx.stat().st_size + 1) # gepackt klein, entpackt zu groß
with pytest.raises(ImportFehler, match="entpackt"):
lies_datei(vodafone_xlsx)
def test_einstellungen_atomar_und_defekt(tmp_path, monkeypatch):
from provtool import config
monkeypatch.setenv("PROVTOOL_HOME", str(tmp_path))
config.save({"db_pfad": "Z:/x.db", "defekt": "weg"})
assert not (tmp_path / "einstellungen.json.tmp").exists()
assert config.load()["db_pfad"] == "Z:/x.db" and "defekt" not in config.load()
(tmp_path / "einstellungen.json").write_text("{ kaputt", encoding="utf-8")
daten = config.load()
assert daten["db_pfad"] is None and daten["defekt"].endswith("einstellungen.defekt.json")
assert (tmp_path / "einstellungen.defekt.json").read_text(encoding="utf-8") == "{ kaputt"
# --------------------------------------------------------------------------- Updates
class _Antwort:
"""Ersatz für urlopen(): liefert Bytes, kennt seine (End-)URL."""
def __init__(self, daten: bytes, url: str):
import io
self._f, self._url = io.BytesIO(daten), url
def read(self, n=-1):
return self._f.read(n)
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
return False
def test_update_version_vergleich():
from provtool import updater
assert updater.ist_neuer("1.10.0", "1.9.2") and updater.ist_neuer("v1.8.0", "1.7.0")
assert not updater.ist_neuer("1.7.0", "1.7.0") and not updater.ist_neuer("1.6.9", "1.7.0")
assert not updater.ist_neuer("kaputt", "1.7.0") and not updater.ist_neuer("1.8.0", "")
def test_update_neueste_nimmt_nur_eigenes_setup(monkeypatch):
import json
from provtool import updater
basis = "https://git.handy-insel.de/kerimatilgan/provisionsmanager95/releases/download/v1.8.0/"
release = {"tag_name": "v1.8.0", "html_url": "https://git.handy-insel.de/x/releases/tag/v1.8.0", "assets": [
{"name": "Provisionsmanager95.exe", "browser_download_url": basis + "Provisionsmanager95.exe", "size": 5},
{"name": "Provisionsmanager-Setup-1.8.0.exe", "browser_download_url": basis + "Provisionsmanager-Setup-1.8.0.exe",
"size": 7}]}
monkeypatch.setattr(updater.urllib.request, "urlopen",
lambda url, timeout=0: _Antwort(json.dumps(release).encode(), url))
info = updater.neueste()
assert info == {"version": "1.8.0", "seite": release["html_url"],
"setup_url": basis + "Provisionsmanager-Setup-1.8.0.exe", "groesse": 7}
release["assets"][1]["browser_download_url"] = "https://boese.example/Provisionsmanager-Setup-1.8.0.exe"
assert updater.neueste()["setup_url"] is None, "Setup von fremdem Server darf nicht geladen werden"
def test_update_download_wird_geprueft(monkeypatch, tmp_path):
from provtool import updater
url = "https://git.handy-insel.de/r/releases/download/v1.8.0/Provisionsmanager-Setup-1.8.0.exe"
inhalt = {"daten": b"MZ" + b"x" * 98, "url": url}
monkeypatch.setattr(updater.urllib.request, "urlopen", lambda u, timeout=0: _Antwort(inhalt["daten"], inhalt["url"]))
setup = updater.herunterladen({"setup_url": url, "groesse": 100}, tmp_path)
assert setup.name == "Provisionsmanager-Setup-1.8.0.exe" and setup.read_bytes().startswith(b"MZ")
for daten, ende, groesse in ((b"MZ" + b"x" * 10, url, 100), # unvollständig
(b"PK" + b"x" * 98, url, 100), # kein Windows-Programm
(b"MZ" + b"x" * 98, "https://boese.example/a.exe", 100)): # umgeleitet
inhalt.update(daten=daten, url=ende)
setup.unlink(missing_ok=True)
with pytest.raises(updater.UpdateFehler):
updater.herunterladen({"setup_url": url, "groesse": groesse}, tmp_path)
assert not list(tmp_path.iterdir()), "halber Download bleibt liegen"
with pytest.raises(updater.UpdateFehler):
updater.herunterladen({"setup_url": "http://git.handy-insel.de/a/Provisionsmanager-Setup-1.8.0.exe"}, tmp_path)