move the whole app in subfolder
This commit is contained in:
1 parent
fa3e99dec0
commit
94e245a3a0
27 files changed
No files matched your search
@@ -0,0 +1,9 @@
|
||||
input/
|
||||
output/
|
||||
.venv/
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
.~lock.*#
|
||||
*.xlsx#
|
||||
~*.xlsx
|
||||
/versions
|
||||
@@ -0,0 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
||||
|
||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
---
|
||||
|
||||
## [1.1] – 2026-06-24
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Streamlit-Web-UI** (`app.py`) mit:
|
||||
- Dropdown zur Auswahl der Input-Datei (`.xlsx`) aus dem `input/`-Ordner – mit Sofort-Validierung und Anzeige von Mitarbeiteranzahl, Antragsnummern und Einträgen pro Monat
|
||||
- Dropdown zur Auswahl des PDF-Templates aus dem `template/`-Ordner – mit Sofort-Validierung und Anzeige gefundener `_Field`-Platzhalter
|
||||
- Mitarbeiter-Multiselect-Liste mit „Alle auswählen“ / „Keine auswählen“-Buttons
|
||||
- „Tätigkeitsnachweise generieren“-Button mit Fortschrittsanzeige
|
||||
- Session-State-Caching für Validierung und Parsing (kein erneutes Einlesen bei UI-Reruns)
|
||||
- Erfolgsmeldung mit Liste generierter Dateien und Button zum Öffnen des Output-Ordners
|
||||
|
||||
- **PDF-basierte Generierung** (`src/writer.py`) via PyMuPDF (fitz):
|
||||
- Pre-Scan aller `_Field`-Platzhalter im Template-PDF über **alle Seiten**
|
||||
- Automatische Erkennung von Position (Bounding Box) und Schriftgröße jedes Platzhalters
|
||||
- Redact + Insert-Text-Verfahren: Platzhalter werden geschwärzt und durch echte Daten ersetzt
|
||||
- Unterstützung für **mehrseitige Templates** (z. B. 2 Seiten pro Monat)
|
||||
- **Mehrzeilige Textboxen** (`Tätigkeiten_Field`, `Dienstreisen_Field`) mit automatischem Wortumbruch
|
||||
- **Automatische Schriftgrößen-Anpassung** bei langen Texten in Qualifikation und Funktion (Autoscale)
|
||||
- Prozentwerte werden als Ganzzahl ohne `%`-Zeichen ausgegeben
|
||||
- Datumsformat `dd.mm.yyyy` wird automatisch aus dem Monatsfeld (`MM.JJ`) berechnet
|
||||
- Ort standardmäßig „Dresden“
|
||||
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.pdf`
|
||||
|
||||
- **Input-Parsing** (`src/parser.py`):
|
||||
- Einlesen von `.xlsx`-Dateien mit openpyxl – ein Sheet pro Monat
|
||||
- Monats-Sheet-Erkennung per Wildcard-Pattern (`* 20??`)
|
||||
- Prozentwert-Konvertierung: Excel-Prozentwerte (0.5 → 50) oder Ganzzahlen (50 → 50)
|
||||
- Automatisches Überspringen leerer Zeilen (Name oder Antragsnummer fehlt)
|
||||
- Gruppierung der Records nach Mitarbeitername (`group_by_employee`)
|
||||
- Hilfsfunktionen: `get_unique_antragsnummern`, `get_unique_employees`
|
||||
|
||||
- **Validierung** (`src/validator.py`):
|
||||
- Input-Datei: Prüft Existenz, Spaltenüberschriften (gegen `input-fields.json`), Datenzeilen pro Monats-Sheet
|
||||
- Input-Daten: Erkennt leere Pflichtfelder pro Person und meldet betroffene Namen
|
||||
- PDF-Template: Sucht alle `_Field`-Platzhalter im PDF und meldet fehlende
|
||||
- Nicht-Monats-Sheets (z. B. „Alle Rollen“) werden ignoriert
|
||||
|
||||
- **Konfigurationsdateien** (`config/`):
|
||||
- `settings.ini`: Pfade für Input/Template/Output, Dateinamen-Pattern, Monats-Sheet-Pattern, Skip-Optionen
|
||||
- `input-fields.json`: Spaltenschema der Input-Tabelle (9 Felder: ID, Kommentar, Antragsnummer, Name, Qualifikation, Funktion, Stellenanteil in %, Monat, Tätigkeit)
|
||||
- `output-fields.json`: Mapping Input-Feld → `_Field`-Platzhalter im PDF (8 Felder)
|
||||
|
||||
- **Datenmodell** (`src/models.py`):
|
||||
- `EmployeeRecord`: Dataclass für eine Mitarbeiter-Zeile mit `month_index`- und `year`-Properties
|
||||
- `FieldSchema`, `OutputMapping`, `AppConfig`, `ValidationResult`
|
||||
|
||||
- **Cold Run** (`coldrun.py`): Headless-Generierung für die erste Person ohne GUI, automatisch mit erster Input- und Template-Datei
|
||||
|
||||
- **Start-Skripte**: `start.bat` (Windows), `start.sh` / `start.command` (macOS/Linux)
|
||||
|
||||
- **End-to-End-Tests** (`tests/test_e2e.py`):
|
||||
- pytest-basiert mit realer Input-Datei (`2025 JM.xlsx`) und realem PDF-Template
|
||||
- Prüft: 168 Datensätze, 19 Personen, 12 Monate
|
||||
- Validiert Output-PDF: Seitenanzahl (24 Seiten = 12 × 2), Inhalte (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat, Funktion, Tätigkeit), keine verbliebenen `_Field`-Platzhalter
|
||||
|
||||
- **README.md**: Vollständige Dokumentation zu Setup, Bedienung, Konfiguration, Cold Run und Tests
|
||||
|
||||
### Geändert
|
||||
|
||||
- Template-Format von `.xlsx` auf **PDF** umgestellt (ursprüngliches Excel-Template war schreibgeschützt)
|
||||
- `config/output-fields.json`: `target_cell` entfernt – Mapping erfolgt jetzt über `_Field`-Platzhalter im PDF
|
||||
- `src/config.py`: `get_available_templates` filtert jetzt `.pdf`-Dateien
|
||||
@@ -0,0 +1,107 @@
|
||||
|
||||
# Tätigkeitsnachweis-Generator
|
||||
|
||||
Streamlit-App zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Python 3.9+ installieren** (falls nicht vorhanden): https://python.org
|
||||
2. **Virtuelle Umgebung erstellen und Abhängigkeiten installieren:**
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # Windows
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **App starten:**
|
||||
|
||||
```bash
|
||||
streamlit run app.py
|
||||
```
|
||||
|
||||
Oder einfach `start.bat` (Windows) bzw. `./start.sh` (macOS/Linux) ausführen.
|
||||
|
||||
## Cold Run – Headless-Generierung
|
||||
|
||||
`coldrun.py` erzeugt ohne GUI einen Tätigkeitsnachweis für die erste Person –
|
||||
automatisch mit der ersten Input- und Template-Datei. Ergebnis wird auf macOS
|
||||
im Finder geöffnet.
|
||||
|
||||
```bash
|
||||
# Kommandozeile
|
||||
.venv/bin/python coldrun.py
|
||||
|
||||
# oder per Doppelklick (macOS)
|
||||
coldrun.command
|
||||
```
|
||||
|
||||
## Bedienung
|
||||
|
||||
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
|
||||
- Nach Auswahl wird die Datei sofort validiert
|
||||
- Angezeigt werden: Anzahl Mitarbeiter, Antragsnummern, Einträge pro Monat
|
||||
2. **Template wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `template/`-Ordner
|
||||
- Nach Auswahl wird das Template sofort validiert
|
||||
- Angezeigt wird: Anzahl gefundener Zielfelder
|
||||
3. **Mitarbeiter auswählen** – Multiselect-Liste aller gefundenen Mitarbeiter
|
||||
- Standardmäßig sind alle ausgewählt
|
||||
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
|
||||
4. **"Tätigkeitsnachweise generieren"** klicken
|
||||
- Die generierten Dateien werden im `output/`-Ordner abgelegt
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Alle Einstellungen liegen im `config/`-Ordner:
|
||||
|
||||
### `settings.ini`
|
||||
- `[paths]` – Ordner für Input, Template, Output
|
||||
- `[output]` – Dateinamen-Pattern (`{first_name}`, `{last_name}`)
|
||||
- `[parsing]` – Monats-Sheet-Erkennung, Leerzeilen-Verhalten
|
||||
|
||||
### `input-fields.json`
|
||||
Definiert die Spalten der Input-Tabelle:
|
||||
- `column`: Spaltenbuchstabe (A, B, C…)
|
||||
- `field_name`: Exakte Spaltenüberschrift aus der Excel-Datei
|
||||
- `type`: `string` oder `number`
|
||||
- `required`: `true` oder `false`
|
||||
|
||||
### `output-fields.json`
|
||||
Mapping von Input-Feldern auf Template-Zellen:
|
||||
- `source_field`: Exakte Spaltenüberschrift aus `input-fields.json`
|
||||
- `target_cell`: Zelladresse im Template (z. B. `"C16"`)
|
||||
- `format`: Optionaler Format-String (z. B. `"%.2f"` für Prozentwerte)
|
||||
|
||||
## Erwartetes Input-Format
|
||||
|
||||
- Eine `.xlsx`-Datei mit einem Sheet pro Monat (z. B. "Januar 2025", "Februar 2025", …)
|
||||
- Jedes Sheet hat in Zeile 1 die Spaltenüberschriften (laut `input-fields.json`)
|
||||
- Daten beginnen ab Zeile 2
|
||||
- Jede Zeile entspricht einer Mitarbeiter-Funktion-Kombination für diesen Monat
|
||||
|
||||
## Output
|
||||
|
||||
- Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt
|
||||
- Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt
|
||||
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
|
||||
|
||||
## Tests
|
||||
|
||||
End-to-End-Tests parsen die reale Input-Datei (`input/2025 JM.xlsx`) und das reale
|
||||
Template (`template/Tätigkeitsnachweise Stellenförderung.xlsx`) und validieren
|
||||
den gesamten Generierungs-Workflow.
|
||||
|
||||
```bash
|
||||
# Test-Abhängigkeiten installieren
|
||||
.venv/bin/pip install pytest
|
||||
|
||||
# Tests ausführen
|
||||
.venv/bin/python -m pytest tests/test_e2e.py -v
|
||||
```
|
||||
|
||||
Die Tests prüfen:
|
||||
- Vorhandensein der Input- und Template-Dateien
|
||||
- Korrekte Parsierung (168 Datensätze, 19 Personen)
|
||||
- Vollständige Pipeline für die erste Person („Draßdo, Cornelia"): 12 Monats-Sheets
|
||||
mit allen Zellinhalten (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat,
|
||||
Funktion, Tätigkeit, Ort, Datum)
|
||||
@@ -0,0 +1,46 @@
|
||||
@echo off
|
||||
title Tätigkeitsnachweis-Generator – Cold Run
|
||||
echo ========================================
|
||||
echo Cold Run – Headless-Generierung
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: Prüfe ob Python verfügbar ist
|
||||
where python >nul 2>nul
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [FEHLER] Python wurde nicht gefunden.
|
||||
echo Bitte installiere Python 3.9 oder neuer von https://python.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [INFO] Python gefunden:
|
||||
python --version
|
||||
|
||||
:: Prüfe ob venv existiert, sonst erstelle
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo [FEHLER] Virtuelle Umgebung nicht gefunden.
|
||||
echo Bitte zuerst start.bat ausführen, um das Projekt einzurichten.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Aktiviere venv und installiere Abhängigkeiten
|
||||
call .venv\Scripts\activate.bat
|
||||
echo [INFO] Stelle sicher, dass Abhängigkeiten installiert sind...
|
||||
pip install -r requirements.txt --quiet
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [FEHLER] Konnte Abhängigkeiten nicht installieren.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Starte Cold Run
|
||||
echo.
|
||||
echo [INFO] Starte Cold Run – generiere Tätigkeitsnachweis für erste Person...
|
||||
echo.
|
||||
python coldrun.py
|
||||
|
||||
echo.
|
||||
echo [INFO] Cold Run abgeschlossen.
|
||||
pause
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Doppelklickbares Cold-Run-Script für macOS
|
||||
# Nimmt automatisch die erste Input-Datei + erstes Template und erzeugt
|
||||
# einen Tätigkeitsnachweis für die erste gelistete Person – ohne GUI.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
VENV_DIR=".venv"
|
||||
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
echo "[FEHLER] Virtuelle Umgebung nicht gefunden."
|
||||
echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten."
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Stelle sicher, dass Abhängigkeiten installiert sind…"
|
||||
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
"$VENV_DIR/bin/python" coldrun.py
|
||||
|
||||
echo ""
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cold-Run Tätigkeitsnachweis-Generator.
|
||||
|
||||
Nimmt automatisch die erste Input-Datei und das erste Template,
|
||||
erzeugt einen Tätigkeitsnachweis für die erste gelistete Person
|
||||
und öffnet das Ergebnis (macOS) – ohne GUI, ohne Fragen.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt-Root
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.config import (
|
||||
load_settings,
|
||||
load_input_schema,
|
||||
load_output_mapping,
|
||||
get_available_input_files,
|
||||
get_available_templates,
|
||||
)
|
||||
from src.parser import parse_input_file, group_by_employee, get_unique_employees
|
||||
from src.validator import validate_input_file, validate_template
|
||||
from src.writer import generate_activity_reports
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" Tätigkeitsnachweis-Generator (Cold Run)")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# ── Konfiguration laden ──────────────────────────────────────────
|
||||
config_dir = PROJECT_ROOT / "config"
|
||||
config = load_settings(config_dir, PROJECT_ROOT)
|
||||
config.input_schema = load_input_schema(config_dir)
|
||||
config.output_mapping = load_output_mapping(config_dir)
|
||||
|
||||
# ── Erste Input-Datei ermitteln ───────────────────────────────────
|
||||
input_files = get_available_input_files(config)
|
||||
if not input_files:
|
||||
print("[FEHLER] Keine .xlsx-Dateien im input/-Ordner gefunden.")
|
||||
return 1
|
||||
|
||||
input_file = input_files[0]
|
||||
input_path = Path(config.input_dir) / input_file
|
||||
print(f"[INFO] Input-Datei: {input_file}")
|
||||
|
||||
# ── Erstes Template ermitteln ─────────────────────────────────────
|
||||
template_files = get_available_templates(config)
|
||||
if not template_files:
|
||||
print("[FEHLER] Keine .pdf-Dateien im template/-Ordner gefunden.")
|
||||
return 1
|
||||
|
||||
template_file = template_files[0]
|
||||
template_path = Path(config.template_dir) / template_file
|
||||
print(f"[INFO] Template: {template_file}")
|
||||
|
||||
# ── Template validieren ───────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Validiere PDF-Template…")
|
||||
tpl_result = validate_template(template_path, config.output_mapping)
|
||||
if not tpl_result.is_valid:
|
||||
for err in tpl_result.errors:
|
||||
print(f"[FEHLER] {err}")
|
||||
return 1
|
||||
found = tpl_result.info.get("found_fields", "?")
|
||||
total = tpl_result.info.get("total_fields", "?")
|
||||
print(f"[OK] {found}/{total} Platzhalter im Template gefunden.")
|
||||
|
||||
# ── Input validieren ──────────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Validiere Input-Datei…")
|
||||
inp_result = validate_input_file(input_path, config.input_schema, config.month_sheet_pattern)
|
||||
if not inp_result.is_valid:
|
||||
for err in inp_result.errors:
|
||||
print(f"[FEHLER] {err}")
|
||||
return 1
|
||||
if inp_result.warnings:
|
||||
for warn in inp_result.warnings:
|
||||
print(f"[WARN] {warn}")
|
||||
|
||||
# ── Input parsen ──────────────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Parse Input-Datei…")
|
||||
records = parse_input_file(
|
||||
filepath=input_path,
|
||||
schema=config.input_schema,
|
||||
month_sheet_pattern=config.month_sheet_pattern,
|
||||
skip_empty_name=config.skip_empty_name,
|
||||
skip_empty_antragsnummer=config.skip_empty_antragsnummer,
|
||||
)
|
||||
|
||||
employees = get_unique_employees(records)
|
||||
print(f"[INFO] {len(records)} Einträge für {len(employees)} Personen gefunden.")
|
||||
|
||||
# ── Erste Person ermitteln ────────────────────────────────────────
|
||||
grouped = group_by_employee(records)
|
||||
first_person = sorted(grouped.keys())[0]
|
||||
first_records = grouped[first_person]
|
||||
print(f"[INFO] Erste Person: {first_person} ({len(first_records)} Monate)")
|
||||
|
||||
# ── Reports generieren ────────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Generiere Tätigkeitsnachweis…")
|
||||
single_person = {first_person: first_records}
|
||||
output_dir = Path(config.output_dir)
|
||||
generated = generate_activity_reports(
|
||||
employee_records=single_person,
|
||||
template_path=template_path,
|
||||
mapping=config.output_mapping,
|
||||
output_dir=output_dir,
|
||||
filename_pattern=config.filename_pattern,
|
||||
fontsize=config.fontsize,
|
||||
)
|
||||
|
||||
output_file = generated[0]
|
||||
print(f"[OK] Erstellt: {output_file.name}")
|
||||
print(f" Pfad: {output_file}")
|
||||
|
||||
# ── Datei öffnen (macOS) ──────────────────────────────────────────
|
||||
if sys.platform == "darwin":
|
||||
print()
|
||||
print("[INFO] Öffne Datei im Finder…")
|
||||
subprocess.run(["open", str(output_file)], check=False)
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print(" Fertig.")
|
||||
print("=" * 50)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{"column": "A", "field_name": "ID", "type": "number", "required": false},
|
||||
{"column": "B", "field_name": "Kommentar", "type": "string", "required": false},
|
||||
{"column": "C", "field_name": "Antragsnummer", "type": "string", "required": true},
|
||||
{"column": "D", "field_name": "Name", "type": "string", "required": true},
|
||||
{"column": "E", "field_name": "Qualifikation", "type": "string", "required": true},
|
||||
{"column": "F", "field_name": "Funktion", "type": "string", "required": true},
|
||||
{"column": "G", "field_name": "Stellenanteil in %", "type": "number", "required": true},
|
||||
{"column": "H", "field_name": "Monat (MM.JJ)", "type": "string", "required": true},
|
||||
{"column": "I", "field_name": "Tätigkeit", "type": "string", "required": true}
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{"source_field": "Antragsnummer", "pdf_field": "Antragsnummer_Field", "format": null},
|
||||
{"source_field": "Name", "pdf_field": "Name_Field", "format": null},
|
||||
{"source_field": "Stellenanteil in %", "pdf_field": "Stellenanteil_Field", "format": null},
|
||||
{"source_field": "Qualifikation", "pdf_field": "Qualifikation_Field", "format": null},
|
||||
{"source_field": "Monat (MM.JJ)", "pdf_field": "Monat_Field", "format": null},
|
||||
{"source_field": "Funktion", "pdf_field": "Funktion_Field", "format": null},
|
||||
{"source_field": "Tätigkeit", "pdf_field": "Tätigkeiten_Field", "format": null},
|
||||
{"source_field": "Datum", "pdf_field": "Datum_Field", "format": null}
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[paths]
|
||||
input_dir = input
|
||||
template_dir = template
|
||||
output_dir = output
|
||||
|
||||
[output]
|
||||
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.pdf
|
||||
|
||||
[font]
|
||||
# Statische Schriftgröße (in Punkt) für alle Felder im PDF
|
||||
fontsize = 9
|
||||
|
||||
[parsing]
|
||||
# Sheets whose names match these patterns are treated as monthly data sheets
|
||||
# Use * as wildcard. Sheets not matching are ignored (e.g. "Alle Rollen").
|
||||
month_sheet_pattern = * 20??
|
||||
|
||||
# Skip rows where the employee name is empty
|
||||
skip_empty_name = true
|
||||
|
||||
# Skip rows where Antragsnummer is missing
|
||||
skip_empty_antragsnummer = true
|
||||
@@ -0,0 +1,3 @@
|
||||
streamlit>=1.28,<2.0
|
||||
openpyxl>=3.1,<4.0
|
||||
PyMuPDF>=1.23,<2.0
|
||||
@@ -0,0 +1 @@
|
||||
"""Tätigkeitsnachweis-Generator package."""
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tätigkeitsnachweis-Generator – Konfigurations-Loader."""
|
||||
|
||||
import configparser
|
||||
import json
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .models import AppConfig, FieldSchema, OutputMapping
|
||||
|
||||
|
||||
def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
|
||||
"""Lädt settings.ini und gibt ein AppConfig-Objekt zurück."""
|
||||
ini_path = config_dir / "settings.ini"
|
||||
if not ini_path.exists():
|
||||
raise FileNotFoundError(f"settings.ini nicht gefunden: {ini_path}")
|
||||
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read(ini_path, encoding="utf-8")
|
||||
|
||||
paths = parser["paths"] if "paths" in parser else {}
|
||||
output = parser["output"] if "output" in parser else {}
|
||||
parsing = parser["parsing"] if "parsing" in parser else {}
|
||||
font = parser["font"] if "font" in parser else {}
|
||||
|
||||
return AppConfig(
|
||||
input_dir=str(project_root / paths.get("input_dir", "input")),
|
||||
template_dir=str(project_root / paths.get("template_dir", "template")),
|
||||
output_dir=str(project_root / paths.get("output_dir", "output")),
|
||||
filename_pattern=output.get("filename_pattern", "{last_name}_{first_name}_Tätigkeitsnachweis.xlsx"),
|
||||
month_sheet_pattern=parsing.get("month_sheet_pattern", "* 20??"),
|
||||
skip_empty_name=parsing.getboolean("skip_empty_name", True),
|
||||
skip_empty_antragsnummer=parsing.getboolean("skip_empty_antragsnummer", True),
|
||||
fontsize=font.getfloat("fontsize", 11.0),
|
||||
)
|
||||
|
||||
|
||||
def load_input_schema(config_dir: Path) -> list[FieldSchema]:
|
||||
"""Lädt input-fields.json."""
|
||||
schema_path = config_dir / "input-fields.json"
|
||||
if not schema_path.exists():
|
||||
raise FileNotFoundError(f"input-fields.json nicht gefunden: {schema_path}")
|
||||
|
||||
with open(schema_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return [FieldSchema(**item) for item in data]
|
||||
|
||||
|
||||
def load_output_mapping(config_dir: Path) -> list[OutputMapping]:
|
||||
"""Lädt output-fields.json."""
|
||||
mapping_path = config_dir / "output-fields.json"
|
||||
if not mapping_path.exists():
|
||||
raise FileNotFoundError(f"output-fields.json nicht gefunden: {mapping_path}")
|
||||
|
||||
with open(mapping_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return [OutputMapping(**item) for item in data]
|
||||
|
||||
|
||||
def get_available_files(directory: Path, extension: str = ".xlsx") -> list[str]:
|
||||
"""Listet alle Dateien mit der angegebenen Erweiterung im Verzeichnis auf."""
|
||||
if not directory.exists():
|
||||
return []
|
||||
files = sorted(
|
||||
f.name for f in directory.iterdir()
|
||||
if f.is_file() and f.suffix.lower() == extension and not f.name.startswith("~")
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def get_available_input_files(config: AppConfig) -> list[str]:
|
||||
"""Listet alle .xlsx-Dateien im input-Ordner."""
|
||||
return get_available_files(Path(config.input_dir))
|
||||
|
||||
|
||||
def get_available_templates(config: AppConfig) -> list[str]:
|
||||
"""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:
|
||||
"""Prüft, ob ein Sheet-Name dem Monatsmuster entspricht."""
|
||||
return fnmatch.fnmatch(sheet_name.strip(), pattern)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tätigkeitsnachweis-Generator – Datenmodelle."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmployeeRecord:
|
||||
"""Eine Zeile aus einem Monats-Sheet der Input-Datei."""
|
||||
name: str
|
||||
antragsnummer: str
|
||||
qualifikation: str
|
||||
funktion: str
|
||||
stellenanteil: str
|
||||
monat: str # z. B. "01.2025"
|
||||
taetigkeit: str
|
||||
id: Optional[int] = None
|
||||
kommentar: Optional[str] = None
|
||||
|
||||
@property
|
||||
def month_index(self) -> int:
|
||||
"""Extrahiere den Monatsindex (1–12) aus dem Monatsfeld."""
|
||||
try:
|
||||
return int(self.monat.split(".")[0])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
@property
|
||||
def year(self) -> int:
|
||||
"""Extrahiere das Jahr aus dem Monatsfeld."""
|
||||
try:
|
||||
return int(self.monat.split(".")[1])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldSchema:
|
||||
"""Definition eines Feldes aus input-fields.json."""
|
||||
column: str
|
||||
field_name: str
|
||||
type: str
|
||||
required: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputMapping:
|
||||
"""Definition eines Mappings aus output-fields.json."""
|
||||
source_field: str
|
||||
format: Optional[str] = None
|
||||
pdf_field: Optional[str] = None # Name des _Field-Platzhalters im PDF (z.B. "Auftragsnummer_Field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""Gesamte App-Konfiguration."""
|
||||
input_dir: str
|
||||
template_dir: str
|
||||
output_dir: str
|
||||
filename_pattern: str
|
||||
input_schema: list = field(default_factory=list)
|
||||
output_mapping: list = field(default_factory=list)
|
||||
month_sheet_pattern: str = "* 20??"
|
||||
skip_empty_name: bool = True
|
||||
skip_empty_antragsnummer: bool = True
|
||||
fontsize: float = 11.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Ergebnis einer Validierung."""
|
||||
is_valid: bool
|
||||
errors: list = field(default_factory=list)
|
||||
warnings: list = field(default_factory=list)
|
||||
info: dict = field(default_factory=dict)
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Input-Datei-Parser: liest monatliche Sheets und erzeugt EmployeeRecords."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
from .config import is_month_sheet
|
||||
from .models import EmployeeRecord, FieldSchema
|
||||
|
||||
|
||||
def _to_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _to_int_or_none(value: Any) -> Optional[int]:
|
||||
if value is None or str(value).strip() == "":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_percent_string(value: Any) -> str:
|
||||
"""Wandelt einen Prozentwert in einen String ohne %-Zeichen um.
|
||||
|
||||
Excel speichert Prozentwerte als Dezimalbruch (50 % = 0.5).
|
||||
Falls der Rohwert eine Zahl ≤ 1 ist, wird er als Excel-Prozentwert
|
||||
interpretiert und mit 100 multipliziert.
|
||||
"""
|
||||
if value is None or str(value).strip() == "":
|
||||
return ""
|
||||
# Falls es bereits ein String mit %-Zeichen ist
|
||||
if isinstance(value, str):
|
||||
return value.strip().rstrip("% ")
|
||||
# Numerischer Wert: Excel-Prozentwert (0.5 = 50 %) oder Ganzzahl (50 = 50 %)
|
||||
if isinstance(value, (int, float)):
|
||||
if 0 < value <= 1:
|
||||
return str(int(value * 100))
|
||||
if value == 0:
|
||||
return "0"
|
||||
# Wert > 1: bereits als Ganzzahl gemeint (z. B. 50)
|
||||
return str(int(value))
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _build_column_map(schema: list[FieldSchema]) -> dict[str, int]:
|
||||
"""Baut ein Mapping von field_name → 0-basierter Spaltenindex."""
|
||||
col_map: dict[str, int] = {}
|
||||
for field in schema:
|
||||
col_map[field.field_name] = column_index_from_string(field.column) - 1
|
||||
return col_map
|
||||
|
||||
|
||||
def parse_input_file(
|
||||
filepath: Path,
|
||||
schema: list[FieldSchema],
|
||||
month_sheet_pattern: str = "* 20??",
|
||||
skip_empty_name: bool = True,
|
||||
skip_empty_antragsnummer: bool = True,
|
||||
) -> list[EmployeeRecord]:
|
||||
"""
|
||||
Öffnet die Input-Datei, iteriert über alle Monats-Sheets
|
||||
und gibt eine Liste von EmployeeRecords zurück.
|
||||
"""
|
||||
wb = load_workbook(filepath, data_only=True)
|
||||
col_map = _build_column_map(schema)
|
||||
records: list[EmployeeRecord] = []
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
if not is_month_sheet(sheet_name, month_sheet_pattern):
|
||||
continue
|
||||
|
||||
ws = wb[sheet_name]
|
||||
# Header ist Zeile 1, Daten ab Zeile 2
|
||||
# min_col=1 explizit setzen für korrekte Spaltenzuordnung
|
||||
schema_col_count = len(schema)
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=1, max_col=schema_col_count, values_only=True):
|
||||
name = _to_text(row[col_map.get("Name", 0)]) if len(row) > col_map.get("Name", 0) else ""
|
||||
antragsnummer = _to_text(row[col_map.get("Antragsnummer", 0)]) if len(row) > col_map.get("Antragsnummer", 0) else ""
|
||||
|
||||
if skip_empty_name and not name:
|
||||
continue
|
||||
if skip_empty_antragsnummer and not antragsnummer:
|
||||
continue
|
||||
|
||||
qualifikation = _to_text(row[col_map.get("Qualifikation", 0)]) if len(row) > col_map.get("Qualifikation", 0) else ""
|
||||
funktion = _to_text(row[col_map.get("Funktion", 0)]) if len(row) > col_map.get("Funktion", 0) else ""
|
||||
stellenanteil_raw = row[col_map.get("Stellenanteil in %", 0)] if len(row) > col_map.get("Stellenanteil in %", 0) else None
|
||||
stellenanteil = _to_percent_string(stellenanteil_raw)
|
||||
monat = _to_text(row[col_map.get("Monat (MM.JJ)", 0)]) if len(row) > col_map.get("Monat (MM.JJ)", 0) else ""
|
||||
taetigkeit = _to_text(row[col_map.get("Tätigkeit", 0)]) if len(row) > col_map.get("Tätigkeit", 0) else ""
|
||||
record_id = _to_int_or_none(row[col_map.get("ID", 0)]) if len(row) > col_map.get("ID", 0) else None
|
||||
kommentar = _to_text(row[col_map.get("Kommentar", 1)]) if "Kommentar" in col_map and len(row) > col_map["Kommentar"] else None
|
||||
|
||||
records.append(EmployeeRecord(
|
||||
id=record_id,
|
||||
name=name,
|
||||
antragsnummer=antragsnummer,
|
||||
qualifikation=qualifikation,
|
||||
funktion=funktion,
|
||||
stellenanteil=stellenanteil,
|
||||
monat=monat,
|
||||
taetigkeit=taetigkeit,
|
||||
kommentar=kommentar,
|
||||
))
|
||||
|
||||
wb.close()
|
||||
return records
|
||||
|
||||
|
||||
def group_by_employee(records: list[EmployeeRecord]) -> dict[str, list[EmployeeRecord]]:
|
||||
"""Gruppiert Records nach Mitarbeitername."""
|
||||
groups: dict[str, list[EmployeeRecord]] = {}
|
||||
for record in records:
|
||||
if record.name not in groups:
|
||||
groups[record.name] = []
|
||||
groups[record.name].append(record)
|
||||
return groups
|
||||
|
||||
|
||||
def get_unique_antragsnummern(records: list[EmployeeRecord]) -> list[str]:
|
||||
"""Gibt eine sortierte Liste aller eindeutigen Antragsnummern zurück."""
|
||||
return sorted(set(r.antragsnummer for r in records if r.antragsnummer))
|
||||
|
||||
|
||||
def get_unique_employees(records: list[EmployeeRecord]) -> list[str]:
|
||||
"""Gibt eine sortierte Liste aller eindeutigen Mitarbeiternamen zurück."""
|
||||
return sorted(set(r.name for r in records if r.name))
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Validierung von Input-Dateien und Templates."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
from .config import is_month_sheet
|
||||
from .models import FieldSchema, OutputMapping, ValidationResult
|
||||
|
||||
|
||||
def validate_input_file(
|
||||
filepath: Path,
|
||||
schema: list[FieldSchema],
|
||||
month_sheet_pattern: str = "* 20??",
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validiert eine Input-Datei:
|
||||
- Datei existiert
|
||||
- Erwartete Spalten sind vorhanden (Header in Zeile 1)
|
||||
- Mindestens ein Monats-Daten-Sheet enthält Daten
|
||||
- Nicht-Monats-Sheets (z. B. "Alle Rollen") werden übersprungen
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
info: dict = {}
|
||||
|
||||
if not filepath.exists():
|
||||
errors.append(f"Input-Datei nicht gefunden: {filepath}")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
try:
|
||||
wb = load_workbook(filepath, data_only=True)
|
||||
except Exception as e:
|
||||
errors.append(f"Datei konnte nicht geöffnet werden: {e}")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
sheet_count = len(wb.sheetnames)
|
||||
info["sheet_count"] = sheet_count
|
||||
info["sheet_names"] = wb.sheetnames
|
||||
|
||||
# Prüfe nur Monats-Sheets (überspringe z. B. "Alle Rollen")
|
||||
schema_col_count = len(schema)
|
||||
sheets_with_data = 0
|
||||
for sheet_name in wb.sheetnames:
|
||||
if not is_month_sheet(sheet_name, month_sheet_pattern):
|
||||
continue
|
||||
|
||||
ws = wb[sheet_name]
|
||||
if ws.max_row < 2:
|
||||
continue # Kein Header + Daten
|
||||
|
||||
# min_col=1 explizit setzen, um Verschiebungen bei
|
||||
# ws.min_column > 1 zu vermeiden
|
||||
header_row = list(ws.iter_rows(
|
||||
min_row=1, max_row=1, min_col=1, max_col=schema_col_count,
|
||||
values_only=True,
|
||||
))[0]
|
||||
missing_cols = []
|
||||
for field in schema:
|
||||
col_idx = column_index_from_string(field.column) - 1
|
||||
if col_idx >= len(header_row):
|
||||
missing_cols.append(f"{field.column} ({field.field_name})")
|
||||
continue
|
||||
header_val = str(header_row[col_idx]).strip() if header_row[col_idx] is not None else ""
|
||||
if header_val != field.field_name:
|
||||
warnings.append(
|
||||
f"Sheet '{sheet_name}', Spalte {field.column}: "
|
||||
f"Erwartet '{field.field_name}', gefunden '{header_val}'"
|
||||
)
|
||||
|
||||
if not missing_cols:
|
||||
sheets_with_data += 1
|
||||
|
||||
# Zähle Datenzeilen
|
||||
data_rows = ws.max_row - 1
|
||||
info[f"rows_{sheet_name}"] = data_rows
|
||||
|
||||
info["sheets_with_data"] = sheets_with_data
|
||||
wb.close()
|
||||
|
||||
# ── Dateninhalts-Prüfung: leere Pflichtfelder ───────────────────
|
||||
required_fields = [f for f in schema if f.required]
|
||||
if required_fields and sheets_with_data > 0:
|
||||
# Mapping field_name → column index
|
||||
field_col_map: dict[str, int] = {}
|
||||
for f in required_fields:
|
||||
field_col_map[f.field_name] = column_index_from_string(f.column) - 1
|
||||
max_col = max(field_col_map.values()) + 1
|
||||
|
||||
# {field_name: {name1, name2, ...}} für leere Pflichtwerte
|
||||
empty_by_field: dict[str, set[str]] = {f.field_name: set() for f in required_fields}
|
||||
|
||||
wb2 = load_workbook(filepath, data_only=True)
|
||||
for sheet_name in wb2.sheetnames:
|
||||
if not is_month_sheet(sheet_name, month_sheet_pattern):
|
||||
continue
|
||||
ws = wb2[sheet_name]
|
||||
if ws.max_row < 2:
|
||||
continue
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=1, max_col=max_col, values_only=True):
|
||||
name_val = str(row[field_col_map.get("Name", 0)] or "").strip() if len(row) > field_col_map.get("Name", 0) else ""
|
||||
if not name_val:
|
||||
continue
|
||||
for f in required_fields:
|
||||
col_idx = field_col_map[f.field_name]
|
||||
if col_idx < len(row):
|
||||
cell_val = str(row[col_idx] or "").strip() if row[col_idx] is not None else ""
|
||||
if not cell_val:
|
||||
empty_by_field[f.field_name].add(name_val)
|
||||
wb2.close()
|
||||
|
||||
for field_name, names in empty_by_field.items():
|
||||
if names:
|
||||
sorted_names = sorted(names)
|
||||
total = len(sorted_names)
|
||||
preview = sorted_names[:5]
|
||||
suffix = " …" if total > 5 else ""
|
||||
warnings.append(
|
||||
f"Feld '{field_name}' ist leer bei {total} Person(en): "
|
||||
f"{', '.join(preview)}{suffix}"
|
||||
)
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if sheets_with_data == 0:
|
||||
errors.append("Kein Sheet mit den erwarteten Spalten gefunden.")
|
||||
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
info=info,
|
||||
)
|
||||
|
||||
|
||||
def validate_template(
|
||||
template_path: Path,
|
||||
mapping: list[OutputMapping],
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validiert ein PDF-Template:
|
||||
- Datei existiert
|
||||
- Alle _Field-Platzhalter (pdf_field) sind im PDF auf irgendeiner Seite vorhanden
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
info: dict = {}
|
||||
|
||||
if not template_path.exists():
|
||||
errors.append(f"Template-Datei nicht gefunden: {template_path}")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
try:
|
||||
doc = fitz.open(template_path)
|
||||
except Exception as e:
|
||||
errors.append(f"PDF-Template konnte nicht geöffnet werden: {e}")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
if doc.page_count == 0:
|
||||
errors.append("PDF-Template enthält keine Seiten.")
|
||||
doc.close()
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
found_count = 0
|
||||
missing_fields = []
|
||||
|
||||
for m in mapping:
|
||||
if m.pdf_field is None:
|
||||
continue # Kein Marker erwartet
|
||||
|
||||
# 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 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)}"
|
||||
)
|
||||
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
info=info,
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""PDF-basierte Activity-Report-Generierung.
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
if "," in cleaned:
|
||||
last_name, first_name = [p.strip() for p in cleaned.split(",", 1)]
|
||||
return first_name or "Unknown", last_name or "Unknown"
|
||||
parts = cleaned.split()
|
||||
if len(parts) >= 2:
|
||||
return parts[0], parts[-1]
|
||||
if len(parts) == 1:
|
||||
return parts[0], "Unknown"
|
||||
return "Unknown", "Unknown"
|
||||
|
||||
|
||||
def _format_value(value, fmt: str | None) -> str:
|
||||
"""Formatiert einen Wert gemäß Format-String."""
|
||||
if value is None:
|
||||
return ""
|
||||
if fmt is None:
|
||||
return str(value)
|
||||
if "%" in fmt:
|
||||
try:
|
||||
return fmt % float(value)
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _format_month_for_template(monat_str: str) -> str:
|
||||
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
|
||||
monat_str = monat_str.strip()
|
||||
parts = monat_str.split(".")
|
||||
if len(parts) == 2:
|
||||
month = parts[0]
|
||||
year = parts[1]
|
||||
if len(year) == 2:
|
||||
year = "20" + year
|
||||
return f"{month}.{year}"
|
||||
return monat_str
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Daten-Extraktion aus EmployeeRecord
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
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 %":
|
||||
try:
|
||||
value = int(float(str(record.stellenanteil).strip()))
|
||||
return f"{value},00"
|
||||
except (ValueError, TypeError):
|
||||
return str(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]
|
||||
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],
|
||||
fontsize: float,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""
|
||||
Scannt ALLE Seiten des PDF-Templates nach _Field-Platzhaltern.
|
||||
Rückgabe: dict[pdf_field] → Liste von {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, list[dict]] = {}
|
||||
|
||||
# Alle Vorkommen aller PDF-Feld-Marker finden (alle Seiten)
|
||||
for pdf_field, source_field in pdf_to_source.items():
|
||||
occurrences: list[dict] = []
|
||||
for page_num in range(doc.page_count):
|
||||
page = doc[page_num]
|
||||
found = page.search_for(pdf_field)
|
||||
for rect in found:
|
||||
occurrences.append({
|
||||
"source_field": source_field,
|
||||
"page_num": page_num,
|
||||
"rect": rect,
|
||||
"fontsize": fontsize,
|
||||
"fontname": "helv",
|
||||
})
|
||||
field_info[pdf_field] = occurrences
|
||||
|
||||
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, list[dict]],
|
||||
fontsize: float,
|
||||
) -> None:
|
||||
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
|
||||
# Mehrzeilige Felder, die als Textbox (mit Zeilenumbruch) behandelt werden
|
||||
MULTILINE_FIELDS = {"Tätigkeiten_Field", "Dienstreisen_Field"}
|
||||
TEXTBOX_WIDTH_PT = 400 # Breite der Textbox in Punkt
|
||||
|
||||
for m in mapping:
|
||||
if m.pdf_field is None:
|
||||
continue
|
||||
|
||||
occurrences = field_info.get(m.pdf_field, [])
|
||||
if not occurrences:
|
||||
continue # Feld nicht im Template gefunden
|
||||
|
||||
value = _get_field_value(record, employee_name, m.source_field)
|
||||
formatted = _format_value(value, m.format)
|
||||
|
||||
# Jedes Vorkommen des Feldes im PDF befüllen
|
||||
for info in occurrences:
|
||||
page_num = info["page_num"]
|
||||
if page_num >= len(pages):
|
||||
continue
|
||||
|
||||
page = pages[page_num]
|
||||
rect = info["rect"]
|
||||
|
||||
# Platzhalter redacten
|
||||
page.add_redact_annot(rect)
|
||||
page.apply_redactions()
|
||||
|
||||
if not formatted:
|
||||
continue
|
||||
|
||||
# Mehrzeilige Felder: Zeilenweise per insert_text schreiben
|
||||
if m.pdf_field in MULTILINE_FIELDS:
|
||||
# ... (unverändert)
|
||||
clean_text = "\n".join(
|
||||
line for line in formatted.split("\n") if line.strip()
|
||||
)
|
||||
if not clean_text:
|
||||
continue
|
||||
|
||||
lines = clean_text.split("\n")
|
||||
line_spacing = fontsize * 1.2
|
||||
|
||||
output_lines: list[str] = []
|
||||
for line in lines:
|
||||
max_width = TEXTBOX_WIDTH_PT
|
||||
words = line.split(" ")
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test_line = current_line + (" " if current_line else "") + word
|
||||
text_width = fitz.get_text_length(test_line, fontname="helv", fontsize=fontsize)
|
||||
if text_width <= max_width or not current_line:
|
||||
current_line = test_line
|
||||
else:
|
||||
output_lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
output_lines.append(current_line)
|
||||
|
||||
y = rect.y0 + fontsize
|
||||
for current_line in output_lines:
|
||||
page.insert_text(
|
||||
(rect.x0, y), current_line,
|
||||
fontname="helv", fontsize=fontsize,
|
||||
)
|
||||
y += line_spacing
|
||||
else:
|
||||
# Einzeilige Felder: vertikal zentriert platzieren
|
||||
mid_y = (rect.y0 + rect.y1) / 2
|
||||
baseline_y = mid_y + fontsize * 0.35
|
||||
page.insert_text(
|
||||
(rect.x0, baseline_y),
|
||||
formatted,
|
||||
fontname="helv",
|
||||
fontsize=fontsize,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Hauptfunktion: Reports generieren
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def generate_activity_reports(
|
||||
employee_records: dict[str, list[EmployeeRecord]],
|
||||
template_path: Path,
|
||||
mapping: list[OutputMapping],
|
||||
output_dir: Path,
|
||||
filename_pattern: str,
|
||||
fontsize: float = 11.0,
|
||||
) -> list[Path]:
|
||||
"""
|
||||
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] = []
|
||||
|
||||
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
|
||||
field_info = _scan_template_fields(template_path, mapping, fontsize)
|
||||
|
||||
for employee_name, records in employee_records.items():
|
||||
if not records:
|
||||
continue
|
||||
|
||||
first_name, last_name = _split_name_for_filename(employee_name)
|
||||
safe_first = first_name.replace(" ", "_").replace("/", "_")
|
||||
safe_last = last_name.replace(" ", "_").replace("/", "_")
|
||||
|
||||
filename = filename_pattern.format(first_name=safe_first, last_name=safe_last)
|
||||
output_path = output_dir / filename
|
||||
|
||||
sorted_records = sorted(records, key=lambda r: r.month_index)
|
||||
|
||||
# Ergebnis-PDF erstellen
|
||||
result_doc = fitz.open()
|
||||
|
||||
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)]
|
||||
|
||||
_fill_pdf_pages(pages, record, employee_name, mapping, field_info, fontsize)
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,48 @@
|
||||
@echo off
|
||||
title Tätigkeitsnachweis-Generator
|
||||
echo ========================================
|
||||
echo Tätigkeitsnachweis-Generator
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: Prüfe ob Python verfügbar ist
|
||||
where python >nul 2>nul
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [FEHLER] Python wurde nicht gefunden.
|
||||
echo Bitte installiere Python 3.9 oder neuer von https://python.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [INFO] Python gefunden:
|
||||
python --version
|
||||
|
||||
:: Prüfe ob venv existiert, sonst erstelle
|
||||
if not exist ".venv\Scripts\python.exe" (
|
||||
echo [INFO] Erstelle virtuelle Umgebung...
|
||||
python -m venv .venv
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [FEHLER] Konnte venv nicht erstellen.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
:: Aktiviere venv und installiere Abhängigkeiten
|
||||
call .venv\Scripts\activate.bat
|
||||
echo [INFO] Installiere Abhängigkeiten...
|
||||
pip install -r requirements.txt --quiet
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo [FEHLER] Konnte Abhängigkeiten nicht installieren.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Starte Streamlit
|
||||
echo.
|
||||
echo [INFO] Starte Tätigkeitsnachweis-Generator...
|
||||
echo [INFO] Die App öffnet sich im Browser unter http://localhost:8501
|
||||
echo.
|
||||
streamlit run app.py
|
||||
|
||||
pause
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Doppelklickbares Start-Script für macOS (.command wird von Finder automatisch im Terminal geöffnet)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "========================================"
|
||||
echo " Tätigkeitsnachweis-Generator"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python3"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
else
|
||||
echo "[FEHLER] Python wurde nicht gefunden."
|
||||
echo "Bitte installiere Python 3.9+ von https://python.org"
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Python: $("$PYTHON_BIN" --version)"
|
||||
|
||||
VENV_DIR=".venv"
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
if [[ -d "$VENV_DIR" ]]; then
|
||||
echo "[INFO] Erstelle venv neu..."
|
||||
rm -rf "$VENV_DIR"
|
||||
fi
|
||||
echo "[INFO] Erstelle virtuelle Umgebung..."
|
||||
"$PYTHON_BIN" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "[INFO] Installiere Abhängigkeiten..."
|
||||
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet
|
||||
|
||||
echo ""
|
||||
echo "[INFO] Starte Tätigkeitsnachweis-Generator..."
|
||||
echo "[INFO] Browser: http://localhost:8501"
|
||||
echo ""
|
||||
"$VENV_DIR/bin/streamlit" run app.py
|
||||
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Always run from this script's directory.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "========================================"
|
||||
echo " Tätigkeitsnachweis-Generator"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Python verfügbar ist
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python3"
|
||||
elif command -v python >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
else
|
||||
echo "[FEHLER] Python wurde nicht gefunden."
|
||||
echo "Bitte installiere Python 3.9 oder neuer von https://python.org"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Python gefunden:"
|
||||
"$PYTHON_BIN" --version
|
||||
|
||||
# Prüfe ob venv existiert, sonst erstelle
|
||||
VENV_DIR=".venv"
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
if [[ -d "$VENV_DIR" ]]; then
|
||||
echo "[INFO] Vorhandene venv ist nicht kompatibel. Erstelle neu..."
|
||||
rm -rf "$VENV_DIR"
|
||||
fi
|
||||
echo "[INFO] Erstelle virtuelle Umgebung..."
|
||||
"$PYTHON_BIN" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# Aktiviere venv und installiere Abhängigkeiten
|
||||
echo "[INFO] Installiere Abhängigkeiten..."
|
||||
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet
|
||||
|
||||
# Starte Streamlit
|
||||
echo ""
|
||||
echo "[INFO] Starte Tätigkeitsnachweis-Generator..."
|
||||
echo "[INFO] Die App öffnet sich im Browser unter http://localhost:8501"
|
||||
echo ""
|
||||
"$VENV_DIR/bin/streamlit" run app.py
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Doppelklickbares Test-Script für macOS (.command wird von Finder automatisch im Terminal geöffnet)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "========================================"
|
||||
echo " Tätigkeitsnachweis-Generator – Tests"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
VENV_DIR=".venv"
|
||||
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
echo "[FEHLER] Virtuelle Umgebung nicht gefunden."
|
||||
echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten."
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Stelle sicher, dass pytest installiert ist..."
|
||||
"$VENV_DIR/bin/pip" install pytest --quiet 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "[INFO] Führe End-to-End-Tests aus…"
|
||||
echo ""
|
||||
|
||||
"$VENV_DIR/bin/python" -m pytest tests/test_e2e.py -v
|
||||
|
||||
echo ""
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests für den Tätigkeitsnachweis-Generator."""
|
||||
Binary file not shown.
@@ -0,0 +1,220 @@
|
||||
"""End-to-End-Test: Reale Input-Datei + reales PDF-Template → PDF-Output für erste Person."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import pytest
|
||||
|
||||
# Projekt-Root zum Python-Path hinzufügen
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.config import load_settings, load_input_schema, load_output_mapping
|
||||
from src.parser import parse_input_file, group_by_employee, get_unique_employees
|
||||
from src.writer import generate_activity_reports
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Pfade zu den realen Fixture-Dateien
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx"
|
||||
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf"
|
||||
|
||||
|
||||
def _load_app_config():
|
||||
"""Lädt die echte App-Konfiguration (Schema, Mapping, Settings)."""
|
||||
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
|
||||
config.input_schema = load_input_schema(CONFIG_DIR)
|
||||
config.output_mapping = load_output_mapping(CONFIG_DIR)
|
||||
return config
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_config():
|
||||
"""Reale App-Konfiguration aus config/."""
|
||||
return _load_app_config()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parsed_records(app_config):
|
||||
"""Geparste Records aus der realen Input-Datei."""
|
||||
return parse_input_file(
|
||||
filepath=INPUT_FILE,
|
||||
schema=app_config.input_schema,
|
||||
month_sheet_pattern=app_config.month_sheet_pattern,
|
||||
skip_empty_name=app_config.skip_empty_name,
|
||||
skip_empty_antragsnummer=app_config.skip_empty_antragsnummer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def output_dir(tmp_path):
|
||||
"""Temporäres Output-Verzeichnis."""
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
return out
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Hilfsfunktion: Text aus PDF extrahieren
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _get_pdf_text(pdf_path: Path) -> str:
|
||||
"""Extrahiert den gesamten Text aus einem PDF."""
|
||||
doc = fitz.open(pdf_path)
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += page.get_text()
|
||||
doc.close()
|
||||
return text
|
||||
|
||||
|
||||
def _get_page_text(pdf_path: Path, page_num: int = 0) -> str:
|
||||
"""Extrahiert Text von einer bestimmten PDF-Seite."""
|
||||
doc = fitz.open(pdf_path)
|
||||
text = doc[page_num].get_text() if page_num < doc.page_count else ""
|
||||
doc.close()
|
||||
return text
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# End-to-End Tests
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class TestEndToEndFirstPerson:
|
||||
"""End-to-End: Reale Input-Datei + reales PDF-Template → nur erste Person."""
|
||||
|
||||
def test_input_file_exists(self):
|
||||
"""Stellt sicher, dass die reale Input-Datei vorhanden ist."""
|
||||
assert INPUT_FILE.exists(), f"Input-Datei nicht gefunden: {INPUT_FILE}"
|
||||
|
||||
def test_template_file_exists(self):
|
||||
"""Stellt sicher, dass das reale PDF-Template vorhanden ist."""
|
||||
assert TEMPLATE_FILE.exists(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
|
||||
|
||||
def test_template_has_field_markers(self, app_config):
|
||||
"""Stellt sicher, dass alle _Field-Platzhalter im PDF-Template vorhanden sind."""
|
||||
doc = fitz.open(TEMPLATE_FILE)
|
||||
missing = []
|
||||
for m in app_config.output_mapping:
|
||||
if m.pdf_field is None:
|
||||
continue
|
||||
# Suche auf allen Seiten
|
||||
found_any = False
|
||||
for page_num in range(doc.page_count):
|
||||
if doc[page_num].search_for(m.pdf_field):
|
||||
found_any = True
|
||||
break
|
||||
if not found_any:
|
||||
missing.append(m.pdf_field)
|
||||
doc.close()
|
||||
assert len(missing) == 0, (
|
||||
f"Folgende _Field-Platzhalter fehlen im PDF: {missing}"
|
||||
)
|
||||
|
||||
def test_parse_real_input(self, parsed_records):
|
||||
"""Validiert das Parsen der realen Input-Datei."""
|
||||
# Sollte 168 Einträge über 12 Monate für 19 Personen ergeben
|
||||
assert len(parsed_records) == 168, (
|
||||
f"Erwartet 168 Records, aber {len(parsed_records)} gefunden"
|
||||
)
|
||||
|
||||
employees = get_unique_employees(parsed_records)
|
||||
assert len(employees) == 19, (
|
||||
f"Erwartet 19 Mitarbeiter, aber {len(employees)} gefunden"
|
||||
)
|
||||
|
||||
# Erste Person alphabetisch: Draßdo, Cornelia
|
||||
assert employees[0] == "Draßdo, Cornelia", (
|
||||
f"Erwartet 'Draßdo, Cornelia' als erste Person, aber '{employees[0]}'"
|
||||
)
|
||||
|
||||
def test_full_pipeline_first_person(
|
||||
self, app_config, parsed_records, output_dir
|
||||
):
|
||||
"""Kompletter Workflow für die erste gefundene Person (Draßdo, Cornelia)."""
|
||||
# 1. Nach Mitarbeiter gruppieren
|
||||
grouped = group_by_employee(parsed_records)
|
||||
assert len(grouped) == 19
|
||||
|
||||
# 2. Erste Person alphabetisch
|
||||
first_person = sorted(grouped.keys())[0]
|
||||
assert first_person == "Draßdo, Cornelia"
|
||||
|
||||
first_records = grouped[first_person]
|
||||
# Draßdo hat 12 Einträge (einen pro Monat)
|
||||
assert len(first_records) == 12, (
|
||||
f"Erwartet 12 Records für {first_person}, aber {len(first_records)}"
|
||||
)
|
||||
|
||||
# 3. Reports NUR für die erste Person generieren
|
||||
single_person_group = {first_person: first_records}
|
||||
generated = generate_activity_reports(
|
||||
employee_records=single_person_group,
|
||||
template_path=TEMPLATE_FILE,
|
||||
mapping=app_config.output_mapping,
|
||||
output_dir=output_dir,
|
||||
filename_pattern=app_config.filename_pattern,
|
||||
)
|
||||
|
||||
# 4. Ergebnisse prüfen
|
||||
assert len(generated) == 1, (
|
||||
f"Erwartet 1 generierte Datei, aber {len(generated)}"
|
||||
)
|
||||
output_file = generated[0]
|
||||
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}"
|
||||
assert output_file.suffix == ".pdf"
|
||||
|
||||
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.pdf"
|
||||
assert output_file.name == erwarteter_name, (
|
||||
f"Erwartet '{erwarteter_name}', aber '{output_file.name}'"
|
||||
)
|
||||
|
||||
# 5. PDF öffnen und Inhalt validieren
|
||||
doc = fitz.open(output_file)
|
||||
assert doc.page_count == 24, (
|
||||
f"Erwartet 24 Seiten (12 Monate × 2 Seiten), aber {doc.page_count}"
|
||||
)
|
||||
|
||||
# 6. Prüfe, dass KEINE _Field-Platzhalter mehr im Output sind
|
||||
full_text = ""
|
||||
for page in doc:
|
||||
full_text += page.get_text()
|
||||
assert "_Field" not in full_text, (
|
||||
"Es sind noch _Field-Platzhalter im Output-PDF vorhanden!"
|
||||
)
|
||||
|
||||
# 7. Prüfe Seite 0 (Januar, Seite 1 – Stammdaten)
|
||||
jan0_text = doc[0].get_text()
|
||||
assert "100740439" in jan0_text, "Antragsnummer fehlt auf Januar-Seite 1"
|
||||
assert "Draßdo, Cornelia" in jan0_text, "Name fehlt auf Januar-Seite 1"
|
||||
assert "50" in jan0_text, "Stellenanteil fehlt auf Januar-Seite 1"
|
||||
assert "Meisterabschluß Küche" in jan0_text, "Qualifikation fehlt auf Januar-Seite 1"
|
||||
assert "01.2025" in jan0_text, "Monat fehlt auf Januar-Seite 1"
|
||||
assert "Anleiterin / Ausbilderin" in jan0_text, "Funktion fehlt auf Januar-Seite 1"
|
||||
assert "Tätigkeiten im Praxisbereich Garten" in jan0_text, "Tätigkeit fehlt auf Januar-Seite 1"
|
||||
|
||||
# 8. Prüfe Seite 1 (Januar, Seite 2 – Unterschriftsblock mit Ort/Datum)
|
||||
jan1_text = doc[1].get_text()
|
||||
assert "100740439" not in jan1_text, "Antragsnummer sollte nicht auf Seite 2 sein"
|
||||
assert "Dresden" in jan1_text, "Ort 'Dresden' fehlt auf Januar-Seite 2"
|
||||
assert "31.01.2025" in jan1_text, "Datum '31.01.2025' fehlt auf Januar-Seite 2"
|
||||
# Tätigkeit wiederholt auf Seite 2
|
||||
assert "Tätigkeiten im Praxisbereich Garten" in jan1_text, "Tätigkeit fehlt auf Januar-Seite 2"
|
||||
|
||||
# 9. Prüfe Juli (Seite 12+13) – Stellenanteil wechselt auf 40%
|
||||
jul0_text = doc[12].get_text()
|
||||
assert "40" in jul0_text, "Stellenanteil 40% fehlt auf Juli-Seite 1"
|
||||
assert "07.2025" in jul0_text, "Monat 07.2025 fehlt auf Juli-Seite 1"
|
||||
|
||||
jul1_text = doc[13].get_text()
|
||||
assert "31.07.2025" in jul1_text, "Datum 31.07.2025 fehlt auf Juli-Seite 2"
|
||||
|
||||
doc.close()
|
||||
Reference in new issue
Block a user