Modernes Design (Standard, Win95 als Legacy), Zeitraum abrechnen, Abrechnung erledigt, Linux-Build
- Design: „modern“ (hell, flach, wie Windows 11, normaler Windows-Rahmen) ist Standard; „win95“ bleibt unter Datei > Einstellungen wählbar (Einstellung „design“). - Export: Zeitraum von–bis (nach Aktivierungsdatum oder Abrechnungszeitraum des Anbieters), exportierte Positionen werden als abgerechnet gekennzeichnet (Betrag/Händler eingefroren) und per Fingerabdruck auch in späteren Dateien/Abrechnungen nicht noch einmal abgerechnet. - Datei > Abrechnung erledigt / wieder öffnen: erledigte Abrechnungen sind gegen Änderungen gesperrt. - DB-Schema v4 (Migration inkl. Fingerabdrücke für alte Positionen). - Linux-Build (python:3.12-bookworm) als zweiter Pipeline-Job mit Tests und Selbsttest; Release-Upload für beide Jobs über build/release_hochladen.sh. - Behoben: normaler Windows-Rahmen stürzte beim Start ab (Hauptrahmen.nativ fehlte). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
97c73b82c7
commit
851fd6600a
17 files changed
+829
-80
No files matched your search
+147
-2
@@ -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))
|
||||
Reference in new issue
Block a user