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 os
|
||||||
|
import tempfile
|
||||||
import sys
|
import sys
|
||||||
import datetime
|
import datetime
|
||||||
import queue
|
import queue
|
||||||
|
|
@ -106,6 +107,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
self._open_pop = None
|
self._open_pop = None
|
||||||
self._menus = {
|
self._menus = {
|
||||||
"Datei": [
|
"Datei": [
|
||||||
|
("Vorschau ansehen", self.vorschau_zeigen),
|
||||||
("PDF erstellen", self.pdf_erstellen),
|
("PDF erstellen", self.pdf_erstellen),
|
||||||
("Formular zurücksetzen", self.reset_formular),
|
("Formular zurücksetzen", self.reset_formular),
|
||||||
("Ausgabeordner öffnen", self._ordner_oeffnen),
|
("Ausgabeordner öffnen", self._ordner_oeffnen),
|
||||||
|
|
@ -524,6 +526,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
|
|
||||||
links = ttk.Frame(f)
|
links = ttk.Frame(f)
|
||||||
links.pack(side="left")
|
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="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4)
|
||||||
ttk.Button(links, text="Storno", command=self.dlg_storno,
|
ttk.Button(links, text="Storno", command=self.dlg_storno,
|
||||||
width=12).pack(side="left", padx=4)
|
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, nummer
|
||||||
return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
|
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):
|
def pdf_erstellen(self, angebot=False):
|
||||||
montage = self.ist_montage()
|
montage = self.ist_montage()
|
||||||
r = self.recalc()
|
r = self.recalc()
|
||||||
|
|
@ -1145,14 +1218,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
|
||||||
# unangetastet, die naechste Rechnung behaelt ihre Nummer.
|
# unangetastet, die naechste Rechnung behaelt ihre Nummer.
|
||||||
a_jahr, a_zaehler, nummer = self._naechstes_angebot()
|
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.
|
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
|
||||||
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
|
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
|
||||||
# geloescht und es bleibt allein die urspruengliche Rechnung stehen.
|
# 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']}.")
|
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
|
||||||
return
|
return
|
||||||
|
|
||||||
kopf = {
|
kopf = self._kopf_basis(nummer, r)
|
||||||
"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],
|
|
||||||
}
|
|
||||||
if montage:
|
if montage:
|
||||||
# Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung
|
# Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung
|
||||||
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.
|
# 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",
|
"email": "E-Mail kontakt@example.de",
|
||||||
"telefon": "Telefon +49 (0) 000 0000000",
|
"telefon": "Telefon +49 (0) 000 0000000",
|
||||||
"logo": "assets/logo.png", # Bilddatei -> oben links auf der Rechnung (relativ zum Tool-Ordner)
|
"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).
|
# Festtexte unten auf der Rechnung (aus der Vorlage).
|
||||||
|
|
@ -135,8 +140,16 @@ DEFAULT_TEXTE = {
|
||||||
"hinweis": "* Bei Zahlung per Überweisung bitte die Rechnungsnummer angeben *",
|
"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 = {
|
DEFAULT_CONFIG = {
|
||||||
"firma": DEFAULT_FIRMA,
|
"firma": DEFAULT_FIRMA,
|
||||||
|
"design": DEFAULT_DESIGN,
|
||||||
"texte": DEFAULT_TEXTE,
|
"texte": DEFAULT_TEXTE,
|
||||||
"katalog": DEFAULT_KATALOG,
|
"katalog": DEFAULT_KATALOG,
|
||||||
"montage": DEFAULT_MONTAGE,
|
"montage": DEFAULT_MONTAGE,
|
||||||
|
|
|
||||||
101
einstellungen.py
101
einstellungen.py
|
|
@ -8,9 +8,10 @@ hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, messagebox, filedialog
|
from tkinter import ttk, messagebox, filedialog, colorchooser
|
||||||
|
|
||||||
import berechnung
|
import berechnung
|
||||||
|
import bilder
|
||||||
import config
|
import config
|
||||||
from helfer import parse_num
|
from helfer import parse_num
|
||||||
|
|
||||||
|
|
@ -333,7 +334,7 @@ class EinstellungenMixin:
|
||||||
d.transient(self)
|
d.transient(self)
|
||||||
d.grab_set()
|
d.grab_set()
|
||||||
self._style_dialog(d)
|
self._style_dialog(d)
|
||||||
d.geometry("640x640")
|
d.geometry("640x740")
|
||||||
|
|
||||||
frm = ttk.Frame(d, padding=12)
|
frm = ttk.Frame(d, padding=12)
|
||||||
frm.pack(fill="both", expand=True)
|
frm.pack(fill="both", expand=True)
|
||||||
|
|
@ -353,39 +354,91 @@ class EinstellungenMixin:
|
||||||
frm.columnconfigure(1, weight=1)
|
frm.columnconfigure(1, weight=1)
|
||||||
base = len(felder)
|
base = len(felder)
|
||||||
|
|
||||||
# Logo (Bilddatei) mit Durchsuchen / Entfernen
|
# Bilder auf der Rechnung: Kopf (oben links) und Fuss (Streifen unten).
|
||||||
ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2)
|
# Beide funktionieren gleich, deshalb baut EINE Funktion beide Zeilen -
|
||||||
logo_row = ttk.Frame(frm)
|
# sonst laufen sie beim naechsten Umbau auseinander.
|
||||||
logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2)
|
bild_vars = {}
|
||||||
v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", ""))
|
|
||||||
ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True)
|
|
||||||
|
|
||||||
def logo_waehlen():
|
def bild_zeile(zeile, key, beschriftung):
|
||||||
p = filedialog.askopenfilename(
|
ttk.Label(frm, text=beschriftung).grid(row=zeile, column=0, sticky="nw", pady=2)
|
||||||
title="Logo auswählen",
|
kasten = ttk.Frame(frm)
|
||||||
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")])
|
kasten.grid(row=zeile, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||||
if p:
|
v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
|
||||||
v_logo.set(p)
|
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))
|
def waehlen():
|
||||||
ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0))
|
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 = 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.insert("1.0", self.cfg["texte"].get("dank1", ""))
|
||||||
t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), 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 + 2, column=0, sticky="nw", 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 = 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.insert("1.0", self.cfg["texte"].get("dank2", ""))
|
||||||
t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
|
t2.grid(row=base + 4, column=1, sticky="ew", padx=(8, 0), pady=2)
|
||||||
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", 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", ""))
|
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():
|
def speichern():
|
||||||
for key, v in vars_.items():
|
for key, v in vars_.items():
|
||||||
self.cfg["firma"][key] = v.get()
|
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"]["dank1"] = t1.get("1.0", "end").strip()
|
||||||
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
|
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
|
||||||
self.cfg["texte"]["hinweis"] = vh.get().strip()
|
self.cfg["texte"]["hinweis"] = vh.get().strip()
|
||||||
|
|
@ -394,7 +447,7 @@ class EinstellungenMixin:
|
||||||
d.destroy()
|
d.destroy()
|
||||||
|
|
||||||
btns = ttk.Frame(frm)
|
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="Speichern", command=speichern).pack(side="right", padx=4)
|
||||||
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")
|
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.pagesizes import A4
|
||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.lib.utils import ImageReader
|
|
||||||
from reportlab.lib import colors
|
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
|
||||||
|
|
@ -24,6 +23,7 @@ from reportlab.platypus import (
|
||||||
Flowable,
|
Flowable,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import bilder
|
||||||
import config
|
import config
|
||||||
from berechnung import eur, eur_kurz, runde
|
from berechnung import eur, eur_kurz, runde
|
||||||
|
|
||||||
|
|
@ -46,11 +46,49 @@ class _AmtMarke(Flowable):
|
||||||
self._merker["seite"] = self.canv.getPageNumber()
|
self._merker["seite"] = self.canv.getPageNumber()
|
||||||
|
|
||||||
# ---- Farben / Masse ---------------------------------------------------------
|
# ---- 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")
|
GREY_BG = colors.HexColor("#eef1ee")
|
||||||
LINE = colors.HexColor("#9aa39c")
|
LINE = colors.HexColor("#9aa39c")
|
||||||
DARK = colors.HexColor("#222222")
|
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_L = 18 * mm
|
||||||
MARGIN_R = 18 * mm
|
MARGIN_R = 18 * mm
|
||||||
MARGIN_TOP = 40 * mm # Platz fuer den Kopf
|
MARGIN_TOP = 40 * mm # Platz fuer den Kopf
|
||||||
|
|
@ -100,36 +138,46 @@ def _styles():
|
||||||
|
|
||||||
|
|
||||||
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) --------------------------
|
# ---- 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)."""
|
"""Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
|
||||||
firma = cfg["firma"]
|
firma = cfg["firma"]
|
||||||
canvas.saveState()
|
canvas.saveState()
|
||||||
|
if vorschau:
|
||||||
|
_vorschau_marke(canvas)
|
||||||
|
|
||||||
# --- Kopf ---
|
# --- Kopf ---
|
||||||
logo = (firma.get("logo") or "").strip()
|
# Das Kopfbild liegt oben links, Firmenname und Untertitel stehen rechts
|
||||||
# relativen Pfad (z.B. "assets/logo.png") portabel aufloesen:
|
# daneben - genau wie bisher. Austauschbar ist nur das BILD; die Texte
|
||||||
# erst neben Tool/exe, dann im gebuendelten Ressourcen-Ordner (.exe)
|
# kommen aus den Einstellungen und bleiben gezeichnet.
|
||||||
if logo and not os.path.isabs(logo):
|
img, iw, ih = bilder.laden(firma.get("logo"))
|
||||||
for _base in (config.BASE_DIR, config.RESOURCE_DIR):
|
line_y = PAGE_H - 25.5 * mm # Hoehe der Trennlinie ohne Bild (wie bisher)
|
||||||
_cand = os.path.join(_base, logo)
|
|
||||||
if os.path.exists(_cand):
|
|
||||||
logo = _cand
|
|
||||||
break
|
|
||||||
logo_ok = False
|
logo_ok = False
|
||||||
if logo and os.path.exists(logo):
|
if img:
|
||||||
try:
|
dw, dh = bilder.einpassen(iw, ih, 60 * mm, 20 * mm) # Kasten oben links
|
||||||
img = ImageReader(logo)
|
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
|
||||||
iw, ih = img.getSize()
|
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
|
||||||
box_w, box_h = 60 * mm, 20 * mm # max. Logo-Box oben links
|
preserveAspectRatio=True, mask="auto")
|
||||||
scale = min(box_w / iw, box_h / ih)
|
logo_ok = True
|
||||||
dw, dh = iw * scale, ih * scale
|
line_y = ly - 3 * mm
|
||||||
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 logo_ok:
|
if logo_ok:
|
||||||
# Firmenname + Untertitel rechtsbuendig neben dem Logo
|
# 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_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
|
||||||
|
# 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_l = [] if amt else [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", "")] if amt else [firma.get("inhaber", ""),
|
||||||
firma.get("steuernummer", "")]
|
firma.get("steuernummer", "")]
|
||||||
|
|
@ -667,7 +729,7 @@ def _kenndaten(rechnung, kopf):
|
||||||
return json.dumps(daten, ensure_ascii=False)
|
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
|
pfad : Zielpfad der PDF
|
||||||
cfg : geladene Konfiguration
|
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),
|
Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
|
||||||
keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
|
keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
|
||||||
"""
|
"""
|
||||||
|
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
|
||||||
st = _styles()
|
st = _styles()
|
||||||
|
|
||||||
def bauen(ziel, amt_seite):
|
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
|
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
|
||||||
|
|
||||||
def seite(c, d):
|
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)
|
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