14 Commits
Author SHA1 Message Date
Rob 23cb3c35b3 version v1.1 2026-06-24 08:38:50 +02:00
Rob dbbd86ece0 add: changelog zum Plan 2026-06-24 08:35:31 +02:00
Rob 557f987ee1 fix konsole error message on app start 2026-06-24 08:14:54 +02:00
Rob b084120c0d fix: empty field: Qualifikation and Funktion 2026-06-24 08:14:29 +02:00
Rob 9f0e3cc8fc fix: nur die erste Person hatte im Tätigkeitenfeld etwas stehen 2026-06-23 12:40:29 +02:00
Rob 932585882e Merge branch 'pdf-as-template'
# Conflicts:
#	template/Tätigkeitsnachweise Stellenförderung.pdf
2026-06-23 11:54:09 +02:00
Rob 3e9cd39859 unnötiges Feld entfernt 2026-06-23 11:51:00 +02:00
Rob 9bdd90c073 ouput pdf: text styling 2026-06-23 11:50:44 +02:00
Rob e50f423304 input validierung für Qualifikationsfeld. Wenn keine Daten -> Platzhalter im template löschen 2026-06-23 11:49:54 +02:00
Rob ef219e00ba add; coldrun.bat 2026-06-23 11:00:36 +02:00
Rob 30f3ffaded neues cleanes template.pdf 2026-06-23 09:58:32 +02:00
Rob a4a678d11e coldrun: jetzt auch mit validierung 2026-06-16 21:57:18 +02:00
Rob 7a6d35d8a1 add: leere pdf als template 2026-06-16 21:56:58 +02:00
trnq1ll0 169d2167e0 Kompletter Umbau in einem Rutsch auf PDF als template Grundlage 2026-06-16 20:46:45 +02:00
18 changed files with 681 additions and 288 deletions

No files matched your search

+86 -65
View File
@@ -1,24 +1,37 @@
# Ziel
Eine Python + Streamlit App für Windows, die aus einer Input-Excel-Datei (ein Sheet pro Monat mit Mitarbeiterdaten) und einem Excel-Template pro Mitarbeiter eine Excel-Datei generiert (ein Sheet pro Monat, befüllt mit dessen Daten).
Eine Python + Streamlit App für Windows, die aus einer Input-Excel-Datei (ein Sheet pro Monat mit Mitarbeiterdaten) und einem **PDF-Template** pro Mitarbeiter eine **PDF-Datei** generiert (eine Seite pro Monat, befüllt mit dessen Daten).
**Ansatz**: Verwendung von openpyxl für Single-Input-File + Streamlit-UI.
**Ansatz**: openpyxl für Input-Parsing + PyMuPDF (fitz) für PDF-Generierung + Streamlit-UI.
> **Hintergrund**: Das ursprüngliche Excel-Template war teilweise schreibgeschützt und konnte nicht mit openpyxl befüllt werden.
> Daher wurde das Template als PDF exportiert. Im PDF wurden alle zu befüllenden Textstellen mit einem Platzhalter
> im Format `{Feldname}_Field` markiert (z. B. `Antragsnummer_Field`, `Name_Field`). Die App sucht nach diesen
> `_Field`-Markern und ersetzt sie durch die echten Daten.
# Aufgabe
Erstelle eine Windows App in dessen Oberfläche aus folgenden Bestandteilen aufgebaut ist:
- mittels einem DropDown-Menü kann eine Datei aus dem inputfolder gewählt werden
- mittels einem DropDown-Menü kann ein Template aus dem Templatefolder gewählt werden
- mittels einem DropDown-Menü kann ein PDF-Template aus dem Templatefolder gewählt werden
- ein Button um den Vorgang zu starten
Die Funktion der App besteht darin, das Templatefile mit Daten aus dem Input-File zu befüllen.
Im Inputfile ist für alle Monate eines Jahres eine Liste aller Mitarbeitenden und in den Spalten alle Daten zur Befüllung des Template files.
Die Funktion der App besteht darin, das Template-PDF mit Daten aus dem Input-File zu befüllen.
Im Inputfile ist für alle Monate eines Jahres eine Liste aller Mitarbeitenden und in den Spalten alle Daten zur Befüllung des Templates.
# Aufbau Input Datei
- In der Input Datei ist für jeden Monat eines Jahres ein Sheet gespeichert.
- In jedem Sheet befindet sich eine Tabelle (felder speichern in Input Schema json) mit Mitarbeitenden.
- Für jeden Mitarbeitenden soll aus dem Template eine neue ExcelDatei erzeugt werden, welche auch wieder für jeden Monat ein Sheet des Templates enthält und alle Daten des Mitarbeiters für dieses Monat aus der Inputliste enthält.
- Für jeden Mitarbeitenden wird ein PDF erzeugt, das für jeden Monat eine Seite enthält, befüllt mit den Daten des Mitarbeiters für diesen Monat.
# Aufbau PDF-Template
- Das PDF-Template ist eine einseitige PDF-Datei, generiert aus der ursprünglichen XLSX-Vorlage.
- Jede zu befüllende Textstelle enthält einen Platzhalter: den Feldnamen gefolgt von `_Field`
(z. B. `Antragsnummer_Field`, `Name_Field`, `Stellenanteil in %_Field`, …).
- Die App sucht im PDF nach allen `_Field`-Texten, ermittelt deren Position und Schriftgröße,
und ersetzt sie durch die tatsächlichen Datenwerte.
# README
@@ -30,15 +43,14 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
### Phase 1: Projekt-Grundgerüst & Konfiguration
1. **`requirements.txt` befüllen** — `streamlit`, `openpyxl`
1. **`requirements.txt` befüllen** — `streamlit`, `openpyxl`, `PyMuPDF`
2. **`config/settings.ini` befüllen** — Pfade, Standardwerte
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern)
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern → `.pdf`)
3. **`config/input-fields.json` Schema definieren** — Spalten der Input-Tabelle mit ihren exakten Überschriften
- Wird durch Analyse der Datei `input/2025 JM.xlsx` ermittelt: Spaltenposition (A, B, C…) und die exakte Spaltenüberschrift aus Zeile 1 der jeweiligen Sheets
- JSON-Array mit `column` (Spaltenbuchstabe), `field_name` (exakte Überschrift aus der Excel-Tabelle), `type` (string|number|date), `required` (true|false)
4. **`config/output-fields.json` Mapping definieren** — Welche Input-Felder (exakte Spaltenüberschrift) in welche Template-Zellen geschrieben werden
- JSON-Array mit `source_field` (exakte Spaltenüberschrift aus der Input-Tabelle, wie in input-fields.json definiert), `target_cell` (Zelladresse im Template, z.B. "A17"), `format` (optional, z.B. "dd.mm.yyyy")
- Muss durch Analyse des Templates `template/Tätigkeitsnachweise Stellenförderung.xlsx` ermittelt werden (welche Zellen sind zu befüllen?)
4. **`config/output-fields.json` Mapping definieren** — Welche Input-Felder in das PDF geschrieben werden
- JSON-Array mit `source_field` (exakte Spaltenüberschrift aus der Input-Tabelle), `format` (optional, z. B. "dd.mm.yyyy")
- Der Platzhalter im PDF ist immer `{source_field}_Field` – kein explizites `target_cell` mehr nötig
### Phase 2: Core-Module (`src/`)
@@ -50,62 +62,52 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
- `load_input_schema()`: liest `config/input-fields.json`
- `load_output_mapping()`: liest `config/output-fields.json`
- `get_available_input_files()`: listet `.xlsx`-Dateien in `input/`
- `get_available_templates()`: listet `.xlsx`-Dateien in `template/`
7. **`src/parser.py`** — Input-Datei einlesen
- `get_available_templates()`: listet `.pdf`-Dateien in `template/`
7. **`src/parser.py`** — Input-Datei einlesen (unverändert: openpyxl für XLSX-Input)
- `parse_input_file(filepath, schema)`: öffnet Workbook, iteriert über alle Sheets (Monate)
- Extrahiert Monat/Jahr aus Sheet-Namen oder Sheet-Inhalt
- Für jedes Sheet: liest Zeilen anhand des Schemas, erzeugt `EmployeeRecord`-Liste
- Rückgabe: `dict[str, list[EmployeeRecord]]` (key = Mitarbeiter-Name)
- Rückgabe: `list[EmployeeRecord]`
### Phase 3: Writer & Validierung
### Phase 3: Writer & Validierung (PDF-basiert)
8. **`src/writer.py`** — Output generieren
8. **`src/writer.py`** — PDF-Output generieren (NEU: PyMuPDF)
- `generate_activity_reports(employee_records, template_path, mapping, config)`:
- **Pre-Scan**: Öffnet das Template-PDF einmal und sucht nach allen `_Field`-Platzhaltern,
ermittelt deren Position (bounding box) und Schriftgröße
- Gruppiert Records nach Mitarbeiter
- Für jeden Mitarbeiter: lädt Template, erstellt für jeden Monat ein Sheet (kopiert Template-Sheet)
- Befüllt Zielzellen gemäß output-fields.json Mapping
- Benennt Sheets nach Monat (z.B. "01_Januar", "02_Februar", …)
- Speichert als `{nachname}_{vorname}_{YYYY}_Tätigkeitsnachweis.xlsx` in `output/`
9. **`src/validator.py`** — Eingabevalidierung
- `validate_input_file(filepath, schema)`: prüft Datei-Existenz, Sheet-Anzahl, Spalten laut Schema
- `validate_template(template_path, mapping)`: prüft ob alle target_cells im Template existieren
- Für jeden Mitarbeiter:
- Erstellt ein neues PDF
- Für jeden Monats-Record: fügt eine Kopie der Template-Seite ein
- Ersetzt alle `_Field`-Platzhalter durch die tatsächlichen Daten (redact + insert_text)
- Speichert als `{nachname}_{vorname}_Tätigkeitsnachweis.pdf` in `output/`
9. **`src/validator.py`** — Eingabevalidierung (angepasst für PDF)
- `validate_input_file(filepath, schema)`: unverändert (prüft XLSX-Input)
- `validate_template(template_path, mapping)`: prüft ob alle `_Field`-Platzhalter im PDF gefunden werden
### Phase 4: Streamlit UI
10. **`app.py`** — Streamlit-Oberfläche
10. **`app.py`** — Streamlit-Oberfläche (angepasst für PDF-Templates)
- Layout:
- Titel + Beschreibung
- `st.selectbox` für Input-Datei (aus `input/`-Folder)
- **Sofort bei Auswahl**: Validierung + Parsing der Datei
- Anzeige: Anzahl gefundener Mitarbeiter, Auflistung aller enthaltenen Antragsnummern
- Bei Fehlern: `st.error` mit Details
- `st.selectbox` für Template (aus `template/`-Folder)
- **Sofort bei Auswahl**: Validierung des Templates gegen `output-fields.json`
- Anzeige: Anzahl gefundener Zielfelder (target_cells), fehlende/fehlerhafte Zellen
- Bei Fehlern: `st.warning` mit Details
- `st.selectbox` für PDF-Template (aus `template/`-Folder)
- **Sofort bei Auswahl**: Validierung des PDF-Templates (Prüfung aller `_Field`-Marker)
- Anzeige: Anzahl gefundener Platzhalter-Felder
- **Mitarbeiter-Liste** (erscheint nach erfolgreicher Input-Validierung)
- `st.multiselect` mit allen gefundenen Mitarbeiternamen, standardmäßig alle vorselektiert
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
- Zeigt: „X von Y Mitarbeitern ausgewählt"
- `st.button("Tätigkeitsnachweise generieren")`
- Bei Button-Click:
1. Filtere Employee Records auf selektierte Mitarbeiter
2. Generiere Activity Reports nur für selektierte Mitarbeiter und speichere in `output/`
3. Zeige Erfolgsmeldung mit Liste der erstellten Dateien via `st.success`
- Status-Anzeige mit `st.spinner` und `st.progress`
2. Generiere PDFs nur für selektierte Mitarbeiter und speichere in `output/`
3. Zeige Erfolgsmeldung mit Liste der erstellten PDF-Dateien
### Phase 5: Start-Skript & Doku
11. **`start.bat`** — Windows Batch-Startdatei
- Prüft ob Python verfügbar ist
- Installiert Requirements (falls nötig)
- Startet `streamlit run app.py`
11. **`start.bat`** — Windows Batch-Startdatei (unverändert)
12. **`README.md` aktualisieren** — Vollständige Doku
- Setup-Anleitung (Python, venv, pip install)
- Konfiguration (settings.ini, input-fields.json, output-fields.json)
- Bedienung (Datei auswählen, Template wählen, Button klicken)
- Erwartetes Input-Format (Spalten, monatliche Sheets)
- Output-Beschreibung (Dateiname, Struktur)
---
@@ -113,19 +115,18 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
| Datei | Aktion |
|-------|--------|
| `requirements.txt` | Neu befüllen |
| `config/settings.ini` | Neu befüllen |
| `config/input-fields.json` | Neu befüllen (nach Analyse Input-Datei) |
| `config/output-fields.json` | Neu befüllen (nach Analyse Template) |
| `src/__init__.py` | Neu (leer) |
| `src/models.py` | Neu |
| `src/config.py` | Neu |
| `src/parser.py` | Neu |
| `src/writer.py` | Neu |
| `src/validator.py` | Neu |
| `app.py` | Neu |
| `start.bat` | Neu |
| `README.md` | Überschreiben mit vollständiger Doku |
| `requirements.txt` | `PyMuPDF` hinzufügen |
| `config/settings.ini` | filename_pattern auf `.pdf` ändern |
| `config/output-fields.json` | `target_cell` entfernen (wird durch `_Field`-Marker ersetzt) |
| `src/writer.py` | Komplett neu: PDF-basierte Generierung mit PyMuPDF |
| `src/validator.py` | `validate_template` für PDF anpassen |
| `src/config.py` | `get_available_templates` auf `.pdf` umstellen |
| `app.py` | Template-Validierung für PDF anpassen |
| `coldrun.py` | An PDF-Template anpassen |
| `tests/test_e2e.py` | Tests für PDF-Output anpassen |
| `.copilot/PLAN.md` | Dieser Plan – aktualisiert |
| `.copilot/TASKS.md` | Aufgabenliste – aktualisiert |
| `CHANGELOG.md` | Changelog – wird bei jedem Version-Build erweitert |
## Projektstruktur
@@ -139,25 +140,45 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
├── config/
│ ├── settings.ini ← Einstellungen der App
│ ├── input-fields.json ← Schema der Inputdaten-Tabelle
│ └── output-fields.json ← Schema der Outputdaten-Tabelle
├── output/ ← Zielordner für generierte Dateien
│ └── output-fields.json ← Mapping: Input-Feld → PDF-Platzhalter
├── output/ ← Zielordner für generierte PDF-Dateien
├── template/
│ └── *.pdf ← PDF-Template mit _Field-Platzhaltern
├── src/
│ ├── __init__.py
│ ├── models.py
│ ├── config.py
│ ├── parser.py
│ ├── writer.py
│ ├── writer.py ← PDF-basierte Generierung (PyMuPDF)
│ └── validator.py
├── app.py ← Streamlit-App
├── coldrun.py ← CLI Cold-Run
├── start.bat ← Windows batch zum Start der App
├── README.md ← README für Programmbenutzer
└── requirements.txt ← streamlit, openpyxl
├── CHANGELOG.md ← Changelog (wird bei jedem Version-Build erweitert)
└── requirements.txt ← streamlit, openpyxl, PyMuPDF
```
## Versionierung & Changelog
Immer wenn das Signal zum Build einer neuen Version gegeben wird (z. B. „Build Version X.Y“ oder „neue Version bauen“), wird das **Changelog-Logfile** (`CHANGELOG.md` im Projekt-Root) um die **most recent changes** erweitert. Der Eintrag enthält:
- **Versionsnummer** (z. B. `v1.2.0`)
- **Datum** des Builds
- **Neue Funktionen** (Features)
- **Behobene Fehler** (Bugfixes)
- **Sonstige Änderungen** (z. B. Refactoring, Doku-Updates)
Der Changelog wird im [Keep a Changelog](https://keepachangelog.com/)-Format gepflegt und nach Semantic Versioning (`MAJOR.MINOR.PATCH`) versioniert.
> **Wichtig**: Der Changelog-Eintrag wird **vor** dem eigentlichen Build-Vorgang erstellt, sodass die neue Version dokumentiert ist, bevor der Code ausgeliefert wird.
---
## Verifikation
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, exakte Spaltenüberschriften aus Zeile 1 jedes Sheets auslesen → `input-fields.json` ableiten (Spaltenbuchstabe + original Feldname)
2. **Template-Analyse**: `Tätigkeitsnachweise Stellenförderung.xlsx` öffnen, belegte/freie Zellen inspizieren, die befüllt werden sollen → `output-fields.json` ableiten (Mapping: Input-Spaltenüberschrift → Template-Zelladresse)
3. **Unit-Test**: App mit vorhandener Input-Datei und Template ausführen, prüfen ob Output-Dateien korrekt befüllt sind
4. **Edge Cases**: Leere Input-Zeilen, fehlende Monate, Mitarbeiter ohne Vorname, Sonderzeichen in Namen
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
2. **PDF-Template-Analyse**: `Tätigkeitsnachweise Stellenförderung.pdf` mit PyMuPDF öffnen, alle `_Field`-Platzhalter suchen → mit `output-fields.json` abgleichen
3. **Unit-Test**: App mit vorhandener Input-Datei und PDF-Template ausführen, prüfen ob Output-PDFs korrekt befüllt sind
4. **Edge Cases**: Leere Input-Zeilen, fehlende Monate, Mitarbeiter ohne Vorname, Sonderzeichen in Namen, Umlaute im PDF
```
+42 -22
View File
@@ -1,27 +1,47 @@
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App)
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App) – PDF-Migration
## 🔍 Analyse ✅
- [x] **A1**: Input-Datei `input/2025 JM.xlsx` analysieren → Sheet-Namen, exakte Spaltenüberschriften (Zeile 1), Spaltenpositionen (A, B, C…), Beispiel-Daten
- [x] **A2**: Template `template/Tätigkeitsnachweise Stellenförderung.xlsx` analysieren → zu befüllende Zielzellen identifizieren (Zelladresse + erwarteter Inhalt)
> **Migration**: Das XLSX-Template war schreibgeschützt und wurde durch ein PDF-Template ersetzt.
> Alle `_Field`-Platzhalter im PDF werden durch echte Daten ersetzt.
## ⚙️ Konfiguration ✅
- [x] **K1**: `config/input-fields.json` mit Spaltenschema befüllen — `column` (A, B…), `field_name` (exakte Überschrift aus Excel), `type`, `required`
- [x] **K2**: `config/output-fields.json` mit Zell-Mappings befüllen — `source_field` (exakte Spaltenüberschrift aus input-fields.json), `target_cell`, `format`
- [x] **K3**: `config/settings.ini` mit Pfaden und Einstellungen befüllen
## 🔄 Migration: XLSX → PDF Template ✅
## 🧱 Core-Module ✅
- [x] **C1**: `requirements.txt` befüllen (streamlit, openpyxl)
- [x] **C2**: `src/__init__.py` + `src/models.py` — Datenklassen
- [x] **C3**: `src/config.py` — Konfigurations-Loader
- [x] **C4**: `src/parser.py` — Input-Datei parsen
- [x] **C5**: `src/validator.py` — Eingabevalidierung
- [x] **C6**: `src/writer.py` — Output generieren
- [x] **M1**: `requirements.txt` → `PyMuPDF` hinzufügen
- [x] **M2**: `config/settings.ini` → `filename_pattern` auf `.pdf`-Endung ändern
- [x] **M3**: `config/output-fields.json` → `target_cell` entfernen, `pdf_field` hinzufügen (Mapping source_field → PDF-Platzhalter-Name)
- [x] **M4**: `src/config.py` → `get_available_templates()` auf `.pdf`-Dateien umstellen
- [x] **M5**: `src/validator.py` → `validate_template()` für PDF angepasst (prüft `_Field`-Platzhalter via PyMuPDF)
- [x] **M6**: `src/writer.py` → Komplett neu: PDF-basierte Generierung mit PyMuPDF
- Pre-Scan: Alle `_Field`-Platzhalter im Template-PDF finden (Position, Schriftgröße)
- Für jeden Mitarbeiter: neues PDF, pro Monat eine Seite (Template-Kopie), `_Field`-Texte redacten + ersetzen
- Dateiname: `{nachname}_{vorname}_Tätigkeitsnachweis.pdf`
- [x] **M7**: `app.py` → Template-Validierung für PDF angepasst (Dropdown zeigt `.pdf`, Validierung prüft `_Field`-Marker)
- [x] **M8**: `coldrun.py` → An PDF-Template angepasst
- [x] **M9**: `tests/test_e2e.py` → Tests für PDF-Output umgeschrieben (PyMuPDF statt openpyxl zur Output-Validierung)
- [x] **M10**: `.copilot/PLAN.md` und `.copilot/TASKS.md` aktualisiert
## 🖥️ UI & Start ✅
- [x] **U1**: `app.py` — Streamlit-Oberfläche (Input-Selektion: validieren & Mitarbeiteranzahl + Antragsnummern anzeigen; Template-Selektion: validieren & Zielfelder anzeigen; Mitarbeiter-Multiselect zum Ein-/Ausschließen)
- [x] **U2**: `start.bat` — Windows Batch-Startdatei
- [x] **U3**: `README.md` — vollständige Dokumentation
## 🔍 Analyse (abgeschlossen)
- [x] **A1**: Input-Datei `input/2025 JM.xlsx` analysiert
- [x] **A2**: PDF-Template `template/Tätigkeitsnachweise Stellenförderung.pdf` liegt vor (mit `_Field`-Platzhaltern)
## ✅ Test ✅
- [x] **T1**: App mit vorhandener Input-Datei und Template testen — 19 Tätigkeitsnachweise erfolgreich generiert
- [x] **T2**: Edge Cases prüfen (leere Zeilen, Sonderzeichen, fehlende Monate) — Umlaute, Sonderzeichen in Namen, "Alle Rollen"-Sheet korrekt behandelt
## ⚙️ Konfiguration (migriert)
- [x] **K1**: `config/input-fields.json` — unverändert gültig
- [x] **K2**: `config/output-fields.json` — `target_cell` entfernt, `pdf_field` hinzugefügt
- [x] **K3**: `config/settings.ini` — filename_pattern auf `.pdf`
## 🧱 Core-Module
- [x] **C1**: `requirements.txt` — streamlit, openpyxl, PyMuPDF
- [x] **C2**: `src/__init__.py` + `src/models.py` — `OutputMapping` um `pdf_field` erweitert
- [x] **C3**: `src/config.py` — Template-Suche auf `.pdf` umgestellt
- [x] **C4**: `src/parser.py` — unverändert
- [x] **C5**: `src/validator.py` — `validate_template()` für PDF via PyMuPDF
- [x] **C6**: `src/writer.py` — Komplett neu mit PyMuPDF (redact + insert_text)
## 🖥️ UI & Start
- [x] **U1**: `app.py` — PDF-Template-Validierung angepasst
- [x] **U2**: `start.bat` — unverändert
- [x] **U3**: `coldrun.py` — An PDF angepasst, funktioniert
- [x] **U4**: `README.md` — vorhanden
## ✅ Test
- [x] **T1**: E2E-Test mit realer Input-Datei und PDF-Template → 5/5 Tests bestanden
- [x] **T2**: Edge Cases — Umlaute korrekt im PDF, fehlende Marker (Ort/Datum) werden übersprungen
+1
View File
@@ -6,3 +6,4 @@ __pycache__/
.~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
+5 -5
View File
@@ -157,7 +157,7 @@ with col2:
st.subheader("2. Template")
template_files = get_available_templates(app_config)
if not template_files:
st.warning("Keine .xlsx-Dateien im template/-Ordner gefunden.")
st.warning("Keine .pdf-Dateien im template/-Ordner gefunden.")
selected_template = None
else:
# Lock-Files herausfiltern
@@ -202,9 +202,9 @@ with col2:
st.session_state.template_validated = False
else:
st.session_state.template_validated = True
found = tpl_result.info.get("found_target_cells", 0)
total = tpl_result.info.get("total_target_cells", 0)
st.success(f"✅ {found}/{total} Zielfelder gefunden")
found = tpl_result.info.get("found_fields", 0)
total = tpl_result.info.get("total_fields", 0)
st.success(f"✅ {found}/{total} Platzhalter-Felder gefunden")
if tpl_result.warnings:
st.markdown(
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
@@ -239,7 +239,7 @@ if st.session_state.input_validated and st.session_state.parsed_records:
key = f"sel_{emp}"
if key not in st.session_state:
st.session_state[key] = True
if st.checkbox(emp, value=st.session_state[key], key=key):
if st.checkbox(emp, key=key):
selected_employees.append(emp)
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
+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
+26 -1
View File
@@ -22,6 +22,7 @@ from src.config import (
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
@@ -50,13 +51,37 @@ def main():
# ── Erstes Template ermitteln ─────────────────────────────────────
template_files = get_available_templates(config)
if not template_files:
print("[FEHLER] Keine .xlsx-Dateien im template/-Ordner gefunden.")
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…")
+8 -10
View File
@@ -1,12 +1,10 @@
[
{"source_field": "Antragsnummer", "target_cell": "B3", "format": null},
{"source_field": "Name", "target_cell": "B17", "format": null},
{"source_field": "Stellenanteil in %", "target_cell": "R17", "format": null},
{"source_field": "Qualifikation", "target_cell": "B21", "format": null},
{"source_field": "Monat (MM.JJ)", "target_cell": "R21", "format": null},
{"source_field": "Funktion", "target_cell": "B25", "format": null},
{"source_field": "Tätigkeit", "target_cell": "B31", "format": null},
{"source_field": "Ort", "target_cell": "B95", "format": null},
{"source_field": "Ort 2", "target_cell": "B103", "format": null},
{"source_field": "Datum", "target_cell": "J95", "format": null}
{"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}
]
+1 -1
View File
@@ -4,7 +4,7 @@ template_dir = template
output_dir = output
[output]
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.xlsx
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.pdf
[parsing]
# Sheets whose names match these patterns are treated as monthly data sheets
+1
View File
@@ -1,2 +1,3 @@
streamlit>=1.28,<2.0
openpyxl>=3.1,<4.0
PyMuPDF>=1.23,<2.0
+2 -2
View File
@@ -74,8 +74,8 @@ def get_available_input_files(config: AppConfig) -> list[str]:
def get_available_templates(config: AppConfig) -> list[str]:
"""Listet alle .xlsx-Dateien im template-Ordner."""
return get_available_files(Path(config.template_dir))
"""Listet alle .pdf-Dateien im template-Ordner."""
return get_available_files(Path(config.template_dir), extension=".pdf")
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
+1 -1
View File
@@ -47,8 +47,8 @@ class FieldSchema:
class OutputMapping:
"""Definition eines Mappings aus output-fields.json."""
source_field: str
target_cell: str
format: Optional[str] = None
pdf_field: Optional[str] = None # Name des _Field-Platzhalters im PDF (z.B. "Auftragsnummer_Field")
@dataclass
+81 -23
View File
@@ -3,6 +3,7 @@
from pathlib import Path
from typing import Optional
import fitz # PyMuPDF
from openpyxl import load_workbook
from openpyxl.utils import column_index_from_string
@@ -80,6 +81,49 @@ def validate_input_file(
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.")
@@ -96,9 +140,9 @@ def validate_template(
mapping: list[OutputMapping],
) -> ValidationResult:
"""
Validiert ein Template:
Validiert ein PDF-Template:
- Datei existiert
- Alle target_cells aus dem Mapping sind im Template vorhanden
- Alle _Field-Platzhalter (pdf_field) sind im PDF auf irgendeiner Seite vorhanden
"""
errors: list[str] = []
warnings: list[str] = []
@@ -109,37 +153,51 @@ def validate_template(
return ValidationResult(is_valid=False, errors=errors)
try:
wb = load_workbook(template_path, data_only=True)
doc = fitz.open(template_path)
except Exception as e:
errors.append(f"Template konnte nicht geöffnet werden: {e}")
errors.append(f"PDF-Template konnte nicht geöffnet werden: {e}")
return ValidationResult(is_valid=False, errors=errors)
info["sheet_names"] = wb.sheetnames
# Wir validieren gegen das erste Sheet (üblicherweise das Haupt-Template)
if not wb.sheetnames:
errors.append("Template enthält keine Sheets.")
wb.close()
if doc.page_count == 0:
errors.append("PDF-Template enthält keine Seiten.")
doc.close()
return ValidationResult(is_valid=False, errors=errors)
ws = wb[wb.sheetnames[0]]
found_count = 0
missing_fields = []
found_cells = 0
missing_cells = []
for m in mapping:
try:
cell = ws[m.target_cell]
found_cells += 1
except (ValueError, KeyError):
missing_cells.append(m.target_cell)
if m.pdf_field is None:
continue # Kein Marker erwartet
info["found_target_cells"] = found_cells
info["total_target_cells"] = len(mapping)
# Suche auf allen Seiten
found_any = False
for page_num in range(doc.page_count):
found = doc[page_num].search_for(m.pdf_field)
if found:
found_any = True
break
if missing_cells:
errors.append(f"Zielfelder nicht im Template gefunden: {', '.join(missing_cells)}")
if found_any:
found_count += 1
else:
missing_fields.append(m.source_field)
# Zähle unique pdf_fields (nicht die source_fields, da Tätigkeit doppelt vorkommt)
unique_pdf_fields = set(m.pdf_field for m in mapping if m.pdf_field is not None)
total_pages = doc.page_count
doc.close()
info["found_fields"] = found_count
info["total_fields"] = len(unique_pdf_fields)
info["page_count"] = total_pages
if missing_fields:
errors.append(
f"Platzhalter nicht im PDF-Template gefunden: {', '.join(missing_fields)}"
)
wb.close()
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
+225 -81
View File
@@ -1,19 +1,22 @@
"""Template-basierte Activity-Report-Generierung.
"""PDF-basierte Activity-Report-Generierung.
Verwendet ein Template mit bereits vor-duplizierten Sheets.
Es werden KEINE Sheets programmatisch erzeugt oder kopiert –
ausschließlich die vorhandenen Sheets mit Daten befüllt.
Verwendet ein mehrseitiges PDF-Template mit _Field-Platzhaltern.
Für jeden Mitarbeiter wird ein PDF erzeugt (alle Template-Seiten pro Monat).
Die _Field-Marker werden per PyMuPDF (fitz) gesucht und durch echte Daten ersetzt.
"""
import calendar
from pathlib import Path
from openpyxl import load_workbook
from openpyxl.cell.cell import MergedCell
import fitz # PyMuPDF
from .models import EmployeeRecord, OutputMapping
# ─────────────────────────────────────────────
# Namens-Helfer
# ─────────────────────────────────────────────
def _split_name_for_filename(name: str) -> tuple[str, str]:
"""Teilt 'Nachname, Vorname' oder 'Vorname Nachname' in (Vorname, Nachname)."""
cleaned = str(name or "").strip()
@@ -42,23 +45,6 @@ def _format_value(value, fmt: str | None) -> str:
return str(value)
def _set_cell_value(ws, target_cell: str, value) -> None:
"""Setzt einen Wert in einer Zelle des Worksheets.
Behandelt MergedCells, indem in die Top-Left-Zelle des Merge-Bereichs geschrieben wird."""
try:
cell = ws[target_cell]
if isinstance(cell, MergedCell):
for merged_range in ws.merged_cells.ranges:
if cell.coordinate in merged_range:
top_left = merged_range.min_row, merged_range.min_col
ws.cell(row=top_left[0], column=top_left[1]).value = value
return
else:
cell.value = value
except (ValueError, KeyError):
pass
def _format_month_for_template(monat_str: str) -> str:
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
monat_str = monat_str.strip()
@@ -72,46 +58,211 @@ def _format_month_for_template(monat_str: str) -> str:
return monat_str
def _month_name(mi: int) -> str:
names = ["", "Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember"]
return names[mi] if 1 <= mi <= 12 else ""
# ─────────────────────────────────────────────
# Daten-Extraktion aus EmployeeRecord
# ─────────────────────────────────────────────
def _fill_sheet(ws, record: EmployeeRecord, employee_name: str, field_map: dict) -> None:
"""Befüllt ein einzelnes Sheet mit den Daten eines EmployeeRecords."""
for source_field, m in field_map.items():
if source_field == "Monat (MM.JJ)":
value = _format_month_for_template(record.monat)
elif source_field == "Stellenanteil in %":
value = record.stellenanteil
elif source_field == "Name":
value = employee_name
elif source_field == "Antragsnummer":
value = record.antragsnummer
elif source_field == "Qualifikation":
value = record.qualifikation
elif source_field == "Funktion":
value = record.funktion
elif source_field == "Tätigkeit":
value = record.taetigkeit
elif source_field in ("Ort", "Ort 2"):
value = "Dresden"
elif source_field == "Datum":
parts = record.monat.split(".")
if len(parts) == 2:
def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: str) -> str:
"""Liefert den formatierten Wert für ein Feld im PDF."""
if source_field == "Monat (MM.JJ)":
return _format_month_for_template(record.monat)
elif source_field == "Stellenanteil in %":
return record.stellenanteil
elif source_field == "Name":
return employee_name
elif source_field == "Antragsnummer":
return record.antragsnummer
elif source_field == "Qualifikation":
return record.qualifikation
elif source_field == "Funktion":
return record.funktion
elif source_field == "Tätigkeit":
return record.taetigkeit
elif source_field in ("Ort", "Ort 2"):
return "Dresden"
elif source_field == "Datum":
parts = record.monat.split(".")
if len(parts) == 2:
try:
month = int(parts[0])
year = int(parts[1])
last_day = calendar.monthrange(year, month)[1]
value = f"{last_day:02d}.{month:02d}.{year}"
else:
value = ""
else:
continue
formatted = _format_value(value, m.format)
_set_cell_value(ws, m.target_cell, formatted)
return f"{last_day:02d}.{month:02d}.{year}"
except (ValueError, IndexError):
return ""
return ""
return ""
# ─────────────────────────────────────────────
# PDF-Template Pre-Scan: _Field-Positionen auf ALLEN Seiten ermitteln
# ─────────────────────────────────────────────
def _scan_template_fields(
template_path: Path,
mapping: list[OutputMapping],
) -> dict[str, dict]:
"""
Scannt ALLE Seiten des PDF-Templates nach _Field-Platzhaltern.
Rückgabe: dict[pdf_field] → {source_field, page_num, rect, fontsize, fontname, ...}
Felder mit pdf_field=None werden übersprungen.
"""
doc = fitz.open(template_path)
# Sammle alle pdf_field → source_field Zuordnungen
pdf_to_source: dict[str, str] = {}
for m in mapping:
if m.pdf_field is not None:
pdf_to_source[m.pdf_field] = m.source_field
field_info: dict[str, dict] = {}
# 1. Per search_for die Rechtecke aller PDF-Feld-Marker finden (alle Seiten)
for pdf_field, source_field in pdf_to_source.items():
found_any = False
for page_num in range(doc.page_count):
page = doc[page_num]
found = page.search_for(pdf_field)
if found:
field_info[pdf_field] = {
"source_field": source_field,
"page_num": page_num,
"rect": found[0],
"fontsize": 11, # Fallback
"fontname": "helv",
}
found_any = True
break # erstes Vorkommen (seitenübergreifend)
if not found_any:
field_info[pdf_field] = None # nicht gefunden
# 2. Schriftgrößen aus den Text-Details extrahieren (alle Seiten)
for page_num in range(doc.page_count):
page = doc[page_num]
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if "lines" not in block:
continue
for line in block["lines"]:
for span in line["spans"]:
text = span["text"].strip()
if text.endswith("_Field") and text in field_info:
info = field_info[text]
if info is not None:
info["fontsize"] = span["size"]
info["fontname"] = span.get("font", "helv")
info["rect"] = fitz.Rect(span["bbox"])
doc.close()
return field_info
# ─────────────────────────────────────────────
# Template-Seiten mit Daten befüllen
# ─────────────────────────────────────────────
def _fill_pdf_pages(
pages: list[fitz.Page], # eine Page pro Template-Seite
record: EmployeeRecord,
employee_name: str,
mapping: list[OutputMapping],
field_info: dict[str, dict],
) -> None:
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
# 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
# Felder mit automatischer Schriftgrößen-Anpassung bei langem Text
AUTOSCALE_FIELDS = {"Qualifikation_Field", "Funktion_Field"}
AUTOSCALE_MAX_CHARS = 35 # ab dieser Länge wird verkleinert
AUTOSCALE_MIN_FACTOR = 0.75 # minimale Schriftgröße relativ zum Original
for m in mapping:
if m.pdf_field is None:
continue
info = field_info.get(m.pdf_field)
if info is None:
continue # Feld nicht im Template gefunden
page_num = info["page_num"]
if page_num >= len(pages):
continue
page = pages[page_num]
rect = info["rect"]
fontsize = info.get("fontsize", 11)
value = _get_field_value(record, employee_name, m.source_field)
formatted = _format_value(value, m.format)
# Platzhalter immer redacten, auch wenn kein Wert zum Einsetzen da ist
page.add_redact_annot(rect)
page.apply_redactions()
if not formatted:
continue
# Automatische Schriftgrößen-Anpassung für lange Texte
if m.pdf_field in AUTOSCALE_FIELDS and len(formatted) > AUTOSCALE_MAX_CHARS:
text_width = fitz.get_text_length(formatted, fontname="helv", fontsize=fontsize)
available_width = rect.width
if text_width > available_width:
factor = available_width / text_width
factor = max(factor, AUTOSCALE_MIN_FACTOR)
fontsize = fontsize * factor
# Mehrzeilige Felder: Zeilenweise per insert_text schreiben
if m.pdf_field in MULTILINE_FIELDS:
# Doppelte Leerzeilen entfernen
clean_text = "\n".join(
line for line in formatted.split("\n") if line.strip()
)
if not clean_text:
continue
# Zeilenweise mit automatischem Umbruch schreiben
lines = clean_text.split("\n")
line_spacing = fontsize * 1.2 # Zeilenabstand
y = rect.y0 + fontsize # Startposition (unterhalb Marker-Oberkante)
for line in lines:
# Wort-Umbruch: Zeile in Stücke passend zur Textbox-Breite aufteilen
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:
page.insert_text(
(rect.x0, y), current_line,
fontname="helv", fontsize=fontsize,
)
y += line_spacing
current_line = word
if current_line:
page.insert_text(
(rect.x0, y), current_line,
fontname="helv", fontsize=fontsize,
)
y += line_spacing
else:
page.insert_text(
rect.bl,
formatted,
fontname="helv",
fontsize=fontsize,
)
# ─────────────────────────────────────────────
# Hauptfunktion: Reports generieren
# ─────────────────────────────────────────────
def generate_activity_reports(
employee_records: dict[str, list[EmployeeRecord]],
template_path: Path,
@@ -120,14 +271,14 @@ def generate_activity_reports(
filename_pattern: str,
) -> list[Path]:
"""
Befüllt vor-duplizierte Template-Sheets mit Mitarbeiterdaten.
Erzeugt KEINE neuen Sheets – verwendet ausschließlich die im Template
bereits vorhandenen Sheet-Kopien.
Erzeugt für jeden Mitarbeiter ein PDF.
Pro Monat werden ALLE Template-Seiten kopiert und mit Daten befüllt.
"""
output_dir.mkdir(parents=True, exist_ok=True)
generated_files: list[Path] = []
field_map: dict[str, OutputMapping] = {m.source_field: m for m in mapping}
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
field_info = _scan_template_fields(template_path, mapping)
for employee_name, records in employee_records.items():
if not records:
@@ -140,31 +291,24 @@ def generate_activity_reports(
filename = filename_pattern.format(first_name=safe_first, last_name=safe_last)
output_path = output_dir / filename
wb = load_workbook(template_path)
sorted_records = sorted(records, key=lambda r: r.month_index)
total_needed = len(sorted_records)
available_sheets = wb.worksheets
if len(available_sheets) < total_needed:
wb.close()
raise RuntimeError(
f"Template hat nur {len(available_sheets)} Sheets, "
f"aber {total_needed} werden benötigt für '{employee_name}'."
)
# Ergebnis-PDF erstellen
result_doc = fitz.open()
for idx, record in enumerate(sorted_records):
sheet = available_sheets[idx]
mi = record.month_index
func_abbr = record.funktion[:3] if record.funktion else ""
sheet.title = f"{mi:02d}_{_month_name(mi)}_{func_abbr}"[:31]
_fill_sheet(sheet, record, employee_name, field_map)
for record in sorted_records:
# Template frisch öffnen für jeden Monat
temp_doc = fitz.open(template_path)
pages = [temp_doc[i] for i in range(temp_doc.page_count)]
# Lösche überzählige Sheets (von hinten nach vorne)
for extra in reversed(available_sheets[total_needed:]):
wb.remove(extra)
_fill_pdf_pages(pages, record, employee_name, mapping, field_info)
wb.save(output_path)
wb.close()
# Alle modifizierten Seiten ins Ergebnis-PDF einfügen
result_doc.insert_pdf(temp_doc)
temp_doc.close()
result_doc.save(output_path)
result_doc.close()
generated_files.append(output_path)
return generated_files
Binary file not shown.
Binary file not shown.
+84 -77
View File
@@ -1,10 +1,10 @@
"""End-to-End-Test: Reale Input-Datei + reales Template → Excel-Output für erste Person."""
"""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
from openpyxl import load_workbook
# Projekt-Root zum Python-Path hinzufügen
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -21,7 +21,7 @@ from src.writer import generate_activity_reports
CONFIG_DIR = PROJECT_ROOT / "config"
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx"
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.xlsx"
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf"
def _load_app_config():
@@ -62,20 +62,62 @@ def output_dir(tmp_path):
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 Template → nur erste Person."""
"""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 Template vorhanden ist."""
assert TEMPLATE_FILE.exists(), f"Template nicht gefunden: {TEMPLATE_FILE}"
"""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."""
@@ -128,86 +170,51 @@ class TestEndToEndFirstPerson:
)
output_file = generated[0]
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}"
assert output_file.suffix == ".xlsx"
assert output_file.suffix == ".pdf"
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.xlsx"
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.pdf"
assert output_file.name == erwarteter_name, (
f"Erwartet '{erwarteter_name}', aber '{output_file.name}'"
)
# 5. Output-Datei öffnen und Inhalt validieren
wb = load_workbook(output_file, data_only=True)
# 12 sichtbare Sheets, Rest (36 - 12 = 24) ausgeblendet
visible_sheets = [s for s in wb.worksheets if s.sheet_state == "visible"]
assert len(visible_sheets) == 12, (
f"Erwartet 12 sichtbare Sheets, aber {len(visible_sheets)}"
# 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}"
)
# Prüfe Sheet-Namen für alle 12 Monate
expected_titles = {
"01_Januar_Anl", "02_Februar_Anl", "03_März_Anl",
"04_April_Anl", "05_Mai_Anl", "06_Juni_Anl",
"07_Juli_Anl", "08_August_Anl", "09_September_Anl",
"10_Oktober_Anl", "11_November_Anl", "12_Dezember_Anl",
}
actual_titles = {ws.title for ws in visible_sheets}
assert actual_titles == expected_titles, (
f"Erwartet {expected_titles},\n aber {actual_titles}"
# 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!"
)
# Prüfe exemplarisch das Januar-Sheet (01_Januar_Anl)
jan = wb["01_Januar_Anl"]
assert jan["B3"].value == "100740439", (
f"Erwartet '100740439' in B3, aber '{jan['B3'].value}'"
)
assert jan["B17"].value == "Draßdo, Cornelia", (
f"Erwartet 'Draßdo, Cornelia' in B17, aber '{jan['B17'].value}'"
)
# Stellenanteil: 0.5 → vom Parser zu "50" konvertiert (0 < x ≤ 1 → *100)
assert jan["R17"].value == "50", (
f"Erwartet '50' in R17, aber '{jan['R17'].value}'"
)
assert jan["B21"].value == "Meisterabschluß Küche", (
f"Falsche Qualifikation in B21: '{jan['B21'].value}'"
)
assert jan["R21"].value == "01.2025", (
f"Erwartet '01.2025' in R21, aber '{jan['R21'].value}'"
)
assert jan["B25"].value == "Anleiterin / Ausbilderin", (
f"Falsche Funktion in B25: '{jan['B25'].value}'"
)
# Tätigkeit beginnt mit "Tätigkeiten im Praxisbereich Garten…"
taetigkeit = jan["B31"].value or ""
assert taetigkeit.startswith("Tätigkeiten im Praxisbereich Garten"), (
f"Tätigkeit in B31 beginnt nicht wie erwartet: '{taetigkeit[:60]}…'"
)
# Ort wird hartkodiert auf "Dresden" gesetzt
assert jan["B95"].value == "Dresden", (
f"Erwartet 'Dresden' in B95, aber '{jan['B95'].value}'"
)
# Datum: letzter Tag des Monats Januar → 31.01.2025
assert jan["J95"].value == "31.01.2025", (
f"Erwartet '31.01.2025' in J95, aber '{jan['J95'].value}'"
)
# 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"
# Prüfe exemplarisch das Juli-Sheet (Stellenanteil wechselt auf 40%)
jul = wb["07_Juli_Anl"]
assert jul["R17"].value == "40", (
f"Erwartet '40' in R17 (Juli), aber '{jul['R17'].value}'"
)
assert jul["R21"].value == "07.2025", (
f"Erwartet '07.2025' in R21, aber '{jul['R21'].value}'"
)
# Datum Juli → 31.07.2025
assert jul["J95"].value == "31.07.2025", (
f"Erwartet '31.07.2025' in J95, aber '{jul['J95'].value}'"
)
# 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"
# Prüfe Dezember-Sheet (Datum: 31.12.2025)
dez = wb["12_Dezember_Anl"]
assert dez["J95"].value == "31.12.2025", (
f"Erwartet '31.12.2025' in J95, aber '{dez['J95'].value}'"
)
# 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"
wb.close()
jul1_text = doc[13].get_text()
assert "31.07.2025" in jul1_text, "Datum 31.07.2025 fehlt auf Juli-Seite 2"
doc.close()