Vodafone: Zuordnung über Kundennamen in GROSSBUCHSTABEN, feste Händler-Zuweisung, nur erste Seite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
da2e3c9ba1
commit
5cd59d0728
6 files changed
+235
-27
No files matched your search
@@ -9,8 +9,13 @@ fertige Excel-Abrechnung. Optik: Windows 95.
|
|||||||
|
|
||||||
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*:
|
1. **Händler anlegen** (`Strg+H`): Name, Standard-Auszahlung in % und die *Kennungen*:
|
||||||
- freenet → Spalte **Unterhaendler** (z. B. `11111102`)
|
- freenet → Spalte **Unterhaendler** (z. B. `11111102`)
|
||||||
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`)
|
- Vodafone → Spalte **Vo-Nr** (z. B. `80000002`, `EU000001`) **oder**
|
||||||
|
„Vodafone (Name in GROSSBUCHSTABEN)“: alle Vodafone-Positionen, deren Kundenname komplett
|
||||||
|
großgeschrieben ist (so erfasst unser Unterhändler seine Kunden). Diese Regel kann nur ein
|
||||||
|
Händler haben und hat Vorrang vor der Vo-Nr.
|
||||||
Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern.
|
Ein Händler kann mehrere Kennungen haben, auch bei beiden Anbietern.
|
||||||
|
Einzelne Fehltreffer: Positionen markieren → Rechtsklick ▸ **Händler zuweisen…**
|
||||||
|
(feste Zuweisung, Vorrang vor allem anderen; zurück mit „Automatisch zuordnen“).
|
||||||
2. **Neue Abrechnung** (`Strg+N`), z. B. „Oktober 2026“.
|
2. **Neue Abrechnung** (`Strg+N`), z. B. „Oktober 2026“.
|
||||||
3. **Import** (`Strg+I`): beliebig viele Dateien auf einmal, z. B. alle freenet-Wochenlisten
|
3. **Import** (`Strg+I`): beliebig viele Dateien auf einmal, z. B. alle freenet-Wochenlisten
|
||||||
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
|
eines Monats plus die Vodafone-Datei. Das Format wird an der Kopfzeile erkannt.
|
||||||
@@ -49,8 +54,8 @@ Speicherort änderbar unter *Datei ▸ Einstellungen*, Datensicherung über *Dat
|
|||||||
| Dienst | TarifKey02 |
|
| Dienst | TarifKey02 |
|
||||||
| Aktionscode | Text |
|
| Aktionscode | Text |
|
||||||
|
|
||||||
Bei Vodafone wird nur das Blatt **All** gelesen (die Einzelblätter je Abrechnungslauf würden
|
Bei Vodafone wird nur die **erste Seite** („All“) gelesen – die weiteren Blätter je
|
||||||
sonst doppelt zählen).
|
Abrechnungslauf würden sonst doppelt zählen.
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
|
|||||||
+58
-5
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from provtool.importer import ImportErgebnis
|
from provtool.importer import ImportErgebnis
|
||||||
from provtool.money import apply_percent
|
from provtool.money import apply_percent
|
||||||
|
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
|
CREATE TABLE IF NOT EXISTS meta (schluessel TEXT PRIMARY KEY, wert TEXT);
|
||||||
@@ -83,6 +83,16 @@ CREATE INDEX IF NOT EXISTS ix_position_abr ON position(abrechnung_id);
|
|||||||
|
|
||||||
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
|
UNZUGEORDNET = 0 # Pseudo-Händler-ID für Positionen ohne bekannte Kennung
|
||||||
|
|
||||||
|
# Sonder-Kennung: Vodafone-Positionen, deren Kundenname komplett in GROSSBUCHSTABEN steht
|
||||||
|
# (so erfasst ein Unterhändler seine Kunden). Hat Vorrang vor der VO-Nr.
|
||||||
|
CAPS_ANBIETER = "vodafone_caps"
|
||||||
|
CAPS_WERT = "*"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Haendler:
|
class Haendler:
|
||||||
@@ -107,9 +117,18 @@ class Datenbank:
|
|||||||
self.con.row_factory = sqlite3.Row
|
self.con.row_factory = sqlite3.Row
|
||||||
self.con.execute("PRAGMA foreign_keys = ON")
|
self.con.execute("PRAGMA foreign_keys = ON")
|
||||||
self.con.executescript(SCHEMA)
|
self.con.executescript(SCHEMA)
|
||||||
|
self._migrieren()
|
||||||
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
|
self.con.execute("INSERT OR IGNORE INTO meta VALUES ('schema_version', ?)", (str(SCHEMA_VERSION),))
|
||||||
self.con.commit()
|
self.con.commit()
|
||||||
|
|
||||||
|
def _migrieren(self):
|
||||||
|
spalten = {r[1] for r in self.con.execute("PRAGMA table_info(position)")}
|
||||||
|
if "haendler_fest" not in spalten:
|
||||||
|
# NULL = automatisch über Kennungen, sonst fest zugewiesene Händler-ID (0 = bewusst keiner)
|
||||||
|
self.con.execute("ALTER TABLE position ADD COLUMN haendler_fest INTEGER")
|
||||||
|
self.con.execute("UPDATE meta SET wert=? WHERE schluessel='schema_version'", (str(SCHEMA_VERSION),))
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.con.close()
|
self.con.close()
|
||||||
|
|
||||||
@@ -163,6 +182,9 @@ class Datenbank:
|
|||||||
besitzer = self.con.execute("SELECT h.name FROM kennung k JOIN haendler h ON h.id=k.haendler_id "
|
besitzer = self.con.execute("SELECT h.name FROM kennung k JOIN haendler h ON h.id=k.haendler_id "
|
||||||
"WHERE k.anbieter=? AND k.wert=?", (anbieter, wert)).fetchone()
|
"WHERE k.anbieter=? AND k.wert=?", (anbieter, wert)).fetchone()
|
||||||
if besitzer:
|
if besitzer:
|
||||||
|
if anbieter == CAPS_ANBIETER:
|
||||||
|
raise ValueError("Die Regel „Vodafone-Kundenname in GROSSBUCHSTABEN“ ist bereits dem "
|
||||||
|
f"Händler „{besitzer[0]}“ zugeordnet – sie kann nur einen Händler haben.")
|
||||||
raise ValueError(f"Die Kennung {wert} ({anbieter}) ist bereits dem Händler „{besitzer[0]}“ zugeordnet.")
|
raise ValueError(f"Die Kennung {wert} ({anbieter}) ist bereits dem Händler „{besitzer[0]}“ zugeordnet.")
|
||||||
self.con.execute("INSERT INTO kennung (haendler_id, anbieter, wert) VALUES (?,?,?)", (hid, anbieter, wert))
|
self.con.execute("INSERT INTO kennung (haendler_id, anbieter, wert) VALUES (?,?,?)", (hid, anbieter, wert))
|
||||||
return hid
|
return hid
|
||||||
@@ -179,6 +201,20 @@ class Datenbank:
|
|||||||
return {(r[0], r[1]): r[2] for r in self.con.execute(
|
return {(r[0], r[1]): r[2] for r in self.con.execute(
|
||||||
"SELECT k.anbieter, k.wert, k.haendler_id FROM kennung k JOIN haendler h ON h.id=k.haendler_id WHERE h.aktiv=1")}
|
"SELECT k.anbieter, k.wert, k.haendler_id FROM kennung k JOIN haendler h ON h.id=k.haendler_id WHERE h.aktiv=1")}
|
||||||
|
|
||||||
|
def zuordner(self):
|
||||||
|
"""Liefert eine Funktion (anbieter, kennung, name, haendler_fest) -> Händler-ID."""
|
||||||
|
kmap = self.kennung_map()
|
||||||
|
caps = kmap.get((CAPS_ANBIETER, CAPS_WERT))
|
||||||
|
aktive = {h.id for h in self.haendler_liste(nur_aktive=True)}
|
||||||
|
|
||||||
|
def finde(anbieter: str, kennung: str, name: str | None = None, fest: int | None = None) -> int:
|
||||||
|
if fest is not None:
|
||||||
|
return fest if (fest == UNZUGEORDNET or fest in aktive) else UNZUGEORDNET
|
||||||
|
if caps and anbieter == "vodafone" and ist_grossgeschrieben(name):
|
||||||
|
return caps
|
||||||
|
return kmap.get((anbieter, kennung), UNZUGEORDNET)
|
||||||
|
return finde
|
||||||
|
|
||||||
# ------------------------------------------------------------------ Regeln
|
# ------------------------------------------------------------------ Regeln
|
||||||
def regeln(self, hid: int) -> dict[str, float]:
|
def regeln(self, hid: int) -> dict[str, float]:
|
||||||
return {r[0]: r[1] for r in self.con.execute(
|
return {r[0]: r[1] for r in self.con.execute(
|
||||||
@@ -241,7 +277,7 @@ class Datenbank:
|
|||||||
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
self.con.execute("DELETE FROM abrechnung_datei WHERE id=?", (datei_id,))
|
||||||
|
|
||||||
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
def importieren(self, aid: int, erg: ImportErgebnis) -> int:
|
||||||
kmap = self.kennung_map()
|
finde = self.zuordner()
|
||||||
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
|
start = self.con.execute("SELECT coalesce(max(reihenfolge), 0) FROM position WHERE abrechnung_id=?",
|
||||||
(aid,)).fetchone()[0]
|
(aid,)).fetchone()[0]
|
||||||
with self.con:
|
with self.con:
|
||||||
@@ -250,7 +286,7 @@ class Datenbank:
|
|||||||
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
|
(aid, erg.datei, erg.sha256, erg.format, _now(), len(erg.positionen)))
|
||||||
did = cur.lastrowid
|
did = cur.lastrowid
|
||||||
for i, p in enumerate(erg.positionen, start=start + 1):
|
for i, p in enumerate(erg.positionen, start=start + 1):
|
||||||
hid = kmap.get((p.anbieter, p.kennung), UNZUGEORDNET)
|
hid = finde(p.anbieter, p.kennung, p.name)
|
||||||
proz = self.prozent_fuer(hid, p.provisionsart)
|
proz = self.prozent_fuer(hid, p.provisionsart)
|
||||||
self.con.execute(
|
self.con.execute(
|
||||||
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
|
"INSERT INTO position (abrechnung_id, datei_id, reihenfolge, anbieter, zeitraum, provisionsart, kennung, "
|
||||||
@@ -265,14 +301,31 @@ class Datenbank:
|
|||||||
|
|
||||||
def positionen(self, aid: int) -> list[dict]:
|
def positionen(self, aid: int) -> list[dict]:
|
||||||
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen)."""
|
"""Alle Positionen mit aktueller Händler-Zuordnung (über die Kennungen)."""
|
||||||
kmap = self.kennung_map()
|
finde = self.zuordner()
|
||||||
out = []
|
out = []
|
||||||
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
|
for r in self.con.execute("SELECT * FROM position WHERE abrechnung_id=? ORDER BY reihenfolge", (aid,)):
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
d["haendler_id"] = kmap.get((d["anbieter"], d["kennung"]), UNZUGEORDNET)
|
d["haendler_id"] = finde(d["anbieter"], d["kennung"], d["name"], d["haendler_fest"])
|
||||||
out.append(d)
|
out.append(d)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
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."""
|
||||||
|
with self.con:
|
||||||
|
for pid in pids:
|
||||||
|
self.con.execute("UPDATE position SET haendler_fest=? WHERE id=?", (hid, pid))
|
||||||
|
finde = self.zuordner()
|
||||||
|
with self.con:
|
||||||
|
for pid in pids:
|
||||||
|
r = self.con.execute("SELECT * FROM position WHERE id=?", (pid,)).fetchone()
|
||||||
|
if r is None or r["manuell"]:
|
||||||
|
continue
|
||||||
|
neu_hid = finde(r["anbieter"], r["kennung"], r["name"], r["haendler_fest"])
|
||||||
|
proz = self.prozent_fuer(neu_hid, r["provisionsart"])
|
||||||
|
self.con.execute("UPDATE position SET betrag=?, prozent=? WHERE id=?",
|
||||||
|
(apply_percent(r["betrag_orig"], proz), proz, pid))
|
||||||
|
|
||||||
def betrag_setzen(self, pid: int, cent: int) -> None:
|
def betrag_setzen(self, pid: int, cent: int) -> None:
|
||||||
with self.con:
|
with self.con:
|
||||||
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
|
self.con.execute("UPDATE position SET betrag=?, manuell=1, prozent=NULL WHERE id=?", (cent, pid))
|
||||||
|
|||||||
+24
-3
@@ -116,6 +116,8 @@ class App:
|
|||||||
h = tk.Menu(m, tearoff=0)
|
h = tk.Menu(m, tearoff=0)
|
||||||
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler)
|
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler)
|
||||||
h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl)
|
h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||||||
|
h.add_command(label="Auswahl fest einem Händler zuweisen…", command=self.haendler_zuweisen)
|
||||||
|
h.add_command(label="Auswahl automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
|
||||||
m.add_cascade(label="Händler", menu=h, underline=0)
|
m.add_cascade(label="Händler", menu=h, underline=0)
|
||||||
|
|
||||||
hi = tk.Menu(m, tearoff=0)
|
hi = tk.Menu(m, tearoff=0)
|
||||||
@@ -328,7 +330,8 @@ class App:
|
|||||||
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
|
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
|
||||||
t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
|
t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
|
||||||
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
||||||
p["kennung"], fmt(p["betrag_orig"]),
|
p["kennung"] + (" (fest)" if p["haendler_fest"] is not None else ""),
|
||||||
|
fmt(p["betrag_orig"]),
|
||||||
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
|
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
|
||||||
tags=tuple(tags))
|
tags=tuple(tags))
|
||||||
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
|
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
|
||||||
@@ -641,11 +644,28 @@ class App:
|
|||||||
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
|
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
|
||||||
m.add_separator()
|
m.add_separator()
|
||||||
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
|
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
|
||||||
|
m.add_separator()
|
||||||
|
m.add_command(label="Händler zuweisen…", command=self.haendler_zuweisen)
|
||||||
|
m.add_command(label="Automatisch zuordnen", command=lambda: self.haendler_zuweisen(automatisch=True))
|
||||||
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
|
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
|
||||||
m.add_separator()
|
|
||||||
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
|
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||||||
m.tk_popup(e.x_root, e.y_root)
|
m.tk_popup(e.x_root, e.y_root)
|
||||||
|
|
||||||
|
def haendler_zuweisen(self, automatisch: bool = False):
|
||||||
|
sel, pids, _ = self._auswahl_info()
|
||||||
|
if not pids:
|
||||||
|
w.info(self.root, "Händler zuweisen", "Bitte zuerst Positionen oder eine Gruppe auswählen.")
|
||||||
|
return
|
||||||
|
if automatisch:
|
||||||
|
hid = None
|
||||||
|
else:
|
||||||
|
hid = dlg.HaendlerWaehlen(self.root, self.db, len(pids)).zeigen()
|
||||||
|
if hid is None:
|
||||||
|
return
|
||||||
|
self.db.haendler_zuweisen(pids, hid)
|
||||||
|
self.db.touch(self.aid)
|
||||||
|
self._aktualisieren()
|
||||||
|
|
||||||
def _null(self):
|
def _null(self):
|
||||||
sel, pids, _ = self._auswahl_info()
|
sel, pids, _ = self._auswahl_info()
|
||||||
self.db.prozent_anwenden(pids, 0.0)
|
self.db.prozent_anwenden(pids, 0.0)
|
||||||
@@ -746,7 +766,8 @@ class App:
|
|||||||
def hilfe(self):
|
def hilfe(self):
|
||||||
w.info(self.root, "Kurzanleitung",
|
w.info(self.root, "Kurzanleitung",
|
||||||
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
|
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
|
||||||
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n\n"
|
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr. oder die Regel\n"
|
||||||
|
" „Name in GROSSBUCHSTABEN“. Einzelne Positionen: Rechtsklick ▸ Händler zuweisen.\n\n"
|
||||||
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
|
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
|
||||||
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
|
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
|
||||||
" auch alle Wochenlisten eines Monats.\n\n"
|
" auch alle Wochenlisten eines Monats.\n\n"
|
||||||
|
|||||||
+55
-9
@@ -5,9 +5,14 @@ import tkinter as tk
|
|||||||
from tkinter import filedialog, ttk
|
from tkinter import filedialog, ttk
|
||||||
|
|
||||||
from provtool import APP_NAME, APP_ORG, __version__
|
from provtool import APP_NAME, APP_ORG, __version__
|
||||||
from provtool.db import Datenbank, Haendler
|
from provtool.db import CAPS_ANBIETER, CAPS_WERT, Datenbank, Haendler
|
||||||
from provtool.gui import win95 as w
|
from provtool.gui import win95 as w
|
||||||
from provtool.importer import ANBIETER
|
from provtool.importer import ANBIETER
|
||||||
|
|
||||||
|
# Auswahl im Händler-Dialog: Schlüssel → Anzeige
|
||||||
|
KENNUNG_ARTEN = {"freenet": "freenet (Unterhändler-Nr.)", "vodafone": "Vodafone (VO-Nr.)",
|
||||||
|
CAPS_ANBIETER: "Vodafone (Name in GROSSBUCHSTABEN)"}
|
||||||
|
CAPS_TEXT = "Kunden-Nachname komplett in GROSSBUCHSTABEN"
|
||||||
from provtool.money import fmt, fmt_percent, parse_percent
|
from provtool.money import fmt, fmt_percent, parse_percent
|
||||||
|
|
||||||
|
|
||||||
@@ -68,15 +73,18 @@ class HaendlerDialog(w.Dialog):
|
|||||||
self.kliste.heading("anbieter", text="Anbieter")
|
self.kliste.heading("anbieter", text="Anbieter")
|
||||||
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
||||||
self.kliste.column("anbieter", width=90)
|
self.kliste.column("anbieter", width=90)
|
||||||
self.kliste.column("wert", width=200)
|
self.kliste.column("wert", width=260)
|
||||||
r2.pack(fill="both", expand=True)
|
r2.pack(fill="both", expand=True)
|
||||||
kf = tk.Frame(g2, bg=w.FACE)
|
kf = tk.Frame(g2, bg=w.FACE)
|
||||||
kf.pack(fill="x", pady=(4, 0))
|
kf.pack(fill="x", pady=(4, 0))
|
||||||
self.v_kanb = tk.StringVar(value="freenet")
|
self.v_kanb = tk.StringVar(value=KENNUNG_ARTEN["freenet"])
|
||||||
cb = ttk.Combobox(kf, textvariable=self.v_kanb, values=list(ANBIETER.values()), width=10, state="readonly")
|
cb = ttk.Combobox(kf, textvariable=self.v_kanb, values=list(KENNUNG_ARTEN.values()), width=34,
|
||||||
|
state="readonly")
|
||||||
cb.pack(side="left")
|
cb.pack(side="left")
|
||||||
|
cb.bind("<<ComboboxSelected>>", lambda e: self._art_gewaehlt())
|
||||||
self.v_kwert = tk.StringVar()
|
self.v_kwert = tk.StringVar()
|
||||||
ke = tk.Entry(kf, textvariable=self.v_kwert, width=18)
|
ke = tk.Entry(kf, textvariable=self.v_kwert, width=14)
|
||||||
|
self._ke = ke
|
||||||
ke.pack(side="left", padx=4)
|
ke.pack(side="left", padx=4)
|
||||||
ke.bind("<Return>", lambda e: self._kennung_dazu())
|
ke.bind("<Return>", lambda e: self._kennung_dazu())
|
||||||
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
|
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
|
||||||
@@ -111,7 +119,12 @@ class HaendlerDialog(w.Dialog):
|
|||||||
# ---- intern
|
# ---- intern
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _anbieter_key(anzeige: str) -> str:
|
def _anbieter_key(anzeige: str) -> str:
|
||||||
return next((k for k, v in ANBIETER.items() if v == anzeige), anzeige)
|
return next((k for k, v in KENNUNG_ARTEN.items() if v == anzeige), anzeige)
|
||||||
|
|
||||||
|
def _art_gewaehlt(self):
|
||||||
|
caps = self._anbieter_key(self.v_kanb.get()) == CAPS_ANBIETER
|
||||||
|
self.v_kwert.set("")
|
||||||
|
self._ke.configure(state="disabled" if caps else "normal")
|
||||||
|
|
||||||
def _liste_laden(self, auswahl_id=None):
|
def _liste_laden(self, auswahl_id=None):
|
||||||
self.liste.delete(*self.liste.get_children())
|
self.liste.delete(*self.liste.get_children())
|
||||||
@@ -147,7 +160,10 @@ class HaendlerDialog(w.Dialog):
|
|||||||
def _kennungen_zeigen(self):
|
def _kennungen_zeigen(self):
|
||||||
self.kliste.delete(*self.kliste.get_children())
|
self.kliste.delete(*self.kliste.get_children())
|
||||||
for i, (a, v) in enumerate(self._kennungen):
|
for i, (a, v) in enumerate(self._kennungen):
|
||||||
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
|
if a == CAPS_ANBIETER:
|
||||||
|
self.kliste.insert("", "end", iid=str(i), values=("Vodafone", CAPS_TEXT))
|
||||||
|
else:
|
||||||
|
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
|
||||||
|
|
||||||
def _regeln_zeigen(self):
|
def _regeln_zeigen(self):
|
||||||
self.rliste.delete(*self.rliste.get_children())
|
self.rliste.delete(*self.rliste.get_children())
|
||||||
@@ -168,10 +184,10 @@ class HaendlerDialog(w.Dialog):
|
|||||||
self._regeln_zeigen()
|
self._regeln_zeigen()
|
||||||
|
|
||||||
def _kennung_dazu(self):
|
def _kennung_dazu(self):
|
||||||
wert = self.v_kwert.get().strip()
|
anb = self._anbieter_key(self.v_kanb.get())
|
||||||
|
wert = CAPS_WERT if anb == CAPS_ANBIETER else self.v_kwert.get().strip()
|
||||||
if not wert:
|
if not wert:
|
||||||
return
|
return
|
||||||
anb = self._anbieter_key(self.v_kanb.get())
|
|
||||||
if (anb, wert) not in self._kennungen:
|
if (anb, wert) not in self._kennungen:
|
||||||
self._kennungen.append((anb, wert))
|
self._kennungen.append((anb, wert))
|
||||||
self.v_kwert.set("")
|
self.v_kwert.set("")
|
||||||
@@ -486,6 +502,36 @@ class KennungZuordnen(w.Dialog):
|
|||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class HaendlerWaehlen(w.Dialog):
|
||||||
|
"""Feste Zuweisung ausgewählter Positionen zu einem Händler."""
|
||||||
|
|
||||||
|
KEINER = "(keinem Händler – nicht auszahlen)"
|
||||||
|
|
||||||
|
def __init__(self, master, db: Datenbank, anzahl: int):
|
||||||
|
super().__init__(master, "Händler zuweisen")
|
||||||
|
self._hmap = {h.name: h.id for h in db.haendler_liste(nur_aktive=True)}
|
||||||
|
tk.Label(self, text=f"{anzahl} Position(en) fest zuweisen an:", anchor="w").pack(fill="x", padx=12,
|
||||||
|
pady=(12, 4))
|
||||||
|
self.cb = ttk.Combobox(self, values=list(self._hmap) + [self.KEINER], state="readonly", width=40)
|
||||||
|
self.cb.pack(padx=12, pady=4)
|
||||||
|
tk.Label(self, text="Die feste Zuweisung hat Vorrang vor Kennungen und der GROSSBUCHSTABEN-Regel.\n"
|
||||||
|
"Rückgängig: Rechtsklick ▸ „Automatisch zuordnen“.", justify="left",
|
||||||
|
fg=w.SHADOW).pack(fill="x", padx=12)
|
||||||
|
self.bind("<Return>", lambda e: self._ok())
|
||||||
|
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
|
||||||
|
|
||||||
|
def _ok(self):
|
||||||
|
name = self.cb.get()
|
||||||
|
if not name:
|
||||||
|
w.warnung(self, "Händler zuweisen", "Bitte einen Händler auswählen.")
|
||||||
|
return
|
||||||
|
self.result = 0 if name == self.KEINER else self._hmap[name]
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def zeigen(self, fokus=None):
|
||||||
|
return super().zeigen(self.cb)
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================== Export
|
# =========================================================================== Export
|
||||||
|
|
||||||
class ExportDialog(w.Dialog):
|
class ExportDialog(w.Dialog):
|
||||||
|
|||||||
+10
-7
@@ -288,13 +288,16 @@ def lies_datei(path: str | Path) -> ImportErgebnis:
|
|||||||
)
|
)
|
||||||
|
|
||||||
warnungen: list[str] = []
|
warnungen: list[str] = []
|
||||||
# Vodafone/Euronics: Das Blatt "All" enthält alle Abrechnungsläufe. Sind zusätzlich
|
# Vodafone/Euronics: Maßgeblich ist nur die erste Seite ("All") – sie enthält alle
|
||||||
# Einzelblätter befüllt, würden Positionen doppelt gezählt – daher nur "All" verwenden.
|
# Abrechnungsläufe. Weitere Blätter würden Positionen doppelt zählen.
|
||||||
alle = [g for g in gefunden if g[0].lower() == "all"]
|
if any(f.key == VodafoneFormat.key for _, f, _ in gefunden):
|
||||||
if alle and len(gefunden) > 1:
|
erstes = blaetter[0][0]
|
||||||
uebrig = [g[0] for g in gefunden if g is not alle[0]]
|
uebrig = [g[0] for g in gefunden if g[0] != erstes]
|
||||||
warnungen.append(f"Nur Blatt „All“ übernommen, Einzelblätter ignoriert: {', '.join(uebrig)}")
|
gefunden = [g for g in gefunden if g[0] == erstes]
|
||||||
gefunden = alle
|
if uebrig:
|
||||||
|
warnungen.append(f"Nur die erste Seite „{erstes}“ übernommen, ignoriert: {', '.join(uebrig)}")
|
||||||
|
if not gefunden:
|
||||||
|
raise ImportFehler(f"Auf der ersten Seite von „{path.name}“ wurde keine Vodafone-Abrechnung gefunden.")
|
||||||
|
|
||||||
positionen = [p for _, _, ps in gefunden for p in ps]
|
positionen = [p for _, _, ps in gefunden for p in ps]
|
||||||
formate = sorted({f.key for _, f, _ in gefunden})
|
formate = sorted({f.key for _, f, _ in gefunden})
|
||||||
|
|||||||
@@ -185,3 +185,83 @@ def test_datei_doppelt_erkannt_und_entfernbar(db, freenet_csv):
|
|||||||
did = db.dateien(aid)[0]["id"]
|
did = db.dateien(aid)[0]["id"]
|
||||||
db.datei_entfernen(did)
|
db.datei_entfernen(did)
|
||||||
assert db.positionen(aid) == []
|
assert db.positionen(aid) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Vodafone: GROSSBUCHSTABEN-Regel
|
||||||
|
|
||||||
|
def test_grossbuchstaben_erkennung():
|
||||||
|
from provtool.db import ist_grossgeschrieben
|
||||||
|
assert ist_grossgeschrieben("HUBERMEIER")
|
||||||
|
assert ist_grossgeschrieben("ÖSTERLEHNER ")
|
||||||
|
assert ist_grossgeschrieben("GROß") # ß gibt es nicht als Großbuchstabe
|
||||||
|
assert not ist_grossgeschrieben("Musterfrau")
|
||||||
|
assert not ist_grossgeschrieben("Max & Moritz Test")
|
||||||
|
assert not ist_grossgeschrieben("")
|
||||||
|
assert not ist_grossgeschrieben("X")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vodafone_caps_xlsx(tmp_path):
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "All"
|
||||||
|
kopf = ["Anschaltdatum", "Kartennummer", "IMEI", "Rufnummer", "Kunde", "Artikelbezeichnung", "Tarif",
|
||||||
|
"TarifKey02", "Prämie in EUR", "Abrechnungslauf", "Vo-Nr", "Lieferantennr", "Text"]
|
||||||
|
for c, h in enumerate(kopf, 1):
|
||||||
|
ws.cell(6, c, h)
|
||||||
|
for r, (kunde, betrag) in enumerate([("HUBERMEIER ", 295), ("Musterfrau", 370), ("BEISPIELER", 235)], 7):
|
||||||
|
for c, v in enumerate((datetime(2026, 9, 1), "1", "", "4917", kunde, "Vergütung aus VVL otelo", "Tarif",
|
||||||
|
"otelo Allnet Flat", betrag, "VF2609", "80000001", "700000", "10000"), 1):
|
||||||
|
ws.cell(r, c, v)
|
||||||
|
# zweite Seite mit Daten – darf NICHT eingelesen werden
|
||||||
|
ws2 = wb.create_sheet("VF2609")
|
||||||
|
for c, h in enumerate(kopf, 1):
|
||||||
|
ws2.cell(5, c, h)
|
||||||
|
for c, v in enumerate((datetime(2026, 9, 1), "1", "", "4917", "DOPPELT", "Vergütung aus VVL otelo", "T",
|
||||||
|
"x", 999, "VF2609", "80000001", "700000", ""), 1):
|
||||||
|
ws2.cell(6, c, v)
|
||||||
|
p = tmp_path / "vf_caps.xlsx"
|
||||||
|
wb.save(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_vodafone_nur_erste_seite(vodafone_caps_xlsx):
|
||||||
|
erg = lies_datei(vodafone_caps_xlsx)
|
||||||
|
assert [p.name for p in erg.positionen] == ["HUBERMEIER", "Musterfrau", "BEISPIELER"]
|
||||||
|
assert erg.warnungen
|
||||||
|
|
||||||
|
|
||||||
|
def test_caps_regel_und_feste_zuweisung(db, vodafone_caps_xlsx):
|
||||||
|
from provtool.db import CAPS_ANBIETER, CAPS_WERT
|
||||||
|
laden = db.haendler_speichern(Haendler(0, "Eigener Laden"), [("vodafone", "80000001")])
|
||||||
|
uh = db.haendler_speichern(Haendler(0, "Unterhändler Caps", standard_prozent=90), [(CAPS_ANBIETER, CAPS_WERT)])
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
db.haendler_speichern(Haendler(0, "Noch einer"), [(CAPS_ANBIETER, CAPS_WERT)])
|
||||||
|
aid = db.abrechnung_neu("September")
|
||||||
|
db.importieren(aid, lies_datei(vodafone_caps_xlsx))
|
||||||
|
pos = {p["name"]: p for p in db.positionen(aid)}
|
||||||
|
assert pos["HUBERMEIER"]["haendler_id"] == uh and pos["BEISPIELER"]["haendler_id"] == uh
|
||||||
|
assert pos["Musterfrau"]["haendler_id"] == laden
|
||||||
|
assert pos["HUBERMEIER"]["betrag"] == 26550 # 90 % von 295 €
|
||||||
|
|
||||||
|
# Fehltreffer: BEISPIELER gehört eigentlich zum Laden → fest zuweisen
|
||||||
|
db.haendler_zuweisen([pos["BEISPIELER"]["id"]], laden)
|
||||||
|
mayr = next(p for p in db.positionen(aid) if p["name"] == "BEISPIELER")
|
||||||
|
assert mayr["haendler_id"] == laden and mayr["betrag"] == 23500
|
||||||
|
# zurück auf automatisch
|
||||||
|
db.haendler_zuweisen([mayr["id"]], None)
|
||||||
|
mayr = next(p for p in db.positionen(aid) if p["name"] == "BEISPIELER")
|
||||||
|
assert mayr["haendler_id"] == uh and mayr["betrag"] == 21150
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_alter_datenbank(tmp_path):
|
||||||
|
import sqlite3
|
||||||
|
from provtool.db import SCHEMA
|
||||||
|
pfad = tmp_path / "alt.db"
|
||||||
|
con = sqlite3.connect(pfad)
|
||||||
|
con.executescript(SCHEMA) # Schema v1 ohne Spalte haendler_fest
|
||||||
|
con.close()
|
||||||
|
d = Datenbank(pfad)
|
||||||
|
spalten = {r[1] for r in d.con.execute("PRAGMA table_info(position)")}
|
||||||
|
assert "haendler_fest" in spalten
|
||||||
|
d.close()
|
||||||
Reference in new issue
Block a user