Kompletter Umbau in einem Rutsch auf PDF als template Grundlage
This commit is contained in:
1 parent
dbe325d305
commit
169d2167e0
12 files changed
+421
-287
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user