move the whole app in subfolder

This commit is contained in:
Rob committed 2026-06-30 15:34:47 +02:00
1 parent fa3e99dec0
commit 94e245a3a0
27 files changed

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
input/
output/
.venv/
__pycache__/
.DS_Store
.~lock.*#
*.xlsx#
~*.xlsx
/versions
+72
View File
@@ -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
+107
View File
@@ -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)
+46
View File
@@ -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
+27
View File
@@ -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..."
+137
View File
@@ -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())
+11
View File
@@ -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}
]
+10
View File
@@ -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}
]
+22
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
streamlit>=1.28,<2.0
openpyxl>=3.1,<4.0
PyMuPDF>=1.23,<2.0
+1
View File
@@ -0,0 +1 @@
"""Tätigkeitsnachweis-Generator package."""
+85
View File
@@ -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)
+75
View File
@@ -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)
+134
View File
@@ -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))
+206
View File
@@ -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,
)
+289
View File
@@ -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
+48
View File
@@ -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
+46
View File
@@ -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..."
+47
View File
@@ -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
+33
View File
@@ -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..."
+1
View File
@@ -0,0 +1 @@
"""Tests für den Tätigkeitsnachweis-Generator."""
+220
View File
@@ -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()