Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

2 changed files with 20 additions and 106 deletions

View file

@ -111,23 +111,25 @@ def _kopf_fuss(canvas, doc, firma):
canvas.setLineWidth(1.1)
canvas.line(MARGIN_L, line_y, PAGE_W - MARGIN_R, line_y)
# Fußzeile: nur die Seitenzahl.
#
# Hier stand vorher der komplette Briefkopf-Fuß Bankverbindung, Steuernummer,
# Inhaber, Homepage, E-Mail, Telefon. Zwei Gründe, warum das weg ist:
#
# 1. Es ist eine Meldung, keine Rechnung. Das Amt zieht nichts ein und überweist
# nichts; Bankdaten und Steuernummer haben darin nichts verloren. Auf der
# Rechnung (Rechnungstool) bleiben sie, dort werden sie gebraucht.
# 2. Der Block lag ÜBER dem unteren Seitenrand (bottomMargin) und ragte damit in
# die Tabelle hinein auf vollen Seiten stand er quer über der letzten Zeile.
#
# Die Seitenzahl sitzt bewusst unterhalb von MARGIN_BOT, sonst passiert dasselbe
# wieder.
# Fußzeile
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)
canvas.drawCentredString(PAGE_W / 2, MARGIN_BOT - 12 * mm,
f"Seite {canvas.getPageNumber()}")
links = [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
mitte = [firma.get("inhaber", ""), firma.get("steuernummer", "")]
rechts = [firma.get("homepage", ""), firma.get("email", ""), firma.get("telefon", "")]
for i, t in enumerate(links):
canvas.drawString(MARGIN_L, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(mitte):
canvas.drawString(MARGIN_L + CONTENT_W * 0.40, fy + 9 * mm - i * 3.6 * mm, t)
for i, t in enumerate(rechts):
canvas.drawRightString(PAGE_W - MARGIN_R, fy + 9 * mm - i * 3.6 * mm, t)
canvas.setFont("Helvetica", 7.3)
canvas.drawCentredString(PAGE_W / 2, fy + 1 * mm, f"Seite {canvas.getPageNumber()}")
canvas.restoreState()

View file

@ -28,7 +28,6 @@ Nur Seite 1 wird gelesen Seite 2 ist die 1:1-Kopie fürs Amt.
import os
import re
import json
import logging
from datetime import datetime, date
@ -71,23 +70,6 @@ def _naechster_wert(zeilen, i: int) -> str:
return ""
def _block_nach(zeilen, i: int, hoechstens: int = 5):
"""
Die Zeilen des Adressblocks nach 'Rechnung an:' bis zum nächsten Label
(z.B. 'Rechnungsnummer:'). Anrede, Name, Zusatz, Straße, PLZ/Ort.
"""
block = []
for z in zeilen[i + 1:]:
if not z.strip():
continue
if z.strip().endswith(":"): # nächstes Label -> Adressblock zu Ende
break
block.append(z.strip())
if len(block) >= hoechstens:
break
return block
def _datum(text: str) -> date:
for fmt in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"):
try:
@ -101,25 +83,6 @@ RECHTSFORM = {"gmbh", "ag", "kg", "ug", "gbr", "ohg", "mbh", "e.k.", "ek", "eg",
"gmbh & co. kg", "verein", "e.v."}
NAMENSZUSATZ = {"van", "von", "de", "der", "den", "di", "du", "le", "la", "el", "al",
"ten", "ter", "zu", "vom", "zum", "zur", "mc", "mac", "o'"}
# Das Rechnungstool hat ein eigenes Feld "Anrede (optional)" über "Name / Firma".
# Steht dort etwas, ist es die erste Zeile unter "Rechnung an:" und darf NICHT
# als Name durchgehen.
ANREDE = {"herr", "herrn", "frau", "familie", "fam", "eheleute", "firma", "hr", "fr",
"dr", "prof", "mr", "mrs", "ms", "und", "&", "an", "die", "z.h.", "zhd"}
def _nur_anrede(zeile: str) -> bool:
"""True, wenn die Zeile ausschließlich aus Anrede-Wörtern besteht ('Herr', 'Familie')."""
teile = str(zeile or "").split()
return bool(teile) and all(t.lower().strip(".,") in ANREDE for t in teile)
def _ohne_anrede(zeile: str) -> str:
"""Führende Anrede-Wörter abschneiden: 'Firma Muster GmbH' -> 'Muster GmbH'."""
teile = str(zeile or "").split()
while teile and teile[0].lower().strip(".,") in ANREDE:
teile.pop(0)
return " ".join(teile)
def nachname_aus_zeile(zeile: str) -> str:
@ -129,9 +92,8 @@ def nachname_aus_zeile(zeile: str) -> str:
'Meier, Peter' -> 'Meier'
'Familie van Dijk' -> 'van Dijk' (Namenszusatz bleibt dran)
'Hentschke Bau GmbH' -> 'Hentschke Bau GmbH' (Firma komplett)
'Firma Muster GmbH' -> 'Muster GmbH' (Anrede vorn fällt weg)
"""
z = " ".join(_ohne_anrede(zeile).split())
z = " ".join(str(zeile or "").split())
if not z:
return ""
if "," in z: # "Nachname, Vorname"
@ -146,58 +108,8 @@ def nachname_aus_zeile(zeile: str) -> str:
return teile[-1]
def name_aus_block(zeilen) -> str:
"""
Aus dem Adressblock unter 'Rechnung an:' die Zeile mit Name/Firma holen.
Reine Anrede-Zeilen ('Herr', 'Familie') werden übersprungen.
"""
for z in zeilen:
if z.strip() and not _nur_anrede(z):
return nachname_aus_zeile(z)
return ""
def _aus_metadaten(pfad: str):
"""
Rechnungen des Rechnungstools tragen ihre Kenndaten als JSON in den
PDF-Metadaten (Feld "Subject"). Dann muss nichts geraten werden.
Gibt None zurück, wenn die PDF das nicht hat (ältere Rechnungen).
"""
try:
info = PdfReader(pfad).metadata or {}
roh = info.get("/Subject") or ""
if not roh.strip().startswith("{"):
return None
daten = json.loads(roh)
except Exception as e: # noqa: BLE001 - fremde/kaputte Metadaten
log.debug("%s: keine lesbaren Metadaten (%s)", os.path.basename(pfad), e)
return None
if daten.get("quelle") != "rechnungstool" or not daten.get("rechnungsnummer"):
return None
return daten
def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
"""Rechnungs-PDF -> Buchung. Wirft ParserFehler, wenn Pflichtfelder fehlen."""
meta = _aus_metadaten(pfad)
if meta:
datum = _datum(str(meta.get("datum", "")))
jahr, nummer = zerlege_nummer(meta.get("rechnungsnummer"), datum.year)
b = Buchung(
datum=datum,
rechnungsnummer=nummer,
jahr=jahr,
nachname=str(meta.get("nachname") or "").strip(),
naechte=int(zahl_aus_text(meta.get("naechte")) or 0),
gezahlt=runde(meta.get("zwischensumme") or 0),
satz=float(meta.get("steuer_satz") or 0) or standard_satz,
quelle="pdf",
pdf_pfad=os.path.abspath(pfad),
)
log.debug("aus Metadaten: %s | %s | %s | %s Nächte | %.2f",
os.path.basename(pfad), b.voll_nummer, b.nachname, b.naechte, b.gezahlt)
return b
text = _text_seite1(pfad)
zeilen = text.splitlines()
@ -224,7 +136,7 @@ def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
if m:
naechte = int(m.group(1))
elif RE_KUNDE.match(z):
kunde = name_aus_block(_block_nach(zeilen, i))
kunde = _naechster_wert(zeilen, i)
elif RE_ZWISCHEN.match(z) and zwischensumme is None:
zwischensumme = zahl_aus_text(_naechster_wert(zeilen, i))
elif RE_GESAMT.match(z) and gesamtbetrag is None:
@ -263,7 +175,7 @@ def parse_pdf(pfad: str, standard_satz: float = STANDARD_SATZ) -> Buchung:
datum=datum,
rechnungsnummer=nummer,
jahr=jahr,
nachname=kunde, # kommt schon fertig aus name_aus_block()
nachname=nachname_aus_zeile(kunde),
naechte=max(0, naechte),
gezahlt=runde(gezahlt),
satz=satz,