Kompletter Umbau in einem Rutsch auf PDF als template Grundlage

This commit is contained in:
trnq1ll0 committed 2026-06-16 20:46:45 +02:00
1 parent dbe325d305
commit 169d2167e0
12 files changed
+421 -287

No files matched your search

+2 -2
View File
@@ -74,8 +74,8 @@ def get_available_input_files(config: AppConfig) -> list[str]:
def get_available_templates(config: AppConfig) -> list[str]:
"""Listet alle .xlsx-Dateien im template-Ordner."""
return get_available_files(Path(config.template_dir))
"""Listet alle .pdf-Dateien im template-Ordner."""
return get_available_files(Path(config.template_dir), extension=".pdf")
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
+1 -1
View File
@@ -47,8 +47,8 @@ class FieldSchema:
class OutputMapping:
"""Definition eines Mappings aus output-fields.json."""
source_field: str
target_cell: str
format: Optional[str] = None
pdf_field: Optional[str] = None # Name des _Field-Platzhalters im PDF (z.B. "Auftragsnummer_Field")
@dataclass
+38 -23
View File
@@ -3,6 +3,7 @@
from pathlib import Path
from typing import Optional
import fitz # PyMuPDF
from openpyxl import load_workbook
from openpyxl.utils import column_index_from_string
@@ -96,9 +97,9 @@ def validate_template(
mapping: list[OutputMapping],
) -> ValidationResult:
"""
Validiert ein Template:
Validiert ein PDF-Template:
- Datei existiert
- Alle target_cells aus dem Mapping sind im Template vorhanden
- Alle _Field-Platzhalter (pdf_field) sind im PDF auf irgendeiner Seite vorhanden
"""
errors: list[str] = []
warnings: list[str] = []
@@ -109,37 +110,51 @@ def validate_template(
return ValidationResult(is_valid=False, errors=errors)
try:
wb = load_workbook(template_path, data_only=True)
doc = fitz.open(template_path)
except Exception as e:
errors.append(f"Template konnte nicht geöffnet werden: {e}")
errors.append(f"PDF-Template konnte nicht geöffnet werden: {e}")
return ValidationResult(is_valid=False, errors=errors)
info["sheet_names"] = wb.sheetnames
# Wir validieren gegen das erste Sheet (üblicherweise das Haupt-Template)
if not wb.sheetnames:
errors.append("Template enthält keine Sheets.")
wb.close()
if doc.page_count == 0:
errors.append("PDF-Template enthält keine Seiten.")
doc.close()
return ValidationResult(is_valid=False, errors=errors)
ws = wb[wb.sheetnames[0]]
found_count = 0
missing_fields = []
found_cells = 0
missing_cells = []
for m in mapping:
try:
cell = ws[m.target_cell]
found_cells += 1
except (ValueError, KeyError):
missing_cells.append(m.target_cell)
if m.pdf_field is None:
continue # Kein Marker erwartet
info["found_target_cells"] = found_cells
info["total_target_cells"] = len(mapping)
# Suche auf allen Seiten
found_any = False
for page_num in range(doc.page_count):
found = doc[page_num].search_for(m.pdf_field)
if found:
found_any = True
break
if missing_cells:
errors.append(f"Zielfelder nicht im Template gefunden: {', '.join(missing_cells)}")
if found_any:
found_count += 1
else:
missing_fields.append(m.source_field)
# Zähle unique pdf_fields (nicht die source_fields, da Tätigkeit doppelt vorkommt)
unique_pdf_fields = set(m.pdf_field for m in mapping if m.pdf_field is not None)
total_pages = doc.page_count
doc.close()
info["found_fields"] = found_count
info["total_fields"] = len(unique_pdf_fields)
info["page_count"] = total_pages
if missing_fields:
errors.append(
f"Platzhalter nicht im PDF-Template gefunden: {', '.join(missing_fields)}"
)
wb.close()
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
+168 -81
View File
@@ -1,19 +1,22 @@
"""Template-basierte Activity-Report-Generierung.
"""PDF-basierte Activity-Report-Generierung.
Verwendet ein Template mit bereits vor-duplizierten Sheets.
Es werden KEINE Sheets programmatisch erzeugt oder kopiert –
ausschließlich die vorhandenen Sheets mit Daten befüllt.
Verwendet ein mehrseitiges PDF-Template mit _Field-Platzhaltern.
Für jeden Mitarbeiter wird ein PDF erzeugt (alle Template-Seiten pro Monat).
Die _Field-Marker werden per PyMuPDF (fitz) gesucht und durch echte Daten ersetzt.
"""
import calendar
from pathlib import Path
from openpyxl import load_workbook
from openpyxl.cell.cell import MergedCell
import fitz # PyMuPDF
from .models import EmployeeRecord, OutputMapping
# ─────────────────────────────────────────────
# Namens-Helfer
# ─────────────────────────────────────────────
def _split_name_for_filename(name: str) -> tuple[str, str]:
"""Teilt 'Nachname, Vorname' oder 'Vorname Nachname' in (Vorname, Nachname)."""
cleaned = str(name or "").strip()
@@ -42,23 +45,6 @@ def _format_value(value, fmt: str | None) -> str:
return str(value)
def _set_cell_value(ws, target_cell: str, value) -> None:
"""Setzt einen Wert in einer Zelle des Worksheets.
Behandelt MergedCells, indem in die Top-Left-Zelle des Merge-Bereichs geschrieben wird."""
try:
cell = ws[target_cell]
if isinstance(cell, MergedCell):
for merged_range in ws.merged_cells.ranges:
if cell.coordinate in merged_range:
top_left = merged_range.min_row, merged_range.min_col
ws.cell(row=top_left[0], column=top_left[1]).value = value
return
else:
cell.value = value
except (ValueError, KeyError):
pass
def _format_month_for_template(monat_str: str) -> str:
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
monat_str = monat_str.strip()
@@ -72,46 +58,154 @@ def _format_month_for_template(monat_str: str) -> str:
return monat_str
def _month_name(mi: int) -> str:
names = ["", "Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember"]
return names[mi] if 1 <= mi <= 12 else ""
# ─────────────────────────────────────────────
# Daten-Extraktion aus EmployeeRecord
# ─────────────────────────────────────────────
def _fill_sheet(ws, record: EmployeeRecord, employee_name: str, field_map: dict) -> None:
"""Befüllt ein einzelnes Sheet mit den Daten eines EmployeeRecords."""
for source_field, m in field_map.items():
if source_field == "Monat (MM.JJ)":
value = _format_month_for_template(record.monat)
elif source_field == "Stellenanteil in %":
value = record.stellenanteil
elif source_field == "Name":
value = employee_name
elif source_field == "Antragsnummer":
value = record.antragsnummer
elif source_field == "Qualifikation":
value = record.qualifikation
elif source_field == "Funktion":
value = record.funktion
elif source_field == "Tätigkeit":
value = record.taetigkeit
elif source_field in ("Ort", "Ort 2"):
value = "Dresden"
elif source_field == "Datum":
parts = record.monat.split(".")
if len(parts) == 2:
def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: str) -> str:
"""Liefert den formatierten Wert für ein Feld im PDF."""
if source_field == "Monat (MM.JJ)":
return _format_month_for_template(record.monat)
elif source_field == "Stellenanteil in %":
return record.stellenanteil
elif source_field == "Name":
return employee_name
elif source_field == "Antragsnummer":
return record.antragsnummer
elif source_field == "Qualifikation":
return record.qualifikation
elif source_field == "Funktion":
return record.funktion
elif source_field == "Tätigkeit":
return record.taetigkeit
elif source_field in ("Ort", "Ort 2"):
return "Dresden"
elif source_field == "Datum":
parts = record.monat.split(".")
if len(parts) == 2:
try:
month = int(parts[0])
year = int(parts[1])
last_day = calendar.monthrange(year, month)[1]
value = f"{last_day:02d}.{month:02d}.{year}"
else:
value = ""
else:
continue
formatted = _format_value(value, m.format)
_set_cell_value(ws, m.target_cell, formatted)
return f"{last_day:02d}.{month:02d}.{year}"
except (ValueError, IndexError):
return ""
return ""
return ""
# ─────────────────────────────────────────────
# PDF-Template Pre-Scan: _Field-Positionen auf ALLEN Seiten ermitteln
# ─────────────────────────────────────────────
def _scan_template_fields(
template_path: Path,
mapping: list[OutputMapping],
) -> dict[str, dict]:
"""
Scannt ALLE Seiten des PDF-Templates nach _Field-Platzhaltern.
Rückgabe: dict[pdf_field] → {source_field, page_num, rect, fontsize, fontname, ...}
Felder mit pdf_field=None werden übersprungen.
"""
doc = fitz.open(template_path)
# Sammle alle pdf_field → source_field Zuordnungen
pdf_to_source: dict[str, str] = {}
for m in mapping:
if m.pdf_field is not None:
pdf_to_source[m.pdf_field] = m.source_field
field_info: dict[str, dict] = {}
# 1. Per search_for die Rechtecke aller PDF-Feld-Marker finden (alle Seiten)
for pdf_field, source_field in pdf_to_source.items():
found_any = False
for page_num in range(doc.page_count):
page = doc[page_num]
found = page.search_for(pdf_field)
if found:
field_info[pdf_field] = {
"source_field": source_field,
"page_num": page_num,
"rect": found[0],
"fontsize": 11, # Fallback
"fontname": "helv",
}
found_any = True
break # erstes Vorkommen (seitenübergreifend)
if not found_any:
field_info[pdf_field] = None # nicht gefunden
# 2. Schriftgrößen aus den Text-Details extrahieren (alle Seiten)
for page_num in range(doc.page_count):
page = doc[page_num]
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if "lines" not in block:
continue
for line in block["lines"]:
for span in line["spans"]:
text = span["text"].strip()
if text.endswith("_Field") and text in field_info:
info = field_info[text]
if info is not None:
info["fontsize"] = span["size"]
info["fontname"] = span.get("font", "helv")
info["rect"] = fitz.Rect(span["bbox"])
doc.close()
return field_info
# ─────────────────────────────────────────────
# Template-Seiten mit Daten befüllen
# ─────────────────────────────────────────────
def _fill_pdf_pages(
pages: list[fitz.Page], # eine Page pro Template-Seite
record: EmployeeRecord,
employee_name: str,
mapping: list[OutputMapping],
field_info: dict[str, dict],
) -> None:
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
for m in mapping:
if m.pdf_field is None:
continue
info = field_info.get(m.pdf_field)
if info is None:
continue # Feld nicht im Template gefunden
page_num = info["page_num"]
if page_num >= len(pages):
continue
page = pages[page_num]
rect = info["rect"]
fontsize = info.get("fontsize", 11)
value = _get_field_value(record, employee_name, m.source_field)
formatted = _format_value(value, m.format)
# Platzhalter redacten
page.add_redact_annot(rect)
page.apply_redactions()
# Neuen Text einfügen
if formatted:
page.insert_text(
rect.bl,
formatted,
fontname="helv",
fontsize=fontsize,
)
# ─────────────────────────────────────────────
# Hauptfunktion: Reports generieren
# ─────────────────────────────────────────────
def generate_activity_reports(
employee_records: dict[str, list[EmployeeRecord]],
template_path: Path,
@@ -120,14 +214,14 @@ def generate_activity_reports(
filename_pattern: str,
) -> list[Path]:
"""
Befüllt vor-duplizierte Template-Sheets mit Mitarbeiterdaten.
Erzeugt KEINE neuen Sheets – verwendet ausschließlich die im Template
bereits vorhandenen Sheet-Kopien.
Erzeugt für jeden Mitarbeiter ein PDF.
Pro Monat werden ALLE Template-Seiten kopiert und mit Daten befüllt.
"""
output_dir.mkdir(parents=True, exist_ok=True)
generated_files: list[Path] = []
field_map: dict[str, OutputMapping] = {m.source_field: m for m in mapping}
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
field_info = _scan_template_fields(template_path, mapping)
for employee_name, records in employee_records.items():
if not records:
@@ -140,31 +234,24 @@ def generate_activity_reports(
filename = filename_pattern.format(first_name=safe_first, last_name=safe_last)
output_path = output_dir / filename
wb = load_workbook(template_path)
sorted_records = sorted(records, key=lambda r: r.month_index)
total_needed = len(sorted_records)
available_sheets = wb.worksheets
if len(available_sheets) < total_needed:
wb.close()
raise RuntimeError(
f"Template hat nur {len(available_sheets)} Sheets, "
f"aber {total_needed} werden benötigt für '{employee_name}'."
)
# Ergebnis-PDF erstellen
result_doc = fitz.open()
for idx, record in enumerate(sorted_records):
sheet = available_sheets[idx]
mi = record.month_index
func_abbr = record.funktion[:3] if record.funktion else ""
sheet.title = f"{mi:02d}_{_month_name(mi)}_{func_abbr}"[:31]
_fill_sheet(sheet, record, employee_name, field_map)
for record in sorted_records:
# Template frisch öffnen für jeden Monat
temp_doc = fitz.open(template_path)
pages = [temp_doc[i] for i in range(temp_doc.page_count)]
# Lösche überzählige Sheets (von hinten nach vorne)
for extra in reversed(available_sheets[total_needed:]):
wb.remove(extra)
_fill_pdf_pages(pages, record, employee_name, mapping, field_info)
wb.save(output_path)
wb.close()
# Alle modifizierten Seiten ins Ergebnis-PDF einfügen
result_doc.insert_pdf(temp_doc)
temp_doc.close()
result_doc.save(output_path)
result_doc.close()
generated_files.append(output_path)
return generated_files