Compare commits
2 commits
5c68278191
...
ecad4b00ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecad4b00ea | ||
|
|
5ef4e0a769 |
15 changed files with 1491 additions and 109 deletions
431
app.py
431
app.py
|
|
@ -27,9 +27,12 @@ from tkinter import ttk, messagebox
|
|||
|
||||
import config
|
||||
import berechnung
|
||||
import gemeinsam
|
||||
import ordnerwahl
|
||||
import pdf_renderer
|
||||
from helfer import ANREDEN, kunde_maengel, parse_date, parse_num, saeubern, safe_filename, ziffern
|
||||
from bestand import naechste_freie_nummer, vorhandene_rechnungen
|
||||
from bestand import (naechste_freie_nummer, rechnungen_im_ordner,
|
||||
vorhandene_rechnungen)
|
||||
from einzelinstanz import (eigene_pid_merken, einzelinstanz_pruefen, fenster_nach_vorn,
|
||||
laufende_pid_lesen)
|
||||
from theme import ThemeMixin
|
||||
|
|
@ -37,6 +40,13 @@ from korrektur import KorrekturMixin
|
|||
from einstellungen import EinstellungenMixin
|
||||
|
||||
|
||||
# Testlauf: kein Erststart-Dialog. Pruefstaende fahren das Fenster fern, ein
|
||||
# modaler Dialog wuerde sie haengen lassen - und in der EXE kommt man an den
|
||||
# Schalter ohnehin nicht heran (Doppelklick uebergibt keine Argumente).
|
||||
# python app.py --test oder CAMPINGHOF_TEST=1
|
||||
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
|
||||
|
||||
|
||||
class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
|
@ -53,6 +63,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
# Zwischenspeicher: geschriebenes Storno, das noch bestaetigt werden muss.
|
||||
# Solange es hier liegt, gehoert zur naechsten PDF die Storno-Kette.
|
||||
self.storno_stand = None
|
||||
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
|
||||
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
|
||||
self.buch = 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
|
||||
|
|
@ -74,6 +87,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
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)
|
||||
# ... und danach nach dem gemeinsamen Ordner fuer das Nummernbuch.
|
||||
self.after(400, self._frage_gemeinsam_falls_noetig)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Menue
|
||||
|
|
@ -92,9 +107,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
"Einstellungen": [
|
||||
("Steuersätze…", self.dlg_steuern),
|
||||
("Leistungskatalog / Preise…", self.dlg_katalog),
|
||||
("Montage-Rechnung…", self.dlg_montage),
|
||||
("Firmendaten, Logo & Texte…", self.dlg_firma),
|
||||
("Rechnungsnummer…", self.dlg_nummer),
|
||||
("Speicherort der Rechnungen…", self.dlg_speicherort),
|
||||
("Gemeinsamer Ordner (Nummernbuch)…", self.dlg_gemeinsam),
|
||||
],
|
||||
"Hilfe": [("Info", self._info)],
|
||||
}
|
||||
|
|
@ -211,8 +228,14 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
("Abreise (TT.MM.JJJJ)", self.r_abreise),
|
||||
]
|
||||
date_vars = (self.r_datum, self.r_anreise, self.r_abreise)
|
||||
self.datum_labels = {}
|
||||
for i, (lbl, var) in enumerate(rows):
|
||||
ttk.Label(info, text=lbl).grid(row=i, column=0, sticky="w", pady=2)
|
||||
beschriftung = ttk.Label(info, text=lbl)
|
||||
beschriftung.grid(row=i, column=0, sticky="w", pady=2)
|
||||
if var is self.r_anreise:
|
||||
self.datum_labels["von"] = beschriftung
|
||||
elif var is self.r_abreise:
|
||||
self.datum_labels["bis"] = beschriftung
|
||||
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:
|
||||
|
|
@ -221,7 +244,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
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)
|
||||
self.naechte_label = ttk.Label(info, text="Nächte")
|
||||
self.naechte_label.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.
|
||||
|
|
@ -245,6 +269,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
e = event.widget
|
||||
s = e.get()
|
||||
d = "".join(c for c in s if c.isdigit())[:8]
|
||||
d = self._datum_ziffern_pruefen(d)
|
||||
if len(d) < 2:
|
||||
out = d
|
||||
elif len(d) == 2:
|
||||
|
|
@ -262,6 +287,29 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
if len(d) == 4: # Tag+Monat fertig, Jahr automatisch -> direkt ins nächste Feld
|
||||
e.tk_focusNext().focus()
|
||||
|
||||
@staticmethod
|
||||
def _datum_ziffern_pruefen(d):
|
||||
"""Unmögliche Tage und Monate gar nicht erst entstehen lassen.
|
||||
|
||||
Den 32. gibt es nicht und den 13. Monat auch nicht - das gehört beim
|
||||
Tippen abgefangen, nicht erst beim Erstellen der Rechnung. Zwei Regeln:
|
||||
|
||||
* Eine 4 bis 9 an erster Stelle kann nur „04.… 09." heißen (kein Tag
|
||||
fängt so an), ebenso eine 2 bis 9 an der ersten Monatsstelle. Die Null
|
||||
wird ergänzt, statt den Anschlag zu schlucken.
|
||||
* Was danach immer noch unmöglich wäre (00, 32.., 13..), fällt weg: die
|
||||
Ziffer erscheint nicht, der Rest bleibt stehen.
|
||||
"""
|
||||
if d and d[0] > "3": # 4 -> 04
|
||||
d = "0" + d
|
||||
if len(d) >= 2 and not 1 <= int(d[:2]) <= 31:
|
||||
d = d[:1] # 35, 00 -> die zweite Ziffer weg
|
||||
if len(d) >= 3 and d[2] > "1": # 3 -> 03 (Monat)
|
||||
d = d[:2] + "0" + d[2:]
|
||||
if len(d) >= 4 and not 1 <= int(d[2:4]) <= 12:
|
||||
d = d[:3] # 13, 00 -> die zweite Ziffer weg
|
||||
return d[:8]
|
||||
|
||||
def _update_naechte(self):
|
||||
a = parse_date(self.r_anreise.get())
|
||||
b = parse_date(self.r_abreise.get())
|
||||
|
|
@ -284,8 +332,22 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
]
|
||||
|
||||
def _build_positionen(self):
|
||||
wrap = ttk.LabelFrame(self, text="Leistungen – Anzahl (und ggf. Personen) eintragen", padding=(6, 4))
|
||||
wrap.pack(side="top", fill="both", expand=True, padx=12, pady=4)
|
||||
# Zwei Sorten Rechnung, zwei Reiter: die Campingrechnung mit dem
|
||||
# Leistungskatalog und die Montage-Rechnung mit der Monatsmiete. Der
|
||||
# Kopf (Kunde, Nummer, Datum, Zeitraum) und die Summenleiste gelten fuer
|
||||
# beide - nur die Mitte wechselt. So tippt niemand den Kunden zweimal.
|
||||
self.blaetter = ttk.Notebook(self)
|
||||
self.blaetter.pack(side="top", fill="both", expand=True, padx=12, pady=4)
|
||||
self.blaetter.bind("<<NotebookTabChanged>>", lambda _e: self._blatt_gewechselt())
|
||||
|
||||
camping = ttk.Frame(self.blaetter, padding=(2, 4))
|
||||
self.blaetter.add(camping, text=" Campingrechnung ")
|
||||
montage_tab = ttk.Frame(self.blaetter, padding=(10, 10))
|
||||
self.blaetter.add(montage_tab, text=" Montage ")
|
||||
self._build_montage(montage_tab)
|
||||
|
||||
wrap = ttk.LabelFrame(camping, text="Leistungen – Anzahl (und ggf. Personen) eintragen", padding=(6, 4))
|
||||
wrap.pack(side="top", fill="both", expand=True)
|
||||
|
||||
canvas = tk.Canvas(wrap, highlightthickness=0, bg=self.col_bg)
|
||||
sb = ttk.Scrollbar(wrap, orient="vertical", command=canvas.yview)
|
||||
|
|
@ -342,6 +404,95 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
g.grid(row=r, column=6, padx=3, pady=2, sticky="ew")
|
||||
self.gesamt_lbls[nr] = g
|
||||
|
||||
# ------------------------------------------------------------ Montage
|
||||
def _build_montage(self, eltern):
|
||||
"""Reiter fuer die Monatsmiete eines Montagearbeiters.
|
||||
|
||||
Eine Zeile, ein Monatspreis, ein Zeitraum - keine Nachtpreise. Der
|
||||
Zeitraum kommt aus denselben beiden Feldern oben rechts, die bei der
|
||||
Campingrechnung An- und Abreise heissen; die Naechte daraus meldet das
|
||||
Steuerjournal spaeter ans Amt.
|
||||
"""
|
||||
self.m_monate = tk.StringVar(value="1")
|
||||
self.m_monate.trace_add("write", lambda *a: self.recalc())
|
||||
|
||||
ttk.Label(eltern, text="Monatsmiete für einen Montagearbeiter",
|
||||
font=("Segoe UI", 11, "bold")).grid(row=0, column=0, columnspan=4, sticky="w")
|
||||
ttk.Label(eltern, wraplength=760, justify="left", foreground=self.col_muted,
|
||||
text="Den Zeitraum trägst du oben rechts ein (dieselben Felder wie Anreise/Abreise). "
|
||||
"Bezeichnung, Steuersatz und Monatspreis stehen unter "
|
||||
"Einstellungen → Montage-Rechnung.").grid(
|
||||
row=1, column=0, columnspan=4, sticky="w", pady=(2, 12))
|
||||
|
||||
ttk.Label(eltern, text="Monate:").grid(row=2, column=0, sticky="w", pady=3)
|
||||
ttk.Entry(eltern, textvariable=self.m_monate, width=8, justify="center").grid(
|
||||
row=2, column=1, sticky="w", padx=(8, 24), pady=3)
|
||||
ttk.Label(eltern, text="Monatspreis (brutto):").grid(row=2, column=2, sticky="w", pady=3)
|
||||
self.m_preis = ttk.Label(eltern, text="", font=("Segoe UI", 10, "bold"))
|
||||
self.m_preis.grid(row=2, column=3, sticky="w", padx=(8, 0), pady=3)
|
||||
|
||||
ttk.Separator(eltern, orient="horizontal").grid(
|
||||
row=3, column=0, columnspan=4, sticky="ew", pady=12)
|
||||
|
||||
ttk.Label(eltern, text="So steht es auf der Rechnung:",
|
||||
font=("Segoe UI", 9, "bold")).grid(row=4, column=0, columnspan=4, sticky="w")
|
||||
self.m_text = ttk.Label(eltern, text="", wraplength=760, justify="left")
|
||||
self.m_text.grid(row=5, column=0, columnspan=4, sticky="w", pady=(4, 10))
|
||||
self.m_zahlen = ttk.Label(eltern, text="", font=("Segoe UI", 10))
|
||||
self.m_zahlen.grid(row=6, column=0, columnspan=4, sticky="w")
|
||||
eltern.columnconfigure(3, weight=1)
|
||||
|
||||
def ist_montage(self):
|
||||
"""Liegt der Montage-Reiter oben?"""
|
||||
try:
|
||||
return self.blaetter.index("current") == 1
|
||||
except (AttributeError, tk.TclError):
|
||||
return False
|
||||
|
||||
def _blatt_gewechselt(self):
|
||||
"""Reiterwechsel: die zwei Datumsfelder heissen hier anders, und gerechnet
|
||||
wird ab jetzt die andere Sorte Rechnung."""
|
||||
if not hasattr(self, "datum_labels") or not hasattr(self, "m_monate"):
|
||||
return
|
||||
if self.ist_montage():
|
||||
self.datum_labels["von"].configure(text="Zeitraum von (TT.MM.JJJJ)")
|
||||
self.datum_labels["bis"].configure(text="Zeitraum bis (TT.MM.JJJJ)")
|
||||
self.naechte_label.configure(text="Nächte im Zeitraum")
|
||||
else:
|
||||
self.datum_labels["von"].configure(text="Anreise (TT.MM.JJJJ)")
|
||||
self.datum_labels["bis"].configure(text="Abreise (TT.MM.JJJJ)")
|
||||
self.naechte_label.configure(text="Nächte")
|
||||
self.recalc()
|
||||
|
||||
def _montage_text(self):
|
||||
"""Die eine Zeile, die auf dem Blatt steht - Wortlaut wie bei Bartl."""
|
||||
m = self.cfg.get("montage", {})
|
||||
satz = int(parse_num(str(m.get("satz", 7))))
|
||||
text = f"{m.get('leistung', 'Miete')} inkl. {satz} % MwSt."
|
||||
von = self.r_anreise.get().strip()
|
||||
bis = self.r_abreise.get().strip()
|
||||
if von and bis:
|
||||
text += f" für den Zeitraum {von} – {bis}"
|
||||
elif von:
|
||||
text += f" ab {von}"
|
||||
monate = parse_num(self.m_monate.get())
|
||||
if monate and abs(monate - 1) > 0.001:
|
||||
anz = berechnung.eur_kurz(monate).replace(",00", "")
|
||||
text += f" ({anz} Monate × {berechnung.eur(float(m.get('preis', 0) or 0))})"
|
||||
return text
|
||||
|
||||
def _montage_katalog(self):
|
||||
"""Ein-Zeilen-Katalog fuer den gemeinsamen Rechenkern.
|
||||
|
||||
Auch die Montage-Rechnung geht durch berechnung.berechne() - es bleibt
|
||||
EINE Rechenstelle, und die Beherbergungssteuer kommt genauso obendrauf
|
||||
wie bei jeder Campingrechnung.
|
||||
"""
|
||||
m = self.cfg.get("montage", {})
|
||||
return [{"nr": 1, "leistung": self._montage_text(),
|
||||
"satz": int(parse_num(str(m.get("satz", 7)))),
|
||||
"preis": float(m.get("preis", 0) or 0), "pro_person": False}]
|
||||
|
||||
# ------------------------------------------------------------- Summen
|
||||
def _build_summen(self):
|
||||
# Statuszeile ganz unten
|
||||
|
|
@ -349,6 +500,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
status.pack(side="bottom", fill="x")
|
||||
ttk.Label(status, text="powered by ravokk", foreground=self.col_muted,
|
||||
font=("Segoe UI", 8)).pack(side="right")
|
||||
# Steht der gemeinsame Ordner? Das gehoert sichtbar hin - ohne ihn kennt
|
||||
# das Programm die Nummern des Steuerjournals nicht.
|
||||
self.buch_lbl = ttk.Label(status, text="", foreground=self.col_muted,
|
||||
font=("Segoe UI", 8))
|
||||
self.buch_lbl.pack(side="left")
|
||||
|
||||
f = ttk.Frame(self, padding=(12, 6, 12, 10))
|
||||
f.pack(side="bottom", fill="x")
|
||||
|
|
@ -367,6 +523,161 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
width=14).pack(side="left", padx=4)
|
||||
ttk.Button(links, text="Zurücksetzen", command=self.reset_formular, width=14).pack(side="left", padx=4)
|
||||
|
||||
# --------------------------------------------------- Gemeinsames Buch
|
||||
def _buch_oeffnen(self):
|
||||
"""Nummernbuch aus dem eingestellten Ordner - oder None."""
|
||||
ordner = str(self.cfg.get("gemeinsam_ordner", "") or "")
|
||||
if not ordner:
|
||||
self.buch = None
|
||||
return
|
||||
taugt, grund = gemeinsam.ordner_taugt(ordner)
|
||||
if not taugt:
|
||||
self.buch = None
|
||||
print("[nummernbuch] nicht erreichbar:", grund)
|
||||
return
|
||||
self.buch = gemeinsam.Nummernbuch(ordner)
|
||||
|
||||
def _frage_gemeinsam_falls_noetig(self):
|
||||
"""Beim ersten Start nach dem gemeinsamen Ordner fragen.
|
||||
|
||||
Wer abbricht, kann weiterarbeiten - dann steht aber in der Fussleiste,
|
||||
dass die Nummern des Steuerjournals unbekannt sind. Beim naechsten Start
|
||||
wird wieder gefragt.
|
||||
"""
|
||||
self._buch_oeffnen()
|
||||
if self.buch is None and not TESTLAUF:
|
||||
self.dlg_gemeinsam(erststart=True)
|
||||
self._buch_anzeigen()
|
||||
|
||||
def dlg_gemeinsam(self, erststart=False):
|
||||
ordner = ordnerwahl.ordner_abfragen(
|
||||
self, "Rechnungstool", str(self.cfg.get("gemeinsam_ordner", "") or ""))
|
||||
if not ordner:
|
||||
if erststart:
|
||||
messagebox.showwarning(
|
||||
"Ohne gemeinsamen Ordner",
|
||||
"Es wurde kein gemeinsamer Ordner gewählt.\n\n"
|
||||
"Das Rechnungstool kennt jetzt nur die Nummern seiner eigenen "
|
||||
"PDFs - eine Buchung, die nur im Steuerjournal steht, kann eine "
|
||||
"Nummer ein zweites Mal bekommen.\n\n"
|
||||
"Nachholen: Einstellungen -> Gemeinsamer Ordner (Nummernbuch).")
|
||||
return
|
||||
self.cfg["gemeinsam_ordner"] = ordner
|
||||
config.save_config(self.cfg)
|
||||
self._buch_oeffnen()
|
||||
self._nachtragen()
|
||||
self._buch_anzeigen()
|
||||
self._neue_nummer_vorschlagen()
|
||||
|
||||
def _buch_anzeigen(self):
|
||||
"""Fussleiste: welcher Ordner, wie viele Nummern - oder die Warnung."""
|
||||
if not hasattr(self, "buch_lbl"):
|
||||
return
|
||||
if self.buch is None:
|
||||
self.buch_lbl.configure(
|
||||
text="kein gemeinsamer Ordner - Nummern des Steuerjournals unbekannt",
|
||||
foreground="#c04040")
|
||||
return
|
||||
try:
|
||||
anzahl = len(self.buch.nummern())
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.buch_lbl.configure(text=f"Nummernbuch nicht lesbar: {e}",
|
||||
foreground="#c04040")
|
||||
return
|
||||
self.buch_lbl.configure(text=f"Nummernbuch: {anzahl} Nummern",
|
||||
foreground=self.col_muted)
|
||||
|
||||
def _belegte_nummern(self):
|
||||
"""Alle im Buch vergebenen Nummern. Faellt das Buch aus, ist die Menge
|
||||
leer - eine Rechnung muss auch dann geschrieben werden koennen."""
|
||||
if self.buch is None:
|
||||
return set()
|
||||
try:
|
||||
return self.buch.nummern()
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("[nummernbuch] Lesen fehlgeschlagen:", e)
|
||||
return set()
|
||||
|
||||
def _ins_buch(self, nummer, kopf, rechnung, pdf_pfad=""):
|
||||
"""Eine geschriebene Rechnung ins gemeinsame Buch eintragen."""
|
||||
if self.buch is None or not nummer:
|
||||
return
|
||||
aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None
|
||||
datum = parse_date(str(kopf.get("datum", "")))
|
||||
try:
|
||||
jahr = int(str(nummer).split("-")[0])
|
||||
except (ValueError, IndexError):
|
||||
jahr = datum.year if datum else datetime.date.today().year
|
||||
try:
|
||||
self.buch.eintragen(
|
||||
nummer,
|
||||
jahr=jahr,
|
||||
datum=datum.isoformat() if datum else "",
|
||||
nachname=str(kopf.get("nachname", "") or ""),
|
||||
vorname=str(kopf.get("vorname", "") or ""),
|
||||
naechte=int(parse_num(str(kopf.get("naechte") or 0)) or 0),
|
||||
zwischensumme=berechnung.runde(rechnung.brutto),
|
||||
steuer_satz=float(aufschlag.satz) if aufschlag else 0.0,
|
||||
steuer_betrag=berechnung.runde(aufschlag.betrag) if aufschlag else 0.0,
|
||||
gesamt=berechnung.runde(rechnung.gesamt),
|
||||
art=str(kopf.get("art", "rechnung") or "rechnung"),
|
||||
vorgang=str(kopf.get("vorgang", "") or ""),
|
||||
storno_zu=str(kopf.get("storno_zu", "") or ""),
|
||||
ersetzt=str(kopf.get("ersetzt", "") or ""),
|
||||
berichtigt_zu=str(kopf.get("berichtigt_zu", "") or ""),
|
||||
vorlage=str(kopf.get("vorlage", "") or ""),
|
||||
quelle="rechnungstool", herkunft="pdf", pdf_pfad=pdf_pfad)
|
||||
except Exception as e: # noqa: BLE001 - die PDF ist schon geschrieben
|
||||
print("[nummernbuch] Eintragen fehlgeschlagen:", e)
|
||||
messagebox.showwarning(
|
||||
"Nummernbuch nicht erreichbar",
|
||||
"Die Rechnung ist geschrieben, konnte aber nicht ins gemeinsame "
|
||||
f"Nummernbuch eingetragen werden:\n{e}\n\n"
|
||||
"Das Steuerjournal sieht sie trotzdem - es liest die PDFs.")
|
||||
|
||||
def _nachtragen(self):
|
||||
"""Alles, was schon im Rechnungsordner liegt, ins Buch schreiben.
|
||||
|
||||
Laeuft beim Einrichten des Ordners einmal durch - sonst faengt das Buch
|
||||
bei null an und kennt die bisherigen Nummern nicht.
|
||||
"""
|
||||
if self.buch is None:
|
||||
return
|
||||
anzahl = 0
|
||||
for meta in rechnungen_im_ordner(config.ensure_output_dir(self.cfg)):
|
||||
nummer = str(meta.get("rechnungsnummer") or "")
|
||||
if not nummer:
|
||||
continue
|
||||
datum = parse_date(str(meta.get("datum", "")))
|
||||
try:
|
||||
jahr = int(nummer.split("-")[0])
|
||||
except (ValueError, IndexError):
|
||||
jahr = datum.year if datum else 0
|
||||
try:
|
||||
self.buch.eintragen(
|
||||
nummer, jahr=jahr,
|
||||
datum=datum.isoformat() if datum else "",
|
||||
nachname=str(meta.get("nachname", "") or ""),
|
||||
vorname=str(meta.get("vorname", "") or ""),
|
||||
naechte=int(parse_num(str(meta.get("naechte") or 0)) or 0),
|
||||
zwischensumme=float(meta.get("zwischensumme") or 0),
|
||||
steuer_satz=float(meta.get("steuer_satz") or 0),
|
||||
steuer_betrag=float(meta.get("steuer_betrag") or 0),
|
||||
gesamt=float(meta.get("gesamt") or 0),
|
||||
art=str(meta.get("art", "rechnung") or "rechnung"),
|
||||
vorgang=str(meta.get("vorgang", "") or ""),
|
||||
storno_zu=str(meta.get("storno_zu", "") or ""),
|
||||
ersetzt=str(meta.get("ersetzt", "") or ""),
|
||||
berichtigt_zu=str(meta.get("berichtigt_zu", "") or ""),
|
||||
vorlage=str(meta.get("vorlage", "") or ""),
|
||||
quelle="rechnungstool", herkunft="pdf")
|
||||
anzahl += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("[nummernbuch] Nachtragen fehlgeschlagen:", e)
|
||||
break
|
||||
if anzahl:
|
||||
print(f"[nummernbuch] {anzahl} vorhandene Rechnungen nachgetragen")
|
||||
|
||||
# --------------------------------------------------------- Berechnung
|
||||
def _eingaben(self):
|
||||
e = {}
|
||||
|
|
@ -374,11 +685,29 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
e[nr] = {"anzahl": parse_num(av.get()), "personen": parse_num(self.personen_vars[nr].get())}
|
||||
return e
|
||||
|
||||
def _recalc_montage(self):
|
||||
"""Montage-Reiter durchrechnen und seine Anzeige nachziehen."""
|
||||
m = self.cfg.get("montage", {})
|
||||
r = berechnung.berechne(self._montage_katalog(),
|
||||
{1: {"anzahl": parse_num(self.m_monate.get()), "personen": 0}},
|
||||
self.cfg)
|
||||
self.m_preis.configure(text=berechnung.eur(float(m.get("preis", 0) or 0)))
|
||||
self.m_text.configure(text=self._montage_text())
|
||||
netto = sum(z.netto for z in r.zeilen)
|
||||
self.m_zahlen.configure(
|
||||
text=f"Netto {berechnung.eur(netto)} "
|
||||
f"USt. {berechnung.eur(r.brutto - netto)} "
|
||||
f"Brutto {berechnung.eur(r.brutto)}")
|
||||
return r
|
||||
|
||||
def recalc(self):
|
||||
r = berechnung.berechne(self.cfg["katalog"], self._eingaben(), self.cfg)
|
||||
aktiv = {z.nr: z.brutto for z in r.zeilen}
|
||||
for nr, lbl in self.gesamt_lbls.items():
|
||||
lbl.configure(text=berechnung.eur_kurz(aktiv.get(nr, 0.0)))
|
||||
if self.ist_montage():
|
||||
r = self._recalc_montage()
|
||||
else:
|
||||
r = berechnung.berechne(self.cfg["katalog"], self._eingaben(), self.cfg)
|
||||
aktiv = {z.nr: z.brutto for z in r.zeilen}
|
||||
for nr, lbl in self.gesamt_lbls.items():
|
||||
lbl.configure(text=berechnung.eur_kurz(aktiv.get(nr, 0.0)))
|
||||
|
||||
# Summenzeilen dynamisch aufbauen: Rechnungsbetrag (brutto) -> darin enthaltene
|
||||
# USt je Satz -> [Beherbergungssteuer obendrauf] -> Gesamtbetrag.
|
||||
|
|
@ -416,7 +745,8 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
"""
|
||||
if self.storno_stand or self.berichtigung_stand:
|
||||
return
|
||||
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg)
|
||||
frei = naechste_freie_nummer(config.ensure_output_dir(self.cfg), self.cfg,
|
||||
self._belegte_nummern())
|
||||
if frei:
|
||||
self.r_nummer.set(frei)
|
||||
|
||||
|
|
@ -434,16 +764,55 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
"strasse": self.k_strasse.get(),
|
||||
"plzort": self.k_plzort.get()})
|
||||
|
||||
def _datumsfelder_pruefen(self):
|
||||
"""Welche Datumsfelder sind unlesbar? Leere Felder sind in Ordnung.
|
||||
|
||||
Geprueft wird mit parse_date - also gegen den echten Kalender. Die
|
||||
Tipp-Hilfe verhindert nur 32. und 13., den 31.02. faengt erst das hier.
|
||||
"""
|
||||
felder = [("Rechnungsdatum", self.r_datum.get().strip())]
|
||||
if self.ist_montage():
|
||||
felder += [("Zeitraum von", self.r_anreise.get().strip()),
|
||||
("Zeitraum bis", self.r_abreise.get().strip())]
|
||||
else:
|
||||
felder += [("Anreise", self.r_anreise.get().strip()),
|
||||
("Abreise", self.r_abreise.get().strip())]
|
||||
return [f"{name}: „{wert}“ ist kein Datum"
|
||||
for name, wert in felder if wert and not parse_date(wert)]
|
||||
|
||||
def pdf_erstellen(self):
|
||||
montage = self.ist_montage()
|
||||
r = self.recalc()
|
||||
if not r.zeilen:
|
||||
messagebox.showwarning("Keine Positionen",
|
||||
"Es wurde keine Leistung mit Anzahl > 0 eingetragen.")
|
||||
messagebox.showwarning(
|
||||
"Keine Positionen",
|
||||
"Bitte die Anzahl der Monate eintragen." if montage else
|
||||
"Es wurde keine Leistung mit Anzahl > 0 eingetragen.")
|
||||
return
|
||||
if montage and not (self.r_anreise.get().strip() and self.r_abreise.get().strip()):
|
||||
# Ohne Zeitraum steht auf dem Blatt nicht, wofuer gezahlt wird - und
|
||||
# das Steuerjournal haette keine Naechte fuer die Meldung ans Amt.
|
||||
messagebox.showwarning(
|
||||
"Zeitraum fehlt",
|
||||
"Bitte oben rechts den Zeitraum eintragen (von / bis).\n\n"
|
||||
"Er steht auf der Rechnung und ist die Grundlage für die "
|
||||
"Übernachtungen, die ans Amt gemeldet werden.")
|
||||
return
|
||||
nummer = self.r_nummer.get().strip()
|
||||
if not nummer:
|
||||
messagebox.showwarning("Rechnungsnummer fehlt", "Bitte eine Rechnungsnummer eingeben.")
|
||||
return
|
||||
# Den 31.02. gibt es auch dann nicht, wenn Tag und Monat einzeln moeglich
|
||||
# sind. Ein falsches Datum auf einer ausgestellten Rechnung laesst sich nur
|
||||
# noch ueber eine Berichtigung geradeziehen - also hier abfangen.
|
||||
kaputt = self._datumsfelder_pruefen()
|
||||
if kaputt:
|
||||
messagebox.showwarning(
|
||||
"Datum nicht lesbar",
|
||||
"So kann die Rechnung nicht geschrieben werden:" + chr(10) + chr(10) +
|
||||
"- " + (chr(10) + "- ").join(kaputt) + chr(10) + chr(10) +
|
||||
"Erwartet wird ein Datum wie 12.08.2026.")
|
||||
return
|
||||
# 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()
|
||||
|
|
@ -531,6 +900,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
|
||||
for z in r.zeilen],
|
||||
}
|
||||
if montage:
|
||||
# Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung
|
||||
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.
|
||||
kopf["vorlage"] = "montage"
|
||||
kopf["montage_text"] = self._montage_text()
|
||||
if stand:
|
||||
kopf["art"] = "korrektur"
|
||||
kopf["vorgang"] = stand.get("vorgang") or stand["zu"]
|
||||
|
|
@ -573,6 +947,17 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
print("[berichtigung] FEHLER:", e)
|
||||
return
|
||||
print(f"[berichtigung] erstellt -> {pfad}")
|
||||
# Das Blatt verbraucht keine Nummer und bucht nichts - im Buch wird
|
||||
# nur der berichtigte Name der Rechnung nachgezogen. Wuerde man hier
|
||||
# den ganzen Eintrag schreiben, stuenden dort die Nullen des Blattes.
|
||||
if self.buch is not None:
|
||||
try:
|
||||
self.buch.eintragen(ber["zu"],
|
||||
nachname=kopf.get("nachname", ""),
|
||||
vorname=kopf.get("vorname", ""),
|
||||
berichtigt_zu=ber["zu"])
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("[nummernbuch] Berichtigung nicht eingetragen:", e)
|
||||
self.berichtigung_stand = None
|
||||
self._storno_anzeigen()
|
||||
if messagebox.askyesno("Fertig",
|
||||
|
|
@ -587,9 +972,14 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
# 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:
|
||||
# Auch das gemeinsame Nummernbuch zaehlt als vergeben: dort stehen die
|
||||
# Nummern des Steuerjournals, zu denen es hier gar keine PDF gibt.
|
||||
im_buch = nummer in self._belegte_nummern()
|
||||
if vorhanden or im_buch:
|
||||
liste = "\n".join("- " + os.path.basename(x) for x in vorhanden)
|
||||
frei = naechste_freie_nummer(out_dir, self.cfg)
|
||||
if im_buch and not vorhanden:
|
||||
liste = "- steht im gemeinsamen Nummernbuch (Steuerjournal)"
|
||||
frei = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
|
||||
if not frei:
|
||||
messagebox.showerror(
|
||||
"Rechnungsnummer schon vergeben",
|
||||
|
|
@ -608,9 +998,14 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
self.r_nummer.set(nummer)
|
||||
kopf["rechnungsnummer"] = nummer
|
||||
|
||||
# Der Dateiname faengt weiter mit "Rechnung_<nummer>" an - daran erkennt
|
||||
# bestand.vorhandene_rechnungen() eine schon vergebene Nummer. Das
|
||||
# "_Montage" haengt nur hinten dran, damit man die Sorte im Ordner sieht.
|
||||
fname = f"Rechnung_{safe_filename(nummer)}"
|
||||
if self.k_name.get().strip():
|
||||
fname += "_" + safe_filename(self.k_name.get().strip())
|
||||
if montage:
|
||||
fname += "_Montage"
|
||||
pfad = os.path.join(out_dir, fname + ".pdf")
|
||||
|
||||
try:
|
||||
|
|
@ -621,6 +1016,11 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
print("[pdf] FEHLER:", e)
|
||||
return
|
||||
|
||||
# Ins gemeinsame Nummernbuch, damit das Steuerjournal die Nummer kennt -
|
||||
# auch wenn es die PDF (noch) nicht eingelesen hat.
|
||||
self._ins_buch(nummer, kopf, r, pfad)
|
||||
self._buch_anzeigen()
|
||||
|
||||
# Rechnungsnummer-Zaehler hochsetzen, falls Format JJJJ-NNN
|
||||
self._zaehler_aus_nummer_speichern(nummer)
|
||||
|
||||
|
|
@ -681,6 +1081,9 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
self.destroy()
|
||||
|
||||
def reset_formular(self):
|
||||
# Der Montage-Reiter hat eine eigene Eingabe - die gehoert mit zurueck.
|
||||
if hasattr(self, "m_monate"):
|
||||
self.m_monate.set("1")
|
||||
# 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:
|
||||
|
|
|
|||
18
bestand.py
18
bestand.py
|
|
@ -168,14 +168,14 @@ def rechnungen_im_ordner(ordner):
|
|||
gefunden.append(daten)
|
||||
gefunden.sort(key=lambda d: str(d.get("rechnungsnummer", "")), reverse=True)
|
||||
return gefunden
|
||||
def naechste_freie_nummer_nach(ordner, cfg, vergeben):
|
||||
def naechste_freie_nummer_nach(ordner, cfg, vergeben, belegt=None):
|
||||
"""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)
|
||||
kandidat = naechste_freie_nummer(ordner, cfg, belegt)
|
||||
if kandidat and kandidat != vergeben:
|
||||
return kandidat
|
||||
ersatz = dict(cfg)
|
||||
|
|
@ -184,15 +184,21 @@ def naechste_freie_nummer_nach(ordner, cfg, vergeben):
|
|||
ersatz["rechnung_jahr"], ersatz["rechnung_zaehler"] = int(jahr), int(z)
|
||||
except (ValueError, AttributeError):
|
||||
return kandidat
|
||||
return naechste_freie_nummer(ordner, ersatz)
|
||||
return naechste_freie_nummer(ordner, ersatz, belegt)
|
||||
|
||||
|
||||
def naechste_freie_nummer(ordner, cfg):
|
||||
"""Erste Nummer JJJJ-NNN, zu der im Ordner noch kein PDF liegt.
|
||||
def naechste_freie_nummer(ordner, cfg, belegt=None):
|
||||
"""Erste Nummer JJJJ-NNN, die weder im Ordner noch im Nummernbuch steht.
|
||||
|
||||
Gezaehlt wird ab dem zuletzt vergebenen Stand aus der config; im Ordner
|
||||
hoeher liegende Nummern werden dabei uebersprungen, weil sie nicht frei sind.
|
||||
|
||||
`belegt` sind zusaetzlich vergebene Nummern - die aus dem gemeinsamen
|
||||
Nummernbuch. Ohne sie kennt das Rechnungstool nur die eigenen PDFs und
|
||||
vergibt eine Nummer aus dem Steuerjournal (Altbestand aus der Excel-Mappe,
|
||||
zu dem es keine PDF gibt) ein zweites Mal.
|
||||
"""
|
||||
belegt = set(belegt or ())
|
||||
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
|
||||
|
|
@ -203,6 +209,6 @@ def naechste_freie_nummer(ordner, cfg):
|
|||
for _ in range(1000):
|
||||
z += 1
|
||||
kandidat = config.format_rechnungsnummer(jahr, z)
|
||||
if not vorhandene_rechnungen(ordner, kandidat):
|
||||
if kandidat not in belegt and not vorhandene_rechnungen(ordner, kandidat):
|
||||
return kandidat
|
||||
return None
|
||||
|
|
|
|||
13
config.py
13
config.py
|
|
@ -96,6 +96,18 @@ DEFAULT_KATALOG = [
|
|||
{"nr": 17, "leistung": "Gasfüllung 11 Kg (zuzüglich Pfand 35 €)", "satz": 19, "preis": 31.5, "pro_person": False},
|
||||
]
|
||||
|
||||
# Die Montage-Rechnung ist eine eigene Sorte Beleg (eigener Reiter im Fenster):
|
||||
# ein Monteur mietet monatsweise, nicht naechteweise. Deshalb steht sie NICHT im
|
||||
# Leistungskatalog - sie hat eine einzige Zeile mit einem Monatspreis, und im
|
||||
# Katalog wuerde sie zwischen den Nachtpreisen nur Unheil stiften.
|
||||
# preis = BRUTTO je Monat (wie im Katalog), die USt wird herausgerechnet.
|
||||
# Die Beherbergungssteuer kommt wie ueberall obendrauf (500 + 5 % = 525).
|
||||
DEFAULT_MONTAGE = {
|
||||
"leistung": "Miete für Montagearbeiter",
|
||||
"satz": 7,
|
||||
"preis": 500.0,
|
||||
}
|
||||
|
||||
# Firmen- und Fusszeilen-Daten – Platzhalter.
|
||||
# Die echten Daten stehen in der config.json neben dem Programm (nicht im Repo)
|
||||
# und werden im Menü "Einstellungen -> Firmendaten & Texte" gepflegt.
|
||||
|
|
@ -127,6 +139,7 @@ DEFAULT_CONFIG = {
|
|||
"firma": DEFAULT_FIRMA,
|
||||
"texte": DEFAULT_TEXTE,
|
||||
"katalog": DEFAULT_KATALOG,
|
||||
"montage": DEFAULT_MONTAGE,
|
||||
# Alle Steuern als EINE benannte Liste.
|
||||
# - extra_blatt = False: USt-Satz, wird auf das Netto der zugeordneten Leistungen gerechnet.
|
||||
# - extra_blatt = True : Aufschlag auf die Endsumme (z.B. Beherbergungssteuer), eigenes Blatt.
|
||||
|
|
|
|||
|
|
@ -132,6 +132,70 @@ class EinstellungenMixin:
|
|||
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_montage(self):
|
||||
"""Bezeichnung, Steuersatz und Monatspreis der Montage-Rechnung.
|
||||
|
||||
Eigener Dialog und NICHT im Leistungskatalog: die Montage-Rechnung ist
|
||||
eine Monatsmiete, im Katalog stuende sie zwischen lauter Nachtpreisen und
|
||||
wuerde auf jeder Campingrechnung mit angeboten.
|
||||
"""
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Montage-Rechnung")
|
||||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
|
||||
m = self.cfg.get("montage", {})
|
||||
v_leistung = tk.StringVar(value=str(m.get("leistung", "")))
|
||||
v_satz = tk.StringVar(value=str(int(parse_num(str(m.get("satz", 7))))))
|
||||
v_preis = tk.StringVar(value=berechnung.eur_kurz(m.get("preis", 0)))
|
||||
|
||||
rahmen = ttk.Frame(d, padding=12)
|
||||
rahmen.pack(fill="both", expand=True)
|
||||
ttk.Label(rahmen, wraplength=520, justify="left",
|
||||
text="So steht die eine Zeile auf dem Montage-Blatt. Der Preis ist ein "
|
||||
"BRUTTO-Monatspreis wie im Leistungskatalog - die Umsatzsteuer wird "
|
||||
"herausgerechnet, die Beherbergungssteuer kommt obendrauf.").grid(
|
||||
row=0, column=0, columnspan=2, sticky="w", pady=(0, 10))
|
||||
|
||||
saetze = [s2 for s2 in self.cfg.get("steuersaetze", []) if not s2.get("extra_blatt")]
|
||||
zeilen = [("Bezeichnung", v_leistung), ("Umsatzsteuer %", v_satz),
|
||||
("Monatspreis brutto (EUR)", v_preis)]
|
||||
for i, (lbl, var) in enumerate(zeilen, start=1):
|
||||
ttk.Label(rahmen, text=lbl + ":").grid(row=i, column=0, sticky="w", pady=3)
|
||||
if var is v_satz:
|
||||
w = ttk.Combobox(rahmen, textvariable=var, width=10, state="readonly",
|
||||
values=[str(x.get("satz")) for x in saetze] or ["7", "19"])
|
||||
else:
|
||||
w = ttk.Entry(rahmen, textvariable=var, width=44)
|
||||
w.grid(row=i, column=1, sticky="ew", padx=(10, 0), pady=3)
|
||||
rahmen.columnconfigure(1, weight=1)
|
||||
|
||||
def speichern():
|
||||
name = v_leistung.get().strip()
|
||||
preis = parse_num(v_preis.get())
|
||||
if not name:
|
||||
messagebox.showwarning("Bezeichnung fehlt",
|
||||
"Bitte eine Bezeichnung eintragen.", parent=d)
|
||||
return
|
||||
if preis <= 0:
|
||||
messagebox.showwarning("Preis fehlt",
|
||||
"Bitte einen Monatspreis groesser als 0 eintragen.",
|
||||
parent=d)
|
||||
return
|
||||
self.cfg["montage"] = {"leistung": name,
|
||||
"satz": int(parse_num(v_satz.get())),
|
||||
"preis": preis}
|
||||
config.save_config(self.cfg)
|
||||
self.recalc()
|
||||
d.destroy()
|
||||
|
||||
knoepfe = ttk.Frame(rahmen)
|
||||
knoepfe.grid(row=len(zeilen) + 1, column=0, columnspan=2, sticky="e", pady=(14, 0))
|
||||
ttk.Button(knoepfe, text="Abbrechen", command=d.destroy).pack(side="right", padx=4)
|
||||
ttk.Button(knoepfe, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
d.bind("<Escape>", lambda _e: d.destroy())
|
||||
|
||||
def dlg_katalog(self):
|
||||
d = tk.Toplevel(self)
|
||||
d.title("Leistungskatalog / Preise")
|
||||
|
|
|
|||
160
gemeinsam.py
Normal file
160
gemeinsam.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal.
|
||||
|
||||
WARUM ES DAS GIBT
|
||||
-----------------
|
||||
Bis hierher wusste jedes Programm nur, was in SEINEM Ordner lag. Das
|
||||
Rechnungstool sucht die naechste freie Nummer in den PDFs im Rechnungsordner -
|
||||
im Steuerjournal stehen aber auch Buchungen, zu denen es gar keine PDF gibt:
|
||||
der Altbestand aus der Excel-Mappe und die frueher von Hand eingetragenen
|
||||
Zeilen. Deren Nummern kannte das Rechnungstool nicht und hat sie ein zweites
|
||||
Mal vergeben. Jede Rechnungsnummer darf es aber nur einmal geben
|
||||
(Paragraph 14 Abs. 4 Nr. 4 UStG).
|
||||
|
||||
Deshalb fuehren beide Programme ab jetzt EIN gemeinsames Buch: eine
|
||||
SQLite-Datei in einem Ordner, den beide kennen. Wer eine Nummer vergibt oder
|
||||
eine Buchung anlegt, traegt sie dort ein; wer eine neue Nummer sucht, schaut
|
||||
zuerst hier nach.
|
||||
|
||||
Die Datei ist ein REGISTER, keine zweite Buchhaltung: die Wahrheit bleiben die
|
||||
Rechnungs-PDFs. Geht das Buch verloren, fehlt keine Rechnung - es muss nur neu
|
||||
gefuellt werden (beide Programme tun das beim naechsten Einlesen von selbst).
|
||||
|
||||
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
|
||||
hinueber - sonst schreiben zwei Fassungen in dieselbe Datei.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
log = logging.getLogger("gemeinsam")
|
||||
|
||||
DATEINAME = "campinghof-nummernbuch.sqlite3"
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS nummern (
|
||||
nummer TEXT PRIMARY KEY, -- '2026-442', immer die volle Nummer
|
||||
jahr INTEGER NOT NULL DEFAULT 0,
|
||||
datum TEXT NOT NULL DEFAULT '', -- ISO JJJJ-MM-TT
|
||||
nachname TEXT NOT NULL DEFAULT '',
|
||||
vorname TEXT NOT NULL DEFAULT '',
|
||||
naechte INTEGER NOT NULL DEFAULT 0,
|
||||
zwischensumme REAL NOT NULL DEFAULT 0,
|
||||
steuer_satz REAL NOT NULL DEFAULT 0,
|
||||
steuer_betrag REAL NOT NULL DEFAULT 0,
|
||||
gesamt REAL NOT NULL DEFAULT 0,
|
||||
art TEXT NOT NULL DEFAULT 'rechnung', -- rechnung|storno|korrektur|berichtigung
|
||||
vorgang TEXT NOT NULL DEFAULT '', -- Klammer um Rechnung+Storno+Korrektur
|
||||
storno_zu TEXT NOT NULL DEFAULT '', -- welche Rechnung hebt dieser Storno auf
|
||||
ersetzt TEXT NOT NULL DEFAULT '', -- welche Rechnung ersetzt diese hier
|
||||
berichtigt_zu TEXT NOT NULL DEFAULT '', -- zu welcher Rechnung gehoert das Blatt
|
||||
vorlage TEXT NOT NULL DEFAULT '', -- '' oder 'montage'
|
||||
quelle TEXT NOT NULL DEFAULT '', -- rechnungstool|journal
|
||||
herkunft TEXT NOT NULL DEFAULT '', -- pdf|xlsx|manuell
|
||||
pdf_pfad TEXT NOT NULL DEFAULT '',
|
||||
geaendert TEXT NOT NULL DEFAULT '' -- ISO-Zeitstempel
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_nummern_jahr ON nummern (jahr);
|
||||
"""
|
||||
|
||||
FELDER = ("jahr", "datum", "nachname", "vorname", "naechte", "zwischensumme",
|
||||
"steuer_satz", "steuer_betrag", "gesamt", "art", "vorgang", "storno_zu",
|
||||
"ersetzt", "berichtigt_zu", "vorlage", "quelle", "herkunft", "pdf_pfad")
|
||||
|
||||
|
||||
def pfad_zur_datei(ordner):
|
||||
"""Vollstaendiger Pfad der gemeinsamen Datei in diesem Ordner."""
|
||||
return os.path.join(ordner or "", DATEINAME)
|
||||
|
||||
|
||||
def ordner_taugt(ordner):
|
||||
"""Kann in diesem Ordner wirklich gearbeitet werden? Wird ausprobiert,
|
||||
nicht geraten - ein nicht verbundenes Netzlaufwerk sieht sonst gut aus,
|
||||
bis die erste Rechnung geschrieben wird."""
|
||||
if not ordner or not os.path.isdir(ordner):
|
||||
return False, "Den Ordner gibt es nicht."
|
||||
probe = os.path.join(ordner, ".schreibprobe")
|
||||
try:
|
||||
with open(probe, "w", encoding="ascii") as f:
|
||||
f.write("x")
|
||||
os.remove(probe)
|
||||
except OSError as e:
|
||||
return False, f"In den Ordner kann nicht geschrieben werden ({e.strerror or e})."
|
||||
return True, ""
|
||||
|
||||
|
||||
class Nummernbuch:
|
||||
"""Das gemeinsame Register. Wird zum Schreiben kurz geoeffnet und wieder
|
||||
geschlossen - zwei Programme haben die Datei sonst dauerhaft in der Hand,
|
||||
und auf einem Netzlaufwerk gibt das gesperrte Dateien."""
|
||||
|
||||
def __init__(self, ordner):
|
||||
self.ordner = ordner
|
||||
self.pfad = pfad_zur_datei(ordner)
|
||||
|
||||
def _verbindung(self):
|
||||
con = sqlite3.connect(self.pfad, timeout=10)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.executescript(SCHEMA)
|
||||
return con
|
||||
|
||||
# ---- Lesen -------------------------------------------------------------
|
||||
def nummern(self, jahr=None):
|
||||
"""Alle vergebenen Nummern (als Menge), auf Wunsch nur eines Jahres."""
|
||||
with self._verbindung() as con:
|
||||
if jahr:
|
||||
zeilen = con.execute("SELECT nummer FROM nummern WHERE jahr=?", (int(jahr),))
|
||||
else:
|
||||
zeilen = con.execute("SELECT nummer FROM nummern")
|
||||
return {z["nummer"] for z in zeilen}
|
||||
|
||||
def eintrag(self, nummer):
|
||||
with self._verbindung() as con:
|
||||
z = con.execute("SELECT * FROM nummern WHERE nummer=?", (str(nummer),)).fetchone()
|
||||
return dict(z) if z else None
|
||||
|
||||
def alle(self):
|
||||
with self._verbindung() as con:
|
||||
return [dict(z) for z in con.execute("SELECT * FROM nummern ORDER BY nummer")]
|
||||
|
||||
def belegt(self, nummer):
|
||||
return self.eintrag(nummer) is not None
|
||||
|
||||
# ---- Schreiben ---------------------------------------------------------
|
||||
def eintragen(self, nummer, **werte):
|
||||
"""Nummer eintragen oder ihre Angaben auffrischen.
|
||||
|
||||
Eine schon eingetragene Nummer wird NICHT als Fehler behandelt: beide
|
||||
Programme sehen dieselbe Rechnung (das Rechnungstool beim Schreiben,
|
||||
das Journal beim Einlesen der PDF) und sollen sich gegenseitig
|
||||
ergaenzen duerfen.
|
||||
"""
|
||||
import datetime
|
||||
nummer = str(nummer or "").strip()
|
||||
if not nummer:
|
||||
return False
|
||||
daten = {f: werte.get(f) for f in FELDER if werte.get(f) is not None}
|
||||
daten["geaendert"] = datetime.datetime.now().isoformat(timespec="seconds")
|
||||
spalten = ["nummer"] + list(daten)
|
||||
platzhalter = ",".join("?" * len(spalten))
|
||||
setzen = ",".join(f"{k}=excluded.{k}" for k in daten)
|
||||
with self._verbindung() as con:
|
||||
con.execute(
|
||||
f"INSERT INTO nummern ({','.join(spalten)}) VALUES ({platzhalter}) "
|
||||
f"ON CONFLICT(nummer) DO UPDATE SET {setzen}",
|
||||
[nummer] + list(daten.values()))
|
||||
log.info("Nummernbuch: %s eingetragen (%s)", nummer, daten.get("quelle", ""))
|
||||
return True
|
||||
|
||||
def storniert(self):
|
||||
"""Nummern, die durch einen Storno aufgehoben sind."""
|
||||
with self._verbindung() as con:
|
||||
return {z["storno_zu"] for z in
|
||||
con.execute("SELECT storno_zu FROM nummern WHERE storno_zu <> ''")}
|
||||
|
||||
def entfernen(self, nummer):
|
||||
"""Nur fuer den Rueckweg: ein verworfener Storno gibt seine Nummer zurueck."""
|
||||
with self._verbindung() as con:
|
||||
con.execute("DELETE FROM nummern WHERE nummer=?", (str(nummer),))
|
||||
log.info("Nummernbuch: %s entfernt", nummer)
|
||||
51
korrektur.py
51
korrektur.py
|
|
@ -27,7 +27,7 @@ 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 helfer import kunde_maengel, nummer_normalisieren, parse_num, safe_filename
|
||||
from storno import storno_rechnung_bauen
|
||||
|
||||
|
||||
|
|
@ -106,6 +106,13 @@ class KorrekturMixin:
|
|||
"Zähler nicht gespeichert",
|
||||
f"Die Einstellungen ließen sich nicht schreiben:\n{e}\n\n"
|
||||
"Bitte im Menü unter „Rechnungsnummer…“ prüfen.")
|
||||
# Die Nummer ist wieder frei - auch im gemeinsamen Buch, sonst
|
||||
# ueberspringt das Rechnungstool sie fuer immer.
|
||||
if self.buch is not None and stand.get("nummer"):
|
||||
try:
|
||||
self.buch.entfernen(stand["nummer"])
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("[nummernbuch] Storno nicht ausgetragen:", e)
|
||||
self.storno_stand = None
|
||||
self._storno_anzeigen()
|
||||
self._neue_nummer_vorschlagen()
|
||||
|
|
@ -298,7 +305,7 @@ class KorrekturMixin:
|
|||
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)
|
||||
nummer = naechste_freie_nummer(out_dir, self.cfg, self._belegte_nummern())
|
||||
if not nummer:
|
||||
messagebox.showerror("Keine freie Nummer",
|
||||
"Es wurde keine freie Rechnungsnummer gefunden.")
|
||||
|
|
@ -310,7 +317,7 @@ class KorrekturMixin:
|
|||
# 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)
|
||||
folge_nummer = naechste_freie_nummer_nach(out_dir, self.cfg, nummer, self._belegte_nummern())
|
||||
# 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)
|
||||
|
|
@ -333,10 +340,17 @@ class KorrekturMixin:
|
|||
icon="warning", default="no"):
|
||||
return
|
||||
|
||||
# Der Storno ist das Spiegelbild der Rechnung - auch bei den
|
||||
# UEBERNACHTUNGEN. Stand hier vorher nichts, blieben die Naechte der
|
||||
# aufgehobenen Rechnung im Steuerjournal stehen: die Betraege kuerzten
|
||||
# sich, die Zahl der Uebernachtungen nicht, und das Amt bekam sie
|
||||
# doppelt gemeldet (4 - 0 + 4 = 8 statt 4).
|
||||
alt_naechte = int(parse_num(str(meta.get("naechte") or 0)) or 0)
|
||||
kopf = {
|
||||
"rechnungsnummer": nummer,
|
||||
"datum": heute,
|
||||
"anreise": "", "abreise": "", "naechte": "",
|
||||
"anreise": "", "abreise": "",
|
||||
"naechte": -abs(alt_naechte) if alt_naechte else "",
|
||||
"kunde_zeilen": kunde_zeilen,
|
||||
"nachname": kunde.get("nachname", ""),
|
||||
"vorname": kunde.get("vorname", ""),
|
||||
|
|
@ -373,6 +387,11 @@ class KorrekturMixin:
|
|||
}
|
||||
self._zaehler_aus_nummer_speichern(nummer)
|
||||
print(f"[storno] erstellt -> {pfad}")
|
||||
# Der Storno hat eine eigene Nummer - die gehoert ins gemeinsame Buch,
|
||||
# sonst vergibt sie das andere Programm noch einmal, und das Journal
|
||||
# erfaehrt nicht, dass die Rechnung aufgehoben ist.
|
||||
self._ins_buch(nummer, kopf, r, pfad)
|
||||
self._buch_anzeigen()
|
||||
|
||||
fehlend = self._formular_aus_meta(meta)
|
||||
self.r_nummer.set(folge_nummer or "")
|
||||
|
|
@ -405,14 +424,28 @@ class KorrekturMixin:
|
|||
self.r_abreise.set(str(meta.get("abreise", "") or ""))
|
||||
self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y"))
|
||||
|
||||
# Eine Montage-Rechnung gehoert in ihren Reiter: ihre Position 1 ist die
|
||||
# Monatsmiete, im Campingformular waere es Position 1 des Katalogs
|
||||
# (Wohnwagen pro Nacht) - der Storno haette stillschweigend etwas
|
||||
# anderes berichtigt.
|
||||
montage = str(meta.get("vorlage", "") or "") == "montage"
|
||||
try:
|
||||
self.blaetter.select(1 if montage else 0)
|
||||
except (AttributeError, tk.TclError):
|
||||
pass
|
||||
|
||||
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 ""))
|
||||
if montage:
|
||||
erste = (meta.get("positionen") or [{}])[0]
|
||||
self.m_monate.set(str(erste.get("anzahl", "1") or "1"))
|
||||
else:
|
||||
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 = []
|
||||
|
|
|
|||
183
ordnerwahl.py
Normal file
183
ordnerwahl.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Den gemeinsamen Ordner abfragen - in beiden Programmen gleich.
|
||||
|
||||
Rechnungstool und Steuerjournal fuehren ein gemeinsames Nummernbuch
|
||||
(siehe gemeinsam.py). Damit sie sich finden, muss BEIDEN derselbe Ordner
|
||||
genannt werden - deshalb fragt jedes Programm beim ersten Start danach und
|
||||
sagt ausdruecklich dazu, dass es derselbe sein muss.
|
||||
|
||||
Der Weg ist bewusst umstaendlich: auswaehlen, dann OK, dann noch einmal
|
||||
bestaetigen. Ein falscher Ordner faellt sonst erst auf, wenn eine
|
||||
Rechnungsnummer zum zweiten Mal vergeben wurde.
|
||||
|
||||
Wer jetzt keinen Ordner hat, kommt mit "Spaeter einrichten" weiter - das
|
||||
Programm laeuft dann ohne das gemeinsame Buch und sagt das in der Fussleiste.
|
||||
|
||||
⚠ Das Fenster bekommt seine Groesse ausdruecklich gesetzt. Ohne das war es zu
|
||||
schmal, der Knopf zum Auswaehlen lag ausserhalb - und der Dialog war nicht zu
|
||||
bedienen (gefunden am 03.09.2026, bevor es jemand benutzen musste).
|
||||
|
||||
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
|
||||
hinueber.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
|
||||
import gemeinsam
|
||||
|
||||
BREITE = 780
|
||||
|
||||
|
||||
class OrdnerWahl(tk.Toplevel):
|
||||
"""Modaler Dialog. Nach dem Schliessen steht das Ergebnis in `.ergebnis`
|
||||
(gewaehlter Ordner) oder es ist None (spaeter/abgebrochen)."""
|
||||
|
||||
def __init__(self, master, programm: str, vorgabe: str = ""):
|
||||
super().__init__(master)
|
||||
self.ergebnis = None
|
||||
self.title("Gemeinsamer Ordner für das Nummernbuch")
|
||||
self.transient(master)
|
||||
self.pfad = tk.StringVar(value=vorgabe or "")
|
||||
|
||||
rahmen = ttk.Frame(self, padding=18)
|
||||
rahmen.pack(fill="both", expand=True)
|
||||
rahmen.columnconfigure(0, weight=1)
|
||||
breite = BREITE - 60
|
||||
|
||||
ttk.Label(rahmen, text="Bitte den gemeinsamen Ordner auswählen",
|
||||
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, sticky="w")
|
||||
ttk.Label(rahmen, wraplength=breite, justify="left", text=(
|
||||
"Rechnungstool und Steuerjournal führen zusammen ein Nummernbuch. Darin steht "
|
||||
"jede vergebene Rechnungsnummer – auch die aus der alten Excel-Tabelle, zu der "
|
||||
"es gar keine PDF gibt.\n\n"
|
||||
"Ohne dieses Buch kennt jedes Programm nur seine eigenen Nummern und vergibt "
|
||||
"eine Nummer ein zweites Mal. Jede Nummer darf es aber nur einmal geben "
|
||||
"(§ 14 Abs. 4 Nr. 4 UStG).")).grid(row=1, column=0, sticky="w", pady=(8, 12))
|
||||
|
||||
tk.Label(rahmen, wraplength=breite, justify="left", fg="#a00000",
|
||||
font=("Segoe UI", 10, "bold"),
|
||||
text="WICHTIG: In BEIDEN Programmen muss derselbe Ordner stehen – am besten "
|
||||
"der Ordner, in dem die Programme liegen.").grid(
|
||||
row=2, column=0, sticky="w", pady=(0, 14))
|
||||
|
||||
wahl = ttk.Frame(rahmen)
|
||||
wahl.grid(row=3, column=0, sticky="ew")
|
||||
wahl.columnconfigure(0, weight=1)
|
||||
self.feld = ttk.Entry(wahl, textvariable=self.pfad, state="readonly")
|
||||
self.feld.grid(row=0, column=0, sticky="ew")
|
||||
ttk.Button(wahl, text="Install-Ordner auswählen…", command=self.waehlen,
|
||||
width=26).grid(row=0, column=1, sticky="w", padx=(10, 0))
|
||||
|
||||
self.stand = tk.Label(rahmen, text="", wraplength=breite, justify="left",
|
||||
font=("Segoe UI", 9))
|
||||
self.stand.grid(row=4, column=0, sticky="w", pady=(10, 0))
|
||||
|
||||
knoepfe = ttk.Frame(rahmen)
|
||||
knoepfe.grid(row=5, column=0, sticky="ew", pady=(18, 0))
|
||||
self.ok = ttk.Button(knoepfe, text="OK", command=self.uebernehmen, width=14)
|
||||
self.ok.pack(side="right")
|
||||
ttk.Button(knoepfe, text="Später einrichten", command=self.spaeter,
|
||||
width=18).pack(side="left")
|
||||
|
||||
self._stand_zeigen()
|
||||
self.protocol("WM_DELETE_WINDOW", self.spaeter)
|
||||
self.bind("<Escape>", lambda _e: self.spaeter())
|
||||
|
||||
# Groesse ausdruecklich setzen: sonst schneidet Tk das Fenster an der
|
||||
# zweiten Spalte ab und der Auswahl-Knopf ist nicht erreichbar.
|
||||
self.update_idletasks()
|
||||
hoehe = max(self.winfo_reqheight(), 300)
|
||||
x = master.winfo_rootx() + max(0, (master.winfo_width() - BREITE) // 2)
|
||||
y = master.winfo_rooty() + 90
|
||||
self.geometry(f"{BREITE}x{hoehe}+{max(0, x)}+{max(0, y)}")
|
||||
self.minsize(BREITE, hoehe)
|
||||
self.grab_set()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
|
||||
# ------------------------------------------------------------------ Weg
|
||||
def waehlen(self):
|
||||
# Der Griff (grab_set) muss waehrend des Windows-Ordnerdialogs los sein:
|
||||
# sonst kann der native Dialog hinter dem gesperrten Fenster landen und
|
||||
# nimmt keine Eingabe an - man klickt und es passiert nichts.
|
||||
try:
|
||||
self.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
ordner = filedialog.askdirectory(
|
||||
title="Gemeinsamen Ordner wählen (in beiden Programmen derselbe!)",
|
||||
initialdir=self.pfad.get() or os.path.expanduser("~"), parent=self)
|
||||
finally:
|
||||
try:
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
pass
|
||||
if ordner:
|
||||
self.pfad.set(os.path.abspath(ordner))
|
||||
self._stand_zeigen()
|
||||
|
||||
def _stand_zeigen(self):
|
||||
"""Sagt, was in dem gewaehlten Ordner schon liegt.
|
||||
|
||||
Ein bereits vorhandenes Nummernbuch ist das beste Zeichen, dass es der
|
||||
richtige Ordner ist - dann hat das andere Programm ihn schon.
|
||||
"""
|
||||
ordner = self.pfad.get().strip()
|
||||
if not ordner:
|
||||
self.stand.configure(text="Noch kein Ordner gewählt.", fg="#a00000")
|
||||
self.ok.state(["disabled"])
|
||||
return
|
||||
taugt, grund = gemeinsam.ordner_taugt(ordner)
|
||||
if not taugt:
|
||||
self.stand.configure(text=grund, fg="#a00000")
|
||||
self.ok.state(["disabled"])
|
||||
return
|
||||
self.ok.state(["!disabled"])
|
||||
if os.path.exists(gemeinsam.pfad_zur_datei(ordner)):
|
||||
try:
|
||||
anzahl = len(gemeinsam.Nummernbuch(ordner).nummern())
|
||||
except Exception: # noqa: BLE001
|
||||
anzahl = "?"
|
||||
self.stand.configure(
|
||||
text=f"Hier liegt schon ein Nummernbuch mit {anzahl} Rechnungsnummern – "
|
||||
"das ist der Ordner des anderen Programms. Genau richtig.",
|
||||
fg="#1f5c3d")
|
||||
else:
|
||||
self.stand.configure(
|
||||
text="Hier ist noch kein Nummernbuch – es wird neu angelegt. Wähle im "
|
||||
"anderen Programm denselben Ordner.", fg="#444444")
|
||||
|
||||
def uebernehmen(self):
|
||||
ordner = self.pfad.get().strip()
|
||||
taugt, grund = gemeinsam.ordner_taugt(ordner)
|
||||
if not taugt:
|
||||
messagebox.showwarning("Ordner geht nicht", grund or "Bitte einen Ordner wählen.",
|
||||
parent=self)
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Sind Sie sicher?",
|
||||
f"Gemeinsamer Ordner:\n\n{ordner}\n\n"
|
||||
"Steht im anderen Programm derselbe Ordner? Nur dann kennen beide "
|
||||
"dieselben Rechnungsnummern.\n\n"
|
||||
"Ja = Ordner übernehmen\n"
|
||||
"Nein = zurück zur Auswahl",
|
||||
icon="question", default="yes", parent=self):
|
||||
# Zurueck ins Auswahlfeld - der Dialog bleibt stehen, nichts ist gesetzt.
|
||||
self.feld.focus_set()
|
||||
return
|
||||
self.ergebnis = ordner
|
||||
self.destroy()
|
||||
|
||||
def spaeter(self):
|
||||
self.ergebnis = None
|
||||
self.destroy()
|
||||
|
||||
|
||||
def ordner_abfragen(fenster, programm, vorgabe=""):
|
||||
"""Dialog zeigen und den gewaehlten Ordner zurueckgeben (oder None)."""
|
||||
d = OrdnerWahl(fenster, programm, vorgabe)
|
||||
fenster.wait_window(d)
|
||||
return d.ergebnis
|
||||
223
pdf_renderer.py
223
pdf_renderer.py
|
|
@ -188,85 +188,8 @@ def _kopf_fuss(canvas, doc, cfg, amt=False):
|
|||
|
||||
|
||||
# ---- Hauptfunktion ----------------------------------------------------------
|
||||
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
|
||||
|
||||
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
|
||||
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
|
||||
"""
|
||||
# Absenderzeile (klein, unterstrichen)
|
||||
story.append(Paragraph(
|
||||
f"<u>{cfg['firma'].get('absender','')}</u>",
|
||||
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
|
||||
# Kundenadresse (links) | Rechnungsinfos (rechts)
|
||||
kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])]
|
||||
for z in kopf.get("kunde_zeilen", []):
|
||||
if str(z).strip():
|
||||
kunde_flow.append(Paragraph(str(z), st["addr"]))
|
||||
if len(kunde_flow) == 1:
|
||||
kunde_flow.append(Paragraph(" ", st["addr"]))
|
||||
|
||||
art = kopf.get("art", "rechnung")
|
||||
info_rows = [
|
||||
("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"):
|
||||
info_rows.append(("Abreise:", kopf.get("abreise", "")))
|
||||
if kopf.get("naechte") not in (None, "", 0, "0"):
|
||||
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
|
||||
info_tbl = Table(
|
||||
[[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows],
|
||||
colWidths=[39 * mm, 25 * mm])
|
||||
info_tbl.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
|
||||
]))
|
||||
|
||||
head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm])
|
||||
head.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
]))
|
||||
story.append(head)
|
||||
story.append(Spacer(1, 7 * mm))
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
def _positionstabelle(story, st, rechnung):
|
||||
"""Die gewohnte Positionstabelle der Campingrechnung."""
|
||||
col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm]
|
||||
data = [[
|
||||
Paragraph("Pos.", st["th"]),
|
||||
|
|
@ -305,6 +228,138 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||||
]))
|
||||
story.append(items)
|
||||
|
||||
|
||||
def _montage_zeile(story, st, rechnung, kopf):
|
||||
"""Das Montage-Blatt: eine Textzeile, darunter Netto / USt / Brutto.
|
||||
|
||||
Bartl schreibt dieses Blatt seit Jahren von Hand ("Miete fuer Montagearbeiter
|
||||
inkl. 7 % MwSt. fuer den Monat August 2026"), darum hat es keine Tabelle mit
|
||||
Anzahl und Einzelpreis. Gerechnet wird trotzdem im selben Rechenkern - die
|
||||
Zahlen kommen aus rechnung.zeilen, nicht aus dem Text.
|
||||
"""
|
||||
text = kopf.get("montage_text") or ""
|
||||
if not text:
|
||||
text = ", ".join(z.leistung for z in rechnung.zeilen)
|
||||
story.append(Paragraph(text, st["leistung"]))
|
||||
story.append(Spacer(1, 3 * mm))
|
||||
|
||||
netto = sum(z.netto for z in rechnung.zeilen)
|
||||
ust = rechnung.brutto - netto
|
||||
dreier = Table(
|
||||
[[Paragraph(f"Netto {eur(netto)}", st["cell_c"]),
|
||||
Paragraph(f"USt. {eur(ust)}", st["cell_c"]),
|
||||
Paragraph(f"Brutto {eur(rechnung.brutto)}", st["cell_c"])]],
|
||||
colWidths=[CONTENT_W / 3.0] * 3)
|
||||
dreier.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GREY_BG),
|
||||
("LINEBELOW", (0, 0), (-1, -1), 0.8, ACCENT),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
]))
|
||||
story.append(dreier)
|
||||
|
||||
|
||||
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
|
||||
|
||||
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
|
||||
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
|
||||
"""
|
||||
# Absenderzeile (klein, unterstrichen)
|
||||
story.append(Paragraph(
|
||||
f"<u>{cfg['firma'].get('absender','')}</u>",
|
||||
ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK)))
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
|
||||
# Kundenadresse (links) | Rechnungsinfos (rechts)
|
||||
kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])]
|
||||
for z in kopf.get("kunde_zeilen", []):
|
||||
if str(z).strip():
|
||||
kunde_flow.append(Paragraph(str(z), st["addr"]))
|
||||
if len(kunde_flow) == 1:
|
||||
kunde_flow.append(Paragraph(" ", st["addr"]))
|
||||
|
||||
art = kopf.get("art", "rechnung")
|
||||
# Montage-Rechnung: eigenes Blatt fuer die Monatsmiete eines Monteurs.
|
||||
# Keine Positionstabelle, keine Naechte im Kopf - der Zeitraum steht in der
|
||||
# einen Textzeile, so wie Bartl das Blatt bisher von Hand geschrieben hat.
|
||||
montage = kopf.get("vorlage") == "montage"
|
||||
info_rows = [
|
||||
("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"):
|
||||
# "Neue Rechnung" war missverstaendlich - man liest es als die Nummer
|
||||
# DIESES Blattes. Gemeint ist die Rechnung, die den Storno ersetzt.
|
||||
info_rows.append(("Berichtigte 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 not montage:
|
||||
if kopf.get("anreise"):
|
||||
info_rows.append(("Anreise:", kopf.get("anreise", "")))
|
||||
if kopf.get("abreise"):
|
||||
info_rows.append(("Abreise:", kopf.get("abreise", "")))
|
||||
if kopf.get("naechte") not in (None, "", 0, "0"):
|
||||
# Auf dem Storno stehen die Naechte negativ in den Kenndaten (das
|
||||
# Spiegelbild fuer das Steuerjournal). Auf dem BLATT liest sich "-4
|
||||
# Naechte" wie ein Tippfehler - dort steht, was aufgehoben wird.
|
||||
try:
|
||||
anzahl = int(float(str(kopf.get("naechte")).replace(",", ".")))
|
||||
except (TypeError, ValueError):
|
||||
anzahl = 0
|
||||
if anzahl < 0:
|
||||
info_rows.append(("Zeitraum:", f"{abs(anzahl)} Nächte (aufgehoben)"))
|
||||
else:
|
||||
info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte"))
|
||||
info_tbl = Table(
|
||||
[[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows],
|
||||
colWidths=[39 * mm, 25 * mm])
|
||||
info_tbl.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 0),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
|
||||
]))
|
||||
|
||||
head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm])
|
||||
head.setStyle(TableStyle([
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 0),
|
||||
]))
|
||||
story.append(head)
|
||||
story.append(Spacer(1, 7 * mm))
|
||||
|
||||
# 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.
|
||||
# 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"]))
|
||||
|
||||
if montage:
|
||||
_montage_zeile(story, st, rechnung, kopf)
|
||||
else:
|
||||
_positionstabelle(story, st, rechnung)
|
||||
story.append(Spacer(1, 3 * mm))
|
||||
|
||||
# ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto)
|
||||
|
|
@ -343,7 +398,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
|||
|
||||
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
|
||||
summen_rows.append([
|
||||
Paragraph("Gesamtbetrag:", st["total_l"]),
|
||||
Paragraph("Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:", st["total_l"]),
|
||||
Paragraph(eur(endsumme), st["total_v"]),
|
||||
])
|
||||
style_cmds = [
|
||||
|
|
@ -507,6 +562,14 @@ def _kenndaten(rechnung, kopf):
|
|||
"kunde": kopf.get("kunde", {}),
|
||||
"positionen": kopf.get("positionen", []),
|
||||
}
|
||||
# Montage-Rechnung: welches Formular sie geschrieben hat und der Text der
|
||||
# einen Zeile. "art" bleibt "rechnung" - fuer das Steuerjournal ist sie eine
|
||||
# ganz normale Buchung (Miete mit Beherbergungssteuer), nur eben monatsweise.
|
||||
# Damit kann das Formular sie spaeter wieder laden und der Storno-Weg
|
||||
# erkennt, in welchen Reiter sie gehoert.
|
||||
if kopf.get("vorlage"):
|
||||
daten["vorlage"] = kopf["vorlage"]
|
||||
daten["montage_text"] = kopf.get("montage_text", "")
|
||||
return json.dumps(daten, ensure_ascii=False)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ 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)
|
||||
|
||||
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
|
||||
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||
import app as A # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,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)
|
||||
|
||||
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
|
||||
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||
import app as A # noqa: E402
|
||||
import bestand as BE # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
|
|
|||
|
|
@ -36,6 +36,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)
|
||||
|
||||
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
|
||||
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||
import app as A # noqa: E402
|
||||
import bestand as BE # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
|
|
|||
249
pruef_montage.py
Normal file
249
pruef_montage.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand fuer die Montage-Rechnung - der ganze Weg an echten Dateien.
|
||||
|
||||
Bartl vermietet auch monatsweise an Montagearbeiter. Dieses Blatt schrieb er
|
||||
bisher von Hand in Word:
|
||||
|
||||
Miete fuer Montagearbeiter inkl. 7 % MwSt. fuer den Monat August 2026
|
||||
Netto 467,29 USt 32,71 Brutto 500,00
|
||||
|
||||
Im Rechnungstool ist das ein eigener Reiter. Geprueft wird deshalb nicht nur
|
||||
gerechnet, sondern der Weg, den er geht: Reiter waehlen, Kunde und Zeitraum
|
||||
eintragen, PDF erstellen. Danach liest das STEUERJOURNAL die Datei ein - die
|
||||
Miete ist eine Beherbergung, die Steuer kommt obendrauf (500 + 25 = 525) und
|
||||
muss genau so beim Amt ankommen.
|
||||
|
||||
Die echte config.json wird nicht angefasst - es wird mit einer Kopie in einem
|
||||
Wegwerf-Ordner gearbeitet.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
QUELLE = r"C:\claude\rechnungstool"
|
||||
JOURNAL_PROG = r"C:\claude\beherbergungssteuer"
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="montage_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)
|
||||
|
||||
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
|
||||
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||
import app as A # noqa: E402
|
||||
import bestand as BE # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog, "
|
||||
"der nicht beantwortet wird")
|
||||
os._exit(2)
|
||||
|
||||
|
||||
_wecker = threading.Timer(150, _reissleine)
|
||||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
kodierung = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kodierung, "replace").decode(kodierung))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
# Dialoge automatisch beantworten; jede Frage wird protokolliert.
|
||||
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True}
|
||||
gefragt = []
|
||||
|
||||
|
||||
def askyesno(titel, text, **kw):
|
||||
gefragt.append(titel)
|
||||
for schlue, wert in antworten.items():
|
||||
if schlue in titel:
|
||||
return wert
|
||||
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
||||
|
||||
|
||||
warnungen = []
|
||||
messagebox.askyesno = askyesno
|
||||
messagebox.showinfo = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: warnungen.append(a[0] if a else "")
|
||||
messagebox.showerror = lambda *a, **k: print("[fehler]", a[0] if a else "",
|
||||
a[1] if len(a) > 1 else "")
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.update()
|
||||
f.cfg["output_dir"] = ordner
|
||||
|
||||
# ---- 0. Der Reiter ---------------------------------------------------------
|
||||
reiter = [f.blaetter.tab(i, "text").strip() for i in range(f.blaetter.index("end"))]
|
||||
pruefe(reiter == ["Campingrechnung", "Montage"], f"zwei Reiter da ({reiter})")
|
||||
pruefe(not f.ist_montage(), "beim Start liegt die Campingrechnung oben")
|
||||
pruefe(f.datum_labels["von"].cget("text").startswith("Anreise"),
|
||||
"dort heisst das Feld Anreise")
|
||||
|
||||
f.blaetter.select(1)
|
||||
f.update()
|
||||
pruefe(f.ist_montage(), "nach dem Wechsel liegt Montage oben")
|
||||
pruefe(f.datum_labels["von"].cget("text").startswith("Zeitraum von"),
|
||||
f"dort heisst dasselbe Feld Zeitraum von ({f.datum_labels['von'].cget('text')!r})")
|
||||
|
||||
# ---- 1. Die Rechnung, wie er sie tippt ------------------------------------
|
||||
f.k_anrede.set("Herr")
|
||||
f.k_vorname.set("Dominik")
|
||||
f.k_name.set("Schmidt")
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.r_datum.set("12.05.2026")
|
||||
f.r_anreise.set("01.08.2026")
|
||||
f.r_abreise.set("31.08.2026")
|
||||
f.m_monate.set("1")
|
||||
f.update()
|
||||
|
||||
pruefe(f.r_naechte.get() == "30", f"30 Naechte im Zeitraum ({f.r_naechte.get()})")
|
||||
text = f.m_text.cget("text")
|
||||
pruefe("Miete für Montagearbeiter" in text and "7 % MwSt." in text
|
||||
and "01.08.2026" in text and "31.08.2026" in text,
|
||||
f"die Zeile steht so da, wie sie aufs Blatt gehoert ({text!r})")
|
||||
|
||||
f.pdf_erstellen()
|
||||
pdf = os.path.join(ordner, "Rechnung_2026-001_Schmidt_Montage.pdf")
|
||||
pruefe(os.path.exists(pdf), f"Montage-Rechnung geschrieben ({os.path.basename(pdf)})")
|
||||
|
||||
# ---- 2. Was in den Kenndaten steht ----------------------------------------
|
||||
meta = BE.kenndaten_lesen(pdf) or {}
|
||||
pruefe(meta.get("vorlage") == "montage", f"vorlage = montage ({meta.get('vorlage')!r})")
|
||||
pruefe(meta.get("art") == "rechnung",
|
||||
f"art bleibt rechnung - fuer das Journal eine normale Buchung ({meta.get('art')!r})")
|
||||
pruefe(float(meta.get("zwischensumme") or 0) == 500.0,
|
||||
f"Zwischensumme 500,00 ({meta.get('zwischensumme')})")
|
||||
pruefe(float(meta.get("steuer_satz") or 0) == 5.0,
|
||||
f"Beherbergungssteuer 5 % ({meta.get('steuer_satz')})")
|
||||
pruefe(float(meta.get("steuer_betrag") or 0) == 25.0,
|
||||
f"Steuer 25,00 - obendrauf, nicht drin ({meta.get('steuer_betrag')})")
|
||||
pruefe(float(meta.get("gesamt") or 0) == 525.0, f"Gesamt 525,00 ({meta.get('gesamt')})")
|
||||
pruefe(str(meta.get("naechte")) == "30",
|
||||
f"30 Naechte in den Kenndaten - die Meldung ans Amt ({meta.get('naechte')!r})")
|
||||
pruefe(meta.get("nachname") == "Schmidt", f"Nachname ({meta.get('nachname')!r})")
|
||||
bloecke = meta.get("ust_bloecke") or []
|
||||
pruefe(len(bloecke) == 1 and int(bloecke[0]["satz"]) == 7,
|
||||
f"eine USt-Zeile mit 7 % ({bloecke})")
|
||||
pruefe(bloecke and abs(bloecke[0]["netto"] - 467.29) < 0.005
|
||||
and abs(bloecke[0]["ust"] - 32.71) < 0.005,
|
||||
f"Netto 467,29 und USt 32,71 wie auf seinem Blatt ({bloecke})")
|
||||
pos = meta.get("positionen") or []
|
||||
pruefe(len(pos) == 1 and float(pos[0]["anzahl"]) == 1.0, f"eine Position, ein Monat ({pos})")
|
||||
|
||||
# ---- 3. Was auf dem Blatt steht -------------------------------------------
|
||||
from pypdf import PdfReader # noqa: E402
|
||||
|
||||
seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages]
|
||||
pruefe(len(seiten) == 2, f"zwei Seiten - Kunde und Amt ({len(seiten)})")
|
||||
kunde_seite = seiten[0].replace("\n", " ")
|
||||
pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt")
|
||||
pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum")
|
||||
pruefe("Rechnungsbetrag Insgesamt" in kunde_seite,
|
||||
"und die Schlusszeile heisst wie bei ihm: Rechnungsbetrag Insgesamt")
|
||||
pruefe("Tage/Menge" not in kunde_seite and "Pos." not in kunde_seite,
|
||||
"keine Positionstabelle auf dem Montage-Blatt")
|
||||
pruefe("525,00" in kunde_seite and "500,00" in kunde_seite,
|
||||
"500,00 und 525,00 stehen drauf")
|
||||
pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen")
|
||||
amt_seite = seiten[1].replace("\n", " ")
|
||||
pruefe("Beherbergungssteuer" not in amt_seite,
|
||||
"auf dem Amtsblatt steht sie nicht - wie bei jeder anderen Rechnung")
|
||||
|
||||
# ---- 4. Das Steuerjournal liest die Datei ---------------------------------
|
||||
sys.path.append(JOURNAL_PROG)
|
||||
import db as BST # noqa: E402
|
||||
|
||||
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
|
||||
neu, akt, uebersprungen, kaputt = journal.scanne(ordner, 5.0, voll=True)
|
||||
pruefe(neu == 1 and not kaputt, f"das Journal liest sie ein ({neu} neu, Fehler: {kaputt})")
|
||||
buchungen = journal.buchungen(jahr=2026)
|
||||
pruefe(len(buchungen) == 1, f"eine Buchung im Journal ({len(buchungen)})")
|
||||
if buchungen:
|
||||
b = buchungen[0]
|
||||
pruefe(b.nachname == "Schmidt", f"auf Schmidt gebucht ({b.nachname!r})")
|
||||
pruefe(abs(b.gezahlt - 500.0) < 0.005, f"Gezahlt 500,00 ({b.gezahlt})")
|
||||
pruefe(abs(b.steuer - 25.0) < 0.005, f"Steuer 25,00 ({b.steuer})")
|
||||
pruefe(b.naechte == 30, f"30 Uebernachtungen ({b.naechte})")
|
||||
pruefe(b.datum.strftime("%d.%m.%Y") == "12.05.2026", f"Datum ({b.datum})")
|
||||
journal.schliessen()
|
||||
|
||||
# ---- 5. Storno einer Montage-Rechnung -------------------------------------
|
||||
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)
|
||||
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
|
||||
pruefe(os.path.exists(storno_pdf), f"Storno geschrieben ({os.path.basename(storno_pdf)})")
|
||||
m2 = BE.kenndaten_lesen(storno_pdf) or {}
|
||||
pruefe(float(m2.get("zwischensumme") or 0) == -500.0,
|
||||
f"Storno-Zwischensumme -500,00 ({m2.get('zwischensumme')})")
|
||||
pruefe(float(m2.get("steuer_betrag") or 0) == -25.0,
|
||||
f"Beherbergungssteuer wird mitstorniert ({m2.get('steuer_betrag')})")
|
||||
pruefe(f.ist_montage(), "das Formular steht danach wieder im Montage-Reiter")
|
||||
pruefe(f.m_monate.get() in ("1", "1.0"), f"mit einem Monat ({f.m_monate.get()!r})")
|
||||
f._storno_verwerfen("Pruefstand")
|
||||
|
||||
# ---- 6. Die Campingrechnung darf sich nicht veraendert haben --------------
|
||||
f.blaetter.select(0)
|
||||
f.update()
|
||||
pruefe(not f.ist_montage(), "zurueck im Campingreiter")
|
||||
pruefe(f.datum_labels["von"].cget("text").startswith("Anreise"), "Feld heisst wieder Anreise")
|
||||
f.r_anreise.set("01.09.2026")
|
||||
f.r_abreise.set("05.09.2026")
|
||||
erste_pos = sorted(f.anzahl_vars)[0]
|
||||
f.anzahl_vars[erste_pos].set("4")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
camping = [d for d in os.listdir(ordner) if d.endswith(".pdf") and "Montage" not in d
|
||||
and not d.startswith("Storno")]
|
||||
pruefe(len(camping) == 1, f"die Campingrechnung entsteht wie immer ({camping})")
|
||||
if camping:
|
||||
t = (PdfReader(os.path.join(ordner, camping[0])).pages[0].extract_text() or "").replace("\n", " ")
|
||||
pruefe("Tage/Menge" in t, "und hat weiter ihre Positionstabelle")
|
||||
|
||||
# ---- 7. Datum: Tag bis 31, Monat bis 12 -----------------------------------
|
||||
p = A.RechnungsApp._datum_ziffern_pruefen
|
||||
faelle = [("5", "05"), ("9", "09"), ("35", "3"), ("00", "0"), ("31", "31"),
|
||||
("013", "0103"), ("1213", "121"), ("1200", "120"), ("3112", "3112")]
|
||||
for ein, soll in faelle:
|
||||
pruefe(p(ein) == soll, f"Tippen {ein!r} ergibt {p(ein)!r} (soll {soll!r})")
|
||||
|
||||
f.r_datum.set("31.02.2026") # Tag und Monat je fuer sich moeglich - den Tag gibt es nicht
|
||||
kaputt = f._datumsfelder_pruefen()
|
||||
pruefe(len(kaputt) == 1 and "31.02.2026" in kaputt[0],
|
||||
f"31.02.2026 wird als Datum beanstandet ({kaputt})")
|
||||
vorher = len([d for d in os.listdir(ordner) if d.endswith(".pdf")])
|
||||
warnungen.clear()
|
||||
f.pdf_erstellen()
|
||||
nachher = len([d for d in os.listdir(ordner) if d.endswith(".pdf")])
|
||||
pruefe(nachher == vorher, "und es wird keine Rechnung mit diesem Datum geschrieben")
|
||||
pruefe(any("Datum" in w for w in warnungen), f"mit Meldung an den Benutzer ({warnungen})")
|
||||
|
||||
f.destroy()
|
||||
if not fehler:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
else:
|
||||
print(f" (Wegwerf-Ordner bleibt: {tmp})")
|
||||
print("---")
|
||||
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||
sys.exit(1 if fehler else 0)
|
||||
186
pruef_nummernbuch.py
Normal file
186
pruef_nummernbuch.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: das gemeinsame Nummernbuch von Rechnungstool und Steuerjournal.
|
||||
|
||||
Der Fall, um den es geht (sein Satz vom 03.09.2026):
|
||||
|
||||
"er hat manuelle eingetragen die das andere programm die rechnungs nummer
|
||||
nicht kennt - mit der sqlite beheben wir das"
|
||||
|
||||
Im Steuerjournal stehen Buchungen aus der alten Excel-Mappe, zu denen es keine
|
||||
PDF gibt. Das Rechnungstool sucht die naechste freie Nummer bisher nur in
|
||||
seinem eigenen Ordner - und vergibt so eine Nummer zum zweiten Mal
|
||||
(§ 14 Abs. 4 Nr. 4 UStG: jede Nummer nur einmal).
|
||||
|
||||
Geprueft wird mit BEIDEN echten Programmen auf EINEM gemeinsamen Ordner.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
TOOL = r"C:\claude\rechnungstool"
|
||||
JOURNAL = r"C:\claude\beherbergungssteuer"
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||
kodierung = sys.stdout.encoding or "utf-8"
|
||||
print(zeile.encode(kodierung, "replace").decode(kodierung))
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
|
||||
os._exit(2)
|
||||
|
||||
|
||||
_wecker = threading.Timer(150, _reissleine)
|
||||
_wecker.daemon = True
|
||||
_wecker.start()
|
||||
|
||||
os.environ["CAMPINGHOF_TEST"] = "1" # kein Erststart-Dialog
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="buch_pruef_")
|
||||
gemeinsam_ordner = os.path.join(tmp, "install") # hier liegt das Buch
|
||||
rechnungen = os.path.join(tmp, "rechnungen")
|
||||
journal_daten = os.path.join(tmp, "journal_daten")
|
||||
for d in (gemeinsam_ordner, rechnungen, journal_daten):
|
||||
os.makedirs(d)
|
||||
|
||||
# ---- Erst das Steuerjournal (eigener Datenordner) --------------------------
|
||||
sys.path.insert(0, JOURNAL)
|
||||
import ablage # noqa: E402
|
||||
|
||||
ablage.daten_ordner = lambda: journal_daten
|
||||
import app as JA # noqa: E402 - das Journal
|
||||
from modell import Buchung # noqa: E402
|
||||
import gemeinsam # noqa: E402
|
||||
|
||||
del sys.modules["app"]
|
||||
sys.path.remove(JOURNAL)
|
||||
|
||||
from datetime import date # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
messagebox.showinfo = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: None
|
||||
|
||||
journal = JA.App()
|
||||
journal.withdraw()
|
||||
# Altbestand wie aus der Excel-Mappe: Nummern OHNE PDF.
|
||||
for nr, name in (("001", "Alt Eins"), ("002", "Alt Zwei"), ("003", "Alt Drei")):
|
||||
journal.journal.speichern(Buchung(date(2026, 5, 1), nr, 2026, name, 2, 50.0,
|
||||
quelle="xlsx"))
|
||||
journal.jahr.set(2026)
|
||||
journal.aktualisieren()
|
||||
journal.journal.setze("gemeinsam_ordner", gemeinsam_ordner)
|
||||
journal._buch_oeffnen()
|
||||
journal._buch_fuellen()
|
||||
|
||||
buch = gemeinsam.Nummernbuch(gemeinsam_ordner)
|
||||
pruefe(os.path.exists(gemeinsam.pfad_zur_datei(gemeinsam_ordner)),
|
||||
"das Journal legt das Nummernbuch im gemeinsamen Ordner an")
|
||||
nummern = buch.nummern()
|
||||
pruefe(nummern == {"2026-001", "2026-002", "2026-003"},
|
||||
f"die drei Altbestands-Nummern stehen drin ({sorted(nummern)})")
|
||||
eintrag = buch.eintrag("2026-002")
|
||||
pruefe(eintrag and eintrag["herkunft"] == "xlsx" and eintrag["quelle"] == "journal",
|
||||
f"mit Herkunft und Quelle ({eintrag and (eintrag['quelle'], eintrag['herkunft'])})")
|
||||
|
||||
# ---- Jetzt das Rechnungstool auf denselben Ordner --------------------------
|
||||
sys.path.insert(0, TOOL)
|
||||
import config # noqa: E402
|
||||
|
||||
shutil.copy(os.path.join(TOOL, "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"] = rechnungen
|
||||
roh["gemeinsam_ordner"] = gemeinsam_ordner
|
||||
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
|
||||
import app as A # noqa: E402 - das Rechnungstool
|
||||
import bestand as BE # noqa: E402
|
||||
|
||||
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
|
||||
"Storno wirklich": True}
|
||||
|
||||
|
||||
def askyesno(titel, text, **kw):
|
||||
for schlue, wert in antworten.items():
|
||||
if schlue in titel:
|
||||
return wert
|
||||
raise AssertionError(f"unangemeldeter Dialog: {titel!r}")
|
||||
|
||||
|
||||
messagebox.askyesno = askyesno
|
||||
|
||||
tool = A.RechnungsApp()
|
||||
tool.cfg["output_dir"] = rechnungen
|
||||
tool._buch_oeffnen()
|
||||
pruefe(tool.buch is not None, "das Rechnungstool findet dasselbe Buch")
|
||||
tool._neue_nummer_vorschlagen()
|
||||
pruefe(tool.r_nummer.get() == "2026-004",
|
||||
f"es schlaegt 2026-004 vor und ueberspringt den Altbestand ({tool.r_nummer.get()})")
|
||||
|
||||
# ---- Eine Rechnung schreiben: sie muss im Buch landen ---------------------
|
||||
tool.k_vorname.set("Anna")
|
||||
tool.k_name.set("Neu")
|
||||
tool.k_strasse.set("Musterweg 1")
|
||||
tool.k_plzort.set("12345 Musterstadt")
|
||||
tool.r_anreise.set("01.09.2026")
|
||||
tool.r_abreise.set("03.09.2026")
|
||||
tool.anzahl_vars[sorted(tool.anzahl_vars)[0]].set("2")
|
||||
tool.update()
|
||||
tool.pdf_erstellen()
|
||||
pruefe(os.path.exists(os.path.join(rechnungen, "Rechnung_2026-004_Neu.pdf")),
|
||||
"die Rechnung 2026-004 ist geschrieben")
|
||||
e4 = buch.eintrag("2026-004")
|
||||
pruefe(e4 is not None, "und steht im gemeinsamen Buch")
|
||||
pruefe(e4 and e4["quelle"] == "rechnungstool" and e4["nachname"] == "Neu",
|
||||
f"mit Quelle rechnungstool und Namen ({e4 and (e4['quelle'], e4['nachname'])})")
|
||||
pruefe(e4 and abs(float(e4["zwischensumme"]) - 26.0) < 0.005,
|
||||
f"und dem Betrag ({e4 and e4['zwischensumme']})")
|
||||
|
||||
# ---- Storno: eigene Nummer ins Buch, beim Verwerfen wieder raus -----------
|
||||
offen = [m for m in BE.rechnungen_im_ordner(rechnungen) if m.get("art") != "storno"]
|
||||
tool._storno_anlegen(offen[0], rechnungen)
|
||||
storno_nr = tool.storno_stand["nummer"] if tool.storno_stand else ""
|
||||
pruefe(buch.belegt(storno_nr), f"der Storno {storno_nr} steht im Buch")
|
||||
e_storno = buch.eintrag(storno_nr)
|
||||
pruefe(e_storno and e_storno["storno_zu"] == "2026-004",
|
||||
f"und sagt, welche Rechnung er aufhebt ({e_storno and e_storno['storno_zu']})")
|
||||
tool._storno_verwerfen("Pruefstand")
|
||||
pruefe(not buch.belegt(storno_nr),
|
||||
f"beim Verwerfen ist die Nummer wieder frei ({storno_nr})")
|
||||
|
||||
# ---- Das Journal sieht die neue Rechnung im Buch --------------------------
|
||||
journal._buch_oeffnen()
|
||||
pruefe("2026-004" in journal.buch.nummern(),
|
||||
"das Journal sieht die Nummer des Rechnungstools im Buch")
|
||||
|
||||
# ---- Faellt das Buch aus, laeuft trotzdem alles weiter --------------------
|
||||
tool.cfg["gemeinsam_ordner"] = os.path.join(tmp, "gibtsnicht")
|
||||
tool._buch_oeffnen()
|
||||
pruefe(tool.buch is None, "ein nicht erreichbarer Ordner schaltet das Buch ab")
|
||||
pruefe(tool._belegte_nummern() == set(), "die belegten Nummern sind dann leer")
|
||||
tool._neue_nummer_vorschlagen()
|
||||
pruefe(bool(tool.r_nummer.get()), f"und es wird trotzdem eine Nummer vorgeschlagen "
|
||||
f"({tool.r_nummer.get()})")
|
||||
|
||||
tool.destroy()
|
||||
journal.journal.schliessen()
|
||||
journal.destroy()
|
||||
|
||||
if not fehler:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
else:
|
||||
print(f" (Wegwerf-Ordner bleibt: {tmp})")
|
||||
print("---")
|
||||
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||
sys.exit(1 if fehler else 0)
|
||||
|
|
@ -32,6 +32,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)
|
||||
|
||||
# Kein Erststart-Dialog im Pruefstand - er wuerde den Lauf haengen lassen.
|
||||
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||
import app as A # noqa: E402
|
||||
import bestand as BE # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
|
|
|||
14
theme.py
14
theme.py
|
|
@ -94,6 +94,20 @@ class ThemeMixin:
|
|||
style.map(sbs, background=[("active", accent)],
|
||||
troughcolor=[("disabled", bg)])
|
||||
style.configure("TSeparator", background=self.col_line)
|
||||
# Reiter (Campingrechnung / Montage): ohne eigene Farben zeichnet clam den
|
||||
# AKTIVEN Reiter dunkler als die ruhenden - man sieht dann nicht, wo man
|
||||
# ist. Der offene Reiter bekommt darum den Hintergrund des Blattes und die
|
||||
# Akzentfarbe, die ruhenden bleiben zurueckgenommen.
|
||||
style.configure("TNotebook", background=bg, bordercolor=self.col_line,
|
||||
tabmargins=(2, 4, 2, 0))
|
||||
style.configure("TNotebook.Tab", background=field, foreground=self.col_muted,
|
||||
bordercolor=self.col_line, padding=(16, 7),
|
||||
font=("Segoe UI", 10))
|
||||
style.map("TNotebook.Tab",
|
||||
background=[("selected", bg), ("active", sel)],
|
||||
foreground=[("selected", accent), ("active", fg)],
|
||||
font=[("selected", ("Segoe UI", 10, "bold"))],
|
||||
expand=[("selected", (1, 1, 1, 0))])
|
||||
# Klassische tk-Widgets (Canvas, Toplevel, Text) erben diese Defaults
|
||||
self.option_add("*background", bg)
|
||||
self.option_add("*foreground", fg)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue