Author SHA1 Message Date
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
trnq1ll0 dbe325d305 add: ColdRun-Mode in dem automatisch ein kompletter Durchlauf stattfindet ohne GUI 2026-06-10 21:51:17 +02:00
trnq1ll0 6e541a04df add first test tools 2026-06-10 21:27:27 +02:00
trnq1ll0 6fda6aea6b automatisch selektieren wenn in Input- oder Template-Ordner nur ein Element/File liegt 2026-06-10 17:16:43 +02:00
trnq1ll0 9a44f8904e Rahmen um MItarbeiterliste 2026-06-10 17:12:32 +02:00
trnq1ll0 b05849e673 fix: Stellenanteil in Prozentangabe und jeweiliges Monatsende als Datum eintragen 2026-06-10 17:03:46 +02:00
trnq1ll0 0d390f79b4 add: open generated files an folder on click 2026-06-10 16:05:19 +02:00
trnq1ll0 f4afd4b2e5 input Validierung und UI handling fühlbar beschleunigt 2026-06-10 08:33:34 +02:00
trnq1ll0 31cdc7747f fix: input validierung überspringt erstes sheet, weil diese nur zur Übersicht ist 2026-06-10 07:59:02 +02:00
trnq1ll0 1e44aaab9d erzeuge macos start.command 2026-06-09 10:17:14 +02:00
trnq1ll0 39d3ee8baf template mit 36 Monaten erzeugt. Denn es werden keine sheets selber erstellt. 2026-06-09 10:14:35 +02:00
trnq1ll0 56d605c0cb fix: es werden nun die richtigen Felder beschrieben 2026-06-09 09:33:57 +02:00
trnq1ll0 19dfc06272 add start.sh 2026-06-09 08:48:41 +02:00
trnq1ll0 044e4df783 deepseek den plan umsetzen lassen 2026-06-09 08:33:47 +02:00
trnq1ll0 6c60a72e0f first shot plan 2026-06-08 22:56:16 +02:00
52 changed files with 2044 additions and 2070 deletions

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
# 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).
**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 PDF-Template aus dem Templatefolder gewählt werden
- 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.
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 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
In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche Konfigurationsparameter es gibt.
---
## Phasen-Plan
### Phase 1: Projekt-Grundgerüst & Konfiguration
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 → `.pdf`)
3. **`config/input-fields.json` Schema definieren** — Spalten der Input-Tabelle mit ihren exakten Überschriften
- 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
- 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/`)
5. **`src/__init__.py` + `src/models.py`** — Datenklassen
- `EmployeeRecord`: name, vorname, nachname, antragsnummer, qualifikation, funktion, taetigkeit, stellenanteil, monat, jahr, … (Felder aus input-fields.json)
- `AppConfig`: geladene settings.ini + input/output-schemas
6. **`src/config.py`** — Konfigurations-Loader
- `load_settings()`: liest `config/settings.ini` mit `configparser`
- `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 `.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: `list[EmployeeRecord]`
### Phase 3: Writer & Validierung (PDF-basiert)
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:
- 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 (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
- `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
- `st.button("Tätigkeitsnachweise generieren")`
- Bei Button-Click:
1. Filtere Employee Records auf selektierte Mitarbeiter
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 (unverändert)
12. **`README.md` aktualisieren** — Vollständige Doku
---
## Relevante Dateien (zu erstellen/ändern)
| Datei | Aktion |
|-------|--------|
| `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 |
## Projektstruktur
```
/
├── .copilot/
│ ├── PLAN.md ← diese Datei
│ └── TASKS.md ← Aufgabenliste
├── input/
│ └── *.xlsx ← Tabelle mit den Input-daten
├── config/
│ ├── settings.ini ← Einstellungen der App
│ ├── input-fields.json ← Schema der Inputdaten-Tabelle
│ └── 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 ← 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, PyMuPDF
```
## Verifikation
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
```
+47
View File
@@ -0,0 +1,47 @@
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App) – PDF-Migration
> **Migration**: Das XLSX-Template war schreibgeschützt und wurde durch ein PDF-Template ersetzt.
> Alle `_Field`-Platzhalter im PDF werden durch echte Daten ersetzt.
## 🔄 Migration: XLSX → PDF Template ✅
- [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
## 🔍 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)
## ⚙️ 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
BIN
View File
Binary file not shown.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
-108
View File
@@ -1,108 +0,0 @@
# SAB Taetigkeitsnachweise Generator - Implementierungsplan
## Ziel
Monatliche SAB-Taetigkeitsnachweise automatisiert erzeugen auf Basis von:
- PS_PW.xlsx (monatliche gefoerderte Stunden je Projektanteil)
- Stellenbeschreibungen.xlsx (Stammdaten je Mitarbeiter/Funktion)
- Taetigkeitsnachweis Stellenfoerderung.xlsx (Ausgabevorlage)
## Scope
- Nur Verzeichnisse unter Tool verwenden.
- Eingaben nur aus input.
- Vorlage nur aus template.
- Ausgaben nur nach output.
## Annahmen
- PS_PW.xlsx und Stellenbeschreibungen.xlsx enthalten jeweils Tabelle1.
- Monatsmarker in PS_PW.xlsx stehen in Zeile 2 im Format ab 01.MM.YYYY.
- Name ist primaerer Match-Schluessel zwischen beiden Quellen.
- Template hat genau ein Quellblatt, das geklont wird.
## Architektur
- cli: Kommandozeilen-Einstieg und Orchestrierung
- parser: Einlesen und Normalisierung der beiden Quelldateien
- matcher: Zuordnung und Disambiguierung
- validator: Source- und Business-Validierung
- writer: Template-Klonen, Zellen befuellen, Speichern
- model: gemeinsame Datentypen
- util: Konvertierung, Datums-/Text-Helfer, Logging
## Reihenfolge (Task-Flow)
1. T1 Plan finalisieren
2. T2 Konfiguration definieren
3. T3 Projektgeruest anlegen
4. T4 Abhaengigkeiten einrichten
5. T5 Parser PS_PW
6. T6 Parser Stellenbeschreibungen
7. T7 Foerderquote berechnen
8. T8 Matching-Engine
9. T9 Source-Validierung
10. T10 Business-Validierung
11. T11 Template-Verarbeitung
12. T12 Dateinamen + Persistenz
13. T13 CLI Pipeline
14. T14 Vorschau + Bestaetigung
15. T15 Smoke-Test
16. T16 Fachtests
17. T17 Ergebnisabnahme
## Definition of Done je Task
### T1 Plan final
- DoD: Dieser Plan ist vollstaendig, nachvollziehbar und in Reihenfolge mit klarer DoD-Liste dokumentiert.
### T2 config.json
- DoD: Alle Pfade, Dateinamen, Sheetnamen, Spalten, Zielzellen und Dateinamenregeln sind extern konfigurierbar.
### T3 Projektgeruest
- DoD: Ordner-/Modulstruktur steht, zentrale Datentypen und Fehler-/Log-Basis sind vorhanden.
### T4 Abhaengigkeiten
- DoD: requirements gepflegt, lokale Installation dokumentiert, Ausfuehrung startet ohne Importfehler.
### T5 Parser PS_PW
- DoD: Monatsspalte wird eindeutig gefunden, Records sind normalisiert und Mitarbeiterkontext korrekt vererbt.
### T6 Parser Stellenbeschreibungen
- DoD: Stammdaten sauber eingelesen, Prozentwerte robust konvertiert, Mehrfacheintraege bleiben erhalten.
### T7 Foerderquote
- DoD: Formel korrekt implementiert, Grenz-/Rundungsfaelle dokumentiert und testsicher.
### T8 Matching
- DoD: Eindeutige Zuordnung pro aktivem Record oder klarer Fehlerstatus (missing/ambiguous).
### T9 Source-Validierung
- DoD: Alle Struktur- und Formatpruefungen laufen vor Verarbeitung und liefern konkrete Fehlermeldungen.
### T10 Business-Validierung
- DoD: Aktivitaet, Eindeutigkeit, Template-Regeln und Dateinamenkollisionen werden vor dem Schreiben geprueft.
### T11 Template-Verarbeitung
- DoD: Ein Quellblatt wird pro Zuordnung geklont und korrekt mit assignment-spezifischen Werten befuellt.
### T12 Dateinamen/Speichern
- DoD: Ausgabedateien folgen Muster Taetigkeitsnachweis_YYYY_MM_Vorname_Nachname.xlsx und werden konfliktfrei gespeichert.
### T13 CLI
- DoD: generate.py akzeptiert month/year, orchestriert End-to-End und liefert sinnvolle Exit-Codes.
### T14 Vorschau/Bestaetigung
- DoD: Vor Schreibzugriff wird Vorschau angezeigt; Abbruch ist ohne Dateiaenderung moeglich.
### T15 Smoke-Test
- DoD: Mindestens ein realer Monatslauf erzeugt valide Dateien in output.
### T16 Fachtests
- DoD: Abdeckung fuer Mehrfachzuordnung, fehlende Matches, ungueltige Monatsmarker und fehlende Struktur.
### T17 Ergebnisabnahme
- DoD: Pflichtfelder, Monats-/Datumswerte und Taetigkeitstexte stimmen mit Template- und Fachregeln ueberein.
## Risiko- und Entscheidungslog
- Name-Matching kann uneinheitliche Schreibweisen enthalten; ggf. Normalisierung (Whitespace/Kommas) zentral in util.
- Prozentvergleich kann Rundungsabweichungen haben; toleranzbasierten Vergleich definieren.
- Mehrdeutige Matches nicht still aufloesen; immer sichtbar validieren.
## Start der Umsetzung
- Aktiver Schritt: T2 (Konfiguration als naechste technische Umsetzung)
View File
Whitespace-only changes.
-121
View File
@@ -1,121 +0,0 @@
# SAB Taetigkeitsnachweise Generator - Aufgabenliste
## Ziel
Projekt vollstaendig umsetzen: aus PS_PW.xlsx und Stellenbeschreibungen.xlsx monatliche Taetigkeitsnachweise per Template erzeugen.
## Arbeitsmodus
- [ ] Immer nur genau eine Aufgabe aktiv bearbeiten.
- [ ] Nach Abschluss jeder Aufgabe: Ergebnis pruefen und abhaken.
- [ ] Bei Blockern: Fehler dokumentieren und naechsten konkreten Schritt notieren.
- [ ] Nach jedem erledigten task einen git commit erzeugen
## Phase 1 - Planung und Konfiguration
- [x] T1: Plan final im Plan-Dokument pflegen (Scope, Annahmen, Reihenfolge, DoD pro Aufgabe).
- [x] T2: `config.json` Struktur festlegen
- [ ] Pfade fuer `input/`, `template/`, `output/`
- [ ] Dateinamen der drei Excel-Dateien
- [ ] Sheetnamen und Spaltenzuordnung je Quelle
- [ ] Zielzellen-Mapping fuer Template
- [ ] Dateinamenmuster fuer Ausgaben
- [ ] Regeln fuer Monatsmarker und Datumsformat
## Phase 2 - Projektgeruest und Basiskomponenten
- [x] T3: Python-Projektstruktur anlegen
- [x] Modulaufteilung (cli, parser, matcher, validator, writer, model, util)
- [x] Gemeinsame Datenmodelle (z. B. dataclasses) definieren
- [x] Zentrales Logging/Fehlermodell vorbereiten
- [x] T4: Abhaengigkeiten und Laufzeit vorbereiten
- [x] benoetigte Pakete festlegen (z. B. openpyxl, click)
- [x] requirements-Datei pflegen
- [x] Startkommando fuer lokale Ausfuehrung dokumentieren
## Phase 3 - Einlesen der Quelldaten
- [x] T5: Parser fuer PS_PW.xlsx implementieren
- [x] Monatsmarker in Zeile 2 erkennen und parsen (`ab 01.MM.YYYY`)
- [x] Zielspalte fuer requested month/year eindeutig bestimmen
- [x] Mitarbeiterkontext ueber Mehrzeilen-Eintraege vererben
- [x] Werte normalisieren (`-`, `0`, leer, numerisch)
- [x] Normalisierte Records erzeugen
- [x] T6: Parser fuer Stellenbeschreibungen.xlsx implementieren
- [x] Felder ID, Antragsnummer, Name, Qualifikation, Stellenanteil, Funktion, Taetigkeit einlesen
- [x] Prozentwerte (deutsches Dezimalformat) robust in Zahlen umwandeln
- [x] Mehrfache Eintraege je Name korrekt behalten
## Phase 4 - Matching und Fachlogik
- [x] T7: Foerderquote berechnen
- [x] Formel: funded_weekly_hours / contract_weekly_hours * 100
- [x] Rundungs-/Vergleichsregeln definieren
- [x] T8: Matching-Engine bauen
- [x] Primaerschluessel Name anwenden
- [x] Kandidaten via Stellenanteil/Foerderquote aufloesen
- [x] Ambigue und fehlende Treffer sauber markieren
- [x] Hinweise aus Bemerkungsspalte als Validierungshinweis weitergeben
## Phase 5 - Validierung
- [x] T9: Source-Validierung implementieren
- [x] Dateien vorhanden und lesbar
- [x] Benoetigte Sheets vorhanden
- [x] Spalten vorhanden
- [x] Monatsmarker parsebar
- [x] Gewuenschter Monat exakt einmal vorhanden
- [x] Stunden-/Vertragswerte gueltig
- [x] T10: Business-Validierung implementieren
- [x] Mindestens ein aktiver Fall vorhanden
- [x] Jeder aktive Fall hat eindeutigen Match oder klaren Fehler
- [x] Template enthaelt genau ein Quellblatt
- [x] Dateinamenkollisionen vermeiden
- [x] Pflicht-Zielzellen vollstaendig konfiguriert
## Phase 6 - Ausgabeerzeugung
- [x] T11: Template-Verarbeitung bauen
- [x] Template-Workbook laden
- [x] Sheet pro aktiver Zuordnung klonen
- [x] Inhalte assignment-spezifisch in Zielzellen schreiben
- [x] T12: Dateinamen und Speichern
- [x] Muster: `Taetigkeitsnachweis_YYYY_MM_Vorname_Nachname.xlsx`
- [x] Pro Mitarbeiter genau eine Datei pro Monat erzeugen
- [x] Bei mehreren Zuordnungen: mehrere Sheets in derselben Datei
## Phase 7 - CLI und Benutzerfluss
- [x] T13: CLI in `generate.py` implementieren
- [x] Parameter `--month` und `--year`
- [x] Pipeline orchestrieren: parse -> match -> validate -> preview -> write
- [x] Kontrollierter Exit-Code bei Fehlern
- [x] T14: Vorschau + Bestaetigung vor Schreiben
- [x] Uebersicht zu Berichtsmonat, aktiven Faellen, Fehlern
- [x] Liste der geplanten Ausgabedateien
- [x] Interaktive Bestaetigung (Abbruch ohne Schreibzugriff moeglich)
## Phase 8 - Tests und Abnahme
- [ ] T15: Smoke-Test fuer End-to-End-Lauf
- [x] Beispielmonat ausfuehren
- [x] Pipeline laeuft bis Dateiausgabe durch (0 Dateien, da 0 gematchte Faelle)
- [ ] Dateien in `output/` entstehen erwartungsgemaess
- [ ] DATENBLOCKER: `Stellenbeschreibungen.xlsx` enthaelt nur Meyer,Johannes (25%/75%).
Alle anderen aktiven Mitarbeiter aus PS_PW.xlsx fehlen als Stammdaten.
Meyer's Foerderquote (85%/15%) stimmt nicht mit eingetragenem Stellenanteil (25%/75%) ueberein.
-> Stellenbeschreibungen.xlsx mit allen aktiven Mitarbeitern und korrekten Anteilen befuellen.
- [x] Template-Zielzellen konfiguriert (A3, A17, Q17, A21, Q21, A25, A31, I95)
- [x] Template-Sheet-Pfad konfiguriert (<MM_YYYY>_<Stellenbeschreibung>)
- [ ] T16: Fachtests definieren und ausfuehren
- [ ] Mehrfachzuordnung eines Mitarbeiters
- [ ] Fehlender Match
- [ ] Ungueltige Monatsmarker
- [ ] Fehlende Pflichtspalte/Sheet
- [ ] T17: Ergebnispruefung gegen Template-Regeln
- [ ] Pflichtfelder korrekt befuellt
- [ ] Taetigkeitstext inkl. Zeilenumbrueche korrekt
- [ ] Datums- und Monatsanzeige korrekt
## Umsetzungsreihenfolge
1. T1 -> T2
2. T3 -> T4
3. T5 und T6 (danach zusammenfuehren)
4. T7 -> T8
5. T9 -> T10
6. T11 -> T12
7. T13 -> T14
8. T15 -> T16 -> T17
## Aktueller Startpunkt
- [ ] Naechste aktive Aufgabe: T15 (Smoke-Test fuer End-to-End-Lauf)
@@ -1,322 +0,0 @@
---
description: "Use when implementing, extending, or reviewing the application. Contains the full application masterplan, data sources, workbook mapping rules, and generation logic for SAB Tätigkeitsnachweise."
applyTo: "**/*.py"
---
# Application Masterplan: SAB Taetigkeitsnachweis Generator
## Purpose
A Python program that reads personnel allocation data from two Excel workbooks and generates monthly SAB Taetigkeitsnachweise as Excel files based on a dedicated template workbook.
The application exists to replace a manual process that currently causes two recurring problems:
1. Incomplete monthly Taetigkeitsnachweise because employees submit them late or not at all.
2. Incorrect entries because staffing shares, project assignments, and role changes vary over time and are no longer reliable to maintain manually.
The system must therefore produce monthly, auditable, and consistent output from a central data basis.
## Project Scope and Paths
The tool must operate exclusively below `Tool/`.
- Input files are read only from `Tool/input/`.
- Template files are read only from `Tool/template/`.
- Generated files are written only to `Tool/output/`.
- No runtime reads/writes are allowed outside `Tool/`.
## Source Workbooks
The implementation is based on three concrete Excel files.
### 1. Allocation Overview: `PS_PW.xlsx`
This workbook is the monthly allocation matrix that determines how many funded hours a person has in each project for each month.
Observed structure in `Tabelle1`:
- Column `A`: Employee name in `Last name, First name` format.
- Column `B`: Contracted weekly hours / AV.
- Column `C`: Project or assignment key, e.g. `PS`, `PW`, `BeWo`, `NB`, `PS / NB`.
- Columns `D` to at least `H`: One funded-hours column per month.
- Row `2`: Month markers per column, e.g. `ab 01.01.2026`, `ab 01.02.2026`, `ab 01.03.2026`, `ab 01.04.2026`, `ab 01.05.2026`.
- Column `I` (or next free column after month values): Free-text remarks describing assignment changes.
Interpretation rules:
- Each person can span multiple consecutive rows when they have multiple project shares.
- The first row of a person contains the name and total weekly hours.
- Following rows without a name belong to the same person and describe additional project shares.
- Numeric values in the month columns are funded weekly hours for the respective month.
- zero (0) or `-` means no funded share in that project for that month.
- Free-text remarks may contain clarifying timing details and must be surfaced in validation output, but the primary logic is driven by the structured columns, not by parsing remarks.
### 2. Central Master Data: `Stellenbeschreibungen.xlsx`
This workbook is the central basis data source for descriptive person/project content used to fill the SAB form.
Observed structure in `Tabelle1`:
| Column | Meaning |
|---|---|
| `A` | `ID` |
| `B` | `Antragsnummer` |
| `C` | `Name` |
| `D` | `Qualifikation` |
| `E` | `Stellenanteil` |
| `F` | `Funktion` |
| `G` | `Taetigkeit` |
Interpretation rules:
- One employee can appear multiple times with different `Stellenanteil` and `Funktion` values.
- `Stellenanteil` is stored as a percentage-like value such as `25,00` or `75,00`.
- `Tätigkeit` contains multi-line free text that must be written into the SAB template unchanged except for Excel-compatible line break handling.
- `Antragsnummer` is sourced from this workbook when a matching employee/function combination exists.
### 3. SAB Output Template: `Tätigkeitsnachweis Stellenförderung.xlsx`
This workbook is the Excel output format mandated by SAB and is the dedicated template base for generated files.
Template rule:
- The template workbook contains exactly one sheet.
- This one sheet is the blueprint that must be copied for generated sheets.
Observed content blocks in the workbook:
- `Antragsnummer`
- `Name, Vorname`
- `Qualifikation`
- `Funktion`
- `Taetigkeiten inkl. Angaben zu Dienstreisen fuer das Projekt im Abrechnungszeitraum`
- final declaration / signature section
The generator must treat this workbook as the authoritative structure for the output file. Placeholder values in the template, if present, are ignored and overwritten during generation. Exact target cells or named ranges are implementation details and must be externalized in configuration once confirmed.
## Core Business Goal
For a requested reporting month, generate one SAB Taetigkeitsnachweis per active employee assignment so that the document shows the correct funded staff share for that month.
The generated workbook must combine:
- allocation hours from `PS_PW.xlsx`
- descriptive employee master data from `Stellenbeschreibungen.xlsx`
- layout and target cells from `Tätigkeitsnachweis Stellenförderung.xlsx`
## Reporting Model
### Reporting Period
The CLI works on a single target month and year, for example `04/2026`.
The application must determine the single month column from `PS_PW.xlsx` that matches the requested month.
### Monthly Column Resolution
The date markers in row `2` of `PS_PW.xlsx` define one column per month. For a target month:
1. Read all month markers from row `2`.
2. Parse each marker as first day of month (`ab 01.MM.YYYY`).
3. Select the column whose parsed date exactly matches the requested month and year.
4. Use this single column value as the funded weekly hours for that employee/project row.
5. If the value is `-`, `0`, or empty, the employee/project combination is not active for that month.
### Employee Grouping in `PS_PW.xlsx`
Because some rows omit the employee name, the parser must normalize the sheet into records with an inherited person context.
Normalized record shape:
| Field | Source |
|---|---|
| `employee_name` | current non-empty value from column `A` |
| `contract_hours_weekly` | current non-empty value from column `B` |
| `project_key` | column `C` |
| `monthly_hours` | single month column selected from `D+` via row `2` |
| `notes` | remarks column when present |
Rows without a project key are ignored.
## Matching Logic Across Workbooks
The generator must join records across the two source workbooks.
### Primary Match Key
- `Name` in `Stellenbeschreibungen.xlsx` matches `employee_name` from normalized `PS_PW.xlsx` records.
### Secondary Disambiguation
If one person has multiple master-data rows in `Stellenbeschreibungen.xlsx`, choose the row whose `Stellenanteil` best matches the funded share derived from `PS_PW.xlsx` for the requested month.
Derived funded share formula:
$$
Foerderquote = (funded_weekly_hours / contract_weekly_hours) * 100
$$
Example:
- contract weekly hours = `20`
- funded weekly hours = `10`
- Foerderquote = `50 %`
Matching rule:
- Convert `Stellenbeschreibungen.xlsx`.`Stellenanteil` from German decimal text to a numeric percentage.
- Choose the row whose percentage equals the derived Foerderquote.
- If several rows match, prefer the one whose `Funktion` best matches the assignment context or require explicit disambiguation in validation output.
### Missing Match Handling
If no master-data row can be matched for an active allocation record:
- log a validation error
- do not silently invent qualification, function, or activity text
- depending on CLI mode, either skip that record or abort the full generation run
## Output Workbook Structure
Each generated file is an Excel workbook derived from the SAB template. One file corresponds to one employee for one reporting month.
Sheet rule for multiple assignments:
- If an employee has one active assignment in the month, the output workbook contains one sheet.
- If an employee has multiple active assignments (multiple Stellen/Funktionen) in the month, the output workbook must contain one separate sheet per assignment.
- Every generated sheet is a copy of the single template sheet and is then filled with the assignment-specific data.
Required logical output fields:
| Output field | Source |
|---|---|
| `Antragsnummer` | `Stellenbeschreibungen.xlsx`.`Antragsnummer` |
| `Name, Vorname` | matched employee name |
| `Qualifikation` | `Stellenbeschreibungen.xlsx`.`Qualifikation` |
| `Funktion` | `Stellenbeschreibungen.xlsx`.`Funktion` |
| `Taetigkeiten ...` | `Stellenbeschreibungen.xlsx`.`Taetigkeit` |
| reporting month label | derived from CLI month/year |
| Foerderquote / Stellenanteil for the month | derived from `PS_PW.xlsx` and contract hours |
| generated-on date | current system date |
If the SAB sheet requires additional header or footer fields that are not yet represented in the example extraction, those mappings must be added to configuration, not hardcoded into business logic.
## File Generation Strategy
- The generator creates one output workbook per employee and month.
- The generator copies the single configured template sheet as many times as needed.
- It writes the matched values into configured target cells.
- It saves one output file per employee for the target month.
Recommended default filename pattern:
`Tätigkeitsnachweis_<YYYY>_<MM>_<FirstName>_<LastName>.xlsx`
If an employee has multiple active assignments in the same month, the resulting Excel file contains one separate sheet per assignment.
## Validation Rules
Before any workbook is written, the application must run a full validation phase.
### Source Validation
| Check | Description |
|---|---|
| Source files exist | `Tool/input/PS_PW.xlsx`, `Tool/input/Stellenbeschreibungen.xlsx`, and `Tool/template/Tätigkeitsnachweis Stellenförderung.xlsx` are readable |
| Required sheets exist | `Tabelle1` exists in both source workbooks; configured template sheet exists |
| Month-marker row valid | Row `2` in `PS_PW.xlsx` contains parseable monthly markers `ab 01.MM.YYYY` |
| Requested month exists | Exactly one month column in `PS_PW.xlsx` matches the requested month/year |
| Required columns exist | all configured columns are present in both source workbooks |
| Hour values valid | resolved funded hour values are numeric, `0`, or `-` |
| Contract hours valid | total weekly hours are numeric and greater than `0` |
| Share values valid | derived Förderquote is between `0` and `100` unless explicitly allowed otherwise |
| Master-data match exists | every active record can be matched to one descriptive row or is reported as ambiguous/missing |
### Business Validation
| Check | Description |
|---|---|
| Active assignment exists | at least one normalized allocation record resolves to funded hours > `0` in the requested month |
| Match uniqueness | each generated output uses exactly one master-data row |
| Template has one sheet | configured template workbook contains exactly one source sheet to clone |
| Output filename unique | no two generated files resolve to the same path |
| Required template targets configured | all mandatory target cells are configured before writing |
Validation errors are logged and summarized. They must never be silently ignored.
## Console Preview and Confirmation
After validation and before writing files, show a preview similar to:
```text
=== Vorschau der ermittelten Werte ===
Berichtsmonat : 04/2026
PS_PW-Datei : PS_PW.xlsx
Stammdaten : Stellenbeschreibungen.xlsx
Vorlage : Tätigkeitsnachweis Stellenförderung.xlsx
Aktive Faelle : 8
Fehler : 0
--- Zu erzeugende Taetigkeitsnachweise ---
Mitarbeitende(r) Projekt Funktion Stunden Quote
Meyer, Johannes PS Produktionsschulleiter 10 / 40 25,00 %
Meyer, Johannes PW Soz. Paed 30 / 40 75,00 %
...
Moechten Sie die Dateien erzeugen? [j/N]
```
- If validation errors exist, print them before the prompt.
- Continue only if the user explicitly confirms with `j`.
- Any other input aborts the run without writing output files.
## Configuration Rules
All environment-specific details must be stored in `config.json`.
Configuration must include at least:
- path to `Tool/input/PS_PW.xlsx`
- path to `Tool/input/Stellenbeschreibungen.xlsx`
- path to `Tool/template/Tätigkeitsnachweis Stellenförderung.xlsx`
- source sheet names
- source column mappings
- row number for month markers in `PS_PW.xlsx`
- template sheet selection strategy
- output cell mappings
- output directory (`Tool/output/`)
- filename pattern
No workbook-specific coordinates may be buried directly inside business logic.
## CLI Requirements
The CLI entry point is `generate.py` using Click.
Required arguments:
- `--month` target month as `MM`
- `--year` target year as `YYYY`
Optional arguments:
- `--employee` limit generation to one employee name
- `--project` limit generation to one project key
- `--dry-run` run validation and preview without writing files
## Testing Requirements
All business logic must be covered by tests in `tests/`.
At minimum, tests must cover:
- inheritance of employee context across multiple `PS_PW.xlsx` rows
- resolution of the correct month column for a requested month
- conversion of German decimal percentages such as `25,00`
- Foerderquote calculation
- matching of one employee to multiple master-data rows by `Stellenanteil`
- skipping inactive `-` assignments
- validation for missing and ambiguous matches
- generation of collision-free output filenames
Tests must not depend on real employee names or on the exact attached workbooks.
@@ -1,48 +0,0 @@
---
applyTo: '**'
---
<!-- skill: caveman | description: Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, wenyan-lite, wenyan-full, wenyan-ultra. Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. -->
Respond terse like smart caveman. All technical substance stay. Only fluff die.
## Persistence
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active
if unsure. Off only: "stop caveman" / "normal mode".
Default: full. Switch: `/caveman lite|full|ultra`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply),
pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short
synonyms (big not extensive, fix not "implement a solution for"). Technical terms
exact. Code blocks unchanged. Errors quoted exact.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is
likely caused by..." Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Intensity
| Level | Behavior |
|-------|----------|
| lite | No filler/hedging. Keep articles + full sentences. Professional but tight |
| full | Drop articles, fragments OK, short synonyms. Classic caveman |
| ultra | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
| wenyan-lite | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| wenyan-full | Maximum classical terseness. Fully 文言文. 80-90% character reduction |
| wenyan-ultra | Extreme abbreviation while keeping classical Chinese feel. Maximum compression |
## Auto-Clarity
Drop caveman for: security warnings, irreversible action confirmations,
multi-step sequences where fragment order risks misread, user asks to clarify or repeats
question. Resume caveman after clear part done.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level
persist until changed or session end.
-30
View File
@@ -1,30 +0,0 @@
---
applyTo: '**'
---
This is a very professional terminal project on Windows.
The outline and the structure of the project is very well planned and organized written down in the application masterplan instruction file.
# Write Code
You must always make a plan before you write a programm or implement something!
# Make a Plan
Write down your plan into the planning file at: .github\copilot-plan.md
# Create Tasks
- If your plan contains more than 3 issues to work on, create Tasks for every single issue.
- Write down the tasks into the task file: .github\copilot-tasks.md
- Work only a a single task at a time. Work every single task step by step.
# Questions
If the user or I do ask a question, you think about the question and if nessesary ask importand questions back to clarify the context. Than try to find information about the aked question and additional informations.
If I ask a question, don't implement some solution directly, but ask me what to do and ask questions back to clarify the context if nessesary.
# Commit changes in git
Git commits must be always atomic and be written in plain english.
+7 -2
View File
@@ -1,3 +1,8 @@
__pycache__
input/
output/
.venv/
__pycache__/
.DS_Store
*.xlsx\#
.~lock.*#
*.xlsx#
~*.xlsx
+88 -98
View File
@@ -1,117 +1,107 @@
# SAB Tätigkeitsnachweise Tool
Automatisierte Generierung monatlicher Tätigkeitsnachweise (SAB Stellenförderung). Das Tool liest eine zentrale Stundenzuordnung und Stammdaten aus Excel-Dateien, ordnet Mitarbeiter zu Projekten zu und erzeugt pro Mitarbeiter eine vorausgefüllte Excel-Datei auf Basis der SAB-Vorlage.
# Tätigkeitsnachweis-Generator
Streamlit-App zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
## Setup
1. Create and activate a virtual environment.
2. Install dependencies:
1. **Python 3.9+ installieren** (falls nicht vorhanden): https://python.org
2. **Virtuelle Umgebung erstellen und Abhängigkeiten installieren:**
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
```
## Ausführung
Haupteinstiegspunkt ist `generate.py` (Click-basierte CLI):
3. **App starten:**
```bash
python generate.py --month 5 --year 2026
streamlit run app.py
```
### CLI-Optionen
Oder einfach `start.bat` (Windows) bzw. `./start.sh` (macOS/Linux) ausführen.
| Option | Typ | Pflicht | Beschreibung |
|--------|-----|---------|--------------|
| `--month` | 1–12 | Ja | Berichtsmonat |
| `--year` | 2000–2100 | Ja | Berichtsjahr |
| `--yes` | Flag | Nein | Interaktive Bestätigung überspringen |
| `--skip-errors` | Flag | Nein | Generierung trotz nicht-zuordenbarer Einträge fortsetzen |
## Cold Run – Headless-Generierung
## Implementierte Features
`coldrun.py` erzeugt ohne GUI einen Tätigkeitsnachweis für die erste Person –
automatisch mit der ersten Input- und Template-Datei. Ergebnis wird auf macOS
im Finder geöffnet.
### Pipeline (generate.py)
Vollständige Generierungs-Pipeline mit folgenden Schritten:
1. **Konfiguration laden** – `config.json` wird eingelesen
2. **Quellvalidierung** – Prüfung der Eingabedateien auf Existenz, Sheets, Spalten, Monatsmarker
3. **Parsing** – Stundenzuordnung und Stammdaten aus Excel einlesen
4. **Matching** – Zuordnung der Mitarbeiter über normalisierte Namensabgleiche
5. **Geschäftsregel-Validierung** – Duplikate, fehlende Template-Sheets, Output-Kollisionen
6. **Vorschau** – Zusammenfassung der geplanten Ausgabe (Dry-Run-Ansicht)
7. **Bestätigung** – Interaktive Abfrage (überspringbar mit `--yes`)
8. **Generierung** – Excel-Workbooks erstellen und in `output/` speichern
### Parser (src/taetigkeitsnachweis/parser.py)
- **Stundenzuordnung parsen** (`parse_allocation_records`): Liest `PS_PW.xlsx`, erkennt Monatsspalte über Datumsmarker (`ab DD.MM.YYYY`), vererbt Mitarbeiter-/Vertragsstunden-Kontext über Zeilen
- **Stammdaten parsen** (`parse_masterdata_records`): Liest `Stellenbeschreibungen.xlsx` mit Antragsnummer, Qualifikation, Funktion, Tätigkeiten etc.
- Erkennung inaktiver Werte (Elternzeit, Kündigung, etc.)
### Matcher (src/taetigkeitsnachweis/matcher.py)
- **Namens-Normalisierung**: Trimming, Akzente entfernen, Casefolding, Leerzeichenbereinigung
- **Flexible Namens-Zuordnung**: Unterstützt „Nachname, Vorname" ↔ „Vorname Nachname"
- **Disambiguierung**: Bei mehreren Stammdaten-Treffern wird über `project_scope` aufgelöst
- **Förderquote-Berechnung**: `(geförderte Stunden / Vertragsstunden) × 100`
- Erzeugt Validierungsmeldungen: `MISSING_MASTERDATA`, `AMBIGUOUS_MASTERDATA`, `ALLOCATION_NOTE`
### Validator (src/taetigkeitsnachweis/validator.py)
Zweistufige Validierung:
- **Quellvalidierung** (`validate_sources`): Datei-Existenz, Sheet-Existenz, Spaltenprüfung, Monatsmarker-Parsebarkeit, Vertragsstunden-Prüfung
- **Geschäftsregel-Validierung** (`validate_business_rules`): Keine aktiven Zuordnungen, doppelte Matches, Template-Sheet-Existenz, Template-Targets-Vollständigkeit, Dateinamen-Kollisionen
### Writer (src/taetigkeitsnachweis/writer.py)
- **Workbook-Generierung** (`build_employee_workbooks`): Lädt SAB-Vorlage, erstellt pro Zuordnung ein Sheet, befüllt konfigurierte Zielzellen (Antragsnummer, Name, Qualifikation, Funktion, Tätigkeiten, Berichtsmonat, Förderquote, Erstelldatum)
- **Dateinamen-Generierung** (`build_output_filename`): Pattern-basiert mit `{year}`, `{month}`, `{first_name}`, `{last_name}`
- **Speicherung** (`save_employee_workbooks`): Schreibt in `output/`, erkennt Dateinamen-Kollisionen
### Datenmodelle (src/taetigkeitsnachweis/models.py)
- `AllocationRecord` – Mitarbeitername, Vertragsstunden, Projektkürzel, geförderte Stunden, Anmerkungen
- `MasterDataRecord` – Antragsnummer, Name, Qualifikation, Projektumfang, Stellenanteil, Funktion, Tätigkeit
- `MatchedAssignment` – Verknüpfung Zuordnung + Stammdaten + berechnete Förderquote
- `ValidationIssue` – Code, Meldung, Schweregrad, Kontext
### Fehlerbehandlung (src/taetigkeitsnachweis/errors.py)
Exception-Hierarchie: `TaetigkeitsnachweisError` → `ConfigurationError`, `ValidationError`, `MatchingError`
### Konfiguration (config.json)
Zentrale Konfigurationsdatei mit:
- **Pfade**: Input-, Template- und Output-Verzeichnisse
- **Dateien**: Zuordnungs-Workbook, Stammdaten-Workbook, Template-Workbook
- **Sheet-Namen**: Pro Workbook konfigurierbar
- **Parsing-Regeln**: Spalten-Mappings, Monatsmarker-Format, inaktive Werte
- **Matching-Einstellungen**: Normalisierung, Toleranz
- **Output-Einstellungen**: Dateinamen-Pattern, ein Workbook pro Mitarbeiter, ein Sheet pro Zuordnung
- **Template-Targets**: Zielzellen-Adressen in der Vorlage
## Projektstruktur
```bash
# Kommandozeile
.venv/bin/python coldrun.py
# oder per Doppelklick (macOS)
coldrun.command
```
Tool/
├── generate.py # Haupteinstiegspunkt (Click CLI)
├── config.json # Zentrale Konfiguration
├── start.sh # Shell-Wrapper
├── requirements.txt # Python-Abhängigkeiten
├── input/ # Eingabe-Excel-Dateien (PS_PW.xlsx, Stellenbeschreibungen.xlsx)
├── output/ # Generierte Tätigkeitsnachweise
├── template/ # SAB-Vorlage (Tätigkeitsnachweis Stellenförderung.xlsx)
└── src/taetigkeitsnachweis/
├── cli.py # Legacy-Scaffold (argparse)
├── errors.py # Exception-Klassen
├── logging_utils.py # Logging-Konfiguration
├── matcher.py # Namenszuordnung & Förderquote
├── models.py # Datenklassen
├── parser.py # Excel-Parsing
├── util.py # Utilities (noch leer)
├── validator.py # Zwei-Phasen-Validierung
└── writer.py # Excel-Ausgabe
## Bedienung
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
- Nach Auswahl wird die Datei sofort validiert
- Angezeigt werden: Anzahl Mitarbeiter, Antragsnummern, Einträge pro Monat
2. **Template wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `template/`-Ordner
- Nach Auswahl wird das Template sofort validiert
- Angezeigt wird: Anzahl gefundener Zielfelder
3. **Mitarbeiter auswählen** – Multiselect-Liste aller gefundenen Mitarbeiter
- Standardmäßig sind alle ausgewählt
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
4. **"Tätigkeitsnachweise generieren"** klicken
- Die generierten Dateien werden im `output/`-Ordner abgelegt
## Konfiguration
Alle Einstellungen liegen im `config/`-Ordner:
### `settings.ini`
- `[paths]` – Ordner für Input, Template, Output
- `[output]` – Dateinamen-Pattern (`{first_name}`, `{last_name}`)
- `[parsing]` – Monats-Sheet-Erkennung, Leerzeilen-Verhalten
### `input-fields.json`
Definiert die Spalten der Input-Tabelle:
- `column`: Spaltenbuchstabe (A, B, C…)
- `field_name`: Exakte Spaltenüberschrift aus der Excel-Datei
- `type`: `string` oder `number`
- `required`: `true` oder `false`
### `output-fields.json`
Mapping von Input-Feldern auf Template-Zellen:
- `source_field`: Exakte Spaltenüberschrift aus `input-fields.json`
- `target_cell`: Zelladresse im Template (z. B. `"C16"`)
- `format`: Optionaler Format-String (z. B. `"%.2f"` für Prozentwerte)
## Erwartetes Input-Format
- Eine `.xlsx`-Datei mit einem Sheet pro Monat (z. B. "Januar 2025", "Februar 2025", …)
- Jedes Sheet hat in Zeile 1 die Spaltenüberschriften (laut `input-fields.json`)
- Daten beginnen ab Zeile 2
- Jede Zeile entspricht einer Mitarbeiter-Funktion-Kombination für diesen Monat
## Output
- Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt
- Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
## Tests
End-to-End-Tests parsen die reale Input-Datei (`input/2025 JM.xlsx`) und das reale
Template (`template/Tätigkeitsnachweise Stellenförderung.xlsx`) und validieren
den gesamten Generierungs-Workflow.
```bash
# Test-Abhängigkeiten installieren
.venv/bin/pip install pytest
# Tests ausführen
.venv/bin/python -m pytest tests/test_e2e.py -v
```
Die Tests prüfen:
- Vorhandensein der Input- und Template-Dateien
- Korrekte Parsierung (168 Datensätze, 19 Personen)
- Vollständige Pipeline für die erste Person („Draßdo, Cornelia"): 12 Monats-Sheets
mit allen Zellinhalten (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat,
Funktion, Tätigkeit, Ort, Datum)
+322
View File
@@ -0,0 +1,322 @@
"""Tätigkeitsnachweis-Generator – Streamlit App."""
import os
import subprocess
import sys
from pathlib import Path
import streamlit as st
# Projekt-Root zum Python-Path hinzufügen
PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.config import (
load_settings,
load_input_schema,
load_output_mapping,
get_available_input_files,
get_available_templates,
)
from src.models import AppConfig
from src.parser import parse_input_file, group_by_employee, get_unique_antragsnummern, get_unique_employees
from src.validator import validate_input_file, validate_template
from src.writer import generate_activity_reports
# --- Seite konfigurieren ---
st.set_page_config(
page_title="Tätigkeitsnachweis-Generator",
page_icon="📋",
layout="wide",
)
st.title("📋 Tätigkeitsnachweis-Generator")
st.markdown("Erstellt Tätigkeitsnachweise aus Input-Daten und einem Excel-Template.")
# --- Konfiguration laden ---
CONFIG_DIR = PROJECT_ROOT / "config"
@st.cache_data
def load_app_config():
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
config.input_schema = load_input_schema(CONFIG_DIR)
config.output_mapping = load_output_mapping(CONFIG_DIR)
return config
try:
app_config = load_app_config()
except Exception as e:
st.error(f"Fehler beim Laden der Konfiguration: {e}")
st.stop()
# --- Session State initialisieren ---
if "parsed_records" not in st.session_state:
st.session_state.parsed_records = []
if "input_validated" not in st.session_state:
st.session_state.input_validated = False
if "template_validated" not in st.session_state:
st.session_state.template_validated = False
# Caches für Validierung/Parsing – nur bei Dateiwechsel neu ausführen
if "last_input_file" not in st.session_state:
st.session_state.last_input_file = None
if "last_template_file" not in st.session_state:
st.session_state.last_template_file = None
if "cached_val_result" not in st.session_state:
st.session_state.cached_val_result = None
if "cached_tpl_result" not in st.session_state:
st.session_state.cached_tpl_result = None
# --- Layout: zwei Spalten ---
col1, col2 = st.columns(2)
with col1:
st.subheader("1. Input-Datei")
input_files = get_available_input_files(app_config)
if not input_files:
st.warning("Keine .xlsx-Dateien im input/-Ordner gefunden.")
selected_input = None
else:
# Bei nur einer Datei: automatisch selektieren (keine Leerauswahl)
if len(input_files) == 1:
selected_input = st.selectbox(
"Input-Datei wählen:",
options=input_files,
)
else:
selected_input = st.selectbox(
"Input-Datei wählen:",
options=[""] + input_files,
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
)
if selected_input:
input_path = Path(app_config.input_dir) / selected_input
# Nur validieren/parsen wenn sich die Datei geändert hat
if selected_input != st.session_state.last_input_file:
with st.spinner("Validiere Input-Datei…"):
val_result = validate_input_file(input_path, app_config.input_schema, app_config.month_sheet_pattern)
if val_result.is_valid:
records = parse_input_file(
input_path,
app_config.input_schema,
app_config.month_sheet_pattern,
app_config.skip_empty_name,
app_config.skip_empty_antragsnummer,
)
st.session_state.parsed_records = records
st.session_state.input_validated = True
else:
st.session_state.parsed_records = []
st.session_state.input_validated = False
st.session_state.cached_val_result = val_result
st.session_state.last_input_file = selected_input
else:
val_result = st.session_state.cached_val_result
if not val_result.is_valid:
st.markdown(
'<div style="border:2px solid #e74c3c; border-radius:8px; padding:12px; '
'background:#fdedec; margin-bottom:12px">'
'<span style="color:#b03a2e; font-weight:bold; font-size:1.1em">'
'❌ Validierung fehlgeschlagen</span></div>',
unsafe_allow_html=True,
)
for err in val_result.errors:
st.error(f"🔴 {err}")
st.session_state.input_validated = False
else:
if val_result.warnings:
st.markdown(
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
'background:#fef9e7; margin-bottom:8px">'
'<span style="color:#7d6608; font-weight:bold">'
'⚠️ Hinweise</span></div>',
unsafe_allow_html=True,
)
for warn in val_result.warnings:
st.warning(warn)
# Zeige Zusammenfassung aus gecachten Records
records = st.session_state.parsed_records
employees = get_unique_employees(records)
antragsnummern = get_unique_antragsnummern(records)
st.success(f"✅ {len(records)} Einträge in {val_result.info.get('sheets_with_data', 0)} Monats-Sheets")
pills = " ".join(
f'<span style="display:inline-block; background:#e8f0fe; color:#1a73e8; '
f'border-radius:16px; padding:4px 12px; margin:3px; font-size:0.9em; '
f'white-space:nowrap">{an}</span>'
for an in antragsnummern
)
st.markdown(f"**Antragsnummern:**<br>{pills}", unsafe_allow_html=True)
with col2:
st.subheader("2. Template")
template_files = get_available_templates(app_config)
if not template_files:
st.warning("Keine .pdf-Dateien im template/-Ordner gefunden.")
selected_template = None
else:
# Lock-Files herausfiltern
real_templates = [t for t in template_files if not t.startswith("~")]
if not real_templates:
real_templates = template_files
# Bei nur einem Template: automatisch selektieren (keine Leerauswahl)
if len(real_templates) == 1:
selected_template = st.selectbox(
"Template wählen:",
options=real_templates,
)
else:
selected_template = st.selectbox(
"Template wählen:",
options=[""] + real_templates,
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
)
if selected_template:
# Nur validieren wenn sich das Template geändert hat
if selected_template != st.session_state.last_template_file:
template_path = Path(app_config.template_dir) / selected_template
with st.spinner("Validiere Template…"):
tpl_result = validate_template(template_path, app_config.output_mapping)
st.session_state.cached_tpl_result = tpl_result
st.session_state.last_template_file = selected_template
st.session_state.template_validated = tpl_result.is_valid
else:
tpl_result = st.session_state.cached_tpl_result
if not tpl_result.is_valid:
st.markdown(
'<div style="border:2px solid #e74c3c; border-radius:8px; padding:12px; '
'background:#fdedec; margin-bottom:12px">'
'<span style="color:#b03a2e; font-weight:bold; font-size:1.1em">'
'❌ Validierung fehlgeschlagen</span></div>',
unsafe_allow_html=True,
)
for err in tpl_result.errors:
st.error(f"🔴 {err}")
st.session_state.template_validated = False
else:
st.session_state.template_validated = True
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; '
'background:#fef9e7; margin-bottom:8px">'
'<span style="color:#7d6608; font-weight:bold">'
'⚠️ Hinweise</span></div>',
unsafe_allow_html=True,
)
for warn in tpl_result.warnings:
st.warning(warn)
# --- Mitarbeiter-Auswahl ---
st.subheader("3. Mitarbeiter auswählen")
if st.session_state.input_validated and st.session_state.parsed_records:
employees = get_unique_employees(st.session_state.parsed_records)
st.caption(f"{len(employees)} Mitarbeiter gefunden – wähle aus, für wen Tätigkeitsnachweise erstellt werden sollen:")
# "Alle" / "Keine" Buttons
btn_col1, btn_col2 = st.columns(2)
if btn_col1.button("☑ Alle auswählen", use_container_width=True):
for emp in employees:
st.session_state[f"sel_{emp}"] = True
if btn_col2.button("☐ Keine auswählen", use_container_width=True):
for emp in employees:
st.session_state[f"sel_{emp}"] = False
# Mitarbeiter-Liste mit Rahmen
with st.container(border=True):
selected_employees = []
for emp in employees:
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):
selected_employees.append(emp)
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
else:
selected_employees = []
if not st.session_state.input_validated:
st.info("Bitte zuerst eine gültige Input-Datei auswählen.")
# --- Hilfsfunktion zum Öffnen von Dateien/Ordnern ---
def open_path(path_str: str):
"""Öffnet eine Datei mit dem Standardprogramm oder einen Ordner im Dateimanager.
Funktioniert plattformübergreifend: macOS, Windows, Linux."""
if sys.platform == "win32":
os.startfile(path_str)
elif sys.platform == "darwin":
subprocess.run(["open", path_str])
else:
subprocess.run(["xdg-open", path_str])
# --- Generate Button ---
st.divider()
gen_col1, gen_col2, gen_col3 = st.columns([1, 1, 2])
with gen_col1:
generate_clicked = st.button(
"🚀 Tätigkeitsnachweise generieren",
type="primary",
disabled=not (st.session_state.input_validated and st.session_state.template_validated and selected_employees),
)
if generate_clicked:
if not selected_employees:
st.error("Keine Mitarbeiter ausgewählt.")
else:
# Filtere Records auf selektierte Mitarbeiter
all_groups = group_by_employee(st.session_state.parsed_records)
selected_groups = {name: all_groups[name] for name in selected_employees if name in all_groups}
template_path = Path(app_config.template_dir) / selected_template
output_dir = Path(app_config.output_dir)
with st.spinner(f"Generiere {len(selected_groups)} Tätigkeitsnachweise…"):
try:
generated = generate_activity_reports(
employee_records=selected_groups,
template_path=template_path,
mapping=app_config.output_mapping,
output_dir=output_dir,
filename_pattern=app_config.filename_pattern,
)
st.session_state.generated_files = generated
except Exception as e:
st.error(f"Fehler bei der Generierung: {e}")
# --- Generierte Dateien anzeigen (aus Session State, übersteht Reruns) ---
if "generated_files" in st.session_state and st.session_state.generated_files:
generated = st.session_state.generated_files
st.success(f"✅ {len(generated)} Tätigkeitsnachweise erstellt:")
for i, fpath in enumerate(generated):
col_file, col_open, col_folder = st.columns([0.78, 0.11, 0.11])
with col_file:
st.markdown(f"📄 `{fpath.name}`")
with col_open:
st.button(
"🔗 Öffnen",
key=f"open_file_{i}",
on_click=open_path,
args=(str(fpath),),
help="Datei mit Standardprogramm öffnen",
use_container_width=True,
)
with col_folder:
st.button(
"📂",
key=f"open_folder_{i}",
on_click=open_path,
args=(str(fpath.parent),),
help="Ordner im Finder öffnen",
use_container_width=True,
)
+46
View File
@@ -0,0 +1,46 @@
@echo off
title Tätigkeitsnachweis-Generator – Cold Run
echo ========================================
echo Cold Run – Headless-Generierung
echo ========================================
echo.
:: Prüfe ob Python verfügbar ist
where python >nul 2>nul
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Python wurde nicht gefunden.
echo Bitte installiere Python 3.9 oder neuer von https://python.org
pause
exit /b 1
)
echo [INFO] Python gefunden:
python --version
:: Prüfe ob venv existiert, sonst erstelle
if not exist ".venv\Scripts\python.exe" (
echo [FEHLER] Virtuelle Umgebung nicht gefunden.
echo Bitte zuerst start.bat ausführen, um das Projekt einzurichten.
pause
exit /b 1
)
:: Aktiviere venv und installiere Abhängigkeiten
call .venv\Scripts\activate.bat
echo [INFO] Stelle sicher, dass Abhängigkeiten installiert sind...
pip install -r requirements.txt --quiet
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Konnte Abhängigkeiten nicht installieren.
pause
exit /b 1
)
:: Starte Cold Run
echo.
echo [INFO] Starte Cold Run – generiere Tätigkeitsnachweis für erste Person...
echo.
python coldrun.py
echo.
echo [INFO] Cold Run abgeschlossen.
pause
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
# Doppelklickbares Cold-Run-Script für macOS
# Nimmt automatisch die erste Input-Datei + erstes Template und erzeugt
# einen Tätigkeitsnachweis für die erste gelistete Person – ohne GUI.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
VENV_DIR=".venv"
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
echo "[FEHLER] Virtuelle Umgebung nicht gefunden."
echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten."
read -p "Drücke Enter zum Beenden..."
exit 1
fi
echo "[INFO] Stelle sicher, dass Abhängigkeiten installiert sind…"
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet 2>/dev/null || true
echo ""
"$VENV_DIR/bin/python" coldrun.py
echo ""
read -p "Drücke Enter zum Beenden..."
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Cold-Run Tätigkeitsnachweis-Generator.
Nimmt automatisch die erste Input-Datei und das erste Template,
erzeugt einen Tätigkeitsnachweis für die erste gelistete Person
und öffnet das Ergebnis (macOS) – ohne GUI, ohne Fragen.
"""
import subprocess
import sys
from pathlib import Path
# Projekt-Root
PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.config import (
load_settings,
load_input_schema,
load_output_mapping,
get_available_input_files,
get_available_templates,
)
from src.parser import parse_input_file, group_by_employee, get_unique_employees
from src.validator import validate_input_file, validate_template
from src.writer import generate_activity_reports
def main():
print("=" * 50)
print(" Tätigkeitsnachweis-Generator (Cold Run)")
print("=" * 50)
print()
# ── Konfiguration laden ──────────────────────────────────────────
config_dir = PROJECT_ROOT / "config"
config = load_settings(config_dir, PROJECT_ROOT)
config.input_schema = load_input_schema(config_dir)
config.output_mapping = load_output_mapping(config_dir)
# ── Erste Input-Datei ermitteln ───────────────────────────────────
input_files = get_available_input_files(config)
if not input_files:
print("[FEHLER] Keine .xlsx-Dateien im input/-Ordner gefunden.")
return 1
input_file = input_files[0]
input_path = Path(config.input_dir) / input_file
print(f"[INFO] Input-Datei: {input_file}")
# ── Erstes Template ermitteln ─────────────────────────────────────
template_files = get_available_templates(config)
if not template_files:
print("[FEHLER] Keine .pdf-Dateien im template/-Ordner gefunden.")
return 1
template_file = template_files[0]
template_path = Path(config.template_dir) / template_file
print(f"[INFO] Template: {template_file}")
# ── Template validieren ───────────────────────────────────────────
print()
print("[INFO] Validiere PDF-Template…")
tpl_result = validate_template(template_path, config.output_mapping)
if not tpl_result.is_valid:
for err in tpl_result.errors:
print(f"[FEHLER] {err}")
return 1
found = tpl_result.info.get("found_fields", "?")
total = tpl_result.info.get("total_fields", "?")
print(f"[OK] {found}/{total} Platzhalter im Template gefunden.")
# ── Input validieren ──────────────────────────────────────────────
print()
print("[INFO] Validiere Input-Datei…")
inp_result = validate_input_file(input_path, config.input_schema, config.month_sheet_pattern)
if not inp_result.is_valid:
for err in inp_result.errors:
print(f"[FEHLER] {err}")
return 1
if inp_result.warnings:
for warn in inp_result.warnings:
print(f"[WARN] {warn}")
# ── Input parsen ──────────────────────────────────────────────────
print()
print("[INFO] Parse Input-Datei…")
records = parse_input_file(
filepath=input_path,
schema=config.input_schema,
month_sheet_pattern=config.month_sheet_pattern,
skip_empty_name=config.skip_empty_name,
skip_empty_antragsnummer=config.skip_empty_antragsnummer,
)
employees = get_unique_employees(records)
print(f"[INFO] {len(records)} Einträge für {len(employees)} Personen gefunden.")
# ── Erste Person ermitteln ────────────────────────────────────────
grouped = group_by_employee(records)
first_person = sorted(grouped.keys())[0]
first_records = grouped[first_person]
print(f"[INFO] Erste Person: {first_person} ({len(first_records)} Monate)")
# ── Reports generieren ────────────────────────────────────────────
print()
print("[INFO] Generiere Tätigkeitsnachweis…")
single_person = {first_person: first_records}
output_dir = Path(config.output_dir)
generated = generate_activity_reports(
employee_records=single_person,
template_path=template_path,
mapping=config.output_mapping,
output_dir=output_dir,
filename_pattern=config.filename_pattern,
)
output_file = generated[0]
print(f"[OK] Erstellt: {output_file.name}")
print(f" Pfad: {output_file}")
# ── Datei öffnen (macOS) ──────────────────────────────────────────
if sys.platform == "darwin":
print()
print("[INFO] Öffne Datei im Finder…")
subprocess.run(["open", str(output_file)], check=False)
print()
print("=" * 50)
print(" Fertig.")
print("=" * 50)
return 0
if __name__ == "__main__":
sys.exit(main())
-84
View File
@@ -1,84 +0,0 @@
{
"paths": {
"input_dir": "input",
"template_dir": "template",
"output_dir": "output"
},
"files": {
"allocation_workbook": "PS_PW.xlsx",
"masterdata_workbook": "Stellenbeschreibungen.xlsx",
"template_workbook": "Tätigkeitsnachweis Stellenförderung.xlsx"
},
"sheets": {
"allocation": "Tabelle1",
"masterdata": "Tabelle1",
"template_source": "<MM_YYYY>_<Stellenbeschreibung>"
},
"allocation": {
"header_month_marker_row": 2,
"columns": {
"employee_name": "A",
"contract_hours_weekly": "B",
"project_key": "C",
"first_month_column": "D",
"remarks": "I"
},
"month_marker": {
"prefix": "ab ",
"date_format": "%d.%m.%Y",
"exact_first_day_required": true
},
"inactive_values": [
"",
"-",
"0",
0
]
},
"masterdata": {
"columns": {
"id": "A",
"antragsnummer": "B",
"name": "C",
"qualifikation": "D",
"project_scope": "E",
"stellenanteil": "F",
"funktion": "G",
"taetigkeit": "H"
},
"decimal": {
"decimal_separator": ",",
"thousands_separator": "."
}
},
"matching": {
"name_normalization": {
"trim": true,
"collapse_spaces": true,
"case_insensitive": true
},
"share_tolerance_percent": 0.5,
"prefer_function_hint": true
},
"output": {
"filename_pattern": "Taetigkeitsnachweis_{year}_{month}_{first_name}_{last_name}.xlsx",
"one_workbook_per_employee": true,
"one_sheet_per_assignment": true,
"sheet_name_pattern": "{index}_{funktion}"
},
"template_targets": {
"antragsnummer": "A3",
"name_vorname": "A17",
"qualifikation": "A21",
"funktion": "A25",
"taetigkeiten": "A31",
"reporting_month_label": "Q21",
"foerderquote": "Q17",
"generated_on": "I95"
},
"runtime": {
"abort_on_validation_errors": true,
"preview_before_write": true,
"confirm_before_write": true
}
}
+11
View File
@@ -0,0 +1,11 @@
[
{"column": "A", "field_name": "ID", "type": "number", "required": false},
{"column": "B", "field_name": "Kommentar", "type": "string", "required": false},
{"column": "C", "field_name": "Antragsnummer", "type": "string", "required": true},
{"column": "D", "field_name": "Name", "type": "string", "required": true},
{"column": "E", "field_name": "Qualifikation", "type": "string", "required": true},
{"column": "F", "field_name": "Funktion", "type": "string", "required": true},
{"column": "G", "field_name": "Stellenanteil in %", "type": "number", "required": true},
{"column": "H", "field_name": "Monat (MM.JJ)", "type": "string", "required": true},
{"column": "I", "field_name": "Tätigkeit", "type": "string", "required": true}
]
+10
View File
@@ -0,0 +1,10 @@
[
{"source_field": "Antragsnummer", "pdf_field": "Antragsnummer_Field", "format": null},
{"source_field": "Name", "pdf_field": "Name_Field", "format": null},
{"source_field": "Stellenanteil in %", "pdf_field": "Stellenanteil_Field", "format": null},
{"source_field": "Qualifikation", "pdf_field": "Qualifikation_Field", "format": null},
{"source_field": "Monat (MM.JJ)", "pdf_field": "Monat_Field", "format": null},
{"source_field": "Funktion", "pdf_field": "Funktion_Field", "format": null},
{"source_field": "Tätigkeit", "pdf_field": "Tätigkeiten_Field", "format": null},
{"source_field": "Datum", "pdf_field": "Datum_Field", "format": null}
]
+18
View File
@@ -0,0 +1,18 @@
[paths]
input_dir = input
template_dir = template
output_dir = output
[output]
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.pdf
[parsing]
# Sheets whose names match these patterns are treated as monthly data sheets
# Use * as wildcard. Sheets not matching are ignored (e.g. "Alle Rollen").
month_sheet_pattern = * 20??
# Skip rows where the employee name is empty
skip_empty_name = true
# Skip rows where Antragsnummer is missing
skip_empty_antragsnummer = true
-158
View File
@@ -1,158 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
import click
from src.taetigkeitsnachweis.errors import TaetigkeitsnachweisError
from src.taetigkeitsnachweis.logging_utils import configure_logging
from src.taetigkeitsnachweis.matcher import match_assignments
from src.taetigkeitsnachweis.parser import parse_allocation_records, parse_masterdata_records
from src.taetigkeitsnachweis.validator import validate_business_rules, validate_sources
from src.taetigkeitsnachweis.writer import (
build_employee_workbooks,
build_output_filename,
save_employee_workbooks,
)
def _load_config(project_root: Path) -> dict:
config_path = project_root / "config.json"
with config_path.open("r", encoding="utf-8") as config_file:
return json.load(config_file)
def _print_issues(issues: list, title: str) -> None:
if not issues:
return
click.echo(title)
for issue in issues:
click.echo(f"- [{issue.severity}] {issue.code}: {issue.message}")
def _print_preview(month: int, year: int, matched_assignments: list, planned_files: list[str]) -> None:
click.echo("=== Vorschau der ermittelten Werte ===")
click.echo(f"Berichtsmonat : {month:02d}/{year}")
click.echo(f"Aktive Faelle : {len(matched_assignments)}")
click.echo("--- Zu erzeugende Taetigkeitsnachweise ---")
for item in matched_assignments:
click.echo(
"- "
f"{item.allocation.employee_name} | "
f"{item.masterdata.funktion} | "
f"{item.foerderquote_percent:.2f}%"
)
click.echo("--- Geplante Ausgabedateien ---")
for file_name in planned_files:
click.echo(f"- {file_name}")
@click.command()
@click.option("--month", type=click.IntRange(1, 12), required=True)
@click.option("--year", type=click.IntRange(2000, 2100), required=True)
@click.option("--yes", is_flag=True, default=False, help="Skip interactive confirmation.")
@click.option("--skip-errors", is_flag=True, default=False, help="Generate files for matched records, skip unresolved ones.")
def main(month: int, year: int, yes: bool, skip_errors: bool) -> None:
configure_logging()
project_root = Path(__file__).resolve().parent
try:
config = _load_config(project_root)
source_issues = validate_sources(
config=config,
report_month=month,
report_year=year,
project_root=project_root,
)
_print_issues(source_issues, "=== Source validation ===")
if any(issue.severity == "error" for issue in source_issues):
raise click.ClickException("Source validation failed.")
allocation_records = parse_allocation_records(
config=config,
report_month=month,
report_year=year,
project_root=project_root,
)
masterdata_records = parse_masterdata_records(
config=config,
project_root=project_root,
)
matched_assignments, match_issues = match_assignments(
allocation_records=allocation_records,
masterdata_records=masterdata_records,
config=config,
)
_print_issues(match_issues, "=== Matching issues ===")
business_issues = validate_business_rules(
config=config,
report_month=month,
report_year=year,
allocation_records=allocation_records,
matched_assignments=matched_assignments,
project_root=project_root,
)
_print_issues(business_issues, "=== Business validation ===")
all_issues = [*match_issues, *business_issues]
has_errors = any(issue.severity == "error" for issue in all_issues)
if has_errors and not skip_errors:
raise click.ClickException("Validation failed after matching.")
if has_errors and skip_errors:
click.echo("Warnhinweis: Fehler vorhanden. Weiterfahrt mit --skip-errors (nur gematchte Faelle werden erzeugt).")
filename_pattern = config["output"]["filename_pattern"]
unique_employees = sorted({item.allocation.employee_name for item in matched_assignments})
planned_files = [
build_output_filename(
filename_pattern=filename_pattern,
employee_name=employee_name,
report_year=year,
report_month=month,
)
for employee_name in unique_employees
]
_print_preview(
month=month,
year=year,
matched_assignments=matched_assignments,
planned_files=planned_files,
)
if not yes:
confirmed = click.confirm("Dateien jetzt schreiben?", default=False)
if not confirmed:
click.echo("Abbruch ohne Schreibzugriff.")
return
employee_workbooks = build_employee_workbooks(
config=config,
matched_assignments=matched_assignments,
report_month=month,
report_year=year,
project_root=project_root,
)
output_paths = save_employee_workbooks(
config=config,
employee_workbooks=employee_workbooks,
report_month=month,
report_year=year,
project_root=project_root,
)
click.echo("=== Generated files ===")
for output_path in output_paths:
click.echo(f"- {output_path}")
except TaetigkeitsnachweisError as exc:
raise click.ClickException(str(exc)) from exc
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -1,2 +1,3 @@
click>=8.1,<9.0
streamlit>=1.28,<2.0
openpyxl>=3.1,<4.0
PyMuPDF>=1.23,<2.0
+1
View File
@@ -0,0 +1 @@
"""Tätigkeitsnachweis-Generator package."""
+83
View File
@@ -0,0 +1,83 @@
"""Tätigkeitsnachweis-Generator – Konfigurations-Loader."""
import configparser
import json
import fnmatch
from pathlib import Path
from typing import Optional
from .models import AppConfig, FieldSchema, OutputMapping
def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
"""Lädt settings.ini und gibt ein AppConfig-Objekt zurück."""
ini_path = config_dir / "settings.ini"
if not ini_path.exists():
raise FileNotFoundError(f"settings.ini nicht gefunden: {ini_path}")
parser = configparser.ConfigParser()
parser.read(ini_path, encoding="utf-8")
paths = parser["paths"] if "paths" in parser else {}
output = parser["output"] if "output" in parser else {}
parsing = parser["parsing"] if "parsing" in parser else {}
return AppConfig(
input_dir=str(project_root / paths.get("input_dir", "input")),
template_dir=str(project_root / paths.get("template_dir", "template")),
output_dir=str(project_root / paths.get("output_dir", "output")),
filename_pattern=output.get("filename_pattern", "{last_name}_{first_name}_Tätigkeitsnachweis.xlsx"),
month_sheet_pattern=parsing.get("month_sheet_pattern", "* 20??"),
skip_empty_name=parsing.getboolean("skip_empty_name", True),
skip_empty_antragsnummer=parsing.getboolean("skip_empty_antragsnummer", True),
)
def load_input_schema(config_dir: Path) -> list[FieldSchema]:
"""Lädt input-fields.json."""
schema_path = config_dir / "input-fields.json"
if not schema_path.exists():
raise FileNotFoundError(f"input-fields.json nicht gefunden: {schema_path}")
with open(schema_path, "r", encoding="utf-8") as f:
data = json.load(f)
return [FieldSchema(**item) for item in data]
def load_output_mapping(config_dir: Path) -> list[OutputMapping]:
"""Lädt output-fields.json."""
mapping_path = config_dir / "output-fields.json"
if not mapping_path.exists():
raise FileNotFoundError(f"output-fields.json nicht gefunden: {mapping_path}")
with open(mapping_path, "r", encoding="utf-8") as f:
data = json.load(f)
return [OutputMapping(**item) for item in data]
def get_available_files(directory: Path, extension: str = ".xlsx") -> list[str]:
"""Listet alle Dateien mit der angegebenen Erweiterung im Verzeichnis auf."""
if not directory.exists():
return []
files = sorted(
f.name for f in directory.iterdir()
if f.is_file() and f.suffix.lower() == extension and not f.name.startswith("~")
)
return files
def get_available_input_files(config: AppConfig) -> list[str]:
"""Listet alle .xlsx-Dateien im input-Ordner."""
return get_available_files(Path(config.input_dir))
def get_available_templates(config: AppConfig) -> list[str]:
"""Listet alle .pdf-Dateien im template-Ordner."""
return get_available_files(Path(config.template_dir), extension=".pdf")
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
"""Prüft, ob ein Sheet-Name dem Monatsmuster entspricht."""
return fnmatch.fnmatch(sheet_name.strip(), pattern)
+74
View File
@@ -0,0 +1,74 @@
"""Tätigkeitsnachweis-Generator – Datenmodelle."""
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class EmployeeRecord:
"""Eine Zeile aus einem Monats-Sheet der Input-Datei."""
name: str
antragsnummer: str
qualifikation: str
funktion: str
stellenanteil: str
monat: str # z. B. "01.2025"
taetigkeit: str
id: Optional[int] = None
kommentar: Optional[str] = None
@property
def month_index(self) -> int:
"""Extrahiere den Monatsindex (1–12) aus dem Monatsfeld."""
try:
return int(self.monat.split(".")[0])
except (ValueError, IndexError):
return 0
@property
def year(self) -> int:
"""Extrahiere das Jahr aus dem Monatsfeld."""
try:
return int(self.monat.split(".")[1])
except (ValueError, IndexError):
return 0
@dataclass
class FieldSchema:
"""Definition eines Feldes aus input-fields.json."""
column: str
field_name: str
type: str
required: bool
@dataclass
class OutputMapping:
"""Definition eines Mappings aus output-fields.json."""
source_field: str
format: Optional[str] = None
pdf_field: Optional[str] = None # Name des _Field-Platzhalters im PDF (z.B. "Auftragsnummer_Field")
@dataclass
class AppConfig:
"""Gesamte App-Konfiguration."""
input_dir: str
template_dir: str
output_dir: str
filename_pattern: str
input_schema: list = field(default_factory=list)
output_mapping: list = field(default_factory=list)
month_sheet_pattern: str = "* 20??"
skip_empty_name: bool = True
skip_empty_antragsnummer: bool = True
@dataclass
class ValidationResult:
"""Ergebnis einer Validierung."""
is_valid: bool
errors: list = field(default_factory=list)
warnings: list = field(default_factory=list)
info: dict = field(default_factory=dict)
+134
View File
@@ -0,0 +1,134 @@
"""Input-Datei-Parser: liest monatliche Sheets und erzeugt EmployeeRecords."""
import re
from pathlib import Path
from typing import Any, Optional
from openpyxl import load_workbook
from openpyxl.utils import column_index_from_string
from .config import is_month_sheet
from .models import EmployeeRecord, FieldSchema
def _to_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _to_int_or_none(value: Any) -> Optional[int]:
if value is None or str(value).strip() == "":
return None
try:
return int(value)
except (ValueError, TypeError):
return None
def _to_percent_string(value: Any) -> str:
"""Wandelt einen Prozentwert in einen String ohne %-Zeichen um.
Excel speichert Prozentwerte als Dezimalbruch (50 % = 0.5).
Falls der Rohwert eine Zahl ≤ 1 ist, wird er als Excel-Prozentwert
interpretiert und mit 100 multipliziert.
"""
if value is None or str(value).strip() == "":
return ""
# Falls es bereits ein String mit %-Zeichen ist
if isinstance(value, str):
return value.strip().rstrip("% ")
# Numerischer Wert: Excel-Prozentwert (0.5 = 50 %) oder Ganzzahl (50 = 50 %)
if isinstance(value, (int, float)):
if 0 < value <= 1:
return str(int(value * 100))
if value == 0:
return "0"
# Wert > 1: bereits als Ganzzahl gemeint (z. B. 50)
return str(int(value))
return str(value).strip()
def _build_column_map(schema: list[FieldSchema]) -> dict[str, int]:
"""Baut ein Mapping von field_name → 0-basierter Spaltenindex."""
col_map: dict[str, int] = {}
for field in schema:
col_map[field.field_name] = column_index_from_string(field.column) - 1
return col_map
def parse_input_file(
filepath: Path,
schema: list[FieldSchema],
month_sheet_pattern: str = "* 20??",
skip_empty_name: bool = True,
skip_empty_antragsnummer: bool = True,
) -> list[EmployeeRecord]:
"""
Öffnet die Input-Datei, iteriert über alle Monats-Sheets
und gibt eine Liste von EmployeeRecords zurück.
"""
wb = load_workbook(filepath, data_only=True)
col_map = _build_column_map(schema)
records: list[EmployeeRecord] = []
for sheet_name in wb.sheetnames:
if not is_month_sheet(sheet_name, month_sheet_pattern):
continue
ws = wb[sheet_name]
# Header ist Zeile 1, Daten ab Zeile 2
# min_col=1 explizit setzen für korrekte Spaltenzuordnung
schema_col_count = len(schema)
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=1, max_col=schema_col_count, values_only=True):
name = _to_text(row[col_map.get("Name", 0)]) if len(row) > col_map.get("Name", 0) else ""
antragsnummer = _to_text(row[col_map.get("Antragsnummer", 0)]) if len(row) > col_map.get("Antragsnummer", 0) else ""
if skip_empty_name and not name:
continue
if skip_empty_antragsnummer and not antragsnummer:
continue
qualifikation = _to_text(row[col_map.get("Qualifikation", 0)]) if len(row) > col_map.get("Qualifikation", 0) else ""
funktion = _to_text(row[col_map.get("Funktion", 0)]) if len(row) > col_map.get("Funktion", 0) else ""
stellenanteil_raw = row[col_map.get("Stellenanteil in %", 0)] if len(row) > col_map.get("Stellenanteil in %", 0) else None
stellenanteil = _to_percent_string(stellenanteil_raw)
monat = _to_text(row[col_map.get("Monat (MM.JJ)", 0)]) if len(row) > col_map.get("Monat (MM.JJ)", 0) else ""
taetigkeit = _to_text(row[col_map.get("Tätigkeit", 0)]) if len(row) > col_map.get("Tätigkeit", 0) else ""
record_id = _to_int_or_none(row[col_map.get("ID", 0)]) if len(row) > col_map.get("ID", 0) else None
kommentar = _to_text(row[col_map.get("Kommentar", 1)]) if "Kommentar" in col_map and len(row) > col_map["Kommentar"] else None
records.append(EmployeeRecord(
id=record_id,
name=name,
antragsnummer=antragsnummer,
qualifikation=qualifikation,
funktion=funktion,
stellenanteil=stellenanteil,
monat=monat,
taetigkeit=taetigkeit,
kommentar=kommentar,
))
wb.close()
return records
def group_by_employee(records: list[EmployeeRecord]) -> dict[str, list[EmployeeRecord]]:
"""Gruppiert Records nach Mitarbeitername."""
groups: dict[str, list[EmployeeRecord]] = {}
for record in records:
if record.name not in groups:
groups[record.name] = []
groups[record.name].append(record)
return groups
def get_unique_antragsnummern(records: list[EmployeeRecord]) -> list[str]:
"""Gibt eine sortierte Liste aller eindeutigen Antragsnummern zurück."""
return sorted(set(r.antragsnummer for r in records if r.antragsnummer))
def get_unique_employees(records: list[EmployeeRecord]) -> list[str]:
"""Gibt eine sortierte Liste aller eindeutigen Mitarbeiternamen zurück."""
return sorted(set(r.name for r in records if r.name))
-7
View File
@@ -1,7 +0,0 @@
"""SAB Taetigkeitsnachweis generator package."""
__all__ = [
"models",
"errors",
"logging_utils",
]
-14
View File
@@ -1,14 +0,0 @@
class TaetigkeitsnachweisError(Exception):
"""Base exception for all domain-specific failures."""
class ConfigurationError(TaetigkeitsnachweisError):
"""Raised when configuration is missing or invalid."""
class ValidationError(TaetigkeitsnachweisError):
"""Raised when source or business validation fails."""
class MatchingError(TaetigkeitsnachweisError):
"""Raised when records cannot be matched uniquely."""
-8
View File
@@ -1,8 +0,0 @@
import logging
def configure_logging(level: int = logging.INFO) -> None:
logging.basicConfig(
level=level,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
-182
View File
@@ -1,182 +0,0 @@
"""Record matching logic across allocation and master data."""
from __future__ import annotations
import re
import unicodedata
from typing import Any
from .errors import ValidationError
from .models import AllocationRecord, MasterDataRecord, MatchedAssignment, ValidationIssue
def calculate_foerderquote_percent(allocation: AllocationRecord, precision: int = 2) -> float:
if allocation.contract_hours_weekly <= 0:
raise ValidationError(
"contract_hours_weekly must be greater than 0 for foerderquote calculation."
)
raw_share = (allocation.funded_hours_weekly / allocation.contract_hours_weekly) * 100.0
return round(raw_share, precision)
def _normalize_name(name: str) -> str:
compact = re.sub(r"\s+", " ", name.strip())
no_accents = "".join(
ch for ch in unicodedata.normalize("NFKD", compact) if not unicodedata.combining(ch)
)
return no_accents.casefold()
def _name_keys(name: str) -> set[str]:
normalized = _normalize_name(name)
keys = {normalized}
if "," in normalized:
last_name, first_name = [part.strip() for part in normalized.split(",", 1)]
if first_name and last_name:
keys.add(f"{first_name} {last_name}")
keys.add(f"{last_name}, {first_name}")
else:
tokens = normalized.split()
if len(tokens) >= 2:
first_name = tokens[0]
last_name = tokens[-1]
keys.add(f"{first_name} {last_name}")
keys.add(f"{last_name}, {first_name}")
return {re.sub(r"\s+", " ", key).strip() for key in keys if key.strip()}
def _disambiguate_by_project_key(
candidates: list[MasterDataRecord],
project_key: str,
) -> tuple[MasterDataRecord | None, str]:
"""Return the single candidate matching allocation project key via masterdata taetigkeit.
Returns a tuple of (matched_record, status) where status is one of:
- "ok": exactly one match found
- "no_match": no candidate has a matching taetigkeit
- "multiple": more than one candidate matches
"""
normalized_key = project_key.strip().casefold()
matched = []
for candidate in candidates:
candidate_taetigkeit = (candidate.taetigkeit or "").strip().casefold()
if candidate_taetigkeit == normalized_key:
matched.append(candidate)
if len(matched) == 1:
return matched[0], "ok"
elif len(matched) == 0:
return None, "no_match"
else:
return None, "multiple"
def match_assignments(
allocation_records: list[AllocationRecord],
masterdata_records: list[MasterDataRecord],
config: dict[str, Any],
) -> tuple[list[MatchedAssignment], list[ValidationIssue]]:
# Build lookup by all normalized name variants
master_by_name: dict[str, list[MasterDataRecord]] = {}
for record in masterdata_records:
for key in _name_keys(record.name):
master_by_name.setdefault(key, []).append(record)
# Deduplicate so the same record does not appear twice for one name key
for key in master_by_name:
seen_ids: set[str] = set()
deduped: list[MasterDataRecord] = []
for rec in master_by_name[key]:
if rec.record_id not in seen_ids:
seen_ids.add(rec.record_id)
deduped.append(rec)
master_by_name[key] = deduped
matched: list[MatchedAssignment] = []
issues: list[ValidationIssue] = []
for allocation in allocation_records:
# Collect all master-data candidates for this employee name
candidate_map: dict[str, MasterDataRecord] = {}
for key in _name_keys(allocation.employee_name):
for candidate in master_by_name.get(key, []):
candidate_map[candidate.record_id] = candidate
candidates = list(candidate_map.values())
if not candidates:
issues.append(
ValidationIssue(
code="MISSING_MASTERDATA",
message=(
f"In der Stellenbeschreibung ist kein Datensatz für "
f"'{allocation.employee_name}' hinterlegt "
f"(Projekt '{allocation.project_key}'). Keine Förderung möglich."
),
severity="info",
context=allocation.employee_name,
)
)
continue
# Single candidate: use directly
if len(candidates) == 1:
selected = candidates[0]
else:
# Multiple candidates: disambiguate by Tätigkeitsschlüssel.
selected, match_status = _disambiguate_by_project_key(candidates, allocation.project_key)
if match_status == "no_match":
issues.append(
ValidationIssue(
code="NO_MATCHING_TAETIGKEIT",
message=(
f"No Tätigkeitsschlüssel '{allocation.project_key}' found in "
f"master-data for '{allocation.employee_name}'. "
f"Available: {[c.taetigkeit for c in candidates]}. "
f"Keine Förderung möglich."
),
severity="warning",
context=allocation.employee_name,
)
)
continue
elif selected is None:
issues.append(
ValidationIssue(
code="AMBIGUOUS_MASTERDATA",
message=(
f"Ambiguous master-data for '{allocation.employee_name}' / "
f"'{allocation.project_key}': {len(candidates)} rows, "
f"multiple match taetigkeit. "
f"taetigkeit values: {[c.taetigkeit for c in candidates]}"
),
severity="error",
context=allocation.employee_name,
)
)
continue
foerderquote = calculate_foerderquote_percent(allocation)
if allocation.notes:
issues.append(
ValidationIssue(
code="ALLOCATION_NOTE",
message=(
f"Note for '{allocation.employee_name}' / "
f"'{allocation.project_key}': {allocation.notes}"
),
severity="warning",
context=allocation.employee_name,
)
)
matched.append(
MatchedAssignment(
allocation=allocation,
masterdata=selected,
foerderquote_percent=foerderquote,
)
)
return matched, issues
-40
View File
@@ -1,40 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class AllocationRecord:
employee_name: str
contract_hours_weekly: float
project_key: str
funded_hours_weekly: float
notes: str = ""
@dataclass(frozen=True)
class MasterDataRecord:
record_id: str
antragsnummer: str
name: str
qualifikation: str
project_scope: str
stellenanteil_percent: float
funktion: str
taetigkeit: str
@dataclass(frozen=True)
class MatchedAssignment:
allocation: AllocationRecord
masterdata: MasterDataRecord
foerderquote_percent: float
@dataclass(frozen=True)
class ValidationIssue:
code: str
message: str
severity: str = "error"
context: Optional[str] = None
-289
View File
@@ -1,289 +0,0 @@
"""Parsing and normalization for source workbooks."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
from openpyxl.utils.cell import column_index_from_string
from .errors import ValidationError
from .models import AllocationRecord, MasterDataRecord
def _to_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _to_float(value: Any, field_name: str) -> float:
if value is None or str(value).strip() == "":
raise ValidationError(f"Missing numeric value for {field_name}.")
text = str(value).strip().replace(",", ".")
try:
return float(text)
except ValueError as exc:
raise ValidationError(f"Invalid numeric value for {field_name}: {value}") from exc
def _to_float_or_none(value: Any) -> float | None:
if value is None or str(value).strip() == "":
return None
text = str(value).strip().replace(",", ".")
try:
return float(text)
except ValueError:
return None
def _parse_german_decimal(
value: Any,
field_name: str,
decimal_separator: str,
thousands_separator: str,
) -> float:
if value is None or str(value).strip() == "":
raise ValidationError(f"Missing numeric value for {field_name}.")
text = str(value).strip()
if thousands_separator:
text = text.replace(thousands_separator, "")
if decimal_separator:
text = text.replace(decimal_separator, ".")
try:
return float(text)
except ValueError as exc:
raise ValidationError(f"Invalid numeric value for {field_name}: {value}") from exc
def _parse_month_marker(value: Any, prefix: str, date_format: str) -> datetime | None:
marker = _to_text(value)
if not marker:
return None
if not marker.lower().startswith(prefix.lower()):
return None
date_text = marker[len(prefix) :].strip()
return datetime.strptime(date_text, date_format)
def _resolve_month_column_index(
worksheet: Any,
header_row: int,
first_month_col: str,
target_month: int,
target_year: int,
month_prefix: str,
month_date_format: str,
) -> int:
start_index = column_index_from_string(first_month_col)
matched_columns: list[int] = []
for column_index in range(start_index, worksheet.max_column + 1):
value = worksheet.cell(row=header_row, column=column_index).value
parsed = _parse_month_marker(value, month_prefix, month_date_format)
if parsed is None:
continue
if parsed.month == target_month and parsed.year == target_year:
matched_columns.append(column_index)
if len(matched_columns) == 0:
raise ValidationError(
f"No month column found for {target_month:02d}/{target_year} in allocation workbook."
)
if len(matched_columns) > 1:
raise ValidationError(
f"Multiple month columns found for {target_month:02d}/{target_year} in allocation workbook."
)
return matched_columns[0]
def _is_inactive_value(value: Any, inactive_values: list[Any]) -> bool:
normalized = _to_text(value)
if normalized in {"", "-"}:
return True
for inactive in inactive_values:
if str(inactive).strip() == normalized:
return True
return False
def parse_allocation_records(
config: dict[str, Any],
report_month: int,
report_year: int,
project_root: Path,
) -> list[AllocationRecord]:
allocation_cfg = config["allocation"]
columns_cfg = allocation_cfg["columns"]
files_cfg = config["files"]
sheets_cfg = config["sheets"]
paths_cfg = config["paths"]
workbook_path = (
project_root
/ paths_cfg["input_dir"]
/ files_cfg["allocation_workbook"]
)
if not workbook_path.exists():
raise ValidationError(f"Allocation workbook not found: {workbook_path}")
workbook = load_workbook(workbook_path, data_only=True)
sheet_name = sheets_cfg["allocation"]
if sheet_name not in workbook.sheetnames:
raise ValidationError(f"Sheet '{sheet_name}' not found in {workbook_path.name}")
worksheet = workbook[sheet_name]
header_row = allocation_cfg["header_month_marker_row"]
month_marker_cfg = allocation_cfg["month_marker"]
month_column = _resolve_month_column_index(
worksheet=worksheet,
header_row=header_row,
first_month_col=columns_cfg["first_month_column"],
target_month=report_month,
target_year=report_year,
month_prefix=month_marker_cfg["prefix"],
month_date_format=month_marker_cfg["date_format"],
)
employee_col = column_index_from_string(columns_cfg["employee_name"])
contract_col = column_index_from_string(columns_cfg["contract_hours_weekly"])
project_col = column_index_from_string(columns_cfg["project_key"])
remarks_col = column_index_from_string(columns_cfg["remarks"])
inactive_values = allocation_cfg.get("inactive_values", ["", "-", "0", 0])
current_employee = ""
current_contract_hours: float | None = None
normalized_records: list[AllocationRecord] = []
for row_index in range(header_row + 1, worksheet.max_row + 1):
row_employee = _to_text(worksheet.cell(row=row_index, column=employee_col).value)
row_contract = worksheet.cell(row=row_index, column=contract_col).value
row_project = _to_text(worksheet.cell(row=row_index, column=project_col).value)
if row_employee:
current_employee = row_employee
current_contract_hours = _to_float_or_none(row_contract)
if not row_project:
continue
if not current_employee or current_contract_hours is None:
raise ValidationError(
f"Project row {row_index} has no inherited employee context."
)
month_value = worksheet.cell(row=row_index, column=month_column).value
if _is_inactive_value(month_value, inactive_values):
continue
funded_hours = _to_float(month_value, f"funded_hours row {row_index}")
if funded_hours <= 0:
continue
if current_contract_hours <= 0:
raise ValidationError(
f"Contract hours must be > 0 for employee '{current_employee}'"
)
notes = _to_text(worksheet.cell(row=row_index, column=remarks_col).value)
normalized_records.append(
AllocationRecord(
employee_name=current_employee,
contract_hours_weekly=current_contract_hours,
project_key=row_project,
funded_hours_weekly=funded_hours,
notes=notes,
)
)
workbook.close()
return normalized_records
def parse_masterdata_records(
config: dict[str, Any],
project_root: Path,
) -> list[MasterDataRecord]:
files_cfg = config["files"]
sheets_cfg = config["sheets"]
paths_cfg = config["paths"]
master_cfg = config["masterdata"]
columns_cfg = master_cfg["columns"]
decimal_cfg = master_cfg["decimal"]
workbook_path = (
project_root
/ paths_cfg["input_dir"]
/ files_cfg["masterdata_workbook"]
)
if not workbook_path.exists():
raise ValidationError(f"Master data workbook not found: {workbook_path}")
workbook = load_workbook(workbook_path, data_only=True)
sheet_name = sheets_cfg["masterdata"]
if sheet_name not in workbook.sheetnames:
raise ValidationError(f"Sheet '{sheet_name}' not found in {workbook_path.name}")
worksheet = workbook[sheet_name]
id_col = column_index_from_string(columns_cfg["id"])
antragsnummer_col = column_index_from_string(columns_cfg["antragsnummer"])
name_col = column_index_from_string(columns_cfg["name"])
qualifikation_col = column_index_from_string(columns_cfg["qualifikation"])
project_scope_col = column_index_from_string(columns_cfg["project_scope"])
stellenanteil_col = column_index_from_string(columns_cfg["stellenanteil"])
funktion_col = column_index_from_string(columns_cfg["funktion"])
taetigkeit_col = column_index_from_string(columns_cfg["taetigkeit"])
records: list[MasterDataRecord] = []
for row_index in range(2, worksheet.max_row + 1):
name = _to_text(worksheet.cell(row=row_index, column=name_col).value)
if not name:
continue
stellenanteil_raw = worksheet.cell(row=row_index, column=stellenanteil_col).value
stellenanteil = _to_float_or_none(stellenanteil_raw)
if stellenanteil is None:
stellenanteil = 0.0
project_scope = _to_text(
worksheet.cell(row=row_index, column=project_scope_col).value
)
if not project_scope:
project_scope = "PS"
records.append(
MasterDataRecord(
record_id=_to_text(worksheet.cell(row=row_index, column=id_col).value),
antragsnummer=_to_text(
worksheet.cell(row=row_index, column=antragsnummer_col).value
),
name=name,
qualifikation=_to_text(
worksheet.cell(row=row_index, column=qualifikation_col).value
),
project_scope=project_scope,
stellenanteil_percent=stellenanteil,
funktion=_to_text(worksheet.cell(row=row_index, column=funktion_col).value),
taetigkeit=_to_text(
worksheet.cell(row=row_index, column=taetigkeit_col).value
),
)
)
workbook.close()
return records
-1
View File
@@ -1 +0,0 @@
"""Shared utility helpers."""
-383
View File
@@ -1,383 +0,0 @@
"""Source and business rule validation."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
from openpyxl.utils.cell import column_index_from_string
from .models import AllocationRecord, MatchedAssignment, ValidationIssue
def _to_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _to_float_or_none(value: Any) -> float | None:
text = _to_text(value)
if not text:
return None
text = text.replace(",", ".")
try:
return float(text)
except ValueError:
return None
def _parse_month_marker(value: Any, prefix: str, date_format: str) -> datetime | None:
marker = _to_text(value)
if not marker or not marker.lower().startswith(prefix.lower()):
return None
date_text = marker[len(prefix) :].strip()
try:
return datetime.strptime(date_text, date_format)
except ValueError:
return None
def validate_sources(
config: dict[str, Any],
report_month: int,
report_year: int,
project_root: Path,
) -> list[ValidationIssue]:
issues: list[ValidationIssue] = []
paths_cfg = config["paths"]
files_cfg = config["files"]
sheets_cfg = config["sheets"]
allocation_cfg = config["allocation"]
allocation_cols = allocation_cfg["columns"]
month_cfg = allocation_cfg["month_marker"]
input_dir = project_root / paths_cfg["input_dir"]
template_dir = project_root / paths_cfg["template_dir"]
allocation_path = input_dir / files_cfg["allocation_workbook"]
masterdata_path = input_dir / files_cfg["masterdata_workbook"]
template_path = template_dir / files_cfg["template_workbook"]
for required_path in [allocation_path, masterdata_path, template_path]:
if not required_path.exists():
issues.append(
ValidationIssue(
code="MISSING_FILE",
message=f"Required file not found: {required_path}",
severity="error",
)
)
if any(issue.code == "MISSING_FILE" for issue in issues):
return issues
allocation_wb = load_workbook(allocation_path, data_only=True)
master_wb = load_workbook(masterdata_path, data_only=True)
allocation_sheet_name = sheets_cfg["allocation"]
master_sheet_name = sheets_cfg["masterdata"]
if allocation_sheet_name not in allocation_wb.sheetnames:
issues.append(
ValidationIssue(
code="MISSING_SHEET",
message=f"Sheet '{allocation_sheet_name}' missing in {allocation_path.name}",
severity="error",
)
)
if master_sheet_name not in master_wb.sheetnames:
issues.append(
ValidationIssue(
code="MISSING_SHEET",
message=f"Sheet '{master_sheet_name}' missing in {masterdata_path.name}",
severity="error",
)
)
if any(issue.code == "MISSING_SHEET" for issue in issues):
allocation_wb.close()
master_wb.close()
return issues
allocation_ws = allocation_wb[allocation_sheet_name]
master_ws = master_wb[master_sheet_name]
required_allocation_columns = [
allocation_cols["employee_name"],
allocation_cols["contract_hours_weekly"],
allocation_cols["project_key"],
allocation_cols["first_month_column"],
allocation_cols["remarks"],
]
max_allocation_col = allocation_ws.max_column
for col_name in required_allocation_columns:
if column_index_from_string(col_name) > max_allocation_col:
issues.append(
ValidationIssue(
code="MISSING_COLUMN",
message=f"Allocation column '{col_name}' is not present.",
severity="error",
)
)
master_cols = config["masterdata"]["columns"]
required_master_columns = [
master_cols["id"],
master_cols["antragsnummer"],
master_cols["name"],
master_cols["qualifikation"],
master_cols["project_scope"],
master_cols["stellenanteil"],
master_cols["funktion"],
master_cols["taetigkeit"],
]
max_master_col = master_ws.max_column
for col_name in required_master_columns:
if column_index_from_string(col_name) > max_master_col:
issues.append(
ValidationIssue(
code="MISSING_COLUMN",
message=f"Master-data column '{col_name}' is not present.",
severity="error",
)
)
header_row = allocation_cfg["header_month_marker_row"]
first_month_col = column_index_from_string(allocation_cols["first_month_column"])
month_matches: list[int] = []
parseable_markers = 0
for col_idx in range(first_month_col, allocation_ws.max_column + 1):
marker_value = allocation_ws.cell(row=header_row, column=col_idx).value
parsed = _parse_month_marker(
marker_value,
month_cfg["prefix"],
month_cfg["date_format"],
)
if parsed is None:
continue
parseable_markers += 1
if parsed.month == report_month and parsed.year == report_year:
month_matches.append(col_idx)
if parseable_markers == 0:
issues.append(
ValidationIssue(
code="INVALID_MONTH_MARKERS",
message="No parseable month marker found in allocation header row.",
severity="error",
)
)
if len(month_matches) == 0:
issues.append(
ValidationIssue(
code="MONTH_NOT_FOUND",
message=f"Requested month {report_month:02d}/{report_year} not found in allocation sheet.",
severity="error",
)
)
elif len(month_matches) > 1:
issues.append(
ValidationIssue(
code="MONTH_AMBIGUOUS",
message=f"Requested month {report_month:02d}/{report_year} appears in multiple columns.",
severity="error",
)
)
if len(month_matches) == 1:
month_column = month_matches[0]
employee_col = column_index_from_string(allocation_cols["employee_name"])
contract_col = column_index_from_string(allocation_cols["contract_hours_weekly"])
project_col = column_index_from_string(allocation_cols["project_key"])
current_contract_value: float | None = None
for row_idx in range(header_row + 1, allocation_ws.max_row + 1):
employee_value = _to_text(allocation_ws.cell(row=row_idx, column=employee_col).value)
contract_raw = allocation_ws.cell(row=row_idx, column=contract_col).value
parsed_contract = _to_float_or_none(contract_raw)
if employee_value:
current_contract_value = parsed_contract
project_key = _to_text(allocation_ws.cell(row=row_idx, column=project_col).value)
if not project_key:
continue
if current_contract_value is None or current_contract_value <= 0:
issues.append(
ValidationIssue(
code="INVALID_CONTRACT_HOURS",
message=(
f"Invalid inherited contract weekly hours at row {row_idx}."
),
severity="error",
)
)
monthly_value = allocation_ws.cell(row=row_idx, column=month_column).value
monthly_text = _to_text(monthly_value)
if monthly_text in {"", "-", "0"}:
continue
monthly_hours = _to_float_or_none(monthly_value)
if monthly_hours is None:
issues.append(
ValidationIssue(
code="INVALID_HOUR_VALUE",
message=f"Invalid funded hour value at row {row_idx}.",
severity="error",
)
)
allocation_wb.close()
master_wb.close()
return issues
def _split_name_for_filename(name: str) -> tuple[str, str]:
cleaned = _to_text(name)
if "," in cleaned:
last_name, first_name = [part.strip() for part in cleaned.split(",", 1)]
return first_name or "Unknown", last_name or "Unknown"
parts = cleaned.split()
if len(parts) >= 2:
return parts[0], parts[-1]
if len(parts) == 1:
return parts[0], "Unknown"
return "Unknown", "Unknown"
def _build_output_filename(pattern: str, year: int, month: int, employee_name: str) -> str:
first_name, last_name = _split_name_for_filename(employee_name)
safe_first = first_name.replace(" ", "_")
safe_last = last_name.replace(" ", "_")
return pattern.format(
year=year,
month=f"{month:02d}",
first_name=safe_first,
last_name=safe_last,
)
def validate_business_rules(
config: dict[str, Any],
report_month: int,
report_year: int,
allocation_records: list[AllocationRecord],
matched_assignments: list[MatchedAssignment],
project_root: Path,
) -> list[ValidationIssue]:
issues: list[ValidationIssue] = []
if len(allocation_records) == 0:
issues.append(
ValidationIssue(
code="NO_ACTIVE_ASSIGNMENT",
message="No active allocation record found for the requested month.",
severity="error",
)
)
matched_keys: set[tuple[str, str, float]] = set()
for item in matched_assignments:
key = (
item.allocation.employee_name,
item.allocation.project_key,
item.allocation.funded_hours_weekly,
)
if key in matched_keys:
issues.append(
ValidationIssue(
code="DUPLICATE_MATCH",
message=(
f"Duplicate matched assignment for '{item.allocation.employee_name}' "
f"and project '{item.allocation.project_key}'."
),
severity="error",
context=item.allocation.employee_name,
)
)
matched_keys.add(key)
paths_cfg = config["paths"]
files_cfg = config["files"]
template_path = (
project_root
/ paths_cfg["template_dir"]
/ files_cfg["template_workbook"]
)
if template_path.exists():
template_wb = load_workbook(template_path, data_only=True)
configured_sheet = config["sheets"].get("template_source", "")
if configured_sheet not in template_wb.sheetnames:
issues.append(
ValidationIssue(
code="TEMPLATE_SHEET_MISSING",
message=(
f"Configured template sheet '{configured_sheet}' not found in template workbook."
),
severity="error",
)
)
template_wb.close()
else:
issues.append(
ValidationIssue(
code="MISSING_TEMPLATE",
message=f"Template workbook not found: {template_path}",
severity="error",
)
)
template_targets = config.get("template_targets", {})
missing_targets = [name for name, cell in template_targets.items() if not _to_text(cell)]
if missing_targets:
issues.append(
ValidationIssue(
code="MISSING_TEMPLATE_TARGETS",
message=(
"Mandatory template target cells not configured: "
+ ", ".join(missing_targets)
),
severity="error",
)
)
filename_pattern = config["output"]["filename_pattern"]
filename_to_employee: dict[str, str] = {}
for assignment in matched_assignments:
filename = _build_output_filename(
filename_pattern,
report_year,
report_month,
assignment.allocation.employee_name,
)
previous_owner = filename_to_employee.get(filename)
if previous_owner is None:
filename_to_employee[filename] = assignment.allocation.employee_name
continue
if previous_owner != assignment.allocation.employee_name:
issues.append(
ValidationIssue(
code="FILENAME_COLLISION",
message=(
f"Output filename collision between '{previous_owner}' and "
f"'{assignment.allocation.employee_name}' -> {filename}"
),
severity="error",
)
)
return issues
-153
View File
@@ -1,153 +0,0 @@
"""Template-based output workbook writer."""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from pathlib import Path
from typing import Any
from openpyxl import Workbook, load_workbook
from .models import MatchedAssignment
def _split_name_for_filename(name: str) -> tuple[str, str]:
cleaned = str(name or "").strip()
if "," in cleaned:
last_name, first_name = [part.strip() for part in cleaned.split(",", 1)]
return first_name or "Unknown", last_name or "Unknown"
parts = cleaned.split()
if len(parts) >= 2:
return parts[0], parts[-1]
if len(parts) == 1:
return parts[0], "Unknown"
return "Unknown", "Unknown"
def _normalize_sheet_title(raw_title: str, fallback_index: int) -> str:
cleaned = (raw_title or "").strip().replace("[", "(").replace("]", ")")
if not cleaned:
cleaned = f"Assignment_{fallback_index}"
return cleaned[:31]
def _set_target_value(worksheet: Any, target_cell: str, value: Any) -> None:
if not target_cell:
return
worksheet[target_cell] = value
def build_employee_workbooks(
config: dict[str, Any],
matched_assignments: list[MatchedAssignment],
report_month: int,
report_year: int,
project_root: Path,
) -> dict[str, Workbook]:
paths_cfg = config["paths"]
files_cfg = config["files"]
output_cfg = config["output"]
targets = config.get("template_targets", {})
template_path = (
project_root
/ paths_cfg["template_dir"]
/ files_cfg["template_workbook"]
)
assignments_by_employee: dict[str, list[MatchedAssignment]] = defaultdict(list)
for assignment in matched_assignments:
assignments_by_employee[assignment.allocation.employee_name].append(assignment)
workbooks: dict[str, Workbook] = {}
month_label = f"{report_month:02d}/{report_year}"
today_label = date.today().strftime("%d.%m.%Y")
for employee_name, assignments in assignments_by_employee.items():
workbook = load_workbook(template_path)
template_sheet_name = config["sheets"].get("template_source", "")
if template_sheet_name and template_sheet_name in workbook.sheetnames:
base_sheet = workbook[template_sheet_name]
else:
base_sheet = workbook.worksheets[0]
for index, assignment in enumerate(assignments, start=1):
if index == 1:
sheet = base_sheet
else:
sheet = workbook.copy_worksheet(base_sheet)
sheet_name_pattern = output_cfg.get("sheet_name_pattern", "{index}_{funktion}")
raw_sheet_name = sheet_name_pattern.format(
index=index,
funktion=assignment.masterdata.funktion,
)
sheet.title = _normalize_sheet_title(raw_sheet_name, index)
_set_target_value(sheet, targets.get("antragsnummer", ""), assignment.masterdata.antragsnummer)
_set_target_value(sheet, targets.get("name_vorname", ""), assignment.allocation.employee_name)
_set_target_value(sheet, targets.get("qualifikation", ""), assignment.masterdata.qualifikation)
_set_target_value(sheet, targets.get("funktion", ""), assignment.masterdata.funktion)
_set_target_value(sheet, targets.get("taetigkeiten", ""), assignment.masterdata.taetigkeit)
_set_target_value(sheet, targets.get("reporting_month_label", ""), month_label)
_set_target_value(sheet, targets.get("foerderquote", ""), f"{assignment.foerderquote_percent:.2f} %")
_set_target_value(sheet, targets.get("generated_on", ""), today_label)
workbooks[employee_name] = workbook
return workbooks
def build_output_filename(
filename_pattern: str,
employee_name: str,
report_year: int,
report_month: int,
) -> str:
first_name, last_name = _split_name_for_filename(employee_name)
safe_first = first_name.replace(" ", "_")
safe_last = last_name.replace(" ", "_")
return filename_pattern.format(
year=report_year,
month=f"{report_month:02d}",
first_name=safe_first,
last_name=safe_last,
)
def save_employee_workbooks(
config: dict[str, Any],
employee_workbooks: dict[str, Workbook],
report_month: int,
report_year: int,
project_root: Path,
) -> list[Path]:
paths_cfg = config["paths"]
output_cfg = config["output"]
filename_pattern = output_cfg["filename_pattern"]
output_dir = project_root / paths_cfg["output_dir"]
output_dir.mkdir(parents=True, exist_ok=True)
saved_paths: list[Path] = []
used_names: set[str] = set()
for employee_name, workbook in employee_workbooks.items():
filename = build_output_filename(
filename_pattern=filename_pattern,
employee_name=employee_name,
report_year=report_year,
report_month=report_month,
)
if filename in used_names:
raise ValueError(f"Output filename collision detected: {filename}")
used_names.add(filename)
output_path = output_dir / filename
workbook.save(output_path)
workbook.close()
saved_paths.append(output_path)
return saved_paths
+206
View File
@@ -0,0 +1,206 @@
"""Validierung von Input-Dateien und Templates."""
from pathlib import Path
from typing import Optional
import fitz # PyMuPDF
from openpyxl import load_workbook
from openpyxl.utils import column_index_from_string
from .config import is_month_sheet
from .models import FieldSchema, OutputMapping, ValidationResult
def validate_input_file(
filepath: Path,
schema: list[FieldSchema],
month_sheet_pattern: str = "* 20??",
) -> ValidationResult:
"""
Validiert eine Input-Datei:
- Datei existiert
- Erwartete Spalten sind vorhanden (Header in Zeile 1)
- Mindestens ein Monats-Daten-Sheet enthält Daten
- Nicht-Monats-Sheets (z. B. "Alle Rollen") werden übersprungen
"""
errors: list[str] = []
warnings: list[str] = []
info: dict = {}
if not filepath.exists():
errors.append(f"Input-Datei nicht gefunden: {filepath}")
return ValidationResult(is_valid=False, errors=errors)
try:
wb = load_workbook(filepath, data_only=True)
except Exception as e:
errors.append(f"Datei konnte nicht geöffnet werden: {e}")
return ValidationResult(is_valid=False, errors=errors)
sheet_count = len(wb.sheetnames)
info["sheet_count"] = sheet_count
info["sheet_names"] = wb.sheetnames
# Prüfe nur Monats-Sheets (überspringe z. B. "Alle Rollen")
schema_col_count = len(schema)
sheets_with_data = 0
for sheet_name in wb.sheetnames:
if not is_month_sheet(sheet_name, month_sheet_pattern):
continue
ws = wb[sheet_name]
if ws.max_row < 2:
continue # Kein Header + Daten
# min_col=1 explizit setzen, um Verschiebungen bei
# ws.min_column > 1 zu vermeiden
header_row = list(ws.iter_rows(
min_row=1, max_row=1, min_col=1, max_col=schema_col_count,
values_only=True,
))[0]
missing_cols = []
for field in schema:
col_idx = column_index_from_string(field.column) - 1
if col_idx >= len(header_row):
missing_cols.append(f"{field.column} ({field.field_name})")
continue
header_val = str(header_row[col_idx]).strip() if header_row[col_idx] is not None else ""
if header_val != field.field_name:
warnings.append(
f"Sheet '{sheet_name}', Spalte {field.column}: "
f"Erwartet '{field.field_name}', gefunden '{header_val}'"
)
if not missing_cols:
sheets_with_data += 1
# Zähle Datenzeilen
data_rows = ws.max_row - 1
info[f"rows_{sheet_name}"] = data_rows
info["sheets_with_data"] = sheets_with_data
wb.close()
# ── Dateninhalts-Prüfung: leere Pflichtfelder ───────────────────
required_fields = [f for f in schema if f.required]
if required_fields and sheets_with_data > 0:
# Mapping field_name → column index
field_col_map: dict[str, int] = {}
for f in required_fields:
field_col_map[f.field_name] = column_index_from_string(f.column) - 1
max_col = max(field_col_map.values()) + 1
# {field_name: {name1, name2, ...}} für leere Pflichtwerte
empty_by_field: dict[str, set[str]] = {f.field_name: set() for f in required_fields}
wb2 = load_workbook(filepath, data_only=True)
for sheet_name in wb2.sheetnames:
if not is_month_sheet(sheet_name, month_sheet_pattern):
continue
ws = wb2[sheet_name]
if ws.max_row < 2:
continue
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=1, max_col=max_col, values_only=True):
name_val = str(row[field_col_map.get("Name", 0)] or "").strip() if len(row) > field_col_map.get("Name", 0) else ""
if not name_val:
continue
for f in required_fields:
col_idx = field_col_map[f.field_name]
if col_idx < len(row):
cell_val = str(row[col_idx] or "").strip() if row[col_idx] is not None else ""
if not cell_val:
empty_by_field[f.field_name].add(name_val)
wb2.close()
for field_name, names in empty_by_field.items():
if names:
sorted_names = sorted(names)
total = len(sorted_names)
preview = sorted_names[:5]
suffix = " …" if total > 5 else ""
warnings.append(
f"Feld '{field_name}' ist leer bei {total} Person(en): "
f"{', '.join(preview)}{suffix}"
)
# ──────────────────────────────────────────────────────────────────
if sheets_with_data == 0:
errors.append("Kein Sheet mit den erwarteten Spalten gefunden.")
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
warnings=warnings,
info=info,
)
def validate_template(
template_path: Path,
mapping: list[OutputMapping],
) -> ValidationResult:
"""
Validiert ein PDF-Template:
- Datei existiert
- Alle _Field-Platzhalter (pdf_field) sind im PDF auf irgendeiner Seite vorhanden
"""
errors: list[str] = []
warnings: list[str] = []
info: dict = {}
if not template_path.exists():
errors.append(f"Template-Datei nicht gefunden: {template_path}")
return ValidationResult(is_valid=False, errors=errors)
try:
doc = fitz.open(template_path)
except Exception as e:
errors.append(f"PDF-Template konnte nicht geöffnet werden: {e}")
return ValidationResult(is_valid=False, errors=errors)
if doc.page_count == 0:
errors.append("PDF-Template enthält keine Seiten.")
doc.close()
return ValidationResult(is_valid=False, errors=errors)
found_count = 0
missing_fields = []
for m in mapping:
if m.pdf_field is None:
continue # Kein Marker erwartet
# Suche auf allen Seiten
found_any = False
for page_num in range(doc.page_count):
found = doc[page_num].search_for(m.pdf_field)
if found:
found_any = True
break
if found_any:
found_count += 1
else:
missing_fields.append(m.source_field)
# Zähle unique pdf_fields (nicht die source_fields, da Tätigkeit doppelt vorkommt)
unique_pdf_fields = set(m.pdf_field for m in mapping if m.pdf_field is not None)
total_pages = doc.page_count
doc.close()
info["found_fields"] = found_count
info["total_fields"] = len(unique_pdf_fields)
info["page_count"] = total_pages
if missing_fields:
errors.append(
f"Platzhalter nicht im PDF-Template gefunden: {', '.join(missing_fields)}"
)
return ValidationResult(
is_valid=len(errors) == 0,
errors=errors,
warnings=warnings,
info=info,
)
+289
View File
@@ -0,0 +1,289 @@
"""PDF-basierte Activity-Report-Generierung.
Verwendet ein mehrseitiges PDF-Template mit _Field-Platzhaltern.
Für jeden Mitarbeiter wird ein PDF erzeugt (alle Template-Seiten pro Monat).
Die _Field-Marker werden per PyMuPDF (fitz) gesucht und durch echte Daten ersetzt.
"""
import calendar
from pathlib import Path
import fitz # PyMuPDF
from .models import EmployeeRecord, OutputMapping
# ─────────────────────────────────────────────
# Namens-Helfer
# ─────────────────────────────────────────────
def _split_name_for_filename(name: str) -> tuple[str, str]:
"""Teilt 'Nachname, Vorname' oder 'Vorname Nachname' in (Vorname, Nachname)."""
cleaned = str(name or "").strip()
if "," in cleaned:
last_name, first_name = [p.strip() for p in cleaned.split(",", 1)]
return first_name or "Unknown", last_name or "Unknown"
parts = cleaned.split()
if len(parts) >= 2:
return parts[0], parts[-1]
if len(parts) == 1:
return parts[0], "Unknown"
return "Unknown", "Unknown"
def _format_value(value, fmt: str | None) -> str:
"""Formatiert einen Wert gemäß Format-String."""
if value is None:
return ""
if fmt is None:
return str(value)
if "%" in fmt:
try:
return fmt % float(value)
except (ValueError, TypeError):
return str(value)
return str(value)
def _format_month_for_template(monat_str: str) -> str:
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
monat_str = monat_str.strip()
parts = monat_str.split(".")
if len(parts) == 2:
month = parts[0]
year = parts[1]
if len(year) == 2:
year = "20" + year
return f"{month}.{year}"
return monat_str
# ─────────────────────────────────────────────
# Daten-Extraktion aus EmployeeRecord
# ─────────────────────────────────────────────
def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: str) -> str:
"""Liefert den formatierten Wert für ein Feld im PDF."""
if source_field == "Monat (MM.JJ)":
return _format_month_for_template(record.monat)
elif source_field == "Stellenanteil in %":
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]
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_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:
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,
mapping: list[OutputMapping],
output_dir: Path,
filename_pattern: str,
) -> list[Path]:
"""
Erzeugt für jeden Mitarbeiter ein PDF.
Pro Monat werden ALLE Template-Seiten kopiert und mit Daten befüllt.
"""
output_dir.mkdir(parents=True, exist_ok=True)
generated_files: list[Path] = []
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
field_info = _scan_template_fields(template_path, mapping)
for employee_name, records in employee_records.items():
if not records:
continue
first_name, last_name = _split_name_for_filename(employee_name)
safe_first = first_name.replace(" ", "_").replace("/", "_")
safe_last = last_name.replace(" ", "_").replace("/", "_")
filename = filename_pattern.format(first_name=safe_first, last_name=safe_last)
output_path = output_dir / filename
sorted_records = sorted(records, key=lambda r: r.month_index)
# Ergebnis-PDF erstellen
result_doc = fitz.open()
for record in sorted_records:
# Template frisch öffnen für jeden Monat
temp_doc = fitz.open(template_path)
pages = [temp_doc[i] for i in range(temp_doc.page_count)]
_fill_pdf_pages(pages, record, employee_name, mapping, field_info)
# Alle modifizierten Seiten ins Ergebnis-PDF einfügen
result_doc.insert_pdf(temp_doc)
temp_doc.close()
result_doc.save(output_path)
result_doc.close()
generated_files.append(output_path)
return generated_files
+48
View File
@@ -0,0 +1,48 @@
@echo off
title Tätigkeitsnachweis-Generator
echo ========================================
echo Tätigkeitsnachweis-Generator
echo ========================================
echo.
:: Prüfe ob Python verfügbar ist
where python >nul 2>nul
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Python wurde nicht gefunden.
echo Bitte installiere Python 3.9 oder neuer von https://python.org
pause
exit /b 1
)
echo [INFO] Python gefunden:
python --version
:: Prüfe ob venv existiert, sonst erstelle
if not exist ".venv\Scripts\python.exe" (
echo [INFO] Erstelle virtuelle Umgebung...
python -m venv .venv
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Konnte venv nicht erstellen.
pause
exit /b 1
)
)
:: Aktiviere venv und installiere Abhängigkeiten
call .venv\Scripts\activate.bat
echo [INFO] Installiere Abhängigkeiten...
pip install -r requirements.txt --quiet
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Konnte Abhängigkeiten nicht installieren.
pause
exit /b 1
)
:: Starte Streamlit
echo.
echo [INFO] Starte Tätigkeitsnachweis-Generator...
echo [INFO] Die App öffnet sich im Browser unter http://localhost:8501
echo.
streamlit run app.py
pause
Executable
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# Doppelklickbares Start-Script für macOS (.command wird von Finder automatisch im Terminal geöffnet)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "========================================"
echo " Tätigkeitsnachweis-Generator"
echo "========================================"
echo ""
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="python3"
elif command -v python >/dev/null 2>&1; then
PYTHON_BIN="python"
else
echo "[FEHLER] Python wurde nicht gefunden."
echo "Bitte installiere Python 3.9+ von https://python.org"
read -p "Drücke Enter zum Beenden..."
exit 1
fi
echo "[INFO] Python: $("$PYTHON_BIN" --version)"
VENV_DIR=".venv"
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
if [[ -d "$VENV_DIR" ]]; then
echo "[INFO] Erstelle venv neu..."
rm -rf "$VENV_DIR"
fi
echo "[INFO] Erstelle virtuelle Umgebung..."
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
echo "[INFO] Installiere Abhängigkeiten..."
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet
echo ""
echo "[INFO] Starte Tätigkeitsnachweis-Generator..."
echo "[INFO] Browser: http://localhost:8501"
echo ""
"$VENV_DIR/bin/streamlit" run app.py
read -p "Drücke Enter zum Beenden..."
Regular → Executable
+29 -21
View File
@@ -5,35 +5,43 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
VENV_DIR=".venv"
echo "========================================"
echo " Tätigkeitsnachweis-Generator"
echo "========================================"
echo ""
# Prüfe ob Python verfügbar ist
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN="python3"
PYTHON_BIN="python3"
elif command -v python >/dev/null 2>&1; then
PYTHON_BIN="python"
PYTHON_BIN="python"
else
echo "Error: Python was not found on PATH."
exit 1
echo "[FEHLER] Python wurde nicht gefunden."
echo "Bitte installiere Python 3.9 oder neuer von https://python.org"
exit 1
fi
echo "Using Python: $PYTHON_BIN"
echo "[INFO] Python gefunden:"
"$PYTHON_BIN" --version
# Prüfe ob venv existiert, sonst erstelle
VENV_DIR=".venv"
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
if [[ -d "$VENV_DIR" ]]; then
echo "Existing virtual environment is not compatible with this OS. Recreating $VENV_DIR ..."
rm -rf "$VENV_DIR"
fi
echo "Creating virtual environment in $VENV_DIR ..."
"$PYTHON_BIN" -m venv "$VENV_DIR"
if [[ -d "$VENV_DIR" ]]; then
echo "[INFO] Vorhandene venv ist nicht kompatibel. Erstelle neu..."
rm -rf "$VENV_DIR"
fi
echo "[INFO] Erstelle virtuelle Umgebung..."
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
VENV_PYTHON="$VENV_DIR/bin/python"
# Aktiviere venv und installiere Abhängigkeiten
echo "[INFO] Installiere Abhängigkeiten..."
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet
echo "Upgrading pip and packaging tools ..."
"$VENV_PYTHON" -m pip install --upgrade pip
echo "Installing requirements ..."
"$VENV_PYTHON" -m pip install -r requirements.txt
echo "Starting program ..."
"$VENV_PYTHON" "generate.py" --month 5 --year 2026
# Starte Streamlit
echo ""
echo "[INFO] Starte Tätigkeitsnachweis-Generator..."
echo "[INFO] Die App öffnet sich im Browser unter http://localhost:8501"
echo ""
"$VENV_DIR/bin/streamlit" run app.py
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
# Doppelklickbares Test-Script für macOS (.command wird von Finder automatisch im Terminal geöffnet)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "========================================"
echo " Tätigkeitsnachweis-Generator – Tests"
echo "========================================"
echo ""
VENV_DIR=".venv"
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
echo "[FEHLER] Virtuelle Umgebung nicht gefunden."
echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten."
read -p "Drücke Enter zum Beenden..."
exit 1
fi
echo "[INFO] Stelle sicher, dass pytest installiert ist..."
"$VENV_DIR/bin/pip" install pytest --quiet 2>/dev/null || true
echo ""
echo "[INFO] Führe End-to-End-Tests aus…"
echo ""
"$VENV_DIR/bin/python" -m pytest tests/test_e2e.py -v
echo ""
read -p "Drücke Enter zum Beenden..."
+1
View File
@@ -0,0 +1 @@
"""Tests für den Tätigkeitsnachweis-Generator."""
+220
View File
@@ -0,0 +1,220 @@
"""End-to-End-Test: Reale Input-Datei + reales PDF-Template → PDF-Output für erste Person."""
import sys
from pathlib import Path
import fitz # PyMuPDF
import pytest
# Projekt-Root zum Python-Path hinzufügen
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.config import load_settings, load_input_schema, load_output_mapping
from src.parser import parse_input_file, group_by_employee, get_unique_employees
from src.writer import generate_activity_reports
# ──────────────────────────────────────────────
# Pfade zu den realen Fixture-Dateien
# ──────────────────────────────────────────────
CONFIG_DIR = PROJECT_ROOT / "config"
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx"
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf"
def _load_app_config():
"""Lädt die echte App-Konfiguration (Schema, Mapping, Settings)."""
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
config.input_schema = load_input_schema(CONFIG_DIR)
config.output_mapping = load_output_mapping(CONFIG_DIR)
return config
# ──────────────────────────────────────────────
# Fixtures
# ──────────────────────────────────────────────
@pytest.fixture(scope="module")
def app_config():
"""Reale App-Konfiguration aus config/."""
return _load_app_config()
@pytest.fixture(scope="module")
def parsed_records(app_config):
"""Geparste Records aus der realen Input-Datei."""
return parse_input_file(
filepath=INPUT_FILE,
schema=app_config.input_schema,
month_sheet_pattern=app_config.month_sheet_pattern,
skip_empty_name=app_config.skip_empty_name,
skip_empty_antragsnummer=app_config.skip_empty_antragsnummer,
)
@pytest.fixture
def output_dir(tmp_path):
"""Temporäres Output-Verzeichnis."""
out = tmp_path / "output"
out.mkdir()
return out
# ──────────────────────────────────────────────
# Hilfsfunktion: Text aus PDF extrahieren
# ──────────────────────────────────────────────
def _get_pdf_text(pdf_path: Path) -> str:
"""Extrahiert den gesamten Text aus einem PDF."""
doc = fitz.open(pdf_path)
text = ""
for page in doc:
text += page.get_text()
doc.close()
return text
def _get_page_text(pdf_path: Path, page_num: int = 0) -> str:
"""Extrahiert Text von einer bestimmten PDF-Seite."""
doc = fitz.open(pdf_path)
text = doc[page_num].get_text() if page_num < doc.page_count else ""
doc.close()
return text
# ──────────────────────────────────────────────
# End-to-End Tests
# ──────────────────────────────────────────────
class TestEndToEndFirstPerson:
"""End-to-End: Reale Input-Datei + reales PDF-Template → nur erste Person."""
def test_input_file_exists(self):
"""Stellt sicher, dass die reale Input-Datei vorhanden ist."""
assert INPUT_FILE.exists(), f"Input-Datei nicht gefunden: {INPUT_FILE}"
def test_template_file_exists(self):
"""Stellt sicher, dass das reale PDF-Template vorhanden ist."""
assert TEMPLATE_FILE.exists(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
def test_template_has_field_markers(self, app_config):
"""Stellt sicher, dass alle _Field-Platzhalter im PDF-Template vorhanden sind."""
doc = fitz.open(TEMPLATE_FILE)
missing = []
for m in app_config.output_mapping:
if m.pdf_field is None:
continue
# Suche auf allen Seiten
found_any = False
for page_num in range(doc.page_count):
if doc[page_num].search_for(m.pdf_field):
found_any = True
break
if not found_any:
missing.append(m.pdf_field)
doc.close()
assert len(missing) == 0, (
f"Folgende _Field-Platzhalter fehlen im PDF: {missing}"
)
def test_parse_real_input(self, parsed_records):
"""Validiert das Parsen der realen Input-Datei."""
# Sollte 168 Einträge über 12 Monate für 19 Personen ergeben
assert len(parsed_records) == 168, (
f"Erwartet 168 Records, aber {len(parsed_records)} gefunden"
)
employees = get_unique_employees(parsed_records)
assert len(employees) == 19, (
f"Erwartet 19 Mitarbeiter, aber {len(employees)} gefunden"
)
# Erste Person alphabetisch: Draßdo, Cornelia
assert employees[0] == "Draßdo, Cornelia", (
f"Erwartet 'Draßdo, Cornelia' als erste Person, aber '{employees[0]}'"
)
def test_full_pipeline_first_person(
self, app_config, parsed_records, output_dir
):
"""Kompletter Workflow für die erste gefundene Person (Draßdo, Cornelia)."""
# 1. Nach Mitarbeiter gruppieren
grouped = group_by_employee(parsed_records)
assert len(grouped) == 19
# 2. Erste Person alphabetisch
first_person = sorted(grouped.keys())[0]
assert first_person == "Draßdo, Cornelia"
first_records = grouped[first_person]
# Draßdo hat 12 Einträge (einen pro Monat)
assert len(first_records) == 12, (
f"Erwartet 12 Records für {first_person}, aber {len(first_records)}"
)
# 3. Reports NUR für die erste Person generieren
single_person_group = {first_person: first_records}
generated = generate_activity_reports(
employee_records=single_person_group,
template_path=TEMPLATE_FILE,
mapping=app_config.output_mapping,
output_dir=output_dir,
filename_pattern=app_config.filename_pattern,
)
# 4. Ergebnisse prüfen
assert len(generated) == 1, (
f"Erwartet 1 generierte Datei, aber {len(generated)}"
)
output_file = generated[0]
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}"
assert output_file.suffix == ".pdf"
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.pdf"
assert output_file.name == erwarteter_name, (
f"Erwartet '{erwarteter_name}', aber '{output_file.name}'"
)
# 5. PDF öffnen und Inhalt validieren
doc = fitz.open(output_file)
assert doc.page_count == 24, (
f"Erwartet 24 Seiten (12 Monate × 2 Seiten), aber {doc.page_count}"
)
# 6. Prüfe, dass KEINE _Field-Platzhalter mehr im Output sind
full_text = ""
for page in doc:
full_text += page.get_text()
assert "_Field" not in full_text, (
"Es sind noch _Field-Platzhalter im Output-PDF vorhanden!"
)
# 7. Prüfe Seite 0 (Januar, Seite 1 – Stammdaten)
jan0_text = doc[0].get_text()
assert "100740439" in jan0_text, "Antragsnummer fehlt auf Januar-Seite 1"
assert "Draßdo, Cornelia" in jan0_text, "Name fehlt auf Januar-Seite 1"
assert "50" in jan0_text, "Stellenanteil fehlt auf Januar-Seite 1"
assert "Meisterabschluß Küche" in jan0_text, "Qualifikation fehlt auf Januar-Seite 1"
assert "01.2025" in jan0_text, "Monat fehlt auf Januar-Seite 1"
assert "Anleiterin / Ausbilderin" in jan0_text, "Funktion fehlt auf Januar-Seite 1"
assert "Tätigkeiten im Praxisbereich Garten" in jan0_text, "Tätigkeit fehlt auf Januar-Seite 1"
# 8. Prüfe Seite 1 (Januar, Seite 2 – Unterschriftsblock mit Ort/Datum)
jan1_text = doc[1].get_text()
assert "100740439" not in jan1_text, "Antragsnummer sollte nicht auf Seite 2 sein"
assert "Dresden" in jan1_text, "Ort 'Dresden' fehlt auf Januar-Seite 2"
assert "31.01.2025" in jan1_text, "Datum '31.01.2025' fehlt auf Januar-Seite 2"
# Tätigkeit wiederholt auf Seite 2
assert "Tätigkeiten im Praxisbereich Garten" in jan1_text, "Tätigkeit fehlt auf Januar-Seite 2"
# 9. Prüfe Juli (Seite 12+13) – Stellenanteil wechselt auf 40%
jul0_text = doc[12].get_text()
assert "40" in jul0_text, "Stellenanteil 40% fehlt auf Juli-Seite 1"
assert "07.2025" in jul0_text, "Monat 07.2025 fehlt auf Juli-Seite 1"
jul1_text = doc[13].get_text()
assert "31.07.2025" in jul1_text, "Datum 31.07.2025 fehlt auf Juli-Seite 2"
doc.close()