diff --git a/app.py b/app.py index 07f7ed0..60c0169 100644 --- a/app.py +++ b/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 @@ -96,6 +111,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): ("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)], } @@ -484,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") @@ -502,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 = {} @@ -569,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) @@ -770,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", @@ -784,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", @@ -823,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) diff --git a/bestand.py b/bestand.py index baac192..3333712 100644 --- a/bestand.py +++ b/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 - 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 diff --git a/gemeinsam.py b/gemeinsam.py new file mode 100644 index 0000000..1bf3a19 --- /dev/null +++ b/gemeinsam.py @@ -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) diff --git a/korrektur.py b/korrektur.py index 43fcfc7..7c7cdcd 100644 --- a/korrektur.py +++ b/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 "") diff --git a/ordnerwahl.py b/ordnerwahl.py new file mode 100644 index 0000000..616cab6 --- /dev/null +++ b/ordnerwahl.py @@ -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("", 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 diff --git a/pdf_renderer.py b/pdf_renderer.py index 50c5971..a95b013 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -301,7 +301,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): # 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"])) + # "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": @@ -315,7 +317,17 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): 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")) + # 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]) diff --git a/pruef_dialoge.py b/pruef_dialoge.py index 284dfb0..bac300b 100644 --- a/pruef_dialoge.py +++ b/pruef_dialoge.py @@ -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 diff --git a/pruef_kernregeln.py b/pruef_kernregeln.py index 723a4d1..e5369a5 100644 --- a/pruef_kernregeln.py +++ b/pruef_kernregeln.py @@ -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 diff --git a/pruef_kette.py b/pruef_kette.py index 163ae59..3bb28fe 100644 --- a/pruef_kette.py +++ b/pruef_kette.py @@ -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 diff --git a/pruef_montage.py b/pruef_montage.py index 1aff9dc..cea63cb 100644 --- a/pruef_montage.py +++ b/pruef_montage.py @@ -40,6 +40,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 diff --git a/pruef_nummernbuch.py b/pruef_nummernbuch.py new file mode 100644 index 0000000..e857f84 --- /dev/null +++ b/pruef_nummernbuch.py @@ -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) diff --git a/pruef_storno.py b/pruef_storno.py index 1f29489..232279b 100644 --- a/pruef_storno.py +++ b/pruef_storno.py @@ -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