# -*- coding: utf-8 -*- """ PDF-Renderer fuer die Campinghof-Bartl-Rechnung (reportlab). render_rechnung(pfad, cfg, rechnung, kopf) erzeugt ein druckfertiges A4-PDF im Layout der Excel-Vorlage: Firmenkopf -> Absenderzeile -> Kundenadresse | Rechnungsinfos -> Positionstabelle -> Summen + Steueraufstellung -> Danktexte -> Bankfusszeile """ import os 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 from reportlab.platypus import ( SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, KeepTogether, PageBreak, ) import config from berechnung import eur, eur_kurz, runde # ---- Farben / Masse --------------------------------------------------------- ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping) GREY_BG = colors.HexColor("#eef1ee") LINE = colors.HexColor("#9aa39c") DARK = colors.HexColor("#222222") MARGIN_L = 18 * mm MARGIN_R = 18 * mm MARGIN_TOP = 40 * mm # Platz fuer den Kopf MARGIN_BOT = 34 * mm # Platz fuer die Fusszeile PAGE_W, PAGE_H = A4 CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R # ~174mm # ---- Absatz-Stile ----------------------------------------------------------- def _styles(): return { "leistung": ParagraphStyle("leistung", fontName="Helvetica", fontSize=8.5, leading=10.5, textColor=DARK, alignment=TA_LEFT), "cell_r": ParagraphStyle("cell_r", fontName="Helvetica", fontSize=8.5, leading=10.5, textColor=DARK, alignment=TA_RIGHT), "cell_c": ParagraphStyle("cell_c", fontName="Helvetica", fontSize=8.5, leading=10.5, textColor=DARK, alignment=TA_CENTER), "th": ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8, leading=9.3, textColor=DARK, alignment=TA_CENTER), "th_l": ParagraphStyle("th_l", fontName="Helvetica-Bold", fontSize=8, leading=9.3, textColor=DARK, alignment=TA_LEFT), "sum_r": ParagraphStyle("sum_r", fontName="Helvetica-Bold", fontSize=8.5, leading=10.5, textColor=DARK, alignment=TA_RIGHT), "sum_l": ParagraphStyle("sum_l", fontName="Helvetica-Bold", fontSize=8.5, leading=10.5, textColor=DARK, alignment=TA_LEFT), "addr": ParagraphStyle("addr", fontName="Helvetica", fontSize=10, leading=13, textColor=DARK), "addr_label": ParagraphStyle("addr_label", fontName="Helvetica-Bold", fontSize=9, leading=12, textColor=ACCENT), "info_l": ParagraphStyle("info_l", fontName="Helvetica-Bold", fontSize=9, leading=13, textColor=DARK, alignment=TA_RIGHT), "info_v": ParagraphStyle("info_v", fontName="Helvetica", fontSize=9, leading=13, textColor=DARK, alignment=TA_LEFT), "h": ParagraphStyle("h", fontName="Helvetica-Bold", fontSize=11, leading=14, textColor=ACCENT, spaceBefore=4, spaceAfter=4), "h2": ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15, leading=19, textColor=ACCENT, spaceAfter=4), "dank": ParagraphStyle("dank", fontName="Helvetica", fontSize=8.5, leading=11, textColor=DARK, spaceAfter=4), "hinweis": ParagraphStyle("hinweis", fontName="Helvetica-Oblique", fontSize=8.5, leading=11, textColor=ACCENT, alignment=TA_CENTER, spaceBefore=4), "total_l": ParagraphStyle("total_l", fontName="Helvetica-Bold", fontSize=10.5, leading=14, textColor=DARK, alignment=TA_RIGHT), "total_v": ParagraphStyle("total_v", fontName="Helvetica-Bold", fontSize=10.5, leading=14, textColor=ACCENT, alignment=TA_RIGHT), } # ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) -------------------------- def _kopf_fuss(canvas, doc, cfg): firma = cfg["firma"] canvas.saveState() # --- 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 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 logo_ok: # Firmenname + Untertitel rechtsbuendig neben dem Logo top = PAGE_H - 15 * mm canvas.setFillColor(ACCENT) canvas.setFont("Helvetica-Bold", 16) canvas.drawRightString(PAGE_W - MARGIN_R, top, firma.get("name", "")) canvas.setFillColor(DARK) canvas.setFont("Helvetica-Oblique", 9.5) canvas.drawRightString(PAGE_W - MARGIN_R, top - 5.5 * mm, firma.get("untertitel", "")) else: top = PAGE_H - 16 * mm canvas.setFillColor(ACCENT) canvas.setFont("Helvetica-Bold", 19) canvas.drawString(MARGIN_L, top, firma.get("name", "")) canvas.setFillColor(DARK) canvas.setFont("Helvetica-Oblique", 10.5) canvas.drawString(MARGIN_L, top - 6.2 * mm, firma.get("untertitel", "")) line_y = top - 9.5 * mm canvas.setStrokeColor(ACCENT) canvas.setLineWidth(1.1) canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y) # --- Fusszeile --- fy = MARGIN_BOT - 4 * mm canvas.setStrokeColor(LINE) canvas.setLineWidth(0.6) canvas.line(MARGIN_L, fy + 13 * mm, PAGE_W - MARGIN_R, fy + 13 * mm) canvas.setFont("Helvetica", 7.3) canvas.setFillColor(DARK) 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_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): canvas.drawString(col_m, fy + 9 * mm - i * 3.6 * mm, t) for i, t in enumerate(rows_r): canvas.drawRightString(col_r, fy + 9 * mm - i * 3.6 * mm, t) canvas.restoreState() # ---- Hauptfunktion ---------------------------------------------------------- def _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont): """Baut den kompletten Seiteninhalt (Absender -> Positionen -> Summen -> Danktexte). gesamtrechnung_betont=True hebt die Zwischensumme-Zeile wie den Gesamtbetrag hervor (fett + zwei Streifen) -> fuer das Amt-Blatt. Sonst 1:1 die Kundenseite. """ # Absenderzeile (klein, unterstrichen) story.append(Paragraph( f"{cfg['firma'].get('absender','')}", ParagraphStyle("abs", fontName="Helvetica", fontSize=7.5, textColor=DARK))) story.append(Spacer(1, 5 * mm)) # Kundenadresse (links) | Rechnungsinfos (rechts) kunde_flow = [Paragraph("Rechnung an:", st["addr_label"])] for z in kopf.get("kunde_zeilen", []): if str(z).strip(): kunde_flow.append(Paragraph(str(z), st["addr"])) if len(kunde_flow) == 1: kunde_flow.append(Paragraph(" ", st["addr"])) info_rows = [ ("Rechnungsnummer:", kopf.get("rechnungsnummer", "")), ("Rechnungsdatum:", kopf.get("datum", "")), ] if kopf.get("anreise"): info_rows.append(("Anreise:", kopf.get("anreise", ""))) if kopf.get("abreise"): info_rows.append(("Abreise:", kopf.get("abreise", ""))) if kopf.get("naechte") not in (None, "", 0, "0"): info_rows.append(("Zeitraum:", f"{kopf.get('naechte')} Nächte")) info_tbl = Table( [[Paragraph(k, st["info_l"]), Paragraph(str(v), st["info_v"])] for k, v in info_rows], colWidths=[39 * mm, 25 * mm]) info_tbl.setStyle(TableStyle([ ("VALIGN", (0, 0), (-1, -1), "TOP"), ("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 1), ])) head = Table([[kunde_flow, info_tbl]], colWidths=[CONTENT_W - 64 * mm, 64 * mm]) head.setStyle(TableStyle([ ("VALIGN", (0, 0), (-1, -1), "TOP"), ("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0), ])) story.append(head) 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 "") story.append(Paragraph(titel, st["h"])) # Positionstabelle col_w = [10 * mm, 70 * mm, 12 * mm, 21 * mm, 13 * mm, 22 * mm, 26 * mm] data = [[ Paragraph("Pos.", st["th"]), Paragraph("Leistung", st["th_l"]), Paragraph("USt %", st["th"]), Paragraph("Anzahl
Tage/Menge", st["th"]), Paragraph("Pers.", st["th"]), Paragraph("E.-Preis", st["th"]), Paragraph("Gesamt", st["th"]), ]] for z in rechnung.zeilen: pers = eur_kurz(z.personen).replace(",00", "") if z.pro_person else "" data.append([ Paragraph(str(z.nr), st["cell_c"]), Paragraph(z.leistung, st["leistung"]), Paragraph(str(z.satz), st["cell_c"]), Paragraph(eur_kurz(z.anzahl).replace(",00", ""), st["cell_c"]), Paragraph(pers, st["cell_c"]), Paragraph(eur_kurz(z.preis), st["cell_r"]), Paragraph(eur_kurz(z.brutto), st["cell_r"]), ]) if len(data) == 1: # keine Position erfasst data.append([Paragraph("", st["cell_c"]), Paragraph("(keine Positionen erfasst)", st["leistung"]), "", "", "", "", ""]) items = Table(data, colWidths=col_w, repeatRows=1) items.setStyle(TableStyle([ ("BACKGROUND", (0, 0), (-1, 0), GREY_BG), ("LINEBELOW", (0, 0), (-1, 0), 0.8, ACCENT), ("LINEBELOW", (0, 1), (-1, -1), 0.35, LINE), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ("LEFTPADDING", (0, 0), (-1, -1), 3), ("RIGHTPADDING", (0, 0), (-1, -1), 3), ])) story.append(items) story.append(Spacer(1, 3 * mm)) # ---- Summenblock: Netto -> USt je Satz -> Zwischensumme (brutto) # -> zzgl. Beherbergungssteuer -> Gesamtbetrag ---- summen_rows = [[ Paragraph("Nettobetrag:", st["info_l"]), Paragraph(eur(rechnung.netto_gesamt), st["cell_r"]), ]] for b in rechnung.bloecke: name = b.bezeichnung or "USt" summen_rows.append([ 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"] summen_rows.append([ Paragraph("Zwischensumme:", gr_l), Paragraph(eur(rechnung.brutto), gr_v), ]) # Auf dem Amt-Blatt stehen nur noch die Umsatzsteuern (7 % / 19 %). Die # Beherbergungssteuer selbst wird dort NICHT ausgewiesen - so will es das Amt. # Es braucht nur die Zwischensumme als Bemessungsgrundlage und rechnet selbst. # Auf der Kundenseite bleibt die Zeile stehen, dort gehoert sie hin. if not gesamtrechnung_betont: for a in rechnung.aufschlaege: satz_txt = eur_kurz(a.satz).replace(",00", "") summen_rows.append([ Paragraph(f"zzgl. {a.bezeichnung or 'Aufschlag'} ({satz_txt} %):", st["info_l"]), Paragraph(eur(a.betrag), st["cell_r"]), ]) summen_rows.append([ Paragraph("Gesamtbetrag:", st["total_l"]), Paragraph(eur(rechnung.gesamt), st["total_v"]), ]) style_cmds = [ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("TOPPADDING", (0, 0), (-1, -1), 1.5), ("BOTTOMPADDING", (0, 0), (-1, -1), 1.5), ("LINEABOVE", (0, -1), (1, -1), 0.8, ACCENT), ("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"])) story.append(Spacer(1, 8 * mm)) # ---- Danktexte + Hinweis (nur Kundenseite; auf dem Amt-Blatt unnoetig) ---- if not gesamtrechnung_betont: texte = cfg.get("texte", {}) block = [] if texte.get("dank1"): block.append(Paragraph(texte["dank1"], st["dank"])) if texte.get("dank2"): block.append(Paragraph(texte["dank2"], st["dank"])) if texte.get("hinweis"): block.append(Paragraph(texte["hinweis"], st["hinweis"])) story.append(KeepTogether(block)) def _kenndaten(rechnung, kopf): """ 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 cfg : geladene Konfiguration rechnung : berechnung.Rechnung 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. """ 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", ""), subject=_kenndaten(rechnung, kopf), # maschinenlesbar fuers Steuerjournal ) 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), ) return pfad