Storno und Berichtigung kommen an, gemeinsames Nummernbuch, kein Bearbeiten mehr
Storno: das Journal zaehlte die Uebernachtungen der aufgehobenen Rechnung weiter mit (4 - 0 + 3 = 7 statt 3 gemeldeter Naechte). Die Betraege kuerzten sich, die Naechte nicht. Der Storno traegt jetzt die negative Zahl, und das Journal kennt Art, Bezug und Datum des Belegs (vier neue Spalten, alte Journale bauen sich beim Oeffnen um). Berichtigung: das Blatt wurde nur uebersprungen. Der berichtigte NAME kommt jetzt in der Buchung an - im Amtsbericht stand sonst weiter der falsche. Angewandt wird erst am Ende des Scans, das Blatt kann vor seiner Rechnung im Ordner liegen. Doppelte Nummern: eine stornierte Rechnung ist keine offene mehr und faellt aus der Meldung. Zugeordnet wird ueber Nummer + Datum + Empfaenger, nicht ueber die Nummer allein - bei einer doppelt vergebenen Nummer waere die kein eindeutiger Bezug, und genau den verlangt § 31 Abs. 5 UStDV. Die Texte nennen jetzt den Weg: eine der beiden im Rechnungstool stornieren und mit freier Nummer neu ausstellen (§ 14 Abs. 4 Nr. 4 UStG - jede Nummer nur einmal). Neue Spalte "Art": Storno zu ..., storniert, Neuausstellung. Aufgehobene Zeilen stehen grau - geloescht wird nichts. Gemeinsames Nummernbuch (gemeinsam.py, ordnerwahl.py - in beiden Programmen dieselbe Datei): eine SQLite in einem Ordner, den beide kennen. Das Journal traegt seine Nummern ein, auch die aus der Excel-Mappe ohne PDF - genau die kannte das Rechnungstool nicht und vergab sie ein zweites Mal. Beim ersten Start wird nach dem Ordner gefragt (auswaehlen, OK, Sicherheitsfrage, bei Nein zurueck ins Feld); wer ihn nicht hat, kommt mit "Spaeter einrichten" weiter. Kein Doppelklick zum Bearbeiten mehr (gui_dialoge.py geloescht): wer hier Betrag oder Nummer verstellt, meldet dem Amt etwas anderes, als auf dem Beleg steht. Loeschen bleibt. --test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog (Pruefstaende). Pruefstaende: pruef_storno_journal.py (neu, ueber BEIDE Programme: Storno, Berichtigung, doppelte Nummer aufloesen), pruef_doppelte, pruef_berichtigung, pruef_einzelinstanz - alle gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
This commit is contained in:
parent
9e564466c7
commit
59d2db75af
12 changed files with 906 additions and 212 deletions
152
app.py
152
app.py
|
|
@ -43,13 +43,15 @@ if __name__ == "__main__":
|
|||
sys.exit(0)
|
||||
|
||||
import db
|
||||
import gemeinsam
|
||||
import ordnerwahl
|
||||
import xlsx_io
|
||||
import firma as firma_lib
|
||||
import bericht_pdf
|
||||
import ablage
|
||||
from modell import MONATE, STANDARD_SATZ, summiere, eur, eur_kurz, doppelte_nummern
|
||||
from modell import (MONATE, STANDARD_SATZ, summiere, eur, eur_kurz,
|
||||
aufgehobene, doppelte_nummern)
|
||||
from gui_monat import MonatsTab, SummenLeiste, WARNUNG
|
||||
from gui_dialoge import BuchungDialog
|
||||
|
||||
# Als EXE liegt alles in einem eigenen Datenordner - siehe ablage.py.
|
||||
BASE_DIR = ablage.daten_ordner()
|
||||
|
|
@ -57,6 +59,12 @@ JOURNAL = os.path.join(BASE_DIR, "journal.sqlite3")
|
|||
LOGDATEI = os.path.join(BASE_DIR, "beherbergungssteuer.log")
|
||||
BERICHTE = os.path.join(BASE_DIR, "berichte")
|
||||
|
||||
# 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"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)-8s %(message)s",
|
||||
|
|
@ -73,6 +81,7 @@ class App(tk.Tk):
|
|||
self.minsize(820, 520)
|
||||
|
||||
self.journal = db.Journal(JOURNAL)
|
||||
self.buch = None # gemeinsames Nummernbuch, siehe gemeinsam.py
|
||||
self.satz = float(self.journal.hole("satz", str(STANDARD_SATZ)))
|
||||
self.pdf_ordner = self.journal.hole("pdf_ordner", "")
|
||||
self.jahr = tk.IntVar(value=int(self.journal.hole("jahr", str(date.today().year))))
|
||||
|
|
@ -94,6 +103,7 @@ class App(tk.Tk):
|
|||
self.aktualisieren()
|
||||
if self.pdf_ordner and os.path.isdir(self.pdf_ordner):
|
||||
self.after(200, lambda: self.einlesen(still=True))
|
||||
self.after(400, self._frage_gemeinsam_falls_noetig)
|
||||
|
||||
# ------------------------------------------------------------ Aufbau ----
|
||||
def _menue(self):
|
||||
|
|
@ -118,6 +128,8 @@ class App(tk.Tk):
|
|||
daten.add_command(label="Steuersatz ändern…", command=self.satz_aendern)
|
||||
daten.add_command(label="Firmendaten des Rechnungstools wählen…",
|
||||
command=self.firma_waehlen)
|
||||
daten.add_command(label="Gemeinsamer Ordner (Nummernbuch)…",
|
||||
command=self.dlg_gemeinsam)
|
||||
leiste.add_cascade(label="Daten", menu=daten)
|
||||
self.config(menu=leiste)
|
||||
|
||||
|
|
@ -149,8 +161,7 @@ class App(tk.Tk):
|
|||
self.blaetter.pack(fill="both", expand=True, padx=10, pady=(0, 6))
|
||||
self.monate = {}
|
||||
for m in range(1, 13):
|
||||
tab = MonatsTab(self.blaetter, m, bei_doppelklick=self.buchung_bearbeiten,
|
||||
bei_haken=self.papierkorb_auffrischen)
|
||||
tab = MonatsTab(self.blaetter, m, bei_haken=self.papierkorb_auffrischen)
|
||||
self.blaetter.add(tab, text=MONATE[m - 1])
|
||||
self.monate[m] = tab
|
||||
self.blaetter.select(min(date.today().month, 12) - 1)
|
||||
|
|
@ -174,6 +185,7 @@ class App(tk.Tk):
|
|||
# wenn es wirklich welche gibt - im Normalfall soll hier nichts stehen
|
||||
# und auch kein leerer Streifen Platz wegnehmen.
|
||||
self._doppelte = {}
|
||||
self._aufgehoben = set()
|
||||
self.doppelt_leiste = ttk.Frame(rahmen)
|
||||
self.knopf_doppelte = ttk.Button(self.doppelt_leiste,
|
||||
text="Doppelte Rechnungen anzeigen…",
|
||||
|
|
@ -206,6 +218,9 @@ class App(tk.Tk):
|
|||
# Ueber ALLE Buchungen des Jahres, nicht ueber die gefilterten: sonst
|
||||
# versteckt die Suche den zweiten Beleg und die Dublette waere weg.
|
||||
self._doppelte = doppelte_nummern(alle)
|
||||
# Was ein Storno aufgehoben hat, steht weiter da - aber grau, und es
|
||||
# zaehlt nicht mehr als offene Rechnung.
|
||||
self._aufgehoben = aufgehobene(alle)
|
||||
if self._doppelte:
|
||||
# Gehoert ins Log: der Amtsbericht wird aus diesen Zeilen gebaut, und
|
||||
# spaeter soll nachvollziehbar sein, wann der Fall zuerst dastand.
|
||||
|
|
@ -214,7 +229,8 @@ class App(tk.Tk):
|
|||
", ".join(f"{nr} ({len(v)}x)" for nr, v in self._doppelte.items()))
|
||||
|
||||
for m in range(1, 13):
|
||||
self.monate[m].fuellen([b for b in sichtbar if b.monat == m], self._doppelte)
|
||||
self.monate[m].fuellen([b for b in sichtbar if b.monat == m],
|
||||
self._doppelte, self._aufgehoben)
|
||||
# Reiter des Monats markieren, damit man die Dublette auch dann
|
||||
# sieht, wenn gerade ein anderer Monat offen ist.
|
||||
betroffen = any(b.voll_nummer in self._doppelte for b in alle if b.monat == m)
|
||||
|
|
@ -245,6 +261,85 @@ class App(tk.Tk):
|
|||
self.status.config(text=text)
|
||||
log.info(text)
|
||||
|
||||
# ---- Gemeinsames Nummernbuch -------------------------------------------
|
||||
def _buch_oeffnen(self):
|
||||
ordner = self.journal.hole("gemeinsam_ordner", "")
|
||||
if not ordner:
|
||||
self.buch = None
|
||||
return
|
||||
taugt, grund = gemeinsam.ordner_taugt(ordner)
|
||||
if not taugt:
|
||||
self.buch = None
|
||||
log.warning("Nummernbuch nicht erreichbar: %s", grund)
|
||||
return
|
||||
self.buch = gemeinsam.Nummernbuch(ordner)
|
||||
|
||||
def _frage_gemeinsam_falls_noetig(self):
|
||||
"""Beim ersten Start nach dem gemeinsamen Ordner fragen.
|
||||
|
||||
Das Buch ist der Weg, auf dem das Rechnungstool die Nummern aus dem
|
||||
Altbestand erfaehrt - zu denen es keine PDF gibt und die es sonst ein
|
||||
zweites Mal vergeben wuerde.
|
||||
"""
|
||||
self._buch_oeffnen()
|
||||
if self.buch is None and not TESTLAUF:
|
||||
self.dlg_gemeinsam(erststart=True)
|
||||
self._buch_fuellen()
|
||||
|
||||
def dlg_gemeinsam(self, erststart=False):
|
||||
ordner = ordnerwahl.ordner_abfragen(self, "Steuerjournal",
|
||||
self.journal.hole("gemeinsam_ordner", ""))
|
||||
if not ordner:
|
||||
if erststart:
|
||||
messagebox.showwarning(
|
||||
"Ohne gemeinsamen Ordner",
|
||||
"Es wurde kein gemeinsamer Ordner gewählt.\n\n"
|
||||
"Das Rechnungstool erfährt dann nicht, welche Nummern hier schon "
|
||||
"vergeben sind - auch nicht die aus der alten Excel-Tabelle.\n\n"
|
||||
"Nachholen: Daten → Gemeinsamer Ordner (Nummernbuch).", parent=self)
|
||||
return
|
||||
self.journal.setze("gemeinsam_ordner", ordner)
|
||||
self._buch_oeffnen()
|
||||
self._buch_fuellen(melden=True)
|
||||
|
||||
def _buch_fuellen(self, melden=False):
|
||||
"""Alle Buchungen ins gemeinsame Buch schreiben.
|
||||
|
||||
Auch die aus der Excel-Mappe und die von Hand angelegten - genau die
|
||||
kennt das Rechnungstool sonst nicht. Faellt das Buch aus (Netzlaufwerk
|
||||
weg), passiert hier nichts weiter: das Journal arbeitet ohne es.
|
||||
"""
|
||||
if self.buch is None:
|
||||
return
|
||||
anzahl = 0
|
||||
try:
|
||||
for b in self.journal.buchungen():
|
||||
self.buch.eintragen(
|
||||
b.voll_nummer, jahr=b.jahr, datum=b.datum.isoformat(),
|
||||
nachname=b.nachname, naechte=int(b.naechte or 0),
|
||||
zwischensumme=float(b.gezahlt or 0), steuer_satz=float(b.satz or 0),
|
||||
steuer_betrag=float(b.steuer or 0),
|
||||
gesamt=float(b.gezahlt or 0) + float(b.steuer or 0),
|
||||
art=b.art or "rechnung", storno_zu=b.storno_zu or "",
|
||||
vorgang=b.vorgang or "", quelle="journal", herkunft=b.quelle,
|
||||
pdf_pfad=b.pdf_pfad or "")
|
||||
anzahl += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("Nummernbuch: Schreiben fehlgeschlagen (%s)", e)
|
||||
if melden:
|
||||
messagebox.showwarning(
|
||||
"Nummernbuch nicht erreichbar",
|
||||
f"Die Nummern konnten nicht eingetragen werden:\n{e}", parent=self)
|
||||
return
|
||||
log.info("Nummernbuch: %d Buchungen eingetragen", anzahl)
|
||||
if melden:
|
||||
messagebox.showinfo(
|
||||
"Nummernbuch",
|
||||
f"{anzahl} Rechnungsnummern stehen jetzt im gemeinsamen Buch.\n\n"
|
||||
"Wähle im Rechnungstool denselben Ordner - dann vergibt es keine "
|
||||
"dieser Nummern noch einmal.", parent=self)
|
||||
self.melde(f"Nummernbuch: {anzahl} Nummern eingetragen")
|
||||
|
||||
# ---- Doppelt vergebene Rechnungsnummern --------------------------------
|
||||
def _doppelte_melden(self):
|
||||
"""Warnleiste zeigen, solange es Dubletten gibt - sonst verschwindet sie."""
|
||||
|
|
@ -260,8 +355,9 @@ class App(tk.Tk):
|
|||
self.warnung.config(text=(
|
||||
f"⚠ {len(self._doppelte)} Rechnungsnummer(n) doppelt vergeben: "
|
||||
+ "; ".join(teile) + (f" … und {rest} weitere" if rest else "")
|
||||
+ ". Die Beträge stehen trotzdem in allen Summen und im Amtsbericht – "
|
||||
"hier wird nichts stillschweigend weggerechnet."))
|
||||
+ ". Jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG) – eine "
|
||||
"der beiden im Rechnungstool stornieren und mit freier Nummer neu "
|
||||
"ausstellen. Bis dahin stehen die Beträge in allen Summen."))
|
||||
self.doppelt_leiste.pack(fill="x", pady=(0, 4), before=self.fuss)
|
||||
|
||||
def doppelte_zeigen(self):
|
||||
|
|
@ -277,11 +373,14 @@ class App(tk.Tk):
|
|||
f"Jahr {self.jahr.get()}: diese Rechnungsnummern kommen mehrfach vor. "
|
||||
"Jede Nummer darf es nur einmal geben (§ 14 Abs. 4 Nr. 4 UStG).\n\n"
|
||||
"Alle Beträge sind in den Summen und im Bericht fürs Amt enthalten – hier wird "
|
||||
"nichts weggerechnet. Zwei Zeilen zur selben Rechnung (z. B. einmal aus dem "
|
||||
"Excel-Altbestand und einmal aus der PDF) melden die Übernachtung doppelt ans "
|
||||
"Amt; zwei verschiedene Gäste unter einer Nummer sind ein Fehler im "
|
||||
"Rechnungstool. Was zu tun ist, entscheidest du – das Programm ändert nichts "
|
||||
"von allein.")).pack(anchor="w", pady=(0, 10))
|
||||
"nichts weggerechnet.\n\n"
|
||||
"SO WIRD ES GERADEGERÜCKT: eine der beiden Rechnungen im Rechnungstool "
|
||||
"stornieren und mit einer freien Nummer neu ausstellen. Das Storno trägt "
|
||||
"Nummer, Datum UND Empfänger der aufgehobenen Rechnung – bei einer doppelt "
|
||||
"vergebenen Nummer wäre die Nummer allein kein eindeutiger Bezug, und "
|
||||
"§ 31 Abs. 5 UStDV verlangt genau den. Danach steht die Zeile hier grau als "
|
||||
"„storniert“ und die Warnung verschwindet von allein.")).pack(
|
||||
anchor="w", pady=(0, 10))
|
||||
|
||||
innen = ttk.Frame(rahmen)
|
||||
innen.pack(fill="both", expand=True)
|
||||
|
|
@ -351,6 +450,8 @@ class App(tk.Tk):
|
|||
self.config(cursor="")
|
||||
|
||||
self.aktualisieren()
|
||||
# Was neu eingelesen wurde, gehoert auch ins gemeinsame Buch.
|
||||
self._buch_fuellen()
|
||||
text = f"{neu} neu, {akt} aktualisiert, {unveraendert} unverändert"
|
||||
if fehler:
|
||||
text += f", {len(fehler)} übersprungen"
|
||||
|
|
@ -365,26 +466,13 @@ class App(tk.Tk):
|
|||
f"{len(fehler)} Datei(en) sind keine lesbare Rechnung:\n\n{namen}",
|
||||
parent=self)
|
||||
|
||||
# Es gibt bewusst KEIN "Buchung von Hand anlegen" mehr: jede Zeile im Journal
|
||||
# gehoert zu einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht,
|
||||
# schreibt die Rechnung - sonst meldet das Journal ans Amt, was in keinem
|
||||
# Rechnungsordner steht. Bestehende Zeilen lassen sich weiter bearbeiten.
|
||||
def buchung_bearbeiten(self, buchung):
|
||||
d = BuchungDialog(self, buchung=buchung, satz=self.satz)
|
||||
self.wait_window(d)
|
||||
if d.loeschen:
|
||||
self.journal.loeschen(buchung.id)
|
||||
self.aktualisieren()
|
||||
self.melde(f"Buchung {buchung.voll_nummer} gelöscht")
|
||||
return
|
||||
if d.ergebnis:
|
||||
# Nummer/Jahr können geändert worden sein -> alte Zeile weicht der neuen
|
||||
if d.ergebnis.schluessel != buchung.schluessel and buchung.id:
|
||||
self.journal.loeschen(buchung.id)
|
||||
self.journal.speichern(d.ergebnis)
|
||||
self.aktualisieren()
|
||||
self.melde(f"Buchung {d.ergebnis.voll_nummer} geändert")
|
||||
|
||||
# Im Journal wird NICHTS von Hand geaendert - weder angelegt noch bearbeitet.
|
||||
# Jede Zeile gehoert zu einer Rechnung aus dem Rechnungstool; wer hier Betrag
|
||||
# oder Nummer verstellt, meldet dem Amt etwas anderes, als auf dem Beleg
|
||||
# steht, und beim naechsten Einlesen kaeme die Aenderung ohnehin zurueck.
|
||||
# Stimmt eine Rechnung nicht, wird sie im Rechnungstool storniert oder
|
||||
# berichtigt. Loeschen bleibt moeglich (Entf / Papierkorb) - es entfernt nur
|
||||
# die Zeile aus dem Zwischenspeicher, die PDF bleibt liegen.
|
||||
def _markierte(self):
|
||||
return self.monate[self._monat].markierte()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue