Compare commits
14 commits
b5799a60a1
...
5c68278191
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c68278191 | ||
|
|
3ea31b3421 | ||
|
|
d22631467d | ||
|
|
67a09e4085 | ||
|
|
1060a6421f | ||
|
|
d67448c58f | ||
|
|
2277a12e0f | ||
|
|
d3962ec8d1 | ||
|
|
abb462725f | ||
|
|
0095305176 | ||
|
|
d2fee38bfd | ||
|
|
790ac4c0f6 | ||
|
|
1564d769c9 | ||
|
|
a0cfc31ae6 |
16 changed files with 3071 additions and 508 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ config.json
|
|||
_tmp/
|
||||
*.7z.*
|
||||
release/
|
||||
rechnungstool.pid
|
||||
|
|
|
|||
792
app.py
792
app.py
|
|
@ -8,60 +8,36 @@ Bedienung:
|
|||
3. "PDF erstellen" -> druckfertige Rechnung im Ordner rechnungen/
|
||||
|
||||
Steuersaetze, Bettensteuer, Preise und Firmendaten -> Menue "Einstellungen".
|
||||
|
||||
Aufgeteilt in:
|
||||
helfer.py - Zahlen, Datum, Namen, Pruefungen
|
||||
bestand.py - was im Rechnungsordner liegt (nur lesen)
|
||||
storno.py - den spiegelbildlichen Storno rechnen
|
||||
einzelinstanz.py - nur ein Fenster zulassen
|
||||
theme.py - Farben nach Windows hell/dunkel
|
||||
korrektur.py - Storno- und Berichtigungsweg
|
||||
einstellungen.py - die Dialoge aus dem Menue "Einstellungen"
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import datetime
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
import config
|
||||
import berechnung
|
||||
import pdf_renderer
|
||||
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
|
||||
from bestand import naechste_freie_nummer, vorhandene_rechnungen
|
||||
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
|
||||
laufende_pid_lesen)
|
||||
from theme import ThemeMixin
|
||||
from korrektur import KorrekturMixin
|
||||
from einstellungen import EinstellungenMixin
|
||||
|
||||
|
||||
def parse_num(s):
|
||||
"""'12', '3,5' -> float; leer/ungueltig -> 0.0"""
|
||||
s = (s or "").strip().replace(",", ".")
|
||||
if not s:
|
||||
return 0.0
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def parse_date(s):
|
||||
"""'28.01.2026' -> date; sonst None"""
|
||||
s = (s or "").strip()
|
||||
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.datetime.strptime(s, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def safe_filename(s):
|
||||
keep = "-_.() "
|
||||
return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung"
|
||||
|
||||
|
||||
def windows_dark_mode():
|
||||
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
|
||||
if not sys.platform.startswith("win"):
|
||||
return False
|
||||
try:
|
||||
import winreg
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
|
||||
return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class RechnungsApp(tk.Tk):
|
||||
class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.cfg = config.load_config()
|
||||
|
|
@ -74,6 +50,12 @@ class RechnungsApp(tk.Tk):
|
|||
self.anzahl_vars = {} # nr -> StringVar
|
||||
self.personen_vars = {} # nr -> StringVar
|
||||
self.gesamt_lbls = {} # nr -> Label
|
||||
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
|
||||
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
|
||||
self.storno_stand = None
|
||||
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
|
||||
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
|
||||
self.berichtigung_stand = None
|
||||
|
||||
self._apply_theme() # Farben nach Windows hell/dunkel
|
||||
self._build_menu()
|
||||
|
|
@ -84,95 +66,15 @@ class RechnungsApp(tk.Tk):
|
|||
self._neue_nummer_vorschlagen()
|
||||
self.recalc()
|
||||
|
||||
# Beim Schliessen nachfragen, wenn ein Storno noch offen ist - sonst liegt
|
||||
# es morgen ohne berichtigte Rechnung im Ordner und keiner weiss warum.
|
||||
self.protocol("WM_DELETE_WINDOW", self._beenden)
|
||||
|
||||
# Titelleiste dunkel (nach dem Mappen des Fensters)
|
||||
self.after(60, lambda: self._dark_titlebar(self))
|
||||
# Beim ersten Start nach dem Speicherort fragen (wenn noch keiner gesetzt ist)
|
||||
self.after(250, self._frage_speicherort_falls_noetig)
|
||||
|
||||
# ----------------------------------------------------------------- Theme
|
||||
def _apply_theme(self):
|
||||
"""Farben passend zum Windows-Theme (hell/dunkel) setzen."""
|
||||
dark = windows_dark_mode()
|
||||
self._dark_mode = dark
|
||||
if dark:
|
||||
self.col_bg = "#23272e" # Fenster-Hintergrund
|
||||
self.col_fg = "#e6e6e6" # Schrift
|
||||
self.col_field = "#2f343c" # Eingabefelder
|
||||
self.col_sel = "#3a4a44" # aktiv/hover
|
||||
self.col_accent = "#5fb98f" # Akzent (Gruen, hell)
|
||||
self.col_line = "#3a3f47"
|
||||
self.col_scroll = "#4a505a" # Scroll-Griff
|
||||
self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by")
|
||||
else:
|
||||
self.col_bg = "#f3f3f3"
|
||||
self.col_fg = "#1a1a1a"
|
||||
self.col_field = "#ffffff"
|
||||
self.col_sel = "#cfe3d8"
|
||||
self.col_accent = "#1f5c3d"
|
||||
self.col_line = "#cccccc"
|
||||
self.col_scroll = "#b8b8b8"
|
||||
self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by")
|
||||
|
||||
self.configure(bg=self.col_bg)
|
||||
style = ttk.Style(self)
|
||||
try:
|
||||
style.theme_use("clam")
|
||||
except tk.TclError:
|
||||
pass
|
||||
bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field,
|
||||
self.col_sel, self.col_accent)
|
||||
style.configure(".", background=bg, foreground=fg, fieldbackground=field,
|
||||
bordercolor=self.col_line, lightcolor=bg, darkcolor=bg)
|
||||
style.configure("TFrame", background=bg)
|
||||
style.configure("TLabel", background=bg, foreground=fg)
|
||||
style.configure("TLabelframe", background=bg, bordercolor=self.col_line)
|
||||
style.configure("TLabelframe.Label", background=bg, foreground=accent)
|
||||
style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line)
|
||||
style.map("TButton", background=[("active", sel)], foreground=[("active", fg)])
|
||||
style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat",
|
||||
arrowcolor=bg, padding=(10, 3))
|
||||
style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)])
|
||||
style.configure("TCheckbutton", background=bg, foreground=fg)
|
||||
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
|
||||
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg)
|
||||
style.configure("TCombobox", fieldbackground=field, foreground=fg,
|
||||
background=field, arrowcolor=fg)
|
||||
style.map("TCombobox", fieldbackground=[("readonly", field)],
|
||||
foreground=[("readonly", fg)], background=[("readonly", field)])
|
||||
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
|
||||
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
|
||||
bordercolor=bg, lightcolor=bg, darkcolor=bg,
|
||||
arrowcolor=fg, relief="flat", gripcount=0)
|
||||
style.map(sbs, background=[("active", accent)],
|
||||
troughcolor=[("disabled", bg)])
|
||||
style.configure("TSeparator", background=self.col_line)
|
||||
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
|
||||
self.option_add("*background", bg)
|
||||
self.option_add("*foreground", fg)
|
||||
|
||||
def _style_dialog(self, d):
|
||||
"""Toplevel-Dialog an das Theme angleichen."""
|
||||
d.configure(bg=self.col_bg)
|
||||
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
|
||||
d.after(20, lambda: self._dark_titlebar(d))
|
||||
|
||||
def _dark_titlebar(self, win):
|
||||
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
|
||||
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
win.update_idletasks()
|
||||
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
|
||||
val = ctypes.c_int(1)
|
||||
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
|
||||
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
|
||||
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
|
||||
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("[theme] Titelleiste:", e)
|
||||
|
||||
# ---------------------------------------------------------------- Menue
|
||||
def _build_menu(self):
|
||||
|
|
@ -185,7 +87,7 @@ class RechnungsApp(tk.Tk):
|
|||
("Formular zurücksetzen", self.reset_formular),
|
||||
("Ausgabeordner öffnen", self._ordner_oeffnen),
|
||||
("-", None),
|
||||
("Beenden", self.destroy),
|
||||
("Beenden", self._beenden), # fragt nach, wenn ein Storno offen ist
|
||||
],
|
||||
"Einstellungen": [
|
||||
("Steuersätze…", self.dlg_steuern),
|
||||
|
|
@ -205,7 +107,9 @@ class RechnungsApp(tk.Tk):
|
|||
lbl.bind("<Leave>", lambda e, w=lbl: w.configure(bg=self.col_bg))
|
||||
lbl.bind("<Button-1>", lambda e, b=lbl, it=items: self._menu_popup(b, it))
|
||||
ttk.Separator(self, orient="horizontal").pack(side="top", fill="x")
|
||||
self.bind_all("<Control-p>", lambda e: self.pdf_erstellen())
|
||||
# bind statt bind_all: sonst feuert Strg+P auch aus einem offenen
|
||||
# Dialog heraus und schreibt im Formular dahinter eine Rechnung.
|
||||
self.bind("<Control-p>", lambda e: self.pdf_erstellen())
|
||||
|
||||
def _menu_popup(self, anchor, items):
|
||||
if self._open_pop: # offenes Menu zu -> Toggle
|
||||
|
|
@ -271,16 +175,25 @@ class RechnungsApp(tk.Tk):
|
|||
self.k_strasse = tk.StringVar()
|
||||
self.k_plzort = tk.StringVar()
|
||||
felder = [
|
||||
("Anrede (optional)", self.k_anrede),
|
||||
("Vorname (optional)", self.k_vorname),
|
||||
("Nachname / Firma", self.k_name),
|
||||
("Anrede", self.k_anrede),
|
||||
("Vorname *", self.k_vorname),
|
||||
("Nachname / Firma *", self.k_name),
|
||||
("Zusatz (optional)", self.k_zusatz),
|
||||
("Straße / Nr.", self.k_strasse),
|
||||
("PLZ / Ort", self.k_plzort),
|
||||
("Straße / Nr. *", self.k_strasse),
|
||||
("PLZ / Ort *", self.k_plzort),
|
||||
]
|
||||
for i, (lbl, var) in enumerate(felder):
|
||||
ttk.Label(kunde, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||||
ttk.Entry(kunde, textvariable=var, width=38).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
if var is self.k_anrede:
|
||||
# Auswahlliste, aber nicht gesperrt - Sonderfaelle kann man tippen.
|
||||
w = ttk.Combobox(kunde, textvariable=var, width=36, values=ANREDEN)
|
||||
else:
|
||||
w = ttk.Entry(kunde, textvariable=var, width=38)
|
||||
w.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(kunde, text="* Pflicht – Name und Anschrift des Kunden gehören "
|
||||
"auf die Rechnung (§ 14 Abs. 4 Nr. 1 UStG).",
|
||||
foreground=self.col_muted, wraplength=330, justify="left").grid(
|
||||
row=len(felder), column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||||
kunde.columnconfigure(1, weight=1)
|
||||
|
||||
# Rechnungsinfos (rechts)
|
||||
|
|
@ -302,11 +215,20 @@ class RechnungsApp(tk.Tk):
|
|||
ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||||
ent = ttk.Entry(info, textvariable=var, width=18)
|
||||
ent.grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
if var is self.r_nummer:
|
||||
# Waehrend eines Stornos wird das Feld gesperrt - die Nummern
|
||||
# vergibt dann das Programm, damit keine doppelt sein kann.
|
||||
self.nummer_entry = ent
|
||||
if var in date_vars: # Datum: Punkte automatisch, Cursor ans Ende
|
||||
ent.bind("<KeyRelease>", self._auto_datum_evt)
|
||||
ttk.Label(info, text="Nächte").grid(row=4, column=0, sticky="w", pady=2)
|
||||
ttk.Label(info, textvariable=self.r_naechte, font=("Segoe UI", 10, "bold")).grid(
|
||||
row=4, column=1, sticky="w", padx=(8, 0))
|
||||
# Storno-Hinweis: steht nur da, solange ein Storno im Zwischenspeicher liegt.
|
||||
self.storno_hinweis = tk.StringVar(value="")
|
||||
ttk.Label(info, textvariable=self.storno_hinweis, foreground="#b00020",
|
||||
font=("Segoe UI", 9, "bold"), wraplength=200, justify="left").grid(
|
||||
row=5, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||||
info.columnconfigure(1, weight=1)
|
||||
self.r_anreise.trace_add("write", lambda *a: self._update_naechte())
|
||||
self.r_abreise.trace_add("write", lambda *a: self._update_naechte())
|
||||
|
|
@ -439,6 +361,10 @@ class RechnungsApp(tk.Tk):
|
|||
links = ttk.Frame(f)
|
||||
links.pack(side="left")
|
||||
ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4)
|
||||
ttk.Button(links, text="Storno", command=self.dlg_storno,
|
||||
width=12).pack(side="left", padx=4)
|
||||
ttk.Button(links, text="Berichtigen", command=self.dlg_berichtigen,
|
||||
width=14).pack(side="left", padx=4)
|
||||
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
|
||||
|
||||
# --------------------------------------------------------- Berechnung
|
||||
|
|
@ -483,12 +409,30 @@ class RechnungsApp(tk.Tk):
|
|||
|
||||
# ----------------------------------------------------------- Aktionen
|
||||
def _neue_nummer_vorschlagen(self):
|
||||
jahr = self.cfg.get("rechnung_jahr", datetime.date.today().year)
|
||||
zaehler = self.cfg.get("rechnung_zaehler", 0)
|
||||
heute_jahr = datetime.date.today().year
|
||||
if heute_jahr != jahr:
|
||||
jahr, zaehler = heute_jahr, 0
|
||||
self.r_nummer.set(config.format_rechnungsnummer(jahr, zaehler + 1))
|
||||
"""Nächste freie Nummer ins Formular - EINE Quelle, sonst laufen sie auseinander.
|
||||
|
||||
Läuft gerade ein Storno oder eine Berichtigung, gehört die Nummer im Feld
|
||||
zu diesem Vorgang und wird nicht angefasst.
|
||||
"""
|
||||
if self.storno_stand or self.berichtigung_stand:
|
||||
return
|
||||
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg)
|
||||
if frei:
|
||||
self.r_nummer.set(frei)
|
||||
|
||||
|
||||
def kunde_pruefen(self):
|
||||
"""Beanstandungen an den Kundenfeldern; leere Liste = in Ordnung.
|
||||
|
||||
Saeubert die Felder und prueft sie mit kunde_maengel().
|
||||
"""
|
||||
for var in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
|
||||
self.k_strasse, self.k_plzort):
|
||||
var.set(saeubern(var.get()))
|
||||
return kunde_maengel({"vorname": self.k_vorname.get(),
|
||||
"nachname": self.k_name.get(),
|
||||
"strasse": self.k_strasse.get(),
|
||||
"plzort": self.k_plzort.get()})
|
||||
|
||||
def pdf_erstellen(self):
|
||||
r = self.recalc()
|
||||
|
|
@ -500,9 +444,45 @@ class RechnungsApp(tk.Tk):
|
|||
if not nummer:
|
||||
messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
|
||||
return
|
||||
if not self.k_name.get().strip():
|
||||
if not messagebox.askyesno("Kein Kundenname",
|
||||
"Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"):
|
||||
# Name und Anschrift des Kunden sind Pflichtangaben auf der Rechnung
|
||||
# (§ 14 Abs. 4 Nr. 1 UStG) - ohne sie wird nichts geschrieben.
|
||||
maengel = self.kunde_pruefen()
|
||||
if maengel:
|
||||
messagebox.showwarning(
|
||||
"Kundenangaben fehlen",
|
||||
"So kann die Rechnung nicht geschrieben werden:\n\n- " + "\n- ".join(maengel) +
|
||||
"\n\nName und vollständige Anschrift des Kunden gehören auf jede "
|
||||
"Rechnung (§ 14 Abs. 4 Nr. 1 UStG).")
|
||||
return
|
||||
# Hausnummer nur nachfragen, nicht blockieren - es gibt Anschriften ohne
|
||||
# (Postfach, Haus am Feldweg), und eine falsche Sperre waere schlimmer.
|
||||
if not ziffern(self.k_strasse.get()):
|
||||
if not messagebox.askyesno(
|
||||
"Hausnummer fehlt",
|
||||
f"In „{self.k_strasse.get()}“ steht keine Hausnummer.\n\n"
|
||||
"Trotzdem so auf die Rechnung schreiben?"):
|
||||
return
|
||||
|
||||
# Letzte Gelegenheit, bevor der Beleg entsteht. Danach ist Ändern nicht
|
||||
# mehr: eine ausgestellte Rechnung wird nur noch über einen Storno oder
|
||||
# eine Berichtigung korrigiert - beides kostet Papier und einen Weg zum
|
||||
# Gast. Beim Storno- und Berichtigungsweg fragt das Programm ohnehin
|
||||
# schon, darum hier nur bei einer ganz normalen Rechnung.
|
||||
if not self.storno_stand and not self.berichtigung_stand:
|
||||
wer = " ".join(t for t in (self.k_vorname.get().strip(),
|
||||
self.k_name.get().strip()) if t)
|
||||
if not messagebox.askyesno(
|
||||
"Rechnung endgültig erstellen?",
|
||||
f"Rechnung {nummer} für {wer or 'ohne Namen'} über "
|
||||
f"{berechnung.eur(r.gesamt)}.\n\n"
|
||||
"Sind alle Angaben richtig? Danach lässt sich die Rechnung "
|
||||
"nicht mehr ändern.\n\n"
|
||||
"Eine ausgestellte Rechnung darf weder überschrieben noch "
|
||||
"gelöscht werden. Korrigiert wird sie nur noch über "
|
||||
"„Storno“ (falsche Beträge) oder „Berichtigen“ (falsche "
|
||||
"Kundenangaben, § 31 Abs. 5 UStDV) – und jede Nummer gibt "
|
||||
"es nur einmal (§ 14 Abs. 4 Nr. 4 UStG).",
|
||||
icon="question", default="yes"):
|
||||
return
|
||||
|
||||
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
|
||||
|
|
@ -513,6 +493,26 @@ class RechnungsApp(tk.Tk):
|
|||
namenszeile = " ".join(t for t in (vorname, nachname) if t)
|
||||
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
|
||||
self.k_strasse.get(), self.k_plzort.get()]
|
||||
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
|
||||
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
|
||||
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
|
||||
stand = self.storno_stand
|
||||
if stand:
|
||||
if not messagebox.askyesno(
|
||||
"Storno wirklich durchführen?",
|
||||
f"Storno {stand['nummer']} hebt die Rechnung {stand['zu']} auf, "
|
||||
f"und {nummer} wird die berichtigte Rechnung.\n\n"
|
||||
"Beide gehen an den Gast – die Berichtigung wirkt erst, wenn sie "
|
||||
"ihm zugegangen ist.\n\n"
|
||||
"Ja = Storno behalten und berichtigte Rechnung erstellen\n"
|
||||
f"Nein = alles verwerfen, es bleibt allein Rechnung {stand['zu']}",
|
||||
icon="warning", default="yes"):
|
||||
self._storno_verwerfen("vom Benutzer verworfen")
|
||||
messagebox.showinfo(
|
||||
"Storno verworfen",
|
||||
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
|
||||
return
|
||||
|
||||
kopf = {
|
||||
"rechnungsnummer": nummer,
|
||||
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
|
||||
|
|
@ -522,8 +522,92 @@ class RechnungsApp(tk.Tk):
|
|||
"kunde_zeilen": kunde_zeilen,
|
||||
"nachname": nachname,
|
||||
"vorname": vorname,
|
||||
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
|
||||
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
|
||||
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
|
||||
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
|
||||
"strasse": self.k_strasse.get().strip(),
|
||||
"plzort": self.k_plzort.get().strip()},
|
||||
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
|
||||
for z in r.zeilen],
|
||||
}
|
||||
if stand:
|
||||
kopf["art"] = "korrektur"
|
||||
kopf["vorgang"] = stand.get("vorgang") or stand["zu"]
|
||||
kopf["ersetzt"] = stand["zu"]
|
||||
kopf["storno_nummer"] = stand["nummer"]
|
||||
out_dir = config.ensure_output_dir(self.cfg)
|
||||
|
||||
# ---- reine Berichtigung: gleiche Nummer, gleiche Betraege, neues Blatt ----
|
||||
ber = self.berichtigung_stand
|
||||
if ber:
|
||||
kopf["art"] = "berichtigung"
|
||||
kopf["vorgang"] = ber.get("vorgang") or ber["zu"]
|
||||
kopf["berichtigt_zu"] = ber["zu"]
|
||||
kopf["berichtigt_am"] = datetime.date.today().strftime("%d.%m.%Y")
|
||||
if not messagebox.askyesno(
|
||||
"Berichtigte Rechnung erstellen?",
|
||||
f"Zu Rechnung {ber['zu']} vom {ber['datum']} wird ein berichtigtes "
|
||||
"Blatt geschrieben.\n\n"
|
||||
"Nummer, Leistungen und Beträge bleiben unverändert – die alte "
|
||||
"Rechnung bleibt liegen. Das Blatt gehört zum Gast: erst wenn es "
|
||||
"ihm zugegangen ist, ist die Rechnung berichtigt.\n\nJetzt erstellen?",
|
||||
default="yes"):
|
||||
return
|
||||
# Dateiname: "Berichtigte Rechnung", Datum, Nummer der Rechnung. Das
|
||||
# Blatt verbraucht keine eigene Nummer, das Datum haelt mehrere
|
||||
# Berichtigungen zur selben Rechnung auseinander (sortierbar).
|
||||
pfad = os.path.join(
|
||||
out_dir, f"Berichtigte Rechnung_{datetime.date.today():%Y-%m-%d}"
|
||||
f"_{safe_filename(ber['zu'])}.pdf")
|
||||
if os.path.exists(pfad) and not messagebox.askyesno(
|
||||
"Berichtigung von heute ersetzen?",
|
||||
f"Zu Rechnung {ber['zu']} gibt es von heute schon eine Berichtigung:\n"
|
||||
f"{os.path.basename(pfad)}\n\nDurch die neue ersetzen?"):
|
||||
return
|
||||
try:
|
||||
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
|
||||
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
|
||||
messagebox.showerror("Fehler beim Erstellen",
|
||||
f"Berichtigung konnte nicht erstellt werden:\n{e}")
|
||||
print("[berichtigung] FEHLER:", e)
|
||||
return
|
||||
print(f"[berichtigung] erstellt -> {pfad}")
|
||||
self.berichtigung_stand = None
|
||||
self._storno_anzeigen()
|
||||
if messagebox.askyesno("Fertig",
|
||||
f"Berichtigte Rechnung zu {ber['zu']}:\n{pfad}\n\n"
|
||||
"Jetzt öffnen (zum Drucken)?"):
|
||||
self._oeffne_datei(pfad)
|
||||
return
|
||||
|
||||
# Nummer schon vergeben? Dann NICHT drueberschreiben: eine ausgestellte
|
||||
# Rechnung bleibt liegen - § 146 Abs. 4 AO verbietet Aenderungen, bei denen
|
||||
# der urspruengliche Inhalt nicht mehr feststellbar ist; ein unangetasteter
|
||||
# Beleg ist im Pruefungsfall die einfachere Geschichte. Und jede
|
||||
# Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG). Also neue Nummer.
|
||||
vorhanden = vorhandene_rechnungen(out_dir, nummer)
|
||||
if vorhanden:
|
||||
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
|
||||
frei = naechste_freie_nummer(out_dir, self.cfg)
|
||||
if not frei:
|
||||
messagebox.showerror(
|
||||
"Rechnungsnummer schon vergeben",
|
||||
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
|
||||
"Es wurde keine freie Nummer gefunden - bitte die Nummer von Hand setzen.")
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Rechnungsnummer schon vergeben",
|
||||
f"Die Rechnungsnummer {nummer} ist schon vergeben:\n\n{liste}\n\n"
|
||||
"Die vorhandene Rechnung bleibt bestehen - sie darf weder überschrieben "
|
||||
"noch gelöscht werden (§ 14 Abs. 4 Nr. 4 UStG, § 146 Abs. 4 AO).\n\n"
|
||||
f"Stattdessen die nächste freie Nummer {frei} verwenden?",
|
||||
icon="warning", default="yes"):
|
||||
return
|
||||
nummer = frei
|
||||
self.r_nummer.set(nummer)
|
||||
kopf["rechnungsnummer"] = nummer
|
||||
|
||||
fname = f"Rechnung_{safe_filename(nummer)}"
|
||||
if self.k_name.get().strip():
|
||||
fname += "_" + safe_filename(self.k_name.get().strip())
|
||||
|
|
@ -542,8 +626,19 @@ class RechnungsApp(tk.Tk):
|
|||
|
||||
print(f"[pdf] erstellt -> {pfad}")
|
||||
hinweis = "\n\n(Seite 1 = Kunde, Seite 2 = Beherbergungssteuer fürs Amt)" if r.aufschlaege else ""
|
||||
storno_pfad = None
|
||||
if stand:
|
||||
# Storno ist jetzt bestaetigt - Zwischenspeicher leeren, Sperre loesen.
|
||||
storno_pfad = stand["pfad"]
|
||||
self.storno_stand = None
|
||||
self._storno_anzeigen()
|
||||
hinweis = (f"\n\nDazu gehört die Stornorechnung {stand['nummer']} "
|
||||
f"({os.path.basename(storno_pfad)}).\n"
|
||||
"Beide Blätter gehören zum Gast.") + hinweis
|
||||
if messagebox.askyesno("Fertig", f"Rechnung erstellt:\n{pfad}{hinweis}\n\nJetzt öffnen (zum Drucken)?"):
|
||||
self._oeffne_datei(pfad)
|
||||
if storno_pfad:
|
||||
self._oeffne_datei(storno_pfad)
|
||||
|
||||
def _zaehler_aus_nummer_speichern(self, nummer):
|
||||
try:
|
||||
|
|
@ -551,12 +646,58 @@ class RechnungsApp(tk.Tk):
|
|||
jahr, z = int(jahr_s), int(z_s)
|
||||
except (ValueError, AttributeError):
|
||||
return
|
||||
# Der Zaehler darf nur vorwaerts. Wird eine alte Rechnung ueberschrieben,
|
||||
# wuerde er sonst zurueckfallen und die naechsten Nummern noch einmal
|
||||
# vergeben - genau der Fehler, den wir loswerden wollen.
|
||||
alt_jahr = self.cfg.get("rechnung_jahr", 0)
|
||||
alt_z = self.cfg.get("rechnung_zaehler", 0)
|
||||
if jahr < alt_jahr or (jahr == alt_jahr and z <= alt_z):
|
||||
return
|
||||
self.cfg["rechnung_jahr"] = jahr
|
||||
self.cfg["rechnung_zaehler"] = z
|
||||
config.save_config(self.cfg)
|
||||
try:
|
||||
config.save_config(self.cfg)
|
||||
except OSError as e: # noqa: BLE001 - die PDF ist schon geschrieben
|
||||
# Ohne diese Meldung merkt niemand, dass der Zaehler stehengeblieben
|
||||
# ist: die Ausgabe geht in eine Konsole, die es in der EXE nicht gibt.
|
||||
messagebox.showwarning(
|
||||
"Rechnungsnummer nicht gespeichert",
|
||||
f"Die Rechnung ist geschrieben, der Zählerstand aber nicht:\n{e}\n\n"
|
||||
f"Bitte im Menü unter „Rechnungsnummer…“ den Zähler auf {z} setzen, "
|
||||
"sonst wird die nächste Nummer noch einmal vergeben.")
|
||||
self._neue_nummer_vorschlagen()
|
||||
|
||||
def _beenden(self):
|
||||
"""Schliessen - mit Rückfrage, wenn ein Storno noch offen ist."""
|
||||
if self.storno_stand:
|
||||
if not messagebox.askyesno(
|
||||
"Storno ist noch offen",
|
||||
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||||
f"{self.storno_stand['zu']} liegt im Ordner, die berichtigte "
|
||||
"Rechnung fehlt noch.\n\n"
|
||||
"Beim Schließen bleibt er so liegen – morgen weiß niemand mehr, "
|
||||
"warum.\n\nTrotzdem beenden?", icon="warning", default="no"):
|
||||
return
|
||||
self.destroy()
|
||||
|
||||
def reset_formular(self):
|
||||
# Ein laufender Vorgang darf nicht stumm liegenbleiben: sonst gehören die
|
||||
# neu eingetippten Daten plötzlich zum Storno der fremden Rechnung.
|
||||
if self.storno_stand:
|
||||
if not messagebox.askyesno(
|
||||
"Storno verwerfen?",
|
||||
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||||
f"{self.storno_stand['zu']} liegt noch bereit.\n\n"
|
||||
"Zurücksetzen verwirft ihn – das Storno-PDF wird gelöscht und "
|
||||
f"es bleibt allein die Rechnung {self.storno_stand['zu']}.\n\n"
|
||||
"Wirklich zurücksetzen?", icon="warning", default="no"):
|
||||
return
|
||||
self._storno_verwerfen("beim Zurücksetzen verworfen")
|
||||
elif self.berichtigung_stand:
|
||||
# Da ist noch nichts geschrieben - einfach fallenlassen.
|
||||
self.berichtigung_stand = None
|
||||
self._storno_anzeigen()
|
||||
|
||||
for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz,
|
||||
self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise):
|
||||
v.set("")
|
||||
|
|
@ -571,28 +712,6 @@ class RechnungsApp(tk.Tk):
|
|||
def _ordner_oeffnen(self):
|
||||
self._oeffne_datei(config.ensure_output_dir(self.cfg))
|
||||
|
||||
def _frage_speicherort_falls_noetig(self):
|
||||
if (self.cfg.get("output_dir") or "").strip():
|
||||
return
|
||||
vorschlag = config.default_output_dir()
|
||||
messagebox.showinfo(
|
||||
"Speicherort der Rechnungen",
|
||||
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n"
|
||||
f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
|
||||
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen",
|
||||
initialdir=os.path.expanduser("~"))
|
||||
self.cfg["output_dir"] = d if d else vorschlag
|
||||
config.save_config(self.cfg)
|
||||
config.ensure_output_dir(self.cfg)
|
||||
|
||||
def dlg_speicherort(self):
|
||||
aktuell = config.ensure_output_dir(self.cfg)
|
||||
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell)
|
||||
if d:
|
||||
self.cfg["output_dir"] = d
|
||||
config.save_config(self.cfg)
|
||||
config.ensure_output_dir(self.cfg)
|
||||
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
|
||||
|
||||
def _oeffne_datei(self, pfad):
|
||||
try:
|
||||
|
|
@ -605,331 +724,24 @@ class RechnungsApp(tk.Tk):
|
|||
except Exception as e: # noqa: BLE001
|
||||
messagebox.showinfo("Pfad", f"{pfad}\n\n(Konnte nicht automatisch geöffnet werden: {e})")
|
||||
|
||||
def _info(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Info")
|
||||
d.transient(self)
|
||||
d.resizable(False, False)
|
||||
self._style_dialog(d)
|
||||
frm = ttk.Frame(d, padding=18)
|
||||
frm.pack(fill="both", expand=True)
|
||||
ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"),
|
||||
foreground=self.col_accent).pack(anchor="w")
|
||||
ttk.Label(frm, justify="left",
|
||||
text="Daten eingeben, PDF erstellen, drucken.\n"
|
||||
"Steuersätze, Preise und Firmendaten lassen sich\n"
|
||||
"im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12))
|
||||
ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w")
|
||||
ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0))
|
||||
d.bind("<Escape>", lambda e: d.destroy())
|
||||
d.bind("<Return>", lambda e: d.destroy())
|
||||
d.grab_set()
|
||||
|
||||
# ====================================================== Einstellungen
|
||||
def dlg_steuern(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Steuersätze")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
d.geometry("620x470")
|
||||
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
|
||||
ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"),
|
||||
text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses "
|
||||
"Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei "
|
||||
"'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den "
|
||||
"Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8))
|
||||
|
||||
liste = ttk.Frame(frm)
|
||||
liste.pack(fill="x")
|
||||
arbeit = []
|
||||
for s in self.cfg.get("steuersaetze", []):
|
||||
arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))),
|
||||
"bez": tk.StringVar(value=s.get("bezeichnung", "")),
|
||||
"extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))})
|
||||
|
||||
def render_saetze():
|
||||
for w in liste.winfo_children():
|
||||
w.destroy()
|
||||
for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]):
|
||||
ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid(
|
||||
row=0, column=c, padx=4, pady=(0, 3), sticky="w")
|
||||
for i, row in enumerate(arbeit, start=1):
|
||||
ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2)
|
||||
ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w")
|
||||
ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2)
|
||||
ttk.Button(liste, text="✕", width=3,
|
||||
command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4)
|
||||
|
||||
def satz_hinzufuegen():
|
||||
arbeit.append({"satz": tk.StringVar(value="0"),
|
||||
"bez": tk.StringVar(value="Mehrwertsteuer"),
|
||||
"extra": tk.BooleanVar(value=False)})
|
||||
render_saetze()
|
||||
|
||||
render_saetze()
|
||||
ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0))
|
||||
|
||||
def speichern():
|
||||
neu = []
|
||||
for row in arbeit:
|
||||
s = row["satz"].get().strip()
|
||||
if s == "":
|
||||
continue
|
||||
neu.append({"satz": int(parse_num(s)),
|
||||
"bezeichnung": row["bez"].get().strip() or "USt",
|
||||
"extra_blatt": bool(row["extra"].get())})
|
||||
if not neu:
|
||||
messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d)
|
||||
return
|
||||
self.cfg["steuersaetze"] = neu
|
||||
config.save_config(self.cfg)
|
||||
self._render_positionszeilen()
|
||||
self.recalc()
|
||||
d.destroy()
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.pack(side="bottom", fill="x", pady=(12, 0))
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
|
||||
def dlg_katalog(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Leistungskatalog / Preise")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
|
||||
info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left",
|
||||
text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob "
|
||||
"pro Person gerechnet wird. Weitere Steuersätze legst du unter "
|
||||
"Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X "
|
||||
"rechts eine Leistung entfernen.")
|
||||
info_lbl.pack(side="top", fill="x")
|
||||
|
||||
# alle definierten Steuersaetze sind je Position waehlbar
|
||||
saetze_cfg = self.cfg.get("steuersaetze", [])
|
||||
|
||||
def satz_label(satz):
|
||||
for s in saetze_cfg:
|
||||
if int(s.get("satz", -1)) == int(satz or 0):
|
||||
return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –")
|
||||
return f"{satz} %"
|
||||
|
||||
def combo_werte(aktuell):
|
||||
opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg]
|
||||
al = satz_label(aktuell)
|
||||
if al not in opts:
|
||||
opts.append(al)
|
||||
return opts
|
||||
|
||||
def satz_aus_label(lbl):
|
||||
try:
|
||||
return int(parse_num(str(lbl).split("%")[0]))
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
# Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen)
|
||||
arbeit = []
|
||||
for pos in self.cfg["katalog"]:
|
||||
arbeit.append({
|
||||
"leistung": tk.StringVar(value=pos["leistung"]),
|
||||
"satz": tk.StringVar(value=str(pos["satz"])),
|
||||
"preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])),
|
||||
"pro_person": tk.BooleanVar(value=bool(pos["pro_person"])),
|
||||
})
|
||||
|
||||
def render():
|
||||
for w in inner.winfo_children():
|
||||
w.destroy()
|
||||
for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]):
|
||||
ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid(
|
||||
row=0, column=c, padx=4, pady=(0, 4), sticky="w")
|
||||
for i, row in enumerate(arbeit, start=1):
|
||||
ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1)
|
||||
ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we")
|
||||
cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get()))
|
||||
cb.set(satz_label(row["satz"].get()))
|
||||
cb.bind("<<ComboboxSelected>>",
|
||||
lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get()))))
|
||||
cb.grid(row=i, column=2, padx=4, pady=1, sticky="w")
|
||||
ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1)
|
||||
ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1)
|
||||
ttk.Button(inner, text="✕", width=3,
|
||||
command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1)
|
||||
|
||||
def hinzufuegen():
|
||||
arbeit.append({
|
||||
"leistung": tk.StringVar(value="Neue Leistung"),
|
||||
"satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"),
|
||||
"preis": tk.StringVar(value="0,00"),
|
||||
"pro_person": tk.BooleanVar(value=False),
|
||||
})
|
||||
render()
|
||||
canvas.update_idletasks()
|
||||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
canvas.yview_moveto(1.0)
|
||||
|
||||
def speichern():
|
||||
neu = []
|
||||
for i, row in enumerate(arbeit, start=1):
|
||||
name = row["leistung"].get().strip()
|
||||
if not name:
|
||||
continue # leere Zeilen verwerfen
|
||||
neu.append({
|
||||
"nr": i,
|
||||
"leistung": name,
|
||||
"satz": int(parse_num(row["satz"].get())),
|
||||
"preis": parse_num(row["preis"].get()),
|
||||
"pro_person": bool(row["pro_person"].get()),
|
||||
})
|
||||
if not neu:
|
||||
messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d)
|
||||
return
|
||||
self.cfg["katalog"] = neu
|
||||
config.save_config(self.cfg)
|
||||
self._render_positionszeilen()
|
||||
self.recalc()
|
||||
d.destroy()
|
||||
|
||||
# Buttonleiste unten zuerst -> bleibt immer sichtbar
|
||||
btns = ttk.Frame(d, padding=8)
|
||||
btns.pack(side="bottom", fill="x")
|
||||
ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4)
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4)
|
||||
|
||||
# Scrollbereich
|
||||
body = ttk.Frame(d)
|
||||
body.pack(side="top", fill="both", expand=True)
|
||||
canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg)
|
||||
sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview)
|
||||
inner = ttk.Frame(canvas, padding=8)
|
||||
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||
canvas.create_window((0, 0), window=inner, anchor="nw")
|
||||
canvas.configure(yscrollcommand=sb.set)
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
sb.pack(side="right", fill="y")
|
||||
|
||||
render()
|
||||
|
||||
# Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile)
|
||||
d.update_idletasks()
|
||||
inhalt_w = inner.winfo_reqwidth()
|
||||
canvas.configure(width=inhalt_w)
|
||||
win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80)
|
||||
win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90,
|
||||
d.winfo_screenheight() - 120)
|
||||
d.geometry(f"{win_w}x{int(win_h)}")
|
||||
d.minsize(min(win_w, 820), 420)
|
||||
info_lbl.configure(wraplength=win_w - 24)
|
||||
|
||||
def dlg_firma(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Firmendaten & Texte")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
d.geometry("640x640")
|
||||
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
felder = [
|
||||
("name", "Firmenname"), ("untertitel", "Untertitel"),
|
||||
("absender", "Absenderzeile"), ("inhaber", "Inhaber"),
|
||||
("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"),
|
||||
("steuernummer", "Steuernummer"), ("homepage", "Homepage"),
|
||||
("email", "E-Mail"), ("telefon", "Telefon"),
|
||||
]
|
||||
vars_ = {}
|
||||
for i, (key, lbl) in enumerate(felder):
|
||||
ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||||
v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
|
||||
ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
vars_[key] = v
|
||||
frm.columnconfigure(1, weight=1)
|
||||
base = len(felder)
|
||||
|
||||
# Logo (Bilddatei) mit Durchsuchen / Entfernen
|
||||
ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2)
|
||||
logo_row = ttk.Frame(frm)
|
||||
logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", ""))
|
||||
ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True)
|
||||
|
||||
def logo_waehlen():
|
||||
p = filedialog.askopenfilename(
|
||||
title="Logo auswählen",
|
||||
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")])
|
||||
if p:
|
||||
v_logo.set(p)
|
||||
|
||||
ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0))
|
||||
ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0))
|
||||
|
||||
ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2)
|
||||
t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
|
||||
t1.insert("1.0", self.cfg["texte"].get("dank1", ""))
|
||||
t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2)
|
||||
t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
|
||||
t2.insert("1.0", self.cfg["texte"].get("dank2", ""))
|
||||
t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2)
|
||||
vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", ""))
|
||||
ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
|
||||
def speichern():
|
||||
for key, v in vars_.items():
|
||||
self.cfg["firma"][key] = v.get()
|
||||
self.cfg["firma"]["logo"] = v_logo.get().strip()
|
||||
self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip()
|
||||
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
|
||||
self.cfg["texte"]["hinweis"] = vh.get().strip()
|
||||
config.save_config(self.cfg)
|
||||
self.title("Rechnungstool - " + self.cfg["firma"].get("name", ""))
|
||||
d.destroy()
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e")
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
|
||||
def dlg_nummer(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Rechnungsnummer")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
|
||||
v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year)))
|
||||
v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0)))
|
||||
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))
|
||||
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()))
|
||||
config.save_config(self.cfg)
|
||||
self._neue_nummer_vorschlagen()
|
||||
d.destroy()
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e")
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Muss VOR dem Fenster passieren - das Handle bleibt bis zum Ende offen.
|
||||
_sperre = einzelinstanz_pruefen()
|
||||
if _sperre is False:
|
||||
# Laeuft schon: dessen Fenster nach vorn holen und still beenden.
|
||||
if not fenster_nach_vorn(laufende_pid_lesen()):
|
||||
_r = tk.Tk()
|
||||
_r.withdraw()
|
||||
messagebox.showerror(
|
||||
"Rechnungstool läuft bereits",
|
||||
"""Das Rechnungstool ist schon geöffnet, das Fenster ließ sich
|
||||
aber nicht nach vorn holen - bitte über die Taskleiste zurückwechseln.
|
||||
|
||||
Ein zweites Fenster würde dieselbe Rechnungsnummer noch einmal vergeben.""")
|
||||
_r.destroy()
|
||||
sys.exit(0)
|
||||
|
||||
eigene_pid_merken()
|
||||
app = RechnungsApp()
|
||||
app.mainloop()
|
||||
|
|
|
|||
208
bestand.py
Normal file
208
bestand.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Was im Rechnungsordner liegt - lesen, nicht schreiben.
|
||||
|
||||
Der Ordner ist die Buchhaltung: welche Nummern vergeben sind, was in einer alten
|
||||
Rechnung steht, welche Nummer als nächste frei ist. Hier wird nur gelesen.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
|
||||
import config
|
||||
from helfer import ANREDEN, name_aufteilen, saeubern, safe_filename
|
||||
|
||||
|
||||
def vorhandene_rechnungen(ordner, nummer):
|
||||
"""Alle PDFs im Ordner, die schon zu dieser Rechnungsnummer gehoeren.
|
||||
|
||||
Der Kundenname haengt mit im Dateinamen ("Rechnung_2026-013_Meier.pdf"),
|
||||
darum wird nur der Nummernteil verglichen - sonst rutscht dieselbe Nummer
|
||||
unter einem anderen Namen als zweite Datei durch.
|
||||
"""
|
||||
# Stornos heissen "Storno_2026-002_Meier.pdf" - sie verbrauchen dieselben
|
||||
# Nummern und muessen deshalb mitzaehlen, sonst wird eine Nummer zweimal
|
||||
# vergeben.
|
||||
koepfe = ("Rechnung_" + safe_filename(nummer), "Storno_" + safe_filename(nummer))
|
||||
try:
|
||||
namen = os.listdir(ordner)
|
||||
except OSError:
|
||||
return []
|
||||
# Windows haengt beim Kopieren " - Kopie" an oder setzt "Kopie von " davor.
|
||||
VORSAETZE = ("kopie von ", "kopie (2) von ", "copy of ")
|
||||
treffer = []
|
||||
for name in namen:
|
||||
if not name.lower().endswith(".pdf"):
|
||||
continue
|
||||
stamm = name[:-4]
|
||||
for vorsatz in VORSAETZE:
|
||||
if stamm.lower().startswith(vorsatz):
|
||||
stamm = stamm[len(vorsatz):]
|
||||
break
|
||||
# Nach der Nummer darf alles kommen, nur keine weitere Ziffer. Damit
|
||||
# zaehlen auch "Rechnung_2026-013 (1).pdf" und "... - Kopie.pdf" mit -
|
||||
# genau so sind die alten Dubletten entstanden. "2026-0135" faellt raus,
|
||||
# weil dort eine Ziffer folgt.
|
||||
klein = stamm.lower()
|
||||
for k in koepfe:
|
||||
if klein.startswith(k.lower()) and (
|
||||
len(stamm) == len(k) or not stamm[len(k)].isdigit()):
|
||||
treffer.append(os.path.join(ordner, name))
|
||||
break
|
||||
return sorted(treffer)
|
||||
def kenndaten_lesen(pfad):
|
||||
"""Kenndaten (Feld /Subject) aus einer vom Tool erzeugten PDF; None wenn keine.
|
||||
|
||||
Gelesen wird der Bestand, nicht das Formular - ein Storno muss genau das
|
||||
spiegeln, was rausgegangen ist.
|
||||
"""
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
roh = (PdfReader(pfad).metadata or {}).get("/Subject") or ""
|
||||
if not str(roh).strip().startswith("{"):
|
||||
return None
|
||||
daten = json.loads(str(roh))
|
||||
except Exception as e: # noqa: BLE001 - fremde oder kaputte PDF
|
||||
print(f"[storno] {os.path.basename(pfad)}: keine lesbaren Kenndaten ({e})")
|
||||
return None
|
||||
if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"):
|
||||
return None
|
||||
daten["_pfad"] = pfad
|
||||
return daten
|
||||
def adresse_aus_pdf(pfad):
|
||||
"""Adressblock aus dem Fliesstext einer Rechnung lesen.
|
||||
|
||||
Fuer alte PDFs (v1/v2), die den Kunden noch nicht in den Kenndaten fuehren.
|
||||
Der Block steht zwischen "Rechnung an:" und der Rechnungsnummer.
|
||||
"""
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
zeilen = (PdfReader(pfad).pages[0].extract_text() or "").splitlines()
|
||||
except Exception as e: # noqa: BLE001 - kaputte oder fremde PDF
|
||||
print(f"[storno] {os.path.basename(pfad)}: Text nicht lesbar ({e})")
|
||||
return {}
|
||||
|
||||
start = next((i for i, z in enumerate(zeilen)
|
||||
if z.strip().startswith("Rechnung an")), None)
|
||||
if start is None:
|
||||
return {}
|
||||
block = []
|
||||
for z in zeilen[start + 1:start + 8]:
|
||||
t = z.strip()
|
||||
if not t or t.startswith(("Rechnungsnummer", "Stornonummer")):
|
||||
break
|
||||
block.append(t)
|
||||
if not block:
|
||||
return {}
|
||||
|
||||
daten = {}
|
||||
if block[0] in ANREDEN:
|
||||
daten["anrede"] = block.pop(0)
|
||||
# PLZ/Ort ist die Zeile, die mit einer 4- bis 5-stelligen Zahl beginnt und
|
||||
# noch etwas dahinter hat. Die Zeile davor ist die Strasse - aber nur, wenn
|
||||
# sie nicht selbst die Namenszeile ist.
|
||||
for i in range(len(block) - 1, -1, -1):
|
||||
teile = block[i].split()
|
||||
if len(teile) > 1 and teile[0].isdigit() and 4 <= len(teile[0]) <= 5:
|
||||
daten["plzort"] = block.pop(i)
|
||||
if i - 1 >= 1:
|
||||
daten["strasse"] = block.pop(i - 1)
|
||||
break
|
||||
if block:
|
||||
daten["name"] = block.pop(0)
|
||||
if block:
|
||||
daten["zusatz"] = " ".join(block)
|
||||
return daten
|
||||
def kundendaten_ermitteln(meta):
|
||||
"""Kunde einer alten Rechnung so vollstaendig wie moeglich.
|
||||
|
||||
Reihenfolge: Kenndaten (v3), sonst der Adressblock aus dem Fliesstext.
|
||||
Steht der ganze Name in der Vornamen-Zeile - so sind die Altrechnungen
|
||||
entstanden -, wird das letzte Wort zum Nachnamen.
|
||||
|
||||
Rueckgabe: (kunde-dict, woher) - woher ist eine Liste der Felder, die nur
|
||||
aus dem Text kamen und deshalb geprueft gehoeren.
|
||||
"""
|
||||
kunde = dict(meta.get("kunde") or {})
|
||||
woher = []
|
||||
vorname = saeubern(kunde.get("vorname") or meta.get("vorname"))
|
||||
nachname = saeubern(kunde.get("nachname") or meta.get("nachname"))
|
||||
|
||||
if not kunde.get("plzort") and meta.get("_pfad"):
|
||||
aus_text = adresse_aus_pdf(meta["_pfad"])
|
||||
for feld, klartext in (("anrede", "Anrede"), ("zusatz", "Zusatz"),
|
||||
("strasse", "Straße"), ("plzort", "PLZ / Ort")):
|
||||
if aus_text.get(feld) and not kunde.get(feld):
|
||||
kunde[feld] = aus_text[feld]
|
||||
woher.append(klartext)
|
||||
if not (vorname or nachname) and aus_text.get("name"):
|
||||
vorname = aus_text["name"]
|
||||
woher.append("Name")
|
||||
|
||||
if not nachname:
|
||||
# Alte Rechnungen tragen den ganzen Namen in der Vornamen-Zeile. Getrennt
|
||||
# wird am Leerzeichen: das letzte Wort ist der Nachname.
|
||||
vorname, nachname = name_aufteilen(vorname)
|
||||
if nachname:
|
||||
woher.append("Nachname vom Vornamen abgetrennt")
|
||||
kunde["vorname"], kunde["nachname"] = vorname, nachname
|
||||
return kunde, woher
|
||||
def rechnungen_im_ordner(ordner):
|
||||
"""Alle lesbaren Rechnungen des Ordners, neueste Nummer zuerst.
|
||||
|
||||
Stornos und schon stornierte Rechnungen sind mit drin - welche davon
|
||||
stornierbar ist, entscheidet der Aufrufer.
|
||||
"""
|
||||
try:
|
||||
namen = sorted(os.listdir(ordner))
|
||||
except OSError:
|
||||
return []
|
||||
gefunden = []
|
||||
for name in namen:
|
||||
# Kein Filter auf den Dateinamen: Stornos heissen "Storno_...", und ob
|
||||
# eine PDF von uns stammt, sagen ohnehin erst die Kenndaten.
|
||||
if not name.lower().endswith(".pdf"):
|
||||
continue
|
||||
daten = kenndaten_lesen(os.path.join(ordner, name))
|
||||
if daten:
|
||||
gefunden.append(daten)
|
||||
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
|
||||
return gefunden
|
||||
def naechste_freie_nummer_nach(ordner, cfg, vergeben):
|
||||
"""Erste freie Nummer NACH <vergeben> - fuer die Rechnung, die auf ein Storno folgt.
|
||||
|
||||
Das Storno hat seine Nummer gerade bekommen; je nachdem, ob der Zaehler schon
|
||||
mitgezogen ist, liefert naechste_freie_nummer() sonst genau diese Nummer noch
|
||||
einmal.
|
||||
"""
|
||||
kandidat = naechste_freie_nummer(ordner, cfg)
|
||||
if kandidat and kandidat != vergeben:
|
||||
return kandidat
|
||||
ersatz = dict(cfg)
|
||||
try:
|
||||
jahr, z = str(vergeben).split("-")
|
||||
ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z)
|
||||
except (ValueError, AttributeError):
|
||||
return kandidat
|
||||
return naechste_freie_nummer(ordner, ersatz)
|
||||
|
||||
|
||||
def naechste_freie_nummer(ordner, cfg):
|
||||
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
|
||||
|
||||
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
|
||||
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
|
||||
"""
|
||||
jahr = cfg.get("rechnung_jahr", datetime.date.today().year)
|
||||
z = cfg.get("rechnung_zaehler", 0)
|
||||
# Jahreswechsel: neues Jahr faengt bei 001 an. Muss HIER stehen und nirgends
|
||||
# sonst - sonst schlaegt das Formular 2026-001 vor und der Storno nimmt
|
||||
# gleichzeitig noch 2025-088.
|
||||
if datetime.date.today().year != jahr:
|
||||
jahr, z = datetime.date.today().year, 0
|
||||
for _ in range(1000):
|
||||
z += 1
|
||||
kandidat = config.format_rechnungsnummer(jahr, z)
|
||||
if not vorhandene_rechnungen(ordner, kandidat):
|
||||
return kandidat
|
||||
return None
|
||||
56
config.py
56
config.py
|
|
@ -16,11 +16,59 @@ import sys
|
|||
# - eingefroren (.exe): config.json/rechnungen NEBEN der .exe (beschreibbar),
|
||||
# gebuendelte Ressourcen (Logo) liegen in sys._MEIPASS
|
||||
if getattr(sys, "frozen", False):
|
||||
BASE_DIR = os.path.dirname(sys.executable)
|
||||
RESOURCE_DIR = getattr(sys, "_MEIPASS", BASE_DIR)
|
||||
EXE_DIR = os.path.dirname(sys.executable)
|
||||
RESOURCE_DIR = getattr(sys, "_MEIPASS", EXE_DIR)
|
||||
else:
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
RESOURCE_DIR = BASE_DIR
|
||||
EXE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
RESOURCE_DIR = EXE_DIR
|
||||
|
||||
|
||||
def _beschreibbar(ordner):
|
||||
"""Kann in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten."""
|
||||
try:
|
||||
os.makedirs(ordner, exist_ok=True)
|
||||
probe = os.path.join(ordner, ".schreibprobe")
|
||||
with open(probe, "w") as f:
|
||||
f.write("x")
|
||||
os.remove(probe)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _daten_ordner():
|
||||
"""Der Ordner, den das Programm sich beim ersten Start selbst anlegt.
|
||||
|
||||
Alles, was das Programm schreibt, liegt darin beieinander - man sieht auf
|
||||
einen Blick, was von ihm stammt, und kann es mitnehmen oder sichern.
|
||||
|
||||
1. neben der EXE ein Unterordner "Daten"
|
||||
2. geht das nicht (EXE liegt z.B. unter "Programme"), dann
|
||||
der Ordner Rechnungstool unter LOCALAPPDATA
|
||||
|
||||
Was schon da ist, wird benutzt; was fehlt, wird angelegt. Lagen die Dateien
|
||||
noch direkt neben der EXE - so war es frueher -, ziehen sie mit um, statt
|
||||
dass das Programm ohne Firmendaten und Zaehler neu anfaengt.
|
||||
"""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return EXE_DIR # aus der Quelle heraus bleibt alles, wo es ist
|
||||
kandidat = os.path.join(EXE_DIR, "Daten")
|
||||
if not _beschreibbar(kandidat):
|
||||
kandidat = os.path.join(os.environ.get("LOCALAPPDATA", EXE_DIR), "Rechnungstool")
|
||||
if not _beschreibbar(kandidat):
|
||||
return EXE_DIR # letzter Ausweg: wie frueher
|
||||
for name in ("config.json",):
|
||||
alt, neu_ = os.path.join(EXE_DIR, name), os.path.join(kandidat, name)
|
||||
if os.path.exists(alt) and not os.path.exists(neu_):
|
||||
try:
|
||||
os.replace(alt, neu_)
|
||||
print(f"[config] {name} in den Datenordner übernommen")
|
||||
except OSError:
|
||||
pass
|
||||
return kandidat
|
||||
|
||||
|
||||
BASE_DIR = _daten_ordner()
|
||||
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
|
||||
|
||||
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).
|
||||
|
|
|
|||
366
einstellungen.py
Normal file
366
einstellungen.py
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Die Dialoge aus dem Menue "Einstellungen" - Steuersaetze, Katalog, Firma, Nummer.
|
||||
|
||||
Alles, was hier eingestellt wird, landet in der config.json. Nichts davon steht
|
||||
hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
|
||||
import berechnung
|
||||
import config
|
||||
from helfer import parse_num
|
||||
|
||||
|
||||
class EinstellungenMixin:
|
||||
"""Einstellungsdialoge. Erwartet die Felder der RechnungsApp."""
|
||||
|
||||
def _frage_speicherort_falls_noetig(self):
|
||||
if (self.cfg.get("output_dir") or "").strip():
|
||||
return
|
||||
vorschlag = config.default_output_dir()
|
||||
messagebox.showinfo(
|
||||
"Speicherort der Rechnungen",
|
||||
"Bitte wähle einen Ordner, in dem die fertigen Rechnungen (PDF) gespeichert werden.\n\n"
|
||||
f"Vorschlag: {vorschlag}\n\n(Lässt sich später unter Einstellungen ändern.)")
|
||||
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen",
|
||||
initialdir=os.path.expanduser("~"))
|
||||
self.cfg["output_dir"] = d if d else vorschlag
|
||||
config.save_config(self.cfg)
|
||||
config.ensure_output_dir(self.cfg)
|
||||
|
||||
def dlg_speicherort(self):
|
||||
aktuell = config.ensure_output_dir(self.cfg)
|
||||
d = filedialog.askdirectory(title="Ordner für die Rechnungen wählen", initialdir=aktuell)
|
||||
if d:
|
||||
self.cfg["output_dir"] = d
|
||||
config.save_config(self.cfg)
|
||||
config.ensure_output_dir(self.cfg)
|
||||
messagebox.showinfo("Speicherort", f"Rechnungen werden gespeichert in:\n{d}")
|
||||
|
||||
def _info(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Info")
|
||||
d.transient(self)
|
||||
d.resizable(False, False)
|
||||
self._style_dialog(d)
|
||||
frm = ttk.Frame(d, padding=18)
|
||||
frm.pack(fill="both", expand=True)
|
||||
ttk.Label(frm, text="Rechnungstool Campinghof Bartl", font=("Segoe UI", 11, "bold"),
|
||||
foreground=self.col_accent).pack(anchor="w")
|
||||
ttk.Label(frm, justify="left",
|
||||
text="Daten eingeben, PDF erstellen, drucken.\n"
|
||||
"Steuersätze, Preise und Firmendaten lassen sich\n"
|
||||
"im Menü Einstellungen anpassen.").pack(anchor="w", pady=(8, 12))
|
||||
ttk.Label(frm, text="powered by ravokk", foreground=self.col_muted).pack(anchor="w")
|
||||
ttk.Button(frm, text="OK", command=d.destroy, width=10).pack(anchor="e", pady=(14, 0))
|
||||
d.bind("<Escape>", lambda e: d.destroy())
|
||||
d.bind("<Return>", lambda e: d.destroy())
|
||||
d.grab_set()
|
||||
# ====================================================== Einstellungen
|
||||
|
||||
def dlg_steuern(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Steuersätze")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
d.geometry("620x470")
|
||||
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
|
||||
ttk.Label(frm, wraplength=580, justify="left", font=("Segoe UI", 9, "bold"),
|
||||
text="Steuersätze: Satz % + Name. Die Katalog-Preise sind BRUTTO; die USt dieses "
|
||||
"Satzes wird herausgerechnet und als 'enthalten' ausgewiesen. Haken bei "
|
||||
"'Aufschlag': diese Steuer (z.B. Beherbergungssteuer) wird stattdessen auf den "
|
||||
"Rechnungsbetrag aufgeschlagen und direkt darunter ausgewiesen.").pack(anchor="w", pady=(0, 8))
|
||||
|
||||
liste = ttk.Frame(frm)
|
||||
liste.pack(fill="x")
|
||||
arbeit = []
|
||||
for s in self.cfg.get("steuersaetze", []):
|
||||
arbeit.append({"satz": tk.StringVar(value=str(s.get("satz", ""))),
|
||||
"bez": tk.StringVar(value=s.get("bezeichnung", "")),
|
||||
"extra": tk.BooleanVar(value=bool(s.get("extra_blatt", False)))})
|
||||
|
||||
def render_saetze():
|
||||
for w in liste.winfo_children():
|
||||
w.destroy()
|
||||
for c, t in enumerate(["Satz %", "Bezeichnung (Name der Steuer)", "Aufschlag", ""]):
|
||||
ttk.Label(liste, text=t, font=("Segoe UI", 9, "bold")).grid(
|
||||
row=0, column=c, padx=4, pady=(0, 3), sticky="w")
|
||||
for i, row in enumerate(arbeit, start=1):
|
||||
ttk.Entry(liste, textvariable=row["satz"], width=8, justify="center").grid(row=i, column=0, padx=4, pady=2)
|
||||
ttk.Entry(liste, textvariable=row["bez"], width=32).grid(row=i, column=1, padx=4, pady=2, sticky="w")
|
||||
ttk.Checkbutton(liste, variable=row["extra"]).grid(row=i, column=2, padx=14, pady=2)
|
||||
ttk.Button(liste, text="✕", width=3,
|
||||
command=lambda r=row: (arbeit.remove(r), render_saetze())).grid(row=i, column=3, padx=4)
|
||||
|
||||
def satz_hinzufuegen():
|
||||
arbeit.append({"satz": tk.StringVar(value="0"),
|
||||
"bez": tk.StringVar(value="Mehrwertsteuer"),
|
||||
"extra": tk.BooleanVar(value=False)})
|
||||
render_saetze()
|
||||
|
||||
render_saetze()
|
||||
ttk.Button(frm, text="+ Steuersatz hinzufügen", command=satz_hinzufuegen).pack(anchor="w", pady=(8, 0))
|
||||
|
||||
def speichern():
|
||||
neu = []
|
||||
for row in arbeit:
|
||||
s = row["satz"].get().strip()
|
||||
if s == "":
|
||||
continue
|
||||
neu.append({"satz": int(parse_num(s)),
|
||||
"bezeichnung": row["bez"].get().strip() or "USt",
|
||||
"extra_blatt": bool(row["extra"].get())})
|
||||
if not neu:
|
||||
messagebox.showwarning("Leer", "Mindestens ein Steuersatz ist nötig.", parent=d)
|
||||
return
|
||||
self.cfg["steuersaetze"] = neu
|
||||
config.save_config(self.cfg)
|
||||
self._render_positionszeilen()
|
||||
self.recalc()
|
||||
d.destroy()
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.pack(side="bottom", fill="x", pady=(12, 0))
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
|
||||
def dlg_katalog(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Leistungskatalog / Preise")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
|
||||
info_lbl = ttk.Label(d, padding=(10, 8), wraplength=900, justify="left",
|
||||
text="Leistungen bearbeiten: Bezeichnung, Steuersatz (Auswahl), Preis in € und ob "
|
||||
"pro Person gerechnet wird. Weitere Steuersätze legst du unter "
|
||||
"Einstellungen > Steuersätze an. Unten neue Zeilen anlegen oder mit dem X "
|
||||
"rechts eine Leistung entfernen.")
|
||||
info_lbl.pack(side="top", fill="x")
|
||||
|
||||
# alle definierten Steuersaetze sind je Position waehlbar
|
||||
saetze_cfg = self.cfg.get("steuersaetze", [])
|
||||
|
||||
def satz_label(satz):
|
||||
for s in saetze_cfg:
|
||||
if int(s.get("satz", -1)) == int(satz or 0):
|
||||
return f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –")
|
||||
return f"{satz} %"
|
||||
|
||||
def combo_werte(aktuell):
|
||||
opts = [f"{s['satz']} % – {s.get('bezeichnung', '')}".rstrip(" –") for s in saetze_cfg]
|
||||
al = satz_label(aktuell)
|
||||
if al not in opts:
|
||||
opts.append(al)
|
||||
return opts
|
||||
|
||||
def satz_aus_label(lbl):
|
||||
try:
|
||||
return int(parse_num(str(lbl).split("%")[0]))
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
# Arbeitskopie als Liste von Variablen-Dicts (erlaubt Hinzufuegen/Loeschen)
|
||||
arbeit = []
|
||||
for pos in self.cfg["katalog"]:
|
||||
arbeit.append({
|
||||
"leistung": tk.StringVar(value=pos["leistung"]),
|
||||
"satz": tk.StringVar(value=str(pos["satz"])),
|
||||
"preis": tk.StringVar(value=berechnung.eur_kurz(pos["preis"])),
|
||||
"pro_person": tk.BooleanVar(value=bool(pos["pro_person"])),
|
||||
})
|
||||
|
||||
def render():
|
||||
for w in inner.winfo_children():
|
||||
w.destroy()
|
||||
for c, t in enumerate(["Pos.", "Bezeichnung", "Steuersatz", "Preis €", "pro Person", ""]):
|
||||
ttk.Label(inner, text=t, font=("Segoe UI", 9, "bold")).grid(
|
||||
row=0, column=c, padx=4, pady=(0, 4), sticky="w")
|
||||
for i, row in enumerate(arbeit, start=1):
|
||||
ttk.Label(inner, text=str(i), width=4).grid(row=i, column=0, padx=4, pady=1)
|
||||
ttk.Entry(inner, textvariable=row["leistung"], width=64).grid(row=i, column=1, padx=4, pady=1, sticky="we")
|
||||
cb = ttk.Combobox(inner, width=22, state="readonly", values=combo_werte(row["satz"].get()))
|
||||
cb.set(satz_label(row["satz"].get()))
|
||||
cb.bind("<<ComboboxSelected>>",
|
||||
lambda ev, rr=row, cc=cb: rr["satz"].set(str(satz_aus_label(cc.get()))))
|
||||
cb.grid(row=i, column=2, padx=4, pady=1, sticky="w")
|
||||
ttk.Entry(inner, textvariable=row["preis"], width=10, justify="right").grid(row=i, column=3, padx=4, pady=1)
|
||||
ttk.Checkbutton(inner, variable=row["pro_person"]).grid(row=i, column=4, padx=4, pady=1)
|
||||
ttk.Button(inner, text="✕", width=3,
|
||||
command=lambda r=row: (arbeit.remove(r), render())).grid(row=i, column=5, padx=4, pady=1)
|
||||
|
||||
def hinzufuegen():
|
||||
arbeit.append({
|
||||
"leistung": tk.StringVar(value="Neue Leistung"),
|
||||
"satz": tk.StringVar(value=str(saetze_cfg[0]["satz"]) if saetze_cfg else "19"),
|
||||
"preis": tk.StringVar(value="0,00"),
|
||||
"pro_person": tk.BooleanVar(value=False),
|
||||
})
|
||||
render()
|
||||
canvas.update_idletasks()
|
||||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
canvas.yview_moveto(1.0)
|
||||
|
||||
def speichern():
|
||||
neu = []
|
||||
for i, row in enumerate(arbeit, start=1):
|
||||
name = row["leistung"].get().strip()
|
||||
if not name:
|
||||
continue # leere Zeilen verwerfen
|
||||
neu.append({
|
||||
"nr": i,
|
||||
"leistung": name,
|
||||
"satz": int(parse_num(row["satz"].get())),
|
||||
"preis": parse_num(row["preis"].get()),
|
||||
"pro_person": bool(row["pro_person"].get()),
|
||||
})
|
||||
if not neu:
|
||||
messagebox.showwarning("Leer", "Es muss mindestens eine Leistung übrig bleiben.", parent=d)
|
||||
return
|
||||
self.cfg["katalog"] = neu
|
||||
config.save_config(self.cfg)
|
||||
self._render_positionszeilen()
|
||||
self.recalc()
|
||||
d.destroy()
|
||||
|
||||
# Buttonleiste unten zuerst -> bleibt immer sichtbar
|
||||
btns = ttk.Frame(d, padding=8)
|
||||
btns.pack(side="bottom", fill="x")
|
||||
ttk.Button(btns, text="+ Position hinzufügen", command=hinzufuegen).pack(side="left", padx=4)
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right", padx=4)
|
||||
|
||||
# Scrollbereich
|
||||
body = ttk.Frame(d)
|
||||
body.pack(side="top", fill="both", expand=True)
|
||||
canvas = tk.Canvas(body, highlightthickness=0, bg=self.col_bg)
|
||||
sb = ttk.Scrollbar(body, orient="vertical", command=canvas.yview)
|
||||
inner = ttk.Frame(canvas, padding=8)
|
||||
inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||
canvas.create_window((0, 0), window=inner, anchor="nw")
|
||||
canvas.configure(yscrollcommand=sb.set)
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
sb.pack(side="right", fill="y")
|
||||
|
||||
render()
|
||||
|
||||
# Fenstergröße an den Inhalt anpassen (Breite = breiteste Zeile)
|
||||
d.update_idletasks()
|
||||
inhalt_w = inner.winfo_reqwidth()
|
||||
canvas.configure(width=inhalt_w)
|
||||
win_w = min(inhalt_w + sb.winfo_reqwidth() + 26, d.winfo_screenwidth() - 80)
|
||||
win_h = min(inner.winfo_reqheight() + btns.winfo_reqheight() + 90,
|
||||
d.winfo_screenheight() - 120)
|
||||
d.geometry(f"{win_w}x{int(win_h)}")
|
||||
d.minsize(min(win_w, 820), 420)
|
||||
info_lbl.configure(wraplength=win_w - 24)
|
||||
|
||||
def dlg_firma(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Firmendaten & Texte")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
d.geometry("640x640")
|
||||
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
felder = [
|
||||
("name", "Firmenname"), ("untertitel", "Untertitel"),
|
||||
("absender", "Absenderzeile"), ("inhaber", "Inhaber"),
|
||||
("bank", "Bank"), ("iban", "IBAN"), ("bic", "SWIFT/BIC"),
|
||||
("steuernummer", "Steuernummer"), ("homepage", "Homepage"),
|
||||
("email", "E-Mail"), ("telefon", "Telefon"),
|
||||
]
|
||||
vars_ = {}
|
||||
for i, (key, lbl) in enumerate(felder):
|
||||
ttk.Label(frm, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||||
v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
|
||||
ttk.Entry(frm, textvariable=v, width=58).grid(row=i, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
vars_[key] = v
|
||||
frm.columnconfigure(1, weight=1)
|
||||
base = len(felder)
|
||||
|
||||
# Logo (Bilddatei) mit Durchsuchen / Entfernen
|
||||
ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2)
|
||||
logo_row = ttk.Frame(frm)
|
||||
logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", ""))
|
||||
ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True)
|
||||
|
||||
def logo_waehlen():
|
||||
p = filedialog.askopenfilename(
|
||||
title="Logo auswählen",
|
||||
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")])
|
||||
if p:
|
||||
v_logo.set(p)
|
||||
|
||||
ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0))
|
||||
ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0))
|
||||
|
||||
ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2)
|
||||
t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
|
||||
t1.insert("1.0", self.cfg["texte"].get("dank1", ""))
|
||||
t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2)
|
||||
t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
|
||||
t2.insert("1.0", self.cfg["texte"].get("dank2", ""))
|
||||
t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2)
|
||||
vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", ""))
|
||||
ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
|
||||
def speichern():
|
||||
for key, v in vars_.items():
|
||||
self.cfg["firma"][key] = v.get()
|
||||
self.cfg["firma"]["logo"] = v_logo.get().strip()
|
||||
self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip()
|
||||
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
|
||||
self.cfg["texte"]["hinweis"] = vh.get().strip()
|
||||
config.save_config(self.cfg)
|
||||
self.title("Rechnungstool - " + self.cfg["firma"].get("name", ""))
|
||||
d.destroy()
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e")
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
|
||||
def dlg_nummer(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Rechnungsnummer")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
|
||||
v_jahr = tk.StringVar(value=str(self.cfg.get("rechnung_jahr", datetime.date.today().year)))
|
||||
v_z = tk.StringVar(value=str(self.cfg.get("rechnung_zaehler", 0)))
|
||||
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))
|
||||
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()))
|
||||
config.save_config(self.cfg)
|
||||
self._neue_nummer_vorschlagen()
|
||||
d.destroy()
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.grid(row=3, column=0, columnspan=2, pady=(12, 0), sticky="e")
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
90
einzelinstanz.py
Normal file
90
einzelinstanz.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Nur ein Fenster zulassen und das offene nach vorn holen.
|
||||
|
||||
Zwei Fenster lesen beim Start denselben Zähler und schlagen dieselbe
|
||||
Rechnungsnummer vor - die Nummer wird dann doppelt vergeben.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def einzelinstanz_pruefen():
|
||||
"""Sperrt das Programm gegen ein zweites Fenster.
|
||||
|
||||
Beide Fenster lesen beim Start denselben Zaehler aus der config und
|
||||
schlagen darum dieselbe Rechnungsnummer vor -> die Nummer wird doppelt
|
||||
vergeben. Darum: nur eine Instanz je Windows-Sitzung.
|
||||
|
||||
Rueckgabe: Handle (offen halten, solange das Programm laeuft) oder
|
||||
False, wenn schon eine Instanz laeuft.
|
||||
"""
|
||||
if not sys.platform.startswith("win"):
|
||||
return None
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
ERROR_ALREADY_EXISTS = 183
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
k32.CreateMutexW.argtypes = [wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR]
|
||||
k32.CreateMutexW.restype = wintypes.HANDLE
|
||||
handle = k32.CreateMutexW(None, False, "RechnungstoolCampinghofBartl")
|
||||
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
|
||||
return False
|
||||
return handle
|
||||
# Merkzettel mit der PID der laufenden Instanz - liegt neben der config.json.
|
||||
PID_DATEI = os.path.join(config.BASE_DIR, "rechnungstool.pid")
|
||||
def eigene_pid_merken():
|
||||
"""Schreibt die eigene PID, damit eine zweite Instanz das Fenster findet."""
|
||||
try:
|
||||
with open(PID_DATEI, "w", encoding="ascii") as f:
|
||||
f.write(str(os.getpid()))
|
||||
except OSError:
|
||||
pass
|
||||
def laufende_pid_lesen():
|
||||
"""PID der laufenden Instanz aus dem Merkzettel; None wenn unlesbar."""
|
||||
try:
|
||||
with open(PID_DATEI, "r", encoding="ascii") as f:
|
||||
return int(f.read().strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
def fenster_nach_vorn(pid):
|
||||
"""Holt das Fenster des Prozesses <pid> nach vorn. True bei Erfolg.
|
||||
|
||||
Gesucht wird ueber die PID, nicht ueber den Fenstertitel - der Titel
|
||||
haengt am Firmennamen aus der config und aendert sich mit ihm.
|
||||
"""
|
||||
if not pid or not sys.platform.startswith("win"):
|
||||
return False
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
SW_RESTORE = 9
|
||||
u32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
u32.IsWindowVisible.argtypes = [wintypes.HWND]
|
||||
u32.IsIconic.argtypes = [wintypes.HWND]
|
||||
u32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
|
||||
u32.SetForegroundWindow.argtypes = [wintypes.HWND]
|
||||
u32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
|
||||
u32.GetWindowThreadProcessId.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.DWORD)]
|
||||
|
||||
treffer = []
|
||||
|
||||
def besuch(hwnd, _lparam):
|
||||
fremd = wintypes.DWORD()
|
||||
u32.GetWindowThreadProcessId(hwnd, ctypes.byref(fremd))
|
||||
# nur das echte Hauptfenster: sichtbar und mit Titel
|
||||
if fremd.value == pid and u32.IsWindowVisible(hwnd) and u32.GetWindowTextLengthW(hwnd):
|
||||
treffer.append(hwnd)
|
||||
return False
|
||||
return True
|
||||
|
||||
CB = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
u32.EnumWindows(CB(besuch), 0)
|
||||
if not treffer:
|
||||
return False
|
||||
hwnd = treffer[0]
|
||||
if u32.IsIconic(hwnd):
|
||||
u32.ShowWindow(hwnd, SW_RESTORE)
|
||||
u32.SetForegroundWindow(hwnd)
|
||||
return True
|
||||
87
helfer.py
Normal file
87
helfer.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Kleine Helfer, die überall gebraucht werden: Zahlen, Datum, Namen, Prüfungen."""
|
||||
|
||||
import datetime
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def parse_num(s):
|
||||
"""'12', '3,5' -> float; leer/ungueltig -> 0.0"""
|
||||
s = (s or "").strip().replace(",", ".")
|
||||
if not s:
|
||||
return 0.0
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
def parse_date(s):
|
||||
"""'28.01.2026' -> date; sonst None"""
|
||||
s = (s or "").strip()
|
||||
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.datetime.strptime(s, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
def safe_filename(s):
|
||||
keep = "-_.() "
|
||||
return "".join(c for c in s if c.isalnum() or c in keep).strip() or "Rechnung"
|
||||
def saeubern(s):
|
||||
"""Rand-Leerzeichen weg, Mehrfachleerzeichen zu einem.
|
||||
|
||||
Wird vor der Pruefung auf die Felder angewendet - geprueft werden muss
|
||||
genau das, was nachher auf dem Blatt steht.
|
||||
"""
|
||||
return " ".join(str(s or "").split())
|
||||
def buchstaben(s):
|
||||
return sum(1 for c in str(s) if c.isalpha())
|
||||
def ziffern(s):
|
||||
return sum(1 for c in str(s) if c.isdigit())
|
||||
# Auswahl fuer das Anrede-Feld. Die Liste ist nicht bindend - das Feld bleibt
|
||||
# tippbar, damit Sonderfaelle nicht am Programm scheitern.
|
||||
ANREDEN = ["Herr", "Frau", "Herr und Frau", "Familie", "Firma", "Eheleute"]
|
||||
def nummer_normalisieren(s):
|
||||
"""'2026-5', ' 2026-005 ' -> '2026-005'; alles andere kommt getrimmt zurueck.
|
||||
|
||||
Damit findet die Suche die Rechnung auch, wenn die fuehrenden Nullen fehlen.
|
||||
"""
|
||||
s = str(s or "").strip()
|
||||
teile = s.split("-")
|
||||
if len(teile) == 2:
|
||||
try:
|
||||
return config.format_rechnungsnummer(int(teile[0]), int(teile[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
return s
|
||||
def kunde_maengel(kunde):
|
||||
"""Beanstandungen an Name und Anschrift; leere Liste = in Ordnung.
|
||||
|
||||
Prueft nicht nur "irgendwas eingetippt": ein Feld aus Leerzeichen,
|
||||
Bindestrichen oder einem einzelnen Zeichen ist kein Name und keine
|
||||
Anschrift. Vier Ziffern reichen, damit auch oesterreichische und
|
||||
niederlaendische Postleitzahlen durchgehen.
|
||||
"""
|
||||
maengel = []
|
||||
for feld, schluessel in (("Vorname", "vorname"),
|
||||
("Nachname / Firma", "nachname"),
|
||||
("Straße / Nr.", "strasse")):
|
||||
wert = saeubern(kunde.get(schluessel))
|
||||
if not wert:
|
||||
maengel.append(f"{feld}: ist leer")
|
||||
elif buchstaben(wert) < 2:
|
||||
maengel.append(f"{feld}: „{wert}“ ergibt keinen Namen")
|
||||
|
||||
ort = saeubern(kunde.get("plzort"))
|
||||
if not ort:
|
||||
maengel.append("PLZ / Ort: ist leer")
|
||||
elif ziffern(ort) < 4 or buchstaben(ort) < 2:
|
||||
maengel.append(f"PLZ / Ort: „{ort}“ – bitte Postleitzahl UND Ort, "
|
||||
"z. B. 04416 Markkleeberg")
|
||||
return maengel
|
||||
def name_aufteilen(ganz):
|
||||
"""'Andres Schäfer' -> ('Andres', 'Schäfer'); ein einzelnes Wort wird Nachname."""
|
||||
teile = str(ganz or "").split()
|
||||
if len(teile) > 1:
|
||||
return " ".join(teile[:-1]), teile[-1]
|
||||
return "", (teile[0] if teile else "")
|
||||
429
korrektur.py
Normal file
429
korrektur.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Storno- und Berichtigungsweg.
|
||||
|
||||
Zwei Wege, ein Popup-Bauplan:
|
||||
Storno - falsche Betraege. Stornorechnung mit eigener neuer Nummer,
|
||||
danach die berichtigte Rechnung. Zwei Nummern verbraucht.
|
||||
Berichtigung - nur die Kundenangaben stimmen nicht. Die Rechnung behaelt
|
||||
Nummer, Datum und Betraege (§ 31 Abs. 5 UStDV), es wird nur
|
||||
ein berichtigtes Blatt nachgereicht. Keine Nummer verbraucht.
|
||||
|
||||
Die alte Rechnung wird in beiden Faellen NIE angefasst. § 146 Abs. 4 AO verbietet
|
||||
genau genommen nur Aenderungen, bei denen der urspruengliche Inhalt nicht mehr
|
||||
feststellbar ist - ein protokollierter Storno-Vermerk waere also erlaubt. Dieses
|
||||
Programm verzichtet trotzdem darauf: ein unberuehrter Ursprungsbeleg ist im
|
||||
Pruefungsfall die einfachere Geschichte. Korrigiert wird ueber ein eigenes
|
||||
Dokument - so sieht es das Gesetz selbst vor (§ 14 Abs. 6 Nr. 5 UStG,
|
||||
§ 31 Abs. 5 UStDV).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
import berechnung
|
||||
import config
|
||||
import pdf_renderer
|
||||
from bestand import (kundendaten_ermitteln, naechste_freie_nummer,
|
||||
naechste_freie_nummer_nach, rechnungen_im_ordner)
|
||||
from helfer import kunde_maengel, nummer_normalisieren, safe_filename
|
||||
from storno import storno_rechnung_bauen
|
||||
|
||||
|
||||
class KorrekturMixin:
|
||||
"""Storno und Berichtigung. Erwartet die Felder der RechnungsApp."""
|
||||
|
||||
# ------------------------------------------------------------- Storno
|
||||
|
||||
|
||||
|
||||
def _storno_anzeigen(self):
|
||||
"""Hinweiszeile und Sperre der Rechnungsnummer an den Zwischenspeicher anpassen."""
|
||||
stand = self.storno_stand
|
||||
if stand:
|
||||
self.storno_hinweis.set(
|
||||
f"Storno {stand['nummer']} zu Rechnung {stand['zu']} liegt bereit.\n"
|
||||
f"Beim Erstellen wird noch einmal gefragt.")
|
||||
self.nummer_entry.configure(state="readonly")
|
||||
elif self.berichtigung_stand:
|
||||
self.storno_hinweis.set(
|
||||
f"Berichtigung der Rechnung {self.berichtigung_stand['zu']}.\n"
|
||||
"Nummer und Beträge bleiben, nur die Kundenangaben ändern sich.")
|
||||
self.nummer_entry.configure(state="readonly")
|
||||
else:
|
||||
self.storno_hinweis.set("")
|
||||
self.nummer_entry.configure(state="normal")
|
||||
|
||||
def _berichtigung_starten(self, meta, out_dir):
|
||||
"""Rechnung ins Formular holen; "PDF erstellen" schreibt das berichtigte Blatt."""
|
||||
nummer = str(meta.get("rechnungsnummer", ""))
|
||||
fehlend = self._formular_aus_meta(meta)
|
||||
self.berichtigung_stand = {"zu": nummer, "datum": str(meta.get("datum") or ""),
|
||||
"vorgang": str(meta.get("vorgang") or nummer),
|
||||
"meta": meta}
|
||||
# Die Rechnung behaelt ihre Nummer - genau das ist der Unterschied zum Storno.
|
||||
self.r_nummer.set(nummer)
|
||||
self.r_datum.set(str(meta.get("datum") or self.r_datum.get()))
|
||||
self._storno_anzeigen()
|
||||
hinweis = f"\n\nZum Kunden: {fehlend}." if fehlend else ""
|
||||
messagebox.showinfo(
|
||||
"Rechnung geladen",
|
||||
f"Rechnung {nummer} vom {meta.get('datum','')} ist im Formular.\n\n"
|
||||
"Jetzt die Kundenangaben richtigstellen und \"PDF erstellen\" drücken – "
|
||||
"es entsteht ein berichtigtes Blatt zu dieser Rechnung. Die alte Rechnung "
|
||||
f"bleibt liegen.{hinweis}")
|
||||
|
||||
def _storno_verwerfen(self, grund=""):
|
||||
"""Storno rückgängig: PDF löschen, Zähler zurück, Zwischenspeicher leeren.
|
||||
|
||||
Zulaessig ist das nur hier: die Storno-PDF ist Sekunden alt und hat das
|
||||
Haus nie verlassen. Die stornierte Rechnung selbst wird nie angefasst.
|
||||
"""
|
||||
stand = self.storno_stand
|
||||
if not stand:
|
||||
return
|
||||
try:
|
||||
if os.path.exists(stand["pfad"]):
|
||||
os.remove(stand["pfad"])
|
||||
print(f"[storno] verworfen, PDF gelöscht -> {stand['pfad']}")
|
||||
except OSError as e: # noqa: BLE001
|
||||
# Abbrechen, NICHT weiterlaufen: sonst liegt das Storno noch im Ordner,
|
||||
# der Zähler ist zurückgedreht und die Rechnung gilt als storniert,
|
||||
# obwohl der Nutzer sie behalten wollte.
|
||||
messagebox.showerror(
|
||||
"Storno konnte nicht gelöscht werden",
|
||||
f"Die Datei\n{stand['pfad']}\nließ sich nicht löschen:\n{e}\n\n"
|
||||
"Ist sie noch in einem PDF-Betrachter offen? Bitte dort schließen "
|
||||
"und noch einmal versuchen. Es wurde nichts verändert.")
|
||||
return
|
||||
self.cfg["rechnung_jahr"] = stand["jahr_vorher"]
|
||||
self.cfg["rechnung_zaehler"] = stand["zaehler_vorher"]
|
||||
try:
|
||||
config.save_config(self.cfg)
|
||||
except OSError as e: # noqa: BLE001
|
||||
messagebox.showwarning(
|
||||
"Zähler nicht gespeichert",
|
||||
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
|
||||
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
|
||||
self.storno_stand = None
|
||||
self._storno_anzeigen()
|
||||
self._neue_nummer_vorschlagen()
|
||||
if grund:
|
||||
print(f"[storno] {grund}")
|
||||
|
||||
def dlg_storno(self):
|
||||
"""Knopf „Storno“ – setzt den Weg auf Storno und öffnet das Popup."""
|
||||
self._dlg_korrektur("storno")
|
||||
|
||||
def dlg_berichtigen(self):
|
||||
"""Knopf „Berichtigen“ – setzt den Weg auf Berichtigung und öffnet das Popup."""
|
||||
self._dlg_korrektur("berichtigung")
|
||||
|
||||
def _dlg_korrektur(self, modus):
|
||||
"""Popup für beide Wege. Welcher gilt, steht schon beim Öffnen fest.
|
||||
|
||||
"storno" -> Storno-PDF schreiben, danach die berichtigte Rechnung
|
||||
"berichtigung" -> nur die Kundenangaben, Rechnung behält ihre Nummer
|
||||
"""
|
||||
storno = modus == "storno"
|
||||
if self.storno_stand:
|
||||
messagebox.showinfo(
|
||||
"Storno liegt schon bereit",
|
||||
f"Der Storno {self.storno_stand['nummer']} zu Rechnung "
|
||||
f"{self.storno_stand['zu']} ist noch offen.\n\n"
|
||||
"Erst die berichtigte Rechnung erstellen oder den Storno dort verwerfen.")
|
||||
return
|
||||
if self.berichtigung_stand:
|
||||
messagebox.showinfo(
|
||||
"Berichtigung läuft schon",
|
||||
f"Die Rechnung {self.berichtigung_stand['zu']} liegt gerade zum Berichtigen "
|
||||
"im Formular.\n\nErst „PDF erstellen“ drücken oder das Formular zurücksetzen.")
|
||||
return
|
||||
|
||||
out_dir = config.ensure_output_dir(self.cfg)
|
||||
alle = rechnungen_im_ordner(out_dir)
|
||||
stornos = {nummer_normalisieren(d.get("storno_zu")): d
|
||||
for d in alle if d.get("art") == "storno"}
|
||||
stornierte = set(stornos)
|
||||
# Zu welchen Rechnungen gibt es schon eine berichtigte Rechnung?
|
||||
ersetzte = {nummer_normalisieren(d.get("ersetzt"))
|
||||
for d in alle if d.get("art") == "korrektur"}
|
||||
offen = [d for d in alle
|
||||
if d.get("art") not in ("storno", "berichtigung")
|
||||
and nummer_normalisieren(d.get("rechnungsnummer")) not in stornierte]
|
||||
if not offen:
|
||||
messagebox.showwarning(
|
||||
"Nichts zu korrigieren",
|
||||
f"Im Ordner\n{out_dir}\nliegt keine Rechnung, die dafür in Frage kommt.")
|
||||
return
|
||||
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Rechnung stornieren" if storno else "Rechnung berichtigen")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
|
||||
ttk.Label(frm, justify="left", wraplength=470, text=(
|
||||
"Für falsche Beträge oder Leistungen. Die alte Rechnung bleibt unverändert "
|
||||
"liegen – sie darf weder geändert noch gelöscht werden. Es entsteht eine "
|
||||
"Stornorechnung mit eigener neuer Nummer, die sich auf sie bezieht, und danach "
|
||||
"die berichtigte Rechnung."
|
||||
if storno else
|
||||
"Wenn nur die Angaben zum Kunden nicht stimmen. Die Rechnung behält Nummer, "
|
||||
"Datum und Beträge und bleibt gültig – es wird nur ein berichtigtes Blatt "
|
||||
"nachgereicht (§ 31 Abs. 5 UStDV). Für falsche Beträge ist das der falsche Weg, "
|
||||
"dafür gibt es den Storno.")).grid(
|
||||
row=0, column=0, columnspan=2, sticky="w", pady=(0, 10))
|
||||
|
||||
ttk.Label(frm, text="Alte Rechnungsnummer").grid(row=1, column=0, sticky="w", pady=3)
|
||||
# Bewusst OHNE textvariable: eine StringVar, die nur an einer lokalen
|
||||
# Variable haengt, raeumt Python nach dem Dialogaufbau weg - Tk loescht
|
||||
# dann die Tcl-Variable und das Feld steht leer da.
|
||||
ent = ttk.Entry(frm, width=20)
|
||||
ent.grid(row=1, column=1, sticky="w", pady=3, padx=(8, 0))
|
||||
ent.focus_set()
|
||||
|
||||
v_info = tk.StringVar(value="Nummer eintragen – das Programm sucht die Rechnung.")
|
||||
ttk.Label(frm, textvariable=v_info, justify="left", wraplength=430).grid(
|
||||
row=2, column=0, columnspan=2, sticky="w", pady=(10, 0))
|
||||
|
||||
# Wird nur sichtbar, wenn eine Nummer MEHRFACH im Ordner liegt. Genau das
|
||||
# ist der Altbestand: solange zwei Fenster gleichzeitig laufen konnten,
|
||||
# wurde dieselbe Nummer zweimal vergeben, teils als Windows-Kopie mit
|
||||
# "(1)" im Namen. Dann darf das Programm nicht raten, welche gemeint ist.
|
||||
self._auswahl = tk.Listbox(frm, height=4, exportselection=False,
|
||||
bg=self.col_field, fg=self.col_fg,
|
||||
selectbackground=self.col_sel, selectforeground=self.col_fg,
|
||||
relief="flat", font=("Consolas", 9))
|
||||
self._auswahl.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 0))
|
||||
self._auswahl.grid_remove()
|
||||
self._kandidaten = []
|
||||
|
||||
def kandidaten_zur_nummer(nr):
|
||||
return [x for x in alle
|
||||
if nummer_normalisieren(x.get("rechnungsnummer")) == nr
|
||||
and x.get("art") != "berichtigung"]
|
||||
|
||||
def suche():
|
||||
"""(Rechnung, Meldung) zur eingetippten Nummer. Rechnung=None -> Meldung sagt warum."""
|
||||
nr = nummer_normalisieren(ent.get())
|
||||
if not nr:
|
||||
self._kandidaten = []
|
||||
self._auswahl.grid_remove()
|
||||
return None, "Nummer eintragen – das Programm sucht die Rechnung."
|
||||
gefunden = kandidaten_zur_nummer(nr)
|
||||
if not gefunden:
|
||||
self._kandidaten = []
|
||||
self._auswahl.grid_remove()
|
||||
return None, f"Zu {nr} liegt in\n{out_dir}\nkeine Rechnung."
|
||||
|
||||
# Mehrere Belege unter derselben Nummer -> auswählen lassen.
|
||||
if len(gefunden) > 1:
|
||||
if gefunden != self._kandidaten:
|
||||
self._kandidaten = gefunden
|
||||
self._auswahl.delete(0, "end")
|
||||
for x in gefunden:
|
||||
self._auswahl.insert("end",
|
||||
f"{x.get('datum',''):11s} {str(x.get('nachname') or '?'):16s} "
|
||||
f"{berechnung.eur(x.get('gesamt') or 0):>11s} "
|
||||
f"{os.path.basename(x.get('_pfad',''))}")
|
||||
self._auswahl.grid()
|
||||
self._auswahl.selection_clear(0, "end")
|
||||
auswahl = self._auswahl.curselection()
|
||||
if not auswahl:
|
||||
return None, (f"⚠ Die Nummer {nr} liegt {len(gefunden)}× im Ordner – "
|
||||
"das darf es nicht geben.\nBitte unten auswählen, "
|
||||
"welcher Beleg gemeint ist.")
|
||||
treffer = gefunden[auswahl[0]]
|
||||
else:
|
||||
self._kandidaten = []
|
||||
self._auswahl.grid_remove()
|
||||
treffer = gefunden[0]
|
||||
|
||||
if treffer.get("art") == "storno":
|
||||
return None, f"{nr} ist selbst eine Stornorechnung."
|
||||
nr_norm = nummer_normalisieren(treffer.get("rechnungsnummer"))
|
||||
if nr_norm in stornierte:
|
||||
st = stornos.get(nr_norm) or {}
|
||||
folgt = st.get("korrektur_nummer") or ""
|
||||
if nr_norm in ersetzte or not folgt:
|
||||
return None, (f"{nr} wurde bereits mit {st.get('rechnungsnummer','?')} "
|
||||
"storniert – ein zweites Mal geht nicht.")
|
||||
# Auf dem Storno steht, welche Nummer die berichtigte Rechnung
|
||||
# bekommt. Damit braucht es keinen gemerkten Zustand: fehlt sie
|
||||
# noch, wird sie einfach unter dieser Nummer geschrieben.
|
||||
return None, (f"{nr} ist mit {st.get('rechnungsnummer','?')} storniert. "
|
||||
f"Die berichtigte Rechnung gehört unter die Nummer {folgt} – "
|
||||
"die steht auch auf dem Storno-Blatt.")
|
||||
gesamt = float(treffer.get("gesamt") or 0)
|
||||
mehrfach = (f"⚠ Achtung: die Nummer liegt {len(gefunden)}× im Ordner.\n"
|
||||
if len(gefunden) > 1 else "")
|
||||
return treffer, (
|
||||
mehrfach +
|
||||
f"Rechnung {treffer.get('rechnungsnummer')} vom {treffer.get('datum','')}"
|
||||
f" {treffer.get('nachname','')}"
|
||||
+ (f" ({os.path.basename(treffer.get('_pfad',''))})" if mehrfach else "") + "\n"
|
||||
f"Rechnungsbetrag {berechnung.eur(treffer.get('zwischensumme') or 0)}, "
|
||||
f"Gesamt {berechnung.eur(gesamt)}\n"
|
||||
f"Der Storno lautet über {berechnung.eur(-abs(gesamt))}.")
|
||||
|
||||
def vorschau(*_a):
|
||||
v_info.set(suche()[1])
|
||||
ent.bind("<KeyRelease>", vorschau)
|
||||
self._auswahl.bind("<<ListboxSelect>>", vorschau)
|
||||
|
||||
def anlegen(*_a):
|
||||
meta, meldung = suche()
|
||||
if not meta:
|
||||
v_info.set(meldung)
|
||||
return
|
||||
d.destroy()
|
||||
if storno:
|
||||
self._storno_anlegen(meta, out_dir)
|
||||
else:
|
||||
self._berichtigung_starten(meta, out_dir)
|
||||
ent.bind("<Return>", anlegen)
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.grid(row=4, column=0, columnspan=2, pady=(14, 0), sticky="e")
|
||||
ttk.Button(btns, text="Storno erstellen" if storno else "Rechnung laden",
|
||||
command=anlegen).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
frm.columnconfigure(1, weight=1)
|
||||
|
||||
|
||||
def _storno_anlegen(self, meta, out_dir):
|
||||
"""Schreibt die Storno-PDF und füllt das Formular für die berichtigte Rechnung."""
|
||||
alt_nummer = str(meta.get("rechnungsnummer", ""))
|
||||
nummer = naechste_freie_nummer(out_dir, self.cfg)
|
||||
if not nummer:
|
||||
messagebox.showerror("Keine freie Nummer",
|
||||
"Es wurde keine freie Rechnungsnummer gefunden.")
|
||||
return
|
||||
|
||||
r = storno_rechnung_bauen(meta, self.cfg)
|
||||
heute = datetime.date.today().strftime("%d.%m.%Y")
|
||||
# Welche Nummer die berichtigte Rechnung bekommt, steht schon jetzt fest -
|
||||
# die naechste freie NACH dem Storno. Sie kommt aufs Storno-Blatt, damit
|
||||
# die Kette aus dem Beleg allein lesbar ist und kein Zustand gemerkt
|
||||
# werden muss: alte Nummer -> Storno -> neue Nummer, alles auf einem Blatt.
|
||||
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer)
|
||||
# Der Storno geht an denselben Empfaenger wie die alte Rechnung - was in
|
||||
# deren Kenndaten fehlt, wird aus ihrem Adressblock geholt.
|
||||
kunde, woher = kundendaten_ermitteln(meta)
|
||||
namenszeile = " ".join(t for t in (kunde.get("vorname", ""),
|
||||
kunde.get("nachname", "")) if t)
|
||||
kunde_zeilen = [kunde.get("anrede", ""), namenszeile, kunde.get("zusatz", ""),
|
||||
kunde.get("strasse", ""), kunde.get("plzort", "")]
|
||||
|
||||
# Der Storno ist der Beleg, der die Rechnung aufhebt - er geht an den Gast
|
||||
# und braucht dieselben Angaben wie sie. Steht in der alten Rechnung nichts,
|
||||
# kann das Programm nichts erfinden; dann muss der Nutzer entscheiden.
|
||||
maengel = kunde_maengel(kunde)
|
||||
if maengel:
|
||||
if not messagebox.askyesno(
|
||||
"Angaben zum Kunden unvollständig",
|
||||
f"In Rechnung {alt_nummer} steht nicht alles:\n\n- "
|
||||
+ "\n- ".join(maengel) +
|
||||
"\n\nDas Storno geht an den Gast und trägt dieselben Angaben. "
|
||||
"Was fehlt, fehlt auch dort.\n\nTrotzdem erstellen?",
|
||||
icon="warning", default="no"):
|
||||
return
|
||||
|
||||
kopf = {
|
||||
"rechnungsnummer": nummer,
|
||||
"datum": heute,
|
||||
"anreise": "", "abreise": "", "naechte": "",
|
||||
"kunde_zeilen": kunde_zeilen,
|
||||
"nachname": kunde.get("nachname", ""),
|
||||
"vorname": kunde.get("vorname", ""),
|
||||
"art": "storno",
|
||||
# Vorgang der alten Rechnung uebernehmen, nicht deren Nummer: wird eine
|
||||
# schon berichtigte Rechnung noch einmal storniert, bleibt die ganze
|
||||
# Kette unter EINEM Schluessel.
|
||||
"vorgang": str(meta.get("vorgang") or alt_nummer),
|
||||
"storno_zu": alt_nummer,
|
||||
# alte Nummer -> diese Nummer. Steht auf dem Blatt, damit die Kette
|
||||
# aus dem Storno allein lesbar ist.
|
||||
"korrektur_nummer": folge_nummer or "",
|
||||
"storno_datum": str(meta.get("datum") or ""),
|
||||
"kunde": kunde,
|
||||
}
|
||||
name = f"Storno_{safe_filename(nummer)}"
|
||||
if kopf["nachname"]:
|
||||
name += "_" + safe_filename(kopf["nachname"])
|
||||
pfad = os.path.join(out_dir, name + ".pdf")
|
||||
try:
|
||||
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf)
|
||||
except Exception as e: # noqa: BLE001 - dem Nutzer den Fehler zeigen
|
||||
messagebox.showerror("Fehler beim Erstellen",
|
||||
f"Storno-PDF konnte nicht erstellt werden:\n{e}")
|
||||
print("[storno] FEHLER:", e)
|
||||
return
|
||||
|
||||
self.storno_stand = {
|
||||
"pfad": pfad, "nummer": nummer, "zu": alt_nummer,
|
||||
"vorgang": str(meta.get("vorgang") or alt_nummer),
|
||||
"zu_datum": str(meta.get("datum") or ""),
|
||||
"jahr_vorher": self.cfg.get("rechnung_jahr", datetime.date.today().year),
|
||||
"zaehler_vorher": self.cfg.get("rechnung_zaehler", 0),
|
||||
}
|
||||
self._zaehler_aus_nummer_speichern(nummer)
|
||||
print(f"[storno] erstellt -> {pfad}")
|
||||
|
||||
fehlend = self._formular_aus_meta(meta)
|
||||
self.r_nummer.set(folge_nummer or "")
|
||||
self._storno_anzeigen()
|
||||
|
||||
hinweis = ""
|
||||
if fehlend:
|
||||
hinweis = (f"\n\nZum Kunden: {fehlend}.\nBitte oben prüfen und ergänzen – "
|
||||
"ohne vollständige Anschrift wird die Rechnung nicht geschrieben.")
|
||||
messagebox.showinfo(
|
||||
"Storno erstellt",
|
||||
f"Stornorechnung {nummer} zu Rechnung {alt_nummer} liegt hier:\n{pfad}\n\n"
|
||||
f"Jetzt die berichtigte Rechnung {self.r_nummer.get()} anpassen und "
|
||||
f"\"PDF erstellen\" drücken.{hinweis}")
|
||||
|
||||
def _formular_aus_meta(self, meta):
|
||||
"""Formular mit den Daten der alten Rechnung füllen.
|
||||
|
||||
Gibt zurück, was NICHT übernommen werden konnte - aeltere PDFs (v1/v2)
|
||||
tragen weder Anschrift noch Positionen in den Kenndaten.
|
||||
"""
|
||||
kunde, woher = kundendaten_ermitteln(meta)
|
||||
self.k_vorname.set(kunde.get("vorname", ""))
|
||||
self.k_name.set(kunde.get("nachname", ""))
|
||||
self.k_anrede.set(str(kunde.get("anrede", "") or ""))
|
||||
self.k_zusatz.set(str(kunde.get("zusatz", "") or ""))
|
||||
self.k_strasse.set(str(kunde.get("strasse", "") or ""))
|
||||
self.k_plzort.set(str(kunde.get("plzort", "") or ""))
|
||||
self.r_anreise.set(str(meta.get("anreise", "") or ""))
|
||||
self.r_abreise.set(str(meta.get("abreise", "") or ""))
|
||||
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
|
||||
|
||||
for nr, var in self.anzahl_vars.items():
|
||||
var.set("")
|
||||
self.personen_vars[nr].set("")
|
||||
for p in meta.get("positionen") or []:
|
||||
nr = p.get("nr")
|
||||
if nr in self.anzahl_vars:
|
||||
self.anzahl_vars[nr].set(str(p.get("anzahl", "") or ""))
|
||||
self.personen_vars[nr].set(str(p.get("personen", "") or ""))
|
||||
self.recalc()
|
||||
|
||||
hinweise = []
|
||||
if woher:
|
||||
hinweise.append("aus dem Rechnungstext gelesen: " + ", ".join(woher))
|
||||
fehlt = [feld for feld, wert in (("Straße", self.k_strasse.get()),
|
||||
("PLZ / Ort", self.k_plzort.get()),
|
||||
("Nachname", self.k_name.get()))
|
||||
if not wert]
|
||||
if fehlt:
|
||||
hinweise.append("steht in der alten Rechnung nicht drin: " + ", ".join(fehlt))
|
||||
if not (meta.get("positionen") or []):
|
||||
hinweise.append("Leistungen konnten nicht übernommen werden")
|
||||
return "; ".join(hinweise)
|
||||
153
pdf_renderer.py
153
pdf_renderer.py
|
|
@ -208,10 +208,30 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
if len(kunde_flow) == 1:
|
||||
kunde_flow.append(Paragraph(" ", st["addr"]))
|
||||
|
||||
art = kopf.get("art", "rechnung")
|
||||
info_rows = [
|
||||
("Rechnungsnummer:", kopf.get("rechnungsnummer", "")),
|
||||
("Stornonummer:" if art == "storno" else "Rechnungsnummer:",
|
||||
kopf.get("rechnungsnummer", "")),
|
||||
("Rechnungsdatum:", kopf.get("datum", "")),
|
||||
]
|
||||
# Der Bezug auf die alte Rechnung ist Pflicht (§ 31 Abs. 5 UStDV): Nummer UND Datum.
|
||||
if art == "storno" and kopf.get("storno_zu"):
|
||||
# Nummer und Datum in zwei Zeilen - zusammen brechen sie in der schmalen
|
||||
# Spalte um und der Bezug sieht aus wie zwei halbe Angaben.
|
||||
info_rows.append(("Storno zu Rechnung:", kopf.get("storno_zu", "")))
|
||||
if kopf.get("storno_datum"):
|
||||
info_rows.append(("Rechnung vom:", kopf["storno_datum"]))
|
||||
# Die Nummer, unter der die berichtigte Rechnung kommt. Damit steht die
|
||||
# ganze Kette auf dem Blatt: alte Nummer -> Storno -> neue Nummer.
|
||||
if kopf.get("korrektur_nummer"):
|
||||
info_rows.append(("Neue Rechnung:", kopf["korrektur_nummer"]))
|
||||
elif art == "korrektur" and kopf.get("ersetzt"):
|
||||
info_rows.append(("Ersetzt Rechnung:", kopf.get("ersetzt", "")))
|
||||
elif art == "berichtigung":
|
||||
# Die Rechnung behaelt Nummer und Datum - dazu kommt der Tag der
|
||||
# Berichtigung, damit man die Reihenfolge sieht.
|
||||
if kopf.get("berichtigt_am"):
|
||||
info_rows.append(("Berichtigt am:", kopf["berichtigt_am"]))
|
||||
if kopf.get("anreise"):
|
||||
info_rows.append(("Anreise:", kopf.get("anreise", "")))
|
||||
if kopf.get("abreise"):
|
||||
|
|
@ -239,7 +259,11 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
# Ueberschrift (auf dem Amt-Blatt mit Zusatz)
|
||||
# Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht
|
||||
# in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist.
|
||||
titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "")
|
||||
# Das Wort "Stornorechnung" muss auf dem Blatt stehen, damit der Beleg
|
||||
# eindeutig als Aufhebung erkennbar ist.
|
||||
titel = {"storno": "Stornorechnung",
|
||||
"berichtigung": "Berichtigte Rechnung"}.get(art, "Rechnungsübersicht")
|
||||
titel += " (für das Amt)" if gesamtrechnung_betont else ""
|
||||
story.append(Paragraph(titel, st["h"]))
|
||||
|
||||
# Positionstabelle
|
||||
|
|
@ -342,22 +366,64 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
continue
|
||||
satz_txt = eur_kurz(a.satz).replace(",00", "")
|
||||
story.append(Spacer(1, 1.5 * mm))
|
||||
story.append(Paragraph(
|
||||
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die "
|
||||
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"]))
|
||||
if art == "storno":
|
||||
# Auf dem Storno wird nichts erhoben, sondern zurueckgenommen.
|
||||
story.append(Paragraph(
|
||||
f"Die mit der stornierten Rechnung erhobene {a.bezeichnung} "
|
||||
f"({satz_txt} %) wird mit aufgehoben.", st["dank"]))
|
||||
else:
|
||||
story.append(Paragraph(
|
||||
f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die "
|
||||
f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"]))
|
||||
|
||||
story.append(Spacer(1, 8 * mm))
|
||||
|
||||
# ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
|
||||
# ---- Schlusstexte (nur Kundenseite; auf dem Amt-Blatt unnoetig) ----
|
||||
if not gesamtrechnung_betont:
|
||||
texte = cfg.get("texte", {})
|
||||
block = []
|
||||
if texte.get("dank1"):
|
||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
||||
if texte.get("dank2"):
|
||||
block.append(Paragraph(texte["dank2"], st["dank"]))
|
||||
if texte.get("hinweis"):
|
||||
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
|
||||
if art == "storno":
|
||||
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
|
||||
# nichts geschuldet, sondern aufgehoben.
|
||||
bezug = kopf.get("storno_zu", "")
|
||||
if kopf.get("storno_datum"):
|
||||
bezug += f" vom {kopf['storno_datum']}"
|
||||
folgt = kopf.get("korrektur_nummer") or ""
|
||||
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
|
||||
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
|
||||
f"→ {folgt}).") if folgt else ""
|
||||
block.append(Paragraph(
|
||||
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
||||
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
|
||||
"Rechnung verrechnet." + kette, st["dank"]))
|
||||
elif art == "berichtigung":
|
||||
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
|
||||
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
|
||||
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
|
||||
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
|
||||
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
|
||||
# Aussteller. Also bleibt der Satz drauf.
|
||||
block.append(Paragraph(
|
||||
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
|
||||
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
|
||||
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
|
||||
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
|
||||
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
|
||||
"ihre Nummer.", st["dank"]))
|
||||
if texte.get("dank1"):
|
||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
||||
else:
|
||||
if art == "korrektur" and kopf.get("ersetzt"):
|
||||
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
|
||||
if kopf.get("storno_nummer"):
|
||||
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
|
||||
block.append(Paragraph(hinweis + ".", st["dank"]))
|
||||
if texte.get("dank1"):
|
||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
||||
if texte.get("dank2"):
|
||||
block.append(Paragraph(texte["dank2"], st["dank"]))
|
||||
if texte.get("hinweis"):
|
||||
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
|
||||
story.append(KeepTogether(block))
|
||||
|
||||
|
||||
|
|
@ -368,11 +434,44 @@ def _kenndaten(rechnung, kopf):
|
|||
Das Steuerjournal (Programm "Beherbergungssteuer") liest sie von dort aus,
|
||||
statt Nachname und Betraege aus dem Fliesstext zu raten. Fuer den Ausdruck
|
||||
aendert sich nichts - Metadaten stehen nicht auf dem Blatt.
|
||||
|
||||
Version 2 ergaenzt die USt-Aufteilung ("ust_bloecke") fuers dritte Programm,
|
||||
den Steuerrechner: der braucht Brutto->Netto je Steuersatz, und 7 % und 19 %
|
||||
stecken auf einer Rechnung gemischt drin. Alle Felder der Version 1 bleiben
|
||||
unveraendert stehen - am "zwischensumme"/"steuer_satz"/"steuer_betrag" haengt
|
||||
der Import des Steuerjournals, das darf nicht brechen.
|
||||
|
||||
Version 3 kommt mit dem Storno dazu:
|
||||
"art" - "rechnung", "storno" oder "korrektur"
|
||||
"storno_zu" - beim Storno die Nummer der aufgehobenen Rechnung
|
||||
"ersetzt" - bei der Korrektur die Nummer der falschen Rechnung
|
||||
"kunde" / "positionen" / "anreise" / "abreise" - der komplette Formularstand,
|
||||
damit eine Rechnung spaeter wieder ins Formular geladen werden kann, statt
|
||||
sie abzutippen. Aeltere PDFs (v1/v2) haben das nicht.
|
||||
Beim Storno sind alle Betraege NEGATIV - das Steuerjournal und der
|
||||
Steuerrechner summieren dadurch von allein richtig (+100 -100 +120 = 120).
|
||||
"""
|
||||
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
|
||||
if kopf.get("art") == "berichtigung":
|
||||
# Das berichtigte Blatt zeigt dieselben Betraege wie die Rechnung, ist aber
|
||||
# KEINE zweite Einnahme. Es traegt deshalb Nullen: ein Programm, das "art"
|
||||
# nicht kennt, zaehlt dann 0,00 € statt den Umsatz ein zweites Mal.
|
||||
return json.dumps({
|
||||
"quelle": "rechnungstool", "version": 3, "art": "berichtigung",
|
||||
"vorgang": kopf.get("vorgang") or kopf.get("berichtigt_zu", ""),
|
||||
"rechnungsnummer": kopf.get("berichtigt_zu", ""),
|
||||
"berichtigt_zu": kopf.get("berichtigt_zu", ""),
|
||||
"berichtigt_am": kopf.get("berichtigt_am", ""),
|
||||
"datum": kopf.get("datum", ""),
|
||||
"nachname": kopf.get("nachname", ""), "vorname": kopf.get("vorname", ""),
|
||||
"zwischensumme": 0.0, "gesamt": 0.0,
|
||||
"steuer_satz": 0.0, "steuer_betrag": 0.0,
|
||||
"netto_gesamt": 0.0, "ust_gesamt": 0.0, "ust_bloecke": [],
|
||||
"kunde": kopf.get("kunde", {}),
|
||||
}, ensure_ascii=False)
|
||||
daten = {
|
||||
"quelle": "rechnungstool",
|
||||
"version": 1,
|
||||
"version": 3,
|
||||
"rechnungsnummer": kopf.get("rechnungsnummer", ""),
|
||||
"datum": kopf.get("datum", ""),
|
||||
"nachname": kopf.get("nachname", ""),
|
||||
|
|
@ -382,6 +481,31 @@ def _kenndaten(rechnung, kopf):
|
|||
"steuer_satz": float(aufschlag.satz) if aufschlag else 0.0,
|
||||
"steuer_betrag": runde(aufschlag.betrag) if aufschlag else 0.0,
|
||||
"gesamt": runde(rechnung.gesamt),
|
||||
# neu in Version 2: je USt-Satz ein Block (Netto, enthaltene USt, Brutto)
|
||||
"netto_gesamt": runde(rechnung.netto_gesamt),
|
||||
"ust_gesamt": runde(rechnung.ust_gesamt),
|
||||
"ust_bloecke": [
|
||||
{"satz": b.satz, "netto": runde(b.netto), "ust": runde(b.ust),
|
||||
"brutto": runde(b.brutto)}
|
||||
for b in rechnung.bloecke
|
||||
],
|
||||
# neu in Version 3
|
||||
"art": kopf.get("art", "rechnung"),
|
||||
# Klammer um die ganze Kette: Rechnung, ihr Storno, die berichtigte
|
||||
# Rechnung und jede Berichtigung tragen DIESELBE Vorgangsnummer - die der
|
||||
# allerersten Rechnung. Ein anderes Programm gruppiert danach in einem
|
||||
# Schritt, statt sich die Kette ueber storno_zu/ersetzt zusammenzusuchen.
|
||||
# Eine normale Rechnung ist ihr eigener Vorgang.
|
||||
"vorgang": kopf.get("vorgang") or kopf.get("rechnungsnummer", ""),
|
||||
"storno_zu": kopf.get("storno_zu", ""),
|
||||
"korrektur_nummer": kopf.get("korrektur_nummer", ""),
|
||||
"storno_datum": kopf.get("storno_datum", ""),
|
||||
"ersetzt": kopf.get("ersetzt", ""),
|
||||
"storno_nummer": kopf.get("storno_nummer", ""),
|
||||
"anreise": kopf.get("anreise", ""),
|
||||
"abreise": kopf.get("abreise", ""),
|
||||
"kunde": kopf.get("kunde", {}),
|
||||
"positionen": kopf.get("positionen", []),
|
||||
}
|
||||
return json.dumps(daten, ensure_ascii=False)
|
||||
|
||||
|
|
@ -405,7 +529,8 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
|
|||
ziel, pagesize=A4,
|
||||
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
|
||||
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
|
||||
title=f"Rechnung {kopf.get('rechnungsnummer','')}",
|
||||
title=("Stornorechnung" if kopf.get("art") == "storno" else "Rechnung")
|
||||
+ f" {kopf.get('rechnungsnummer','')}",
|
||||
author=cfg["firma"].get("name", ""),
|
||||
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
|
||||
)
|
||||
|
|
|
|||
116
pruef_dialoge.py
Normal file
116
pruef_dialoge.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: geht jeder Dialog ueberhaupt auf?
|
||||
|
||||
Klingt banal, ist es nicht: beim Aufteilen von app.py in Module sind zwei
|
||||
Dialoge stumm kaputtgegangen (fehlende Importe), und kein anderer Pruefstand
|
||||
hat es gemerkt, weil keiner sie oeffnet. Dieser hier oeffnet ALLE.
|
||||
|
||||
Geprueft wird ausserdem, dass jeder Dialog auch wieder zugeht - ein modales
|
||||
Fenster, das haengenbleibt, sperrt das Programm.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
|
||||
QUELLE = r"C:\claude\rechnungstool"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="dialog_pruef_")
|
||||
ordner = os.path.join(tmp, "rechnungen")
|
||||
os.makedirs(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["output_dir"] = ordner
|
||||
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
|
||||
|
||||
import app as A # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
messagebox.showinfo = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: None
|
||||
messagebox.showerror = lambda *a, **k: None
|
||||
messagebox.askyesno = lambda *a, **k: True # Sicherheitsfrage bejahen
|
||||
|
||||
|
||||
# --- Reissleine ---------------------------------------------------------
|
||||
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
|
||||
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
|
||||
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
|
||||
import threading as _th
|
||||
|
||||
|
||||
def _reissleine():
|
||||
import os as _os
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
|
||||
"der nicht beantwortet wird")
|
||||
_os._exit(2)
|
||||
|
||||
|
||||
_wecker = _th.Timer(120, _reissleine)
|
||||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = 0
|
||||
f = A.RechnungsApp()
|
||||
f.update()
|
||||
|
||||
# Damit "Storno"/"Berichtigen" nicht an "nichts zu korrigieren" scheitern:
|
||||
# eine echte Rechnung in den Wegwerf-Ordner legen.
|
||||
f.k_vorname.set("Anna")
|
||||
f.k_name.set("Beispiel")
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.anzahl_vars[sorted(f.anzahl_vars)[0]].set("2")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
|
||||
DIALOGE = ["dlg_steuern", "dlg_katalog", "dlg_firma", "dlg_nummer",
|
||||
"dlg_storno", "dlg_berichtigen"]
|
||||
|
||||
for name in DIALOGE:
|
||||
offen_vorher = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)]
|
||||
try:
|
||||
getattr(f, name)()
|
||||
f.update()
|
||||
except Exception as e: # noqa: BLE001 - genau das wollen wir sehen
|
||||
print(f"ABSTURZ | {name}: {type(e).__name__}: {e}")
|
||||
fehler += 1
|
||||
continue
|
||||
neu = [w for w in f.winfo_children()
|
||||
if isinstance(w, tk.Toplevel) and w not in offen_vorher]
|
||||
if not neu:
|
||||
print(f"FEHLER | {name}: kein Fenster aufgegangen")
|
||||
fehler += 1
|
||||
continue
|
||||
titel = neu[-1].title()
|
||||
for w in neu:
|
||||
w.grab_release()
|
||||
w.destroy()
|
||||
f.update()
|
||||
print(f" OK | {name} -> „{titel}“")
|
||||
|
||||
# Der Speicherort-Dialog oeffnet einen Systemdialog - nur die Vorstufe pruefen
|
||||
try:
|
||||
f._frage_speicherort_falls_noetig()
|
||||
f.update()
|
||||
print(" OK | _frage_speicherort_falls_noetig")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"ABSTURZ | _frage_speicherort_falls_noetig: {type(e).__name__}: {e}")
|
||||
fehler += 1
|
||||
|
||||
f.destroy()
|
||||
|
||||
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
|
||||
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
|
||||
if not fehler: # fehler ist hier ein Zaehler
|
||||
import shutil as _sh
|
||||
_sh.rmtree(tmp, ignore_errors=True)
|
||||
print("---")
|
||||
print("alles gruen" if not fehler else f"{fehler} Fehler")
|
||||
sys.exit(1 if fehler else 0)
|
||||
399
pruef_kernregeln.py
Normal file
399
pruef_kernregeln.py
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand fuer die ZWEI Regeln, um die es dem Programm eigentlich geht:
|
||||
|
||||
1. Eine Rechnungsnummer wird nie zweimal vergeben.
|
||||
2. Eine ausgestellte Rechnung wird nie veraendert und nie geloescht.
|
||||
|
||||
Warum es diesen Pruefstand gibt: pruef_storno.py lief 100x gruen und pruefte
|
||||
trotzdem keine der beiden Regeln - er ging immer nur den gluecklichen Weg mit
|
||||
sauber hochgezaehltem Zaehler. Ein Mutationstest hat gezeigt, dass man BEIDE
|
||||
Sperren gegen die doppelte Nummer ausbauen kann, ohne dass er rot wird.
|
||||
|
||||
Darum hier:
|
||||
- Der Bestand wird ueber PRUEFSUMMEN verglichen, nicht ueber os.path.exists.
|
||||
Eine Datei, die noch da ist, kann trotzdem veraendert worden sein.
|
||||
- Die Nummer wird von HAND ins Feld getippt, so wie es der Nutzer tut.
|
||||
- Unbekannte Dialoge lassen den Lauf scheitern, statt still "Nein" zu sagen.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
QUELLE = r"C:\claude\rechnungstool"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="kern_pruef_")
|
||||
ordner = os.path.join(tmp, "rechnungen")
|
||||
os.makedirs(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)
|
||||
|
||||
import app as A # noqa: E402
|
||||
import bestand as BE # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
|
||||
# --- Reissleine ---------------------------------------------------------
|
||||
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
|
||||
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
|
||||
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
|
||||
import threading as _th
|
||||
|
||||
|
||||
def _reissleine():
|
||||
import os as _os
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
|
||||
"der nicht beantwortet wird")
|
||||
_os._exit(2)
|
||||
|
||||
|
||||
_wecker = _th.Timer(120, _reissleine)
|
||||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
|
||||
# nicht mit einem UnicodeEncodeError abbrechen.
|
||||
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)
|
||||
|
||||
|
||||
# ---- Dialoge: jede Frage muss angemeldet sein --------------------------------
|
||||
antworten = {}
|
||||
gefragt = []
|
||||
meldungen = []
|
||||
|
||||
|
||||
def askyesno(titel, text="", **kw):
|
||||
gefragt.append(titel)
|
||||
for schluessel, wert in antworten.items():
|
||||
if schluessel in titel:
|
||||
return wert
|
||||
# Kein stilles "Nein": sonst haelt der Pruefstand einen abgebrochenen
|
||||
# Ablauf faelschlich fuer ein Ergebnis.
|
||||
raise AssertionError(f"unangemeldeter Dialog: {titel!r} — {text[:80]!r}")
|
||||
|
||||
|
||||
messagebox.askyesno = askyesno
|
||||
messagebox.showinfo = lambda *a, **k: meldungen.append(("info", a[0] if a else ""))
|
||||
messagebox.showwarning = lambda *a, **k: meldungen.append(("warnung", a[0] if a else ""))
|
||||
messagebox.showerror = lambda *a, **k: meldungen.append(("fehler", a[0] if a else ""))
|
||||
|
||||
|
||||
def bestand_lesen():
|
||||
"""Pruefsumme je PDF im Ordner - erkennt auch stille Aenderungen am Inhalt."""
|
||||
stand = {}
|
||||
for name in sorted(os.listdir(ordner)):
|
||||
if name.lower().endswith(".pdf"):
|
||||
with open(os.path.join(ordner, name), "rb") as f:
|
||||
stand[name] = hashlib.sha256(f.read()).hexdigest()
|
||||
return stand
|
||||
|
||||
|
||||
def unveraendert(vorher, ausser=()):
|
||||
"""Alle Dateien aus <vorher> sind byteweise unveraendert (bis auf <ausser>)."""
|
||||
jetzt = bestand_lesen()
|
||||
for name, summe in vorher.items():
|
||||
if name in ausser:
|
||||
continue
|
||||
if jetzt.get(name) != summe:
|
||||
return False, (f"{name} " + ("ist weg" if name not in jetzt else "wurde verändert"))
|
||||
return True, ""
|
||||
|
||||
|
||||
def kunde_setzen(vor, nach, tage="2"):
|
||||
f.k_vorname.set(vor)
|
||||
f.k_name.set(nach)
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.anzahl_vars[erste].set(tage)
|
||||
f.update()
|
||||
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.update()
|
||||
f.cfg["output_dir"] = ordner
|
||||
erste = sorted(f.anzahl_vars)[0]
|
||||
antworten["Fertig"] = False # "jetzt oeffnen?" immer nein
|
||||
antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen
|
||||
|
||||
# ---- Die Sicherheitsfrage muss auch wirklich abbrechen ----------------------
|
||||
# "Sind alle Angaben richtig?" ist die letzte Gelegenheit vor dem Beleg. Sagt
|
||||
# der Nutzer Nein, darf NICHTS entstehen - sonst waere die Frage eine Attrappe.
|
||||
antworten["endgültig erstellen"] = False
|
||||
kunde_setzen("Test", "Abbruch", "1")
|
||||
f.pdf_erstellen()
|
||||
pruefe(not [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")],
|
||||
"bei „Nein“ auf die Sicherheitsfrage entsteht keine PDF")
|
||||
antworten["endgültig erstellen"] = True
|
||||
|
||||
# ---- Ausgangslage: zwei echte Rechnungen ------------------------------------
|
||||
kunde_setzen("Anna", "Beispiel", "4")
|
||||
f.pdf_erstellen()
|
||||
kunde_setzen("Bernd", "Zweit", "3")
|
||||
f.pdf_erstellen()
|
||||
pruefe(sorted(bestand_lesen()) == ["Rechnung_2026-001_Beispiel.pdf",
|
||||
"Rechnung_2026-002_Zweit.pdf"],
|
||||
f"zwei Rechnungen angelegt ({sorted(bestand_lesen())})")
|
||||
|
||||
# =============================================================================
|
||||
# REGEL 1: eine Nummer nie zweimal - auch nicht, wenn sie von Hand kommt
|
||||
# =============================================================================
|
||||
print("--- Regel 1: Nummer von Hand auf eine vergebene setzen ---")
|
||||
vorher = bestand_lesen()
|
||||
gefragt.clear()
|
||||
antworten["Rechnungsnummer schon vergeben"] = True # "naechste freie nehmen?"
|
||||
kunde_setzen("Carla", "Dritt", "1")
|
||||
f.r_nummer.set("2026-001") # genau das tut der Nutzer von Hand
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
|
||||
pruefe(any("schon vergeben" in t for t in gefragt),
|
||||
f"Programm meldet die vergebene Nummer ({gefragt})")
|
||||
ok, warum = unveraendert(vorher)
|
||||
pruefe(ok, f"die beiden alten Rechnungen sind byteweise unverändert ({warum})")
|
||||
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-003_Dritt.pdf")),
|
||||
"die neue Rechnung bekam die nächste FREIE Nummer 2026-003")
|
||||
pruefe(f.r_nummer.get() != "2026-001", f"Feld zeigt nicht mehr die alte Nummer ({f.r_nummer.get()})")
|
||||
|
||||
print("--- Regel 1: Nutzer lehnt die Ausweichnummer ab ---")
|
||||
vorher = bestand_lesen()
|
||||
antworten["Rechnungsnummer schon vergeben"] = False
|
||||
kunde_setzen("Doris", "Vier", "1")
|
||||
f.r_nummer.set("2026-002")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
ok, warum = unveraendert(vorher)
|
||||
pruefe(ok and bestand_lesen() == vorher,
|
||||
f"bei Nein wird gar nichts geschrieben und nichts verändert ({warum})")
|
||||
|
||||
print("--- Regel 1: Zähler steht HINTER dem Ordner (config aus dem Backup) ---")
|
||||
f.cfg["rechnung_zaehler"] = 0 # so sieht eine zurückgespielte config aus
|
||||
f.berichtigung_stand = None
|
||||
f.storno_stand = None
|
||||
f._neue_nummer_vorschlagen()
|
||||
pruefe(f.r_nummer.get() == "2026-004",
|
||||
f"Vorschlag überspringt die belegten Nummern ({f.r_nummer.get()})")
|
||||
|
||||
print("--- Regel 1: der Zähler darf nur vorwärts ---")
|
||||
# Zweite Verteidigungslinie hinter dem Ordner-Abgleich: wer von Hand eine
|
||||
# niedrigere freie Nummer nimmt, darf den Zähler nicht zurückdrehen - sonst
|
||||
# schlägt das Programm die dazwischenliegenden Nummern ein zweites Mal vor.
|
||||
kunde_setzen("Emil", "Hoch", "1")
|
||||
f.r_nummer.set("2026-009")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
pruefe(f.cfg["rechnung_zaehler"] == 9, f"Zähler steht auf 9 ({f.cfg['rechnung_zaehler']})")
|
||||
kunde_setzen("Frida", "Tief", "1")
|
||||
f.r_nummer.set("2026-007")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
pruefe(os.path.exists(os.path.join(ordner, "Rechnung_2026-007_Tief.pdf")),
|
||||
"die niedrigere freie Nummer 2026-007 wurde geschrieben")
|
||||
pruefe(f.cfg["rechnung_zaehler"] == 9,
|
||||
f"Zähler bleibt auf 9, fällt NICHT auf 7 zurück ({f.cfg['rechnung_zaehler']})")
|
||||
f._neue_nummer_vorschlagen()
|
||||
pruefe(f.r_nummer.get() == "2026-010",
|
||||
f"nächster Vorschlag ist 2026-010, keine schon vergebene ({f.r_nummer.get()})")
|
||||
|
||||
print("--- Regel 1: Jahreswechsel - beide Quellen müssen dasselbe sagen ---")
|
||||
f.cfg["rechnung_jahr"] = 2025
|
||||
f.cfg["rechnung_zaehler"] = 87
|
||||
f._neue_nummer_vorschlagen()
|
||||
vom_formular = f.r_nummer.get()
|
||||
vom_storno = BE.naechste_freie_nummer(ordner, f.cfg)
|
||||
pruefe(vom_formular == vom_storno,
|
||||
f"Formular und Storno-Weg schlagen dieselbe Nummer vor ({vom_formular} / {vom_storno})")
|
||||
pruefe(vom_formular.startswith("2026-"),
|
||||
f"neues Jahr fängt im aktuellen Jahr an ({vom_formular})")
|
||||
f.cfg["rechnung_jahr"], f.cfg["rechnung_zaehler"] = 2026, 3
|
||||
config.save_config(f.cfg)
|
||||
|
||||
# =============================================================================
|
||||
# REGEL 2: ausgestellte Rechnung nie anfassen
|
||||
# =============================================================================
|
||||
print("--- Regel 2: Storno über den Dialogknopf, nicht über die interne Methode ---")
|
||||
vorher = bestand_lesen()
|
||||
gefragt.clear()
|
||||
f.dlg_storno()
|
||||
f.update()
|
||||
import tkinter as tk # noqa: E402
|
||||
from tkinter import ttk # noqa: E402
|
||||
dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
|
||||
feld = knopf = None
|
||||
for rahmen in dlg.winfo_children():
|
||||
for w in rahmen.winfo_children():
|
||||
if isinstance(w, ttk.Entry):
|
||||
feld = w
|
||||
elif isinstance(w, ttk.Frame):
|
||||
for b in w.winfo_children():
|
||||
if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")):
|
||||
knopf = b
|
||||
pruefe(feld is not None and knopf is not None, "Storno-Dialog hat Feld und Knopf")
|
||||
feld.delete(0, "end")
|
||||
feld.insert(0, "2026-001")
|
||||
knopf.invoke() # der ECHTE Weg, nicht _storno_anlegen()
|
||||
f.update()
|
||||
|
||||
pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-001",
|
||||
f"der Knopf hat wirklich ein Storno gestartet ({f.storno_stand})")
|
||||
storno_datei = os.path.basename(f.storno_stand["pfad"]) if f.storno_stand else ""
|
||||
ok, warum = unveraendert(vorher)
|
||||
pruefe(ok, f"alle bisherigen Rechnungen unverändert ({warum})")
|
||||
|
||||
print("--- Regel 2: was steht auf dem Storno-Blatt? ---")
|
||||
from pypdf import PdfReader # noqa: E402
|
||||
text = PdfReader(f.storno_stand["pfad"]).pages[0].extract_text()
|
||||
pruefe("Stornorechnung" in text, "Überschrift „Stornorechnung“ steht drauf")
|
||||
pruefe("2026-001" in text, "Bezug auf die alte Nummer steht drauf")
|
||||
# pypdf legt jeden Tabellenwert in eine eigene Zeile - die Betraege stehen also
|
||||
# NACH ihrer Beschriftung, nicht daneben.
|
||||
zeilen = text.splitlines()
|
||||
|
||||
|
||||
def betraege_nach(label, wieviele=6):
|
||||
"""Die Zahlenwerte, die auf die Zeile mit <label> folgen."""
|
||||
for i, z in enumerate(zeilen):
|
||||
if label in z:
|
||||
return [w.strip() for w in zeilen[i + 1:i + 1 + wieviele]
|
||||
if "," in w and any(c.isdigit() for c in w)]
|
||||
return []
|
||||
|
||||
|
||||
pos = betraege_nach("Storno der Rechnung")
|
||||
pruefe(bool(pos), f"Positionszeile hat Beträge ({pos})")
|
||||
pruefe(all(w.startswith("-") for w in pos),
|
||||
f"Positionszeile ist NEGATIV, nicht nur der Summenblock ({pos})")
|
||||
gesamt = betraege_nach("Gesamtbetrag", 1)
|
||||
pruefe(gesamt and gesamt[0].startswith("-"), f"Gesamtbetrag negativ ({gesamt})")
|
||||
netto = betraege_nach("Nettobetrag", 1)
|
||||
pruefe(netto and netto[0].startswith("-"), f"Nettobetrag negativ ({netto})")
|
||||
|
||||
print("--- Regel 2: berichtigte Rechnung schreiben, alter Bestand bleibt ---")
|
||||
vorher = bestand_lesen()
|
||||
antworten["Storno wirklich"] = True
|
||||
kunde_setzen("Anna", "Beispiel", "3")
|
||||
f.pdf_erstellen()
|
||||
ok, warum = unveraendert(vorher)
|
||||
pruefe(ok, f"Storno und alle Rechnungen unverändert ({warum})")
|
||||
pruefe(len(bestand_lesen()) == len(vorher) + 1, "genau eine Datei kam dazu")
|
||||
|
||||
# =============================================================================
|
||||
# Berichtigung: zweimal am selben Tag darf nicht stillschweigend überschreiben
|
||||
# =============================================================================
|
||||
print("--- Berichtigung zweimal am selben Tag ---")
|
||||
antworten["Berichtigte Rechnung erstellen"] = True
|
||||
meta = next(x for x in BE.rechnungen_im_ordner(ordner)
|
||||
if x.get("rechnungsnummer") == "2026-002")
|
||||
f._berichtigung_starten(meta, ordner)
|
||||
f.k_strasse.set("Neue Straße 5")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
erste_fassung = bestand_lesen()
|
||||
ziel = [n for n in erste_fassung if n.startswith("Berichtigte Rechnung")]
|
||||
pruefe(len(ziel) == 1, f"Berichtigungsblatt geschrieben ({ziel})")
|
||||
|
||||
antworten["Berichtigung von heute ersetzen"] = False # NEIN, nicht ersetzen
|
||||
f._berichtigung_starten(meta, ordner)
|
||||
f.k_strasse.set("Ganz andere Straße 9")
|
||||
f.update()
|
||||
gefragt.clear()
|
||||
f.pdf_erstellen()
|
||||
pruefe(any("ersetzen" in t for t in gefragt), f"vor dem Ersetzen wird gefragt ({gefragt})")
|
||||
ok, warum = unveraendert(erste_fassung)
|
||||
pruefe(ok, f"bei Nein bleibt die erste Fassung unangetastet ({warum})")
|
||||
|
||||
# =============================================================================
|
||||
# Der Altbestand: dieselbe Nummer liegt MEHRFACH im Ordner (Windows-Kopie).
|
||||
# Dann darf der Storno-Dialog nicht raten, welcher Beleg gemeint ist.
|
||||
# =============================================================================
|
||||
print("--- zwei Belege unter einer Nummer ---")
|
||||
# Die abgelehnte Berichtigung liegt noch im Zwischenspeicher - so wie der Nutzer
|
||||
# es dann macht: zuruecksetzen.
|
||||
f.reset_formular()
|
||||
pruefe(f.berichtigung_stand is None, "Zurücksetzen räumt die offene Berichtigung weg")
|
||||
import shutil as _sh # noqa: E402
|
||||
quelle = os.path.join(ordner, "Rechnung_2026-002_Zweit.pdf")
|
||||
kopie = os.path.join(ordner, "Rechnung_2026-002_Zweit (1).pdf")
|
||||
_sh.copy(quelle, kopie)
|
||||
|
||||
pruefe(len(BE.vorhandene_rechnungen(ordner, "2026-002")) == 2,
|
||||
"die Windows-Kopie zählt bei der Nummernvergabe mit")
|
||||
|
||||
f.dlg_storno()
|
||||
f.update()
|
||||
dlg = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
|
||||
feld = liste = knopf = None
|
||||
for rahmen in dlg.winfo_children():
|
||||
for w in rahmen.winfo_children():
|
||||
if isinstance(w, ttk.Entry):
|
||||
feld = w
|
||||
elif isinstance(w, tk.Listbox):
|
||||
liste = w
|
||||
elif isinstance(w, ttk.Frame):
|
||||
for b in w.winfo_children():
|
||||
if isinstance(b, ttk.Button) and "Storno erstellen" in str(b.cget("text")):
|
||||
knopf = b
|
||||
elif isinstance(w, ttk.Label) and w.cget("textvariable"):
|
||||
meldezeile = w
|
||||
pruefe(liste is not None, "der Dialog hat eine Auswahlliste")
|
||||
|
||||
feld.delete(0, "end")
|
||||
feld.insert(0, "2026-002")
|
||||
feld.event_generate("<KeyRelease>")
|
||||
f.update()
|
||||
meldung = f.getvar(meldezeile.cget("textvariable"))
|
||||
pruefe("2×" in meldung or "2x" in meldung, f"die Doppelvergabe wird gemeldet ({meldung.splitlines()[0]})")
|
||||
pruefe(liste.winfo_ismapped() and liste.size() == 2,
|
||||
f"beide Belege stehen zur Auswahl ({liste.size()})")
|
||||
|
||||
vorher = bestand_lesen()
|
||||
knopf.invoke() # ohne Auswahl darf NICHTS passieren
|
||||
f.update()
|
||||
pruefe(f.storno_stand is None, "ohne Auswahl wird kein Storno angelegt")
|
||||
pruefe(bestand_lesen() == vorher, "und nichts geschrieben")
|
||||
|
||||
# bewusst den ZWEITEN Eintrag wählen - welche Datei das ist, sagt die Liste
|
||||
zweiter = liste.get(1)
|
||||
datei_zwei = zweiter.split()[-1]
|
||||
liste.selection_set(1)
|
||||
liste.event_generate("<<ListboxSelect>>")
|
||||
f.update()
|
||||
gewaehlt = f.getvar(meldezeile.cget("textvariable"))
|
||||
pruefe(datei_zwei in gewaehlt,
|
||||
f"die Meldung nennt genau die gewählte Datei ({datei_zwei})")
|
||||
pruefe(liste.get(0).split()[-1] not in gewaehlt,
|
||||
"und NICHT die andere Datei mit derselben Nummer")
|
||||
knopf.invoke()
|
||||
f.update()
|
||||
pruefe(f.storno_stand is not None and f.storno_stand["zu"] == "2026-002",
|
||||
f"nach der Auswahl läuft das Storno ({f.storno_stand})")
|
||||
f._storno_verwerfen("Pruefstand raeumt auf")
|
||||
os.remove(kopie)
|
||||
|
||||
f.destroy()
|
||||
|
||||
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
|
||||
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
|
||||
if not fehler:
|
||||
import shutil as _sh
|
||||
_sh.rmtree(tmp, ignore_errors=True)
|
||||
print("---")
|
||||
print(f"Ordner: {ordner}")
|
||||
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||
sys.exit(1 if fehler else 0)
|
||||
189
pruef_kette.py
Normal file
189
pruef_kette.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: haelt die Kette zusammen, und kann ein anderes Programm sie ordnen?
|
||||
|
||||
Der Ordner ist die Buchhaltung. Ein zweites Programm (Steuerrechner,
|
||||
Steuerjournal) muss aus den PDFs allein erkennen koennen, was zusammengehoert:
|
||||
welche Rechnung wurde storniert, welche ersetzt sie, welches Blatt berichtigt
|
||||
nur die Anschrift.
|
||||
|
||||
Dafuer traegt JEDES Blatt einer Kette dieselbe Vorgangsnummer - die der ersten
|
||||
Rechnung. Gruppieren heisst dann: nach "vorgang" sortieren, fertig. Ohne den
|
||||
Schluessel muesste man sich die Kette ueber storno_zu/ersetzt zusammensuchen.
|
||||
|
||||
Durchgespielt wird der schlimmste Fall: Rechnung -> Storno -> berichtigte
|
||||
Rechnung -> Berichtigung der Anschrift -> noch ein Storno -> zweite berichtigte
|
||||
Rechnung. Alle sechs muessen unter EINEM Schluessel landen und in der Summe
|
||||
genau die letzte gueltige Rechnung ergeben.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
QUELLE = r"C:\claude\rechnungstool"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="kette_pruef_")
|
||||
ordner = os.path.join(tmp, "rechnungen")
|
||||
os.makedirs(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)
|
||||
|
||||
import app as A # noqa: E402
|
||||
import bestand as BE # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
|
||||
# --- Reissleine ---------------------------------------------------------
|
||||
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
|
||||
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
|
||||
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
|
||||
import threading as _th
|
||||
|
||||
|
||||
def _reissleine():
|
||||
import os as _os
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
|
||||
"der nicht beantwortet wird")
|
||||
_os._exit(2)
|
||||
|
||||
|
||||
_wecker = _th.Timer(120, _reissleine)
|
||||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
|
||||
# nicht mit einem UnicodeEncodeError abbrechen.
|
||||
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)
|
||||
|
||||
|
||||
antworten = {"Fertig": False, "Storno wirklich": True,
|
||||
"endgültig erstellen": True,
|
||||
"Berichtigte Rechnung erstellen": True, "Hausnummer": True}
|
||||
|
||||
|
||||
def askyesno(titel, text="", **kw):
|
||||
for schluessel, wert in antworten.items():
|
||||
if schluessel in titel:
|
||||
return wert
|
||||
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
||||
|
||||
|
||||
messagebox.askyesno = askyesno
|
||||
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = \
|
||||
lambda *a, **k: None
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.update()
|
||||
f.cfg["output_dir"] = ordner
|
||||
erste = sorted(f.anzahl_vars)[0]
|
||||
|
||||
|
||||
def kunde(strasse="Musterweg 1", tage="4"):
|
||||
f.k_vorname.set("Anna")
|
||||
f.k_name.set("Beispiel")
|
||||
f.k_strasse.set(strasse)
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.anzahl_vars[erste].set(tage)
|
||||
f.update()
|
||||
|
||||
|
||||
def rechnung_mit(nummer):
|
||||
return next(x for x in BE.rechnungen_im_ordner(ordner)
|
||||
if x.get("rechnungsnummer") == nummer and x.get("art") != "berichtigung")
|
||||
|
||||
|
||||
# 1. die urspruengliche Rechnung
|
||||
kunde(tage="4")
|
||||
f.pdf_erstellen()
|
||||
# 2. + 3. Storno und berichtigte Rechnung (falsche Naechtezahl)
|
||||
f._storno_anlegen(rechnung_mit("2026-001"), ordner)
|
||||
kunde(tage="3")
|
||||
f.pdf_erstellen()
|
||||
# 4. Berichtigung der Anschrift auf der berichtigten Rechnung
|
||||
f._berichtigung_starten(rechnung_mit("2026-003"), ordner)
|
||||
f.k_strasse.set("Richtige Straße 7")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
# 5. + 6. dieselbe Rechnung noch einmal stornieren und neu schreiben
|
||||
f._storno_anlegen(rechnung_mit("2026-003"), ordner)
|
||||
kunde(strasse="Richtige Straße 7", tage="2")
|
||||
f.pdf_erstellen()
|
||||
f.destroy()
|
||||
|
||||
alle = BE.rechnungen_im_ordner(ordner)
|
||||
print("--- was im Ordner liegt ---")
|
||||
for x in sorted(alle, key=lambda d: (d.get("vorgang", ""), d.get("rechnungsnummer", ""))):
|
||||
print(f" {x.get('vorgang',''):10s} {x.get('art',''):12s} "
|
||||
f"{x.get('rechnungsnummer',''):10s} {float(x.get('gesamt') or 0):9.2f} "
|
||||
f"{os.path.basename(x['_pfad'])}")
|
||||
|
||||
pruefe(len(alle) == 6, f"sechs Belege im Ordner ({len(alle)})")
|
||||
pruefe(all(x.get("vorgang") for x in alle), "jeder Beleg trägt eine Vorgangsnummer")
|
||||
|
||||
# Genau das macht das andere Programm: nach "vorgang" gruppieren.
|
||||
ketten = {}
|
||||
for x in alle:
|
||||
ketten.setdefault(x["vorgang"], []).append(x)
|
||||
pruefe(len(ketten) == 1, f"alle sechs hängen an EINEM Vorgang ({list(ketten)})")
|
||||
pruefe(list(ketten) == ["2026-001"],
|
||||
f"der Schlüssel ist die erste Rechnung ({list(ketten)})")
|
||||
|
||||
kette = ketten.get("2026-001", [])
|
||||
arten = sorted(x.get("art") for x in kette)
|
||||
pruefe(arten == ["berichtigung", "korrektur", "korrektur", "rechnung", "storno", "storno"],
|
||||
f"Arten der Kette stimmen ({arten})")
|
||||
|
||||
# Die Summe ueber die Kette muss die letzte gueltige Rechnung sein.
|
||||
letzte = max((x for x in kette if x.get("art") in ("rechnung", "korrektur")),
|
||||
key=lambda d: d["rechnungsnummer"])
|
||||
summe = round(sum(float(x.get("gesamt") or 0) for x in kette), 2)
|
||||
pruefe(abs(summe - float(letzte["gesamt"])) < 0.005,
|
||||
f"Summe der Kette = letzte gültige Rechnung ({summe:.2f} vs {letzte['gesamt']:.2f})")
|
||||
|
||||
# Jeder Beleg zeigt ausserdem einzeln auf seinen Vorgaenger.
|
||||
for x in kette:
|
||||
if x.get("art") == "storno":
|
||||
pruefe(bool(x.get("storno_zu")), f"Storno {x['rechnungsnummer']} nennt seine Rechnung")
|
||||
elif x.get("art") == "korrektur":
|
||||
pruefe(bool(x.get("ersetzt")) and bool(x.get("storno_nummer")),
|
||||
f"Korrektur {x['rechnungsnummer']} nennt Rechnung und Storno")
|
||||
elif x.get("art") == "berichtigung":
|
||||
pruefe(bool(x.get("berichtigt_zu")),
|
||||
f"Berichtigung nennt die Rechnung ({x.get('berichtigt_zu')})")
|
||||
|
||||
# Gegenprobe mit dem Steuerrechner: er darf nur die Geldbelege sehen.
|
||||
sys.path.insert(0, r"C:\claude\steuerrechner")
|
||||
import einnahmen # noqa: E402
|
||||
liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
|
||||
pruefe(len(liste) == 5 and not kaputt,
|
||||
f"Steuerrechner liest 5 Belege, kein Fehler ({len(liste)}, {kaputt})")
|
||||
jahr = round(sum(e.gesamt for e in liste), 2)
|
||||
pruefe(abs(jahr - float(letzte["gesamt"])) < 0.005,
|
||||
f"Jahressumme = letzte gültige Rechnung ({jahr:.2f} vs {letzte['gesamt']:.2f})")
|
||||
|
||||
|
||||
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
|
||||
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
|
||||
if not fehler:
|
||||
import shutil as _sh
|
||||
_sh.rmtree(tmp, ignore_errors=True)
|
||||
print("---")
|
||||
print(f"Ordner: {ordner}")
|
||||
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||
sys.exit(1 if fehler else 0)
|
||||
72
pruef_nummern.py
Normal file
72
pruef_nummern.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: welche Nummern gelten als vergeben, welche als frei?
|
||||
|
||||
Kein Rateraum: legt echte Dateien in einem Wegwerf-Ordner an und fragt
|
||||
bestand.py direkt. Geprueft wird beides - vorhandene_rechnungen() und
|
||||
naechste_freie_nummer(), zusammen sind sie die Nummernvergabe.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, r"C:\claude\rechnungstool")
|
||||
from bestand import naechste_freie_nummer, vorhandene_rechnungen # noqa: E402
|
||||
|
||||
ordner = tempfile.mkdtemp(prefix="rechnungspruef_")
|
||||
dateien = [
|
||||
"Rechnung_2026-013_Meier.pdf", # dieselbe Nummer, Kunde Meier
|
||||
"Rechnung_2026-0135_Schmidt.pdf", # laengere Nummer - darf NICHT treffen
|
||||
"Rechnung_2026-777_Gross.PDF", # Endung GROSS geschrieben - zaehlt trotzdem
|
||||
"Rechnung_2026-013_Meier.txt", # richtiger Name, aber kein PDF
|
||||
"Rechnung_2026-050_Alt.pdf", # und drei Windows-Kopien davon:
|
||||
"Rechnung_2026-050_Alt (1).pdf",
|
||||
"Rechnung_2026-050_Alt - Kopie.pdf",
|
||||
"Rechnung_2026-050 (2).pdf",
|
||||
"Kopie von Rechnung_2026-050_Alt.pdf", # aeltere Windows-Fassung
|
||||
"rechnung_2026-050_klein.pdf", # von Hand klein geschrieben
|
||||
"Rechnung_2026-0501_Neu.pdf", # laengere Nummer - darf NICHT mitzaehlen
|
||||
"Rechnung_2026-014_Meier.pdf", # andere Nummer
|
||||
"Rechnung_2026-020.pdf", # ohne Kundenname
|
||||
"Storno_2026-021_Meier.pdf", # Storno verbraucht die Nummer auch
|
||||
"Notiz_2026-013.txt", # kein PDF
|
||||
]
|
||||
for d in dateien:
|
||||
with open(os.path.join(ordner, d), "w") as f:
|
||||
f.write("x")
|
||||
|
||||
faelle = [
|
||||
("2026-013", ["Rechnung_2026-013_Meier.pdf"], "Nummer vergeben, anderer Kundenname"),
|
||||
("2026-0135", ["Rechnung_2026-0135_Schmidt.pdf"], "laengere Nummer trifft nur sich selbst"),
|
||||
("2026-777", ["Rechnung_2026-777_Gross.PDF"], "Endung GROSS geschrieben wird gefunden"),
|
||||
("2026-020", ["Rechnung_2026-020.pdf"], "ohne Kundenname im Dateinamen"),
|
||||
("2026-021", ["Storno_2026-021_Meier.pdf"], "Nummer ist von einem Storno belegt"),
|
||||
("2026-050", ["Kopie von Rechnung_2026-050_Alt.pdf", "Rechnung_2026-050 (2).pdf",
|
||||
"Rechnung_2026-050_Alt (1).pdf", "Rechnung_2026-050_Alt - Kopie.pdf",
|
||||
"Rechnung_2026-050_Alt.pdf", "rechnung_2026-050_klein.pdf"],
|
||||
"alle Windows-Kopien und Kleinschreibung zaehlen mit"),
|
||||
("2026-0501", ["Rechnung_2026-0501_Neu.pdf"], "laengere Nummer bleibt getrennt"),
|
||||
("2026-099", [], "freie Nummer"),
|
||||
("2026-01", [], "Teilnummer darf nicht auf 2026-013 anspringen"),
|
||||
]
|
||||
|
||||
fehler = 0
|
||||
for nummer, erwartet, was in faelle:
|
||||
ist = [os.path.basename(p) for p in vorhandene_rechnungen(ordner, nummer)]
|
||||
ok = ist == erwartet
|
||||
fehler += 0 if ok else 1
|
||||
print((" OK " if ok else "FEHLER") + f" | {nummer} -> {ist} ({was})")
|
||||
|
||||
# leerer / nicht vorhandener Ordner darf nicht knallen
|
||||
if vorhandene_rechnungen(os.path.join(ordner, "gibtsnicht"), "2026-013") != []:
|
||||
print("FEHLER | fehlender Ordner")
|
||||
fehler += 1
|
||||
else:
|
||||
print(" OK | fehlender Ordner gibt leere Liste")
|
||||
|
||||
# Wegwerf-Ordner nur bei Fehlern behalten
|
||||
if not fehler:
|
||||
import shutil as _sh
|
||||
_sh.rmtree(ordner, ignore_errors=True)
|
||||
print("---")
|
||||
print("alles gruen" if fehler == 0 else f"{fehler} Fehler")
|
||||
sys.exit(1 if fehler else 0)
|
||||
437
pruef_storno.py
Normal file
437
pruef_storno.py
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand fuer die Storno-Kette - kompletter Durchlauf in einem Wegwerf-Ordner.
|
||||
|
||||
Geprueft wird an ECHTEN PDFs, die das Programm selbst schreibt:
|
||||
1. Rechnung 2026-001 erstellen
|
||||
2. sie stornieren -> Storno 2026-002 mit negativen Betraegen
|
||||
3. berichtigte Rechnung 2026-003 mit geaenderter Menge
|
||||
4. der Steuerrechner liest den Ordner: 001 + 002 + 003 muss 003 ergeben
|
||||
5. Rueckweg: Storno anlegen und verwerfen -> PDF weg, Zaehler zurueck, Original da
|
||||
|
||||
Die echte config.json wird NICHT angefasst - es wird mit einer Kopie im
|
||||
Wegwerf-Ordner gearbeitet.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
QUELLE = r"C:\claude\rechnungstool"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="storno_pruef_")
|
||||
ordner = os.path.join(tmp, "rechnungen")
|
||||
os.makedirs(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 bestand as BE # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
|
||||
# --- Reissleine ---------------------------------------------------------
|
||||
# Ein Pruefstand darf NIE haengen. Bleibt er in einem Dialog stehen (etwa weil
|
||||
# eine Aenderung eine neue Frage eingefuehrt hat), bricht er nach zwei Minuten
|
||||
# von selbst ab, statt ein Fenster offen zu lassen, auf das niemand klickt.
|
||||
import threading as _th
|
||||
|
||||
|
||||
def _reissleine():
|
||||
import os as _os
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
|
||||
"der nicht beantwortet wird")
|
||||
_os._exit(2)
|
||||
|
||||
|
||||
_wecker = _th.Timer(120, _reissleine)
|
||||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
# Die Windows-Konsole ist cp1252 - ein Sonderzeichen im Text darf den Lauf
|
||||
# nicht mit einem UnicodeEncodeError abbrechen.
|
||||
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)
|
||||
|
||||
|
||||
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
|
||||
antworten = {}
|
||||
gefragt = []
|
||||
|
||||
|
||||
def askyesno(titel, text, **kw):
|
||||
gefragt.append(titel)
|
||||
for schlue, wert in antworten.items():
|
||||
if schlue in titel:
|
||||
return wert
|
||||
# Kein stilles "Nein": ein nicht angemeldeter Dialog wuerde den Ablauf
|
||||
# abbrechen und der Pruefstand haelt das Ergebnis faelschlich fuer gewollt.
|
||||
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
||||
|
||||
|
||||
antworten["Fertig"] = False # "jetzt oeffnen?" - nie oeffnen im Pruefstand
|
||||
antworten["endgültig erstellen"] = True # die Sicherheitsfrage bejahen
|
||||
antworten["Hausnummer"] = True # "Musterweg 1" hat eine, greift nur im Sonderfall
|
||||
messagebox.askyesno = askyesno
|
||||
messagebox.showinfo = lambda *a, **k: None
|
||||
warnungen = []
|
||||
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
|
||||
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "", a[1] if len(a) > 1 else "")
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.update()
|
||||
f.cfg["output_dir"] = ordner
|
||||
|
||||
# ---- 1. normale Rechnung ------------------------------------------------
|
||||
f.k_vorname.set("Anna")
|
||||
f.k_name.set("Beispiel")
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.r_anreise.set("01.08.2026")
|
||||
f.r_abreise.set("05.08.2026")
|
||||
erste = sorted(f.anzahl_vars)[0]
|
||||
f.anzahl_vars[erste].set("4")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
erste_pdf = os.path.join(ordner, "Rechnung_2026-001_Beispiel.pdf")
|
||||
pruefe(os.path.exists(erste_pdf), "Rechnung 2026-001 geschrieben")
|
||||
|
||||
meta1 = BE.kenndaten_lesen(erste_pdf)
|
||||
pruefe(bool(meta1), "Kenndaten der Rechnung lesbar")
|
||||
pruefe(meta1.get("version") == 3, f"Metadaten Version 3 (ist {meta1.get('version')})")
|
||||
pruefe(meta1.get("art") == "rechnung", "art = rechnung")
|
||||
pruefe(len(meta1.get("positionen") or []) == 1, "Positionen stehen in den Kenndaten")
|
||||
pruefe(bool(meta1.get("kunde", {}).get("plzort")), "Anschrift steht in den Kenndaten")
|
||||
brutto1 = float(meta1["zwischensumme"])
|
||||
gesamt1 = float(meta1["gesamt"])
|
||||
print(f" Rechnung 001: brutto {brutto1:.2f}, gesamt {gesamt1:.2f}")
|
||||
|
||||
# ---- 2. stornieren ------------------------------------------------------
|
||||
offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
|
||||
pruefe(len(offen) == 1, "eine stornierbare Rechnung gefunden")
|
||||
f._storno_anlegen(offen[0], ordner)
|
||||
pruefe(f.storno_stand is not None, "Storno liegt im Zwischenspeicher")
|
||||
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
|
||||
pruefe(os.path.exists(storno_pdf), f"Storno-PDF geschrieben ({os.path.basename(storno_pdf)})")
|
||||
|
||||
meta2 = BE.kenndaten_lesen(storno_pdf)
|
||||
pruefe(meta2.get("art") == "storno", "art = storno")
|
||||
pruefe(meta2.get("storno_zu") == "2026-001", "storno_zu zeigt auf 2026-001")
|
||||
import re as _re
|
||||
pruefe(bool(_re.fullmatch(r"\d{2}\.\d{2}\.\d{4}", str(meta2.get("storno_datum") or "")))
|
||||
and meta2.get("storno_datum") == meta1.get("datum"),
|
||||
f"Datum der alten Rechnung steht drin ({meta2.get('storno_datum')!r})")
|
||||
pruefe(float(meta2["zwischensumme"]) == -brutto1, f"Zwischensumme negativ ({meta2['zwischensumme']})")
|
||||
pruefe(float(meta2["gesamt"]) == -gesamt1, f"Gesamt negativ ({meta2['gesamt']})")
|
||||
pruefe(bool(meta2["ust_bloecke"])
|
||||
and all(b["netto"] < 0 and b["ust"] < 0 for b in meta2["ust_bloecke"]),
|
||||
f"alle USt-Bloecke echt negativ ({meta2['ust_bloecke']})")
|
||||
pruefe(f.r_nummer.get() == "2026-003", f"Formular steht auf 2026-003 (ist {f.r_nummer.get()})")
|
||||
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
|
||||
pruefe(f.k_plzort.get() == "12345 Musterstadt", "Anschrift ins Formular uebernommen")
|
||||
pruefe(f.anzahl_vars[erste].get() == "4.0", f"Menge uebernommen (ist {f.anzahl_vars[erste].get()})")
|
||||
|
||||
# ---- 3. berichtigte Rechnung -------------------------------------------
|
||||
antworten["Storno wirklich"] = True
|
||||
f.anzahl_vars[erste].set("3") # das war der Fehler: 4 statt 3 Naechte
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
dritte = os.path.join(ordner, "Rechnung_2026-003_Beispiel.pdf")
|
||||
pruefe(os.path.exists(dritte), "berichtigte Rechnung 2026-003 geschrieben")
|
||||
pruefe(f.storno_stand is None, "Zwischenspeicher nach dem Erstellen leer")
|
||||
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
|
||||
meta3 = BE.kenndaten_lesen(dritte)
|
||||
pruefe(meta3.get("art") == "korrektur", "art = korrektur")
|
||||
pruefe(meta3.get("ersetzt") == "2026-001", "ersetzt zeigt auf 2026-001")
|
||||
pruefe(meta3.get("storno_nummer") == "2026-002", "storno_nummer zeigt auf 2026-002")
|
||||
pruefe(os.path.exists(erste_pdf), "die urspruengliche Rechnung liegt unangetastet da")
|
||||
gesamt3 = float(meta3["gesamt"])
|
||||
print(f" Rechnung 003: gesamt {gesamt3:.2f}")
|
||||
|
||||
# ---- 4. was sieht der Steuerrechner? ------------------------------------
|
||||
sys.path.insert(0, r"C:\claude\steuerrechner")
|
||||
import einnahmen # noqa: E402
|
||||
liste, kaputt = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
|
||||
pruefe(len(liste) == 3, f"Steuerrechner liest 3 Belege (liest {len(liste)})")
|
||||
pruefe(not kaputt, f"kein Beleg uebersprungen ({kaputt})")
|
||||
pruefe(not any(e.unsicher for e in liste),
|
||||
"kein Beleg als unsicher markiert")
|
||||
summe = round(sum(e.gesamt for e in liste), 2)
|
||||
netto = round(sum(e.netto for e in liste), 2)
|
||||
ust = round(sum(e.ust for e in liste), 2)
|
||||
pruefe(abs(summe - gesamt3) < 0.005,
|
||||
f"Jahressumme = nur die berichtigte Rechnung ({summe:.2f} vs {gesamt3:.2f})")
|
||||
print(f" Summe {summe:.2f} | Netto {netto:.2f} | USt {ust:.2f}")
|
||||
|
||||
# ---- 5. Rueckweg: Storno anlegen und verwerfen --------------------------
|
||||
antworten["Storno wirklich"] = False
|
||||
offen = [d for d in BE.rechnungen_im_ordner(ordner)
|
||||
if d.get("art") == "korrektur"]
|
||||
f._storno_anlegen(offen[0], ordner)
|
||||
storno2 = f.storno_stand["pfad"]
|
||||
zaehler_mit_storno = f.cfg["rechnung_zaehler"]
|
||||
pruefe(os.path.exists(storno2), "zweites Storno geschrieben")
|
||||
f.anzahl_vars[erste].set("2")
|
||||
f.update()
|
||||
f.pdf_erstellen() # -> Frage wird mit Nein beantwortet
|
||||
pruefe(not os.path.exists(storno2), "verworfenes Storno-PDF ist geloescht")
|
||||
pruefe(f.storno_stand is None, "Zwischenspeicher geleert")
|
||||
pruefe(f.cfg["rechnung_zaehler"] == zaehler_mit_storno - 1,
|
||||
f"Zaehler zurueckgesetzt ({f.cfg['rechnung_zaehler']} statt {zaehler_mit_storno})")
|
||||
pruefe(os.path.exists(dritte), "die Rechnung 2026-003 steht unveraendert da")
|
||||
pruefe(len([n for n in os.listdir(ordner) if n.endswith('.pdf')]) == 3,
|
||||
"es liegen wieder genau 3 PDFs im Ordner")
|
||||
|
||||
# ---- 6. Suche im Storno-Dialog (Nummer eintippen, nicht auswaehlen) -----
|
||||
import tkinter as tk # noqa: E402
|
||||
from tkinter import ttk # noqa: E402
|
||||
|
||||
f.dlg_storno()
|
||||
f.update()
|
||||
dialog = [w for w in f.winfo_children() if isinstance(w, tk.Toplevel)][-1]
|
||||
feld = None
|
||||
schild = None
|
||||
for rahmen in dialog.winfo_children():
|
||||
for w in rahmen.winfo_children():
|
||||
if isinstance(w, ttk.Entry):
|
||||
feld = w
|
||||
elif isinstance(w, ttk.Label) and w.cget("textvariable"):
|
||||
schild = w
|
||||
pruefe(feld is not None and schild is not None, "Eingabefeld und Meldezeile im Dialog da")
|
||||
|
||||
|
||||
def tippe(text):
|
||||
feld.delete(0, "end")
|
||||
feld.insert(0, text)
|
||||
feld.event_generate("<KeyRelease>")
|
||||
f.update()
|
||||
return f.getvar(schild.cget("textvariable"))
|
||||
|
||||
|
||||
meldung = tippe("2026-003")
|
||||
pruefe("Der Storno lautet" in meldung, f"gueltige Nummer gefunden: {meldung.splitlines()[0]}")
|
||||
meldung = tippe("2026-3")
|
||||
pruefe("2026-003" in meldung, "kurze Schreibweise 2026-3 findet 2026-003")
|
||||
meldung = tippe("2026-001")
|
||||
pruefe("storniert" in meldung and "zweites Mal" in meldung,
|
||||
f"schon stornierte Rechnung wird abgelehnt ({meldung})")
|
||||
meldung = tippe("2026-002")
|
||||
pruefe("selbst eine Stornorechnung" in meldung, "Storno selbst ist nicht stornierbar")
|
||||
meldung = tippe("2026-999")
|
||||
pruefe("keine Rechnung" in meldung, "unbekannte Nummer wird abgelehnt")
|
||||
meldung = tippe("")
|
||||
pruefe("Nummer eintragen" in meldung, "leeres Feld sagt was zu tun ist")
|
||||
dialog.destroy()
|
||||
|
||||
# ---- 7. Pflichtfelder: ohne Anschrift wird nichts geschrieben -----------
|
||||
vorher = sorted(os.listdir(ordner))
|
||||
warnungen.clear()
|
||||
f.k_strasse.set("")
|
||||
f.k_plzort.set("")
|
||||
f.anzahl_vars[erste].set("2")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
pruefe(warnungen and "Kundenangaben fehlen" in warnungen[0],
|
||||
f"fehlende Anschrift wird gemeldet ({warnungen})")
|
||||
pruefe(sorted(os.listdir(ordner)) == vorher, "ohne Anschrift wurde keine PDF geschrieben")
|
||||
|
||||
warnungen.clear()
|
||||
f.k_vorname.set("")
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
pruefe(sorted(os.listdir(ordner)) == vorher, "auch ohne Vornamen wird nichts geschrieben")
|
||||
|
||||
f.k_vorname.set("Anna")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
pruefe(len(os.listdir(ordner)) == len(vorher) + 1,
|
||||
"mit allen Pflichtfeldern entsteht die PDF")
|
||||
|
||||
# ---- 8. Was zaehlt als "richtig ausgefuellt"? ---------------------------
|
||||
def kunde_setzen(vor, nach, strasse, ort):
|
||||
f.k_vorname.set(vor)
|
||||
f.k_name.set(nach)
|
||||
f.k_strasse.set(strasse)
|
||||
f.k_plzort.set(ort)
|
||||
return f.kunde_pruefen()
|
||||
|
||||
|
||||
faelle_schlecht = [
|
||||
(" ", "Meier", "Musterweg 1", "12345 Musterstadt", "Vorname nur Leerzeichen"),
|
||||
("Anna", " ", "Musterweg 1", "12345 Musterstadt", "Nachname nur Leerzeichen"),
|
||||
("Anna", "x", "Musterweg 1", "12345 Musterstadt", "Nachname ein Buchstabe"),
|
||||
("Anna", "123", "Musterweg 1", "12345 Musterstadt", "Nachname nur Ziffern"),
|
||||
("Anna", "Meier", "-", "12345 Musterstadt", "Strasse nur ein Strich"),
|
||||
("Anna", "Meier", "Musterweg 1", "12345", "nur PLZ, kein Ort"),
|
||||
("Anna", "Meier", "Musterweg 1", "Musterstadt", "nur Ort, keine PLZ"),
|
||||
("Anna", "Meier", "Musterweg 1", " ", "Ort nur Leerzeichen"),
|
||||
]
|
||||
for vor, nach, strasse, ort, was in faelle_schlecht:
|
||||
pruefe(bool(kunde_setzen(vor, nach, strasse, ort)), f"abgelehnt: {was}")
|
||||
|
||||
faelle_gut = [
|
||||
("Anna", "Meier", "Musterweg 1", "12345 Musterstadt", "normaler Fall"),
|
||||
("Li", "Ng", "Hauptstr. 2", "04416 Markkleeberg", "kurze Namen"),
|
||||
("Jan", "de Vries", "Kerkstraat 12", "1234 AB Amsterdam", "Niederlande, 4 Ziffern"),
|
||||
("Hans", "Huber", "Seeweg 3", "5020 Salzburg", "Oesterreich, 4 Ziffern"),
|
||||
("Anna", "Camping Nord GmbH", "Am Deich 7a", "12345 Musterstadt", "Firma"),
|
||||
]
|
||||
for vor, nach, strasse, ort, was in faelle_gut:
|
||||
m = kunde_setzen(vor, nach, strasse, ort)
|
||||
pruefe(not m, f"angenommen: {was} {m if m else ''}")
|
||||
|
||||
# Saeubern: Rand- und Mehrfachleerzeichen verschwinden aus den Feldern
|
||||
kunde_setzen(" Anna ", " von Meier ", " Musterweg 1 ", " 12345 Musterstadt ")
|
||||
pruefe(f.k_vorname.get() == "Anna", f"Vorname getrimmt ({f.k_vorname.get()!r})")
|
||||
pruefe(f.k_name.get() == "von Meier", f"Doppelleerzeichen weg ({f.k_name.get()!r})")
|
||||
pruefe(f.k_plzort.get() == "12345 Musterstadt", f"Ort gesaeubert ({f.k_plzort.get()!r})")
|
||||
|
||||
# ---- 9. Strasse ohne Hausnummer: fragen, nicht sperren ------------------
|
||||
kunde_setzen("Anna", "Beispiel", "Am Seeufer", "12345 Musterstadt")
|
||||
pruefe(not f.kunde_pruefen(), "Anschrift ohne Hausnummer ist kein Mangel")
|
||||
vorher = sorted(os.listdir(ordner))
|
||||
gefragt.clear()
|
||||
antworten["Hausnummer"] = False # Nutzer sagt: nein, doch nicht
|
||||
f.anzahl_vars[erste].set("2")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
pruefe(any("Hausnummer" in t for t in gefragt), f"nach der Hausnummer wird gefragt ({gefragt})")
|
||||
pruefe(sorted(os.listdir(ordner)) == vorher, "bei Nein wird nichts geschrieben")
|
||||
antworten["Hausnummer"] = True # Nutzer sagt: ja, so ist es richtig
|
||||
f.pdf_erstellen()
|
||||
pruefe(len(os.listdir(ordner)) == len(vorher) + 1, "bei Ja entsteht die PDF trotzdem")
|
||||
|
||||
# ---- 10. Altrechnung im Muster "alles in der Vornamen-Zeile" -----------
|
||||
import berechnung as B # noqa: E402
|
||||
import pdf_renderer as P # noqa: E402
|
||||
|
||||
def alt_rechnung(name, dateiname, kunde_zeilen):
|
||||
"""Schreibt eine Rechnung wie die alte Fassung: ohne kunde/positionen in den Kenndaten."""
|
||||
rr = B.berechne(f.cfg["katalog"], {erste: {"anzahl": 3, "personen": 0}}, f.cfg)
|
||||
# Nummer aus dem Dateinamen, damit Kenndaten und Datei zusammenpassen
|
||||
nummer = dateiname.split("_")[1].replace(".pdf", "")
|
||||
kopf = {"rechnungsnummer": nummer, "datum": "12.08.2026", "anreise": "", "abreise": "",
|
||||
"naechte": "", "kunde_zeilen": kunde_zeilen, "nachname": "", "vorname": name}
|
||||
ziel = os.path.join(ordner, dateiname)
|
||||
P.render_rechnung(ziel, f.cfg, rr, kopf)
|
||||
return ziel
|
||||
|
||||
|
||||
# a) mit Anschrift im Text, Name komplett in der Vornamen-Zeile
|
||||
alt = alt_rechnung("Andres Schäfer", "Rechnung_2026-900.pdf",
|
||||
["Herr", "Andres Schäfer", "c/o Stellplatz 12",
|
||||
"Bornaer Chaussee 36", "04416 Markkleeberg"])
|
||||
meta_alt = BE.kenndaten_lesen(alt)
|
||||
pruefe(not meta_alt.get("kunde"), "Altrechnung hat keine Kundendaten in den Kenndaten")
|
||||
kunde, woher = BE.kundendaten_ermitteln(meta_alt)
|
||||
pruefe(kunde["vorname"] == "Andres", f"Vorname aufgeteilt ({kunde['vorname']!r})")
|
||||
pruefe(kunde["nachname"] == "Schäfer", f"Nachname aufgeteilt ({kunde['nachname']!r})")
|
||||
pruefe(kunde.get("anrede") == "Herr", f"Anrede aus dem Text ({kunde.get('anrede')!r})")
|
||||
pruefe(kunde.get("strasse") == "Bornaer Chaussee 36", f"Straße aus dem Text ({kunde.get('strasse')!r})")
|
||||
pruefe(kunde.get("plzort") == "04416 Markkleeberg", f"PLZ/Ort aus dem Text ({kunde.get('plzort')!r})")
|
||||
pruefe(kunde.get("zusatz") == "c/o Stellplatz 12", f"Zusatz aus dem Text ({kunde.get('zusatz')!r})")
|
||||
|
||||
# b) sein echter Fall: NUR der Name, keine Anschrift
|
||||
alt2 = alt_rechnung("Andres Schäfer", "Rechnung_2026-901.pdf", ["", "Andres Schäfer", "", "", ""])
|
||||
kunde2, _ = BE.kundendaten_ermitteln(BE.kenndaten_lesen(alt2))
|
||||
pruefe(kunde2["nachname"] == "Schäfer", "auch ohne Anschrift wird der Name aufgeteilt")
|
||||
pruefe(not kunde2.get("plzort"), "keine Anschrift erfunden")
|
||||
|
||||
# c) Formular fuellen: was fehlt, wird benannt
|
||||
meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt2))
|
||||
pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer",
|
||||
"Formular bekommt Vor- und Nachnamen getrennt")
|
||||
pruefe("Straße" in meldung and "PLZ" in meldung and "Nachname vom Vornamen" in meldung, f"fehlende Anschrift wird benannt ({meldung})")
|
||||
meldung = f._formular_aus_meta(BE.kenndaten_lesen(alt))
|
||||
pruefe(f.k_plzort.get() == "04416 Markkleeberg", "Anschrift landet im Formular")
|
||||
pruefe("aus dem Rechnungstext gelesen" in meldung, f"Herkunft wird gesagt ({meldung})")
|
||||
for name in ("Rechnung_2026-900.pdf", "Rechnung_2026-901.pdf"):
|
||||
os.remove(os.path.join(ordner, name))
|
||||
|
||||
# ---- 11. Berichtigung: gleicher Dialog, zweiter Knopf -------------------
|
||||
alt3 = alt_rechnung("Andres Schäfer", "Rechnung_2026-910.pdf", ["", "Andres Schäfer", "", "", ""])
|
||||
meta3 = BE.kenndaten_lesen(alt3)
|
||||
vorher = sorted(os.listdir(ordner))
|
||||
zaehler_vor_berichtigung = f.cfg["rechnung_zaehler"]
|
||||
|
||||
f._berichtigung_starten(meta3, ordner)
|
||||
pruefe(f.berichtigung_stand is not None, "Flag fuer die Berichtigung gesetzt")
|
||||
pruefe(f.r_nummer.get() == "2026-910", f"Rechnung behaelt ihre Nummer ({f.r_nummer.get()})")
|
||||
pruefe(str(f.nummer_entry.cget("state")) == "readonly", "Nummernfeld gesperrt")
|
||||
pruefe(f.k_vorname.get() == "Andres" and f.k_name.get() == "Schäfer",
|
||||
"Name aufgeteilt im Formular")
|
||||
|
||||
# ohne Anschrift wird auch hier nichts geschrieben
|
||||
warnungen.clear()
|
||||
f.anzahl_vars[erste].set("3")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
pruefe(warnungen and "Kundenangaben" in warnungen[0], "Pflichtpruefung gilt auch hier")
|
||||
pruefe(sorted(os.listdir(ordner)) == vorher, "nichts geschrieben")
|
||||
|
||||
# Anschrift ergaenzen und erstellen
|
||||
f.k_strasse.set("Bornaer Chaussee 36")
|
||||
f.k_plzort.set("04416 Markkleeberg")
|
||||
antworten["Berichtigte Rechnung erstellen"] = True
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
import datetime as _dt
|
||||
ziel = os.path.join(ordner, f"Berichtigte Rechnung_{_dt.date.today():%Y-%m-%d}_2026-910.pdf")
|
||||
pruefe(os.path.exists(ziel), f"Blatt geschrieben ({os.path.basename(ziel)})")
|
||||
pruefe(f.berichtigung_stand is None, "Flag nach dem Erstellen wieder weg")
|
||||
pruefe(str(f.nummer_entry.cget("state")) == "normal", "Nummernfeld wieder frei")
|
||||
pruefe(os.path.exists(alt3), "die berichtigte Rechnung liegt unangetastet da")
|
||||
pruefe(f.cfg["rechnung_zaehler"] == zaehler_vor_berichtigung,
|
||||
f"Zaehler unveraendert - die Berichtigung verbraucht keine Nummer "
|
||||
f"({f.cfg['rechnung_zaehler']} vs {zaehler_vor_berichtigung})")
|
||||
|
||||
meta_b = BE.kenndaten_lesen(ziel)
|
||||
pruefe(meta_b.get("art") == "berichtigung", "art = berichtigung")
|
||||
pruefe(meta_b.get("berichtigt_zu") == "2026-910", "Bezug auf die Rechnung")
|
||||
pruefe(float(meta_b.get("gesamt") or 0) == 0.0,
|
||||
f"Kenndaten tragen 0 - keine zweite Einnahme ({meta_b.get('gesamt')})")
|
||||
|
||||
from pypdf import PdfReader as _PR # noqa: E402
|
||||
text = _PR(ziel).pages[0].extract_text()
|
||||
pruefe("Berichtigte Rechnung" in text, "Ueberschrift steht auf dem Blatt")
|
||||
pruefe("2026-910" in text, "Rechnungsnummer steht drauf")
|
||||
pruefe("31 Abs. 5 UStDV" in text and "zus" in text and "tzliche Leistung" in text,
|
||||
"der Schutzsatz steht drauf (keine zusaetzliche Leistung, § 31 Abs. 5)")
|
||||
pruefe("04416 Markkleeberg" in text, "berichtigte Anschrift steht drauf")
|
||||
|
||||
liste3, kaputt3 = einnahmen.scanne(ordner, {"ust_saetze": [7, 19]})
|
||||
pruefe(not any("Berichtigte" in os.path.basename(e.pdf_pfad) for e in liste3),
|
||||
"Steuerrechner zaehlt das Blatt nicht als Einnahme")
|
||||
pruefe(not any("Berichtigte" in os.path.basename(p) for p, _ in kaputt3),
|
||||
f"und meldet es nicht als Fehler ({kaputt3})")
|
||||
|
||||
for name in ("Rechnung_2026-910.pdf", os.path.basename(ziel)):
|
||||
os.remove(os.path.join(ordner, name))
|
||||
|
||||
f.destroy()
|
||||
|
||||
# Wegwerf-Ordner nur bei Fehlern behalten - sonst laeuft das Temp-Verzeichnis
|
||||
# voll. Ein Pruefstand, der 400 Ordner hinterlaesst, ist selbst ein Problem.
|
||||
if not fehler:
|
||||
import shutil as _sh
|
||||
_sh.rmtree(tmp, ignore_errors=True)
|
||||
print("---")
|
||||
print(f"Ordner: {ordner}")
|
||||
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||
sys.exit(1 if fehler else 0)
|
||||
61
storno.py
Normal file
61
storno.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Den spiegelbildlichen Storno zu einer Rechnung rechnen."""
|
||||
|
||||
import berechnung
|
||||
|
||||
|
||||
def storno_rechnung_bauen(meta, cfg):
|
||||
"""Baut aus den Kenndaten der alten Rechnung den spiegelbildlichen Storno.
|
||||
|
||||
Alle Betraege negativ. Je USt-Satz eine Zeile - die einzelnen Leistungen der
|
||||
alten Rechnung werden NICHT wiederholt: aufgehoben wird der Beleg als Ganzes,
|
||||
und die Positionen stehen in aelteren PDFs gar nicht in den Kenndaten.
|
||||
"""
|
||||
r = berechnung.Rechnung()
|
||||
nummer = meta.get("rechnungsnummer", "")
|
||||
bloecke = meta.get("ust_bloecke") or []
|
||||
if not bloecke:
|
||||
# v1-PDF ohne Aufteilung: alles auf den ermaessigten Satz, sonst faellt
|
||||
# die USt unter den Tisch.
|
||||
brutto = float(meta.get("zwischensumme") or 0)
|
||||
# Der erste Satz OHNE "extra_blatt" - also ein echter USt-Satz. Nimmt man
|
||||
# blind den ersten aus der Liste, rechnet ein umsortierter Katalog die
|
||||
# Steuer mit 19 % oder der Beherbergungssteuer heraus.
|
||||
echte = [s for s in cfg.get("steuersaetze", []) if not s.get("extra_blatt")]
|
||||
satz = int((echte or [{"satz": 7}])[0].get("satz", 7))
|
||||
netto = berechnung.runde(brutto * 100 / (100 + satz)) if satz else brutto
|
||||
bloecke = [{"satz": satz, "netto": netto, "ust": berechnung.runde(brutto - netto),
|
||||
"brutto": brutto}]
|
||||
|
||||
saetze = {int(s.get("satz", -1)): s for s in cfg.get("steuersaetze", [])}
|
||||
for i, b in enumerate(bloecke, start=1):
|
||||
satz = int(float(b.get("satz", 0) or 0))
|
||||
brutto = -abs(berechnung.runde(b.get("brutto") or 0))
|
||||
netto = -abs(berechnung.runde(b.get("netto") or 0))
|
||||
ust = -abs(berechnung.runde(b.get("ust") or 0))
|
||||
r.zeilen.append(berechnung.PositionsZeile(
|
||||
nr=i, leistung=f"Storno der Rechnung {nummer} ({satz} % USt)",
|
||||
satz=satz, preis=brutto, anzahl=1, personen=0, pro_person=False,
|
||||
brutto=brutto, netto=netto))
|
||||
info = saetze.get(satz, {})
|
||||
r.bloecke.append(berechnung.Steuerblock(
|
||||
satz=satz, netto=netto, ust=ust,
|
||||
bezeichnung=str(info.get("bezeichnung", "") or "Mehrwertsteuer"),
|
||||
extra_blatt=bool(info.get("extra_blatt", False))))
|
||||
|
||||
r.netto_gesamt = berechnung.runde(sum(b.netto for b in r.bloecke))
|
||||
r.ust_gesamt = berechnung.runde(sum(b.ust for b in r.bloecke))
|
||||
r.brutto = berechnung.runde(r.netto_gesamt + r.ust_gesamt)
|
||||
|
||||
# Beherbergungssteuer wird mitstorniert, sonst stimmt die Meldung ans Amt nicht.
|
||||
# Die Bezeichnung kommt aus dem Satz mit "extra_blatt" - genau dem, aus dem
|
||||
# berechne() den Aufschlag baut.
|
||||
steuer = berechnung.runde(meta.get("steuer_betrag") or 0)
|
||||
if steuer:
|
||||
extra = next((s for s in cfg.get("steuersaetze", []) if s.get("extra_blatt")), {})
|
||||
r.aufschlaege.append(berechnung.Aufschlag(
|
||||
satz=float(meta.get("steuer_satz") or extra.get("satz", 0) or 0),
|
||||
bezeichnung=str(extra.get("bezeichnung", "") or "Beherbergungssteuer"),
|
||||
basis=r.brutto, betrag=-abs(steuer), extra_blatt=True))
|
||||
r.gesamt = berechnung.runde(r.brutto + sum(a.betrag for a in r.aufschlaege))
|
||||
return r
|
||||
123
theme.py
Normal file
123
theme.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Farben nach dem Windows-Theme (hell/dunkel) - inklusive Titelleiste.
|
||||
|
||||
Native tk-Menues und -Popups lassen sich unter Windows nicht einfaerben, darum
|
||||
baut das Programm Menueleiste und Dropdowns selbst; hier stehen nur die Farben.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
|
||||
def windows_dark_mode():
|
||||
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
|
||||
if not sys.platform.startswith("win"):
|
||||
return False
|
||||
try:
|
||||
import winreg
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize") as k:
|
||||
return winreg.QueryValueEx(k, "AppsUseLightTheme")[0] == 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class ThemeMixin:
|
||||
"""Farben setzen. Erwartet ein tk.Tk als Basis."""
|
||||
|
||||
# ----------------------------------------------------------------- Theme
|
||||
|
||||
def _apply_theme(self):
|
||||
"""Farben passend zum Windows-Theme (hell/dunkel) setzen."""
|
||||
dark = windows_dark_mode()
|
||||
self._dark_mode = dark
|
||||
if dark:
|
||||
self.col_bg = "#23272e" # Fenster-Hintergrund
|
||||
self.col_fg = "#e6e6e6" # Schrift
|
||||
self.col_field = "#2f343c" # Eingabefelder
|
||||
self.col_sel = "#3a4a44" # aktiv/hover
|
||||
self.col_accent = "#5fb98f" # Akzent (Gruen, hell)
|
||||
self.col_line = "#3a3f47"
|
||||
self.col_scroll = "#4a505a" # Scroll-Griff
|
||||
self.col_muted = "#8a929c" # gedaempfte Schrift (z.B. "powered by")
|
||||
else:
|
||||
self.col_bg = "#f3f3f3"
|
||||
self.col_fg = "#1a1a1a"
|
||||
self.col_field = "#ffffff"
|
||||
self.col_sel = "#cfe3d8"
|
||||
self.col_accent = "#1f5c3d"
|
||||
self.col_line = "#cccccc"
|
||||
self.col_scroll = "#b8b8b8"
|
||||
self.col_muted = "#777777" # gedaempfte Schrift (z.B. "powered by")
|
||||
|
||||
self.configure(bg=self.col_bg)
|
||||
style = ttk.Style(self)
|
||||
try:
|
||||
style.theme_use("clam")
|
||||
except tk.TclError:
|
||||
pass
|
||||
bg, fg, field, sel, accent = (self.col_bg, self.col_fg, self.col_field,
|
||||
self.col_sel, self.col_accent)
|
||||
style.configure(".", background=bg, foreground=fg, fieldbackground=field,
|
||||
bordercolor=self.col_line, lightcolor=bg, darkcolor=bg)
|
||||
style.configure("TFrame", background=bg)
|
||||
style.configure("TLabel", background=bg, foreground=fg)
|
||||
style.configure("TLabelframe", background=bg, bordercolor=self.col_line)
|
||||
style.configure("TLabelframe.Label", background=bg, foreground=accent)
|
||||
style.configure("TButton", background=field, foreground=fg, bordercolor=self.col_line)
|
||||
style.map("TButton", background=[("active", sel)], foreground=[("active", fg)])
|
||||
style.configure("Menu.TMenubutton", background=bg, foreground=fg, relief="flat",
|
||||
arrowcolor=bg, padding=(10, 3))
|
||||
style.map("Menu.TMenubutton", background=[("active", sel)], foreground=[("active", fg)])
|
||||
style.configure("TCheckbutton", background=bg, foreground=fg)
|
||||
style.map("TCheckbutton", background=[("active", bg)], foreground=[("active", fg)])
|
||||
style.configure("TEntry", fieldbackground=field, foreground=fg, insertcolor=fg)
|
||||
# Bei state="readonly" zeichnet die Combobox ihren Text als MARKIERUNG,
|
||||
# sobald sie den Fokus hat - ohne diese beiden Farben steht der Wert
|
||||
# unsichtbar im Feld.
|
||||
style.configure("TCombobox", fieldbackground=field, foreground=fg,
|
||||
background=field, arrowcolor=fg,
|
||||
selectbackground=field, selectforeground=fg)
|
||||
style.map("TCombobox", fieldbackground=[("readonly", field)],
|
||||
foreground=[("readonly", fg)], background=[("readonly", field)],
|
||||
selectbackground=[("readonly", field)], selectforeground=[("readonly", fg)])
|
||||
# Die aufgeklappte Liste ist ein klassisches tk-Listbox-Widget
|
||||
self.option_add("*TCombobox*Listbox.background", field)
|
||||
self.option_add("*TCombobox*Listbox.foreground", fg)
|
||||
self.option_add("*TCombobox*Listbox.selectBackground", sel)
|
||||
self.option_add("*TCombobox*Listbox.selectForeground", fg)
|
||||
for sbs in ("Vertical.TScrollbar", "Horizontal.TScrollbar", "TScrollbar"):
|
||||
style.configure(sbs, troughcolor=bg, background=self.col_scroll,
|
||||
bordercolor=bg, lightcolor=bg, darkcolor=bg,
|
||||
arrowcolor=fg, relief="flat", gripcount=0)
|
||||
style.map(sbs, background=[("active", accent)],
|
||||
troughcolor=[("disabled", bg)])
|
||||
style.configure("TSeparator", background=self.col_line)
|
||||
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
|
||||
self.option_add("*background", bg)
|
||||
self.option_add("*foreground", fg)
|
||||
|
||||
def _style_dialog(self, d):
|
||||
"""Toplevel-Dialog an das Theme angleichen."""
|
||||
d.configure(bg=self.col_bg)
|
||||
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
|
||||
d.after(20, lambda: self._dark_titlebar(d))
|
||||
|
||||
def _dark_titlebar(self, win):
|
||||
"""Windows-Titelleiste des Fensters dunkel machen (nur im Dark-Mode)."""
|
||||
if not (sys.platform.startswith("win") and getattr(self, "_dark_mode", False)):
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
win.update_idletasks()
|
||||
hwnd = ctypes.windll.user32.GetParent(win.winfo_id())
|
||||
val = ctypes.c_int(1)
|
||||
for attr in (20, 19): # DWMWA_USE_IMMERSIVE_DARK_MODE (Win11=20, frühes Win10=19)
|
||||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||||
hwnd, attr, ctypes.byref(val), ctypes.sizeof(val))
|
||||
# Frame neu zeichnen lassen (ohne Flackern/Fokusverlust)
|
||||
SWP = 0x0001 | 0x0002 | 0x0004 | 0x0020 # NOSIZE|NOMOVE|NOZORDER|FRAMECHANGED
|
||||
ctypes.windll.user32.SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("[theme] Titelleiste:", e)
|
||||
Loading…
Add table
Add a link
Reference in a new issue