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
15
README.md
15
README.md
|
|
@ -27,13 +27,15 @@ Knopfdruck die Excel-Mappe fürs Amt.
|
|||
Über das Menü gibt es zusätzlich den **Jahresbericht**. Gedruckt wird aus dem
|
||||
PDF-Programm heraus.
|
||||
|
||||
Eine Zeile per **Doppelklick** bearbeiten, mit **Entf** aus dem Journal löschen
|
||||
(die PDF bleibt liegen und wird beim nächsten Einlesen wieder gefunden).
|
||||
**Im Journal wird nichts von Hand geändert** (seit v1.4): weder anlegen noch
|
||||
bearbeiten. Jede Zeile gehört zu einer Rechnung aus dem Rechnungstool – auch die
|
||||
Barzahlung. Wer hier Betrag oder Nummer verstellte, meldete dem Amt etwas anderes,
|
||||
als auf dem Beleg steht; beim nächsten Einlesen käme die Änderung ohnehin zurück.
|
||||
Stimmt eine Rechnung nicht, wird sie **im Rechnungstool** storniert oder berichtigt.
|
||||
|
||||
**Buchungen von Hand anlegen gibt es nicht** (seit v1.4 ausgebaut): jede Zeile im
|
||||
Journal gehört zu einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht –
|
||||
auch bei Barzahlung –, schreibt die Rechnung. Sonst meldet das Journal ans Amt, was
|
||||
in keinem Rechnungsordner steht, und der Bericht lässt sich nicht mehr belegen.
|
||||
Löschen bleibt: **Entf** oder die Haken-Spalte mit dem Papierkorb. Das entfernt nur
|
||||
die Zeile aus dem Zwischenspeicher – die PDF bleibt liegen und wird beim nächsten
|
||||
Einlesen wieder gefunden.
|
||||
|
||||
Der Briefkopf des Berichts (Firma, Logo, Bankzeile) wird aus der `config.json` des
|
||||
Rechnungstools gelesen – also nur an einer Stelle gepflegt.
|
||||
|
|
@ -95,7 +97,6 @@ Tabelle grau dargestellt. Rechnungsnummern werden dabei vereinheitlicht
|
|||
|-------|-------|
|
||||
| `app.py` | Fenster, Menü, Aktionen |
|
||||
| `gui_monat.py` | ein Monatsblatt (Tabelle + Summenzeile) |
|
||||
| `gui_dialoge.py` | eine bestehende Buchung nachbessern (Anlegen gibt es nicht) |
|
||||
| `pdf_parser.py` | Rechnungs-PDF → Buchung |
|
||||
| `bericht_pdf.py` | Monats-/Jahresbericht fürs Amt (reportlab) |
|
||||
| `firma.py` | Briefkopf aus der `config.json` des Rechnungstools |
|
||||
|
|
|
|||
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()
|
||||
|
||||
|
|
|
|||
86
db.py
86
db.py
|
|
@ -32,6 +32,10 @@ CREATE TABLE IF NOT EXISTS buchungen (
|
|||
gezahlt REAL NOT NULL DEFAULT 0,
|
||||
satz REAL NOT NULL DEFAULT 5,
|
||||
quelle TEXT NOT NULL DEFAULT 'pdf',
|
||||
art TEXT NOT NULL DEFAULT 'rechnung',
|
||||
storno_zu TEXT NOT NULL DEFAULT '',
|
||||
storno_datum TEXT NOT NULL DEFAULT '',
|
||||
vorgang TEXT NOT NULL DEFAULT '',
|
||||
pdf_pfad TEXT NOT NULL DEFAULT '',
|
||||
pdf_mtime REAL NOT NULL DEFAULT 0,
|
||||
pdf_groesse INTEGER NOT NULL DEFAULT 0
|
||||
|
|
@ -59,6 +63,7 @@ class Journal:
|
|||
self.con.row_factory = sqlite3.Row
|
||||
self._alte_nummern_sperre_loesen()
|
||||
self.con.executescript(SCHEMA)
|
||||
self._spalten_ergaenzen()
|
||||
self.con.commit()
|
||||
log.info("Journal %s (%s)", pfad, "neu angelegt" if neu else "geöffnet")
|
||||
|
||||
|
|
@ -114,6 +119,21 @@ class Journal:
|
|||
log.info("Umbau fertig (%d Buchungen) - doppelte Rechnungsnummern bleiben "
|
||||
"ab jetzt sichtbar", genommen)
|
||||
|
||||
def _spalten_ergaenzen(self):
|
||||
"""Spalten nachziehen, die es in aelteren Journalen noch nicht gab.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS aendert eine bestehende Tabelle nicht - ohne
|
||||
das hier faenden die neuen Felder in einer alten Datei kein Zuhause.
|
||||
"""
|
||||
da = {z["name"] for z in self.con.execute("PRAGMA table_info('buchungen')")}
|
||||
for name, vorgabe in (("art", "'rechnung'"), ("storno_zu", "''"),
|
||||
("storno_datum", "''"), ("vorgang", "''")):
|
||||
if name not in da:
|
||||
self.con.execute(
|
||||
f"ALTER TABLE buchungen ADD COLUMN {name} TEXT NOT NULL DEFAULT {vorgabe}")
|
||||
log.info("Journal um Spalte %s ergänzt", name)
|
||||
self.con.commit()
|
||||
|
||||
def schliessen(self):
|
||||
self.con.close()
|
||||
|
||||
|
|
@ -156,6 +176,10 @@ class Journal:
|
|||
datum=datetime.strptime(r["datum"], "%Y-%m-%d").date(),
|
||||
nachname=r["nachname"], naechte=r["naechte"], gezahlt=r["gezahlt"],
|
||||
satz=r["satz"], quelle=r["quelle"], pdf_pfad=r["pdf_pfad"],
|
||||
art=(r["art"] if "art" in r.keys() else "rechnung"),
|
||||
storno_zu=(r["storno_zu"] if "storno_zu" in r.keys() else ""),
|
||||
storno_datum=(r["storno_datum"] if "storno_datum" in r.keys() else ""),
|
||||
vorgang=(r["vorgang"] if "vorgang" in r.keys() else ""),
|
||||
)
|
||||
|
||||
def _pdf_bekannt(self, pfad: str) -> bool:
|
||||
|
|
@ -179,16 +203,20 @@ class Journal:
|
|||
cur = self.con.execute(
|
||||
"INSERT INTO buchungen "
|
||||
" (jahr, rechnungsnummer, datum, nachname, naechte, gezahlt, satz, "
|
||||
" quelle, pdf_pfad, pdf_mtime, pdf_groesse) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?) "
|
||||
" quelle, pdf_pfad, pdf_mtime, pdf_groesse, art, storno_zu, "
|
||||
" storno_datum, vorgang) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(pdf_pfad) WHERE pdf_pfad <> '' DO UPDATE SET "
|
||||
" jahr=excluded.jahr, rechnungsnummer=excluded.rechnungsnummer, "
|
||||
" datum=excluded.datum, nachname=excluded.nachname, naechte=excluded.naechte, "
|
||||
" gezahlt=excluded.gezahlt, satz=excluded.satz, quelle=excluded.quelle, "
|
||||
" pdf_pfad=excluded.pdf_pfad, pdf_mtime=excluded.pdf_mtime, "
|
||||
" pdf_groesse=excluded.pdf_groesse",
|
||||
" pdf_groesse=excluded.pdf_groesse, art=excluded.art, "
|
||||
" storno_zu=excluded.storno_zu, storno_datum=excluded.storno_datum, "
|
||||
" vorgang=excluded.vorgang",
|
||||
(b.jahr, b.rechnungsnummer, b.datum.isoformat(), b.nachname, int(b.naechte or 0),
|
||||
float(b.gezahlt or 0), float(b.satz or 0), b.quelle, b.pdf_pfad, mtime, groesse))
|
||||
float(b.gezahlt or 0), float(b.satz or 0), b.quelle, b.pdf_pfad, mtime, groesse,
|
||||
b.art or "rechnung", b.storno_zu or "", b.storno_datum or "", b.vorgang or ""))
|
||||
self.con.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
|
@ -214,10 +242,15 @@ class Journal:
|
|||
|
||||
Gibt (neu, aktualisiert, uebersprungen, fehler) zurück.
|
||||
"""
|
||||
from pdf_parser import parse_pdf, keine_buchung, ParserFehler # lokal: hält modell/db leichtgewichtig
|
||||
from pdf_parser import (parse_pdf, berichtigung_daten,
|
||||
ParserFehler) # lokal: hält modell/db leichtgewichtig
|
||||
|
||||
neu = akt = uebersprungen = 0
|
||||
fehler = []
|
||||
# Berichtigungsblaetter werden gesammelt und ERST AM ENDE angewandt: das
|
||||
# Blatt kann vor "seiner" Rechnung im Ordner liegen, und dann gaebe es
|
||||
# noch nichts zu berichtigen.
|
||||
berichtigungen = []
|
||||
if not os.path.isdir(ordner):
|
||||
return 0, 0, 0, [(ordner, "Ordner existiert nicht")]
|
||||
|
||||
|
|
@ -230,10 +263,14 @@ class Journal:
|
|||
uebersprungen += 1
|
||||
continue
|
||||
# Berichtigungsblatt: traegt die Nummer der Rechnung und Nullen.
|
||||
# Frueher haette es die echte Buchung ueberschrieben.
|
||||
if keine_buchung(pfad):
|
||||
# Frueher haette es die echte Buchung ueberschrieben. Es bucht
|
||||
# nichts - aber die berichtigten Angaben zum Gast gehoeren
|
||||
# uebernommen, sonst steht im Amtsbericht weiter der falsche Name.
|
||||
berichtigt = berichtigung_daten(pfad)
|
||||
if berichtigt is not None:
|
||||
self.con.execute("DELETE FROM buchungen WHERE pdf_pfad=?",
|
||||
(os.path.abspath(pfad),))
|
||||
berichtigungen.append(berichtigt)
|
||||
continue
|
||||
try:
|
||||
b = parse_pdf(pfad, standard_satz)
|
||||
|
|
@ -255,6 +292,37 @@ class Journal:
|
|||
else:
|
||||
neu += 1
|
||||
|
||||
log.info("Scan fertig: %d neu, %d aktualisiert, %d unverändert, %d Fehler",
|
||||
neu, akt, uebersprungen, len(fehler))
|
||||
berichtigt_anzahl = self._berichtigungen_anwenden(berichtigungen)
|
||||
log.info("Scan fertig: %d neu, %d aktualisiert, %d unverändert, %d berichtigt, "
|
||||
"%d Fehler", neu, akt, uebersprungen, berichtigt_anzahl, len(fehler))
|
||||
return neu, akt, uebersprungen, fehler
|
||||
|
||||
def _berichtigungen_anwenden(self, berichtigungen):
|
||||
"""Berichtigte Empfaengerangaben in die zugehoerige Buchung uebernehmen.
|
||||
|
||||
Nach dem Datum der Berichtigung sortiert - gibt es zu einer Rechnung
|
||||
mehrere Blaetter, gilt das juengste.
|
||||
"""
|
||||
from modell import zerlege_nummer
|
||||
|
||||
def wann(k):
|
||||
teile = str(k.get("berichtigt_am") or "").split(".")
|
||||
return tuple(reversed(teile)) if len(teile) == 3 else ("",)
|
||||
|
||||
geaendert = 0
|
||||
for kopf in sorted(berichtigungen, key=wann):
|
||||
nachname = str(kopf.get("nachname") or "").strip()
|
||||
bezug = str(kopf.get("berichtigt_zu") or kopf.get("rechnungsnummer") or "")
|
||||
if not nachname or not bezug:
|
||||
continue
|
||||
jahr, nummer = zerlege_nummer(bezug, date.today().year)
|
||||
cur = self.con.execute(
|
||||
"UPDATE buchungen SET nachname=? "
|
||||
"WHERE jahr=? AND rechnungsnummer=? AND nachname<>?",
|
||||
(nachname, jahr, nummer, nachname))
|
||||
if cur.rowcount:
|
||||
geaendert += cur.rowcount
|
||||
log.info("Berichtigung %s-%s: Name jetzt %r", jahr, nummer, nachname)
|
||||
if geaendert:
|
||||
self.con.commit()
|
||||
return geaendert
|
||||
|
|
|
|||
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)
|
||||
144
gui_dialoge.py
144
gui_dialoge.py
|
|
@ -1,144 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Dialog zum Nachbessern einer Zeile, die schon im Journal steht.
|
||||
|
||||
Buchungen von Hand ANLEGEN gibt es bewusst nicht mehr: jede Zeile gehört zu
|
||||
einer Rechnung aus dem Rechnungstool. Wer eine Buchung braucht, schreibt die
|
||||
Rechnung - sonst meldet das Journal ans Amt, was in keinem Rechnungsordner
|
||||
steht.
|
||||
|
||||
Schließen mit ESC, Speichern mit Enter oder Knopf.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from datetime import datetime
|
||||
|
||||
from modell import (Buchung, STANDARD_SATZ, steuer_betrag, eur, zahl_aus_text,
|
||||
zerlege_nummer)
|
||||
|
||||
|
||||
class BuchungDialog(tk.Toplevel):
|
||||
"""
|
||||
Modal. Nach dem Schließen steht das Ergebnis in
|
||||
|
||||
.ergebnis – die gespeicherte Buchung (oder None, wenn abgebrochen)
|
||||
.loeschen – True, wenn die Buchung gelöscht werden soll
|
||||
"""
|
||||
|
||||
def __init__(self, master, buchung: Buchung, satz: float = STANDARD_SATZ):
|
||||
super().__init__(master)
|
||||
self.ergebnis = None
|
||||
self.loeschen = False
|
||||
self._vorlage = buchung
|
||||
self._jahr = buchung.jahr
|
||||
|
||||
self.title("Buchung bearbeiten")
|
||||
self.resizable(False, False)
|
||||
self.transient(master)
|
||||
self.grab_set()
|
||||
|
||||
rahmen = ttk.Frame(self, padding=14)
|
||||
rahmen.grid(sticky="nsew")
|
||||
|
||||
self.felder = {}
|
||||
zeilen = [
|
||||
("datum", "Rechnungsdatum", buchung.datum.strftime("%d.%m.%Y")),
|
||||
("nummer", "Rechnungsnummer", buchung.rechnungsnummer),
|
||||
("nachname", "Nachname", buchung.nachname),
|
||||
("naechte", "Übernachtungen", str(buchung.naechte)),
|
||||
("gezahlt", "Gezahlt (Zwischensumme)", f"{buchung.gezahlt:.2f}".replace(".", ",")),
|
||||
("satz", "Steuersatz %", f"{(buchung.satz or satz):g}".replace(".", ",")),
|
||||
]
|
||||
for i, (schluessel, beschriftung, wert) in enumerate(zeilen):
|
||||
ttk.Label(rahmen, text=beschriftung + ":").grid(row=i, column=0, sticky="w", pady=3, padx=(0, 10))
|
||||
e = ttk.Entry(rahmen, width=26)
|
||||
e.insert(0, wert)
|
||||
e.grid(row=i, column=1, sticky="ew", pady=3)
|
||||
e.bind("<KeyRelease>", lambda _e: self._vorschau())
|
||||
self.felder[schluessel] = e
|
||||
|
||||
self.vorschau = ttk.Label(rahmen, text="", foreground="black",
|
||||
font=("Segoe UI", 10, "bold"), anchor="e")
|
||||
self.vorschau.grid(row=len(zeilen), column=0, columnspan=2, sticky="ew", pady=(10, 4))
|
||||
|
||||
knoepfe = ttk.Frame(rahmen)
|
||||
knoepfe.grid(row=len(zeilen) + 1, column=0, columnspan=2, sticky="ew", pady=(6, 0))
|
||||
ttk.Button(knoepfe, text="Speichern", command=self._speichern) \
|
||||
.pack(side="left", fill="x", expand=True)
|
||||
ttk.Button(knoepfe, text="Löschen", command=self._loeschen) \
|
||||
.pack(side="left", fill="x", expand=True, padx=(8, 0))
|
||||
|
||||
rahmen.columnconfigure(1, weight=1)
|
||||
self.bind("<Escape>", lambda _e: self.destroy())
|
||||
self.bind("<Return>", lambda _e: self._speichern())
|
||||
self.felder["datum"].focus_set()
|
||||
self._vorschau()
|
||||
|
||||
self.update_idletasks()
|
||||
x = master.winfo_rootx() + (master.winfo_width() - self.winfo_width()) // 2
|
||||
y = master.winfo_rooty() + 120
|
||||
self.geometry(f"+{max(0, x)}+{max(0, y)}")
|
||||
|
||||
# ---- Eingaben lesen ----------------------------------------------------
|
||||
def _werte(self):
|
||||
gezahlt = zahl_aus_text(self.felder["gezahlt"].get())
|
||||
satz = zahl_aus_text(self.felder["satz"].get())
|
||||
return gezahlt, satz
|
||||
|
||||
def _vorschau(self):
|
||||
gezahlt, satz = self._werte()
|
||||
self.vorschau.config(text=f"Beherbergungssteuer: {eur(steuer_betrag(gezahlt, satz))}")
|
||||
|
||||
def _loeschen(self):
|
||||
b = self._vorlage
|
||||
hinweis = ("\n\nDie Rechnungs-PDF bleibt liegen – beim nächsten Einlesen "
|
||||
"taucht die Buchung wieder auf.") if b.quelle == "pdf" else ""
|
||||
if not messagebox.askyesno(
|
||||
"Buchung löschen",
|
||||
f"Rechnung {b.voll_nummer} ({b.nachname or 'ohne Namen'}) "
|
||||
f"aus dem Journal löschen?{hinweis}", parent=self):
|
||||
return
|
||||
self.loeschen = True
|
||||
self.destroy()
|
||||
|
||||
def _speichern(self):
|
||||
text_datum = self.felder["datum"].get().strip()
|
||||
datum = None
|
||||
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
|
||||
try:
|
||||
datum = datetime.strptime(text_datum, fmt).date()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if not datum:
|
||||
messagebox.showerror("Datum", f"Datum nicht lesbar: {text_datum}\n"
|
||||
"Erwartet wird z.B. 12.07.2026", parent=self)
|
||||
return
|
||||
|
||||
# "238" und "2026-238" werden beide angenommen
|
||||
jahr, nummer = zerlege_nummer(self.felder["nummer"].get(), datum.year)
|
||||
if not nummer:
|
||||
messagebox.showerror("Rechnungsnummer", "Bitte eine Rechnungsnummer eintragen "
|
||||
"(z.B. 238).", parent=self)
|
||||
return
|
||||
|
||||
gezahlt, satz = self._werte()
|
||||
if gezahlt <= 0:
|
||||
messagebox.showerror("Gezahlt", "Bitte den Rechnungsbetrag (Zwischensumme) "
|
||||
"eintragen.", parent=self)
|
||||
return
|
||||
|
||||
self.ergebnis = Buchung(
|
||||
datum=datum,
|
||||
rechnungsnummer=nummer,
|
||||
jahr=jahr,
|
||||
nachname=self.felder["nachname"].get().strip(),
|
||||
naechte=int(zahl_aus_text(self.felder["naechte"].get()) or 0),
|
||||
gezahlt=gezahlt,
|
||||
satz=satz,
|
||||
quelle=self._vorlage.quelle,
|
||||
pdf_pfad=self._vorlage.pdf_pfad,
|
||||
id=self._vorlage.id,
|
||||
)
|
||||
self.destroy()
|
||||
41
gui_monat.py
41
gui_monat.py
|
|
@ -16,6 +16,7 @@ SPALTEN = [
|
|||
("haken", HAKEN_AUS, 34, "center"), # zum Anhaken fürs Löschen
|
||||
("datum", "Datum", 90, "center"),
|
||||
("nummer", "Rechnung", 95, "center"),
|
||||
("art", "Art", 132, "center"), # Storno zu ... / storniert / Neuausstellung
|
||||
("nachname", "Nachname", 200, "w"),
|
||||
("naechte", "Nächte", 65, "center"),
|
||||
("gezahlt", "Gezahlt", 105, "e"),
|
||||
|
|
@ -30,6 +31,7 @@ BG = "#eef1ee" # heller Streifen wie im PDF-Bericht
|
|||
RAHMEN = "#c3ccc5"
|
||||
GRUEN = "#1f5c3d" # Campinghof-Grün für den Steuerbetrag
|
||||
WARNUNG = "#a00000" # doppelt vergebene Rechnungsnummer
|
||||
GRAU = "#8a8a8a" # aufgehobene Buchung - steht da, zaehlt aber nicht mehr
|
||||
|
||||
|
||||
class SummenLeiste(tk.Frame):
|
||||
|
|
@ -87,10 +89,9 @@ class SummenLeiste(tk.Frame):
|
|||
class MonatsTab(ttk.Frame):
|
||||
"""Tabelle eines Monats. `bei_auswahl` wird mit der markierten Buchung gerufen."""
|
||||
|
||||
def __init__(self, master, monat: int, bei_doppelklick=None, bei_haken=None):
|
||||
def __init__(self, master, monat: int, bei_haken=None):
|
||||
super().__init__(master, padding=(8, 8, 8, 6))
|
||||
self.monat = monat
|
||||
self.bei_doppelklick = bei_doppelklick
|
||||
self.bei_haken = bei_haken # meldet dem Fenster, dass sich die Auswahl änderte
|
||||
self._buchungen = []
|
||||
self._angehakt = set() # ids der angehakten Buchungen
|
||||
|
|
@ -111,6 +112,9 @@ class MonatsTab(ttk.Frame):
|
|||
self.baum.tag_configure(tag, foreground="black")
|
||||
# Rot ist einzig für den Fehlerfall reserviert: die Nummer gibt es zweimal.
|
||||
self.baum.tag_configure("doppelt", foreground=WARNUNG)
|
||||
# Storno und die von ihm aufgehobene Rechnung: beide bleiben stehen
|
||||
# (geloescht wird nichts), sie sind nur zurueckgenommen.
|
||||
self.baum.tag_configure("aufgehoben", foreground=GRAU)
|
||||
|
||||
self.summen = SummenLeiste(self)
|
||||
self.summen.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))
|
||||
|
|
@ -119,8 +123,8 @@ class MonatsTab(ttk.Frame):
|
|||
self.rowconfigure(0, weight=1)
|
||||
|
||||
self.baum.bind("<Button-1>", self._klick)
|
||||
if bei_doppelklick:
|
||||
self.baum.bind("<Double-1>", lambda _e: self._doppelklick())
|
||||
# Kein Doppelklick zum Bearbeiten: die Zahlen kommen aus der Rechnung,
|
||||
# nicht aus diesem Fenster.
|
||||
|
||||
# ---- Anhaken -----------------------------------------------------------
|
||||
def _klick(self, ereignis):
|
||||
|
|
@ -148,11 +152,6 @@ class MonatsTab(ttk.Frame):
|
|||
def haken_loesen(self):
|
||||
self._angehakt.clear()
|
||||
|
||||
def _doppelklick(self):
|
||||
b = self.markierte()
|
||||
if b:
|
||||
self.bei_doppelklick(b)
|
||||
|
||||
def markierte(self):
|
||||
"""Aktuell markierte Buchung oder None."""
|
||||
sel = self.baum.selection()
|
||||
|
|
@ -168,23 +167,39 @@ class MonatsTab(ttk.Frame):
|
|||
+ [schrift.measure("Nachname")])
|
||||
self.baum.column("nachname", width=breit + 24, minwidth=90)
|
||||
|
||||
def fuellen(self, buchungen, doppelte=()):
|
||||
def fuellen(self, buchungen, doppelte=(), aufgehoben=()):
|
||||
"""Zeilen des Monats. `doppelte` sind die doppelt vergebenen Nummern des
|
||||
ganzen JAHRES (aus modell.doppelte_nummern) - eine Nummer, deren Zwilling
|
||||
in einem anderen Monat liegt, gehört hier trotzdem rot markiert."""
|
||||
doppelte = set(doppelte)
|
||||
aufgehoben = set(aufgehoben)
|
||||
self._buchungen = sorted(buchungen, key=lambda b: (b.datum, b.rechnungsnummer))
|
||||
# Haken nur für Zeilen behalten, die es noch gibt
|
||||
self._angehakt &= {b.id for b in self._buchungen}
|
||||
self._namensspalte_anpassen()
|
||||
self.baum.delete(*self.baum.get_children())
|
||||
for i, b in enumerate(self._buchungen):
|
||||
zweimal = b.voll_nummer in doppelte
|
||||
self.baum.insert("", "end", iid=str(i),
|
||||
tags=("doppelt",) if zweimal else (b.quelle,), values=(
|
||||
weg = b.id in aufgehoben or bool(b.storno_zu)
|
||||
zweimal = b.voll_nummer in doppelte and not weg
|
||||
if weg:
|
||||
marke = ("aufgehoben",)
|
||||
elif zweimal:
|
||||
marke = ("doppelt",)
|
||||
else:
|
||||
marke = (b.quelle,)
|
||||
if b.storno_zu:
|
||||
art_text = f"Storno zu {b.storno_zu}"
|
||||
elif b.id in aufgehoben:
|
||||
art_text = "storniert"
|
||||
elif b.art == "korrektur":
|
||||
art_text = "Neuausstellung"
|
||||
else:
|
||||
art_text = ""
|
||||
self.baum.insert("", "end", iid=str(i), tags=marke, values=(
|
||||
HAKEN_AN if b.id in self._angehakt else HAKEN_AUS,
|
||||
b.datum.strftime("%d.%m.%Y"),
|
||||
("⚠ " + b.voll_nummer) if zweimal else b.voll_nummer,
|
||||
art_text,
|
||||
b.nachname,
|
||||
b.naechte,
|
||||
eur_kurz(b.gezahlt),
|
||||
|
|
|
|||
46
modell.py
46
modell.py
|
|
@ -50,6 +50,13 @@ class Buchung:
|
|||
quelle: str = "pdf" # pdf | xlsx | manuell
|
||||
pdf_pfad: str = ""
|
||||
id: int = 0
|
||||
# Aus den Kenndaten der PDF: was fuer ein Beleg ist das, und worauf bezieht
|
||||
# er sich. Ohne diese vier Felder ist eine Stornozeile im Journal nur eine
|
||||
# Buchung mit Minusbetraegen - man sieht nicht, welche Rechnung sie aufhebt.
|
||||
art: str = "rechnung" # rechnung | storno | korrektur
|
||||
storno_zu: str = "" # Nummer der aufgehobenen Rechnung
|
||||
storno_datum: str = "" # deren Datum (TT.MM.JJJJ) - Teil des Bezugs
|
||||
vorgang: str = "" # Klammer um Rechnung, Storno und Neuausstellung
|
||||
|
||||
@property
|
||||
def steuer(self) -> float:
|
||||
|
|
@ -70,6 +77,36 @@ class Buchung:
|
|||
return f"{self.jahr}-{self.rechnungsnummer}"
|
||||
|
||||
|
||||
def aufgehobene(buchungen) -> set:
|
||||
"""ids der Buchungen, die durch einen Storno aufgehoben sind.
|
||||
|
||||
Der Bezug ist Nummer + Datum + Nachname, nicht die Nummer allein. Genau
|
||||
darum geht es hier: bei einer DOPPELT vergebenen Nummer waere die Nummer
|
||||
kein eindeutiger Bezug - § 31 Abs. 5 UStDV verlangt aber, dass sich ein
|
||||
berichtigendes Dokument "spezifisch und eindeutig" auf die Rechnung
|
||||
bezieht. Steht auf dem Storno also 2026-020 vom 05.08. fuer Zweitgast, dann
|
||||
ist die Rechnung von Erstgast NICHT aufgehoben.
|
||||
"""
|
||||
stornos = [b for b in buchungen if b.storno_zu]
|
||||
if not stornos:
|
||||
return set()
|
||||
weg = set()
|
||||
for s in stornos:
|
||||
for b in buchungen:
|
||||
if b.id in weg or b is s or not b.id:
|
||||
continue
|
||||
if b.voll_nummer != s.storno_zu:
|
||||
continue
|
||||
# Datum und Name muessen passen, WENN der Storno sie nennt.
|
||||
if s.storno_datum and b.datum.strftime("%d.%m.%Y") != s.storno_datum:
|
||||
continue
|
||||
if s.nachname and b.nachname and s.nachname != b.nachname:
|
||||
continue
|
||||
weg.add(b.id)
|
||||
break
|
||||
return weg
|
||||
|
||||
|
||||
def doppelte_nummern(buchungen) -> dict:
|
||||
"""Rechnungsnummern, die mehr als einmal im Journal stehen.
|
||||
|
||||
|
|
@ -85,10 +122,15 @@ def doppelte_nummern(buchungen) -> dict:
|
|||
nur einmal geben (Paragraph 14 Abs. 4 Nr. 4 UStG) - was zu tun ist,
|
||||
entscheidet der Wirt, nicht das Programm.
|
||||
"""
|
||||
weg = aufgehobene(buchungen)
|
||||
nach_nummer = {}
|
||||
for b in buchungen:
|
||||
if b.rechnungsnummer:
|
||||
nach_nummer.setdefault(b.voll_nummer, []).append(b)
|
||||
# Stornos tragen eine eigene Nummer und sind nie eine Dublette. Und was
|
||||
# storniert wurde, ist keine offene Rechnung mehr - sonst bliebe die
|
||||
# Meldung stehen, obwohl der Fall richtiggestellt ist.
|
||||
if not b.rechnungsnummer or b.storno_zu or b.id in weg:
|
||||
continue
|
||||
nach_nummer.setdefault(b.voll_nummer, []).append(b)
|
||||
return {nr: sorted(liste, key=lambda b: (b.datum, b.id))
|
||||
for nr, liste in sorted(nach_nummer.items()) if len(liste) > 1}
|
||||
|
||||
|
|
|
|||
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
|
||||
|
|
@ -177,6 +177,19 @@ def _aus_metadaten(pfad: str):
|
|||
return daten
|
||||
|
||||
|
||||
def berichtigung_daten(pfad: str):
|
||||
"""Kenndaten eines Berichtigungsblatts (§ 31 Abs. 5 UStDV), sonst None.
|
||||
|
||||
Das Blatt bucht nichts - es reicht die Angaben zum Empfaenger nach. Genau
|
||||
die will das Journal aber uebernehmen: steht auf der Rechnung noch der
|
||||
falsche Name, muss der Amtsbericht den berichtigten zeigen.
|
||||
"""
|
||||
kopf = _aus_metadaten(pfad)
|
||||
if not kopf or kopf.get("art") != "berichtigung":
|
||||
return None
|
||||
return kopf
|
||||
|
||||
|
||||
def keine_buchung(pfad: str) -> bool:
|
||||
"""True fuer Blaetter, die keine Uebernachtung abrechnen.
|
||||
|
||||
|
|
@ -185,8 +198,7 @@ def keine_buchung(pfad: str) -> bool:
|
|||
die es berichtigt, und lauter Nullen. Wer es einliest, ueberschreibt damit
|
||||
die echte Buchung - im Amtsbericht fehlt die Uebernachtung dann.
|
||||
"""
|
||||
kopf = _aus_metadaten(pfad)
|
||||
return bool(kopf and kopf.get("art") == "berichtigung")
|
||||
return berichtigung_daten(pfad) is not None
|
||||
|
||||
|
||||
def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
|
||||
|
|
@ -205,6 +217,10 @@ def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
|
|||
satz=float(meta.get("steuer_satz") or 0) or standard_satz,
|
||||
quelle="pdf",
|
||||
pdf_pfad=os.path.abspath(pfad),
|
||||
art=str(meta.get("art") or "rechnung"),
|
||||
storno_zu=str(meta.get("storno_zu") or ""),
|
||||
storno_datum=str(meta.get("storno_datum") or ""),
|
||||
vorgang=str(meta.get("vorgang") or ""),
|
||||
)
|
||||
log.debug("aus Metadaten: %s | %s | %s | %s Nächte | %.2f €",
|
||||
os.path.basename(pfad), b.voll_nummer, b.nachname, b.naechte, b.gezahlt)
|
||||
|
|
|
|||
|
|
@ -94,12 +94,20 @@ pruefe(abs(s.steuer - 29.0) < 0.005, f"Steuer unveraendert 29,00 ({s.steuer})")
|
|||
|
||||
|
||||
def zeilen(monat):
|
||||
"""Was im Monatsblatt WIRKLICH steht: (Nachname, Nummerspalte, Tags)."""
|
||||
"""Was im Monatsblatt WIRKLICH steht: (Nachname, Nummerspalte, Tags).
|
||||
|
||||
Die Spalten kommen aus gui_monat.SPALTEN - nicht abgezaehlt, sonst
|
||||
verrutscht der Pruefstand still, sobald eine Spalte dazukommt (genau das
|
||||
ist beim Einbau der Spalte "Art" passiert).
|
||||
"""
|
||||
from gui_monat import SPALTEN
|
||||
namen = [sp[0] for sp in SPALTEN]
|
||||
i_name, i_nr = namen.index("nachname"), namen.index("nummer")
|
||||
baum = fenster.monate[monat].baum
|
||||
aus = []
|
||||
for iid in baum.get_children():
|
||||
w = baum.item(iid, "values")
|
||||
aus.append((w[3], w[2], tuple(baum.item(iid, "tags"))))
|
||||
aus.append((w[i_name], w[i_nr], tuple(baum.item(iid, "tags"))))
|
||||
return aus
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -68,7 +68,9 @@ for name in os.listdir(HIER):
|
|||
|
||||
|
||||
def starte():
|
||||
return subprocess.Popen([sys.executable, "app.py"], cwd=prog,
|
||||
# --test: kein Erststart-Dialog nach dem gemeinsamen Ordner. Die Sperre
|
||||
# selbst greift unabhaengig davon - genau die wird hier geprueft.
|
||||
return subprocess.Popen([sys.executable, "app.py", "--test"], cwd=prog,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
|
||||
|
||||
|
|
|
|||
255
pruef_storno_journal.py
Normal file
255
pruef_storno_journal.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand ueber BEIDE Programme: was das Rechnungstool storniert und
|
||||
berichtigt, muss im Steuerjournal ankommen.
|
||||
|
||||
Der Weg ist echt: das Rechnungstool schreibt die PDFs (Rechnung, Storno,
|
||||
berichtigte Rechnung, Berichtigungsblatt), danach liest das Journal denselben
|
||||
Ordner ein. Geprueft wird, was am Ende ans Amt gemeldet wuerde.
|
||||
|
||||
Zwei Faelle, beide am 03.09.2026 an echten Belegen gefunden:
|
||||
|
||||
1. Der Storno kuerzte die BETRAEGE, aber nicht die UEBERNACHTUNGEN - aus
|
||||
4 - 0 + 3 wurden 7 statt 3 gemeldeter Naechte.
|
||||
2. Das Berichtigungsblatt wurde nur uebersprungen. Der berichtigte NAME kam
|
||||
nie im Journal an, im Amtsbericht stand weiter der falsche.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
TOOL = r"C:\claude\rechnungstool"
|
||||
JOURNAL = r"C:\claude\beherbergungssteuer"
|
||||
sys.path.insert(0, TOOL)
|
||||
|
||||
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()
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="storno_journal_")
|
||||
ordner = os.path.join(tmp, "rechnungen")
|
||||
os.makedirs(ordner)
|
||||
|
||||
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"] = 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
|
||||
|
||||
antworten = {"Fertig": False, "endgültig erstellen": True, "Hausnummer": True,
|
||||
"Storno wirklich": True, "Berichtigte Rechnung erstellen": True,
|
||||
"Berichtigung": True, "Storno erstellen": 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
|
||||
messagebox.showinfo = lambda *a, **k: None
|
||||
messagebox.showwarning = lambda *a, **k: print(" [warnung]", a[0] if a else "")
|
||||
messagebox.showerror = lambda *a, **k: print(" [fehler]", a[0] if a else "")
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.cfg["output_dir"] = ordner
|
||||
|
||||
|
||||
def kunde(vorname, nachname):
|
||||
f.k_vorname.set(vorname)
|
||||
f.k_name.set(nachname)
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
|
||||
|
||||
# ---- Fall 1: Rechnung -> Storno -> berichtigte Rechnung -------------------
|
||||
kunde("Anna", "Beispiel")
|
||||
f.r_anreise.set("01.08.2026")
|
||||
f.r_abreise.set("05.08.2026") # 4 Naechte
|
||||
erste_pos = sorted(f.anzahl_vars)[0]
|
||||
f.anzahl_vars[erste_pos].set("4")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
|
||||
offen = [d for d in BE.rechnungen_im_ordner(ordner) if d.get("art") != "storno"]
|
||||
pruefe(len(offen) == 1, "eine stornierbare Rechnung")
|
||||
f._storno_anlegen(offen[0], ordner)
|
||||
storno_pdf = f.storno_stand["pfad"] if f.storno_stand else ""
|
||||
m_storno = BE.kenndaten_lesen(storno_pdf) or {}
|
||||
pruefe(str(m_storno.get("naechte")) == "-4",
|
||||
f"der Storno traegt -4 Naechte in den Kenndaten ({m_storno.get('naechte')!r})")
|
||||
|
||||
f.r_abreise.set("04.08.2026") # richtig waren 3 Naechte
|
||||
f.anzahl_vars[erste_pos].set("3")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
|
||||
# ---- Fall 2: Rechnung mit falschem Namen -> Berichtigungsblatt ------------
|
||||
f.reset_formular()
|
||||
kunde("Bernd", "Falsch")
|
||||
f.r_anreise.set("10.08.2026")
|
||||
f.r_abreise.set("12.08.2026") # 2 Naechte
|
||||
f.anzahl_vars[erste_pos].set("2")
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
|
||||
zu_berichtigen = [d for d in BE.rechnungen_im_ordner(ordner)
|
||||
if d.get("nachname") == "Falsch"]
|
||||
pruefe(len(zu_berichtigen) == 1, "die Rechnung mit dem falschen Namen ist da")
|
||||
f._berichtigung_starten(zu_berichtigen[0], ordner)
|
||||
f.k_name.set("Richtig") # der Gast heisst anders
|
||||
f.update()
|
||||
f.pdf_erstellen()
|
||||
blaetter = [d for d in os.listdir(ordner) if d.startswith("Berichtigte")]
|
||||
pruefe(len(blaetter) == 1, f"das Berichtigungsblatt liegt im Ordner ({blaetter})")
|
||||
f.destroy()
|
||||
|
||||
print("--- was im Ordner liegt ---")
|
||||
for d in sorted(os.listdir(ordner)):
|
||||
m = BE.kenndaten_lesen(os.path.join(ordner, d)) or {}
|
||||
print(f" {d:44s} art={str(m.get('art')):12s} nr={m.get('rechnungsnummer')} "
|
||||
f"naechte={str(m.get('naechte')):>4} summe={m.get('zwischensumme')}")
|
||||
|
||||
# ---- Das Journal liest denselben Ordner ----------------------------------
|
||||
sys.path.append(JOURNAL)
|
||||
import db as BST # noqa: E402
|
||||
from modell import summiere # noqa: E402
|
||||
|
||||
journal = BST.Journal(os.path.join(tmp, "journal.sqlite3"))
|
||||
journal.scanne(ordner, 5.0, voll=True)
|
||||
buchungen = journal.buchungen(jahr=2026)
|
||||
print("--- was im Journal steht ---")
|
||||
for b in buchungen:
|
||||
print(f" {b.voll_nummer} {b.nachname:10s} {b.naechte:>3} Nächte "
|
||||
f"{b.gezahlt:>8.2f} € Steuer {b.steuer:>6.2f} €")
|
||||
|
||||
s = summiere(buchungen)
|
||||
# Fall 1: 4 - 4 + 3 = 3 Naechte, 52,00 - 52,00 + 39,00 = 39,00
|
||||
# Fall 2: 2 Naechte, 26,00
|
||||
pruefe(s.naechte == 5,
|
||||
f"gemeldet werden 5 Übernachtungen (3 + 2), nicht 9 (ist {s.naechte})")
|
||||
pruefe(abs(s.gezahlt - 65.0) < 0.005, f"Summe 65,00 € (39,00 + 26,00) (ist {s.gezahlt})")
|
||||
pruefe(abs(s.steuer - 3.25) < 0.005, f"Steuer 3,25 € (ist {s.steuer})")
|
||||
|
||||
storno_zeilen = [b for b in buchungen if b.gezahlt < 0]
|
||||
pruefe(len(storno_zeilen) == 1 and storno_zeilen[0].naechte == -4,
|
||||
f"der Storno steht mit -4 Nächten im Journal ({[(b.voll_nummer, b.naechte) for b in storno_zeilen]})")
|
||||
|
||||
namen = {b.nachname for b in buchungen}
|
||||
pruefe("Richtig" in namen and "Falsch" not in namen,
|
||||
f"der berichtigte Name ist übernommen ({sorted(namen)})")
|
||||
berichtigte = [b for b in buchungen if b.nachname == "Richtig"]
|
||||
pruefe(len(berichtigte) == 1 and abs(berichtigte[0].gezahlt - 26.0) < 0.005,
|
||||
f"und die Buchung selbst ist unverändert ({[(b.voll_nummer, b.gezahlt) for b in berichtigte]})")
|
||||
pruefe(len(buchungen) == 4,
|
||||
f"vier Zeilen: Rechnung, Storno, berichtigte Rechnung, zweite Rechnung ({len(buchungen)})")
|
||||
|
||||
# ---- Fall 3: DIESELBE Nummer auf zwei Namen, aufgeloest ------------------
|
||||
# Sein Fall aus dem Altbestand (2026-331, 2026-333, 2026-347 stehen dort auf je
|
||||
# zwei Namen). Beide PDFs werden mit dem Renderer geschrieben - so, wie sie das
|
||||
# alte Rechnungstool geschrieben hat, das die Nummer noch ein zweites Mal
|
||||
# vergab. Danach wird eine davon storniert und mit freier Nummer neu
|
||||
# ausgestellt: § 14 Abs. 4 Nr. 4 UStG laesst jede Nummer nur einmal zu, und das
|
||||
# Storno traegt Nummer, Datum UND Empfaenger - § 31 Abs. 5 UStDV verlangt einen
|
||||
# "spezifischen und eindeutigen" Bezug, und die Nummer allein ist hier keiner.
|
||||
import berechnung # noqa: E402
|
||||
import pdf_renderer # noqa: E402
|
||||
|
||||
f2 = A.RechnungsApp()
|
||||
f2.cfg["output_dir"] = ordner
|
||||
pos_nr = sorted(f2.anzahl_vars)[0]
|
||||
|
||||
|
||||
def alt_rechnung(datei, nummer, nachname, datum, naechte):
|
||||
r = berechnung.berechne(f2.cfg["katalog"], {pos_nr: {"anzahl": naechte, "personen": 0}},
|
||||
f2.cfg)
|
||||
kopf = {"rechnungsnummer": nummer, "datum": datum, "anreise": datum, "abreise": "",
|
||||
"naechte": str(naechte), "nachname": nachname, "vorname": "Carla",
|
||||
"kunde_zeilen": ["", f"Carla {nachname}", "", "Musterweg 9", "12345 Musterstadt"],
|
||||
"kunde": {"vorname": "Carla", "nachname": nachname, "strasse": "Musterweg 9",
|
||||
"plzort": "12345 Musterstadt"},
|
||||
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
|
||||
for z in r.zeilen]}
|
||||
pdf_renderer.render_rechnung(os.path.join(ordner, datei), f2.cfg, r, kopf)
|
||||
|
||||
|
||||
alt_rechnung("Rechnung_2026-900_ZwillingA.pdf", "2026-900", "ZwillingA", "20.08.2026", 5)
|
||||
alt_rechnung("Rechnung_2026-900_ZwillingB.pdf", "2026-900", "ZwillingB", "22.08.2026", 3)
|
||||
|
||||
journal2 = BST.Journal(os.path.join(tmp, "journal2.sqlite3"))
|
||||
journal2.scanne(ordner, 5.0, voll=True)
|
||||
from modell import aufgehobene, doppelte_nummern # noqa: E402
|
||||
|
||||
d = doppelte_nummern(journal2.buchungen(jahr=2026))
|
||||
pruefe("2026-900" in d, f"die doppelt vergebene Nummer wird gemeldet ({sorted(d)})")
|
||||
pruefe(len(d.get("2026-900", [])) == 2,
|
||||
f"mit beiden Gästen ({[b.nachname for b in d.get('2026-900', [])]})")
|
||||
journal2.schliessen()
|
||||
|
||||
# ---- Die Dublette aufloesen: stornieren und neu ausstellen ----------------
|
||||
zwilling = [m for m in BE.rechnungen_im_ordner(ordner) if m.get("nachname") == "ZwillingB"]
|
||||
pruefe(len(zwilling) == 1, "die zweite Rechnung ist stornierbar")
|
||||
f2._storno_anlegen(zwilling[0], ordner)
|
||||
m_storno2 = BE.kenndaten_lesen(f2.storno_stand["pfad"]) if f2.storno_stand else {}
|
||||
pruefe(m_storno2.get("storno_zu") == "2026-900" and m_storno2.get("storno_datum") == "22.08.2026",
|
||||
f"das Storno nennt Nummer UND Datum ({m_storno2.get('storno_zu')}, {m_storno2.get('storno_datum')})")
|
||||
pruefe(m_storno2.get("nachname") == "ZwillingB",
|
||||
f"und den Empfänger - sonst kein eindeutiger Bezug ({m_storno2.get('nachname')!r})")
|
||||
neue_nummer = f2.r_nummer.get()
|
||||
pruefe(neue_nummer != "2026-900", f"die Neuausstellung bekommt eine freie Nummer ({neue_nummer})")
|
||||
f2.update()
|
||||
f2.pdf_erstellen() # berichtigte Rechnung mit neuer Nummer
|
||||
f2.destroy()
|
||||
|
||||
journal3 = BST.Journal(os.path.join(tmp, "journal3.sqlite3"))
|
||||
journal3.scanne(ordner, 5.0, voll=True)
|
||||
neu_buchungen = journal3.buchungen(jahr=2026)
|
||||
d2 = doppelte_nummern(neu_buchungen)
|
||||
pruefe("2026-900" not in d2,
|
||||
f"nach Storno + Neuausstellung ist die Nummer nicht mehr doppelt ({sorted(d2)})")
|
||||
weg = aufgehobene(neu_buchungen)
|
||||
storniert = [b for b in neu_buchungen if b.id in weg and b.voll_nummer == "2026-900"]
|
||||
pruefe(len(storniert) == 1 and storniert[0].nachname == "ZwillingB",
|
||||
f"genau die stornierte Rechnung ist aufgehoben ({[(b.voll_nummer, b.nachname) for b in storniert]})")
|
||||
erst = [b for b in neu_buchungen if b.voll_nummer == "2026-900" and b.nachname == "ZwillingA"]
|
||||
pruefe(len(erst) == 1 and erst[0].id not in weg,
|
||||
"die andere Rechnung mit derselben Nummer bleibt unangetastet")
|
||||
journal3.schliessen()
|
||||
|
||||
journal.schliessen()
|
||||
if not fehler:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
else:
|
||||
print(f" (Wegwerf-Ordner bleibt: {tmp})")
|
||||
print("---")
|
||||
print("alles gruen" if not fehler else f"{len(fehler)} Fehler: {fehler}")
|
||||
sys.exit(1 if fehler else 0)
|
||||
Loading…
Add table
Add a link
Reference in a new issue