diff --git a/app.py b/app.py index 905f65f..43905f9 100644 --- a/app.py +++ b/app.py @@ -265,15 +265,13 @@ class RechnungsApp(tk.Tk): kunde = ttk.LabelFrame(top, text="Rechnung an (Kunde)", padding=8) kunde.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) self.k_anrede = tk.StringVar() - self.k_vorname = tk.StringVar() - self.k_name = tk.StringVar() # Nachname oder Firma – die Zuordnung im Steuerjournal + self.k_name = tk.StringVar() self.k_zusatz = tk.StringVar() self.k_strasse = tk.StringVar() self.k_plzort = tk.StringVar() felder = [ ("Anrede (optional)", self.k_anrede), - ("Vorname (optional)", self.k_vorname), - ("Nachname / Firma", self.k_name), + ("Name / Firma", self.k_name), ("Zusatz (optional)", self.k_zusatz), ("Straße / Nr.", self.k_strasse), ("PLZ / Ort", self.k_plzort), @@ -505,13 +503,7 @@ class RechnungsApp(tk.Tk): "Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"): return - # 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(), + kunde_zeilen = [self.k_anrede.get(), self.k_name.get(), self.k_zusatz.get(), self.k_strasse.get(), self.k_plzort.get()] kopf = { "rechnungsnummer": nummer, @@ -520,8 +512,6 @@ class RechnungsApp(tk.Tk): "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, } out_dir = config.ensure_output_dir(self.cfg) fname = f"Rechnung_{safe_filename(nummer)}" @@ -557,8 +547,8 @@ class RechnungsApp(tk.Tk): self._neue_nummer_vorschlagen() def reset_formular(self): - for v in (self.k_anrede, self.k_vorname, self.k_name, self.k_zusatz, - self.k_strasse, self.k_plzort, self.r_anreise, self.r_abreise): + for v in (self.k_anrede, self.k_name, self.k_zusatz, self.k_strasse, self.k_plzort, + self.r_anreise, self.r_abreise): v.set("") self.r_datum.set(datetime.date.today().strftime("%d.%m.%Y")) for av in self.anzahl_vars.values(): diff --git a/pdf_renderer.py b/pdf_renderer.py index c554322..b1b4eef 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -10,8 +10,6 @@ im Layout der Excel-Vorlage: """ import os -import io -import json from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm @@ -21,30 +19,11 @@ from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER from reportlab.lib.styles import ParagraphStyle from reportlab.platypus import ( SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak, - Flowable, ) import config 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 --------------------------------------------------------- ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping) GREY_BG = colors.HexColor("#eef1ee") @@ -100,8 +79,7 @@ def _styles(): # ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) -------------------------- -def _kopf_fuss(canvas, doc, cfg, amt=False): - """Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile).""" +def _kopf_fuss(canvas, doc, cfg): firma = cfg["firma"] canvas.saveState() @@ -155,13 +133,6 @@ def _kopf_fuss(canvas, doc, cfg, amt=False): canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y) # --- Fusszeile --- - # - # STRIKTE TRENNUNG Kunde / Amt: - # 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 canvas.setStrokeColor(LINE) canvas.setLineWidth(0.6) @@ -171,12 +142,9 @@ def _kopf_fuss(canvas, doc, cfg, amt=False): col_l = MARGIN_L col_m = MARGIN_L + CONTENT_W * 0.40 col_r = PAGE_W - MARGIN_R - - 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", "")] + rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")] + rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")] rows_r = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")] - for i, t in enumerate(rows_l): canvas.drawString(col_l, fy + 9 * mm - i * 3.6 * mm, t) for i, t in enumerate(rows_m): @@ -237,9 +205,7 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): story.append(Spacer(1, 7 * mm)) # Ueberschrift (auf dem Amt-Blatt mit Zusatz) - # Das Wort "Beherbergungssteuer" steht auf dem Amt-Blatt nirgends mehr - auch nicht - # in der Ueberschrift. Der Zusatz sagt nur noch, fuer wen das Blatt ist. - titel = "Rechnungsübersicht" + (" (für das Amt)" if gesamtrechnung_betont else "") + titel = "Rechnungsübersicht" + (" – Beherbergungssteuer (für das Amt)" if gesamtrechnung_betont else "") story.append(Paragraph(titel, st["h"])) # Positionstabelle @@ -295,20 +261,14 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): Paragraph(f"{name} {b.satz} %:", st["info_l"]), Paragraph(eur(b.ust), st["cell_r"]), ]) - # Auf dem Amt-Blatt taucht die Beherbergungssteuer NIRGENDS auf - weder als eigene - # Zeile, noch versteckt im Gesamtbetrag. Das Amt bekommt die Rechnung so, wie sie - # ohne die Steuer aussaehe: Netto, Umsatzsteuern, Gesamt. Genau dieser Gesamtbetrag - # ist die Bemessungsgrundlage, aus der es die Steuer selbst errechnet. - # - # Deshalb entfaellt hier auch die Zwischensumme: sie waere betragsgleich mit dem - # Gesamtbetrag und wuerde nur zweimal dasselbe zeigen. - # - # Auf der Kundenseite bleibt alles wie gehabt - dort MUSS die Steuer ausgewiesen - # sein und im Gesamtbetrag stecken, das ist der Betrag, den der Gast zahlt. - if rechnung.aufschlaege and not gesamtrechnung_betont: + gr_idx = None + if rechnung.aufschlaege: + gr_idx = len(summen_rows) # Zeilen-Index der Zwischensumme + gr_l = st["total_l"] if gesamtrechnung_betont else st["info_l"] + gr_v = st["total_v"] if gesamtrechnung_betont else st["cell_r"] summen_rows.append([ - Paragraph("Zwischensumme:", st["info_l"]), - Paragraph(eur(rechnung.brutto), st["cell_r"]), + Paragraph("Zwischensumme:", gr_l), + Paragraph(eur(rechnung.brutto), gr_v), ]) for a in rechnung.aufschlaege: satz_txt = eur_kurz(a.satz).replace(",00", "") @@ -316,11 +276,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): Paragraph(f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz_txt} %):", st["info_l"]), Paragraph(eur(a.betrag), st["cell_r"]), ]) - - endsumme = rechnung.brutto if gesamtrechnung_betont else rechnung.gesamt summen_rows.append([ Paragraph("Gesamtbetrag:", st["total_l"]), - Paragraph(eur(endsumme), st["total_v"]), + Paragraph(eur(rechnung.gesamt), st["total_v"]), ]) style_cmds = [ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), @@ -330,21 +288,26 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): ("LINEBELOW", (0, -1), (1, -1), 0.8, ACCENT), ("TOPPADDING", (0, -1), (-1, -1), 4), ] + if gesamtrechnung_betont and gr_idx is not None: + # Zwischensumme wie der Gesamtbetrag hervorheben: zwei Streifen + style_cmds += [ + ("LINEABOVE", (0, gr_idx), (1, gr_idx), 0.8, ACCENT), + ("LINEBELOW", (0, gr_idx), (1, gr_idx), 0.8, ACCENT), + ("TOPPADDING", (0, gr_idx), (-1, gr_idx), 4), + ] summen = Table(summen_rows, colWidths=[CONTENT_W - 44 * mm, 44 * mm]) summen.setStyle(TableStyle(style_cmds)) story.append(summen) - # Hinweis zur Beherbergungssteuer - nur auf der Kundenseite. Auf dem Amt-Blatt - # wird die Steuer gar nicht mehr ausgewiesen, dann waere der Satz sinnlos. - if not gesamtrechnung_betont: - for a in rechnung.aufschlaege: - if not a.bezeichnung: - continue - satz_txt = eur_kurz(a.satz).replace(",00", "") - story.append(Spacer(1, 1.5 * mm)) - story.append(Paragraph( - f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die " - f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"])) + # Hinweis zur Beherbergungssteuer + for a in rechnung.aufschlaege: + if not a.bezeichnung: + continue + satz_txt = eur_kurz(a.satz).replace(",00", "") + story.append(Spacer(1, 1.5 * mm)) + story.append(Paragraph( + f"Die {a.bezeichnung} ({satz_txt} %) wird gemäß kommunaler Satzung auf die " + f"Zwischensumme erhoben und ist im Gesamtbetrag enthalten.", st["dank"])) story.append(Spacer(1, 8 * mm)) @@ -361,31 +324,6 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): story.append(KeepTogether(block)) -def _kenndaten(rechnung, kopf): - """ - Kenndaten der Rechnung als JSON in die PDF-Metadaten (Feld "Subject"). - - Das Steuerjournal (Programm "Beherbergungssteuer") liest sie von dort aus, - statt Nachname und Betraege aus dem Fliesstext zu raten. Fuer den Ausdruck - aendert sich nichts - Metadaten stehen nicht auf dem Blatt. - """ - aufschlag = rechnung.aufschlaege[0] if rechnung.aufschlaege else None - daten = { - "quelle": "rechnungstool", - "version": 1, - "rechnungsnummer": kopf.get("rechnungsnummer", ""), - "datum": kopf.get("datum", ""), - "nachname": kopf.get("nachname", ""), - "vorname": kopf.get("vorname", ""), - "naechte": kopf.get("naechte", ""), - "zwischensumme": runde(rechnung.brutto), - "steuer_satz": float(aufschlag.satz) if aufschlag else 0.0, - "steuer_betrag": runde(aufschlag.betrag) if aufschlag else 0.0, - "gesamt": runde(rechnung.gesamt), - } - return json.dumps(daten, ensure_ascii=False) - - def render_rechnung(pfad, cfg, rechnung, kopf): """ pfad : Zielpfad der PDF @@ -394,44 +332,26 @@ def render_rechnung(pfad, cfg, rechnung, kopf): kopf : dict mit rechnungsnummer, datum, anreise, abreise, naechte, kunde_zeilen EIN PDF mit zwei Blaettern (falls Beherbergungssteuer anfaellt): - Seite 1 = Kunden-Rechnung, vollstaendig (Steuer ausgewiesen, Bankverbindung im Fuss). - Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt), - keine Zwischensumme, keine Bankverbindung, keine Steuernummer. + Seite 1 = Kunden-Rechnung. + Seite 2 = 1:1-Kopie fuers Amt, nur die Zwischensumme fett + zwei Streifen. """ st = _styles() + doc = SimpleDocTemplate( + pfad, pagesize=A4, + leftMargin=MARGIN_L, rightMargin=MARGIN_R, + topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, + title=f"Rechnung {kopf.get('rechnungsnummer','')}", + author=cfg["firma"].get("name", ""), + ) + story = [] + _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Seite 1: Kunde + if rechnung.aufschlaege: + story.append(PageBreak()) + _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Seite 2: Amt - def bauen(ziel, amt_seite): - doc = SimpleDocTemplate( - ziel, pagesize=A4, - leftMargin=MARGIN_L, rightMargin=MARGIN_R, - topMargin=MARGIN_TOP, bottomMargin=MARGIN_BOT, - title=f"Rechnung {kopf.get('rechnungsnummer','')}", - author=cfg["firma"].get("name", ""), - subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal - ) - story = [] - _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=False) # Kunde - if rechnung.aufschlaege: - story.append(PageBreak()) - story.append(_AmtMarke(marke)) - _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)) - - 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")) + doc.build( + story, + onFirstPage=lambda c, d: _kopf_fuss(c, d, cfg), + onLaterPages=lambda c, d: _kopf_fuss(c, d, cfg), + ) return pfad