diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml index f1fd953..7cf5c8d 100644 --- a/.gitea/workflows/windows-build.yml +++ b/.gitea/workflows/windows-build.yml @@ -1,7 +1,7 @@ -# Baut die Windows-Version (Provisionsmanager95.exe) mit Windows-Python unter Wine. -# - jeder Push / PR: Tests + .exe als Build-Artefakt -# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe und .zip -name: Windows-Build +# Baut die Windows-Version (.exe, mit Windows-Python unter Wine) und die Linux-Version. +# - jeder Push / PR: Tests + Programmdateien als Build-Artefakte +# - Tag "v*" (z. B. v1.0.0): zusätzlich Gitea-Release mit .exe, .zip und Linux-.tar.gz +name: Build on: push: @@ -38,23 +38,40 @@ jobs: dist/Provisionsmanager95.exe dist/Provisionsmanager95-*.zip - - name: Release erstellen + - name: Release erstellen / ergänzen if: startsWith(github.ref, 'refs/tags/v') env: TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} TAG: ${{ github.ref_name }} + run: build/release_hochladen.sh dist/Provisionsmanager95.exe dist/Provisionsmanager95-$(tr -d '\r\n' < dist/VERSION).zip + + linux: + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + image: python:3.12-bookworm + steps: + - name: Werkzeuge installieren run: | - set -eu - VERSION=$(cat dist/VERSION | tr -d '\r\n') - if [ "v$VERSION" != "$TAG" ]; then - echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1 - fi - resp=$(curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ - -d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows-Version $VERSION – einfach die .exe herunterladen und starten.\"}") - id=$(echo "$resp" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) - for f in dist/Provisionsmanager95.exe dist/Provisionsmanager95-$VERSION.zip; do - curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \ - -H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null - echo "hochgeladen: $f" - done + apt-get update -qq + apt-get install -y -qq --no-install-recommends git nodejs ca-certificates xvfb xauth fonts-dejavu-core >/dev/null + + - uses: actions/checkout@v4 + + - name: Tests + Build (Linux) + run: ./build/build_linux.sh + + - name: Build-Artefakt hochladen + uses: actions/upload-artifact@v3 + with: + name: Provisionsmanager95-Linux + path: dist/Provisionsmanager95-linux-*.tar.gz + + - name: Release ergänzen + if: startsWith(github.ref, 'refs/tags/v') + env: + TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + TAG: ${{ github.ref_name }} + run: build/release_hochladen.sh dist/Provisionsmanager95-linux-x86_64-*.tar.gz diff --git a/README.md b/README.md index 4f1ee82..24db9e4 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,18 @@ Das Hauptfenster ist unter Windows ein echtes Programmfenster mit Win95-Optik: a ziehen (Andocken), `Win`+Pfeiltasten, Doppelklick auf die Titelleiste und Ziehen am Rand funktionieren wie gewohnt. +**Zeitraum abrechnen:** Im Export-Dialog „Nur von … bis …“ eintragen (oder „Vormonat“), wahlweise nach +Aktivierungsdatum oder Abrechnungszeitraum des Anbieters. Exportierte Positionen werden als *abgerechnet* +gekennzeichnet (grau) und nie wieder exportiert – auch nicht, wenn dieselbe Position später in einer +anderen Datei oder Abrechnung auftaucht. Versehentlich? Rechtsklick ▸ *Kennzeichnung „abgerechnet“ aufheben*. +*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 +unter *Datei ▸ Einstellungen ▸ Darstellung* (wirkt nach Neustart). + +**Linux:** `Provisionsmanager95-linux-x86_64-.tar.gz` entpacken und `Provisionsmanager95` starten +(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`. + Alles wird sofort in der Datenbank gespeichert (`%APPDATA%\Provisionsmanager95\provisionen.db`). Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Datei ▸ Datensicherung*. diff --git a/build/build_linux.sh b/build/build_linux.sh new file mode 100755 index 0000000..705a173 --- /dev/null +++ b/build/build_linux.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Baut die Linux-Version (einzelne Programmdatei) – im Container python:3.12-bookworm (glibc 2.36: +# läuft ab Debian 12, Ubuntu 24.04, Linux Mint 22; Debian 11 ist ausgelaufen). +# Ergebnis: dist/Provisionsmanager95-linux-x86_64-.tar.gz +set -euo pipefail +cd "$(dirname "$0")/.." +export LANG=C.UTF-8 LC_ALL=C.UTF-8 + +if ! command -v Xvfb >/dev/null; then + apt-get update -qq && apt-get install -y -qq --no-install-recommends xvfb xauth fonts-dejavu-core >/dev/null +fi +Xvfb :91 -screen 0 1280x800x24 -nolisten tcp >/dev/null 2>&1 & +XVFB_PID=$! +trap 'kill $XVFB_PID 2>/dev/null || true' EXIT +export DISPLAY=:91 +sleep 1 + +python3 --version +python3 -m pip install --disable-pip-version-check -q -r requirements-dev.txt + +echo "== Tests inkl. Oberfläche (Linux) ==" +PROVTOOL_GUI_TESTS=1 timeout 900 python3 -m pytest -v -p no:cacheprovider + +VERSION=$(python3 -c "import provtool; print(provtool.__version__)") +echo "== PyInstaller (Version $VERSION) ==" +python3 -m PyInstaller --noconfirm --clean --distpath dist --workpath build/tmp build/provisionsmanager.spec +test -x dist/Provisionsmanager95 + +echo "== Selbsttest des fertigen Programms ==" +rm -f selbsttest.txt +set +e +timeout 300 dist/Provisionsmanager95 --selbsttest selbsttest.txt +CODE=$? +set -e +cat selbsttest.txt 2>/dev/null || echo "(keine Ergebnisdatei)" +if [ "$CODE" -ne 0 ] || ! head -1 selbsttest.txt 2>/dev/null | grep -qx OK; then + echo "Selbsttest FEHLGESCHLAGEN (Exit-Code $CODE)"; exit 1 +fi +echo "Selbsttest bestanden" + +PAKET="Provisionsmanager95-linux-x86_64-$VERSION" +rm -rf "dist/$PAKET" && mkdir -p "dist/$PAKET" +cp dist/Provisionsmanager95 assets/app.png "dist/$PAKET/" +cp README.md "dist/$PAKET/LIESMICH.md" +cat > "dist/$PAKET/provisionsmanager95.desktop" < dist/VERSION +ls -la dist diff --git a/build/maus_app.py b/build/maus_app.py index f45cbc0..05a3eec 100644 --- a/build/maus_app.py +++ b/build/maus_app.py @@ -15,7 +15,8 @@ faulthandler.enable(open(os.path.join(aus, "absturz.txt"), "w")) home = tempfile.mkdtemp() os.environ["PROVTOOL_HOME"] = home with open(os.path.join(home, "einstellungen.json"), "w") as f: - json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100"}, f) + json.dump({"assistent_erledigt": True, "tipps_beim_start": False, "fenster": "900x560+100+100", + "design": "win95"}, f) # nur Win95 hat den eigenen Fensterrahmen sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from provtool.gui import app as A # noqa: E402 diff --git a/build/provisionsmanager.spec b/build/provisionsmanager.spec index c3ea6ac..3313f0e 100644 --- a/build/provisionsmanager.spec +++ b/build/provisionsmanager.spec @@ -1,6 +1,9 @@ -# PyInstaller-Spezifikation – erzeugt eine einzelne Provisionsmanager95.exe -# Aufruf (unter Windows oder Wine): pyinstaller --noconfirm build/provisionsmanager.spec +# PyInstaller-Spezifikation – erzeugt eine einzelne Programmdatei (Windows: .exe, Linux: ELF) +# Aufruf: pyinstaller --noconfirm build/provisionsmanager.spec import os +import sys + +WIN = sys.platform.startswith("win") ROOT = os.path.abspath(os.path.join(SPECPATH, "..")) @@ -20,8 +23,8 @@ exe = EXE( a.datas, [], name="Provisionsmanager95", - icon=os.path.join(ROOT, "assets", "app.ico"), - version=os.path.join(ROOT, "build", "version_info.txt"), + icon=os.path.join(ROOT, "assets", "app.ico") if WIN else None, + version=os.path.join(ROOT, "build", "version_info.txt") if WIN else None, console=False, upx=False, debug=False, diff --git a/build/release_hochladen.sh b/build/release_hochladen.sh new file mode 100755 index 0000000..000ff83 --- /dev/null +++ b/build/release_hochladen.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Hängt Dateien an das Gitea-Release des Tags (legt es an, falls es noch fehlt – Windows- und +# Linux-Job laufen parallel, wer zuerst fertig ist, legt es an). +# Aufruf: TOKEN=… API=…/api/v1/repos// TAG=v1.2.3 build/release_hochladen.sh datei… +set -eu +VERSION=$(tr -d '\r\n' < dist/VERSION) +if [ "v$VERSION" != "$TAG" ]; then + echo "Tag $TAG passt nicht zu provtool/__init__.py (Version $VERSION)"; exit 1 +fi +release_id() { curl -sf "$API/releases/tags/$TAG" -H "Authorization: token $TOKEN" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2; } +id=$(release_id || true) +if [ -z "$id" ]; then + curl -sf -X POST "$API/releases" -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"Provisions-Manager 95 $TAG\",\"body\":\"Windows: .exe herunterladen und starten. Linux: tar.gz entpacken, Provisionsmanager95 starten (ab Debian 12 / Ubuntu 24.04).\"}" \ + >/dev/null || sleep 5 # Gleichzeitig angelegt? Dann gibt es das Release jetzt. + id=$(release_id) +fi +for f in "$@"; do + curl -sf -X POST "$API/releases/$id/assets?name=$(basename "$f")" \ + -H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null + echo "hochgeladen: $f" +done diff --git a/provtool/__init__.py b/provtool/__init__.py index 493e232..0162574 100644 --- a/provtool/__init__.py +++ b/provtool/__init__.py @@ -1,5 +1,5 @@ """Provisions-Manager 95 – Provisionsabrechnung für Unterhändler der Handy-Insel Miesbach.""" -__version__ = "1.3.1" +__version__ = "1.4.0" APP_NAME = "Provisions-Manager 95" APP_ORG = "Handy-Insel Miesbach" diff --git a/provtool/db.py b/provtool/db.py index d150181..26a31f7 100644 --- a/provtool/db.py +++ b/provtool/db.py @@ -1,6 +1,7 @@ """SQLite-Datenbank: Händler, deren Kennungen, Auszahlungsregeln und gespeicherte Abrechnungen.""" from __future__ import annotations +import hashlib import json import shutil import sqlite3 @@ -12,7 +13,7 @@ from provtool import regeln from provtool.importer import ImportErgebnis from provtool.money import apply_percent -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 SCHEMA = """ CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT); @@ -96,6 +97,14 @@ CREATE TABLE IF NOT EXISTS position ( roh TEXT ); CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id); + +-- Abgerechnete Positionen (Fingerabdruck), über alle Abrechnungen hinweg: taucht eine schon +-- ausgezahlte Position in einer späteren Datei/Abrechnung wieder auf, wird sie nicht erneut abgerechnet. +CREATE TABLE IF NOT EXISTS abgerechnet_fp ( + fp TEXT PRIMARY KEY, + wann TEXT NOT NULL, + abrechnung TEXT NOT NULL +); """ UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung @@ -107,6 +116,27 @@ CAPS_ANBIETER = "vodafone_caps" CAPS_WERT = "*" +FP_FELDER = ("anbieter", "zeitraum", "provisionsart", "kennung", "datum", "rufnummer", "imei", "vorname", "name", + "tarif", "dienst", "aktionscode", "betrag_orig") + + +def fingerabdruecke(zeilen: list[dict]) -> list[str]: + """Fingerabdruck je Position einer Datei. Gleiche Zeilen innerhalb einer Datei (z. B. zwei gleiche + SIM-Karten-Gutschriften) werden durchgezählt, damit beide als eigene Positionen gelten.""" + gesehen: dict[str, int] = {} + out = [] + for z in zeilen: + roh = "\x1f".join(str(z.get(k) if z.get(k) is not None else "").strip() for k in FP_FELDER) + h = hashlib.sha1(roh.encode("utf-8")).hexdigest()[:24] + gesehen[h] = gesehen.get(h, 0) + 1 + out.append(f"{h}#{gesehen[h]}") + return out + + +class AbrechnungErledigt(Exception): + """Die Abrechnung ist als erledigt gekennzeichnet und darf nicht mehr verändert werden.""" + + def ist_grossgeschrieben(name: str | None) -> bool: buchstaben = [c for c in (name or "") if c.isalpha() and c != "ß"] return len(buchstaben) >= 2 and all(c.isupper() for c in buchstaben) @@ -151,9 +181,28 @@ class Datenbank: self.con.execute("ALTER TABLE haendler ADD COLUMN airtime_prozent REAL NOT NULL DEFAULT 0") if "staffel" not in h_spalten: self.con.execute("ALTER TABLE haendler ADD COLUMN staffel TEXT") + if "abgerechnet" not in spalten: + # Zeitpunkt der Abrechnung (NULL = offen), Händler zum Zeitpunkt der Abrechnung (eingefroren), + # Fingerabdruck (erkennt dieselbe Position in späteren Dateien) + self.con.execute("ALTER TABLE position ADD COLUMN abgerechnet TEXT") + self.con.execute("ALTER TABLE position ADD COLUMN abgerechnet_hid INTEGER") + self.con.execute("ALTER TABLE position ADD COLUMN fp TEXT") + a_spalten = {r[1] for r in self.con.execute("PRAGMA table_info(abrechnung)")} + if "erledigt" not in a_spalten: + self.con.execute("ALTER TABLE abrechnung ADD COLUMN erledigt TEXT") + self._fingerabdruecke_nachtragen() self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),)) self.con.commit() + def _fingerabdruecke_nachtragen(self): + """Für Positionen aus älteren Versionen den Fingerabdruck berechnen.""" + dateien = [r[0] for r in self.con.execute("SELECT DISTINCT datei_id FROM position WHERE fp IS NULL")] + for did in dateien: + zeilen = [dict(r) for r in self.con.execute( + "SELECT * FROM position WHERE datei_id IS ? ORDER BY reihenfolge", (did,))] + for pid, fp in zip([z["id"] for z in zeilen], fingerabdruecke(zeilen)): + self.con.execute("UPDATE position SET fp=? WHERE id=? AND fp IS NULL", (fp, pid)) + def close(self): self.con.close() @@ -339,6 +388,7 @@ class Datenbank: def abrechnungen(self) -> list[sqlite3.Row]: return list(self.con.execute( "SELECT a.*, (SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id) AS anzahl, " + "(SELECT count(*) FROM position p WHERE p.abrechnung_id=a.id AND p.abgerechnet IS NOT NULL) AS abgerechnet, " "(SELECT group_concat(dateiname, ', ') FROM abrechnung_datei d WHERE d.abrechnung_id=a.id) AS dateien " "FROM abrechnung a ORDER BY a.geaendert DESC")) @@ -362,11 +412,15 @@ class Datenbank: (sha256,))] def datei_entfernen(self, datei_id: int) -> None: + r = self.con.execute("SELECT abrechnung_id FROM abrechnung_datei WHERE id=?", (datei_id,)).fetchone() + if r: + self._nicht_erledigt(r[0]) with self.con: self.con.execute("DELETE FROM position WHERE datei_id=?", (datei_id,)) self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,)) def importieren(self, aid: int, erg: ImportErgebnis) -> int: + self._nicht_erledigt(aid) start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?", (aid,)).fetchone()[0] with self.con: @@ -384,6 +438,10 @@ class Datenbank: p.tarif, p.netz, p.dienst, p.aktionscode, p.quelle, p.betrag_cent, p.betrag_cent, json.dumps(p.roh, ensure_ascii=False, default=str))) self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid)) + zeilen = [dict(r) for r in self.con.execute( + "SELECT * FROM position WHERE datei_id=? ORDER BY reihenfolge", (did,))] + for z, fp in zip(zeilen, fingerabdruecke(zeilen)): + self.con.execute("UPDATE position SET fp=? WHERE id=?", (fp, z["id"])) neue = {r[0] for r in self.con.execute("SELECT id FROM position WHERE datei_id=?", (did,))} self._regeln_anwenden(aid, neue, auch_manuelle=True) return len(erg.positionen) @@ -392,11 +450,17 @@ class Datenbank: """Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen) und Typ (regeln.py).""" finde = self.zuordner() fest = self.art_typen() + schon = dict(self.con.execute("SELECT fp, abrechnung FROM abgerechnet_fp").fetchall()) out = [] for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)): d = dict(r) - d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"]) + if d["abgerechnet"] and d["abgerechnet_hid"] is not None: + d["haendler_id"] = d["abgerechnet_hid"] # abgerechnet: Zuordnung eingefroren + else: + d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"]) d["typ"] = regeln.typ_von(d, fest) + # schon anderswo abgerechnet (dieselbe Position in einer früheren Datei/Abrechnung)? + d["doppelt"] = schon.get(d["fp"]) if d["fp"] and not d["abgerechnet"] else None out.append(d) return out @@ -441,12 +505,16 @@ class Datenbank: """Berechnet die Auszahlung nach den Händler-Regeln neu: für `pids` (None = alle) und immer für alle Jahresbonus-Positionen (deren Staffel hängt von der Anzahl aller Karten ab). Von Hand geänderte Beträge bleiben, außer auch_manuelle=True. Gibt die Anzahl Änderungen zurück.""" + if self.erledigt(aid): + return 0 # erledigte Abrechnungen bleiben, wie sie abgerechnet wurden alle = self.positionen(aid) karten = self.karten_zaehlen(alle) rechne = self._rechner() n = 0 with self.con: for p in alle: + if p["abgerechnet"]: + continue # schon ausgezahlt: Betrag bleibt if pids is not None and p["id"] not in pids and p["typ"] != regeln.JAHRESBONUS: continue if p["manuell"] and not (auch_manuelle and (pids is None or p["id"] in pids)): @@ -468,6 +536,7 @@ class Datenbank: def haendler_zuweisen(self, pids: list[int], hid: int | None) -> None: """Weist Positionen fest einem Händler zu (hid=None: wieder automatisch zuordnen). Nicht manuell geänderte Beträge werden nach den Regeln des neuen Händlers berechnet.""" + pids = self._offene(pids) with self.con: for pid in pids: self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid)) @@ -480,10 +549,13 @@ class Datenbank: self._regeln_anwenden(aid, set(pids), auch_manuelle=True) def betrag_setzen(self, pid: int, cent: int) -> None: + if not self._offene([pid]): + raise AbrechnungErledigt("Die Position ist bereits abgerechnet.") with self.con: self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid)) def prozent_anwenden(self, pids: list[int], prozent: float, auch_manuelle: bool = True) -> int: + pids = self._offene(pids) n = 0 with self.con: for pid in pids: @@ -500,6 +572,79 @@ class Datenbank: Nötig z. B. wenn ein Händler erst nach dem Import angelegt wurde.""" return self._regeln_anwenden(aid, None, auch_manuelle=auch_manuelle) + # ------------------------------------------------------------------ abgerechnet / erledigt + def _offene(self, pids) -> list[int]: + """Nur Positionen, die noch geändert werden dürfen (nicht abgerechnet, Abrechnung nicht erledigt).""" + pids = list(pids) + if not pids: + return [] + marken = ",".join("?" * len(pids)) + return [r[0] for r in self.con.execute( + f"SELECT p.id FROM position p JOIN abrechnung a ON a.id=p.abrechnung_id " + f"WHERE p.id IN ({marken}) AND p.abgerechnet IS NULL AND a.erledigt IS NULL", pids)] + + def erledigt(self, aid: int) -> str | None: + r = self.con.execute("SELECT erledigt FROM abrechnung WHERE id=?", (aid,)).fetchone() + return r[0] if r else None + + def _nicht_erledigt(self, aid: int) -> None: + if self.erledigt(aid): + raise AbrechnungErledigt("Diese Abrechnung ist als erledigt gekennzeichnet.") + + def abrechnen(self, pids: list[int], wann: str | None = None) -> int: + """Positionen als abgerechnet kennzeichnen: Betrag und Händler werden eingefroren, der + Fingerabdruck gemerkt (dieselbe Position wird später nicht noch einmal abgerechnet). + Nur Positionen echter Händler, die noch offen und nicht schon anderswo abgerechnet sind.""" + wann = wann or _now() + ids = set(pids) + n = 0 + for aid in self._abrechnungen_von(list(ids)): + titel = self.abrechnung(aid)["titel"] + with self.con: + for p in self.positionen(aid): + if p["id"] not in ids or p["abgerechnet"] or p["doppelt"] or p["haendler_id"] <= 0: + continue + self.con.execute("UPDATE position SET abgerechnet=?, abgerechnet_hid=? WHERE id=?", + (wann, p["haendler_id"], p["id"])) + if p["fp"]: + self.con.execute("INSERT OR REPLACE INTO abgerechnet_fp (fp, wann, abrechnung) VALUES (?,?,?)", + (p["fp"], wann, titel)) + n += 1 + return n + + def abrechnung_aufheben(self, pids: list[int]) -> int: + """Kennzeichnung „abgerechnet“ zurücknehmen (z. B. versehentlich exportiert).""" + n = 0 + for aid in self._abrechnungen_von(list(pids)): + self._nicht_erledigt(aid) + with self.con: + for pid in pids: + r = self.con.execute("SELECT fp, abgerechnet FROM position WHERE id=?", (pid,)).fetchone() + if not r or not r["abgerechnet"]: + continue + self.con.execute("UPDATE position SET abgerechnet=NULL, abgerechnet_hid=NULL WHERE id=?", (pid,)) + if r["fp"]: + self.con.execute("DELETE FROM abgerechnet_fp WHERE fp=?", (r["fp"],)) + n += 1 + return n + + def erledigt_setzen(self, aid: int, erledigt: bool, positionen_aufheben: bool = False) -> int: + """Abrechnung als erledigt kennzeichnen (alle offenen Händler-Positionen werden dabei + abgerechnet) bzw. wieder öffnen (auf Wunsch samt Kennzeichnung der Positionen). + Gibt die Anzahl der dabei (ab)gerechneten Positionen zurück.""" + n = 0 + if erledigt: + wann = _now() + n = self.abrechnen([p["id"] for p in self.positionen(aid)], wann) + with self.con: + self.con.execute("UPDATE abrechnung SET erledigt=?, geaendert=? WHERE id=?", (wann, wann, aid)) + else: + with self.con: + self.con.execute("UPDATE abrechnung SET erledigt=NULL, geaendert=? WHERE id=?", (_now(), aid)) + if positionen_aufheben: + n = self.abrechnung_aufheben([p["id"] for p in self.positionen(aid)]) + return n + def touch(self, aid: int) -> None: with self.con: self.con.execute("UPDATE abrechnung SET geaendert=? WHERE id=?", (_now(), aid)) diff --git a/provtool/gui/app.py b/provtool/gui/app.py index c3df9ca..3576477 100644 --- a/provtool/gui/app.py +++ b/provtool/gui/app.py @@ -15,7 +15,7 @@ from tkinter import filedialog from provtool import APP_NAME, config from provtool import export as exp from provtool import regeln -from provtool.db import EIGEN, UNZUGEORDNET, Datenbank +from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank from provtool.gui import assistent as ass from provtool.gui import dialogs as dlg from provtool.gui import menue as mn @@ -70,13 +70,14 @@ class App: self._schliesst = False # Beenden angefordert (offene Dialoge werden gerade geschlossen) self._beendet = False # Datenbank und Fenster sind zu + w.design_setzen(self.settings.get("design", "modern")) w.apply(root) self.icons = w.Icons(root) root._app_icon = self.icons("app", 2) # type: ignore[attr-defined] root.iconphoto(True, self.icons("app", 2), self.icons("app", 1)) root.protocol("WM_DELETE_WINDOW", self.beenden) root.report_callback_exception = self._fehler_handler - klassisch = bool(self.settings.get("klassischer_rahmen", True)) + klassisch = w.DESIGN == "win95" # Win95: eigene Titelleiste; modern: normaler Windows-Rahmen w.KLASSISCH = klassisch self.rahmen = rh.Hauptrahmen(root, APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch) self.rahmen.set_titel(APP_NAME) @@ -116,6 +117,7 @@ class App: d.add_command(label="Dateien importieren...", accelerator="Strg+I", command=self.importieren) d.add_command(label="Importierte Dateien...", command=self.dateien) d.add_command(label="Exportieren...", accelerator="Strg+E", command=self.exportieren) + d.add_command(label="Abrechnung erledigt / wieder öffnen...", command=self.erledigt_umschalten) d.add_separator() d.add_command(label="Datensicherung erstellen...", command=self.sicherung) d.add_command(label="Datensicherung einspielen...", command=self.wiederherstellen) @@ -152,7 +154,7 @@ class App: hi.add_command(label=f"Info über {APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen()) def _toolbar(self): - tb = tk.Frame(self.inhalt, bg=w.FACE, bd=1, relief="raised") + tb = tk.Frame(self.inhalt, bg=w.FACE, bd=0 if w.MODERN else 1, relief="raised") self.tb: dict[str, w.ToolbarButton] = {} tb.pack(side="top", fill="x") for text, icon, cmd, tip in ( @@ -188,7 +190,8 @@ class App: # Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar. # Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim # Loslassen (flüssig, auch mit vielen Positionen). - pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, sashrelief="raised", + pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, + sashrelief="flat" if w.MODERN else "raised", bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow") pw.pack(fill="both", expand=True, padx=6, pady=6) @@ -227,6 +230,7 @@ class App: self.ptree.tag_configure("manuell", foreground="#0000c0") self.ptree.tag_configure("abw", foreground="#a00000") self.ptree.tag_configure("negativ", foreground="#a00000") + self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW) r2.pack(fill="both", expand=True) self.ptree.bind("", self._doppelklick) self.ptree.bind("", self._kontextmenue) @@ -241,7 +245,7 @@ class App: self.v_alle_auf = tk.BooleanVar(value=True) w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right") w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4) - tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack( + tk.Label(leiste, text="Blau = von Hand · Rot = abweichend · Grau = abgerechnet", fg=w.SHADOW).pack( side="left", padx=8) pw.add(self.f_p, minsize=500) @@ -272,7 +276,7 @@ class App: self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()} if self.aid and self.db.abrechnung(self.aid): self.positionen = self.db.positionen(self.aid) - titel = self.db.abrechnung(self.aid)["titel"] + titel = self.db.abrechnung(self.aid)["titel"] + (" – erledigt" if self.db.erledigt(self.aid) else "") self.rahmen.set_titel(f"{APP_NAME} - [{titel}]") self.status.set("info", f" Abrechnung: {titel}") else: @@ -380,7 +384,9 @@ class App: values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",)) for p in plist: tags = [] - if p["manuell"]: + if p["abgerechnet"] or p["doppelt"]: + tags.append("abgerechnet") + elif p["manuell"]: tags.append("manuell") elif p["betrag"] != p["betrag_orig"]: tags.append("abw") @@ -389,6 +395,7 @@ class App: values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]), p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""), fmt(p["betrag_orig"]), + "abgerechnet" if p["abgerechnet"] or p["doppelt"] else "manuell" if p["manuell"] else ("Staffel" if _ist_staffel(p) else fmt_percent(p["prozent"])), fmt(p["betrag"])), tags=tuple(tags)) @@ -470,7 +477,7 @@ class App: if not dateien: return self.settings["import_ordner"] = str(Path(dateien[0]).parent) - if not self._brauche_abrechnung(): + if not self._brauche_abrechnung() or self._gesperrt(): return bericht = [] self.root.configure(cursor="watch") @@ -494,8 +501,12 @@ class App: bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)")) continue n = self.db.importieren(self.aid, erg) + did = self.db.dateien(self.aid)[-1]["id"] + doppelt = sum(1 for p in self.db.positionen(self.aid) if p["datei_id"] == did and p["doppelt"]) + hinweise = list(erg.warnungen) + ([f"{doppelt} Pos. bereits abgerechnet (werden nicht erneut " + "exportiert)"] if doppelt else []) bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen), - "; ".join(erg.warnungen) or "OK")) + "; ".join(hinweise) or "OK")) finally: self.root.configure(cursor="") self._aktualisieren() @@ -567,7 +578,45 @@ class App: ps = [p for p in self.positionen if p["id"] in set(pids)] return sel, pids, ps + def _gesperrt(self) -> bool: + """Erledigte Abrechnung: keine Änderungen (Hinweis statt stillem Nichtstun).""" + if self.aid and self.db.erledigt(self.aid): + w.info(self.root, "Abrechnung erledigt", "Diese Abrechnung ist als erledigt gekennzeichnet und kann " + "nicht mehr geändert werden.\n\nZum Ändern: Datei > Abrechnung erledigt / wieder öffnen.") + return True + return False + + def erledigt_umschalten(self): + if not self.aid: + return + titel = self.db.abrechnung(self.aid)["titel"] + if self.db.erledigt(self.aid): + if not w.frage(self.root, "Wieder öffnen", f"Abrechnung „{titel}“ wieder öffnen?"): + return + auch = w.frage(self.root, "Wieder öffnen", "Sollen die Positionen auch wieder als NICHT abgerechnet " + "gelten (z. B. um sie neu zu exportieren)?", ja="Ja, freigeben", nein="Nein", + default_ja=False) + self.db.erledigt_setzen(self.aid, False, positionen_aufheben=auch) + else: + if not w.frage(self.root, "Abrechnung erledigt", + f"Abrechnung „{titel}“ als erledigt kennzeichnen?\n\nAlle Händler-Positionen gelten dann " + "als abgerechnet und werden auch in späteren Dateien nicht noch einmal abgerechnet. " + "Die Abrechnung kann danach nicht mehr geändert werden."): + return + self.db.erledigt_setzen(self.aid, True) + self._aktualisieren() + + def abgerechnet_aufheben(self): + if self._gesperrt(): + return + _, pids, _ = self._auswahl_info() + n = self.db.abrechnung_aufheben(pids) + self._aktualisieren() + self.status.set("info", f" {n} Position(en) wieder offen") + def prozent(self): + if self._gesperrt(): + return sel, pids, ps = self._auswahl_info() if not ps: w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen " @@ -601,6 +650,8 @@ class App: self.ptree.see(vorhanden[0]) def zuruecksetzen(self): + if self._gesperrt(): + return sel, pids, ps = self._auswahl_info() if not ps: return @@ -610,7 +661,7 @@ class App: self._auswahl_wiederherstellen(sel) def neu_berechnen(self): - if not self.aid: + if not self.aid or self._gesperrt(): return manuell = sum(1 for p in self.positionen if p["manuell"]) auch = False @@ -638,6 +689,8 @@ class App: self.betrag_bearbeiten(iid) def betrag_bearbeiten(self, iid: str | None = None): + if self._gesperrt(): + return if iid is None: sel = [i for i in self.ptree.selection() if i.startswith("p")] if len(sel) != 1: @@ -652,6 +705,10 @@ class App: bbox = self.ptree.bbox(iid, "ausz") pid = int(iid[1:]) p = next(x for x in self.positionen if x["id"] == pid) + if p["abgerechnet"]: + w.info(self.root, "Betrag ändern", "Diese Position ist bereits abgerechnet.\n\n" + "Zum Ändern: Rechtsklick > Kennzeichnung „abgerechnet“ aufheben.") + return if not bbox: # Zelle gerade nicht sichtbar (z. B. Spalte zu schmal) -> kleiner Dialog statt Inline-Feld self._betrag_dialog(p) @@ -748,6 +805,9 @@ class App: if any(p["haendler_id"] == EIGEN for p in ps): m.add_separator() m.add_command(label="Kennung wieder einblenden", command=self.eigene_einblenden) + if any(p["abgerechnet"] for p in ps): + m.add_separator() + m.add_command(label="Kennzeichnung „abgerechnet“ aufheben", command=self.abgerechnet_aufheben) m.tk_popup(e.x_root, e.y_root) # ----- Eigene Kennungen (eigene Filiale) ausblenden @@ -780,6 +840,8 @@ class App: self._aktualisieren() def haendler_zuweisen(self, automatisch: bool = False): + if self._gesperrt(): + return sel, pids, _ = self._auswahl_info() if not pids: w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.") @@ -795,6 +857,8 @@ class App: self._aktualisieren() def _null(self): + if self._gesperrt(): + return sel, pids, _ = self._auswahl_info() self.db.prozent_anwenden(pids, 0.0) self.db.touch(self.aid) @@ -807,14 +871,19 @@ class App: w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n" "Bitte zuerst Dateien importieren.") return + # nur offene Positionen echter Händler (nicht: nicht zugeordnet, eigene, schon abgerechnet) + offen = [p for p in self.positionen if p["haendler_id"] > 0 and not p["abgerechnet"] and not p["doppelt"]] stat: dict[int, list[int]] = defaultdict(lambda: [0, 0]) - for p in self.positionen: - if p["haendler_id"] > 0: # nicht: nicht zugeordnet / eigene Kennungen - stat[p["haendler_id"]][0] += 1 - stat[p["haendler_id"]][1] += p["betrag"] + for p in offen: + stat[p["haendler_id"]][0] += 1 + stat[p["haendler_id"]][1] += p["betrag"] if not stat: - w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n" - "Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.") + if any(p["haendler_id"] > 0 for p in self.positionen): + w.info(self.root, "Export", "Alle Positionen dieser Abrechnung sind bereits abgerechnet.\n\n" + "Zum erneuten Export: Datei > Abrechnung erledigt / wieder öffnen (Positionen freigeben).") + else: + w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n" + "Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.") return liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())] @@ -824,19 +893,27 @@ class App: if not res: return config.save(self.settings) - erzeugt = [] + erzeugt, exportiert = [], [] + zeit = res["zeitraum"] try: for hid in res["ids"]: - pos = [p for p in self.positionen if p["haendler_id"] == hid] + pos = [p for p in offen if p["haendler_id"] == hid and (zeit is None or zeit.enthaelt(p))] erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"], pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"], summe=res["summe"], null_weglassen=res["null_weglassen"]) + exportiert += pos except PermissionError as exc: w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n" "Ist sie vielleicht noch in Excel geöffnet?") return - w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n" - + "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "")) + hinweis = "" + if res["kennzeichnen"] and exportiert: + n = self.db.abrechnen([p["id"] for p in exportiert]) + hinweis = f"\n\n{n} Position(en) als abgerechnet gekennzeichnet." + self._aktualisieren() + w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}" + + (f"\nZeitraum: {zeit.text()}" if zeit else "") + "\n\n" + + "\n".join(p.name for p in erzeugt[:15]) + ("\n..." if len(erzeugt) > 15 else "") + hinweis) if res["oeffnen"]: _oeffne_ordner(res["ordner"]) @@ -891,10 +968,10 @@ class App: res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen() if not res: return - if res["klassisch"] != bool(self.settings.get("klassischer_rahmen", True)): - self.settings["klassischer_rahmen"] = res["klassisch"] + if res["design"] != w.DESIGN: + self.settings["design"] = res["design"] config.save(self.settings) - w.info(self.root, "Einstellungen", "Die Änderung der Titelleiste wird beim nächsten Programmstart wirksam.") + w.info(self.root, "Einstellungen", "Das neue Design wird beim nächsten Programmstart wirksam.") if res["pfad"] and Path(res["pfad"]) != self.db.path: try: self.db_wechseln(res["pfad"]) @@ -919,6 +996,10 @@ class App: "5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat (freenet\n" " und Vodafone zusammen, Airtime als eine Summenzeile) und eine Airtime-Liste.\n" " Positionen mit 0,00 € werden weggelassen.\n\n" + "Zeitraum: Im Export-Dialog „Nur von … bis …“ (oder „Vormonat“). Exportierte Positionen\n" + "gelten als abgerechnet (grau) und werden nicht noch einmal abgerechnet – auch nicht, wenn\n" + "sie in einer späteren Datei wieder auftauchen. Datei > Abrechnung erledigt sperrt eine\n" + "fertige Abrechnung.\n\n" "Welche Provisionsart Airtime bzw. Jahresbonus ist, wird automatisch erkannt –\n" "umstellbar unter Händler > Provisionsarten.\n\n" "Alles wird automatisch in der Datenbank gespeichert.") diff --git a/provtool/gui/dialogs.py b/provtool/gui/dialogs.py index d4acc49..a24ec7f 100644 --- a/provtool/gui/dialogs.py +++ b/provtool/gui/dialogs.py @@ -6,7 +6,8 @@ from tkinter import filedialog, ttk from provtool import APP_NAME, APP_ORG, __version__ from provtool import regeln -from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler +from provtool import zeitraum as zr +from provtool.db import CAPS_ANBIETER, CAPS_WERT, AbrechnungErledigt, Datenbank, Haendler from provtool.gui import win95 as w from provtool.importer import ANBIETER from provtool.money import fmt, fmt_percent, parse_cent, parse_percent @@ -561,9 +562,9 @@ class AbrechnungenDialog(w.Dialog): super().__init__(master, "Abrechnung öffnen", resizable=True) self.db = db tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2)) - r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings", - height=12, selectmode="browse") - for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120), + r, self.tree = w.scroll_tree(self, columns=("titel", "status", "geaendert", "anzahl", "dateien"), + show="headings", height=12, selectmode="browse") + for c, t, b in (("titel", "Bezeichnung", 180), ("status", "Status", 110), ("geaendert", "Zuletzt geändert", 120), ("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)): self.tree.heading(c, text=t) self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w") @@ -581,8 +582,9 @@ class AbrechnungenDialog(w.Dialog): def _laden(self, sel=None): self.tree.delete(*self.tree.get_children()) for a in self.db.abrechnungen(): + status = "erledigt" if a["erledigt"] else (f"{a['abgerechnet']} abgerechnet" if a["abgerechnet"] else "offen") self.tree.insert("", "end", iid=str(a["id"]), - values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or "")) + values=(a["titel"], status, a["geaendert"][:16], a["anzahl"], a["dateien"] or "")) kids = self.tree.get_children() if sel and self.tree.exists(str(sel)): self.tree.selection_set(str(sel)) @@ -655,7 +657,11 @@ class DateienDialog(w.Dialog): name = self.tree.item(s[0], "values")[0] if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n" "Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False): - self.db.datei_entfernen(int(s[0])) + try: + self.db.datei_entfernen(int(s[0])) + except AbrechnungErledigt as exc: + w.info(self, "Datei entfernen", str(exc)) + return self.geaendert = True self._laden() @@ -900,8 +906,32 @@ class ExportDialog(w.Dialog): "eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid( row=7, column=0, columnspan=3, sticky="w", pady=(4, 0)) g.columnconfigure(1, weight=1) + + z = w.gruppe(f, "Zeitraum und Abrechnung") + z.pack(fill="x", pady=(8, 0)) + zf = tk.Frame(z, bg=w.FACE) + zf.pack(fill="x") + tk.Label(zf, text="Nur von:").pack(side="left") + self.v_von, self.v_bis = tk.StringVar(), tk.StringVar() + tk.Entry(zf, textvariable=self.v_von, width=11).pack(side="left", padx=4) + tk.Label(zf, text="bis:").pack(side="left") + tk.Entry(zf, textvariable=self.v_bis, width=11).pack(side="left", padx=4) + w.knopf(zf, "Vormonat", self._vormonat).pack(side="left", padx=(4, 0)) + 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", + width=40).pack(anchor="w", pady=(4, 0)) + tk.Label(z, text="(leer = alle offenen Positionen; Datum als TT.MM.JJJJ)", fg=w.SHADOW, anchor="w").pack( + fill="x") + self.v_kennz = tk.BooleanVar(value=True) + 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)) self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) + def _vormonat(self): + von, bis = zr.vormonat() + self.v_von.set(von.strftime("%d.%m.%Y")) + self.v_bis.set(bis.strftime("%d.%m.%Y")) + def _klick(self, e): if self.tree.identify_column(e.x) == "#0": iid = self.tree.identify_row(e.y) @@ -928,6 +958,12 @@ class ExportDialog(w.Dialog): if not ids: w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.") return + try: + von, bis = zr.datum_lesen(self.v_von.get()), zr.datum_lesen(self.v_bis.get()) + except ValueError as exc: + w.warnung(self, "Zeitraum", str(exc)) + return + basis = next(k for k, v in zr.BASEN.items() if v == self.v_basis.get()) if not self.v_ordner.get().strip(): self._ordner() if not self.v_ordner.get().strip(): @@ -938,7 +974,10 @@ class ExportDialog(w.Dialog): self.settings.pop("export_pro_anbieter", None) self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(), pro_anbieter=self.v_anb.get(), null_weglassen=self.v_null.get(), - kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get()) + kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(), oeffnen=self.v_auf.get(), + zeitraum=zr.Zeitraum(von, bis, basis) if (von or bis) else None, + kennzeichnen=self.v_kennz.get()) + self.settings["export_basis"] = basis self.destroy() @@ -960,9 +999,10 @@ class EinstellungenDialog(w.Dialog): justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w") g2 = w.gruppe(self, "Darstellung") g2.pack(fill="x", padx=10, pady=(0, 10)) - self.v_klassisch = tk.BooleanVar(value=bool(settings.get("klassischer_rahmen", True))) - tk.Checkbutton(g2, text="Windows-95-Titelleiste verwenden (sonst die normale von Windows)", - variable=self.v_klassisch).pack(anchor="w") + 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="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack( + anchor="w") tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w") self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) @@ -974,7 +1014,7 @@ class EinstellungenDialog(w.Dialog): self.v.set(p) def _ok(self): - self.result = dict(pfad=self.v.get().strip(), klassisch=self.v_klassisch.get()) + self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get()) self.destroy() diff --git a/provtool/gui/menue.py b/provtool/gui/menue.py index 6ac5dcb..3ebe40b 100644 --- a/provtool/gui/menue.py +++ b/provtool/gui/menue.py @@ -51,25 +51,28 @@ class Popup: self.leiste = leiste root = self.master._root() f = self.fenster = tk.Toplevel(root, bg=w.FACE, bd=2, relief="raised") + if w.MODERN: + f.configure(bg=w.WHITE, bd=0, relief="flat", highlightthickness=1, highlightbackground=w.RAND) f.withdraw() f.overrideredirect(True) self.zeilen = [] + grund, py = (w.WHITE, 4) if w.MODERN else (w.FACE, 2) for e in self.eintraege: if e["art"] == "trenner": - t = tk.Frame(f, bg=w.FACE, height=8) + t = tk.Frame(f, bg=grund, height=8) t.pack(fill="x") linie = w.trenner(t, vertikal=False) linie.place(relx=0, rely=0.5, relwidth=1, y=-1) continue - zeile = tk.Frame(f, bg=w.FACE) + zeile = tk.Frame(f, bg=grund) zeile.pack(fill="x") grau = e["state"] == "disabled" - text = tk.Label(zeile, text=e["label"], bg=w.FACE, anchor="w", padx=18, pady=2, + text = tk.Label(zeile, text=e["label"], bg=grund, anchor="w", padx=18, pady=py, fg=w.SHADOW if grau else w.DARK) text.pack(side="left", fill="x", expand=True) labels = [text] if e["accelerator"]: - acc = tk.Label(zeile, text=e["accelerator"], bg=w.FACE, anchor="e", padx=12, pady=2, + acc = tk.Label(zeile, text=e["accelerator"], bg=grund, anchor="e", padx=12, pady=py, fg=w.SHADOW if grau else w.DARK) acc.pack(side="right") labels.append(acc) @@ -170,8 +173,8 @@ class Popup: self.aktiv = i for j, (e, zeile, labels) in enumerate(self.zeilen): an = j == i and e.get("state") != "disabled" - bg = w.NAVY if an else w.FACE - fg = w.WHITE if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK) + bg = w.SEL_BG if an else (w.WHITE if w.MODERN else w.FACE) + fg = w.SEL_FG if an else (w.SHADOW if e.get("state") == "disabled" else w.DARK) zeile.configure(bg=bg) for lbl in labels: lbl.configure(bg=bg, fg=fg) @@ -248,7 +251,7 @@ class Menueleiste(tk.Frame): def _darstellen(self): for k in self.knoepfe.values(): if k is self.offener_knopf: - k.configure(bg=w.NAVY, fg=w.WHITE, relief="flat") + k.configure(bg=w.SEL_BG, fg=w.SEL_FG, relief="flat") else: k.configure(bg=w.FACE, fg=w.DARK, relief="flat") @@ -289,7 +292,10 @@ class Menueleiste(tk.Frame): def _hover(self, knopf, drin: bool): if self.offener_knopf is None: - knopf.configure(relief="raised" if drin else "flat") + if w.MODERN: + knopf.configure(bg=w.LIGHT if drin else w.FACE) + else: + knopf.configure(relief="raised" if drin else "flat") def _alt(self, knopf): self.oeffnen(knopf) diff --git a/provtool/gui/rahmen.py b/provtool/gui/rahmen.py index 5de100d..d188ac4 100644 --- a/provtool/gui/rahmen.py +++ b/provtool/gui/rahmen.py @@ -231,6 +231,7 @@ class Hauptrahmen: self.maximiert = False self._normal_geo: str | None = None self._schliessen = schliessen + self.nativ = None root.minsize(minbreite, minhoehe) if not klassisch: self.aussen = tk.Frame(root, bg=w.FACE) @@ -266,7 +267,6 @@ class Hauptrahmen: self.aussen.bind("", self._resize) root.bind_all("", lambda e: self.alt_f4(e.widget)) - self.nativ = None if IS_WIN and NATIVER_RAHMEN: from provtool.gui.nativ import NativerRahmen self.nativ = NativerRahmen(root, self.RAND, self.ECKE) diff --git a/provtool/gui/win95.py b/provtool/gui/win95.py index d639421..26339ce 100644 --- a/provtool/gui/win95.py +++ b/provtool/gui/win95.py @@ -1,4 +1,8 @@ -"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine.""" +"""Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine. + +Zwei Designs: „modern“ (hell und flach, wie Windows 11 – Standard) und „win95“ (klassisch, Legacy). +design_setzen() muss VOR dem Aufbau der Oberfläche laufen; alle Module lesen die Farben als w.FACE usw. +""" from __future__ import annotations import sys @@ -14,6 +18,19 @@ NAVY = "#000080" TEAL = "#008080" WHITE = "#ffffff" INFO_BG = "#ffffe1" +SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung +RAND = SHADOW # dünne Rahmenlinien (nur modern) +DESIGN = "win95" +MODERN = False + +_PALETTEN = { + "win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000", + NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080", + SEL_FG="#ffffff", RAND="#808080"), + "modern": dict(FACE="#f3f3f3", HILITE="#ffffff", LIGHT="#ebebeb", SHADOW="#8a8a8a", DARK="#1b1b1b", + NAVY="#005fb8", TEAL="#e6eef6", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#cce4f7", + SEL_FG="#1b1b1b", RAND="#d1d1d1"), +} IS_WIN = sys.platform.startswith("win") FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans" @@ -23,6 +40,24 @@ FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold") FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold") FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1) + +def design_setzen(name: str) -> None: + """„modern“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply()).""" + global DESIGN, MODERN, FONT_FAMILY, FONT_SIZE + DESIGN = name if name in _PALETTEN else "modern" + MODERN = DESIGN == "modern" + globals().update(_PALETTEN[DESIGN]) + 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 + _schriften() + + +def _schriften(): + global FONT, FONT_BOLD, FONT_BIG + FONT = (FONT_FAMILY, FONT_SIZE) + FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold") + FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold") + # --------------------------------------------------------------------------- Pixel-Icons (16x16) PALETTE = { "k": "#000000", "w": "#ffffff", "g": "#c0c0c0", "d": "#808080", "y": "#ffff00", "Y": "#808000", @@ -288,6 +323,13 @@ class Icons: # --------------------------------------------------------------------------- Theme def apply(root: tk.Tk) -> None: + global FONT_FAMILY + if MODERN and not IS_WIN: # Linux: die erste vorhandene moderne Schrift + from tkinter import font as tkfont + vorhanden = set(tkfont.families(root)) + FONT_FAMILY = next((f for f in ("Segoe UI", "Noto Sans", "Cantarell", "Ubuntu", "DejaVu Sans") + if f in vorhanden), FONT_FAMILY) + _schriften() root.configure(bg=FACE) o = root.option_add o("*Font", FONT) @@ -343,6 +385,45 @@ def apply(root: tk.Tk) -> None: selectforeground=[("readonly", DARK)]) st.configure("TPanedwindow", background=FACE) st.configure("Sash", sashthickness=6, background=FACE) + if MODERN: + _modern(root, st, zeile) + + +def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None: + """Flach und hell: dünne graue Rahmen statt 3D-Kanten, Akzentfarbe beim Fokus.""" + o = root.option_add + for klasse in ("Entry", "Text", "Listbox", "Spinbox"): + o(f"*{klasse}.relief", "flat") + o(f"*{klasse}.borderWidth", 3) + o(f"*{klasse}.highlightThickness", 1) + o(f"*{klasse}.highlightBackground", RAND) + o(f"*{klasse}.highlightColor", NAVY) + o("*Button.relief", "flat") + o("*Button.borderWidth", 0) + o("*Button.highlightThickness", 1) + o("*Button.highlightBackground", RAND) + o("*Button.background", WHITE) + o("*Button.activeBackground", SEL_BG) + o("*Button.padY", 3) + o("*Checkbutton.activeBackground", FACE) + o("*Radiobutton.activeBackground", FACE) + o("*Menu.relief", "flat") + st.theme_use("clam") + st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG, + selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=WHITE, + darkcolor=RAND, focuscolor=NAVY) + st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile + 4, borderwidth=0, font=FONT) + 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), + bordercolor=RAND, lightcolor=WHITE, darkcolor=RAND) + st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")]) + st.configure("TScrollbar", background=LIGHT, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE, + lightcolor=LIGHT, darkcolor=LIGHT, relief="flat", arrowsize=12, width=12) + st.map("TScrollbar", background=[("active", RAND)]) + st.configure("TCombobox", fieldbackground=WHITE, background=WHITE, arrowcolor=DARK, padding=3, + bordercolor=RAND, lightcolor=WHITE, darkcolor=WHITE) + st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)], + selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)]) # --------------------------------------------------------------------------- Wort löschen @@ -423,6 +504,8 @@ class Sunken(tk.Frame): """Eingelassener Rahmen (Doppellinie wie bei Win95-Listen).""" def __init__(self, master, **kw): + if MODERN: + kw.update(bd=0, relief="flat", highlightthickness=1, highlightbackground=RAND) kw.setdefault("bd", 2) kw.setdefault("relief", "sunken") super().__init__(master, **kw) @@ -432,6 +515,15 @@ 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) @@ -453,7 +545,7 @@ class Statusleiste(tk.Frame): self.vars: dict[str, tk.StringVar] = {} for i, (key, breite) in enumerate(felder): v = tk.StringVar() - lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4) + lbl = tk.Label(self, textvariable=v, bd=1, relief="flat" if MODERN else "sunken", anchor="w", padx=4) if breite: lbl.configure(width=breite) lbl.pack(side="left", padx=(0, 2), pady=2) @@ -466,8 +558,11 @@ class Statusleiste(tk.Frame): def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw): - b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE, - activebackground=FACE, **kw) + if MODERN: + b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern() + else: + b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE, + activebackground=FACE, **kw) if icon is not None: b.configure(image=icon, compound="left", padx=6) if breite: @@ -482,6 +577,15 @@ class ToolbarButton(tk.Label): super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE, padx=6, pady=2, font=FONT) self.command = command + if MODERN: # Hervorhebung durch Farbe statt 3D-Kante + self.configure(bd=0, padx=8, pady=4) + self.bind("", lambda e: self.configure(bg=LIGHT)) + self.bind("", lambda e: self.configure(bg=FACE)) + self.bind("", lambda e: self.configure(bg=SEL_BG)) + self.bind("", self._release) + if tooltip: + Tooltip(self, tooltip) + return self.bind("", lambda e: self.configure(relief="raised")) self.bind("", lambda e: self.configure(relief="flat")) self.bind("", lambda e: self.configure(relief="sunken")) @@ -490,7 +594,7 @@ class ToolbarButton(tk.Label): Tooltip(self, tooltip) def _release(self, e): - self.configure(relief="raised") + self.configure(bg=LIGHT) if MODERN else self.configure(relief="raised") if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command: self.command() @@ -528,6 +632,10 @@ class Tooltip: def trenner(master, vertikal: bool = True): f = tk.Frame(master, bg=FACE) + if MODERN: + tk.Frame(f, width=1, height=1, bg=RAND).pack(side="left", fill="y") if vertikal else \ + tk.Frame(f, height=1, bg=RAND).pack(fill="x") + return f if vertikal: tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y") tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y") @@ -538,6 +646,8 @@ def trenner(master, vertikal: bool = True): def gruppe(master, titel: str, **kw) -> tk.LabelFrame: + if MODERN: + return tk.LabelFrame(master, text=titel, bd=0, relief="flat", bg=FACE, padx=4, pady=2, font=FONT_BOLD, **kw) return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw) diff --git a/provtool/selbsttest.py b/provtool/selbsttest.py index 1821477..588c2ea 100644 --- a/provtool/selbsttest.py +++ b/provtool/selbsttest.py @@ -122,7 +122,8 @@ def ausfuehren(ergebnisdatei: str) -> int: # 2) Assistent komplett, Import, Export, Rundgang, Beenden csv = basis / "freenet.csv" _beispiel_csv(csv) - s = _Szenario("Assistent, Import, Export", basis / "b") + # Win95-Design: prüft auch den nativen Fensterrahmen (Szenario 1 läuft im modernen Standard) + s = _Szenario("Assistent, Import, Export (Win95)", basis / "b", '{"design": "win95"}') wiz = lambda: s.dialog(Einrichtungsassistent) # noqa: E731 seite = lambda n: (lambda: wiz() is not None and wiz().nr == n) # noqa: E731 diff --git a/provtool/zeitraum.py b/provtool/zeitraum.py new file mode 100644 index 0000000..9b48e75 --- /dev/null +++ b/provtool/zeitraum.py @@ -0,0 +1,94 @@ +"""Zeitraum für Ansicht und Export: nur Positionen von–bis abrechnen. + +Bezugsdatum einer Position („basis“): +- ``aktivierung`` – Aktivierungs-/Anschaltdatum der Position (Spalte „Datum“) +- ``abrechnung`` – Abrechnungszeitraum des Anbieters: bei freenet der Beginn der Abrechnungswoche + („2026.10.01-2026.10.07“), bei Vodafone der Monat des Abrechnungslaufs („VF2609“, + „CY202609UM“ -> 1.9.2026). Ist er nicht lesbar, gilt das Aktivierungsdatum. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import date, timedelta + +AKTIVIERUNG, ABRECHNUNG = "aktivierung", "abrechnung" +BASEN = {AKTIVIERUNG: "nach Aktivierungsdatum", ABRECHNUNG: "nach Abrechnungszeitraum des Anbieters"} + + +@dataclass(frozen=True) +class Zeitraum: + von: date | None = None + bis: date | None = None + basis: str = AKTIVIERUNG + + def enthaelt(self, p: dict) -> bool: + d = bezugsdatum(p, self.basis) + if d is None: + return False # ohne Datum lässt sich nicht sagen, ob sie dazugehört + return (self.von is None or d >= self.von) and (self.bis is None or d <= self.bis) + + def text(self) -> str: + von = self.von.strftime("%d.%m.%Y") if self.von else "Anfang" + bis = self.bis.strftime("%d.%m.%Y") if self.bis else "heute" + return f"{von} – {bis} ({BASEN[self.basis]})" + + +def datum_lesen(text: str) -> date | None: + """'1.10.2026', '01.10.26', '2026-10-01' -> date; leer -> None; sonst ValueError.""" + s = (text or "").strip() + if not s: + return None + m = re.fullmatch(r"(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})", s) + if m: + t, mo, j = (int(x) for x in m.groups()) + if j < 100: + j += 2000 + return date(j, mo, t) + m = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", s) + if m: + return date(*(int(x) for x in m.groups())) + raise ValueError(f"Kein gültiges Datum: {text!r} (bitte TT.MM.JJJJ)") + + +def abrechnungsdatum(zeitraum: str | None) -> date | None: + s = (zeitraum or "").strip() + m = re.match(r"(\d{4})\.(\d{2})\.(\d{2})", s) # freenet: 2026.10.01-2026.10.07 + if m: + try: + return date(*(int(x) for x in m.groups())) + except ValueError: + return None + m = re.match(r"(?:VF|CY)(\d{4}|\d{2})(\d{2})", s.upper()) # Vodafone: VF2609, CY202609UM + if m: + j, mo = int(m.group(1)), int(m.group(2)) + if j < 100: + j += 2000 + if 1 <= mo <= 12: + return date(j, mo, 1) + return None + + +def bezugsdatum(p: dict, basis: str) -> date | None: + if basis == ABRECHNUNG: + d = abrechnungsdatum(p.get("zeitraum")) + if d is not None: + return d + if p.get("datum"): + try: + return date.fromisoformat(p["datum"]) + except ValueError: + return None + return None + + +def vormonat(heute: date | None = None) -> tuple[date, date]: + heute = heute or date.today() + ende = heute.replace(day=1) - timedelta(days=1) + return ende.replace(day=1), ende + + +def dieser_monat(heute: date | None = None) -> tuple[date, date]: + heute = heute or date.today() + naechster = (heute.replace(day=28) + timedelta(days=4)).replace(day=1) + return heute.replace(day=1), naechster - timedelta(days=1) diff --git a/tests/test_gui.py b/tests/test_gui.py index f436ed4..438364b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -203,9 +203,7 @@ def home(tmp_path, monkeypatch): return tmp_path -@pytest.fixture -def lauf_mit_daten(home): - """App mit einem Händler und einer Abrechnung (erfundene Daten).""" +def _profil_mit_daten(home): from tests.test_kern import FREENET_KOPF, freenet_zeile csv = home / "freenet.csv" csv.write_bytes(("\r\n".join([ @@ -219,6 +217,21 @@ def lauf_mit_daten(home): from provtool.importer import lies_datei db.importieren(db.abrechnung_neu("Oktober 2026"), lies_datei(csv)) db.close() + + +@pytest.fixture +def lauf_mit_daten(home): + """App mit einem Händler und einer Abrechnung (erfundene Daten), klassisches Win95-Design.""" + _profil_mit_daten(home) + lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": "win95"}) + yield lauf + lauf.ende() + + +@pytest.fixture +def lauf_modern(home): + """Wie lauf_mit_daten, aber im modernen Design (Standard).""" + _profil_mit_daten(home) lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False}) yield lauf lauf.ende() @@ -324,8 +337,10 @@ def _dialog_oeffner(): @pytest.mark.parametrize("name", ["haendler", "abrechnungen", "export", "einstellungen", "neu", "info", "tipp", "dateien", "hilfe", "assistent", "prozent", "neu_berechnen", "provisionsarten", "staffel"]) -def test_schliessen_waehrend_dialog_offen(lauf_mit_daten, name): - lauf = lauf_mit_daten +@pytest.mark.parametrize("design", ["win95", "modern"]) +def test_schliessen_waehrend_dialog_offen(home, name, design): + _profil_mit_daten(home) + lauf = Lauf(home, {"assistent_erledigt": True, "tipps_beim_start": False, "design": design}) oeffnen = _dialog_oeffner()[name] lauf.ablauf([(IMMER, lambda: oeffnen(lauf.app), f"{name} öffnen"), (lambda: bool(lauf.dialoge()), lauf.schliessen_ueber_fenster, "Programm schließen")]) @@ -1052,3 +1067,52 @@ def test_rand_druecken_startet_natives_ziehen(lauf_mit_daten, monkeypatch): r._resize_start(ev) assert gezogen == ["w", "se"] lauf.pruefen() + + +def test_modern_ist_standard(lauf_modern): + from provtool.gui import win95 as w + lauf = lauf_modern + assert w.DESIGN == "modern" and not lauf.app.rahmen.klassisch and lauf.app.rahmen.titelleiste is None + lauf.ablauf([(IMMER, lambda: lauf.app.menueleiste._klick(lauf.app.menueleiste.knoepfe["Datei"]), "Menü"), + (lambda: lauf.app.menueleiste.offener_knopf is not None, + lambda: lauf.app.menueleiste.schliessen(), "zu")]) + assert not lauf.uebrig, lauf.diagnose() + lauf.pruefen() + + +def test_export_zeitraum_kennzeichnen_und_erledigt(lauf_modern, tmp_path, monkeypatch): + """Zeitraum abrechnen, exportierte Positionen nicht erneut exportieren, Abrechnung erledigt sperrt.""" + from provtool.gui import win95 as w + from provtool.gui.dialogs import ExportDialog + lauf = lauf_modern + app = lauf.app + app.settings["export_ordner"] = str(tmp_path / "x") + meldungen = [] + monkeypatch.setattr(w, "info", lambda m, t, text: meldungen.append(text)) + monkeypatch.setattr(w, "frage", lambda *a, **k: True) + + def ok(von="", bis=""): + d = lauf.dialog(ExportDialog) + d.v_von.set(von) + d.v_bis.set(bis) + d.v_auf.set(False) + d._ok() + + lauf.ablauf([ + (IMMER, app.exportieren, "Export Oktober 6."), + (lambda: lauf.dialog(ExportDialog) is not None, lambda: ok("01.10.2026", "05.10.2026"), "nur bis 5.10."), + (lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "Export Rest"), + (lambda: lauf.dialog(ExportDialog) is not None, ok, "alles Offene"), + (lambda: lauf.dialog(ExportDialog) is None, app.exportieren, "nochmal"), + ]) + assert not lauf.uebrig, lauf.diagnose() + assert sum(1 for p in app.positionen if p["abgerechnet"]) == 2 + assert "1 Position(en) als abgerechnet" in meldungen[0] and "1 Position(en) als abgerechnet" in meldungen[1] + assert "bereits abgerechnet" in meldungen[2], meldungen + # erledigt: Änderungen gesperrt + app.erledigt_umschalten() + assert app.db.erledigt(app.aid) + app.ptree.selection_set(app.ptree.get_children()[0]) + app.prozent() + assert "erledigt" in meldungen[-1] + lauf.pruefen() diff --git a/tests/test_kern.py b/tests/test_kern.py index 813730d..52f2523 100644 --- a/tests/test_kern.py +++ b/tests/test_kern.py @@ -419,3 +419,99 @@ def test_migration_v2_haendler_ohne_airtime(tmp_path): d.standard_staffel_setzen([(1, 100), (5, None)]) assert d.staffel_fuer(h.id) == [(1, 100), (5, None)] d.close() + + +# --------------------------------------------------------------------------- Zeitraum / abgerechnet / erledigt + +def test_zeitraum_lesen_und_bezugsdatum(): + from datetime import date + from provtool import zeitraum as zr + assert zr.datum_lesen("1.10.2026") == date(2026, 10, 1) and zr.datum_lesen("01.10.26") == date(2026, 10, 1) + assert zr.datum_lesen("") is None + with pytest.raises(ValueError): + zr.datum_lesen("32.13.2026") + assert zr.abrechnungsdatum("2026.10.08-2026.10.15") == date(2026, 10, 8) + assert zr.abrechnungsdatum("VF2609") == date(2026, 9, 1) == zr.abrechnungsdatum("CY202609UM") + assert zr.abrechnungsdatum("VF2609P6 Fil 0") == date(2026, 9, 1) + p = {"zeitraum": "VF2609", "datum": "2026-08-30"} + assert zr.bezugsdatum(p, zr.AKTIVIERUNG) == date(2026, 8, 30) + assert zr.bezugsdatum(p, zr.ABRECHNUNG) == date(2026, 9, 1) + okt = zr.Zeitraum(date(2026, 10, 1), date(2026, 10, 31)) + assert okt.enthaelt({"datum": "2026-10-31"}) and not okt.enthaelt({"datum": "2026-11-01"}) + assert not okt.enthaelt({"datum": None}) + assert zr.vormonat(date(2026, 3, 15)) == (date(2026, 2, 1), date(2026, 2, 28)) + assert zr.dieser_monat(date(2026, 12, 5)) == (date(2026, 12, 1), date(2026, 12, 31)) + + +def test_abgerechnet_einfrieren_und_doppelt_erkennen(db, freenet_csv): + from provtool.db import AbrechnungErledigt + hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")]) + a1 = db.abrechnung_neu("Okt") + db.importieren(a1, lies_datei(freenet_csv)) + pos = [p for p in db.positionen(a1) if p["haendler_id"] == hid] + assert len(pos) == 4 and all(p["fp"] for p in pos) + erste = pos[:2] + assert db.abrechnen([p["id"] for p in erste]) == 2 + # abgerechnet: Betrag und Händler eingefroren + h = db.haendler(hid) + h.standard_prozent = 50 + db.haendler_speichern(h, []) # Kennung weg, 50 % + db.neu_berechnen(a1) + nach = {p["id"]: p for p in db.positionen(a1)} + for p in erste: + assert nach[p["id"]]["haendler_id"] == hid and nach[p["id"]]["betrag"] == p["betrag"] + with pytest.raises(AbrechnungErledigt): + db.betrag_setzen(erste[0]["id"], 1) + assert db.prozent_anwenden([erste[0]["id"]], 10) == 0 + + # dieselbe Datei in einer neuen Abrechnung: die beiden abgerechneten Positionen sind „doppelt“ + db.haendler_speichern(db.haendler(hid), [("freenet", "111")]) + a2 = db.abrechnung_neu("Nov") + db.importieren(a2, lies_datei(freenet_csv)) + 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 db.abrechnen([p["id"] for p in doppelt]) == 0, "doppelte Positionen dürfen nicht erneut abgerechnet werden" + + # Kennzeichnung aufheben -> nicht mehr doppelt + assert db.abrechnung_aufheben([p["id"] for p in erste]) == 2 + assert not any(p["doppelt"] for p in db.positionen(a2)) + + +def test_abrechnung_erledigt_sperrt(db, freenet_csv): + from provtool.db import AbrechnungErledigt + hid = db.haendler_speichern(Haendler(0, "A"), [("freenet", "111")]) + aid = db.abrechnung_neu("Okt") + db.importieren(aid, lies_datei(freenet_csv)) + assert db.erledigt_setzen(aid, True) == 4 # nur die 4 Händler-Positionen, nicht die unzugeordnete + assert db.erledigt(aid) + assert all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid) + with pytest.raises(AbrechnungErledigt): + db.importieren(aid, lies_datei(freenet_csv)) + with pytest.raises(AbrechnungErledigt): + db.datei_entfernen(db.dateien(aid)[0]["id"]) + unz = next(p for p in db.positionen(aid) if p["haendler_id"] <= 0) + db.haendler_zuweisen([unz["id"]], hid) + assert next(p for p in db.positionen(aid) if p["id"] == unz["id"])["haendler_id"] <= 0, "erledigt: keine Änderung" + assert db.abrechnungen()[0]["abgerechnet"] == 4 + # wieder öffnen, Positionen bleiben abgerechnet bzw. werden auf Wunsch freigegeben + db.erledigt_setzen(aid, False) + assert not db.erledigt(aid) and all(p["abgerechnet"] for p in db.positionen(aid) if p["haendler_id"] == hid) + db.erledigt_setzen(aid, True) + db.erledigt_setzen(aid, False, positionen_aufheben=True) + assert not any(p["abgerechnet"] for p in db.positionen(aid)) + + +def test_migration_v3_fingerabdruck(tmp_path, freenet_csv): + import sqlite3 + d = Datenbank(tmp_path / "x.db") + aid = d.abrechnung_neu("Alt") + d.importieren(aid, lies_datei(freenet_csv)) + d.close() + con = sqlite3.connect(tmp_path / "x.db") # wie eine v3-Datenbank: ohne Fingerabdrücke + con.execute("UPDATE position SET fp=NULL") + con.commit() + con.close() + d = Datenbank(tmp_path / "x.db") + assert all(p["fp"] for p in d.positionen(aid)) + assert len({p["fp"] for p in d.positionen(aid)}) == 5 + d.close()