Bilder und Farbe gehoeren der Firma, dazu ein Vorschau-Knopf
Logo und ein Fussstreifen sind austauschbar (bilder.py: das gewaehlte Bild wird KOPIERT, ein Pfad auf den Desktop waere morgen tot), die Akzentfarbe kommt aus den Einstellungen - Hex-Feld und Farbwaehler. Firmenname, Anschrift, Bank und Steuernummer bleiben gezeichnet; kein Bild kann eine Pflichtangabe verdecken. Der Vorschau-Knopf zeigt das Blatt, bevor es eine Rechnung wird: kein Nummernverbrauch, nichts im Rechnungsordner, "VORSCHAU - keine Rechnung" quer ueber jeder Seite. Sonst kostet jeder Tippfehler einen Storno. Neue Pruefstaende: pruef_bilder.py, pruef_vorschau.py Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
parent
37b5c60266
commit
142dddc647
7 changed files with 636 additions and 78 deletions
100
app.py
100
app.py
|
|
@ -20,6 +20,7 @@ Aufgeteilt in:
|
|||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import sys
|
||||
import datetime
|
||||
import queue
|
||||
|
|
@ -106,6 +107,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
self._open_pop = None
|
||||
self._menus = {
|
||||
"Datei": [
|
||||
("Vorschau ansehen", self.vorschau_zeigen),
|
||||
("PDF erstellen", self.pdf_erstellen),
|
||||
("Formular zurücksetzen", self.reset_formular),
|
||||
("Ausgabeordner öffnen", self._ordner_oeffnen),
|
||||
|
|
@ -524,6 +526,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
|
||||
links = ttk.Frame(f)
|
||||
links.pack(side="left")
|
||||
ttk.Button(links, text="Vorschau", command=self.vorschau_zeigen, width=12).pack(side="left", padx=4)
|
||||
ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4)
|
||||
ttk.Button(links, text="Storno", command=self.dlg_storno,
|
||||
width=12).pack(side="left", padx=4)
|
||||
|
|
@ -1074,6 +1077,76 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
return jahr, zaehler, nummer
|
||||
return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
|
||||
|
||||
def _kopf_basis(self, nummer, r):
|
||||
"""Die Kopfdaten, die JEDES Blatt braucht - Rechnung wie Vorschau.
|
||||
|
||||
Steht nur hier: sonst tippt die Vorschau denselben Adressblock ein
|
||||
zweites Mal ab, und beim naechsten neuen Feld zeigt sie ein Blatt, das
|
||||
es so gar nicht gibt.
|
||||
|
||||
Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
|
||||
der Nachname geht zusaetzlich getrennt mit, damit das Steuerjournal ihn
|
||||
nicht aus dem Adressblock raten muss.
|
||||
"""
|
||||
nachname = self.k_name.get().strip()
|
||||
vorname = self.k_vorname.get().strip()
|
||||
namenszeile = " ".join(t for t in (vorname, nachname) if t)
|
||||
return {
|
||||
"rechnungsnummer": nummer,
|
||||
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
|
||||
"anreise": self.r_anreise.get().strip(),
|
||||
"abreise": self.r_abreise.get().strip(),
|
||||
"naechte": self.r_naechte.get() if self.r_naechte.get() != "–" else "",
|
||||
"kunde_zeilen": [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
|
||||
self.k_strasse.get(), self.k_plzort.get()],
|
||||
"nachname": nachname,
|
||||
"vorname": vorname,
|
||||
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
|
||||
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
|
||||
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
|
||||
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
|
||||
"strasse": self.k_strasse.get().strip(),
|
||||
"plzort": self.k_plzort.get().strip()},
|
||||
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
|
||||
for z in r.zeilen],
|
||||
}
|
||||
|
||||
def vorschau_zeigen(self):
|
||||
"""Zeigt das Blatt, BEVOR es eine Rechnung wird.
|
||||
|
||||
Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno
|
||||
geradeziehen - Papier, Porto und ein Weg zum Gast. Deshalb dieser Knopf:
|
||||
ansehen, aendern, ansehen. Die Vorschau
|
||||
|
||||
- verbraucht KEINE Rechnungsnummer und traegt nichts ins Nummernbuch,
|
||||
- landet NICHT im Rechnungsordner, sondern im Temp-Ordner,
|
||||
- traegt quer ueber jede Seite "VORSCHAU - keine Rechnung".
|
||||
|
||||
Gepruefte Pflichtfelder gibt es hier bewusst nicht: man will ja gerade
|
||||
sehen, was noch fehlt.
|
||||
"""
|
||||
montage = self.ist_montage()
|
||||
r = self.recalc()
|
||||
if not r.zeilen:
|
||||
messagebox.showinfo(
|
||||
"Noch nichts einzutragen",
|
||||
"Bitte die Anzahl der Monate eintragen." if montage else
|
||||
"Trage erst bei einer Leistung eine Anzahl ein - dann zeigt die "
|
||||
"Vorschau, wie die Rechnung aussehen wird.")
|
||||
return
|
||||
kopf = self._kopf_basis(self.r_nummer.get().strip() or "(noch keine Nummer)", r)
|
||||
if montage:
|
||||
kopf["vorlage"] = "montage"
|
||||
kopf["montage_text"] = self._montage_text()
|
||||
pfad = os.path.join(tempfile.gettempdir(), "Vorschau - so wird die Rechnung.pdf")
|
||||
try:
|
||||
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True)
|
||||
print(f"[vorschau] geschrieben -> {pfad}")
|
||||
os.startfile(pfad) # noqa: S606
|
||||
except OSError as e:
|
||||
messagebox.showerror("Vorschau nicht möglich",
|
||||
f"Die Vorschau konnte nicht geöffnet werden:\n{e}")
|
||||
|
||||
def pdf_erstellen(self, angebot=False):
|
||||
montage = self.ist_montage()
|
||||
r = self.recalc()
|
||||
|
|
@ -1145,14 +1218,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
# unangetastet, die naechste Rechnung behaelt ihre Nummer.
|
||||
a_jahr, a_zaehler, nummer = self._naechstes_angebot()
|
||||
|
||||
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
|
||||
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
|
||||
# nicht aus dem Adressblock raten muss.
|
||||
nachname = self.k_name.get().strip()
|
||||
vorname = self.k_vorname.get().strip()
|
||||
namenszeile = " ".join(t for t in (vorname, nachname) if t)
|
||||
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
|
||||
self.k_strasse.get(), self.k_plzort.get()]
|
||||
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
|
||||
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
|
||||
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
|
||||
|
|
@ -1173,24 +1238,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
|||
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
|
||||
return
|
||||
|
||||
kopf = {
|
||||
"rechnungsnummer": nummer,
|
||||
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
|
||||
"anreise": self.r_anreise.get().strip(),
|
||||
"abreise": self.r_abreise.get().strip(),
|
||||
"naechte": self.r_naechte.get() if self.r_naechte.get() != "–" else "",
|
||||
"kunde_zeilen": kunde_zeilen,
|
||||
"nachname": nachname,
|
||||
"vorname": vorname,
|
||||
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
|
||||
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
|
||||
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
|
||||
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
|
||||
"strasse": self.k_strasse.get().strip(),
|
||||
"plzort": self.k_plzort.get().strip()},
|
||||
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
|
||||
for z in r.zeilen],
|
||||
}
|
||||
kopf = self._kopf_basis(nummer, r)
|
||||
if montage:
|
||||
# Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung
|
||||
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.
|
||||
|
|
|
|||
117
bilder.py
Normal file
117
bilder.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Bilder fuer Kopf- und Fusszeile der Rechnung: ablegen, wiederfinden, laden.
|
||||
|
||||
Warum eine eigene Datei: Kopfbild und Fussbild brauchen dieselben drei Schritte
|
||||
(uebernehmen -> aufloesen -> laden), und der PDF-Renderer, der Einstellungsdialog
|
||||
und die Pruefstaende brauchen sie alle. Stuende die Logik im Renderer, muesste der
|
||||
Dialog sie nachbauen - und beim naechsten Bild laufen die beiden auseinander.
|
||||
|
||||
⭐ Der wichtigste Teil ist `uebernehmen`: das gewaehlte Bild wird in den Ordner
|
||||
`bilder/` neben der config.json KOPIERT, statt nur seinen Pfad zu merken. Sonst
|
||||
zeigt die Einstellung auf den Desktop des Nutzers - und sobald er dort aufraeumt,
|
||||
faellt das Logo still von der Rechnung. Gemerkt wird nur der kurze Name
|
||||
("bilder/logo.png"), damit die Einstellung auch nach einem Umzug des Ordners passt.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import config
|
||||
|
||||
# Erlaubte Formate. reportlab liest PNG/JPG/GIF direkt; SVG kann es nicht -
|
||||
# deshalb steht es hier bewusst NICHT.
|
||||
FORMATE = (".png", ".jpg", ".jpeg", ".gif")
|
||||
ORDNER = "bilder"
|
||||
|
||||
|
||||
def bilder_dir():
|
||||
"""Ordner fuer die uebernommenen Bilder (neben der config.json). Wird angelegt."""
|
||||
d = os.path.join(config.BASE_DIR, ORDNER)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def uebernehmen(quelle):
|
||||
"""Bild in den Bilderordner kopieren -> gibt den zu speichernden Pfad zurueck.
|
||||
|
||||
Liegt die Datei schon im Bilderordner, wird nichts kopiert. Ein Name, der
|
||||
bereits vergeben ist, bekommt eine Nummer angehaengt - ein bestehendes Bild
|
||||
wird NIE ueberschrieben, sonst waere das alte Logo mit einem Klick weg.
|
||||
"""
|
||||
quelle = (quelle or "").strip()
|
||||
if not quelle:
|
||||
return ""
|
||||
if not os.path.exists(quelle):
|
||||
print(f"[bilder] Quelle gibt es nicht: {quelle}")
|
||||
return ""
|
||||
ziel_dir = bilder_dir()
|
||||
name = os.path.basename(quelle)
|
||||
if os.path.dirname(os.path.abspath(quelle)) == os.path.abspath(ziel_dir):
|
||||
return f"{ORDNER}/{name}"
|
||||
stamm, endung = os.path.splitext(name)
|
||||
ziel = os.path.join(ziel_dir, name)
|
||||
n = 2
|
||||
while os.path.exists(ziel) and not _gleich(quelle, ziel):
|
||||
ziel = os.path.join(ziel_dir, f"{stamm}-{n}{endung}")
|
||||
n += 1
|
||||
try:
|
||||
shutil.copyfile(quelle, ziel)
|
||||
print(f"[bilder] uebernommen: {quelle} -> {ziel}")
|
||||
except OSError as e:
|
||||
print(f"[bilder] FEHLER beim Kopieren ({e}) - merke den Originalpfad")
|
||||
return quelle
|
||||
return f"{ORDNER}/{os.path.basename(ziel)}"
|
||||
|
||||
|
||||
def _gleich(a, b):
|
||||
"""Zwei Dateien byte-gleich? Verhindert 'logo-2.png' beim zweiten Waehlen desselben Bilds."""
|
||||
try:
|
||||
if os.path.getsize(a) != os.path.getsize(b):
|
||||
return False
|
||||
with open(a, "rb") as fa, open(b, "rb") as fb:
|
||||
return fa.read() == fb.read()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def aufloesen(pfad):
|
||||
"""Gespeicherten Pfad -> existierender absoluter Pfad, sonst "".
|
||||
|
||||
Ein relativer Pfad wird erst neben dem Programm gesucht (BASE_DIR, dort liegt
|
||||
der Bilderordner), dann im gebuendelten Ressourcenordner der .exe - so findet
|
||||
die exe ihr mitgeliefertes Standardlogo, der Nutzer aber sein eigenes.
|
||||
"""
|
||||
pfad = (pfad or "").strip()
|
||||
if not pfad:
|
||||
return ""
|
||||
if os.path.isabs(pfad):
|
||||
return pfad if os.path.exists(pfad) else ""
|
||||
for basis in (config.BASE_DIR, config.RESOURCE_DIR):
|
||||
kandidat = os.path.join(basis, pfad)
|
||||
if os.path.exists(kandidat):
|
||||
return kandidat
|
||||
return ""
|
||||
|
||||
|
||||
def laden(pfad):
|
||||
"""(ImageReader, breite, hoehe) oder (None, 0, 0). Meldet, warum es nicht ging."""
|
||||
voll = aufloesen(pfad)
|
||||
if not voll:
|
||||
if (pfad or "").strip():
|
||||
print(f"[bilder] nicht gefunden: {pfad}")
|
||||
return None, 0, 0
|
||||
try:
|
||||
from reportlab.lib.utils import ImageReader
|
||||
img = ImageReader(voll)
|
||||
iw, ih = img.getSize()
|
||||
return img, iw, ih
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[bilder] '{voll}' laesst sich nicht lesen: {e}")
|
||||
return None, 0, 0
|
||||
|
||||
|
||||
def einpassen(iw, ih, box_w, box_h):
|
||||
"""Groesse im Kasten, Seitenverhaeltnis bleibt. Nie groesser als das Original."""
|
||||
if not iw or not ih:
|
||||
return 0, 0
|
||||
skala = min(box_w / iw, box_h / ih)
|
||||
return iw * skala, ih * skala
|
||||
13
config.py
13
config.py
|
|
@ -124,6 +124,11 @@ DEFAULT_FIRMA = {
|
|||
"email": "E-Mail kontakt@example.de",
|
||||
"telefon": "Telefon +49 (0) 000 0000000",
|
||||
"logo": "assets/logo.png", # Bilddatei -> oben links auf der Rechnung (relativ zum Tool-Ordner)
|
||||
# Bild fuer die Fusszeile: ein schmaler Streifen UNTER den Fusstexten. Firma,
|
||||
# Anschrift, Bank und Steuernummer bleiben immer gezeichnet - sie kommen aus
|
||||
# den Einstellungen und duerfen nicht in einem Bild verschwinden, das niemand
|
||||
# nachlesen kann. Leer = die Rechnung sieht aus wie bisher.
|
||||
"fussbild": "",
|
||||
}
|
||||
|
||||
# Festtexte unten auf der Rechnung (aus der Vorlage).
|
||||
|
|
@ -135,8 +140,16 @@ DEFAULT_TEXTE = {
|
|||
"hinweis": "* Bei Zahlung per Überweisung bitte die Rechnungsnummer angeben *",
|
||||
}
|
||||
|
||||
# Aussehen der Rechnung. EINE Farbe - die helle Flaeche im Tabellenkopf wird im
|
||||
# Renderer daraus gemischt. Jede Firma faerbt ihr Blatt selbst; ein Handwerker
|
||||
# will kein Camping-Gruen.
|
||||
DEFAULT_DESIGN = {
|
||||
"akzent": "#1f5c3d",
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"firma": DEFAULT_FIRMA,
|
||||
"design": DEFAULT_DESIGN,
|
||||
"texte": DEFAULT_TEXTE,
|
||||
"katalog": DEFAULT_KATALOG,
|
||||
"montage": DEFAULT_MONTAGE,
|
||||
|
|
|
|||
101
einstellungen.py
101
einstellungen.py
|
|
@ -8,9 +8,10 @@ hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
|
|||
import datetime
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from tkinter import ttk, messagebox, filedialog, colorchooser
|
||||
|
||||
import berechnung
|
||||
import bilder
|
||||
import config
|
||||
from helfer import parse_num
|
||||
|
||||
|
|
@ -333,7 +334,7 @@ class EinstellungenMixin:
|
|||
d.transient(self)
|
||||
d.grab_set()
|
||||
self._style_dialog(d)
|
||||
d.geometry("640x640")
|
||||
d.geometry("640x740")
|
||||
|
||||
frm = ttk.Frame(d, padding=12)
|
||||
frm.pack(fill="both", expand=True)
|
||||
|
|
@ -353,39 +354,91 @@ class EinstellungenMixin:
|
|||
frm.columnconfigure(1, weight=1)
|
||||
base = len(felder)
|
||||
|
||||
# Logo (Bilddatei) mit Durchsuchen / Entfernen
|
||||
ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2)
|
||||
logo_row = ttk.Frame(frm)
|
||||
logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", ""))
|
||||
ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True)
|
||||
# Bilder auf der Rechnung: Kopf (oben links) und Fuss (Streifen unten).
|
||||
# Beide funktionieren gleich, deshalb baut EINE Funktion beide Zeilen -
|
||||
# sonst laufen sie beim naechsten Umbau auseinander.
|
||||
bild_vars = {}
|
||||
|
||||
def logo_waehlen():
|
||||
p = filedialog.askopenfilename(
|
||||
title="Logo auswählen",
|
||||
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")])
|
||||
if p:
|
||||
v_logo.set(p)
|
||||
def bild_zeile(zeile, key, beschriftung):
|
||||
ttk.Label(frm, text=beschriftung).grid(row=zeile, column=0, sticky="nw", pady=2)
|
||||
kasten = ttk.Frame(frm)
|
||||
kasten.grid(row=zeile, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
|
||||
reihe = ttk.Frame(kasten)
|
||||
reihe.pack(fill="x")
|
||||
ttk.Entry(reihe, textvariable=v).pack(side="left", fill="x", expand=True)
|
||||
|
||||
ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0))
|
||||
ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0))
|
||||
def waehlen():
|
||||
p = filedialog.askopenfilename(
|
||||
title=beschriftung + " auswählen",
|
||||
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")],
|
||||
parent=d)
|
||||
if p:
|
||||
# Das Bild wird in den Ordner "bilder" neben das Programm
|
||||
# KOPIERT. Der Pfad zum Desktop waere morgen tot - und das
|
||||
# faellt erst beim Drucken auf.
|
||||
v.set(bilder.uebernehmen(p))
|
||||
|
||||
ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2)
|
||||
ttk.Button(reihe, text="Durchsuchen…", command=waehlen).pack(side="left", padx=(6, 0))
|
||||
ttk.Button(reihe, text="Entfernen", command=lambda: v.set("")).pack(side="left", padx=(4, 0))
|
||||
bild_vars[key] = v
|
||||
|
||||
# Ausgetauscht wird nur das BILD. Firmenname, Anschrift, Bank und
|
||||
# Steuernummer stehen weiter gezeichnet auf dem Blatt - sie kommen aus den
|
||||
# Feldern darueber und duerfen in keinem Bild verschwinden.
|
||||
bild_zeile(base, "logo", "Bild oben (Logo)")
|
||||
bild_zeile(base + 1, "fussbild", "Bild unten (Streifen)")
|
||||
|
||||
# Farbe der Rechnung: Ueberschriften, Linie unter dem Kopf, Tabellenkopf.
|
||||
# EINE Farbe - die helle Flaeche wird im Renderer daraus gemischt.
|
||||
ttk.Label(frm, text="Farbe der Rechnung").grid(row=base + 2, column=0, sticky="w", pady=2)
|
||||
farb_reihe = ttk.Frame(frm)
|
||||
farb_reihe.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
v_farbe = tk.StringVar(value=(self.cfg.get("design", {}) or {}).get(
|
||||
"akzent", config.DEFAULT_DESIGN["akzent"]))
|
||||
probe = tk.Label(farb_reihe, width=6, relief="solid", borderwidth=1)
|
||||
|
||||
def probe_zeigen(*_):
|
||||
wert = v_farbe.get().strip()
|
||||
try:
|
||||
probe.configure(background=wert)
|
||||
except tk.TclError:
|
||||
# Unlesbare Farbe: das Feld bleibt grau, gespeichert wird sie nicht.
|
||||
probe.configure(background=self.col_field)
|
||||
|
||||
def farbe_waehlen():
|
||||
_, hexwert = colorchooser.askcolor(color=v_farbe.get().strip() or None,
|
||||
title="Farbe der Rechnung", parent=d)
|
||||
if hexwert:
|
||||
v_farbe.set(hexwert)
|
||||
|
||||
probe.pack(side="left")
|
||||
ttk.Entry(farb_reihe, textvariable=v_farbe, width=12).pack(side="left", padx=(6, 0))
|
||||
ttk.Button(farb_reihe, text="Wählen…", command=farbe_waehlen).pack(side="left", padx=(6, 0))
|
||||
ttk.Button(farb_reihe, text="Zurücksetzen",
|
||||
command=lambda: v_farbe.set(config.DEFAULT_DESIGN["akzent"])).pack(side="left", padx=(4, 0))
|
||||
v_farbe.trace_add("write", probe_zeigen)
|
||||
probe_zeigen()
|
||||
|
||||
ttk.Label(frm, text="Danktext 1").grid(row=base + 3, column=0, sticky="nw", pady=2)
|
||||
t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
|
||||
t1.insert("1.0", self.cfg["texte"].get("dank1", ""))
|
||||
t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2)
|
||||
t1.grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(frm, text="Danktext 2").grid(row=base + 4, column=0, sticky="nw", pady=2)
|
||||
t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
|
||||
t2.insert("1.0", self.cfg["texte"].get("dank2", ""))
|
||||
t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2)
|
||||
t2.grid(row=base + 4, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 5, column=0, sticky="w", pady=2)
|
||||
vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", ""))
|
||||
ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 5, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||
|
||||
def speichern():
|
||||
for key, v in vars_.items():
|
||||
self.cfg["firma"][key] = v.get()
|
||||
self.cfg["firma"]["logo"] = v_logo.get().strip()
|
||||
for key, v in bild_vars.items():
|
||||
wert = v.get()
|
||||
self.cfg["firma"][key] = wert.strip() if isinstance(wert, str) else bool(wert)
|
||||
self.cfg.setdefault("design", {})["akzent"] = v_farbe.get().strip() or config.DEFAULT_DESIGN["akzent"]
|
||||
self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip()
|
||||
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
|
||||
self.cfg["texte"]["hinweis"] = vh.get().strip()
|
||||
|
|
@ -394,7 +447,7 @@ class EinstellungenMixin:
|
|||
d.destroy()
|
||||
|
||||
btns = ttk.Frame(frm)
|
||||
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e")
|
||||
btns.grid(row=base + 6, column=0, columnspan=2, pady=(12, 0), sticky="e")
|
||||
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
|
||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
||||
|
||||
|
|
|
|||
121
pdf_renderer.py
121
pdf_renderer.py
|
|
@ -15,7 +15,6 @@ import json
|
|||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
|
|
@ -24,6 +23,7 @@ from reportlab.platypus import (
|
|||
Flowable,
|
||||
)
|
||||
|
||||
import bilder
|
||||
import config
|
||||
from berechnung import eur, eur_kurz, runde
|
||||
|
||||
|
|
@ -46,11 +46,49 @@ class _AmtMarke(Flowable):
|
|||
self._merker["seite"] = self.canv.getPageNumber()
|
||||
|
||||
# ---- Farben / Masse ---------------------------------------------------------
|
||||
ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping)
|
||||
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
|
||||
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
|
||||
# Camping-Gruen auf seiner Rechnung. Eingestellt wird EINE Farbe - die helle
|
||||
# Flaeche im Tabellenkopf wird daraus gemischt, damit niemand zwei Farben
|
||||
# aufeinander abstimmen muss.
|
||||
AKZENT_VORGABE = "#1f5c3d" # gedecktes Gruen (Camping)
|
||||
|
||||
ACCENT = colors.HexColor(AKZENT_VORGABE)
|
||||
GREY_BG = colors.HexColor("#eef1ee")
|
||||
LINE = colors.HexColor("#9aa39c")
|
||||
DARK = colors.HexColor("#222222")
|
||||
|
||||
|
||||
def _farbe(wert, vorgabe):
|
||||
"""'#1f5c3d' -> Farbe. Alles Unlesbare faellt auf die Vorgabe zurueck.
|
||||
|
||||
Eine kaputte Farbe in der config.json darf niemals das Schreiben der
|
||||
Rechnung verhindern - dann steht der Gast an der Schranke.
|
||||
"""
|
||||
try:
|
||||
return colors.HexColor(str(wert).strip())
|
||||
except (ValueError, AttributeError):
|
||||
if str(wert).strip():
|
||||
print(f"[pdf] Farbe '{wert}' nicht lesbar - nehme {vorgabe}")
|
||||
return colors.HexColor(vorgabe)
|
||||
|
||||
|
||||
def _design_anwenden(cfg):
|
||||
"""Firmenfarbe uebernehmen, bevor die Stile gebaut werden.
|
||||
|
||||
Die Farben stehen als Modulwerte da, weil 13 Stellen im Layout sie lesen.
|
||||
Sie werden hier EINMAL je Rechnung gesetzt - das Programm rendert nie zwei
|
||||
Rechnungen gleichzeitig.
|
||||
"""
|
||||
global ACCENT, GREY_BG
|
||||
design = (cfg or {}).get("design") or {}
|
||||
ACCENT = _farbe(design.get("akzent", AKZENT_VORGABE), AKZENT_VORGABE)
|
||||
# Helle Flaeche = 8 % Akzent auf Weiss. So passt der Tabellenkopf zu jeder
|
||||
# gewaehlten Farbe, ohne dass jemand einen zweiten Wert pflegen muss.
|
||||
GREY_BG = colors.Color(1 - 0.08 * (1 - ACCENT.red),
|
||||
1 - 0.08 * (1 - ACCENT.green),
|
||||
1 - 0.08 * (1 - ACCENT.blue))
|
||||
|
||||
MARGIN_L = 18 * mm
|
||||
MARGIN_R = 18 * mm
|
||||
MARGIN_TOP = 40 * mm # Platz fuer den Kopf
|
||||
|
|
@ -100,36 +138,46 @@ def _styles():
|
|||
|
||||
|
||||
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) --------------------------
|
||||
def _kopf_fuss(canvas, doc, cfg, amt=False):
|
||||
def _vorschau_marke(canvas):
|
||||
"""Schraeg ueber das Blatt: das hier ist KEINE Rechnung.
|
||||
|
||||
Die Vorschau soll zeigen, wie das Blatt aussehen wird - ausgedruckt oder
|
||||
weitergereicht darf sie nie mit der Rechnung verwechselt werden. Sonst haette
|
||||
der Gast ein Blatt in der Hand, das keine Nummer verbraucht hat und in keiner
|
||||
Buchhaltung steht.
|
||||
"""
|
||||
canvas.saveState()
|
||||
canvas.setFont("Helvetica-Bold", 62)
|
||||
canvas.setFillColor(colors.Color(0.55, 0.55, 0.55, alpha=0.22))
|
||||
canvas.translate(PAGE_W / 2, PAGE_H / 2)
|
||||
canvas.rotate(38)
|
||||
canvas.drawCentredString(0, 0, "VORSCHAU")
|
||||
canvas.setFont("Helvetica-Bold", 15)
|
||||
canvas.drawCentredString(0, -22 * mm, "keine Rechnung")
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
|
||||
"""Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
|
||||
firma = cfg["firma"]
|
||||
canvas.saveState()
|
||||
if vorschau:
|
||||
_vorschau_marke(canvas)
|
||||
|
||||
# --- Kopf ---
|
||||
logo = (firma.get("logo") or "").strip()
|
||||
# relativen Pfad (z.B. "assets/logo.png") portabel aufloesen:
|
||||
# erst neben Tool/exe, dann im gebuendelten Ressourcen-Ordner (.exe)
|
||||
if logo and not os.path.isabs(logo):
|
||||
for _base in (config.BASE_DIR, config.RESOURCE_DIR):
|
||||
_cand = os.path.join(_base, logo)
|
||||
if os.path.exists(_cand):
|
||||
logo = _cand
|
||||
break
|
||||
# Das Kopfbild liegt oben links, Firmenname und Untertitel stehen rechts
|
||||
# daneben - genau wie bisher. Austauschbar ist nur das BILD; die Texte
|
||||
# kommen aus den Einstellungen und bleiben gezeichnet.
|
||||
img, iw, ih = bilder.laden(firma.get("logo"))
|
||||
line_y = PAGE_H - 25.5 * mm # Hoehe der Trennlinie ohne Bild (wie bisher)
|
||||
logo_ok = False
|
||||
if logo and os.path.exists(logo):
|
||||
try:
|
||||
img = ImageReader(logo)
|
||||
iw, ih = img.getSize()
|
||||
box_w, box_h = 60 * mm, 20 * mm # max. Logo-Box oben links
|
||||
scale = min(box_w / iw, box_h / ih)
|
||||
dw, dh = iw * scale, ih * scale
|
||||
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
|
||||
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
|
||||
preserveAspectRatio=True, mask="auto")
|
||||
logo_ok = True
|
||||
line_y = ly - 3 * mm
|
||||
except Exception as e: # noqa: BLE001
|
||||
print("[pdf] Logo konnte nicht geladen werden:", e)
|
||||
if img:
|
||||
dw, dh = bilder.einpassen(iw, ih, 60 * mm, 20 * mm) # Kasten oben links
|
||||
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
|
||||
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
|
||||
preserveAspectRatio=True, mask="auto")
|
||||
logo_ok = True
|
||||
line_y = ly - 3 * mm
|
||||
|
||||
if logo_ok:
|
||||
# Firmenname + Untertitel rechtsbuendig neben dem Logo
|
||||
|
|
@ -172,6 +220,20 @@ def _kopf_fuss(canvas, doc, cfg, amt=False):
|
|||
col_m = MARGIN_L + CONTENT_W * 0.40
|
||||
col_r = PAGE_W - MARGIN_R
|
||||
|
||||
# Das Fusszeilen-Bild steht auf dem AMT-BLATT NICHT. Ein fertiges
|
||||
# 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:
|
||||
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
|
||||
# Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal
|
||||
# welches Bild jemand einstellt.
|
||||
dw, dh = bilder.einpassen(fiw, fih, CONTENT_W, 17 * mm)
|
||||
canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm,
|
||||
width=dw, height=dh, preserveAspectRatio=True, mask="auto")
|
||||
|
||||
rows_l = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
|
||||
rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""),
|
||||
firma.get("steuernummer", "")]
|
||||
|
|
@ -667,7 +729,7 @@ def _kenndaten(rechnung, kopf):
|
|||
return json.dumps(daten, ensure_ascii=False)
|
||||
|
||||
|
||||
def render_rechnung(pfad, cfg, rechnung, kopf):
|
||||
def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False):
|
||||
"""
|
||||
pfad : Zielpfad der PDF
|
||||
cfg : geladene Konfiguration
|
||||
|
|
@ -679,6 +741,7 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
|
|||
Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
|
||||
keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
|
||||
"""
|
||||
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
|
||||
st = _styles()
|
||||
|
||||
def bauen(ziel, amt_seite):
|
||||
|
|
@ -702,7 +765,9 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
|
|||
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
|
||||
|
||||
def seite(c, d):
|
||||
_kopf_fuss(c, d, cfg, amt=(amt_seite is not None and c.getPageNumber() >= amt_seite))
|
||||
_kopf_fuss(c, d, cfg,
|
||||
amt=(amt_seite is not None and c.getPageNumber() >= amt_seite),
|
||||
vorschau=vorschau)
|
||||
|
||||
doc.build(story, onFirstPage=seite, onLaterPages=seite)
|
||||
|
||||
|
|
|
|||
145
pruef_bilder.py
Normal file
145
pruef_bilder.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: Kopf- und Fussbild auf der Rechnung.
|
||||
|
||||
Drei Dinge muessen stimmen, sonst faellt es erst beim Drucken auf:
|
||||
|
||||
1. Ein gewaehltes Bild wird KOPIERT (Ordner "bilder"), nicht nur verlinkt -
|
||||
sonst ist das Logo weg, sobald der Nutzer seinen Desktop aufraeumt.
|
||||
2. Das Fussbild steht auf der Kundenrechnung, aber NICHT auf dem Amt-Blatt:
|
||||
ein Briefpapier-Band traegt Bank und Steuernummer, und die haben auf dem
|
||||
Blatt fuers Amt nichts zu suchen.
|
||||
3. Der Haken "Angaben stehen im Bild" laesst die gezeichneten Texte weg -
|
||||
und zwar nur dort, wo er gilt.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
QUELLE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
import config # noqa: E402
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="bilder_pruef_")
|
||||
config.BASE_DIR = tmp
|
||||
config.CONFIG_PATH = os.path.join(tmp, "config.json")
|
||||
shutil.copy(os.path.join(QUELLE, "config.json"), config.CONFIG_PATH)
|
||||
|
||||
import bilder # noqa: E402
|
||||
import pdf_renderer # noqa: E402
|
||||
from berechnung import berechne # noqa: E402
|
||||
|
||||
fehler = []
|
||||
|
||||
|
||||
def pruefe(bedingung, text):
|
||||
print(("OK | " if bedingung else "FEHL | ") + text)
|
||||
if not bedingung:
|
||||
fehler.append(text)
|
||||
|
||||
|
||||
def testbild(pfad, farbe):
|
||||
"""Kleines PNG erzeugen - der Pruefstand braucht keine echten Kundenbilder."""
|
||||
from PIL import Image
|
||||
Image.new("RGB", (600, 120), farbe).save(pfad)
|
||||
return pfad
|
||||
|
||||
|
||||
# ---- 1. Uebernehmen: kopieren, nicht verlinken -----------------------------
|
||||
fremd = os.path.join(tmp, "vom_desktop.png")
|
||||
testbild(fremd, (10, 90, 60))
|
||||
gespeichert = bilder.uebernehmen(fremd)
|
||||
pruefe(gespeichert == "bilder/vom_desktop.png", f"Pfad wird kurz gemerkt: {gespeichert}")
|
||||
pruefe(os.path.exists(os.path.join(tmp, "bilder", "vom_desktop.png")), "Bild liegt im Ordner bilder/")
|
||||
|
||||
# Das Original loeschen - die Rechnung muss trotzdem ein Bild haben.
|
||||
os.remove(fremd)
|
||||
pruefe(bool(bilder.aufloesen(gespeichert)), "Bild bleibt da, wenn das Original geloescht wird")
|
||||
|
||||
# Dasselbe Bild zweimal waehlen -> kein zweites Exemplar.
|
||||
shutil.copy(os.path.join(tmp, "bilder", "vom_desktop.png"), fremd)
|
||||
pruefe(bilder.uebernehmen(fremd) == "bilder/vom_desktop.png", "gleiches Bild zweimal -> kein Duplikat")
|
||||
# Anderer Inhalt, gleicher Name -> das alte Bild wird NICHT ueberschrieben.
|
||||
testbild(fremd, (200, 30, 30))
|
||||
zweit = bilder.uebernehmen(fremd)
|
||||
pruefe(zweit == "bilder/vom_desktop-2.png", f"gleicher Name, anderes Bild -> {zweit}")
|
||||
|
||||
# ---- 2. Rendern: Kunde vs. Amt --------------------------------------------
|
||||
cfg = config.load_config()
|
||||
cfg["firma"]["logo"] = bilder.uebernehmen(testbild(os.path.join(tmp, "kopf.png"), (20, 60, 120)))
|
||||
cfg["firma"]["fussbild"] = bilder.uebernehmen(testbild(os.path.join(tmp, "fuss.png"), (120, 60, 20)))
|
||||
cfg["firma"]["bank"] = "Bank Testbank"
|
||||
|
||||
mengen = {1: {"anzahl": 3, "personen": 0}}
|
||||
r = berechne(cfg["katalog"], mengen, cfg)
|
||||
kopf = {"art": "rechnung", "rechnungsnummer": "2026-999", "datum": "05.09.2026",
|
||||
"kunde_zeilen": ["Herr", "Max Muster", "Musterweg 1", "04416 Markkleeberg"],
|
||||
"anreise": "01.09.2026", "abreise": "04.09.2026", "naechte": 3}
|
||||
pfad = os.path.join(tmp, "probe.pdf")
|
||||
pdf_renderer.render_rechnung(pfad, cfg, r, kopf)
|
||||
pruefe(os.path.exists(pfad), "PDF mit beiden Bildern entsteht")
|
||||
|
||||
|
||||
def seiten_bilder_und_text(pdf):
|
||||
"""Je Seite: (Anzahl eingebetteter Bilder, Text)."""
|
||||
from pypdf import PdfReader
|
||||
raus = []
|
||||
for seite in PdfReader(pdf).pages:
|
||||
xo = seite.get("/Resources", {}).get("/XObject", {})
|
||||
try:
|
||||
xo = xo.get_object()
|
||||
except AttributeError:
|
||||
pass
|
||||
anzahl = sum(1 for k in xo if xo[k].get_object().get("/Subtype") == "/Image")
|
||||
raus.append((anzahl, seite.extract_text() or ""))
|
||||
return raus
|
||||
|
||||
|
||||
seiten = seiten_bilder_und_text(pfad)
|
||||
pruefe(len(seiten) >= 2, f"Kundenblatt + Amt-Blatt: {len(seiten)} Seiten")
|
||||
kunde_bilder, kunde_text = seiten[0]
|
||||
amt_bilder, amt_text = seiten[-1]
|
||||
pruefe(kunde_bilder == 2, f"Kundenblatt traegt Kopf- UND Fussbild ({kunde_bilder})")
|
||||
pruefe(amt_bilder == 1, f"Amt-Blatt traegt NUR das Kopfbild ({amt_bilder})")
|
||||
pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Kundenrechnung")
|
||||
pruefe("Testbank" not in amt_text, "Bankverbindung steht NICHT auf dem Amt-Blatt")
|
||||
|
||||
# ---- 3. Die Firmenfarbe faerbt das Blatt ----------------------------------
|
||||
# Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte
|
||||
# Farbe wirklich im PDF landet - und dass eine kaputte Eingabe das Schreiben
|
||||
# NICHT verhindert, sondern still auf die Vorgabe zurueckfaellt.
|
||||
cfg["design"] = {"akzent": "#8a1f1f"}
|
||||
pfad2 = os.path.join(tmp, "probe_rot.pdf")
|
||||
pdf_renderer.render_rechnung(pfad2, cfg, r, kopf)
|
||||
pruefe(pdf_renderer.ACCENT.hexval().endswith("8a1f1f"),
|
||||
f"gewaehlte Farbe steht im Blatt: {pdf_renderer.ACCENT.hexval()}")
|
||||
pruefe(pdf_renderer.GREY_BG.red > 0.9,
|
||||
"helle Flaeche wird aus der Farbe gemischt (bleibt hell)")
|
||||
|
||||
cfg["design"] = {"akzent": "gruen bitte"}
|
||||
pfad3 = os.path.join(tmp, "probe_kaputt.pdf")
|
||||
pdf_renderer.render_rechnung(pfad3, cfg, r, kopf)
|
||||
pruefe(os.path.exists(pfad3), "kaputte Farbe verhindert die Rechnung NICHT")
|
||||
pruefe(pdf_renderer.ACCENT.hexval().endswith("1f5c3d"), "kaputte Farbe faellt auf die Vorgabe zurueck")
|
||||
|
||||
cfg["design"] = {"akzent": pdf_renderer.AKZENT_VORGABE}
|
||||
|
||||
# Ohne Bilder muss das Blatt aussehen wie immer.
|
||||
cfg["firma"]["logo"] = ""
|
||||
cfg["firma"]["fussbild"] = ""
|
||||
pfad4 = os.path.join(tmp, "probe_ohne_bild.pdf")
|
||||
pdf_renderer.render_rechnung(pfad4, cfg, r, kopf)
|
||||
seiten3 = seiten_bilder_und_text(pfad4)
|
||||
pruefe(seiten3[0][0] == 0, "ohne Bilder: keine Bilder im PDF")
|
||||
pruefe(cfg["firma"]["name"] in seiten3[0][1], "ohne Bild steht der Firmenname gezeichnet oben")
|
||||
|
||||
print()
|
||||
print(f"Ordner: {tmp}")
|
||||
if fehler:
|
||||
print(f"FEHLGESCHLAGEN: {len(fehler)}")
|
||||
for f in fehler:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("ALLES GRUEN")
|
||||
117
pruef_vorschau.py
Normal file
117
pruef_vorschau.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Pruefstand: der Vorschau-Knopf.
|
||||
|
||||
Die Vorschau soll zeigen, wie das Blatt aussehen WIRD - und dabei nichts
|
||||
anfassen. Drei Zusicherungen, die alle drei still brechen koennten:
|
||||
|
||||
1. Sie verbraucht KEINE Rechnungsnummer und traegt nichts ins Nummernbuch.
|
||||
Sonst fehlt in der Buchhaltung eine Nummer, fuer die es kein Blatt gibt.
|
||||
2. Sie legt nichts im Rechnungsordner ab - dort liegt die Buchhaltung.
|
||||
3. Auf jeder Seite steht "VORSCHAU - keine Rechnung". Ein Blatt ohne diesen
|
||||
Hinweis koennte ausgedruckt beim Gast landen und als Rechnung gelten.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import threading as _th
|
||||
|
||||
QUELLE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, QUELLE)
|
||||
|
||||
tmp = tempfile.mkdtemp(prefix="vorschau_pruef_")
|
||||
ordner = os.path.join(tmp, "rechnungen")
|
||||
os.makedirs(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
|
||||
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
|
||||
|
||||
os.environ["CAMPINGHOF_TEST"] = "1"
|
||||
import app as A # noqa: E402
|
||||
from tkinter import messagebox # noqa: E402
|
||||
|
||||
|
||||
def _reissleine():
|
||||
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
|
||||
os._exit(2)
|
||||
|
||||
|
||||
_wecker = _th.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
|
||||
|
||||
# Die Vorschau oeffnet den PDF-Betrachter - im Pruefstand wird nur gemerkt, WAS
|
||||
# sie oeffnen wollte.
|
||||
geoeffnet = []
|
||||
A.os.startfile = lambda p, *a, **k: geoeffnet.append(p)
|
||||
|
||||
f = A.RechnungsApp()
|
||||
f.update()
|
||||
f.cfg["output_dir"] = ordner
|
||||
erste = sorted(f.anzahl_vars)[0]
|
||||
|
||||
f.k_vorname.set("Anna")
|
||||
f.k_name.set("Beispiel")
|
||||
f.k_strasse.set("Musterweg 1")
|
||||
f.k_plzort.set("12345 Musterstadt")
|
||||
f.anzahl_vars[erste].set("3")
|
||||
f.update()
|
||||
|
||||
nummer_vorher = f.r_nummer.get()
|
||||
zaehler_vorher = f.cfg.get("rechnung_zaehler")
|
||||
|
||||
f.vorschau_zeigen()
|
||||
|
||||
pruefe(len(geoeffnet) == 1, f"Vorschau oeffnet genau ein Blatt ({len(geoeffnet)})")
|
||||
pfad = geoeffnet[0] if geoeffnet else ""
|
||||
pruefe(os.path.exists(pfad), "Vorschau-PDF wurde geschrieben")
|
||||
pruefe(os.path.abspath(ordner) not in os.path.abspath(pfad),
|
||||
"Vorschau liegt NICHT im Rechnungsordner")
|
||||
pruefe(not os.listdir(ordner), f"Rechnungsordner bleibt leer ({os.listdir(ordner)})")
|
||||
pruefe(f.r_nummer.get() == nummer_vorher,
|
||||
f"Rechnungsnummer unveraendert ({nummer_vorher} -> {f.r_nummer.get()})")
|
||||
pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher, "Zaehler unveraendert")
|
||||
|
||||
from pypdf import PdfReader # noqa: E402
|
||||
seiten = [s.extract_text() or "" for s in PdfReader(pfad).pages]
|
||||
pruefe(all("VORSCHAU" in t for t in seiten),
|
||||
f"'VORSCHAU' steht auf jeder Seite ({len(seiten)} Seiten)")
|
||||
pruefe(all("keine Rechnung" in t for t in seiten), "'keine Rechnung' steht auf jeder Seite")
|
||||
|
||||
# Und die echte Rechnung danach traegt die Marke NICHT.
|
||||
f.pdf_erstellen()
|
||||
echte = [os.path.join(ordner, n) for n in os.listdir(ordner) if n.lower().endswith(".pdf")]
|
||||
pruefe(len(echte) == 1, f"danach entsteht genau eine echte Rechnung ({len(echte)})")
|
||||
if echte:
|
||||
text = "".join((s.extract_text() or "") for s in PdfReader(echte[0]).pages)
|
||||
pruefe("VORSCHAU" not in text, "die echte Rechnung traegt KEINE Vorschau-Marke")
|
||||
|
||||
f.destroy()
|
||||
print()
|
||||
if fehler:
|
||||
print(f"FEHLGESCHLAGEN: {len(fehler)}")
|
||||
for x in fehler:
|
||||
print(" -", x)
|
||||
sys.exit(1)
|
||||
print("ALLES GRUEN")
|
||||
Loading…
Add table
Add a link
Reference in a new issue