diff --git a/app.py b/app.py index 43905f9..905f65f 100644 --- a/app.py +++ b/app.py @@ -265,13 +265,15 @@ 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_name = tk.StringVar() + self.k_vorname = tk.StringVar() + self.k_name = tk.StringVar() # Nachname oder Firma – die Zuordnung im Steuerjournal self.k_zusatz = tk.StringVar() self.k_strasse = tk.StringVar() self.k_plzort = tk.StringVar() felder = [ ("Anrede (optional)", self.k_anrede), - ("Name / Firma", self.k_name), + ("Vorname (optional)", self.k_vorname), + ("Nachname / Firma", self.k_name), ("Zusatz (optional)", self.k_zusatz), ("Straße / Nr.", self.k_strasse), ("PLZ / Ort", self.k_plzort), @@ -503,7 +505,13 @@ class RechnungsApp(tk.Tk): "Es ist kein Kundenname eingetragen. Trotzdem PDF erstellen?"): return - kunde_zeilen = [self.k_anrede.get(), self.k_name.get(), self.k_zusatz.get(), + # 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()] kopf = { "rechnungsnummer": nummer, @@ -512,6 +520,8 @@ 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)}" @@ -547,8 +557,8 @@ class RechnungsApp(tk.Tk): self._neue_nummer_vorschlagen() def reset_formular(self): - for v in (self.k_anrede, 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_vorname, 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 b1b4eef..c554322 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -10,6 +10,8 @@ im Layout der Excel-Vorlage: """ import os +import io +import json from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm @@ -19,11 +21,30 @@ 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") @@ -79,7 +100,8 @@ def _styles(): # ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) -------------------------- -def _kopf_fuss(canvas, doc, cfg): +def _kopf_fuss(canvas, doc, cfg, amt=False): + """Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile).""" firma = cfg["firma"] canvas.saveState() @@ -133,6 +155,13 @@ def _kopf_fuss(canvas, doc, cfg): 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) @@ -142,9 +171,12 @@ def _kopf_fuss(canvas, doc, cfg): col_l = MARGIN_L col_m = MARGIN_L + CONTENT_W * 0.40 col_r = PAGE_W - MARGIN_R - rows_l = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")] - rows_m = [firma.get("inhaber", ""), firma.get("steuernummer", "")] + + 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_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): @@ -205,7 +237,9 @@ def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): story.append(Spacer(1, 7 * mm)) # Ueberschrift (auf dem Amt-Blatt mit Zusatz) - titel = "Rechnungsübersicht" + (" – Beherbergungssteuer (für das Amt)" if gesamtrechnung_betont else "") + # 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 "") story.append(Paragraph(titel, st["h"])) # Positionstabelle @@ -261,14 +295,20 @@ 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"]), ]) - 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"] + # 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: summen_rows.append([ - Paragraph("Zwischensumme:", gr_l), - Paragraph(eur(rechnung.brutto), gr_v), + Paragraph("Zwischensumme:", st["info_l"]), + Paragraph(eur(rechnung.brutto), st["cell_r"]), ]) for a in rechnung.aufschlaege: satz_txt = eur_kurz(a.satz).replace(",00", "") @@ -276,9 +316,11 @@ 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(rechnung.gesamt), st["total_v"]), + Paragraph(eur(endsumme), st["total_v"]), ]) style_cmds = [ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), @@ -288,26 +330,21 @@ 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 - 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 - 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"])) story.append(Spacer(1, 8 * mm)) @@ -324,6 +361,31 @@ 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 @@ -332,26 +394,44 @@ 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. - Seite 2 = 1:1-Kopie fuers Amt, nur die Zwischensumme fett + zwei Streifen. + 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. """ 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 - doc.build( - story, - onFirstPage=lambda c, d: _kopf_fuss(c, d, cfg), - onLaterPages=lambda c, d: _kopf_fuss(c, d, cfg), - ) + 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")) return pfad