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

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

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

No files matched your search

+147 -2
View File
@@ -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))