Gemeinsames Nummernbuch, Storno kuerzt die Naechte, Testlauf-Schalter
Das Rechnungstool kannte nur die Nummern seiner eigenen PDFs. Im Steuerjournal stehen aber Buchungen ohne PDF - der Altbestand aus der Excel-Mappe - und deren Nummern hat es ein zweites Mal vergeben. § 14 Abs. 4 Nr. 4 UStG laesst jede Nummer nur einmal zu. Deshalb fuehren beide Programme jetzt EIN gemeinsames Buch (gemeinsam.py): eine SQLite in einem Ordner, den beide kennen. Es ist ein Register, keine zweite Buchhaltung - die Wahrheit bleiben die PDFs. Was hineinkommt: jede geschriebene Rechnung, jeder Storno (mit storno_zu), jede Neuausstellung. Die Nummernvergabe und die Kollisionspruefung beim Erstellen fragen es mit ab; ein verworfener Storno gibt seine Nummer wieder frei. Faellt der Ordner aus (Netzlaufwerk weg), laeuft alles weiter - dann steht es in der Fussleiste. ordnerwahl.py fragt den Ordner beim ersten Start ab: auswaehlen, OK, Sicherheitsfrage, bei Nein zurueck ins Feld. Zwei Fehler daran gleich behoben, bevor sie jemand treffen konnte: das Fenster war zu schmal (der Auswahl-Knopf lag ausserhalb) und der Griff (grab_set) muss waehrend des Windows-Ordner- dialogs los sein, sonst nimmt der keine Eingabe an. Storno: er ist das Spiegelbild der Rechnung - auch bei den UEBERNACHTUNGEN. Vorher stand dort nichts, und das Steuerjournal meldete dem Amt die Naechte der aufgehobenen Rechnung weiter mit. Auf dem Blatt steht "4 Naechte (aufgehoben)", in den Kenndaten -4. "Neue Rechnung:" auf dem Storno-Blatt heisst jetzt "Berichtigte Rechnung:" - die alte Beschriftung las sich wie die Nummer DIESES Blattes. --test / CAMPINGHOF_TEST=1 unterdrueckt den Erststart-Dialog. In der EXE kommt man ohnehin nicht daran, und die Pruefstaende bleiben sonst im Dialog stehen. Pruefstand pruef_nummernbuch.py (neu): beide Programme auf einem Ordner - das Journal traegt drei Altbestands-Nummern ein, das Rechnungstool schlaegt danach 2026-004 statt 2026-001 vor. Alle uebrigen Pruefstaende gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
This commit is contained in:
parent
5ef4e0a769
commit
ecad4b00ea
12 changed files with 790 additions and 16 deletions
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue