Amt-Blatt raus, Bedienung ueber die API, Erststart fragt nach zwei Wegen
Das zweite Blatt "fuer das Amt" trug DIESELBE Rechnungsnummer, aber einen anderen Gesamtbetrag. Mehrere Blaetter zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber verschiedene Umsaetze abgerechnet - bei einer Pruefung ein Problem. Ersatzlos entfallen; was die Stadt braucht, rechnet das Steuerjournal aus den Kenndaten der PDF. Mit dem Blatt fielen _AmtMarke, gesamtrechnung_betont und der doppelte Renderlauf weg. Die API kann das Fenster jetzt BEDIENEN - mit Schluessel wie bei einem SSH-Key: 32 Byte Zufall in api_schluessel.txt im gemeinsamen Ordner, den nur das Steuerjournal und der Betreiber lesen. Ohne gueltigen Schluessel kein Zutritt (compare_digest). ping und schau_nach bleiben schluessellos, sonst kann ein aelteres Journal nicht mehr anklopfen. Was einen BELEG schreibt, geht nur im Testlauf - eine Fernbedienung, die echte Rechnungen ausstellt, hat in einer Buchhaltung nichts zu suchen. api.py liegt wie immer in beiden Programmen. Der Erststart fragte nach "der zuletzt vergebenen Nummer" - das holt niemanden ab, der bei 0 anfaengt. Jetzt zwei Wege: bei 0 anfangen ODER die eigene Zaehlung weiterfuehren. Unter das schon Vergebene kommt keiner der beiden. pruef_nummernsperre bediente den Dialog nur halb: nach einer abgelehnten Zahl bleibt er offen (so soll er), niemand drueckte "Spaeter" - das Programm wartete in wait_window() ewig. Das war schon vor diesem Umbau so. Neu: pruef_api.py. Alle 14 Pruefstaende gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
parent
142dddc647
commit
ca9ac212cf
11 changed files with 835 additions and 212 deletions
107
AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md
Normal file
107
AUFGABE_RECHTSSICHER_BILDER_VORSCHAU.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Rechnungstool: rechtssicher, gestaltbar, mit Vorschau
|
||||||
|
|
||||||
|
**Stand 05.09.2026 — in Arbeit.** Programm `C:\claude\rechnungstool`,
|
||||||
|
Repo `git.pcore.de/TheMockTv/rechnungstool` (Pre-Tag `pre/rechtssicher-bilder`).
|
||||||
|
|
||||||
|
Seine Ansage: *„wir bauen den rechnungs ersteller so um das der nun richtig
|
||||||
|
rechtlich sicher ist und die kopf und fuss zeile mit eigenen bildern import"* —
|
||||||
|
und im Lauf des Umbaus dazu: *„das die firmen das designen können"*,
|
||||||
|
*„eine vorschau butten wäre gut … sonst haben die den storno auf wand"*,
|
||||||
|
*„das mit rechnung an das amt kommt raus, das ist nicht § conform"*.
|
||||||
|
|
||||||
|
## 1. Bilder und Farbe gehören der Firma (fertig)
|
||||||
|
|
||||||
|
Bis jetzt war das Blatt auf EINEN Betrieb gebaut: Camping-Grün fest im Code,
|
||||||
|
das Logo ein Pfad in der config.
|
||||||
|
|
||||||
|
- **`bilder.py`** (neu): ein gewähltes Bild wird in den Ordner `bilder/` neben
|
||||||
|
die `config.json` **kopiert**, gemerkt wird nur `bilder/logo.png`.
|
||||||
|
Der Grund: ein Pfad auf den Desktop ist tot, sobald dort jemand aufräumt —
|
||||||
|
und das fällt erst beim Drucken auf. Gleicher Name, anderes Bild → `-2`,
|
||||||
|
nie überschreiben.
|
||||||
|
- **Kopfbild** (Logo, oben links) und **Fußbild** (schmaler Streifen unten)
|
||||||
|
sind austauschbar. Das Layout bleibt, wie es war.
|
||||||
|
- **Firmenname, Anschrift, Bank, Steuernummer bleiben gezeichnet.** Kein Bild
|
||||||
|
kann sie ersetzen — die Steuernummer ist Pflichtangabe
|
||||||
|
(§ 14 Abs. 4 Nr. 2 UStG), und was in einem Bild steht, kann niemand prüfen.
|
||||||
|
(Ein Haken „Angaben stehen im Bild" war gebaut und ist auf seine Ansage hin
|
||||||
|
wieder rausgeflogen.)
|
||||||
|
- **Farbe der Rechnung**: EIN Wert (`design.akzent`), Hex-Feld **und**
|
||||||
|
Farbwähler in den Einstellungen. Die helle Fläche im Tabellenkopf wird daraus
|
||||||
|
gemischt — niemand muss zwei Farben aufeinander abstimmen. Eine unlesbare
|
||||||
|
Farbe fällt still auf die Vorgabe zurück, statt das Schreiben der Rechnung zu
|
||||||
|
verhindern.
|
||||||
|
|
||||||
|
Prüfstand: **`pruef_bilder.py`**.
|
||||||
|
|
||||||
|
## 2. Vorschau-Knopf (fertig)
|
||||||
|
|
||||||
|
Seine Begründung: *„sonst haben die den storno auf wand"* — eine ausgestellte
|
||||||
|
Rechnung lässt sich nur noch über Storno oder Berichtigung geradeziehen.
|
||||||
|
|
||||||
|
Der Knopf zeigt das fertige Blatt, **bevor** es eine Rechnung wird:
|
||||||
|
|
||||||
|
- verbraucht **keine** Rechnungsnummer, trägt nichts ins Nummernbuch,
|
||||||
|
- landet **nicht** im Rechnungsordner (Temp-Ordner),
|
||||||
|
- trägt quer über jeder Seite **„VORSCHAU – keine Rechnung"**,
|
||||||
|
- prüft **keine** Pflichtfelder — man will ja gerade sehen, was noch fehlt.
|
||||||
|
|
||||||
|
Prüfstand: **`pruef_vorschau.py`** (u. a.: die echte Rechnung danach trägt die
|
||||||
|
Marke NICHT).
|
||||||
|
|
||||||
|
## 3. Das Blatt „für das Amt" ist entfallen
|
||||||
|
|
||||||
|
Bis 05.09.2026 hing an jeder Rechnung mit Beherbergungssteuer ein zweites Blatt
|
||||||
|
mit **derselben Rechnungsnummer**, aber ohne die Steuer — also mit einem
|
||||||
|
**anderen Gesamtbetrag**.
|
||||||
|
|
||||||
|
Seine Ansage: *„das mit rechnung an das amt kommt raus … wenn so was in den
|
||||||
|
büchern von einem unternehmen drin ist, dann ist das ein genickbruch, wenn der
|
||||||
|
prüfer kommt"*. Vorgeschichte: Bartl wollte es so, weil es sonst seine
|
||||||
|
Steuerberaterin durcheinanderbringe; der Widerspruch stand schon damals.
|
||||||
|
|
||||||
|
Nachgeschlagen: mehrere Blätter zum selben Umsatz mit **gleicher** Nummer sind
|
||||||
|
für sich noch kein § 14c-Fall — aber unterschiedliche Endbeträge erwecken den
|
||||||
|
Eindruck, es werde über **verschiedene Umsätze** abgerechnet. Genau das fällt
|
||||||
|
bei einer Prüfung auf.
|
||||||
|
|
||||||
|
**Was an die Stelle tritt:** nichts im Rechnungstool. Das Steuerjournal liest
|
||||||
|
die Kenndaten (`/Subject`) der PDF und rechnet die Bemessungsgrundlage selbst
|
||||||
|
aus; ans Amt geht eine **Summenanmeldung**, keine Rechnungskopie mit Gastnamen.
|
||||||
|
Vorher geprüft: kein Programm liest die zweite Seite — `pruef_montage.py`
|
||||||
|
belegt weiter, dass das Journal die Datei einliest.
|
||||||
|
|
||||||
|
Mit dem Blatt entfielen: `_AmtMarke`, der Parameter `gesamtrechnung_betont` und
|
||||||
|
der doppelte Renderlauf (der existierte nur, um zu wissen, auf welcher Seite das
|
||||||
|
Amt-Blatt beginnt). Der Renderer ist dadurch deutlich kürzer.
|
||||||
|
|
||||||
|
## 4. Prüfstände nehmen keine Maus mehr
|
||||||
|
|
||||||
|
Sein Fund mitten im Lauf: *„pack doch die maus nicht immer auf das programm,
|
||||||
|
weil ich kann dir sonst nichts schreiben"*.
|
||||||
|
|
||||||
|
- `config.TESTLAUF` steht an **einer** Stelle (`--test` / `CAMPINGHOF_TEST=1`).
|
||||||
|
- Hauptfenster und **jeder** Dialog werden im Prüflauf durchsichtig gemacht und
|
||||||
|
aus dem Bild geschoben, und `grab_set` fällt weg.
|
||||||
|
- ⚠️ **Nicht `withdraw()`**: damit baut ein Fenster seine Dialoge nicht mehr
|
||||||
|
richtig auf — vier Prüfstände wurden davon rot und hätten eine Bedienung
|
||||||
|
geprüft, die es so nicht gibt.
|
||||||
|
- **`pruefsteuerung.py`** (neu): Meldungen wandern ins Protokoll, Ja/Nein-Fragen
|
||||||
|
in einen Fahrplan (`antwortet("Hausnummer", True)`). Eine **unangemeldete**
|
||||||
|
Frage bricht den Lauf ab — stillschweigend „ja" zu sagen würde genau die Fälle
|
||||||
|
übergehen, wegen denen es die Prüfung gibt.
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
|
||||||
|
- **Betriebsart abfragen** (Campingplatz / Handwerker / Unternehmen) — sein
|
||||||
|
Zielbau: *„nicht jeder braucht das so wie der camping typ"*. Handwerker und
|
||||||
|
Unternehmen brauchen **freie Rechnungszeilen** statt des festen Katalogs; das
|
||||||
|
ist ein neuer Eingabemodus, kein Schalter.
|
||||||
|
- **§ 14-Lücke Leistungszeitpunkt**: ohne An-/Abreise (z. B. nur Gasfüllung)
|
||||||
|
steht heute kein Leistungsdatum auf dem Blatt — § 14 Abs. 4 Nr. 6 UStG.
|
||||||
|
- **Zahlungsziel** einstellbar.
|
||||||
|
- **E-Rechnung (ZUGFeRD)**: Pflicht ab 01.01.2028 für B2B — trifft die
|
||||||
|
Montage-Rechnung. Bibliothek `factur-x` ist geprüft und kann XML erzeugen
|
||||||
|
(Profil EN 16931), prüfen (XSD + Schematron) und einbetten (PDF/A-3).
|
||||||
|
- **GoBD**: PDFs nach dem Schreiben schreibgeschützt; Aufbewahrung seit BEG IV
|
||||||
|
8 statt 10 Jahre (§ 147 Abs. 3 AO).
|
||||||
181
api.py
181
api.py
|
|
@ -18,11 +18,38 @@ einem Port, den das Betriebssystem aussucht (Port 0). Wo es lauscht, traegt es
|
||||||
in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen
|
in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen
|
||||||
will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
|
will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
|
||||||
|
|
||||||
Zwei Nachrichten reichen:
|
Zwei Nachrichten reichen dafuer:
|
||||||
|
|
||||||
{"was": "ping"} -> {"antwort": "hallo, bin noch da"}
|
{"was": "ping"} -> {"antwort": "hallo, bin noch da"}
|
||||||
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
|
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
|
||||||
|
|
||||||
|
Dazu kommt eine dritte, die das Fenster BEDIENT - damit ein Pruefstand die
|
||||||
|
Knoepfe druecken kann, ohne dem Menschen am Rechner Maus und Tastatur
|
||||||
|
wegzunehmen:
|
||||||
|
|
||||||
|
{"was": "aktion", "schluessel": "...", "name": "vorschau"} -> {"antwort": {...}}
|
||||||
|
|
||||||
|
DER SCHLUESSEL
|
||||||
|
--------------
|
||||||
|
Wer bedient, muss sich ausweisen - wie mit einem SSH-Schluessel. Beim ersten
|
||||||
|
Start legt das Programm eine Zufallszahl in `api_schluessel.txt` **im
|
||||||
|
gemeinsamen Ordner** ab (dort steht auch das Nummernbuch). Wer diesen Ordner
|
||||||
|
lesen darf, darf sprechen: das Steuerjournal und der Betreiber - sonst niemand.
|
||||||
|
Ohne gueltigen Schluessel wird eine Aktion verworfen, und zwar ohne zu
|
||||||
|
verraten, ob der Name ueberhaupt existiert.
|
||||||
|
|
||||||
|
`ping` und `schau_nach` bleiben schluessellos: sie sagen nur "ich lebe" bzw.
|
||||||
|
"schau in die SQLite" und veraendern nichts. Sonst koennte ein aelteres
|
||||||
|
Steuerjournal nicht mehr anklopfen.
|
||||||
|
|
||||||
|
WAS EINE AKTION DARF
|
||||||
|
--------------------
|
||||||
|
Alles, was einen BELEG SCHREIBT (Rechnung, Angebot, Storno), geht nur im
|
||||||
|
Testlauf (`--test` / `CAMPINGHOF_TEST=1`). Im normalen Betrieb bleiben nur
|
||||||
|
Aktionen, die nichts in die Buchhaltung schreiben: nachsehen, Felder fuellen,
|
||||||
|
Vorschau zeigen. Eine Fernbedienung, die echte Rechnungen ausstellt, hat in
|
||||||
|
einer Buchhaltung nichts zu suchen.
|
||||||
|
|
||||||
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die
|
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die
|
||||||
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
|
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
|
||||||
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
|
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
|
||||||
|
|
@ -42,6 +69,8 @@ hinueber.
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import socketserver
|
import socketserver
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -50,6 +79,33 @@ import threading
|
||||||
log = logging.getLogger("api")
|
log = logging.getLogger("api")
|
||||||
|
|
||||||
ANTWORT_HALLO = "hallo, bin noch da"
|
ANTWORT_HALLO = "hallo, bin noch da"
|
||||||
|
SCHLUESSEL_DATEI = "api_schluessel.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel_holen(ordner):
|
||||||
|
"""Schluessel aus dem Ordner lesen; ist keiner da, einen anlegen.
|
||||||
|
|
||||||
|
Der Schluessel ist eine 32 Byte lange Zufallszahl in Hex. Er liegt als
|
||||||
|
Datei neben dem Nummernbuch, damit beide Programme denselben lesen - eine
|
||||||
|
Absprache ueber den gemeinsamen Ordner, ohne Server und ohne Passwort, das
|
||||||
|
sich jemand merken muesste.
|
||||||
|
"""
|
||||||
|
if not ordner:
|
||||||
|
return ""
|
||||||
|
pfad = os.path.join(ordner, SCHLUESSEL_DATEI)
|
||||||
|
try:
|
||||||
|
if os.path.exists(pfad):
|
||||||
|
wert = open(pfad, encoding="utf-8").read().strip()
|
||||||
|
if wert:
|
||||||
|
return wert
|
||||||
|
wert = secrets.token_hex(32)
|
||||||
|
with open(pfad, "w", encoding="utf-8") as f:
|
||||||
|
f.write(wert)
|
||||||
|
log.info("neuer API-Schluessel angelegt: %s", pfad)
|
||||||
|
return wert
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("API-Schluessel nicht nutzbar (%s)", e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
class _Handler(socketserver.StreamRequestHandler):
|
class _Handler(socketserver.StreamRequestHandler):
|
||||||
|
|
@ -66,14 +122,17 @@ class _Handler(socketserver.StreamRequestHandler):
|
||||||
if not zeile:
|
if not zeile:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
was = str(json.loads(zeile).get("was", ""))
|
nachricht = json.loads(zeile)
|
||||||
|
was = str(nachricht.get("was", ""))
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
was = ""
|
nachricht, was = {}, ""
|
||||||
if was == "ping":
|
if was == "ping":
|
||||||
antwort = ANTWORT_HALLO
|
antwort = ANTWORT_HALLO
|
||||||
elif was == "schau_nach":
|
elif was == "schau_nach":
|
||||||
antwort = "ok"
|
antwort = "ok"
|
||||||
self.server.melden() # Fenster benachrichtigen (threadsicher)
|
self.server.melden() # Fenster benachrichtigen (threadsicher)
|
||||||
|
elif was == "aktion":
|
||||||
|
antwort = self._aktion(nachricht)
|
||||||
else:
|
else:
|
||||||
antwort = "haeh?"
|
antwort = "haeh?"
|
||||||
try:
|
try:
|
||||||
|
|
@ -82,12 +141,35 @@ class _Handler(socketserver.StreamRequestHandler):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _aktion(self, nachricht):
|
||||||
|
"""Eine Bedienung ausfuehren - nur mit gueltigem Schluessel."""
|
||||||
|
erwartet = getattr(self.server, "schluessel", "")
|
||||||
|
gegeben = str(nachricht.get("schluessel", ""))
|
||||||
|
# compare_digest statt "==": ein Vergleich, der bei der ersten falschen
|
||||||
|
# Stelle abbricht, verraet ueber die Zeit, wie weit man richtig lag.
|
||||||
|
if not erwartet or not secrets.compare_digest(erwartet, gegeben):
|
||||||
|
log.warning("Aktion ohne gueltigen Schluessel abgewiesen")
|
||||||
|
return "kein zutritt"
|
||||||
|
if self.server.aktion is None:
|
||||||
|
return "keine bedienung moeglich"
|
||||||
|
name = str(nachricht.get("name", ""))
|
||||||
|
daten = nachricht.get("daten") or {}
|
||||||
|
log.info("Aktion ueber die API: %s", name)
|
||||||
|
try:
|
||||||
|
return self.server.aktion(name, daten)
|
||||||
|
except Exception as e: # noqa: BLE001 - der Server darf daran nicht sterben
|
||||||
|
log.warning("Aktion %r fehlgeschlagen: %s", name, e)
|
||||||
|
return {"fehler": str(e)}
|
||||||
|
|
||||||
|
|
||||||
class _Server(socketserver.ThreadingTCPServer):
|
class _Server(socketserver.ThreadingTCPServer):
|
||||||
allow_reuse_address = True
|
allow_reuse_address = True
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
|
|
||||||
def __init__(self, adresse, bei_push):
|
def __init__(self, adresse, bei_push, aktion=None, schluessel=""):
|
||||||
self.melden = bei_push
|
self.melden = bei_push
|
||||||
|
self.aktion = aktion
|
||||||
|
self.schluessel = schluessel
|
||||||
super().__init__(adresse, _Handler)
|
super().__init__(adresse, _Handler)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,19 +181,30 @@ class MiniApi:
|
||||||
und holt sie im Fenster mit after() ab.
|
und holt sie im Fenster mit after() ab.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, programm, bei_push):
|
def __init__(self, programm, bei_push, aktion=None):
|
||||||
self.programm = programm
|
self.programm = programm
|
||||||
self.bei_push = bei_push
|
self.bei_push = bei_push
|
||||||
|
self.aktion = aktion # (name, daten) -> Antwort; None = keine Bedienung
|
||||||
self.server = None
|
self.server = None
|
||||||
self.faden = None
|
self.faden = None
|
||||||
self.port = 0
|
self.port = 0
|
||||||
|
self.schluessel = ""
|
||||||
|
|
||||||
def starten(self, buch):
|
def starten(self, buch, ordner=None):
|
||||||
"""Server aufmachen und im Telefonbuch (SQLite) eintragen."""
|
"""Server aufmachen und im Telefonbuch (SQLite) eintragen.
|
||||||
|
|
||||||
|
Der Schluessel kommt aus dem Ordner des Nummernbuchs - dort liegt er
|
||||||
|
fuer beide Programme lesbar. Gibt es kein Buch, gibt es keinen
|
||||||
|
Schluessel und damit auch keine Bedienung von aussen.
|
||||||
|
"""
|
||||||
if self.server is not None:
|
if self.server is not None:
|
||||||
return self.port
|
return self.port
|
||||||
|
if ordner is None and buch is not None:
|
||||||
|
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
|
||||||
|
self.schluessel = schluessel_holen(ordner)
|
||||||
try:
|
try:
|
||||||
self.server = _Server(("127.0.0.1", 0), self.bei_push)
|
self.server = _Server(("127.0.0.1", 0), self.bei_push,
|
||||||
|
aktion=self.aktion, schluessel=self.schluessel)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
log.warning("API konnte nicht starten: %s", e)
|
log.warning("API konnte nicht starten: %s", e)
|
||||||
self.server = None
|
self.server = None
|
||||||
|
|
@ -150,12 +243,14 @@ class MiniApi:
|
||||||
self.port = 0
|
self.port = 0
|
||||||
|
|
||||||
|
|
||||||
def _reden(port, was, zeitgrenze=1.5):
|
def _reden(port, was, zeitgrenze=1.5, **rest):
|
||||||
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
|
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
|
||||||
|
nachricht = {"was": was}
|
||||||
|
nachricht.update(rest)
|
||||||
try:
|
try:
|
||||||
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
|
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
|
||||||
s.settimeout(zeitgrenze)
|
s.settimeout(zeitgrenze)
|
||||||
s.sendall((json.dumps({"was": was}) + "\n").encode("utf-8"))
|
s.sendall((json.dumps(nachricht) + chr(10)).encode("utf-8"))
|
||||||
roh = s.makefile("r", encoding="utf-8").readline()
|
roh = s.makefile("r", encoding="utf-8").readline()
|
||||||
return json.loads(roh).get("antwort", "")
|
return json.loads(roh).get("antwort", "")
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
|
|
@ -192,3 +287,69 @@ def anklopfen(buch, programm):
|
||||||
if not eintrag or not eintrag.get("port"):
|
if not eintrag or not eintrag.get("port"):
|
||||||
return False
|
return False
|
||||||
return _reden(eintrag["port"], "schau_nach") == "ok"
|
return _reden(eintrag["port"], "schau_nach") == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def bedienen(buch, programm, name, daten=None, ordner=None, zeitgrenze=15.0):
|
||||||
|
"""Einen Knopf im anderen Programm druecken. None = niemand da / kein Zutritt.
|
||||||
|
|
||||||
|
Fuer Pruefstaende und fuer den Betreiber: damit laesst sich das Fenster
|
||||||
|
bedienen, ohne dem Menschen am Rechner Maus und Tastatur wegzunehmen.
|
||||||
|
|
||||||
|
Der Schluessel kommt aus demselben Ordner wie das Nummernbuch - wer den
|
||||||
|
lesen darf, darf bedienen.
|
||||||
|
"""
|
||||||
|
if buch is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
eintrag = buch.wer_laeuft(programm)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.debug("Telefonbuch nicht lesbar: %s", e)
|
||||||
|
return None
|
||||||
|
if not eintrag or not eintrag.get("port"):
|
||||||
|
return None
|
||||||
|
if ordner is None:
|
||||||
|
ordner = os.path.dirname(getattr(buch, "pfad", "") or "")
|
||||||
|
return _reden(eintrag["port"], "aktion", zeitgrenze=zeitgrenze,
|
||||||
|
schluessel=schluessel_holen(ordner), name=name,
|
||||||
|
daten=daten or {})
|
||||||
|
|
||||||
|
|
||||||
|
class Warteschlange:
|
||||||
|
"""Aktionen aus dem Netzfaden in den Fensterfaden bringen - und zurueck.
|
||||||
|
|
||||||
|
Tkinter darf nur aus SEINEM Faden angefasst werden. Der API-Server lauscht
|
||||||
|
aber in einem eigenen. Deshalb legt er die Aktion hier ab und wartet auf das
|
||||||
|
Ergebnis; das Fenster holt sie in seiner after()-Schleife ab, fuehrt sie aus
|
||||||
|
und legt die Antwort zurueck.
|
||||||
|
|
||||||
|
Kommt das Fenster nicht dazu (es haengt in einem Dialog), laeuft die
|
||||||
|
Wartezeit ab und der Anrufer bekommt eine ehrliche Absage statt einer
|
||||||
|
Verbindung, die nie antwortet.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.eingang = queue.Queue()
|
||||||
|
|
||||||
|
def annehmen(self, name, daten, zeitgrenze=12.0):
|
||||||
|
"""Aus dem Netzfaden gerufen: Aktion einreihen und auf das Ergebnis warten."""
|
||||||
|
fertig = threading.Event()
|
||||||
|
auftrag = {"name": name, "daten": daten, "fertig": fertig, "antwort": None}
|
||||||
|
self.eingang.put(auftrag)
|
||||||
|
if not fertig.wait(zeitgrenze):
|
||||||
|
return {"fehler": "das Fenster hat nicht geantwortet"}
|
||||||
|
return auftrag["antwort"]
|
||||||
|
|
||||||
|
def abarbeiten(self, ausfuehren):
|
||||||
|
"""Im Fensterfaden gerufen: alles erledigen, was eingegangen ist."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
auftrag = self.eingang.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
auftrag["antwort"] = ausfuehren(auftrag["name"], auftrag["daten"])
|
||||||
|
except Exception as e: # noqa: BLE001 - eine Aktion darf das Fenster nicht killen
|
||||||
|
log.warning("Aktion %r fehlgeschlagen: %s", auftrag["name"], e)
|
||||||
|
auftrag["antwort"] = {"fehler": str(e)}
|
||||||
|
finally:
|
||||||
|
auftrag["fertig"].set()
|
||||||
|
|
|
||||||
152
app.py
152
app.py
|
|
@ -70,7 +70,13 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
|
# Das gemeinsame Nummernbuch (siehe gemeinsam.py). None = noch keiner
|
||||||
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
|
# gesetzt; das Programm laeuft dann weiter, warnt aber sichtbar.
|
||||||
self.buch = None
|
self.buch = None
|
||||||
self.api = api.MiniApi("rechnungstool", self._push_empfangen)
|
# Die API kann das Fenster auch BEDIENEN (nur mit Schluessel, siehe
|
||||||
|
# api.py). Die Warteschlange bringt die Aktion aus dem Netzfaden
|
||||||
|
# hierher - Tkinter darf nur aus seinem eigenen Faden angefasst werden.
|
||||||
|
self._api_schlange = api.Warteschlange()
|
||||||
|
self.api = api.MiniApi("rechnungstool", self._push_empfangen,
|
||||||
|
aktion=self._api_schlange.annehmen)
|
||||||
|
self.after(150, self._api_pumpe)
|
||||||
self._push_kasten = queue.Queue()
|
self._push_kasten = queue.Queue()
|
||||||
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
|
# Zwischenspeicher fuer eine reine Berichtigung (§ 31 Abs. 5 UStDV):
|
||||||
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
|
# die naechste PDF ist dann ein berichtigtes Blatt zu dieser Rechnung.
|
||||||
|
|
@ -627,8 +633,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
ttk.Label(rahmen, text="Wo soll die Rechnungsnummer weiterlaufen?",
|
ttk.Label(rahmen, text="Wo soll die Rechnungsnummer weiterlaufen?",
|
||||||
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w")
|
font=("Segoe UI", 13, "bold")).grid(row=0, column=0, columnspan=2, sticky="w")
|
||||||
ttk.Label(rahmen, wraplength=620, justify="left", text=(
|
ttk.Label(rahmen, wraplength=620, justify="left", text=(
|
||||||
"Trag die zuletzt vergebene Nummer ein – die aus deiner bisherigen Liste. "
|
"Zwei Wege - such dir einen aus." + chr(10) + chr(10) +
|
||||||
"Die nächste Rechnung bekommt dann die Nummer danach.\n\n"
|
|
||||||
"Das wird EINMAL eingerichtet. Danach steht der Zähler fest: eine "
|
"Das wird EINMAL eingerichtet. Danach steht der Zähler fest: eine "
|
||||||
"ausgestellte Rechnung wird nur noch über Storno oder Berichtigung "
|
"ausgestellte Rechnung wird nur noch über Storno oder Berichtigung "
|
||||||
"korrigiert, und jede Nummer gibt es nur einmal "
|
"korrigiert, und jede Nummer gibt es nur einmal "
|
||||||
|
|
@ -642,23 +647,50 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
f"{jahr}-{gefunden:03d} – niedriger geht es nicht.").grid(
|
f"{jahr}-{gefunden:03d} – niedriger geht es nicht.").grid(
|
||||||
row=2, column=0, columnspan=2, sticky="w", pady=(0, 10))
|
row=2, column=0, columnspan=2, sticky="w", pady=(0, 10))
|
||||||
|
|
||||||
ttk.Label(rahmen, text=f"Zuletzt vergeben: {jahr} –").grid(row=3, column=0, sticky="e")
|
# Zwei Wege statt einer Zahl: neu anfangen oder die eigene Zaehlung
|
||||||
ttk.Entry(rahmen, textvariable=wert, width=10, justify="center").grid(
|
# weiterfuehren. Die Frage "trag die zuletzt vergebene Nummer ein" holt
|
||||||
row=3, column=1, sticky="w", padx=(8, 0))
|
# niemanden ab, der bei 0 anfaengt - der hat gar keine.
|
||||||
|
weg = tk.StringVar(value="weiter" if gefunden else "null")
|
||||||
|
zeile = ttk.Frame(rahmen)
|
||||||
|
# Das Eingabefeld ist ein Kind DIESER Zeile - ein Widget muss dort
|
||||||
|
# zuhause sein, wo es gepackt wird, sonst mischt Tk grid und pack im
|
||||||
|
# selben Rahmen und bricht ab.
|
||||||
|
eingabe = ttk.Entry(zeile, textvariable=wert, width=10, justify="center")
|
||||||
|
|
||||||
|
def weg_gewaehlt(*_):
|
||||||
|
eingabe.configure(state="normal" if weg.get() == "weiter" else "disabled")
|
||||||
|
|
||||||
|
ttk.Radiobutton(rahmen, variable=weg, value="null", command=weg_gewaehlt,
|
||||||
|
text=f"Bei 0 anfangen - die erste Rechnung wird {jahr}-001").grid(
|
||||||
|
row=3, column=0, columnspan=2, sticky="w", pady=(0, 4))
|
||||||
|
zeile.grid(row=4, column=0, columnspan=2, sticky="w")
|
||||||
|
ttk.Radiobutton(zeile, variable=weg, value="weiter", command=weg_gewaehlt,
|
||||||
|
text=f"Meine Zählung weiterführen - zuletzt vergeben: {jahr} -").pack(
|
||||||
|
side="left")
|
||||||
|
eingabe.pack(side="left", padx=(8, 0))
|
||||||
|
weg_gewaehlt()
|
||||||
hinweis = tk.Label(rahmen, text="", wraplength=620, justify="left", fg="#a00000",
|
hinweis = tk.Label(rahmen, text="", wraplength=620, justify="left", fg="#a00000",
|
||||||
font=("Segoe UI", 9))
|
font=("Segoe UI", 9))
|
||||||
hinweis.grid(row=4, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
hinweis.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||||
|
|
||||||
def uebernehmen():
|
def uebernehmen():
|
||||||
try:
|
if weg.get() == "null":
|
||||||
zahl = int(parse_num(wert.get()))
|
zahl = 0
|
||||||
except (TypeError, ValueError):
|
else:
|
||||||
zahl = -1
|
try:
|
||||||
if zahl < gefunden:
|
zahl = int(parse_num(wert.get()))
|
||||||
hinweis.configure(text=f"Mindestens {gefunden} – so weit ist schon vergeben.")
|
except (TypeError, ValueError):
|
||||||
return
|
zahl = -1
|
||||||
if zahl < 0:
|
if zahl < 0:
|
||||||
hinweis.configure(text="Bitte eine Zahl eintragen (0 = es geht bei 001 los).")
|
hinweis.configure(text="Bitte die zuletzt vergebene Nummer eintragen.")
|
||||||
|
return
|
||||||
|
# Unter das, was schon vergeben ist, darf niemand - egal auf welchem
|
||||||
|
# Weg. Sonst gaebe es eine Nummer zweimal (§ 14 Abs. 4 Nr. 4 UStG).
|
||||||
|
if zahl < gefunden:
|
||||||
|
hinweis.configure(
|
||||||
|
text=f"Schon vergeben ist {jahr}-{gefunden:03d} - darunter geht es nicht."
|
||||||
|
if weg.get() == "weiter" else
|
||||||
|
f"Bei 0 anfangen geht nicht mehr: {jahr}-{gefunden:03d} ist schon vergeben.")
|
||||||
return
|
return
|
||||||
if not messagebox.askyesno(
|
if not messagebox.askyesno(
|
||||||
"Sind Sie sicher?",
|
"Sind Sie sicher?",
|
||||||
|
|
@ -680,7 +712,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
d.destroy()
|
d.destroy()
|
||||||
|
|
||||||
knoepfe = ttk.Frame(rahmen)
|
knoepfe = ttk.Frame(rahmen)
|
||||||
knoepfe.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(16, 0))
|
knoepfe.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(16, 0))
|
||||||
ttk.Button(knoepfe, text="Übernehmen", width=16, command=uebernehmen).pack(side="right")
|
ttk.Button(knoepfe, text="Übernehmen", width=16, command=uebernehmen).pack(side="right")
|
||||||
ttk.Button(knoepfe, text="Später", width=12, command=d.destroy).pack(side="left")
|
ttk.Button(knoepfe, text="Später", width=12, command=d.destroy).pack(side="left")
|
||||||
|
|
||||||
|
|
@ -1077,6 +1109,87 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
return jahr, zaehler, nummer
|
return jahr, zaehler, nummer
|
||||||
return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
|
return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
|
||||||
|
|
||||||
|
# ------------------------------------------------- Bedienung ueber die API
|
||||||
|
def _api_pumpe(self):
|
||||||
|
"""Holt Aktionen aus der Warteschlange und fuehrt sie HIER aus.
|
||||||
|
|
||||||
|
Laeuft, solange das Fenster steht. Tkinter darf nur aus seinem eigenen
|
||||||
|
Faden angefasst werden - der API-Server lauscht in einem anderen.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._api_schlange.abarbeiten(self._api_aktion)
|
||||||
|
finally:
|
||||||
|
self.after(150, self._api_pumpe)
|
||||||
|
|
||||||
|
def _api_aktion(self, name, daten):
|
||||||
|
"""Eine Bedienung ausfuehren. Der Schluessel ist schon geprueft (api.py).
|
||||||
|
|
||||||
|
Nachschlagetabelle statt if-Kette: ein neuer Knopf ist EIN Eintrag.
|
||||||
|
|
||||||
|
Was einen BELEG schreibt, geht nur im Testlauf. Eine Fernbedienung, die
|
||||||
|
im laufenden Betrieb echte Rechnungen ausstellt, hat in einer
|
||||||
|
Buchhaltung nichts zu suchen - eine ausgestellte Rechnung bekommt man
|
||||||
|
nur ueber einen Storno wieder weg.
|
||||||
|
"""
|
||||||
|
schreibt_beleg = {"rechnung", "angebot"}
|
||||||
|
tabelle = {
|
||||||
|
"hilfe": lambda _d: {"kann": ["hilfe", "zustand", "setze", "zuruecksetzen",
|
||||||
|
"vorschau", "rechnung", "angebot"]},
|
||||||
|
"zustand": lambda _d: self._api_zustand(),
|
||||||
|
"setze": self._api_setzen,
|
||||||
|
"zuruecksetzen": lambda _d: (self.reset_formular(), {"ok": True})[1],
|
||||||
|
"vorschau": lambda d: {"pfad": self.vorschau_zeigen(
|
||||||
|
oeffnen=bool(d.get("oeffnen", False)))},
|
||||||
|
"rechnung": lambda _d: (self.pdf_erstellen(), self._api_zustand())[1],
|
||||||
|
"angebot": lambda _d: (self.pdf_erstellen(angebot=True), self._api_zustand())[1],
|
||||||
|
}
|
||||||
|
if name not in tabelle:
|
||||||
|
return {"fehler": "kenne ich nicht: " + str(name), "kann": sorted(tabelle)}
|
||||||
|
if name in schreibt_beleg and not TESTLAUF:
|
||||||
|
return {"fehler": "schreibt einen Beleg - geht nur im Testlauf"}
|
||||||
|
return tabelle[name](daten or {})
|
||||||
|
|
||||||
|
def _api_zustand(self):
|
||||||
|
"""Was steht gerade im Formular? Zum Nachsehen, ohne Maus."""
|
||||||
|
r = self.recalc()
|
||||||
|
return {
|
||||||
|
"reiter": "montage" if self.ist_montage() else "camping",
|
||||||
|
"nummer": self.r_nummer.get(),
|
||||||
|
"datum": self.r_datum.get(),
|
||||||
|
"anreise": self.r_anreise.get(),
|
||||||
|
"abreise": self.r_abreise.get(),
|
||||||
|
"naechte": self.r_naechte.get(),
|
||||||
|
"kunde": {"anrede": self.k_anrede.get(), "vorname": self.k_vorname.get(),
|
||||||
|
"nachname": self.k_name.get(), "zusatz": self.k_zusatz.get(),
|
||||||
|
"strasse": self.k_strasse.get(), "plzort": self.k_plzort.get()},
|
||||||
|
"positionen": [{"nr": z.nr, "leistung": z.leistung, "anzahl": z.anzahl,
|
||||||
|
"personen": z.personen, "brutto": z.brutto} for z in r.zeilen],
|
||||||
|
"gesamt": r.gesamt,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _api_setzen(self, daten):
|
||||||
|
"""Felder fuellen, wie es sonst die Tastatur taete."""
|
||||||
|
felder = {
|
||||||
|
"anrede": self.k_anrede, "vorname": self.k_vorname, "nachname": self.k_name,
|
||||||
|
"zusatz": self.k_zusatz, "strasse": self.k_strasse, "plzort": self.k_plzort,
|
||||||
|
"nummer": self.r_nummer, "datum": self.r_datum,
|
||||||
|
"anreise": self.r_anreise, "abreise": self.r_abreise,
|
||||||
|
}
|
||||||
|
for schluessel, wert in (daten.get("kunde") or {}).items():
|
||||||
|
if schluessel in felder:
|
||||||
|
felder[schluessel].set(str(wert))
|
||||||
|
for schluessel in ("nummer", "datum", "anreise", "abreise"):
|
||||||
|
if schluessel in daten:
|
||||||
|
felder[schluessel].set(str(daten[schluessel]))
|
||||||
|
for eintrag in (daten.get("positionen") or []):
|
||||||
|
nr = int(eintrag.get("nr", 0))
|
||||||
|
if nr in self.anzahl_vars:
|
||||||
|
self.anzahl_vars[nr].set(str(eintrag.get("anzahl", "")))
|
||||||
|
if nr in self.personen_vars and "personen" in eintrag:
|
||||||
|
self.personen_vars[nr].set(str(eintrag["personen"]))
|
||||||
|
self.update()
|
||||||
|
return self._api_zustand()
|
||||||
|
|
||||||
def _kopf_basis(self, nummer, r):
|
def _kopf_basis(self, nummer, r):
|
||||||
"""Die Kopfdaten, die JEDES Blatt braucht - Rechnung wie Vorschau.
|
"""Die Kopfdaten, die JEDES Blatt braucht - Rechnung wie Vorschau.
|
||||||
|
|
||||||
|
|
@ -1111,7 +1224,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
for z in r.zeilen],
|
for z in r.zeilen],
|
||||||
}
|
}
|
||||||
|
|
||||||
def vorschau_zeigen(self):
|
def vorschau_zeigen(self, oeffnen=True):
|
||||||
"""Zeigt das Blatt, BEVOR es eine Rechnung wird.
|
"""Zeigt das Blatt, BEVOR es eine Rechnung wird.
|
||||||
|
|
||||||
Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno
|
Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno
|
||||||
|
|
@ -1142,10 +1255,13 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
try:
|
try:
|
||||||
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True)
|
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True)
|
||||||
print(f"[vorschau] geschrieben -> {pfad}")
|
print(f"[vorschau] geschrieben -> {pfad}")
|
||||||
os.startfile(pfad) # noqa: S606
|
if oeffnen:
|
||||||
|
os.startfile(pfad) # noqa: S606
|
||||||
|
return pfad
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
messagebox.showerror("Vorschau nicht möglich",
|
messagebox.showerror("Vorschau nicht möglich",
|
||||||
f"Die Vorschau konnte nicht geöffnet werden:\n{e}")
|
f"Die Vorschau konnte nicht geöffnet werden:\n{e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
def pdf_erstellen(self, angebot=False):
|
def pdf_erstellen(self, angebot=False):
|
||||||
montage = self.ist_montage()
|
montage = self.ist_montage()
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,13 @@ def _daten_ordner():
|
||||||
BASE_DIR = _daten_ordner()
|
BASE_DIR = _daten_ordner()
|
||||||
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
|
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
|
||||||
|
|
||||||
|
# Laeuft gerade ein Pruefstand? Dann baut das Programm alles wie sonst, zeigt es
|
||||||
|
# aber nicht: kein Fenster auf dem Bildschirm, kein Dialog, der Maus und
|
||||||
|
# Tastatur an sich reisst, waehrend der Mensch daneben arbeitet.
|
||||||
|
# Das Flag steht HIER, damit app.py, theme.py und die Pruefstaende dasselbe
|
||||||
|
# lesen - zwei Stellen wuerden auseinanderlaufen.
|
||||||
|
TESTLAUF = ("--test" in sys.argv) or os.environ.get("CAMPINGHOF_TEST") == "1"
|
||||||
|
|
||||||
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).
|
# Leistungskatalog 1:1 aus der Excel-Vorlage (Rechnung Campinghof Bartl).
|
||||||
# satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung)
|
# satz = Umsatzsteuersatz in Prozent (7 = Beherbergung, 19 = Zusatzleistung)
|
||||||
# preis = Brutto-Einzelpreis in Euro (Endpreis inkl. USt)
|
# preis = Brutto-Einzelpreis in Euro (Endpreis inkl. USt)
|
||||||
|
|
|
||||||
288
pdf_renderer.py
288
pdf_renderer.py
|
|
@ -10,7 +10,6 @@ im Layout der Excel-Vorlage:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
|
|
@ -19,8 +18,7 @@ from reportlab.lib import colors
|
||||||
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
|
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
|
||||||
from reportlab.lib.styles import ParagraphStyle
|
from reportlab.lib.styles import ParagraphStyle
|
||||||
from reportlab.platypus import (
|
from reportlab.platypus import (
|
||||||
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak,
|
SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether,
|
||||||
Flowable,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import bilder
|
import bilder
|
||||||
|
|
@ -28,23 +26,6 @@ import config
|
||||||
from berechnung import eur, eur_kurz, runde
|
from berechnung import eur, eur_kurz, runde
|
||||||
|
|
||||||
|
|
||||||
class _AmtMarke(Flowable):
|
|
||||||
"""Unsichtbar. Haelt fest, auf welcher Seite das Amt-Blatt beginnt.
|
|
||||||
|
|
||||||
Nur so kann die Fusszeile die Kundenseite vom Amt-Blatt unterscheiden - sie wird
|
|
||||||
gezeichnet, bevor der Seiteninhalt steht, und weiss von sich aus nichts davon.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, merker):
|
|
||||||
super().__init__()
|
|
||||||
self._merker = merker
|
|
||||||
|
|
||||||
def wrap(self, *_):
|
|
||||||
return (0, 0)
|
|
||||||
|
|
||||||
def draw(self):
|
|
||||||
self._merker["seite"] = self.canv.getPageNumber()
|
|
||||||
|
|
||||||
# ---- Farben / Masse ---------------------------------------------------------
|
# ---- Farben / Masse ---------------------------------------------------------
|
||||||
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
|
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
|
||||||
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
|
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
|
||||||
|
|
@ -157,8 +138,8 @@ def _vorschau_marke(canvas):
|
||||||
canvas.restoreState()
|
canvas.restoreState()
|
||||||
|
|
||||||
|
|
||||||
def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
|
def _kopf_fuss(canvas, doc, cfg, vorschau=False):
|
||||||
"""Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
|
"""Kopf und Fuss einer Seite."""
|
||||||
firma = cfg["firma"]
|
firma = cfg["firma"]
|
||||||
canvas.saveState()
|
canvas.saveState()
|
||||||
if vorschau:
|
if vorschau:
|
||||||
|
|
@ -203,13 +184,8 @@ def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
|
||||||
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
|
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
|
||||||
|
|
||||||
# --- Fusszeile ---
|
# --- Fusszeile ---
|
||||||
#
|
# Bankverbindung und Steuernummer MUESSEN drauf: das ist die Rechnung, die der
|
||||||
# STRIKTE TRENNUNG Kunde / Amt:
|
# Gast bezahlt, und die Steuernummer ist Pflichtangabe (§ 14 Abs. 4 Nr. 2 UStG).
|
||||||
# Kundenseite : voller Briefkopf-Fuss - Bankverbindung und Steuernummer MUESSEN
|
|
||||||
# drauf, das ist die Rechnung, die der Gast bezahlt.
|
|
||||||
# Amt-Blatt : KEINE Bankverbindung, KEINE Steuernummer. Das Amt zieht nichts
|
|
||||||
# ein und ueberweist nichts. Es bleiben Inhaber (wer meldet) und
|
|
||||||
# die Kontaktdaten fuer Rueckfragen.
|
|
||||||
fy = MARGIN_BOT - 4 * mm
|
fy = MARGIN_BOT - 4 * mm
|
||||||
canvas.setStrokeColor(LINE)
|
canvas.setStrokeColor(LINE)
|
||||||
canvas.setLineWidth(0.6)
|
canvas.setLineWidth(0.6)
|
||||||
|
|
@ -220,12 +196,7 @@ def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
|
||||||
col_m = MARGIN_L + CONTENT_W * 0.40
|
col_m = MARGIN_L + CONTENT_W * 0.40
|
||||||
col_r = PAGE_W - MARGIN_R
|
col_r = PAGE_W - MARGIN_R
|
||||||
|
|
||||||
# Das Fusszeilen-Bild steht auf dem AMT-BLATT NICHT. Ein fertiges
|
fuss_img, fiw, fih = bilder.laden(firma.get("fussbild"))
|
||||||
# Briefpapier-Band traegt fast immer Bank und Steuernummer - und genau die
|
|
||||||
# duerfen dort nicht auftauchen (siehe Trennung oben). Das Amt bekommt
|
|
||||||
# deshalb immer die gezeichnete Fusszeile, auch wenn der Nutzer den Haken
|
|
||||||
# "Firmenangaben stehen im Bild" gesetzt hat.
|
|
||||||
fuss_img, fiw, fih = (None, 0, 0) if amt else bilder.laden(firma.get("fussbild"))
|
|
||||||
if fuss_img:
|
if fuss_img:
|
||||||
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
|
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
|
||||||
# Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal
|
# Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal
|
||||||
|
|
@ -234,9 +205,8 @@ def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
|
||||||
canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm,
|
canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm,
|
||||||
width=dw, height=dh, preserveAspectRatio=True, mask="auto")
|
width=dw, height=dh, preserveAspectRatio=True, mask="auto")
|
||||||
|
|
||||||
rows_l = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
|
rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
|
||||||
rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""),
|
rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
|
||||||
firma.get("steuernummer", "")]
|
|
||||||
rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
|
rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
|
||||||
|
|
||||||
for i, t in enumerate(rows_l):
|
for i, t in enumerate(rows_l):
|
||||||
|
|
@ -327,12 +297,8 @@ def _montage_zeile(story, st, rechnung, kopf):
|
||||||
story.append(dreier)
|
story.append(dreier)
|
||||||
|
|
||||||
|
|
||||||
def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
def _seite_inhalt(story, st, cfg, rechnung, kopf):
|
||||||
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte).
|
"""Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte)."""
|
||||||
|
|
||||||
gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor
|
|
||||||
(fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite.
|
|
||||||
"""
|
|
||||||
# Absenderzeile (klein, unterstrichen)
|
# Absenderzeile (klein, unterstrichen)
|
||||||
story.append(Paragraph(
|
story.append(Paragraph(
|
||||||
f"<u>{cfg['firma'].get('absender','')}</u>",
|
f"<u>{cfg['firma'].get('absender','')}</u>",
|
||||||
|
|
@ -428,7 +394,6 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||||
titel = {"storno": "Stornorechnung",
|
titel = {"storno": "Stornorechnung",
|
||||||
"berichtigung": "Berichtigte Rechnung",
|
"berichtigung": "Berichtigte Rechnung",
|
||||||
"angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht")
|
"angebot": "Unverbindliches Angebot"}.get(art, "Rechnungsübersicht")
|
||||||
titel += " (für das Amt)" if gesamtrechnung_betont else ""
|
|
||||||
story.append(Paragraph(titel, st["h"]))
|
story.append(Paragraph(titel, st["h"]))
|
||||||
if angebot:
|
if angebot:
|
||||||
# Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine -
|
# Ohne diesen Satz sieht das Blatt aus wie eine Rechnung. Es ist keine -
|
||||||
|
|
@ -477,7 +442,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||||
# Auf dem Angebot wird gar keine Steuer ausgewiesen - weder Umsatzsteuer
|
# Auf dem Angebot wird gar keine Steuer ausgewiesen - weder Umsatzsteuer
|
||||||
# noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich
|
# noch Beherbergungssteuer. Es steht nur, was der Gast voraussichtlich
|
||||||
# zahlt; aufgeschluesselt wird erst in der Rechnung.
|
# zahlt; aufgeschluesselt wird erst in der Rechnung.
|
||||||
if rechnung.aufschlaege and not gesamtrechnung_betont and not angebot:
|
if rechnung.aufschlaege and not angebot:
|
||||||
summen_rows.append([
|
summen_rows.append([
|
||||||
Paragraph("Zwischensumme:", st["info_l"]),
|
Paragraph("Zwischensumme:", st["info_l"]),
|
||||||
Paragraph(eur(rechnung.brutto), st["cell_r"]),
|
Paragraph(eur(rechnung.brutto), st["cell_r"]),
|
||||||
|
|
@ -489,7 +454,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||||
Paragraph(eur(a.betrag), st["cell_r"]),
|
Paragraph(eur(a.betrag), st["cell_r"]),
|
||||||
])
|
])
|
||||||
|
|
||||||
endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt
|
endsumme = rechnung.gesamt
|
||||||
summen_rows.append([
|
summen_rows.append([
|
||||||
Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else
|
Paragraph("Voraussichtlicher Gesamtbetrag:" if angebot else
|
||||||
"Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:",
|
"Rechnungsbetrag Insgesamt:" if montage else "Gesamtbetrag:",
|
||||||
|
|
@ -508,14 +473,13 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||||
summen.setStyle(TableStyle(style_cmds))
|
summen.setStyle(TableStyle(style_cmds))
|
||||||
story.append(summen)
|
story.append(summen)
|
||||||
|
|
||||||
# Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt
|
# Hinweis zur Beherbergungssteuer.
|
||||||
# wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos.
|
|
||||||
if angebot:
|
if angebot:
|
||||||
story.append(Spacer(1, 1.5 * mm))
|
story.append(Spacer(1, 1.5 * mm))
|
||||||
story.append(Paragraph(
|
story.append(Paragraph(
|
||||||
"In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt "
|
"In diesem Betrag sind alle Steuern und Abgaben enthalten. Aufgeschlüsselt "
|
||||||
"werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
|
"werden sie erst in der Rechnung nach dem Aufenthalt.", st["dank"]))
|
||||||
elif not gesamtrechnung_betont:
|
else:
|
||||||
for a in rechnung.aufschlaege:
|
for a in rechnung.aufschlaege:
|
||||||
if not a.bezeichnung:
|
if not a.bezeichnung:
|
||||||
continue
|
continue
|
||||||
|
|
@ -534,90 +498,83 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont):
|
||||||
story.append(Spacer(1, 8 * mm))
|
story.append(Spacer(1, 8 * mm))
|
||||||
|
|
||||||
# ---- Schlusstexte -------------------------------------------------------
|
# ---- Schlusstexte -------------------------------------------------------
|
||||||
# Dank- und Zahlungstexte gehoeren nur auf die Kundenseite. Die
|
texte = cfg.get("texte", {})
|
||||||
# STORNO-Erklaerung gehoert aber AUCH aufs Amtsblatt: dort steht sonst eine
|
block = []
|
||||||
# Stornonummer neben einer voellig anderen Rechnungsnummer, und niemand
|
if art == "storno":
|
||||||
# erfaehrt, warum. Sein Fund am 04.09.2026: *"in der pdf wird der satz nicht
|
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
|
||||||
# angezeigt, da ist dann ein sprung in den rechnungs nummern"* - genau das
|
# nichts geschuldet, sondern aufgehoben.
|
||||||
# Blatt, das die Stadt bekommt, war der Beleg ohne Begruendung.
|
bezug = kopf.get("storno_zu", "")
|
||||||
if not gesamtrechnung_betont or art == "storno":
|
if kopf.get("storno_datum"):
|
||||||
texte = cfg.get("texte", {})
|
bezug += f" vom {kopf['storno_datum']}"
|
||||||
block = []
|
folgt = kopf.get("korrektur_nummer") or ""
|
||||||
if art == "storno":
|
if kopf.get("ersatz_vorhanden") and folgt:
|
||||||
# Auf einem Storno sind Dank- und Zahlungstexte falsch - hier wird
|
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
|
||||||
# nichts geschuldet, sondern aufgehoben.
|
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
|
||||||
bezug = kopf.get("storno_zu", "")
|
# Gast auf eine Rechnung, die nie kommt.
|
||||||
if kopf.get("storno_datum"):
|
|
||||||
bezug += f" vom {kopf['storno_datum']}"
|
|
||||||
folgt = kopf.get("korrektur_nummer") or ""
|
|
||||||
if kopf.get("ersatz_vorhanden") and folgt:
|
|
||||||
# Doppelt geschriebene Leistung: es kommt nichts nach, sie ist
|
|
||||||
# bereits abgerechnet. Der Satz muss das sagen - sonst wartet der
|
|
||||||
# Gast auf eine Rechnung, die nie kommt.
|
|
||||||
block.append(Paragraph(
|
|
||||||
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
|
||||||
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
|
|
||||||
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
|
|
||||||
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
|
|
||||||
"verrechnet.", st["dank"]))
|
|
||||||
story.append(KeepTogether(block))
|
|
||||||
return
|
|
||||||
if kopf.get("nur_storno"):
|
|
||||||
# Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
|
|
||||||
# Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
|
|
||||||
# es kommt keine, und der Gast wartet sonst darauf.
|
|
||||||
block.append(Paragraph(
|
|
||||||
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
|
||||||
"Die Leistung wird nicht abgerechnet – es folgt keine weitere "
|
|
||||||
"Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
|
|
||||||
story.append(KeepTogether(block))
|
|
||||||
return
|
|
||||||
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
|
|
||||||
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
|
|
||||||
f"→ {folgt}).") if folgt else ""
|
|
||||||
block.append(Paragraph(
|
block.append(Paragraph(
|
||||||
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
||||||
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
|
f"Die Leistung wurde versehentlich doppelt in Rechnung gestellt und "
|
||||||
"Rechnung verrechnet." + kette, st["dank"]))
|
f"ist bereits mit der Rechnung {folgt} abgerechnet – es folgt keine "
|
||||||
elif art == "berichtigung":
|
"weitere Rechnung. Bereits gezahlte Beträge werden erstattet oder "
|
||||||
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
|
"verrechnet.", st["dank"]))
|
||||||
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
|
story.append(KeepTogether(block))
|
||||||
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
|
return
|
||||||
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
|
if kopf.get("nur_storno"):
|
||||||
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
|
# Nur Storno (04.09.2026): die Leistung wird gar nicht abgerechnet.
|
||||||
# Aussteller. Also bleibt der Satz drauf.
|
# Hier darf NICHT von einer berichtigten Rechnung die Rede sein -
|
||||||
|
# es kommt keine, und der Gast wartet sonst darauf.
|
||||||
block.append(Paragraph(
|
block.append(Paragraph(
|
||||||
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
|
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
||||||
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
|
"Die Leistung wird nicht abgerechnet – es folgt keine weitere "
|
||||||
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
|
"Rechnung. Bereits gezahlte Beträge werden erstattet.", st["dank"]))
|
||||||
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
|
story.append(KeepTogether(block))
|
||||||
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
|
return
|
||||||
"ihre Nummer.", st["dank"]))
|
kette = (f" Die berichtigte Rechnung trägt die Nummer {folgt} "
|
||||||
if texte.get("dank1"):
|
f"(also {kopf.get('storno_zu','')} → {kopf.get('rechnungsnummer','')} "
|
||||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
f"→ {folgt}).") if folgt else ""
|
||||||
elif angebot:
|
block.append(Paragraph(
|
||||||
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
|
f"Diese Stornorechnung hebt die Rechnung {bezug} vollständig auf. "
|
||||||
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
|
"Bereits gezahlte Beträge werden erstattet oder mit der berichtigten "
|
||||||
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
|
"Rechnung verrechnet." + kette, st["dank"]))
|
||||||
block.append(Paragraph(
|
elif art == "berichtigung":
|
||||||
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
|
# Der Satz ist kein Beiwerk: mehrere Rechnungen ueber dieselbe Leistung
|
||||||
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
|
# ohne Kennzeichnung loesen die Steuer erneut aus (Abschn. 14c.1 Abs. 4
|
||||||
"wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen "
|
# Satz 4 UStAE). Gegenueber Endverbrauchern entsteht sie zwar nicht
|
||||||
"stehen wir gern zur Verfügung.",
|
# (EuGH C-378/21, BMF v. 27.02.2024) - aber Firmengaeste und
|
||||||
st["dank"]))
|
# gewerbliche Dauercamper gibt es auch, und der Nachweis liegt beim
|
||||||
else:
|
# Aussteller. Also bleibt der Satz drauf.
|
||||||
if art == "korrektur" and kopf.get("ersetzt"):
|
block.append(Paragraph(
|
||||||
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
|
f"Dieses Blatt berichtigt die Angaben zur Rechnung "
|
||||||
if kopf.get("storno_nummer"):
|
f"{kopf.get('berichtigt_zu','')} vom {kopf.get('datum','')} nach "
|
||||||
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
|
"§ 31 Abs. 5 UStDV. Leistungen, Beträge und Umsatzsteuer sind "
|
||||||
block.append(Paragraph(hinweis + ".", st["dank"]))
|
"unverändert – es handelt sich <b>nicht</b> um eine zusätzliche "
|
||||||
if texte.get("dank1"):
|
"Leistung und nicht um eine zweite Rechnung. Die Rechnung behält "
|
||||||
block.append(Paragraph(texte["dank1"], st["dank"]))
|
"ihre Nummer.", st["dank"]))
|
||||||
if texte.get("dank2"):
|
if texte.get("dank1"):
|
||||||
block.append(Paragraph(texte["dank2"], st["dank"]))
|
block.append(Paragraph(texte["dank1"], st["dank"]))
|
||||||
if texte.get("hinweis"):
|
elif angebot:
|
||||||
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
|
# Dank- und Zahlungstexte waeren hier falsch: der Aufenthalt liegt
|
||||||
story.append(KeepTogether(block))
|
# noch vor uns, und bezahlt wird nichts. Der Hinweis "bitte die
|
||||||
|
# Rechnungsnummer angeben" wuerde dem Blatt sogar widersprechen.
|
||||||
|
block.append(Paragraph(
|
||||||
|
"Dieses Angebot ist freibleibend und unverbindlich (§ 145 BGB) und "
|
||||||
|
"keine Zahlungsaufforderung. Sagen Sie uns Bescheid, dann reservieren "
|
||||||
|
"wir – die Rechnung erhalten Sie nach dem Aufenthalt. Für Rückfragen "
|
||||||
|
"stehen wir gern zur Verfügung.",
|
||||||
|
st["dank"]))
|
||||||
|
else:
|
||||||
|
if art == "korrektur" and kopf.get("ersetzt"):
|
||||||
|
hinweis = f"Diese Rechnung ersetzt die Rechnung {kopf['ersetzt']}"
|
||||||
|
if kopf.get("storno_nummer"):
|
||||||
|
hinweis += f", die mit der Stornorechnung {kopf['storno_nummer']} aufgehoben wurde"
|
||||||
|
block.append(Paragraph(hinweis + ".", st["dank"]))
|
||||||
|
if texte.get("dank1"):
|
||||||
|
block.append(Paragraph(texte["dank1"], st["dank"]))
|
||||||
|
if texte.get("dank2"):
|
||||||
|
block.append(Paragraph(texte["dank2"], st["dank"]))
|
||||||
|
if texte.get("hinweis"):
|
||||||
|
block.append(Paragraph(texte["hinweis"], st["hinweis"]))
|
||||||
|
story.append(KeepTogether(block))
|
||||||
|
|
||||||
|
|
||||||
def _kenndaten(rechnung, kopf):
|
def _kenndaten(rechnung, kopf):
|
||||||
|
|
@ -736,52 +693,37 @@ def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False):
|
||||||
rechnung : berechnung.Rechnung
|
rechnung : berechnung.Rechnung
|
||||||
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
|
kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen
|
||||||
|
|
||||||
EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt):
|
EIN Blatt: die Rechnung fuer den Gast.
|
||||||
Seite 1 = Kunden-Rechnung, vollstaendig (Steuer ausgewiesen, Bankverbindung im Fuss).
|
|
||||||
Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
|
Bis zum 05.09.2026 hing hier ein zweites Blatt "fuer das Amt" mit derselben
|
||||||
keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
|
Rechnungsnummer, aber ohne Beherbergungssteuer - und damit mit einem anderen
|
||||||
|
Gesamtbetrag. Genau das ist bei einer Pruefung ein Problem: mehrere Blaetter
|
||||||
|
zum selben Umsatz mit verschiedenen Endbetraegen sehen aus, als werde ueber
|
||||||
|
verschiedene Umsaetze abgerechnet. Es ist ersatzlos entfallen.
|
||||||
|
|
||||||
|
Was die Stadt braucht, kommt aus dem Steuerjournal: es liest die Kenndaten
|
||||||
|
(subject) dieser PDF und rechnet die Bemessungsgrundlage selbst aus. Die
|
||||||
|
Anmeldung geht als Summenmeldung ans Amt, nicht als Rechnungskopie mit
|
||||||
|
Gastnamen.
|
||||||
"""
|
"""
|
||||||
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
|
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
|
||||||
st = _styles()
|
st = _styles()
|
||||||
|
|
||||||
def bauen(ziel, amt_seite):
|
doc = SimpleDocTemplate(
|
||||||
doc = SimpleDocTemplate(
|
pfad, pagesize=A4,
|
||||||
ziel, pagesize=A4,
|
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
|
||||||
leftMargin=MARGIN_L, rightMargin=MARGIN_R,
|
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
|
||||||
topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT,
|
title=({"storno": "Stornorechnung",
|
||||||
title=({"storno": "Stornorechnung",
|
"angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
|
||||||
"angebot": "Angebot"}.get(kopf.get("art"), "Rechnung"))
|
+ f" {kopf.get('rechnungsnummer','')}",
|
||||||
+ f" {kopf.get('rechnungsnummer','')}",
|
author=cfg["firma"].get("name", ""),
|
||||||
author=cfg["firma"].get("name", ""),
|
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
|
||||||
subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal
|
)
|
||||||
)
|
story = []
|
||||||
story = []
|
_seite_inhalt(story, st, cfg, rechnung, kopf)
|
||||||
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde
|
|
||||||
# Ein Angebot bekommt KEIN Blatt fuers Amt: gemeldet wird eine
|
|
||||||
# Uebernachtung erst, wenn sie abgerechnet ist.
|
|
||||||
if rechnung.aufschlaege and kopf.get("art") != "angebot":
|
|
||||||
story.append(PageBreak())
|
|
||||||
story.append(_AmtMarke(marke))
|
|
||||||
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
|
|
||||||
|
|
||||||
def seite(c, d):
|
def seite(c, d):
|
||||||
_kopf_fuss(c, d, cfg,
|
_kopf_fuss(c, d, cfg, vorschau=vorschau)
|
||||||
amt=(amt_seite is not None and c.getPageNumber() >= amt_seite),
|
|
||||||
vorschau=vorschau)
|
|
||||||
|
|
||||||
doc.build(story, onFirstPage=seite, onLaterPages=seite)
|
doc.build(story, onFirstPage=seite, onLaterPages=seite)
|
||||||
|
|
||||||
# Zwei Durchlaeufe, und zwar aus einem konkreten Grund:
|
|
||||||
#
|
|
||||||
# Kopf und Fuss werden VOR dem Seiteninhalt gezeichnet - beim Zeichnen weiss die
|
|
||||||
# Seite also noch nicht, ob auf ihr gleich die Kunden- oder die Amts-Seite landet.
|
|
||||||
# Auf "Seite 2 ist das Amt" zu wetten, waere falsch, sobald die Kundenrechnung
|
|
||||||
# laenger als eine Seite wird: dann stuende die Bankverbindung ploetzlich auf dem
|
|
||||||
# Amt-Blatt und fehlte beim Kunden.
|
|
||||||
#
|
|
||||||
# Darum: erster Lauf ins Nichts, nur um zu erfahren, auf welcher Seite das
|
|
||||||
# Amt-Blatt beginnt. Zweiter Lauf schreibt die echte Datei.
|
|
||||||
marke = {}
|
|
||||||
bauen(io.BytesIO(), None)
|
|
||||||
bauen(pfad, marke.get("seite"))
|
|
||||||
return pfad
|
return pfad
|
||||||
|
|
|
||||||
168
pruef_api.py
Normal file
168
pruef_api.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Pruefstand: das Fenster ueber die API bedienen - mit Schluessel.
|
||||||
|
|
||||||
|
Warum es das gibt (seine Ansage): *"bau dir steuer logik per api rein, dann
|
||||||
|
kannst damit die sachen triggern, brauchst meine maus nicht"* und *"mach pw
|
||||||
|
rein, also schluessel wie bei ssh key, das nur du und das beherbergungssteuer
|
||||||
|
ding rein kann, weil muss ja sprechen"*.
|
||||||
|
|
||||||
|
Geprueft wird:
|
||||||
|
|
||||||
|
1. Ohne Schluessel geht NICHTS - auch nicht "nur nachsehen".
|
||||||
|
2. Mit falschem Schluessel ebenfalls nicht.
|
||||||
|
3. Mit dem Schluessel aus dem gemeinsamen Ordner laesst sich das Fenster
|
||||||
|
fuellen, nachlesen und eine Vorschau erzeugen.
|
||||||
|
4. Was einen BELEG schreibt, geht nur im Testlauf - und schreibt dann
|
||||||
|
tatsaechlich die Rechnung.
|
||||||
|
5. ping und schau_nach bleiben schluessellos, sonst kann ein aelteres
|
||||||
|
Steuerjournal nicht mehr anklopfen.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
QUELLE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, QUELLE)
|
||||||
|
|
||||||
|
tmp = tempfile.mkdtemp(prefix="api_pruef_")
|
||||||
|
ordner = os.path.join(tmp, "rechnungen")
|
||||||
|
gemeinsam_ordner = os.path.join(tmp, "install")
|
||||||
|
os.makedirs(ordner)
|
||||||
|
os.makedirs(gemeinsam_ordner)
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
|
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
|
||||||
|
config.BASE_DIR = tmp
|
||||||
|
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
|
||||||
|
roh["gemeinsam_ordner"] = gemeinsam_ordner
|
||||||
|
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||||
|
import api # noqa: E402
|
||||||
|
import app as A # noqa: E402
|
||||||
|
import gemeinsam # noqa: E402
|
||||||
|
from tkinter import messagebox # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _reissleine():
|
||||||
|
print("ABBRUCH | Pruefstand haengt (Zeitgrenze)")
|
||||||
|
os._exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
_wecker = threading.Timer(120, _reissleine)
|
||||||
|
_wecker.daemon = True
|
||||||
|
_wecker.start()
|
||||||
|
|
||||||
|
fehler = []
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe(bedingung, text):
|
||||||
|
zeile = (" OK | " if bedingung else "FEHLER | ") + text
|
||||||
|
k = sys.stdout.encoding or "utf-8"
|
||||||
|
print(zeile.encode(k, "replace").decode(k))
|
||||||
|
if not bedingung:
|
||||||
|
fehler.append(text)
|
||||||
|
|
||||||
|
|
||||||
|
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
|
||||||
|
messagebox.askyesno = lambda *a, **k: True
|
||||||
|
|
||||||
|
f = A.RechnungsApp()
|
||||||
|
f.cfg["output_dir"] = ordner
|
||||||
|
f.buch = gemeinsam.Nummernbuch(gemeinsam_ordner)
|
||||||
|
f.api.starten(f.buch, ordner=gemeinsam_ordner)
|
||||||
|
f.update()
|
||||||
|
|
||||||
|
port = f.api.port
|
||||||
|
pruefe(bool(port), f"die API laeuft ({port})")
|
||||||
|
|
||||||
|
schluessel = api.schluessel_holen(gemeinsam_ordner)
|
||||||
|
pruefe(len(schluessel) == 64, f"ein Schluessel liegt im gemeinsamen Ordner ({len(schluessel)} Zeichen)")
|
||||||
|
pruefe(os.path.exists(os.path.join(gemeinsam_ordner, api.SCHLUESSEL_DATEI)),
|
||||||
|
"und zwar als Datei neben dem Nummernbuch")
|
||||||
|
pruefe(api.schluessel_holen(gemeinsam_ordner) == schluessel,
|
||||||
|
"beim zweiten Lesen kommt derselbe zurueck (kein neuer je Start)")
|
||||||
|
|
||||||
|
|
||||||
|
def rufen(**nachricht):
|
||||||
|
"""Direkt anrufen - der Pruefstand spielt hier das andere Programm."""
|
||||||
|
was = nachricht.pop("was", "aktion")
|
||||||
|
return api._reden(port, was, zeitgrenze=20.0, **nachricht)
|
||||||
|
|
||||||
|
|
||||||
|
# Das Fenster muss waehrend des Anrufs weiterlaufen: die Aktion wird in seinem
|
||||||
|
# Faden ausgefuehrt. Also nebenher anrufen und die Ereignisschleife drehen.
|
||||||
|
def anrufen_und_drehen(**nachricht):
|
||||||
|
ergebnis = {}
|
||||||
|
|
||||||
|
def anruf():
|
||||||
|
ergebnis["antwort"] = rufen(**nachricht)
|
||||||
|
|
||||||
|
t = threading.Thread(target=anruf, daemon=True)
|
||||||
|
t.start()
|
||||||
|
while t.is_alive():
|
||||||
|
f.update()
|
||||||
|
return ergebnis.get("antwort")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 1. + 2. Ohne (oder mit falschem) Schluessel: kein Zutritt -------------
|
||||||
|
pruefe(anrufen_und_drehen(name="zustand") == "kein zutritt",
|
||||||
|
"ohne Schluessel: kein Zutritt")
|
||||||
|
pruefe(anrufen_und_drehen(name="zustand", schluessel="0" * 64) == "kein zutritt",
|
||||||
|
"falscher Schluessel: kein Zutritt")
|
||||||
|
|
||||||
|
# ---- 5. Die alten zwei Woerter bleiben schluessellos -----------------------
|
||||||
|
pruefe(anrufen_und_drehen(was="ping") == api.ANTWORT_HALLO,
|
||||||
|
"ping antwortet weiterhin ohne Schluessel")
|
||||||
|
|
||||||
|
# ---- 3. Mit Schluessel: fuellen, nachlesen, Vorschau -----------------------
|
||||||
|
antwort = anrufen_und_drehen(
|
||||||
|
name="setze", schluessel=schluessel,
|
||||||
|
daten={"kunde": {"vorname": "Anna", "nachname": "Beispiel",
|
||||||
|
"strasse": "Musterweg 1", "plzort": "12345 Musterstadt"},
|
||||||
|
"anreise": "01.09.2026", "abreise": "04.09.2026",
|
||||||
|
"positionen": [{"nr": sorted(f.anzahl_vars)[0], "anzahl": 3}]})
|
||||||
|
pruefe(isinstance(antwort, dict) and antwort.get("kunde", {}).get("nachname") == "Beispiel",
|
||||||
|
f"mit Schluessel laesst sich das Formular fuellen ({str(antwort)[:60]}…)")
|
||||||
|
pruefe(antwort.get("gesamt", 0) > 0, f"und es ist gerechnet ({antwort.get('gesamt')})")
|
||||||
|
|
||||||
|
antwort = anrufen_und_drehen(name="vorschau", schluessel=schluessel)
|
||||||
|
pfad = (antwort or {}).get("pfad", "")
|
||||||
|
pruefe(bool(pfad) and os.path.exists(pfad), f"die Vorschau entsteht ohne Maus ({os.path.basename(pfad)})")
|
||||||
|
pruefe(not os.listdir(ordner), "und sie landet NICHT im Rechnungsordner")
|
||||||
|
|
||||||
|
# ---- 4. Beleg schreiben ---------------------------------------------------
|
||||||
|
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
|
||||||
|
pdfs = [n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]
|
||||||
|
pruefe(len(pdfs) == 1, f"im Testlauf schreibt die API die Rechnung ({pdfs})")
|
||||||
|
|
||||||
|
# Und im ECHTEN Betrieb nicht: dieselbe Aktion, aber ohne Testlauf-Flag.
|
||||||
|
A.TESTLAUF = False
|
||||||
|
antwort = anrufen_und_drehen(name="rechnung", schluessel=schluessel)
|
||||||
|
pruefe(isinstance(antwort, dict) and "nur im Testlauf" in str(antwort.get("fehler", "")),
|
||||||
|
f"im echten Betrieb verweigert sie den Beleg ({antwort})")
|
||||||
|
pruefe(len([n for n in os.listdir(ordner) if n.lower().endswith(".pdf")]) == 1,
|
||||||
|
"und es kam keine zweite Rechnung dazu")
|
||||||
|
A.TESTLAUF = True
|
||||||
|
|
||||||
|
# Unbekannter Name sagt, was es gibt - statt still nichts zu tun.
|
||||||
|
antwort = anrufen_und_drehen(name="tanz", schluessel=schluessel)
|
||||||
|
pruefe("kenne ich nicht" in str(antwort), f"unbekannte Aktion wird benannt ({str(antwort)[:50]}…)")
|
||||||
|
|
||||||
|
f.api.stoppen(f.buch)
|
||||||
|
f.destroy()
|
||||||
|
print()
|
||||||
|
if fehler:
|
||||||
|
print(f"FEHLGESCHLAGEN: {len(fehler)}")
|
||||||
|
for x in fehler:
|
||||||
|
print(" -", x)
|
||||||
|
print(f"(Ordner bleibt: {tmp})")
|
||||||
|
sys.exit(1)
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
print("ALLES GRUEN")
|
||||||
|
|
@ -98,13 +98,12 @@ def seiten_bilder_und_text(pdf):
|
||||||
|
|
||||||
|
|
||||||
seiten = seiten_bilder_und_text(pfad)
|
seiten = seiten_bilder_und_text(pfad)
|
||||||
pruefe(len(seiten) >= 2, f"Kundenblatt + Amt-Blatt: {len(seiten)} Seiten")
|
pruefe(len(seiten) == 1, f"genau EIN Blatt ({len(seiten)} Seiten)")
|
||||||
kunde_bilder, kunde_text = seiten[0]
|
kunde_bilder, kunde_text = seiten[0]
|
||||||
amt_bilder, amt_text = seiten[-1]
|
pruefe(kunde_bilder == 2, f"das Blatt traegt Kopf- UND Fussbild ({kunde_bilder})")
|
||||||
pruefe(kunde_bilder == 2, f"Kundenblatt traegt Kopf- UND Fussbild ({kunde_bilder})")
|
pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Rechnung")
|
||||||
pruefe(amt_bilder == 1, f"Amt-Blatt traegt NUR das Kopfbild ({amt_bilder})")
|
pruefe("Steuernummer" in kunde_text,
|
||||||
pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Kundenrechnung")
|
"die Steuernummer steht auf der Rechnung (§ 14 Abs. 4 Nr. 2 UStG)")
|
||||||
pruefe("Testbank" not in amt_text, "Bankverbindung steht NICHT auf dem Amt-Blatt")
|
|
||||||
|
|
||||||
# ---- 3. Die Firmenfarbe faerbt das Blatt ----------------------------------
|
# ---- 3. Die Firmenfarbe faerbt das Blatt ----------------------------------
|
||||||
# Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte
|
# Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,10 @@ pruefe(len(pos) == 1 and float(pos[0]["anzahl"]) == 1.0, f"eine Position, ein Mo
|
||||||
from pypdf import PdfReader # noqa: E402
|
from pypdf import PdfReader # noqa: E402
|
||||||
|
|
||||||
seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages]
|
seiten = [s.extract_text() or "" for s in PdfReader(pdf).pages]
|
||||||
pruefe(len(seiten) == 2, f"zwei Seiten - Kunde und Amt ({len(seiten)})")
|
# Seit 05.09.2026 gibt es nur noch EIN Blatt. Ein zweites mit derselben Nummer,
|
||||||
|
# aber anderem Gesamtbetrag ("fuer das Amt") sieht bei einer Pruefung aus, als
|
||||||
|
# werde ueber zwei verschiedene Umsaetze abgerechnet.
|
||||||
|
pruefe(len(seiten) == 1, f"genau EIN Blatt - kein zweites fuers Amt ({len(seiten)})")
|
||||||
kunde_seite = seiten[0].replace("\n", " ")
|
kunde_seite = seiten[0].replace("\n", " ")
|
||||||
pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt")
|
pruefe("Miete für Montagearbeiter" in kunde_seite, "die Miet-Zeile steht auf dem Blatt")
|
||||||
pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum")
|
pruefe("Zeitraum 01.08.2026" in kunde_seite, "mit dem Zeitraum")
|
||||||
|
|
@ -166,9 +169,8 @@ pruefe("Tage/Menge" not in kunde_seite and "Pos." not in kunde_seite,
|
||||||
pruefe("525,00" in kunde_seite and "500,00" in kunde_seite,
|
pruefe("525,00" in kunde_seite and "500,00" in kunde_seite,
|
||||||
"500,00 und 525,00 stehen drauf")
|
"500,00 und 525,00 stehen drauf")
|
||||||
pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen")
|
pruefe("Beherbergungssteuer" in kunde_seite, "die Steuer ist auf der Kundenseite ausgewiesen")
|
||||||
amt_seite = seiten[1].replace("\n", " ")
|
pruefe(kunde_seite.count("Rechnungsbetrag Insgesamt") == 1,
|
||||||
pruefe("Beherbergungssteuer" not in amt_seite,
|
"der Gesamtbetrag steht genau einmal in der Datei")
|
||||||
"auf dem Amtsblatt steht sie nicht - wie bei jeder anderen Rechnung")
|
|
||||||
|
|
||||||
# ---- 4. Das Steuerjournal liest die Datei ---------------------------------
|
# ---- 4. Das Steuerjournal liest die Datei ---------------------------------
|
||||||
sys.path.append(JOURNAL_PROG)
|
sys.path.append(JOURNAL_PROG)
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,17 @@ def erststart(zahl, erwartet_ja=True):
|
||||||
for k in knoepfe_von(top):
|
for k in knoepfe_von(top):
|
||||||
if "bernehmen" in str(k.cget("text")):
|
if "bernehmen" in str(k.cget("text")):
|
||||||
k.invoke()
|
k.invoke()
|
||||||
|
# Lehnt der Dialog die Zahl ab, BLEIBT er offen - das ist
|
||||||
|
# gewollt und wird weiter unten geprueft. Dann muss hier
|
||||||
|
# aber, wie ein Benutzer, "Spaeter" gedrueckt werden:
|
||||||
|
# sonst wartet das Programm in wait_window() ewig auf
|
||||||
|
# einen Klick, den im Pruefstand niemand macht.
|
||||||
|
top.update()
|
||||||
|
if top.winfo_exists():
|
||||||
|
for spaeter in knoepfe_von(top):
|
||||||
|
if "ter" in str(spaeter.cget("text")):
|
||||||
|
spaeter.invoke()
|
||||||
|
break
|
||||||
return
|
return
|
||||||
if rest:
|
if rest:
|
||||||
f.after(100, lambda: klick(rest - 1))
|
f.after(100, lambda: klick(rest - 1))
|
||||||
|
|
|
||||||
103
pruefsteuerung.py
Normal file
103
pruefsteuerung.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Steuerung fuer Pruefstaende: keine Fenster, keine Popups, keine Maus.
|
||||||
|
|
||||||
|
WARUM
|
||||||
|
-----
|
||||||
|
Ein Pruefstand baut das Programm auf, um es zu BEDIENEN - nicht, um es zu
|
||||||
|
zeigen. Solange er wie ein Benutzer arbeitet, reisst jeder Lauf dem Menschen am
|
||||||
|
Rechner Fokus und Maus weg, und jeder Dialog bleibt stehen und wartet auf einen
|
||||||
|
Klick, den niemand macht.
|
||||||
|
|
||||||
|
Deshalb zwei Dinge, beide nur im Testlauf (`--test` / `CAMPINGHOF_TEST=1`):
|
||||||
|
|
||||||
|
* Das Fenster wird gar nicht erst gezeigt (`withdraw()` in app.py).
|
||||||
|
* Alle Dialoge laufen hierueber statt ueber echte Fenster.
|
||||||
|
|
||||||
|
WAS EIN DIALOG HIER TUT
|
||||||
|
-----------------------
|
||||||
|
`showinfo/showwarning/showerror` werden ins PROTOKOLL geschrieben und
|
||||||
|
verschwinden. `askyesno` und Verwandte schlagen im Fahrplan nach:
|
||||||
|
|
||||||
|
pruefsteuerung.antwortet("Hausnummer", True)
|
||||||
|
|
||||||
|
Passt kein Eintrag, bricht der Lauf mit einer Meldung ab - ABSICHTLICH. Ein
|
||||||
|
unbekannter Dialog heisst: das Programm fragt etwas Neues, und niemand hat
|
||||||
|
entschieden, was der Pruefstand darauf antworten soll. Stillschweigend "ja" zu
|
||||||
|
sagen waere die faule Loesung und wuerde genau die Faelle uebergehen, wegen
|
||||||
|
denen es die Pruefung gibt.
|
||||||
|
|
||||||
|
BENUTZUNG IM PRUEFSTAND
|
||||||
|
-----------------------
|
||||||
|
import pruefsteuerung as PS
|
||||||
|
PS.antwortet("Storno wirklich", True)
|
||||||
|
...
|
||||||
|
PS.gemeldet("Kundenangaben fehlen") -> True, wenn das Programm es sagte
|
||||||
|
"""
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
# Was das Programm gemeldet haette: Liste aus (art, titel, text).
|
||||||
|
PROTOKOLL = []
|
||||||
|
# Fahrplan fuer Ja/Nein-Fragen: Textstueck im Titel -> Antwort.
|
||||||
|
ANTWORTEN = {}
|
||||||
|
_aktiv = False
|
||||||
|
|
||||||
|
|
||||||
|
def antwortet(titelstueck, wert):
|
||||||
|
"""Legt fest, wie eine Frage beantwortet wird, deren Titel das Stueck enthaelt."""
|
||||||
|
ANTWORTEN[titelstueck] = wert
|
||||||
|
|
||||||
|
|
||||||
|
def zuruecksetzen():
|
||||||
|
PROTOKOLL.clear()
|
||||||
|
ANTWORTEN.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def meldungen(stueck=""):
|
||||||
|
"""Alle Meldungen (oder die, in denen das Stueck vorkommt)."""
|
||||||
|
return [m for m in PROTOKOLL if stueck in m[1] or stueck in m[2]]
|
||||||
|
|
||||||
|
|
||||||
|
def gemeldet(stueck):
|
||||||
|
"""Hat das Programm etwas gemeldet, in dem das Stueck vorkommt?"""
|
||||||
|
return bool(meldungen(stueck))
|
||||||
|
|
||||||
|
|
||||||
|
def _melden(art):
|
||||||
|
def gemeldet_(titel="", text="", **_kw):
|
||||||
|
PROTOKOLL.append((art, str(titel), str(text)))
|
||||||
|
print(f"[dialog] {art}: {titel}")
|
||||||
|
return "ok"
|
||||||
|
return gemeldet_
|
||||||
|
|
||||||
|
|
||||||
|
def _fragen(vorgabe=None):
|
||||||
|
def gefragt(titel="", text="", **_kw):
|
||||||
|
PROTOKOLL.append(("frage", str(titel), str(text)))
|
||||||
|
for stueck, wert in ANTWORTEN.items():
|
||||||
|
if stueck in str(titel) or stueck in str(text):
|
||||||
|
print(f"[dialog] frage: {titel} -> {wert}")
|
||||||
|
return wert
|
||||||
|
if vorgabe is not None:
|
||||||
|
return vorgabe
|
||||||
|
raise AssertionError(
|
||||||
|
f"unangemeldeter Dialog: {titel!r}\n"
|
||||||
|
f" {text!r}\n"
|
||||||
|
" -> im Pruefstand mit pruefsteuerung.antwortet(<Stueck>, True/False) "
|
||||||
|
"beantworten")
|
||||||
|
return gefragt
|
||||||
|
|
||||||
|
|
||||||
|
def einschalten():
|
||||||
|
"""Dialoge umleiten. Mehrfaches Aufrufen schadet nicht."""
|
||||||
|
global _aktiv
|
||||||
|
if _aktiv:
|
||||||
|
return
|
||||||
|
messagebox.showinfo = _melden("info")
|
||||||
|
messagebox.showwarning = _melden("warnung")
|
||||||
|
messagebox.showerror = _melden("fehler")
|
||||||
|
messagebox.askyesno = _fragen()
|
||||||
|
messagebox.askokcancel = _fragen()
|
||||||
|
messagebox.askretrycancel = _fragen(False)
|
||||||
|
messagebox.askquestion = _fragen()
|
||||||
|
_aktiv = True
|
||||||
|
print("[pruefsteuerung] Dialoge laufen ohne Fenster")
|
||||||
9
theme.py
9
theme.py
|
|
@ -9,6 +9,8 @@ import sys
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk
|
from tkinter import ttk
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
def windows_dark_mode():
|
def windows_dark_mode():
|
||||||
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
|
"""True, wenn Windows auf das dunkle App-Theme eingestellt ist."""
|
||||||
|
|
@ -113,7 +115,12 @@ class ThemeMixin:
|
||||||
self.option_add("*foreground", fg)
|
self.option_add("*foreground", fg)
|
||||||
|
|
||||||
def _style_dialog(self, d):
|
def _style_dialog(self, d):
|
||||||
"""Toplevel-Dialog an das Theme angleichen."""
|
"""Toplevel-Dialog an das Theme angleichen.
|
||||||
|
|
||||||
|
Im Pruefstand wird derselbe Dialog gebaut und bedient, aber nicht
|
||||||
|
gezeigt - und sein grab_set faellt weg. Sonst haelt jeder Dialog Maus
|
||||||
|
und Tastatur fest, waehrend nebenan jemand schreibt.
|
||||||
|
"""
|
||||||
d.configure(bg=self.col_bg)
|
d.configure(bg=self.col_bg)
|
||||||
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
|
# erst nach dem Anzeigen setzen, sonst greift DWM noch nicht
|
||||||
d.after(20, lambda: self._dark_titlebar(d))
|
d.after(20, lambda: self._dark_titlebar(d))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue