Compare commits
2 commits
ad1ac9af6f
...
7692c79269
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7692c79269 | ||
|
|
d884cff6f8 |
7 changed files with 542 additions and 3 deletions
138
app.py
138
app.py
|
|
@ -33,7 +33,8 @@ import gemeinsam
|
|||
import ordnerwahl
|
||||
import pdf_renderer
|
||||
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
|
||||
from bestand import (naechste_freie_nummer, rechnungen_im_ordner,
|
||||
from bestand import (hoechster_zaehler_im_ordner, naechste_freie_nummer,
|
||||
rechnungen_im_ordner,
|
||||
vorhandene_rechnungen)
|
||||
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
|
||||
laufende_pid_lesen)
|
||||
|
|
@ -93,6 +94,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
self.after(250, self._frage_speicherort_falls_noetig)
|
||||
# ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch.
|
||||
self.after(400, self._frage_gemeinsam_falls_noetig)
|
||||
# ... und danach einmalig nach dem Stand der Rechnungsnummer.
|
||||
self.after(600, self._frage_nummer_falls_noetig)
|
||||
self.after(500, self._push_abholen)
|
||||
|
||||
|
||||
|
|
@ -595,6 +598,135 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
self.buch_lbl.configure(text=f"Nummernbuch: {anzahl} Nummern",
|
||||
foreground=self.col_muted)
|
||||
|
||||
def _frage_nummer_falls_noetig(self):
|
||||
"""Beim ersten Start einmal nach dem Stand der Rechnungsnummer fragen.
|
||||
|
||||
Er kommt aus einer Excel-Tabelle mit laufender Nummer - die muss er
|
||||
einmal nennen koennen, sonst faengt das Programm bei 001 an und vergibt
|
||||
Nummern ein zweites Mal. Danach ist der Zaehler zu.
|
||||
"""
|
||||
if TESTLAUF or self.cfg.get("nummer_eingerichtet"):
|
||||
return
|
||||
jahr = int(self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year)
|
||||
gefunden = self.hoechster_stand(jahr)
|
||||
vorschlag = max(gefunden, int(self.cfg.get("rechnung_zaehler", 0) or 0))
|
||||
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Rechnungsnummer einrichten")
|
||||
d.transient(self)
|
||||
d.resizable(False, False)
|
||||
self._style_dialog(d)
|
||||
wert = tk.StringVar(value=str(vorschlag))
|
||||
fertig = {"ok": False}
|
||||
|
||||
rahmen = ttk.Frame(d, padding=18)
|
||||
rahmen.pack(fill="both", expand=True)
|
||||
ttk.Label(rahmen, text="Wo soll die Rechnungsnummer weiterlaufen?",
|
||||
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w")
|
||||
ttk.Label(rahmen, wraplength=620, justify="left", text=(
|
||||
"Trag die zuletzt vergebene Nummer ein – die aus deiner bisherigen Liste. "
|
||||
"Die nächste Rechnung bekommt dann die Nummer danach.\n\n"
|
||||
"Das wird EINMAL eingerichtet. Danach steht der Zähler fest: eine "
|
||||
"ausgestellte Rechnung wird nur noch über Storno oder Berichtigung "
|
||||
"korrigiert, und jede Nummer gibt es nur einmal "
|
||||
"(§ 14 Abs. 4 Nr. 4 UStG).")).grid(
|
||||
row=1, column=0, columnspan=2, sticky="w", pady=(8, 12))
|
||||
|
||||
if gefunden:
|
||||
tk.Label(rahmen, wraplength=620, justify="left", fg="#1f5c3d",
|
||||
font=("Segoe UI", 9, "bold"),
|
||||
text=f"Im Rechnungsordner und im Nummernbuch steht schon "
|
||||
f"{jahr}-{gefunden:03d} – niedriger geht es nicht.").grid(
|
||||
row=2, column=0, columnspan=2, sticky="w", pady=(0, 10))
|
||||
|
||||
ttk.Label(rahmen, text=f"Zuletzt vergeben: {jahr} –").grid(row=3, column=0, sticky="e")
|
||||
ttk.Entry(rahmen, textvariable=wert, width=10, justify="center").grid(
|
||||
row=3, column=1, sticky="w", padx=(8, 0))
|
||||
hinweis = tk.Label(rahmen, text="", wraplength=620, justify="left", fg="#a00000",
|
||||
font=("Segoe UI", 9))
|
||||
hinweis.grid(row=4, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||
|
||||
def uebernehmen():
|
||||
try:
|
||||
zahl = int(parse_num(wert.get()))
|
||||
except (TypeError, ValueError):
|
||||
zahl = -1
|
||||
if zahl < gefunden:
|
||||
hinweis.configure(text=f"Mindestens {gefunden} – so weit ist schon vergeben.")
|
||||
return
|
||||
if zahl < 0:
|
||||
hinweis.configure(text="Bitte eine Zahl eintragen (0 = es geht bei 001 los).")
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Sind Sie sicher?",
|
||||
f"Die nächste Rechnung bekommt die Nummer {jahr}-{zahl + 1:03d}.\n\n"
|
||||
"Das lässt sich danach nicht mehr ändern.\n\n"
|
||||
"Ja = so übernehmen\n"
|
||||
"Nein = zurück zur Eingabe", icon="question", default="yes", parent=d):
|
||||
return
|
||||
self.cfg["rechnung_jahr"] = jahr
|
||||
self.cfg["rechnung_zaehler"] = zahl
|
||||
self.cfg["nummer_eingerichtet"] = True
|
||||
try:
|
||||
config.save_config(self.cfg)
|
||||
except OSError as e: # noqa: BLE001
|
||||
messagebox.showwarning("Nicht gespeichert",
|
||||
f"Der Stand ließ sich nicht speichern:\n{e}", parent=d)
|
||||
return
|
||||
fertig["ok"] = True
|
||||
d.destroy()
|
||||
|
||||
knoepfe = ttk.Frame(rahmen)
|
||||
knoepfe.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(16, 0))
|
||||
ttk.Button(knoepfe, text="Übernehmen", width=16, command=uebernehmen).pack(side="right")
|
||||
ttk.Button(knoepfe, text="Später", width=12, command=d.destroy).pack(side="left")
|
||||
|
||||
d.protocol("WM_DELETE_WINDOW", d.destroy)
|
||||
d.bind("<Escape>", lambda _e: d.destroy())
|
||||
d.update_idletasks()
|
||||
x = self.winfo_rootx() + max(0, (self.winfo_width() - d.winfo_reqwidth()) // 2)
|
||||
d.geometry(f"+{max(0, x)}+{self.winfo_rooty() + 110}")
|
||||
d.grab_set()
|
||||
self.wait_window(d)
|
||||
if fertig["ok"]:
|
||||
self._neue_nummer_vorschlagen()
|
||||
|
||||
def hoechster_stand(self, jahr=None):
|
||||
"""Hoechster JE VERGEBENER Zaehler dieses Jahres - aus beiden Quellen.
|
||||
|
||||
Der Zaehler in der config allein taugt nicht als Wahrheit: er laesst
|
||||
sich in den Einstellungen zuruecksetzen. Deshalb zaehlt, was WIRKLICH
|
||||
vergeben ist - die PDFs im Ordner und das gemeinsame Nummernbuch (das
|
||||
kennt auch die Nummern aus dem Altbestand, zu denen es keine PDF gibt).
|
||||
"""
|
||||
jahr = int(jahr or self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year)
|
||||
hoch = hoechster_zaehler_im_ordner(config.ensure_output_dir(self.cfg), jahr)
|
||||
if self.buch is not None:
|
||||
try:
|
||||
hoch = max(hoch, self.buch.hoechster_zaehler(jahr))
|
||||
except Exception as e: # noqa: BLE001 - ohne Buch bleibt der Ordner
|
||||
print("[nummernbuch] Hoechststand nicht lesbar:", e)
|
||||
return hoch
|
||||
|
||||
def _zaehler_absichern(self):
|
||||
"""Einen zurueckgedrehten Zaehler stillschweigend wieder anheben.
|
||||
|
||||
Nicht als Vorwurf - es reicht ein alter Rechner, eine wiederhergestellte
|
||||
config oder ein Jahreswechsel von Hand. Vergeben ist vergeben.
|
||||
"""
|
||||
jahr = int(self.cfg.get("rechnung_jahr", 0) or datetime.date.today().year)
|
||||
if jahr != datetime.date.today().year:
|
||||
return
|
||||
hoch = self.hoechster_stand(jahr)
|
||||
if hoch > int(self.cfg.get("rechnung_zaehler", 0) or 0):
|
||||
print(f"[nummern] Zaehler stand auf {self.cfg.get('rechnung_zaehler')}, "
|
||||
f"vergeben ist bis {hoch} - angehoben")
|
||||
self.cfg["rechnung_zaehler"] = hoch
|
||||
try:
|
||||
config.save_config(self.cfg)
|
||||
except OSError as e: # noqa: BLE001
|
||||
print("[nummern] Zaehler nicht gespeichert:", e)
|
||||
|
||||
def _belegte_nummern(self):
|
||||
"""Alle im Buch vergebenen Nummern. Faellt das Buch aus, ist die Menge
|
||||
leer - eine Rechnung muss auch dann geschrieben werden koennen."""
|
||||
|
|
@ -803,6 +935,10 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
"""
|
||||
if self.storno_stand or self.berichtigung_stand:
|
||||
return
|
||||
# Erst sicherstellen, dass der Zaehler nicht hinter dem steht, was
|
||||
# schon vergeben ist - sonst schlaegt das Programm eine Nummer vor,
|
||||
# die es ein zweites Mal vergeben wuerde.
|
||||
self._zaehler_absichern()
|
||||
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg,
|
||||
self._belegte_nummern())
|
||||
if frei:
|
||||
|
|
|
|||
24
bestand.py
24
bestand.py
|
|
@ -8,6 +8,7 @@ Rechnung steht, welche Nummer als nächste frei ist. Hier wird nur gelesen.
|
|||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import config
|
||||
from helfer import ANREDEN, name_aufteilen, saeubern, safe_filename
|
||||
|
|
@ -168,6 +169,29 @@ def rechnungen_im_ordner(ordner):
|
|||
gefunden.append(daten)
|
||||
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
|
||||
return gefunden
|
||||
def hoechster_zaehler_im_ordner(ordner, jahr):
|
||||
"""Groesster Zaehler dieses Jahres, den die PDFs im Ordner hergeben.
|
||||
|
||||
Gelesen wird der DATEINAME (Rechnung_2026-442_..., Storno_2026-443_...) -
|
||||
das geht ohne jede PDF zu oeffnen und reicht: das Programm schreibt die
|
||||
Nummer immer in den Namen. Was jemand von Hand umbenannt hat, faellt hier
|
||||
durch; dafuer gibt es das gemeinsame Nummernbuch als zweite Quelle.
|
||||
"""
|
||||
hoch = 0
|
||||
muster = re.compile(rf"(?:Rechnung|Storno)_{int(jahr)}-(\d+)", re.IGNORECASE)
|
||||
try:
|
||||
namen = os.listdir(ordner)
|
||||
except OSError:
|
||||
return 0
|
||||
for name in namen:
|
||||
if not name.lower().endswith(".pdf"):
|
||||
continue
|
||||
m = muster.search(name)
|
||||
if m:
|
||||
hoch = max(hoch, int(m.group(1)))
|
||||
return hoch
|
||||
|
||||
|
||||
def naechste_freie_nummer_nach(ordner, cfg, vergeben, belegt=None):
|
||||
"""Erste freie Nummer NACH <vergeben> - fuer die Rechnung, die auf ein Storno folgt.
|
||||
|
||||
|
|
|
|||
|
|
@ -142,6 +142,10 @@ DEFAULT_CONFIG = {
|
|||
"montage": DEFAULT_MONTAGE,
|
||||
# Das Angebot hat eine EIGENE Reihe (2026-A001): es darf keine
|
||||
# Rechnungsnummer verbrauchen - die gehoert zu einer echten Rechnung.
|
||||
# Einmal beim ersten Start gesetzt (er kommt aus einer Excel-Tabelle mit
|
||||
# laufender Nummer). Danach steht der Zaehler fest - geaendert wird eine
|
||||
# Rechnung nur noch ueber Storno oder Berichtigung.
|
||||
"nummer_eingerichtet": False,
|
||||
"angebot_jahr": 0,
|
||||
"angebot_zaehler": 0,
|
||||
# Alle Steuern als EINE benannte Liste.
|
||||
|
|
|
|||
|
|
@ -399,6 +399,26 @@ class EinstellungenMixin:
|
|||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
|
||||
def dlg_nummer(self):
|
||||
# Einmal eingerichtet, dann zu: eine ausgestellte Rechnung wird nur noch
|
||||
# ueber Storno oder Berichtigung korrigiert (§ 14 Abs. 4 Nr. 4 UStG,
|
||||
# § 146 Abs. 4 AO). Angezeigt wird der Stand trotzdem - man will ja
|
||||
# nachsehen koennen.
|
||||
if self.cfg.get("nummer_eingerichtet"):
|
||||
jahr = self.cfg.get("rechnung_jahr")
|
||||
zaehler = int(self.cfg.get("rechnung_zaehler", 0) or 0)
|
||||
hoch = self.hoechster_stand(jahr)
|
||||
messagebox.showinfo(
|
||||
"Rechnungsnummer",
|
||||
f"Zuletzt vergeben: {jahr}-{max(zaehler, hoch):03d}\n"
|
||||
f"Die nächste Rechnung bekommt {jahr}-{max(zaehler, hoch) + 1:03d}.\n\n"
|
||||
"Der Zähler wurde einmal eingerichtet und lässt sich nicht mehr ändern. "
|
||||
"Eine ausgestellte Rechnung wird über „Storno“ oder „Berichtigen“ "
|
||||
"korrigiert – jede Nummer darf es nur einmal geben "
|
||||
"(§ 14 Abs. 4 Nr. 4 UStG).")
|
||||
return
|
||||
return self._dlg_nummer_einrichten()
|
||||
|
||||
def _dlg_nummer_einrichten(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Rechnungsnummer")
|
||||
d.transient(self)
|
||||
|
|
@ -412,14 +432,39 @@ class EinstellungenMixin:
|
|||
ttk.Label(frm, text="Format: JJJJ-NNN (z. B. 2026-013). Der Zähler ist der zuletzt\n"
|
||||
"vergebene Wert – beim nächsten Beleg wird +1 vorgeschlagen.",
|
||||
justify="left").grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
|
||||
hoch_jetzt = self.hoechster_stand()
|
||||
ttk.Label(frm, foreground=self.col_muted, justify="left",
|
||||
text=f"Schon vergeben ist bis {self.cfg.get('rechnung_jahr')}-"
|
||||
f"{hoch_jetzt:03d} (aus den PDFs im Ordner und dem gemeinsamen\n"
|
||||
"Nummernbuch). Dahinter zurück geht nicht – nach vorn schon.").grid(
|
||||
row=4, column=0, columnspan=2, sticky="w", pady=(10, 0))
|
||||
ttk.Label(frm, text="Jahr").grid(row=1, column=0, sticky="w", pady=3)
|
||||
ttk.Entry(frm, textvariable=v_jahr, width=10).grid(row=1, column=1, sticky="w", pady=3)
|
||||
ttk.Label(frm, text="Zuletzt vergebener Zähler").grid(row=2, column=0, sticky="w", pady=3)
|
||||
ttk.Entry(frm, textvariable=v_z, width=10).grid(row=2, column=1, sticky="w", pady=3)
|
||||
|
||||
def speichern():
|
||||
self.cfg["rechnung_jahr"] = int(parse_num(v_jahr.get()))
|
||||
self.cfg["rechnung_zaehler"] = int(parse_num(v_z.get()))
|
||||
jahr = int(parse_num(v_jahr.get()))
|
||||
zaehler = int(parse_num(v_z.get()))
|
||||
# Zurueck geht nicht: was vergeben ist, ist vergeben. Geprueft wird
|
||||
# gegen die PDFs im Ordner UND das gemeinsame Nummernbuch - der
|
||||
# Zaehler in der config allein waere ja gerade das, was hier
|
||||
# geaendert wird. Vorwaerts ist erlaubt: eine lueckenlose Folge
|
||||
# verlangt das Gesetz nicht, jede Nummer aber nur einmal
|
||||
# (§ 14 Abs. 4 Nr. 4 UStG).
|
||||
hoch = self.hoechster_stand(jahr)
|
||||
if jahr == int(self.cfg.get("rechnung_jahr", jahr) or jahr) and zaehler < hoch:
|
||||
messagebox.showwarning(
|
||||
"Nummer schon vergeben",
|
||||
f"Für {jahr} ist bereits bis {jahr}-{hoch:03d} vergeben.\n\n"
|
||||
f"Der Zähler kann nicht auf {zaehler} zurückgesetzt werden – sonst "
|
||||
"gäbe es eine Nummer zweimal, und jede Rechnungsnummer darf nur "
|
||||
"einmal vergeben werden (§ 14 Abs. 4 Nr. 4 UStG).\n\n"
|
||||
"Nach vorn ist er frei: Lücken sind erlaubt.", parent=d)
|
||||
v_z.set(str(hoch))
|
||||
return
|
||||
self.cfg["rechnung_jahr"] = jahr
|
||||
self.cfg["rechnung_zaehler"] = zaehler
|
||||
config.save_config(self.cfg)
|
||||
self._neue_nummer_vorschlagen()
|
||||
d.destroy()
|
||||
|
|
|
|||
13
gemeinsam.py
13
gemeinsam.py
|
|
@ -209,6 +209,19 @@ class Nummernbuch:
|
|||
zeilen = con.execute("SELECT nummer FROM nummern")
|
||||
return {z["nummer"] for z in zeilen}
|
||||
|
||||
def hoechster_zaehler(self, jahr):
|
||||
"""Groesster Zaehler dieses Jahres im Buch (0, wenn nichts drinsteht).
|
||||
|
||||
Aus '2026-442' wird 442. Nummern anderer Reihen (Angebote 2026-A001)
|
||||
zaehlen NICHT mit - die haben mit der Rechnungsnummer nichts zu tun.
|
||||
"""
|
||||
hoch = 0
|
||||
for nummer in self.nummern(jahr):
|
||||
teil = str(nummer).split("-")[-1]
|
||||
if teil.isdigit():
|
||||
hoch = max(hoch, int(teil))
|
||||
return hoch
|
||||
|
||||
def eintrag(self, nummer):
|
||||
with self._verbindung() as con:
|
||||
z = con.execute("SELECT * FROM nummern WHERE nummer=?", (str(nummer),)).fetchone()
|
||||
|
|
|
|||
|
|
@ -226,6 +226,18 @@ pruefe(buchungen and buchungen[0].voll_nummer == "2026-001",
|
|||
f"und zwar 2026-001 ({[b.voll_nummer for b in buchungen]})")
|
||||
pruefe(not any("A0" in b.voll_nummer for b in buchungen),
|
||||
"keine Angebots-Nummer im Journal")
|
||||
|
||||
# ---- 9. Zweite Sicherung: auch ohne Kenndaten wird nichts gebucht -----
|
||||
from pdf_parser import keine_buchung # noqa: E402
|
||||
|
||||
nur_name = os.path.join(ordner, "Angebot_2026-A099_OhneKenndaten.pdf")
|
||||
shutil.copy(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf"), nur_name)
|
||||
pruefe(keine_buchung(nur_name),
|
||||
"ein Angebot wird auch am Dateinamen erkannt (falls die Kenndaten fehlen)")
|
||||
pruefe(keine_buchung(os.path.join(angebote, "Angebot_2026-A001_Vorab.pdf")),
|
||||
"und alles im Unterordner Angebote wird uebersprungen")
|
||||
pruefe(not keine_buchung(os.path.join(ordner, "Rechnung_2026-001_Vorab.pdf")),
|
||||
"eine echte Rechnung wird davon NICHT erfasst")
|
||||
journal.schliessen()
|
||||
|
||||
if not fehler:
|
||||
|
|
|
|||
305
pruef_nummernsperre.py
Normal file
305
pruef_nummernsperre.py
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: der Rechnungszaehler kann nicht mehr zurueck.
|
||||
|
||||
Sein Einwand: *"er kann jetzt bescheissen, indem er das in den einstellungen
|
||||
aendert"* - und der Vorschlag dazu: *"dann koennte man noch die pdfs nutzen als
|
||||
referenz, die haben auch die nummern"*.
|
||||
|
||||
Geprueft wird beides als Quelle:
|
||||
* die PDFs im Rechnungsordner (Dateiname traegt die Nummer)
|
||||
* das gemeinsame Nummernbuch (kennt auch Nummern OHNE PDF - Altbestand)
|
||||
|
||||
Vorwaerts darf der Zaehler: eine lueckenlose Folge verlangt das Gesetz nicht.
|
||||
Zurueck nicht: jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
QUELLE = r"C:\claude\rechnungstool"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
kodierung = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kodierung, "replace").decode(kodierung))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
|
||||
os._exit(2)
|
||||
|
||||
|
||||
_wecker = threading.Timer(150, _reissleine)
|
||||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||
tmp = tempfile.mkdtemp(prefix="sperre_pruef_")
|
||||
ordner = os.path.join(tmp, "rechnungen")
|
||||
gemeinsam_ordner = os.path.join(tmp, "install")
|
||||
os.makedirs(ordner)
|
||||
os.makedirs(gemeinsam_ordner)
|
||||
|
||||
import config # noqa: E402
|
||||
|
||||
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
|
||||
config.CONFIG_PATH = os.path.join(tmp, "config.json")
|
||||
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
|
||||
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
|
||||
roh["output_dir"] = ordner
|
||||
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
|
||||
import app as A # noqa: E402
|
||||
import gemeinsam # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
warnungen = []
|
||||
messagebox.askyesno = lambda t, x, **k: ("Fertig" not in t)
|
||||
messagebox.showinfo = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
|
||||
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "")
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.cfg["output_dir"] = ordner
|
||||
|
||||
JAHR = 2026
|
||||
if int(f.cfg.get("rechnung_jahr", 0)) != JAHR:
|
||||
print("HINWEIS | Der Pruefstand rechnet mit 2026 - heute ist ein anderes Jahr.")
|
||||
|
||||
|
||||
def rechnung(name):
|
||||
f.reset_formular()
|
||||
f.k_vorname.set("Anna")
|
||||
f.k_name.set(name)
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.r_anreise.set("01.08.2026")
|
||||
f.r_abreise.set("03.08.2026")
|
||||
f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2")
|
||||
f._testwahl = "rechnung"
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
|
||||
|
||||
# ---- 1. Drei Rechnungen schreiben ----------------------------------------
|
||||
for name in ("Eins", "Zwei", "Drei"):
|
||||
rechnung(name)
|
||||
dateien = sorted(d for d in os.listdir(ordner) if d.endswith(".pdf"))
|
||||
pruefe(len(dateien) == 3, f"drei Rechnungen geschrieben ({dateien})")
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == 3, f"Zaehler steht auf 3 ({f.cfg.get('rechnung_zaehler')})")
|
||||
pruefe(f.hoechster_stand(JAHR) == 3, f"hoechster Stand aus den PDFs: 3 ({f.hoechster_stand(JAHR)})")
|
||||
|
||||
# ---- 2. Zaehler von Hand zurueckdrehen (der Betrugsfall) -----------------
|
||||
f.cfg["rechnung_zaehler"] = 0
|
||||
config.save_config(f.cfg)
|
||||
f._neue_nummer_vorschlagen()
|
||||
pruefe(f.r_nummer.get() == "2026-004",
|
||||
f"trotz zurueckgedrehtem Zaehler wird 2026-004 vorgeschlagen ({f.r_nummer.get()})")
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == 3,
|
||||
f"und der Zaehler ist wieder angehoben ({f.cfg.get('rechnung_zaehler')})")
|
||||
|
||||
# ---- 3. Auch die Einstellungen lassen ihn nicht zurueck ------------------
|
||||
def knoepfe_von(w, aus=None):
|
||||
aus = [] if aus is None else aus
|
||||
for k in w.winfo_children():
|
||||
if k.winfo_class() in ("TButton", "Button"):
|
||||
aus.append(k)
|
||||
knoepfe_von(k, aus)
|
||||
return aus
|
||||
|
||||
|
||||
def felder_von(w, aus=None):
|
||||
aus = [] if aus is None else aus
|
||||
for k in w.winfo_children():
|
||||
if k.winfo_class() in ("TEntry", "Entry"):
|
||||
aus.append(k)
|
||||
felder_von(k, aus)
|
||||
return aus
|
||||
|
||||
|
||||
def nummerndialog(neuer_zaehler):
|
||||
"""Dialog oeffnen, Zaehler eintragen, Speichern druecken."""
|
||||
f.dlg_nummer()
|
||||
f.update()
|
||||
top = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"][-1]
|
||||
felder = felder_von(top)
|
||||
felder[1].delete(0, "end")
|
||||
felder[1].insert(0, str(neuer_zaehler))
|
||||
f.update()
|
||||
for k in knoepfe_von(top):
|
||||
if "Speichern" in str(k.cget("text")):
|
||||
k.invoke()
|
||||
break
|
||||
f.update()
|
||||
noch_offen = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]
|
||||
for w in noch_offen:
|
||||
w.destroy()
|
||||
return bool(noch_offen)
|
||||
|
||||
|
||||
warnungen.clear()
|
||||
blieb_offen = nummerndialog(1)
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == 3,
|
||||
f"die Einstellungen setzen den Zaehler NICHT auf 1 ({f.cfg.get('rechnung_zaehler')})")
|
||||
pruefe(any("vergeben" in w for w in warnungen), f"mit Meldung an den Benutzer ({warnungen})")
|
||||
pruefe(blieb_offen, "und der Dialog bleibt offen, statt still zu schlucken")
|
||||
|
||||
warnungen.clear()
|
||||
nummerndialog(50)
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == 50,
|
||||
f"nach VORN darf er (Luecken sind erlaubt) ({f.cfg.get('rechnung_zaehler')})")
|
||||
pruefe(not warnungen, f"und dabei wird nicht gewarnt ({warnungen})")
|
||||
|
||||
# ---- 4. Das Nummernbuch kennt Nummern OHNE PDF --------------------------
|
||||
f.cfg["gemeinsam_ordner"] = gemeinsam_ordner
|
||||
f._buch_oeffnen()
|
||||
pruefe(f.buch is not None, "das gemeinsame Nummernbuch ist da")
|
||||
f.buch.eintragen("2026-090", jahr=2026, nachname="AltbestandOhnePDF",
|
||||
quelle="journal", herkunft="xlsx")
|
||||
pruefe(f.hoechster_stand(JAHR) == 90,
|
||||
f"der hoechste Stand kommt jetzt aus dem Buch: 90 ({f.hoechster_stand(JAHR)})")
|
||||
|
||||
f.cfg["rechnung_zaehler"] = 5
|
||||
config.save_config(f.cfg)
|
||||
f._neue_nummer_vorschlagen()
|
||||
pruefe(f.r_nummer.get() == "2026-091",
|
||||
f"vorgeschlagen wird 2026-091 - hinter dem Altbestand ({f.r_nummer.get()})")
|
||||
|
||||
warnungen.clear()
|
||||
nummerndialog(10)
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == 90,
|
||||
f"und die Einstellungen kommen auch nicht hinter das Buch zurueck "
|
||||
f"({f.cfg.get('rechnung_zaehler')})")
|
||||
|
||||
# ---- 5. Ohne PDFs im Ordner: das Buch allein haelt die Sperre -----------
|
||||
for d in os.listdir(ordner):
|
||||
if d.endswith(".pdf"):
|
||||
os.remove(os.path.join(ordner, d))
|
||||
pruefe(f.hoechster_stand(JAHR) == 90,
|
||||
f"auch ohne PDFs bleibt der Stand bei 90 ({f.hoechster_stand(JAHR)})")
|
||||
|
||||
# ---- 6. Und ohne Buch: die PDFs allein halten sie ----------------------
|
||||
rechnung("Nachher") # schreibt 2026-091
|
||||
f.buch = None
|
||||
f.cfg["gemeinsam_ordner"] = ""
|
||||
pruefe(f.hoechster_stand(JAHR) == 91,
|
||||
f"ohne Buch zaehlen die PDFs: 91 ({f.hoechster_stand(JAHR)})")
|
||||
warnungen.clear()
|
||||
nummerndialog(2)
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == 91,
|
||||
f"und auch dann geht es nicht zurueck ({f.cfg.get('rechnung_zaehler')})")
|
||||
|
||||
# ---- 7. Der Erststart: EINMAL setzen duerfen ---------------------------
|
||||
# Er kommt aus einer Excel-Tabelle mit laufender Nummer - die muss er einmal
|
||||
# nennen koennen. Danach ist der Zaehler zu.
|
||||
f.cfg["nummer_eingerichtet"] = False
|
||||
f.cfg["rechnung_jahr"] = JAHR
|
||||
config.save_config(f.cfg)
|
||||
A.TESTLAUF = False # den Erststart-Dialog wirklich zeigen
|
||||
|
||||
|
||||
def erststart(zahl, erwartet_ja=True):
|
||||
"""Popup oeffnen, Zahl eintragen, Uebernehmen druecken."""
|
||||
antwort = {"gefragt": False}
|
||||
|
||||
def sicher(titel, text, **kw):
|
||||
antwort["gefragt"] = True
|
||||
return erwartet_ja
|
||||
|
||||
messagebox.askyesno = sicher
|
||||
|
||||
def klick(rest=40):
|
||||
tops = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]
|
||||
if tops:
|
||||
top = tops[-1]
|
||||
felder = felder_von(top)
|
||||
if felder:
|
||||
felder[0].delete(0, "end")
|
||||
felder[0].insert(0, str(zahl))
|
||||
for k in knoepfe_von(top):
|
||||
if "bernehmen" in str(k.cget("text")):
|
||||
k.invoke()
|
||||
return
|
||||
if rest:
|
||||
f.after(100, lambda: klick(rest - 1))
|
||||
|
||||
f.after(200, klick)
|
||||
f._frage_nummer_falls_noetig()
|
||||
for w in [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]:
|
||||
w.destroy()
|
||||
return antwort["gefragt"]
|
||||
|
||||
|
||||
hoch_jetzt = f.hoechster_stand(JAHR)
|
||||
erststart(hoch_jetzt - 5, True)
|
||||
pruefe(not f.cfg.get("nummer_eingerichtet"),
|
||||
"eine Zahl UNTER dem Vergebenen wird beim Erststart nicht angenommen")
|
||||
|
||||
gefragt = erststart(500, True)
|
||||
pruefe(gefragt, "vor dem Uebernehmen wird noch einmal gefragt")
|
||||
pruefe(f.cfg.get("nummer_eingerichtet") is True, "danach ist der Zaehler eingerichtet")
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == 500, f"und steht auf 500 ({f.cfg.get('rechnung_zaehler')})")
|
||||
|
||||
# ---- 8. Ab jetzt ist er zu -------------------------------------------
|
||||
meldungen = []
|
||||
messagebox.showinfo = lambda *a, **k: meldungen.append(a[1] if len(a) > 1 else "")
|
||||
vorher = f.cfg.get("rechnung_zaehler")
|
||||
f.dlg_nummer()
|
||||
f.update()
|
||||
offen = [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"]
|
||||
pruefe(not offen, "die Einstellungen oeffnen kein Eingabefenster mehr")
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == vorher,
|
||||
f"der Zaehler bleibt, wie er ist ({f.cfg.get('rechnung_zaehler')})")
|
||||
pruefe(any("Storno" in m for m in meldungen),
|
||||
f"und es wird auf Storno/Berichtigen verwiesen ({meldungen})")
|
||||
|
||||
# Und der Erststart fragt nicht noch einmal
|
||||
f.after(200, lambda: None)
|
||||
f._frage_nummer_falls_noetig()
|
||||
pruefe(not [w for w in f.winfo_children() if w.winfo_class() == "Toplevel"],
|
||||
"beim naechsten Start wird nicht wieder gefragt")
|
||||
|
||||
# ---- 9. Jahreswechsel: nach dem Bindestrich faengt es wieder bei 001 an --
|
||||
# "beim jahreswechsel sollte die nach dem - zeichen auf 000 gehen, weil neues
|
||||
# jahr". Der Zaehler des VORjahres darf dabei nichts anheben.
|
||||
import datetime as _dt
|
||||
|
||||
heute = _dt.date.today().year
|
||||
f.cfg["rechnung_jahr"] = heute - 1
|
||||
f.cfg["rechnung_zaehler"] = 442
|
||||
f.cfg["nummer_eingerichtet"] = True
|
||||
config.save_config(f.cfg)
|
||||
f._neue_nummer_vorschlagen()
|
||||
pruefe(f.r_nummer.get() == f"{heute}-001",
|
||||
f"im neuen Jahr wird {heute}-001 vorgeschlagen ({f.r_nummer.get()})")
|
||||
|
||||
# Auch dann, wenn im Ordner noch die hohen Nummern des Vorjahres liegen.
|
||||
alt_pdf = os.path.join(ordner, f"Rechnung_{heute - 1}-442_Vorjahr.pdf")
|
||||
open(alt_pdf, "wb").write(b"%PDF-1.4 kein echtes PDF, nur der Name zaehlt")
|
||||
vor_dem_anlegen = 91 # so weit ist DIESES Jahr aus den Schritten davor
|
||||
pruefe(f.hoechster_stand(heute) == vor_dem_anlegen,
|
||||
f"die Rechnung aus dem Vorjahr hebt dieses Jahr NICHT an "
|
||||
f"({f.hoechster_stand(heute)}, erwartet {vor_dem_anlegen})")
|
||||
pruefe(f.hoechster_stand(heute - 1) == 442,
|
||||
f"fuer ihr eigenes Jahr aber schon ({f.hoechster_stand(heute - 1)})")
|
||||
f._neue_nummer_vorschlagen()
|
||||
pruefe(f.r_nummer.get() == f"{heute}-001",
|
||||
f"und es bleibt bei {heute}-001 ({f.r_nummer.get()})")
|
||||
|
||||
f.destroy()
|
||||
if not fehler:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
else:
|
||||
print(f" (Wegwerf-Ordner bleibt: {tmp})")
|
||||
print("---")
|
||||
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||
sys.exit(1 if fehler else 0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue