Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c31655bd |
No files matched your search
+65
-68
@@ -1,37 +1,24 @@
|
|||||||
# Ziel
|
# Ziel
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
**Ansatz**: openpyxl für Input-Parsing + PyMuPDF (fitz) für PDF-Generierung + Streamlit-UI.
|
**Ansatz**: Verwendung von openpyxl für Single-Input-File + 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
|
# Aufgabe
|
||||||
|
|
||||||
Erstelle eine Windows App in dessen Oberfläche aus folgenden Bestandteilen aufgebaut ist:
|
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 eine Datei aus dem inputfolder gewählt werden
|
||||||
- mittels einem DropDown-Menü kann ein PDF-Template aus dem Templatefolder gewählt werden
|
- mittels einem DropDown-Menü kann ein Template aus dem Templatefolder gewählt werden
|
||||||
- ein Button um den Vorgang zu starten
|
- ein Button um den Vorgang zu starten
|
||||||
|
|
||||||
Die Funktion der App besteht darin, das Template-PDF mit Daten aus dem Input-File zu befüllen.
|
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 Templates.
|
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.
|
||||||
|
|
||||||
# Aufbau Input Datei
|
# Aufbau Input Datei
|
||||||
|
|
||||||
- In der Input Datei ist für jeden Monat eines Jahres ein Sheet gespeichert.
|
- 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.
|
- In jedem Sheet befindet sich eine Tabelle (felder speichern in Input Schema json) mit Mitarbeitenden.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
# 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
|
# README
|
||||||
|
|
||||||
@@ -43,14 +30,15 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
|||||||
|
|
||||||
### Phase 1: Projekt-Grundgerüst & Konfiguration
|
### Phase 1: Projekt-Grundgerüst & Konfiguration
|
||||||
|
|
||||||
1. **`requirements.txt` befüllen** — `streamlit`, `openpyxl`, `PyMuPDF`
|
1. **`requirements.txt` befüllen** — `streamlit`, `openpyxl`
|
||||||
2. **`config/settings.ini` befüllen** — Pfade, Standardwerte
|
2. **`config/settings.ini` befüllen** — Pfade, Standardwerte
|
||||||
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern → `.pdf`)
|
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern)
|
||||||
3. **`config/input-fields.json` Schema definieren** — Spalten der Input-Tabelle mit ihren exakten Überschriften
|
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)
|
- 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 in das PDF geschrieben werden
|
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), `format` (optional, z. B. "dd.mm.yyyy")
|
- 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")
|
||||||
- Der Platzhalter im PDF ist immer `{source_field}_Field` – kein explizites `target_cell` mehr nötig
|
- Muss durch Analyse des Templates `template/Tätigkeitsnachweise Stellenförderung.xlsx` ermittelt werden (welche Zellen sind zu befüllen?)
|
||||||
|
|
||||||
### Phase 2: Core-Module (`src/`)
|
### Phase 2: Core-Module (`src/`)
|
||||||
|
|
||||||
@@ -62,52 +50,62 @@ 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_input_schema()`: liest `config/input-fields.json`
|
||||||
- `load_output_mapping()`: liest `config/output-fields.json`
|
- `load_output_mapping()`: liest `config/output-fields.json`
|
||||||
- `get_available_input_files()`: listet `.xlsx`-Dateien in `input/`
|
- `get_available_input_files()`: listet `.xlsx`-Dateien in `input/`
|
||||||
- `get_available_templates()`: listet `.pdf`-Dateien in `template/`
|
- `get_available_templates()`: listet `.xlsx`-Dateien in `template/`
|
||||||
7. **`src/parser.py`** — Input-Datei einlesen (unverändert: openpyxl für XLSX-Input)
|
7. **`src/parser.py`** — Input-Datei einlesen
|
||||||
- `parse_input_file(filepath, schema)`: öffnet Workbook, iteriert über alle Sheets (Monate)
|
- `parse_input_file(filepath, schema)`: öffnet Workbook, iteriert über alle Sheets (Monate)
|
||||||
- Extrahiert Monat/Jahr aus Sheet-Namen oder Sheet-Inhalt
|
- Extrahiert Monat/Jahr aus Sheet-Namen oder Sheet-Inhalt
|
||||||
- Für jedes Sheet: liest Zeilen anhand des Schemas, erzeugt `EmployeeRecord`-Liste
|
- Für jedes Sheet: liest Zeilen anhand des Schemas, erzeugt `EmployeeRecord`-Liste
|
||||||
- Rückgabe: `list[EmployeeRecord]`
|
- Rückgabe: `dict[str, list[EmployeeRecord]]` (key = Mitarbeiter-Name)
|
||||||
|
|
||||||
### Phase 3: Writer & Validierung (PDF-basiert)
|
### Phase 3: Writer & Validierung
|
||||||
|
|
||||||
8. **`src/writer.py`** — PDF-Output generieren (NEU: PyMuPDF)
|
8. **`src/writer.py`** — Output generieren
|
||||||
- `generate_activity_reports(employee_records, template_path, mapping, config)`:
|
- `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
|
- Gruppiert Records nach Mitarbeiter
|
||||||
- Für jeden Mitarbeiter:
|
- Für jeden Mitarbeiter: lädt Template, erstellt für jeden Monat ein Sheet (kopiert Template-Sheet)
|
||||||
- Erstellt ein neues PDF
|
- Befüllt Zielzellen gemäß output-fields.json Mapping
|
||||||
- Für jeden Monats-Record: fügt eine Kopie der Template-Seite ein
|
- Benennt Sheets nach Monat (z.B. "01_Januar", "02_Februar", …)
|
||||||
- Ersetzt alle `_Field`-Platzhalter durch die tatsächlichen Daten (redact + insert_text)
|
- Speichert als `{nachname}_{vorname}_{YYYY}_Tätigkeitsnachweis.xlsx` in `output/`
|
||||||
- Speichert als `{nachname}_{vorname}_Tätigkeitsnachweis.pdf` in `output/`
|
9. **`src/validator.py`** — Eingabevalidierung
|
||||||
9. **`src/validator.py`** — Eingabevalidierung (angepasst für PDF)
|
- `validate_input_file(filepath, schema)`: prüft Datei-Existenz, Sheet-Anzahl, Spalten laut Schema
|
||||||
- `validate_input_file(filepath, schema)`: unverändert (prüft XLSX-Input)
|
- `validate_template(template_path, mapping)`: prüft ob alle target_cells im Template existieren
|
||||||
- `validate_template(template_path, mapping)`: prüft ob alle `_Field`-Platzhalter im PDF gefunden werden
|
|
||||||
|
|
||||||
### Phase 4: Streamlit UI
|
### Phase 4: Streamlit UI
|
||||||
|
|
||||||
10. **`app.py`** — Streamlit-Oberfläche (angepasst für PDF-Templates)
|
10. **`app.py`** — Streamlit-Oberfläche
|
||||||
- Layout:
|
- Layout:
|
||||||
- Titel + Beschreibung
|
- Titel + Beschreibung
|
||||||
- `st.selectbox` für Input-Datei (aus `input/`-Folder)
|
- `st.selectbox` für Input-Datei (aus `input/`-Folder)
|
||||||
- **Sofort bei Auswahl**: Validierung + Parsing der Datei
|
- **Sofort bei Auswahl**: Validierung + Parsing der Datei
|
||||||
- Anzeige: Anzahl gefundener Mitarbeiter, Auflistung aller enthaltenen Antragsnummern
|
- Anzeige: Anzahl gefundener Mitarbeiter, Auflistung aller enthaltenen Antragsnummern
|
||||||
- `st.selectbox` für PDF-Template (aus `template/`-Folder)
|
- Bei Fehlern: `st.error` mit Details
|
||||||
- **Sofort bei Auswahl**: Validierung des PDF-Templates (Prüfung aller `_Field`-Marker)
|
- `st.selectbox` für Template (aus `template/`-Folder)
|
||||||
- Anzeige: Anzahl gefundener Platzhalter-Felder
|
- **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
|
||||||
- **Mitarbeiter-Liste** (erscheint nach erfolgreicher Input-Validierung)
|
- **Mitarbeiter-Liste** (erscheint nach erfolgreicher Input-Validierung)
|
||||||
- `st.multiselect` mit allen gefundenen Mitarbeiternamen, standardmäßig alle vorselektiert
|
- `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")`
|
- `st.button("Tätigkeitsnachweise generieren")`
|
||||||
- Bei Button-Click:
|
- Bei Button-Click:
|
||||||
1. Filtere Employee Records auf selektierte Mitarbeiter
|
1. Filtere Employee Records auf selektierte Mitarbeiter
|
||||||
2. Generiere PDFs nur für selektierte Mitarbeiter und speichere in `output/`
|
2. Generiere Activity Reports nur für selektierte Mitarbeiter und speichere in `output/`
|
||||||
3. Zeige Erfolgsmeldung mit Liste der erstellten PDF-Dateien
|
3. Zeige Erfolgsmeldung mit Liste der erstellten Dateien via `st.success`
|
||||||
|
- Status-Anzeige mit `st.spinner` und `st.progress`
|
||||||
|
|
||||||
### Phase 5: Start-Skript & Doku
|
### Phase 5: Start-Skript & Doku
|
||||||
|
|
||||||
11. **`start.bat`** — Windows Batch-Startdatei (unverändert)
|
11. **`start.bat`** — Windows Batch-Startdatei
|
||||||
|
- Prüft ob Python verfügbar ist
|
||||||
|
- Installiert Requirements (falls nötig)
|
||||||
|
- Startet `streamlit run app.py`
|
||||||
12. **`README.md` aktualisieren** — Vollständige Doku
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -115,17 +113,19 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
|||||||
|
|
||||||
| Datei | Aktion |
|
| Datei | Aktion |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| `requirements.txt` | `PyMuPDF` hinzufügen |
|
| `requirements.txt` | Neu befüllen |
|
||||||
| `config/settings.ini` | filename_pattern auf `.pdf` ändern |
|
| `config/settings.ini` | Neu befüllen |
|
||||||
| `config/output-fields.json` | `target_cell` entfernen (wird durch `_Field`-Marker ersetzt) |
|
| `config/input-fields.json` | Neu befüllen (nach Analyse Input-Datei) |
|
||||||
| `src/writer.py` | Komplett neu: PDF-basierte Generierung mit PyMuPDF |
|
| `config/output-fields.json` | Neu befüllen (nach Analyse Template) |
|
||||||
| `src/validator.py` | `validate_template` für PDF anpassen |
|
| `src/__init__.py` | Neu (leer) |
|
||||||
| `src/config.py` | `get_available_templates` auf `.pdf` umstellen |
|
| `src/models.py` | Neu |
|
||||||
| `app.py` | Template-Validierung für PDF anpassen |
|
| `src/config.py` | Neu |
|
||||||
| `coldrun.py` | An PDF-Template anpassen |
|
| `src/parser.py` | Neu |
|
||||||
| `tests/test_e2e.py` | Tests für PDF-Output anpassen |
|
| `src/writer.py` | Neu |
|
||||||
| `.copilot/PLAN.md` | Dieser Plan – aktualisiert |
|
| `src/validator.py` | Neu |
|
||||||
| `.copilot/TASKS.md` | Aufgabenliste – aktualisiert |
|
| `app.py` | Neu |
|
||||||
|
| `start.bat` | Neu |
|
||||||
|
| `README.md` | Überschreiben mit vollständiger Doku |
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
@@ -139,28 +139,25 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
|||||||
├── config/
|
├── config/
|
||||||
│ ├── settings.ini ← Einstellungen der App
|
│ ├── settings.ini ← Einstellungen der App
|
||||||
│ ├── input-fields.json ← Schema der Inputdaten-Tabelle
|
│ ├── input-fields.json ← Schema der Inputdaten-Tabelle
|
||||||
│ └── output-fields.json ← Mapping: Input-Feld → PDF-Platzhalter
|
│ └── output-fields.json ← Schema der Outputdaten-Tabelle
|
||||||
├── output/ ← Zielordner für generierte PDF-Dateien
|
├── output/ ← Zielordner für generierte Dateien
|
||||||
├── template/
|
|
||||||
│ └── *.pdf ← PDF-Template mit _Field-Platzhaltern
|
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── models.py
|
│ ├── models.py
|
||||||
│ ├── config.py
|
│ ├── config.py
|
||||||
│ ├── parser.py
|
│ ├── parser.py
|
||||||
│ ├── writer.py ← PDF-basierte Generierung (PyMuPDF)
|
│ ├── writer.py
|
||||||
│ └── validator.py
|
│ └── validator.py
|
||||||
├── app.py ← Streamlit-App
|
├── app.py ← Streamlit-App
|
||||||
├── coldrun.py ← CLI Cold-Run
|
|
||||||
├── start.bat ← Windows batch zum Start der App
|
├── start.bat ← Windows batch zum Start der App
|
||||||
├── README.md ← README für Programmbenutzer
|
├── README.md ← README für Programmbenutzer
|
||||||
└── requirements.txt ← streamlit, openpyxl, PyMuPDF
|
└── requirements.txt ← streamlit, openpyxl
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verifikation
|
## Verifikation
|
||||||
|
|
||||||
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
|
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. **PDF-Template-Analyse**: `Tätigkeitsnachweise Stellenförderung.pdf` mit PyMuPDF öffnen, alle `_Field`-Platzhalter suchen → mit `output-fields.json` abgleichen
|
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 PDF-Template ausführen, prüfen ob Output-PDFs korrekt befüllt sind
|
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, Umlaute im PDF
|
4. **Edge Cases**: Leere Input-Zeilen, fehlende Monate, Mitarbeiter ohne Vorname, Sonderzeichen in Namen
|
||||||
```
|
```
|
||||||
+22
-42
@@ -1,47 +1,27 @@
|
|||||||
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App) – PDF-Migration
|
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App)
|
||||||
|
|
||||||
> **Migration**: Das XLSX-Template war schreibgeschützt und wurde durch ein PDF-Template ersetzt.
|
## 🔍 Analyse ✅
|
||||||
> Alle `_Field`-Platzhalter im PDF werden durch echte Daten ersetzt.
|
- [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: XLSX → PDF Template ✅
|
## ⚙️ 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
|
||||||
|
|
||||||
- [x] **M1**: `requirements.txt` → `PyMuPDF` hinzufügen
|
## 🧱 Core-Module ✅
|
||||||
- [x] **M2**: `config/settings.ini` → `filename_pattern` auf `.pdf`-Endung ändern
|
- [x] **C1**: `requirements.txt` befüllen (streamlit, openpyxl)
|
||||||
- [x] **M3**: `config/output-fields.json` → `target_cell` entfernen, `pdf_field` hinzufügen (Mapping source_field → PDF-Platzhalter-Name)
|
- [x] **C2**: `src/__init__.py` + `src/models.py` — Datenklassen
|
||||||
- [x] **M4**: `src/config.py` → `get_available_templates()` auf `.pdf`-Dateien umstellen
|
- [x] **C3**: `src/config.py` — Konfigurations-Loader
|
||||||
- [x] **M5**: `src/validator.py` → `validate_template()` für PDF angepasst (prüft `_Field`-Platzhalter via PyMuPDF)
|
- [x] **C4**: `src/parser.py` — Input-Datei parsen
|
||||||
- [x] **M6**: `src/writer.py` → Komplett neu: PDF-basierte Generierung mit PyMuPDF
|
- [x] **C5**: `src/validator.py` — Eingabevalidierung
|
||||||
- Pre-Scan: Alle `_Field`-Platzhalter im Template-PDF finden (Position, Schriftgröße)
|
- [x] **C6**: `src/writer.py` — Output generieren
|
||||||
- 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
|
|
||||||
|
|
||||||
## 🔍 Analyse (abgeschlossen)
|
## 🖥️ UI & Start ✅
|
||||||
- [x] **A1**: Input-Datei `input/2025 JM.xlsx` analysiert
|
- [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] **A2**: PDF-Template `template/Tätigkeitsnachweise Stellenförderung.pdf` liegt vor (mit `_Field`-Platzhaltern)
|
- [x] **U2**: `start.bat` — Windows Batch-Startdatei
|
||||||
|
- [x] **U3**: `README.md` — vollständige Dokumentation
|
||||||
|
|
||||||
## ⚙️ Konfiguration (migriert)
|
## ✅ Test ✅
|
||||||
- [x] **K1**: `config/input-fields.json` — unverändert gültig
|
- [x] **T1**: App mit vorhandener Input-Datei und Template testen — 19 Tätigkeitsnachweise erfolgreich generiert
|
||||||
- [x] **K2**: `config/output-fields.json` — `target_cell` entfernt, `pdf_field` hinzugefügt
|
- [x] **T2**: Edge Cases prüfen (leere Zeilen, Sonderzeichen, fehlende Monate) — Umlaute, Sonderzeichen in Namen, "Alle Rollen"-Sheet korrekt behandelt
|
||||||
- [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
|
|
||||||
@@ -157,7 +157,7 @@ with col2:
|
|||||||
st.subheader("2. Template")
|
st.subheader("2. Template")
|
||||||
template_files = get_available_templates(app_config)
|
template_files = get_available_templates(app_config)
|
||||||
if not template_files:
|
if not template_files:
|
||||||
st.warning("Keine .pdf-Dateien im template/-Ordner gefunden.")
|
st.warning("Keine .xlsx-Dateien im template/-Ordner gefunden.")
|
||||||
selected_template = None
|
selected_template = None
|
||||||
else:
|
else:
|
||||||
# Lock-Files herausfiltern
|
# Lock-Files herausfiltern
|
||||||
@@ -202,9 +202,9 @@ with col2:
|
|||||||
st.session_state.template_validated = False
|
st.session_state.template_validated = False
|
||||||
else:
|
else:
|
||||||
st.session_state.template_validated = True
|
st.session_state.template_validated = True
|
||||||
found = tpl_result.info.get("found_fields", 0)
|
found = tpl_result.info.get("found_target_cells", 0)
|
||||||
total = tpl_result.info.get("total_fields", 0)
|
total = tpl_result.info.get("total_target_cells", 0)
|
||||||
st.success(f"✅ {found}/{total} Platzhalter-Felder gefunden")
|
st.success(f"✅ {found}/{total} Zielfelder gefunden")
|
||||||
if tpl_result.warnings:
|
if tpl_result.warnings:
|
||||||
st.markdown(
|
st.markdown(
|
||||||
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
|
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
|
||||||
|
|||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
@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
|
|
||||||
+1
-26
@@ -22,7 +22,6 @@ from src.config import (
|
|||||||
get_available_templates,
|
get_available_templates,
|
||||||
)
|
)
|
||||||
from src.parser import parse_input_file, group_by_employee, get_unique_employees
|
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
|
from src.writer import generate_activity_reports
|
||||||
|
|
||||||
|
|
||||||
@@ -51,37 +50,13 @@ def main():
|
|||||||
# ── Erstes Template ermitteln ─────────────────────────────────────
|
# ── Erstes Template ermitteln ─────────────────────────────────────
|
||||||
template_files = get_available_templates(config)
|
template_files = get_available_templates(config)
|
||||||
if not template_files:
|
if not template_files:
|
||||||
print("[FEHLER] Keine .pdf-Dateien im template/-Ordner gefunden.")
|
print("[FEHLER] Keine .xlsx-Dateien im template/-Ordner gefunden.")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
template_file = template_files[0]
|
template_file = template_files[0]
|
||||||
template_path = Path(config.template_dir) / template_file
|
template_path = Path(config.template_dir) / template_file
|
||||||
print(f"[INFO] Template: {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 ──────────────────────────────────────────────────
|
# ── Input parsen ──────────────────────────────────────────────────
|
||||||
print()
|
print()
|
||||||
print("[INFO] Parse Input-Datei…")
|
print("[INFO] Parse Input-Datei…")
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
[
|
[
|
||||||
{"source_field": "Antragsnummer", "pdf_field": "Antragsnummer_Field", "format": null},
|
{"source_field": "Antragsnummer", "target_cell": "B3", "format": null},
|
||||||
{"source_field": "Name", "pdf_field": "Name_Field", "format": null},
|
{"source_field": "Name", "target_cell": "B17", "format": null},
|
||||||
{"source_field": "Stellenanteil in %", "pdf_field": "Stellenanteil_Field", "format": null},
|
{"source_field": "Stellenanteil in %", "target_cell": "R17", "format": null},
|
||||||
{"source_field": "Qualifikation", "pdf_field": "Qualifikation_Field", "format": null},
|
{"source_field": "Qualifikation", "target_cell": "B21", "format": null},
|
||||||
{"source_field": "Monat (MM.JJ)", "pdf_field": "Monat_Field", "format": null},
|
{"source_field": "Monat (MM.JJ)", "target_cell": "R21", "format": null},
|
||||||
{"source_field": "Funktion", "pdf_field": "Funktion_Field", "format": null},
|
{"source_field": "Funktion", "target_cell": "B25", "format": null},
|
||||||
{"source_field": "Tätigkeit", "pdf_field": "Tätigkeiten_Field", "format": null},
|
{"source_field": "Tätigkeit", "target_cell": "B31", "format": null},
|
||||||
{"source_field": "Datum", "pdf_field": "Datum_Field", "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}
|
||||||
]
|
]
|
||||||
+1
-1
@@ -4,7 +4,7 @@ template_dir = template
|
|||||||
output_dir = output
|
output_dir = output
|
||||||
|
|
||||||
[output]
|
[output]
|
||||||
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.pdf
|
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.xlsx
|
||||||
|
|
||||||
[parsing]
|
[parsing]
|
||||||
# Sheets whose names match these patterns are treated as monthly data sheets
|
# Sheets whose names match these patterns are treated as monthly data sheets
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
streamlit>=1.28,<2.0
|
streamlit>=1.28,<2.0
|
||||||
openpyxl>=3.1,<4.0
|
openpyxl>=3.1,<4.0
|
||||||
PyMuPDF>=1.23,<2.0
|
|
||||||
+2
-2
@@ -74,8 +74,8 @@ def get_available_input_files(config: AppConfig) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def get_available_templates(config: AppConfig) -> list[str]:
|
def get_available_templates(config: AppConfig) -> list[str]:
|
||||||
"""Listet alle .pdf-Dateien im template-Ordner."""
|
"""Listet alle .xlsx-Dateien im template-Ordner."""
|
||||||
return get_available_files(Path(config.template_dir), extension=".pdf")
|
return get_available_files(Path(config.template_dir))
|
||||||
|
|
||||||
|
|
||||||
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
|
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
|
||||||
|
|||||||
+1
-1
@@ -47,8 +47,8 @@ class FieldSchema:
|
|||||||
class OutputMapping:
|
class OutputMapping:
|
||||||
"""Definition eines Mappings aus output-fields.json."""
|
"""Definition eines Mappings aus output-fields.json."""
|
||||||
source_field: str
|
source_field: str
|
||||||
|
target_cell: str
|
||||||
format: Optional[str] = None
|
format: Optional[str] = None
|
||||||
pdf_field: Optional[str] = None # Name des _Field-Platzhalters im PDF (z.B. "Auftragsnummer_Field")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+23
-81
@@ -3,7 +3,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import fitz # PyMuPDF
|
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
from openpyxl.utils import column_index_from_string
|
from openpyxl.utils import column_index_from_string
|
||||||
|
|
||||||
@@ -81,49 +80,6 @@ def validate_input_file(
|
|||||||
info["sheets_with_data"] = sheets_with_data
|
info["sheets_with_data"] = sheets_with_data
|
||||||
wb.close()
|
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:
|
if sheets_with_data == 0:
|
||||||
errors.append("Kein Sheet mit den erwarteten Spalten gefunden.")
|
errors.append("Kein Sheet mit den erwarteten Spalten gefunden.")
|
||||||
|
|
||||||
@@ -140,9 +96,9 @@ def validate_template(
|
|||||||
mapping: list[OutputMapping],
|
mapping: list[OutputMapping],
|
||||||
) -> ValidationResult:
|
) -> ValidationResult:
|
||||||
"""
|
"""
|
||||||
Validiert ein PDF-Template:
|
Validiert ein Template:
|
||||||
- Datei existiert
|
- Datei existiert
|
||||||
- Alle _Field-Platzhalter (pdf_field) sind im PDF auf irgendeiner Seite vorhanden
|
- Alle target_cells aus dem Mapping sind im Template vorhanden
|
||||||
"""
|
"""
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
@@ -153,51 +109,37 @@ def validate_template(
|
|||||||
return ValidationResult(is_valid=False, errors=errors)
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = fitz.open(template_path)
|
wb = load_workbook(template_path, data_only=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"PDF-Template konnte nicht geöffnet werden: {e}")
|
errors.append(f"Template konnte nicht geöffnet werden: {e}")
|
||||||
return ValidationResult(is_valid=False, errors=errors)
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
if doc.page_count == 0:
|
info["sheet_names"] = wb.sheetnames
|
||||||
errors.append("PDF-Template enthält keine Seiten.")
|
|
||||||
doc.close()
|
# Wir validieren gegen das erste Sheet (üblicherweise das Haupt-Template)
|
||||||
|
if not wb.sheetnames:
|
||||||
|
errors.append("Template enthält keine Sheets.")
|
||||||
|
wb.close()
|
||||||
return ValidationResult(is_valid=False, errors=errors)
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
found_count = 0
|
ws = wb[wb.sheetnames[0]]
|
||||||
missing_fields = []
|
|
||||||
|
|
||||||
|
found_cells = 0
|
||||||
|
missing_cells = []
|
||||||
for m in mapping:
|
for m in mapping:
|
||||||
if m.pdf_field is None:
|
try:
|
||||||
continue # Kein Marker erwartet
|
cell = ws[m.target_cell]
|
||||||
|
found_cells += 1
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
missing_cells.append(m.target_cell)
|
||||||
|
|
||||||
# Suche auf allen Seiten
|
info["found_target_cells"] = found_cells
|
||||||
found_any = False
|
info["total_target_cells"] = len(mapping)
|
||||||
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:
|
if missing_cells:
|
||||||
found_count += 1
|
errors.append(f"Zielfelder nicht im Template gefunden: {', '.join(missing_cells)}")
|
||||||
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(
|
return ValidationResult(
|
||||||
is_valid=len(errors) == 0,
|
is_valid=len(errors) == 0,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
|
|||||||
+80
-199
@@ -1,22 +1,19 @@
|
|||||||
"""PDF-basierte Activity-Report-Generierung.
|
"""Template-basierte Activity-Report-Generierung.
|
||||||
|
|
||||||
Verwendet ein mehrseitiges PDF-Template mit _Field-Platzhaltern.
|
Verwendet ein Template mit bereits vor-duplizierten Sheets.
|
||||||
Für jeden Mitarbeiter wird ein PDF erzeugt (alle Template-Seiten pro Monat).
|
Es werden KEINE Sheets programmatisch erzeugt oder kopiert –
|
||||||
Die _Field-Marker werden per PyMuPDF (fitz) gesucht und durch echte Daten ersetzt.
|
ausschließlich die vorhandenen Sheets mit Daten befüllt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import calendar
|
import calendar
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import fitz # PyMuPDF
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.cell.cell import MergedCell
|
||||||
|
|
||||||
from .models import EmployeeRecord, OutputMapping
|
from .models import EmployeeRecord, OutputMapping
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# Namens-Helfer
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _split_name_for_filename(name: str) -> tuple[str, str]:
|
def _split_name_for_filename(name: str) -> tuple[str, str]:
|
||||||
"""Teilt 'Nachname, Vorname' oder 'Vorname Nachname' in (Vorname, Nachname)."""
|
"""Teilt 'Nachname, Vorname' oder 'Vorname Nachname' in (Vorname, Nachname)."""
|
||||||
cleaned = str(name or "").strip()
|
cleaned = str(name or "").strip()
|
||||||
@@ -45,6 +42,23 @@ def _format_value(value, fmt: str | None) -> str:
|
|||||||
return str(value)
|
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:
|
def _format_month_for_template(monat_str: str) -> str:
|
||||||
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
|
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
|
||||||
monat_str = monat_str.strip()
|
monat_str = monat_str.strip()
|
||||||
@@ -58,186 +72,46 @@ def _format_month_for_template(monat_str: str) -> str:
|
|||||||
return monat_str
|
return monat_str
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
def _month_name(mi: int) -> str:
|
||||||
# Daten-Extraktion aus EmployeeRecord
|
names = ["", "Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
# ─────────────────────────────────────────────
|
"Juli", "August", "September", "Oktober", "November", "Dezember"]
|
||||||
|
return names[mi] if 1 <= mi <= 12 else ""
|
||||||
|
|
||||||
def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: str) -> str:
|
|
||||||
"""Liefert den formatierten Wert für ein Feld im PDF."""
|
def _fill_sheet(ws, record: EmployeeRecord, employee_name: str, field_map: dict) -> None:
|
||||||
if source_field == "Monat (MM.JJ)":
|
"""Befüllt ein einzelnes Sheet mit den Daten eines EmployeeRecords."""
|
||||||
return _format_month_for_template(record.monat)
|
for source_field, m in field_map.items():
|
||||||
elif source_field == "Stellenanteil in %":
|
if source_field == "Monat (MM.JJ)":
|
||||||
return record.stellenanteil
|
value = _format_month_for_template(record.monat)
|
||||||
elif source_field == "Name":
|
elif source_field == "Stellenanteil in %":
|
||||||
return employee_name
|
value = record.stellenanteil
|
||||||
elif source_field == "Antragsnummer":
|
elif source_field == "Name":
|
||||||
return record.antragsnummer
|
value = employee_name
|
||||||
elif source_field == "Qualifikation":
|
elif source_field == "Antragsnummer":
|
||||||
return record.qualifikation
|
value = record.antragsnummer
|
||||||
elif source_field == "Funktion":
|
elif source_field == "Qualifikation":
|
||||||
return record.funktion
|
value = record.qualifikation
|
||||||
elif source_field == "Tätigkeit":
|
elif source_field == "Funktion":
|
||||||
return record.taetigkeit
|
value = record.funktion
|
||||||
elif source_field in ("Ort", "Ort 2"):
|
elif source_field == "Tätigkeit":
|
||||||
return "Dresden"
|
value = record.taetigkeit
|
||||||
elif source_field == "Datum":
|
elif source_field in ("Ort", "Ort 2"):
|
||||||
parts = record.monat.split(".")
|
value = "Dresden"
|
||||||
if len(parts) == 2:
|
elif source_field == "Datum":
|
||||||
try:
|
parts = record.monat.split(".")
|
||||||
|
if len(parts) == 2:
|
||||||
month = int(parts[0])
|
month = int(parts[0])
|
||||||
year = int(parts[1])
|
year = int(parts[1])
|
||||||
last_day = calendar.monthrange(year, month)[1]
|
last_day = calendar.monthrange(year, month)[1]
|
||||||
return f"{last_day:02d}.{month:02d}.{year}"
|
value = f"{last_day:02d}.{month:02d}.{year}"
|
||||||
except (ValueError, IndexError):
|
else:
|
||||||
return ""
|
value = ""
|
||||||
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_FONT_SCALE = 1 # Schriftgrößen-Faktor für Textboxen
|
|
||||||
TEXTBOX_WIDTH_PT = 400 # Breite der Textbox in Punkt
|
|
||||||
TEXTBOX_HEIGHT_PT = 120 # Höhe der Textbox in Punkt
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Mehrzeilige Felder: insert_textbox mit Zeilenumbruch
|
|
||||||
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
|
|
||||||
|
|
||||||
# Rechteck für die Textbox: ausgehend vom Marker, nach rechts & unten erweitert
|
|
||||||
textbox_rect = fitz.Rect(
|
|
||||||
rect.x0, # links
|
|
||||||
rect.y0, # oben
|
|
||||||
rect.x0 + TEXTBOX_WIDTH_PT, # rechts
|
|
||||||
rect.y0 + TEXTBOX_HEIGHT_PT, # unten
|
|
||||||
)
|
|
||||||
page.insert_textbox(
|
|
||||||
textbox_rect,
|
|
||||||
clean_text,
|
|
||||||
fontname="helv",
|
|
||||||
fontsize=fontsize * TEXTBOX_FONT_SCALE,
|
|
||||||
lineheight=1.5,
|
|
||||||
align=fitz.TEXT_ALIGN_LEFT,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
page.insert_text(
|
continue
|
||||||
rect.bl,
|
formatted = _format_value(value, m.format)
|
||||||
formatted,
|
_set_cell_value(ws, m.target_cell, formatted)
|
||||||
fontname="helv",
|
|
||||||
fontsize=fontsize,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# Hauptfunktion: Reports generieren
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def generate_activity_reports(
|
def generate_activity_reports(
|
||||||
employee_records: dict[str, list[EmployeeRecord]],
|
employee_records: dict[str, list[EmployeeRecord]],
|
||||||
template_path: Path,
|
template_path: Path,
|
||||||
@@ -246,14 +120,14 @@ def generate_activity_reports(
|
|||||||
filename_pattern: str,
|
filename_pattern: str,
|
||||||
) -> list[Path]:
|
) -> list[Path]:
|
||||||
"""
|
"""
|
||||||
Erzeugt für jeden Mitarbeiter ein PDF.
|
Befüllt vor-duplizierte Template-Sheets mit Mitarbeiterdaten.
|
||||||
Pro Monat werden ALLE Template-Seiten kopiert und mit Daten befüllt.
|
Erzeugt KEINE neuen Sheets – verwendet ausschließlich die im Template
|
||||||
|
bereits vorhandenen Sheet-Kopien.
|
||||||
"""
|
"""
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
generated_files: list[Path] = []
|
generated_files: list[Path] = []
|
||||||
|
|
||||||
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
|
field_map: dict[str, OutputMapping] = {m.source_field: m for m in mapping}
|
||||||
field_info = _scan_template_fields(template_path, mapping)
|
|
||||||
|
|
||||||
for employee_name, records in employee_records.items():
|
for employee_name, records in employee_records.items():
|
||||||
if not records:
|
if not records:
|
||||||
@@ -266,24 +140,31 @@ def generate_activity_reports(
|
|||||||
filename = filename_pattern.format(first_name=safe_first, last_name=safe_last)
|
filename = filename_pattern.format(first_name=safe_first, last_name=safe_last)
|
||||||
output_path = output_dir / filename
|
output_path = output_dir / filename
|
||||||
|
|
||||||
|
wb = load_workbook(template_path)
|
||||||
sorted_records = sorted(records, key=lambda r: r.month_index)
|
sorted_records = sorted(records, key=lambda r: r.month_index)
|
||||||
|
total_needed = len(sorted_records)
|
||||||
|
available_sheets = wb.worksheets
|
||||||
|
|
||||||
# Ergebnis-PDF erstellen
|
if len(available_sheets) < total_needed:
|
||||||
result_doc = fitz.open()
|
wb.close()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Template hat nur {len(available_sheets)} Sheets, "
|
||||||
|
f"aber {total_needed} werden benötigt für '{employee_name}'."
|
||||||
|
)
|
||||||
|
|
||||||
for record in sorted_records:
|
for idx, record in enumerate(sorted_records):
|
||||||
# Template frisch öffnen für jeden Monat
|
sheet = available_sheets[idx]
|
||||||
temp_doc = fitz.open(template_path)
|
mi = record.month_index
|
||||||
pages = [temp_doc[i] for i in range(temp_doc.page_count)]
|
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)
|
||||||
|
|
||||||
_fill_pdf_pages(pages, record, employee_name, mapping, field_info)
|
# Lösche überzählige Sheets (von hinten nach vorne)
|
||||||
|
for extra in reversed(available_sheets[total_needed:]):
|
||||||
|
wb.remove(extra)
|
||||||
|
|
||||||
# Alle modifizierten Seiten ins Ergebnis-PDF einfügen
|
wb.save(output_path)
|
||||||
result_doc.insert_pdf(temp_doc)
|
wb.close()
|
||||||
temp_doc.close()
|
|
||||||
|
|
||||||
result_doc.save(output_path)
|
|
||||||
result_doc.close()
|
|
||||||
generated_files.append(output_path)
|
generated_files.append(output_path)
|
||||||
|
|
||||||
return generated_files
|
return generated_files
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+77
-84
@@ -1,10 +1,10 @@
|
|||||||
"""End-to-End-Test: Reale Input-Datei + reales PDF-Template → PDF-Output für erste Person."""
|
"""End-to-End-Test: Reale Input-Datei + reales Template → Excel-Output für erste Person."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import fitz # PyMuPDF
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
# Projekt-Root zum Python-Path hinzufügen
|
# Projekt-Root zum Python-Path hinzufügen
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -21,7 +21,7 @@ from src.writer import generate_activity_reports
|
|||||||
|
|
||||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||||
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx"
|
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx"
|
||||||
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf"
|
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.xlsx"
|
||||||
|
|
||||||
|
|
||||||
def _load_app_config():
|
def _load_app_config():
|
||||||
@@ -62,62 +62,20 @@ def output_dir(tmp_path):
|
|||||||
return out
|
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
|
# End-to-End Tests
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
class TestEndToEndFirstPerson:
|
class TestEndToEndFirstPerson:
|
||||||
"""End-to-End: Reale Input-Datei + reales PDF-Template → nur erste Person."""
|
"""End-to-End: Reale Input-Datei + reales Template → nur erste Person."""
|
||||||
|
|
||||||
def test_input_file_exists(self):
|
def test_input_file_exists(self):
|
||||||
"""Stellt sicher, dass die reale Input-Datei vorhanden ist."""
|
"""Stellt sicher, dass die reale Input-Datei vorhanden ist."""
|
||||||
assert INPUT_FILE.exists(), f"Input-Datei nicht gefunden: {INPUT_FILE}"
|
assert INPUT_FILE.exists(), f"Input-Datei nicht gefunden: {INPUT_FILE}"
|
||||||
|
|
||||||
def test_template_file_exists(self):
|
def test_template_file_exists(self):
|
||||||
"""Stellt sicher, dass das reale PDF-Template vorhanden ist."""
|
"""Stellt sicher, dass das reale Template vorhanden ist."""
|
||||||
assert TEMPLATE_FILE.exists(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
|
assert TEMPLATE_FILE.exists(), f"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):
|
def test_parse_real_input(self, parsed_records):
|
||||||
"""Validiert das Parsen der realen Input-Datei."""
|
"""Validiert das Parsen der realen Input-Datei."""
|
||||||
@@ -170,51 +128,86 @@ class TestEndToEndFirstPerson:
|
|||||||
)
|
)
|
||||||
output_file = generated[0]
|
output_file = generated[0]
|
||||||
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}"
|
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}"
|
||||||
assert output_file.suffix == ".pdf"
|
assert output_file.suffix == ".xlsx"
|
||||||
|
|
||||||
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.pdf"
|
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.xlsx"
|
||||||
assert output_file.name == erwarteter_name, (
|
assert output_file.name == erwarteter_name, (
|
||||||
f"Erwartet '{erwarteter_name}', aber '{output_file.name}'"
|
f"Erwartet '{erwarteter_name}', aber '{output_file.name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5. PDF öffnen und Inhalt validieren
|
# 5. Output-Datei öffnen und Inhalt validieren
|
||||||
doc = fitz.open(output_file)
|
wb = load_workbook(output_file, data_only=True)
|
||||||
assert doc.page_count == 24, (
|
|
||||||
f"Erwartet 24 Seiten (12 Monate × 2 Seiten), aber {doc.page_count}"
|
# 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)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6. Prüfe, dass KEINE _Field-Platzhalter mehr im Output sind
|
# Prüfe Sheet-Namen für alle 12 Monate
|
||||||
full_text = ""
|
expected_titles = {
|
||||||
for page in doc:
|
"01_Januar_Anl", "02_Februar_Anl", "03_März_Anl",
|
||||||
full_text += page.get_text()
|
"04_April_Anl", "05_Mai_Anl", "06_Juni_Anl",
|
||||||
assert "_Field" not in full_text, (
|
"07_Juli_Anl", "08_August_Anl", "09_September_Anl",
|
||||||
"Es sind noch _Field-Platzhalter im Output-PDF vorhanden!"
|
"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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7. Prüfe Seite 0 (Januar, Seite 1 – Stammdaten)
|
# Prüfe exemplarisch das Januar-Sheet (01_Januar_Anl)
|
||||||
jan0_text = doc[0].get_text()
|
jan = wb["01_Januar_Anl"]
|
||||||
assert "100740439" in jan0_text, "Antragsnummer fehlt auf Januar-Seite 1"
|
assert jan["B3"].value == "100740439", (
|
||||||
assert "Draßdo, Cornelia" in jan0_text, "Name fehlt auf Januar-Seite 1"
|
f"Erwartet '100740439' in B3, aber '{jan['B3'].value}'"
|
||||||
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 jan["B17"].value == "Draßdo, Cornelia", (
|
||||||
assert "01.2025" in jan0_text, "Monat fehlt auf Januar-Seite 1"
|
f"Erwartet 'Draßdo, Cornelia' in B17, aber '{jan['B17'].value}'"
|
||||||
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"
|
# 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}'"
|
||||||
|
)
|
||||||
|
|
||||||
# 8. Prüfe Seite 1 (Januar, Seite 2 – Unterschriftsblock mit Ort/Datum)
|
# Prüfe exemplarisch das Juli-Sheet (Stellenanteil wechselt auf 40%)
|
||||||
jan1_text = doc[1].get_text()
|
jul = wb["07_Juli_Anl"]
|
||||||
assert "100740439" not in jan1_text, "Antragsnummer sollte nicht auf Seite 2 sein"
|
assert jul["R17"].value == "40", (
|
||||||
assert "Dresden" in jan1_text, "Ort 'Dresden' fehlt auf Januar-Seite 2"
|
f"Erwartet '40' in R17 (Juli), aber '{jul['R17'].value}'"
|
||||||
assert "31.01.2025" in jan1_text, "Datum '31.01.2025' fehlt auf Januar-Seite 2"
|
)
|
||||||
# Tätigkeit wiederholt auf Seite 2
|
assert jul["R21"].value == "07.2025", (
|
||||||
assert "Tätigkeiten im Praxisbereich Garten" in jan1_text, "Tätigkeit fehlt auf Januar-Seite 2"
|
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}'"
|
||||||
|
)
|
||||||
|
|
||||||
# 9. Prüfe Juli (Seite 12+13) – Stellenanteil wechselt auf 40%
|
# Prüfe Dezember-Sheet (Datum: 31.12.2025)
|
||||||
jul0_text = doc[12].get_text()
|
dez = wb["12_Dezember_Anl"]
|
||||||
assert "40" in jul0_text, "Stellenanteil 40% fehlt auf Juli-Seite 1"
|
assert dez["J95"].value == "31.12.2025", (
|
||||||
assert "07.2025" in jul0_text, "Monat 07.2025 fehlt auf Juli-Seite 1"
|
f"Erwartet '31.12.2025' in J95, aber '{dez['J95'].value}'"
|
||||||
|
)
|
||||||
|
|
||||||
jul1_text = doc[13].get_text()
|
wb.close()
|
||||||
assert "31.07.2025" in jul1_text, "Datum 31.07.2025 fehlt auf Juli-Seite 2"
|
|
||||||
|
|
||||||
doc.close()
|
|
||||||
Reference in new issue
Block a user