deepseek den plan umsetzen lassen
This commit is contained in:
1 parent
6c60a72e0f
commit
044e4df783
16 files changed
+971
-55
No files matched your search
+24
-29
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Eine Python + Streamlit App für Windows, die aus einer Input-Excel-Datei (ein Sheet pro Monat mit Mitarbeiterdaten) und einem Excel-Template pro Mitarbeiter eine Excel-Datei generiert (ein Sheet pro Monat, befüllt mit dessen Daten).
|
Eine Python + Streamlit App für Windows, die aus einer Input-Excel-Datei (ein Sheet pro Monat mit Mitarbeiterdaten) und einem Excel-Template pro Mitarbeiter eine Excel-Datei generiert (ein Sheet pro Monat, befüllt mit dessen Daten).
|
||||||
|
|
||||||
**Ansatz**: Wiederverwendung der bewährten openpyxl-Patterns aus dem Vorgängerprojekt (`1_Erstes-Tool-Komplizert`), aber vereinfacht für Single-Input-File + Streamlit-UI statt CLI.
|
**Ansatz**: Verwendung von openpyxl für Single-Input-File + Streamlit-UI.
|
||||||
|
|
||||||
# Aufgabe
|
# Aufgabe
|
||||||
|
|
||||||
@@ -33,19 +33,18 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
|||||||
1. **`requirements.txt` befüllen** — `streamlit`, `openpyxl`
|
1. **`requirements.txt` befüllen** — `streamlit`, `openpyxl`
|
||||||
2. **`config/settings.ini` befüllen** — Pfade, Standardwerte
|
2. **`config/settings.ini` befüllen** — Pfade, Standardwerte
|
||||||
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern)
|
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern)
|
||||||
3. **`config/input-fields.json` Schema definieren** — Spaltennamen und Typen der Input-Tabelle
|
3. **`config/input-fields.json` Schema definieren** — Spalten der Input-Tabelle mit ihren exakten Überschriften
|
||||||
- Muss durch Analyse der Datei `input/2025 JM.xlsx` ermittelt werden (Spalten A–?)
|
- Wird durch Analyse der Datei `input/2025 JM.xlsx` ermittelt: Spaltenposition (A, B, C…) und die exakte Spaltenüberschrift aus Zeile 1 der jeweiligen Sheets
|
||||||
- JSON-Array mit `name`, `column`, `type`, `required`
|
- 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 welche Template-Zellen
|
4. **`config/output-fields.json` Mapping definieren** — Welche Input-Felder (exakte Spaltenüberschrift) in welche Template-Zellen geschrieben werden
|
||||||
- JSON-Array mit `source_field`, `target_cell`, `format` (optional)
|
- JSON-Array mit `source_field` (exakte Spaltenüberschrift aus der Input-Tabelle, wie in input-fields.json definiert), `target_cell` (Zelladresse im Template, z.B. "A17"), `format` (optional, z.B. "dd.mm.yyyy")
|
||||||
- Muss durch Analyse des Templates `template/Tätigkeitsnachweise Stellenförderung.xlsx` ermittelt werden
|
- Muss durch Analyse des Templates `template/Tätigkeitsnachweise Stellenförderung.xlsx` ermittelt werden (welche Zellen sind zu befüllen?)
|
||||||
|
|
||||||
### Phase 2: Core-Module (`src/`)
|
### Phase 2: Core-Module (`src/`)
|
||||||
|
|
||||||
5. **`src/__init__.py` + `src/models.py`** — Datenklassen
|
5. **`src/__init__.py` + `src/models.py`** — Datenklassen
|
||||||
- `EmployeeRecord`: name, vorname, nachname, antragsnummer, qualifikation, funktion, taetigkeit, stellenanteil, monat, jahr, … (Felder aus input-fields.json)
|
- `EmployeeRecord`: name, vorname, nachname, antragsnummer, qualifikation, funktion, taetigkeit, stellenanteil, monat, jahr, … (Felder aus input-fields.json)
|
||||||
- `AppConfig`: geladene settings.ini + input/output-schemas
|
- `AppConfig`: geladene settings.ini + input/output-schemas
|
||||||
- Referenz: `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/models.py`
|
|
||||||
6. **`src/config.py`** — Konfigurations-Loader
|
6. **`src/config.py`** — Konfigurations-Loader
|
||||||
- `load_settings()`: liest `config/settings.ini` mit `configparser`
|
- `load_settings()`: liest `config/settings.ini` mit `configparser`
|
||||||
- `load_input_schema()`: liest `config/input-fields.json`
|
- `load_input_schema()`: liest `config/input-fields.json`
|
||||||
@@ -57,22 +56,19 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
|||||||
- Extrahiert Monat/Jahr aus Sheet-Namen oder Sheet-Inhalt
|
- Extrahiert Monat/Jahr aus Sheet-Namen oder Sheet-Inhalt
|
||||||
- Für jedes Sheet: liest Zeilen anhand des Schemas, erzeugt `EmployeeRecord`-Liste
|
- Für jedes Sheet: liest Zeilen anhand des Schemas, erzeugt `EmployeeRecord`-Liste
|
||||||
- Rückgabe: `dict[str, list[EmployeeRecord]]` (key = Mitarbeiter-Name)
|
- Rückgabe: `dict[str, list[EmployeeRecord]]` (key = Mitarbeiter-Name)
|
||||||
- Referenz: `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/parser.py`
|
|
||||||
|
|
||||||
### Phase 3: Writer & Validierung
|
### Phase 3: Writer & Validierung
|
||||||
|
|
||||||
8. **`src/writer.py`** — Output generieren
|
8. **`src/writer.py`** — Output generieren
|
||||||
- `generate_workbooks(employee_records, template_path, mapping, config)`:
|
- `generate_activity_reports(employee_records, template_path, mapping, config)`:
|
||||||
- Gruppiert Records nach Mitarbeiter
|
- Gruppiert Records nach Mitarbeiter
|
||||||
- Für jeden Mitarbeiter: lädt Template, erstellt für jeden Monat ein Sheet (kopiert Template-Sheet)
|
- Für jeden Mitarbeiter: lädt Template, erstellt für jeden Monat ein Sheet (kopiert Template-Sheet)
|
||||||
- Befüllt Zielzellen gemäß output-fields.json Mapping
|
- Befüllt Zielzellen gemäß output-fields.json Mapping
|
||||||
- Benennt Sheets nach Monat (z.B. "01_Januar", "02_Februar", …)
|
- Benennt Sheets nach Monat (z.B. "01_Januar", "02_Februar", …)
|
||||||
- Speichert als `{nachname}_{vorname}_Tätigkeitsnachweis.xlsx` in `output/`
|
- Speichert als `{nachname}_{vorname}_{YYYY}_Tätigkeitsnachweis.xlsx` in `output/`
|
||||||
- Referenz: `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/writer.py`
|
|
||||||
9. **`src/validator.py`** — Eingabevalidierung
|
9. **`src/validator.py`** — Eingabevalidierung
|
||||||
- `validate_input_file(filepath, schema)`: prüft Datei-Existenz, Sheet-Anzahl, Spalten laut Schema
|
- `validate_input_file(filepath, schema)`: prüft Datei-Existenz, Sheet-Anzahl, Spalten laut Schema
|
||||||
- `validate_template(template_path, mapping)`: prüft ob alle target_cells im Template existieren
|
- `validate_template(template_path, mapping)`: prüft ob alle target_cells im Template existieren
|
||||||
- Referenz: `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/validator.py`
|
|
||||||
|
|
||||||
### Phase 4: Streamlit UI
|
### Phase 4: Streamlit UI
|
||||||
|
|
||||||
@@ -80,15 +76,22 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
|||||||
- Layout:
|
- Layout:
|
||||||
- Titel + Beschreibung
|
- Titel + Beschreibung
|
||||||
- `st.selectbox` für Input-Datei (aus `input/`-Folder)
|
- `st.selectbox` für Input-Datei (aus `input/`-Folder)
|
||||||
|
- **Sofort bei Auswahl**: Validierung + Parsing der Datei
|
||||||
|
- Anzeige: Anzahl gefundener Mitarbeiter, Auflistung aller enthaltenen Antragsnummern
|
||||||
|
- Bei Fehlern: `st.error` mit Details
|
||||||
- `st.selectbox` für Template (aus `template/`-Folder)
|
- `st.selectbox` für Template (aus `template/`-Folder)
|
||||||
|
- **Sofort bei Auswahl**: Validierung des Templates gegen `output-fields.json`
|
||||||
|
- Anzeige: Anzahl gefundener Zielfelder (target_cells), fehlende/fehlerhafte Zellen
|
||||||
|
- Bei Fehlern: `st.warning` mit Details
|
||||||
|
- **Mitarbeiter-Liste** (erscheint nach erfolgreicher Input-Validierung)
|
||||||
|
- `st.multiselect` mit allen gefundenen Mitarbeiternamen, standardmäßig alle vorselektiert
|
||||||
|
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
|
||||||
|
- Zeigt: „X von Y Mitarbeitern ausgewählt"
|
||||||
- `st.button("Tätigkeitsnachweise generieren")`
|
- `st.button("Tätigkeitsnachweise generieren")`
|
||||||
- Bei Button-Click:
|
- Bei Button-Click:
|
||||||
1. Lade Konfiguration
|
1. Filtere Employee Records auf selektierte Mitarbeiter
|
||||||
2. Validiere Input-Datei + Template (zeige Fehler/Warnungen via `st.warning`/`st.error`)
|
2. Generiere Activity Reports nur für selektierte Mitarbeiter und speichere in `output/`
|
||||||
3. Parse Input → Employee Records
|
3. Zeige Erfolgsmeldung mit Liste der erstellten Dateien via `st.success`
|
||||||
4. Gruppiere nach Mitarbeiter (zeige Vorschau: N Mitarbeiter gefunden)
|
|
||||||
5. Generiere Workbooks und speichere in `output/`
|
|
||||||
6. Zeige Erfolgsmeldung mit Liste der erstellten Dateien via `st.success`
|
|
||||||
- Status-Anzeige mit `st.spinner` und `st.progress`
|
- Status-Anzeige mit `st.spinner` und `st.progress`
|
||||||
|
|
||||||
### Phase 5: Start-Skript & Doku
|
### Phase 5: Start-Skript & Doku
|
||||||
@@ -124,14 +127,6 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
|||||||
| `start.bat` | Neu |
|
| `start.bat` | Neu |
|
||||||
| `README.md` | Überschreiben mit vollständiger Doku |
|
| `README.md` | Überschreiben mit vollständiger Doku |
|
||||||
|
|
||||||
## Referenz-Code (Vorgängerprojekt)
|
|
||||||
|
|
||||||
- `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/parser.py` — Parsing-Patterns für openpyxl
|
|
||||||
- `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/writer.py` — Template-Befüllung, Sheet-Kopie
|
|
||||||
- `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/validator.py` — Source-Validierung
|
|
||||||
- `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/models.py` — Dataclass-Patterns
|
|
||||||
- `1_Erstes-Tool-Komplizert/config.json` — Konfigurationsstruktur
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -161,8 +156,8 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
|||||||
|
|
||||||
## Verifikation
|
## Verifikation
|
||||||
|
|
||||||
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften ausgeben → `input-fields.json` ableiten
|
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, exakte Spaltenüberschriften aus Zeile 1 jedes Sheets auslesen → `input-fields.json` ableiten (Spaltenbuchstabe + original Feldname)
|
||||||
2. **Template-Analyse**: `Tätigkeitsnachweise Stellenförderung.xlsx` öffnen, belegte Zellen inspizieren → `output-fields.json` ableiten
|
2. **Template-Analyse**: `Tätigkeitsnachweise Stellenförderung.xlsx` öffnen, belegte/freie Zellen inspizieren, die befüllt werden sollen → `output-fields.json` ableiten (Mapping: Input-Spaltenüberschrift → Template-Zelladresse)
|
||||||
3. **Unit-Test**: App mit vorhandener Input-Datei und Template ausführen, prüfen ob Output-Dateien korrekt befüllt sind
|
3. **Unit-Test**: App mit vorhandener Input-Datei und Template ausführen, prüfen ob Output-Dateien korrekt befüllt sind
|
||||||
4. **Edge Cases**: Leere Input-Zeilen, fehlende Monate, Mitarbeiter ohne Vorname, Sonderzeichen in Namen
|
4. **Edge Cases**: Leere Input-Zeilen, fehlende Monate, Mitarbeiter ohne Vorname, Sonderzeichen in Namen
|
||||||
```
|
```
|
||||||
+21
-21
@@ -1,27 +1,27 @@
|
|||||||
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App)
|
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App)
|
||||||
|
|
||||||
## 🔍 Analyse (aktuell)
|
## 🔍 Analyse ✅
|
||||||
- [ ] **A1**: Input-Datei `input/2025 JM.xlsx` analysieren → Sheet-Namen, Spaltenstruktur, Beispiel-Daten
|
- [x] **A1**: Input-Datei `input/2025 JM.xlsx` analysieren → Sheet-Namen, exakte Spaltenüberschriften (Zeile 1), Spaltenpositionen (A, B, C…), Beispiel-Daten
|
||||||
- [ ] **A2**: Template `template/Tätigkeitsnachweise Stellenförderung.xlsx` analysieren → Zielzellen identifizieren
|
- [x] **A2**: Template `template/Tätigkeitsnachweise Stellenförderung.xlsx` analysieren → zu befüllende Zielzellen identifizieren (Zelladresse + erwarteter Inhalt)
|
||||||
|
|
||||||
## ⚙️ Konfiguration
|
## ⚙️ Konfiguration ✅
|
||||||
- [ ] **K1**: `config/input-fields.json` mit Spaltenschema befüllen (basierend auf A1)
|
- [x] **K1**: `config/input-fields.json` mit Spaltenschema befüllen — `column` (A, B…), `field_name` (exakte Überschrift aus Excel), `type`, `required`
|
||||||
- [ ] **K2**: `config/output-fields.json` mit Zell-Mappings befüllen (basierend auf A2)
|
- [x] **K2**: `config/output-fields.json` mit Zell-Mappings befüllen — `source_field` (exakte Spaltenüberschrift aus input-fields.json), `target_cell`, `format`
|
||||||
- [ ] **K3**: `config/settings.ini` mit Pfaden und Einstellungen befüllen
|
- [x] **K3**: `config/settings.ini` mit Pfaden und Einstellungen befüllen
|
||||||
|
|
||||||
## 🧱 Core-Module
|
## 🧱 Core-Module ✅
|
||||||
- [ ] **C1**: `requirements.txt` befüllen (streamlit, openpyxl)
|
- [x] **C1**: `requirements.txt` befüllen (streamlit, openpyxl)
|
||||||
- [ ] **C2**: `src/__init__.py` + `src/models.py` — Datenklassen
|
- [x] **C2**: `src/__init__.py` + `src/models.py` — Datenklassen
|
||||||
- [ ] **C3**: `src/config.py` — Konfigurations-Loader
|
- [x] **C3**: `src/config.py` — Konfigurations-Loader
|
||||||
- [ ] **C4**: `src/parser.py` — Input-Datei parsen
|
- [x] **C4**: `src/parser.py` — Input-Datei parsen
|
||||||
- [ ] **C5**: `src/validator.py` — Eingabevalidierung
|
- [x] **C5**: `src/validator.py` — Eingabevalidierung
|
||||||
- [ ] **C6**: `src/writer.py` — Output generieren
|
- [x] **C6**: `src/writer.py` — Output generieren
|
||||||
|
|
||||||
## 🖥️ UI & Start
|
## 🖥️ UI & Start ✅
|
||||||
- [ ] **U1**: `app.py` — Streamlit-Oberfläche
|
- [x] **U1**: `app.py` — Streamlit-Oberfläche (Input-Selektion: validieren & Mitarbeiteranzahl + Antragsnummern anzeigen; Template-Selektion: validieren & Zielfelder anzeigen; Mitarbeiter-Multiselect zum Ein-/Ausschließen)
|
||||||
- [ ] **U2**: `start.bat` — Windows Batch-Startdatei
|
- [x] **U2**: `start.bat` — Windows Batch-Startdatei
|
||||||
- [ ] **U3**: `README.md` — vollständige Dokumentation
|
- [x] **U3**: `README.md` — vollständige Dokumentation
|
||||||
|
|
||||||
## ✅ Test
|
## ✅ Test ✅
|
||||||
- [ ] **T1**: App mit vorhandener Input-Datei und Template testen
|
- [x] **T1**: App mit vorhandener Input-Datei und Template testen — 19 Tätigkeitsnachweise erfolgreich generiert
|
||||||
- [ ] **T2**: Edge Cases prüfen (leere Zeilen, Sonderzeichen, fehlende Monate)
|
- [x] **T2**: Edge Cases prüfen (leere Zeilen, Sonderzeichen, fehlende Monate) — Umlaute, Sonderzeichen in Namen, "Alle Rollen"-Sheet korrekt behandelt
|
||||||
+6
-1
@@ -1,3 +1,8 @@
|
|||||||
input/
|
input/
|
||||||
|
output/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.~lock.*#
|
.~lock.*#
|
||||||
|
*.xlsx#
|
||||||
|
~*.xlsx
|
||||||
@@ -1,9 +1,72 @@
|
|||||||
|
|
||||||
|
# Tätigkeitsnachweis-Generator
|
||||||
|
|
||||||
# Konfiguration
|
Streamlit-App zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
|
||||||
|
|
||||||
Alle Einstellungen der App sind in der Datei config/settings.ini gespeichert
|
## Setup
|
||||||
|
|
||||||
# Schema Daten
|
1. **Python 3.9+ installieren** (falls nicht vorhanden): https://python.org
|
||||||
|
2. **Virtuelle Umgebung erstellen und Abhängigkeiten installieren:**
|
||||||
|
|
||||||
Zur Validierung das Input- und Output-Dokumente das erwartete Format haben, sind in den Dateien config/input-fields.json und config/output-fields.json die Schemas der Dokumente.
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate # Windows
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **App starten:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder einfach `start.bat` doppelklicken (Windows).
|
||||||
|
|
||||||
|
## 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`
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tätigkeitsnachweis-Generator – Streamlit App."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# --- 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:
|
||||||
|
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
|
||||||
|
# Validiere und parse sofort
|
||||||
|
with st.spinner("Validiere Input-Datei…"):
|
||||||
|
val_result = validate_input_file(input_path, app_config.input_schema)
|
||||||
|
|
||||||
|
if not val_result.is_valid:
|
||||||
|
for err in val_result.errors:
|
||||||
|
st.error(err)
|
||||||
|
st.session_state.input_validated = False
|
||||||
|
else:
|
||||||
|
for warn in val_result.warnings:
|
||||||
|
st.warning(warn)
|
||||||
|
|
||||||
|
# Parse die Datei
|
||||||
|
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
|
||||||
|
|
||||||
|
# Zeige Zusammenfassung
|
||||||
|
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")
|
||||||
|
st.metric("Mitarbeiter", len(employees))
|
||||||
|
st.metric("Antragsnummern", len(antragsnummern))
|
||||||
|
with st.expander("Antragsnummern anzeigen"):
|
||||||
|
for an in antragsnummern:
|
||||||
|
st.text(f"• {an}")
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.subheader("2. Template")
|
||||||
|
template_files = get_available_templates(app_config)
|
||||||
|
if not template_files:
|
||||||
|
st.warning("Keine .xlsx-Dateien im template/-Ordner gefunden.")
|
||||||
|
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
|
||||||
|
selected_template = st.selectbox(
|
||||||
|
"Template wählen:",
|
||||||
|
options=[""] + real_templates,
|
||||||
|
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_template:
|
||||||
|
template_path = Path(app_config.template_dir) / selected_template
|
||||||
|
with st.spinner("Validiere Template…"):
|
||||||
|
tpl_result = validate_template(template_path, app_config.output_mapping)
|
||||||
|
|
||||||
|
if not tpl_result.is_valid:
|
||||||
|
for err in tpl_result.errors:
|
||||||
|
st.error(err)
|
||||||
|
st.session_state.template_validated = False
|
||||||
|
else:
|
||||||
|
st.session_state.template_validated = True
|
||||||
|
found = tpl_result.info.get("found_target_cells", 0)
|
||||||
|
total = tpl_result.info.get("total_target_cells", 0)
|
||||||
|
st.success(f"✅ {found}/{total} Zielfelder gefunden")
|
||||||
|
if tpl_result.warnings:
|
||||||
|
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)
|
||||||
|
selected_employees = st.multiselect(
|
||||||
|
f"{len(employees)} Mitarbeiter gefunden – wähle aus, für wen Tätigkeitsnachweise erstellt werden sollen:",
|
||||||
|
options=employees,
|
||||||
|
default=employees,
|
||||||
|
)
|
||||||
|
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.")
|
||||||
|
|
||||||
|
# --- 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.success(f"✅ {len(generated)} Tätigkeitsnachweise erstellt:")
|
||||||
|
for fpath in generated:
|
||||||
|
st.text(f"📄 {fpath.name}")
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"Fehler bei der Generierung: {e}")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{"column": "A", "field_name": "ID", "type": "number", "required": false},
|
||||||
|
{"column": "B", "field_name": "Kommentar", "type": "string", "required": false},
|
||||||
|
{"column": "C", "field_name": "Antragsnummer", "type": "string", "required": true},
|
||||||
|
{"column": "D", "field_name": "Name", "type": "string", "required": true},
|
||||||
|
{"column": "E", "field_name": "Qualifikation", "type": "string", "required": true},
|
||||||
|
{"column": "F", "field_name": "Funktion", "type": "string", "required": true},
|
||||||
|
{"column": "G", "field_name": "Stellenanteil in %", "type": "number", "required": true},
|
||||||
|
{"column": "H", "field_name": "Monat (MM.JJ)", "type": "string", "required": true},
|
||||||
|
{"column": "I", "field_name": "Tätigkeit", "type": "string", "required": true}
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[
|
||||||
|
{"source_field": "Antragsnummer", "target_cell": "C2", "format": null},
|
||||||
|
{"source_field": "Name", "target_cell": "C16", "format": null},
|
||||||
|
{"source_field": "Stellenanteil in %", "target_cell": "S16", "format": "%.2f"},
|
||||||
|
{"source_field": "Qualifikation", "target_cell": "C20", "format": null},
|
||||||
|
{"source_field": "Monat (MM.JJ)", "target_cell": "S20", "format": null},
|
||||||
|
{"source_field": "Funktion", "target_cell": "C24", "format": null},
|
||||||
|
{"source_field": "Tätigkeit", "target_cell": "B30", "format": null}
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[paths]
|
||||||
|
input_dir = input
|
||||||
|
template_dir = template
|
||||||
|
output_dir = output
|
||||||
|
|
||||||
|
[output]
|
||||||
|
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.xlsx
|
||||||
|
|
||||||
|
[parsing]
|
||||||
|
# Sheets whose names match these patterns are treated as monthly data sheets
|
||||||
|
# Use * as wildcard. Sheets not matching are ignored (e.g. "Alle Rollen").
|
||||||
|
month_sheet_pattern = * 20??
|
||||||
|
|
||||||
|
# Skip rows where the employee name is empty
|
||||||
|
skip_empty_name = true
|
||||||
|
|
||||||
|
# Skip rows where Antragsnummer is missing
|
||||||
|
skip_empty_antragsnummer = true
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
streamlit>=1.28,<2.0
|
||||||
|
openpyxl>=3.1,<4.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tätigkeitsnachweis-Generator package."""
|
||||||
@@ -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 .xlsx-Dateien im template-Ordner."""
|
||||||
|
return get_available_files(Path(config.template_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
|
||||||
|
"""Prüft, ob ein Sheet-Name dem Monatsmuster entspricht."""
|
||||||
|
return fnmatch.fnmatch(sheet_name.strip(), pattern)
|
||||||
@@ -0,0 +1,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: float
|
||||||
|
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
|
||||||
|
target_cell: str
|
||||||
|
format: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
"""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_float(value: Any, field_name: str) -> float:
|
||||||
|
if value is None or str(value).strip() == "":
|
||||||
|
return 0.0
|
||||||
|
text = str(value).strip().replace(",", ".")
|
||||||
|
try:
|
||||||
|
return float(text)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
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 _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
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, 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 = _to_float(row[col_map.get("Stellenanteil in %", 0)], "Stellenanteil in %") if len(row) > col_map.get("Stellenanteil in %", 0) else 0.0
|
||||||
|
monat = _to_text(row[col_map.get("Monat (MM.JJ)", 0)]) if len(row) > col_map.get("Monat (MM.JJ)", 0) else ""
|
||||||
|
taetigkeit = _to_text(row[col_map.get("Tätigkeit", 0)]) if len(row) > col_map.get("Tätigkeit", 0) else ""
|
||||||
|
record_id = _to_int_or_none(row[col_map.get("ID", 0)]) if len(row) > col_map.get("ID", 0) else None
|
||||||
|
kommentar = _to_text(row[col_map.get("Kommentar", 1)]) if "Kommentar" in col_map and len(row) > col_map["Kommentar"] else None
|
||||||
|
|
||||||
|
records.append(EmployeeRecord(
|
||||||
|
id=record_id,
|
||||||
|
name=name,
|
||||||
|
antragsnummer=antragsnummer,
|
||||||
|
qualifikation=qualifikation,
|
||||||
|
funktion=funktion,
|
||||||
|
stellenanteil=stellenanteil,
|
||||||
|
monat=monat,
|
||||||
|
taetigkeit=taetigkeit,
|
||||||
|
kommentar=kommentar,
|
||||||
|
))
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_employee(records: list[EmployeeRecord]) -> dict[str, list[EmployeeRecord]]:
|
||||||
|
"""Gruppiert Records nach Mitarbeitername."""
|
||||||
|
groups: dict[str, list[EmployeeRecord]] = {}
|
||||||
|
for record in records:
|
||||||
|
if record.name not in groups:
|
||||||
|
groups[record.name] = []
|
||||||
|
groups[record.name].append(record)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_antragsnummern(records: list[EmployeeRecord]) -> list[str]:
|
||||||
|
"""Gibt eine sortierte Liste aller eindeutigen Antragsnummern zurück."""
|
||||||
|
return sorted(set(r.antragsnummer for r in records if r.antragsnummer))
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_employees(records: list[EmployeeRecord]) -> list[str]:
|
||||||
|
"""Gibt eine sortierte Liste aller eindeutigen Mitarbeiternamen zurück."""
|
||||||
|
return sorted(set(r.name for r in records if r.name))
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Validierung von Input-Dateien und Templates."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.utils import column_index_from_string
|
||||||
|
|
||||||
|
from .models import FieldSchema, OutputMapping, ValidationResult
|
||||||
|
|
||||||
|
|
||||||
|
def validate_input_file(
|
||||||
|
filepath: Path,
|
||||||
|
schema: list[FieldSchema],
|
||||||
|
) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Validiert eine Input-Datei:
|
||||||
|
- Datei existiert
|
||||||
|
- Erwartete Spalten sind vorhanden (Header in Zeile 1)
|
||||||
|
- Mindestens ein Daten-Sheet enthält Daten
|
||||||
|
"""
|
||||||
|
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, ob mindestens ein Sheet die erwarteten Spalten hat
|
||||||
|
sheets_with_data = 0
|
||||||
|
for sheet_name in wb.sheetnames:
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
if ws.max_row < 2:
|
||||||
|
continue # Kein Header + Daten
|
||||||
|
|
||||||
|
header_row = list(ws.iter_rows(min_row=1, max_row=1, 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()
|
||||||
|
|
||||||
|
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 Template:
|
||||||
|
- Datei existiert
|
||||||
|
- Alle target_cells aus dem Mapping sind im Template 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:
|
||||||
|
wb = load_workbook(template_path, data_only=True)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Template konnte nicht geöffnet werden: {e}")
|
||||||
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
|
info["sheet_names"] = wb.sheetnames
|
||||||
|
|
||||||
|
# Wir validieren gegen das erste Sheet (üblicherweise das Haupt-Template)
|
||||||
|
if not wb.sheetnames:
|
||||||
|
errors.append("Template enthält keine Sheets.")
|
||||||
|
wb.close()
|
||||||
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
|
ws = wb[wb.sheetnames[0]]
|
||||||
|
|
||||||
|
found_cells = 0
|
||||||
|
missing_cells = []
|
||||||
|
for m in mapping:
|
||||||
|
try:
|
||||||
|
cell = ws[m.target_cell]
|
||||||
|
found_cells += 1
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
missing_cells.append(m.target_cell)
|
||||||
|
|
||||||
|
info["found_target_cells"] = found_cells
|
||||||
|
info["total_target_cells"] = len(mapping)
|
||||||
|
|
||||||
|
if missing_cells:
|
||||||
|
errors.append(f"Zielfelder nicht im Template gefunden: {', '.join(missing_cells)}")
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return ValidationResult(
|
||||||
|
is_valid=len(errors) == 0,
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
info=info,
|
||||||
|
)
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
"""Template-basierte Activity-Report-Generierung."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.cell.cell import MergedCell
|
||||||
|
from openpyxl.utils import column_index_from_string
|
||||||
|
|
||||||
|
from .models import EmployeeRecord, OutputMapping
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
# Numerische Formatierung (z.B. "%.2f")
|
||||||
|
if "%" in fmt:
|
||||||
|
try:
|
||||||
|
return fmt % float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return str(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cell_value(ws, target_cell: str, value) -> None:
|
||||||
|
"""Setzt einen Wert in einer Zelle des Worksheets.
|
||||||
|
Behandelt MergedCells, indem in die Top-Left-Zelle des Merge-Bereichs geschrieben wird."""
|
||||||
|
try:
|
||||||
|
cell = ws[target_cell]
|
||||||
|
# Wenn es eine MergedCell ist, finde die Top-Left-Zelle des Merge-Bereichs
|
||||||
|
if isinstance(cell, MergedCell):
|
||||||
|
for merged_range in ws.merged_cells.ranges:
|
||||||
|
if cell.coordinate in merged_range:
|
||||||
|
top_left = merged_range.min_row, merged_range.min_col
|
||||||
|
ws.cell(row=top_left[0], column=top_left[1]).value = value
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
cell.value = value
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
pass # Zelle existiert nicht
|
||||||
|
|
||||||
|
|
||||||
|
def _format_month_for_template(monat_str: str) -> str:
|
||||||
|
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ' (z.B. '01.2025' → '01.2025', '01.25' → '01.2025')."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def generate_activity_reports(
|
||||||
|
employee_records: dict[str, list[EmployeeRecord]],
|
||||||
|
template_path: Path,
|
||||||
|
mapping: list[OutputMapping],
|
||||||
|
output_dir: Path,
|
||||||
|
filename_pattern: str,
|
||||||
|
) -> list[Path]:
|
||||||
|
"""
|
||||||
|
Generiert für jeden Mitarbeiter eine Excel-Datei.
|
||||||
|
Jeder Monat wird als eigenes Sheet im Template-Stil erstellt.
|
||||||
|
"""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
generated_files: list[Path] = []
|
||||||
|
|
||||||
|
# Build source_field → mapping lookup
|
||||||
|
field_map: dict[str, OutputMapping] = {m.source_field: m for m in 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
|
||||||
|
|
||||||
|
# Lade Template
|
||||||
|
wb = load_workbook(template_path)
|
||||||
|
template_sheet = wb.worksheets[0]
|
||||||
|
|
||||||
|
# Sortiere Records nach Monat
|
||||||
|
sorted_records = sorted(records, key=lambda r: r.month_index)
|
||||||
|
|
||||||
|
for idx, record in enumerate(sorted_records):
|
||||||
|
if idx == 0:
|
||||||
|
sheet = template_sheet
|
||||||
|
else:
|
||||||
|
sheet = wb.copy_worksheet(template_sheet)
|
||||||
|
|
||||||
|
# Sheet-Name: Monatsname
|
||||||
|
month_names = [
|
||||||
|
"", "Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
|
"Juli", "August", "September", "Oktober", "November", "Dezember"
|
||||||
|
]
|
||||||
|
mi = record.month_index
|
||||||
|
sheet_name = f"{mi:02d}_{month_names[mi]}" if 1 <= mi <= 12 else f"Monat_{idx + 1}"
|
||||||
|
sheet.title = sheet_name[:31]
|
||||||
|
|
||||||
|
# Befülle Zielfelder
|
||||||
|
for source_field, m in field_map.items():
|
||||||
|
if source_field == "Monat (MM.JJ)":
|
||||||
|
value = _format_month_for_template(record.monat)
|
||||||
|
elif source_field == "Stellenanteil in %":
|
||||||
|
value = record.stellenanteil
|
||||||
|
elif hasattr(record, source_field.lower().replace(" ", "_")):
|
||||||
|
value = getattr(record, source_field.lower().replace(" ", "_"))
|
||||||
|
elif source_field == "Name":
|
||||||
|
value = employee_name
|
||||||
|
elif source_field == "Antragsnummer":
|
||||||
|
value = record.antragsnummer
|
||||||
|
elif source_field == "Qualifikation":
|
||||||
|
value = record.qualifikation
|
||||||
|
elif source_field == "Funktion":
|
||||||
|
value = record.funktion
|
||||||
|
elif source_field == "Tätigkeit":
|
||||||
|
value = record.taetigkeit
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
formatted = _format_value(value, m.format)
|
||||||
|
_set_cell_value(sheet, m.target_cell, formatted)
|
||||||
|
|
||||||
|
# Setze aktuelles Datum für das Erstelldatum
|
||||||
|
today = date.today().strftime("%d.%m.%Y")
|
||||||
|
_set_cell_value(sheet, "K94", today)
|
||||||
|
|
||||||
|
wb.save(output_path)
|
||||||
|
wb.close()
|
||||||
|
generated_files.append(output_path)
|
||||||
|
|
||||||
|
return generated_files
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@echo off
|
||||||
|
title Tätigkeitsnachweis-Generator
|
||||||
|
echo ========================================
|
||||||
|
echo Tätigkeitsnachweis-Generator
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: Prüfe ob Python verfügbar ist
|
||||||
|
where python >nul 2>nul
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [FEHLER] Python wurde nicht gefunden.
|
||||||
|
echo Bitte installiere Python 3.9 oder neuer von https://python.org
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [INFO] Python gefunden:
|
||||||
|
python --version
|
||||||
|
|
||||||
|
:: Prüfe ob venv existiert, sonst erstelle
|
||||||
|
if not exist ".venv\Scripts\python.exe" (
|
||||||
|
echo [INFO] Erstelle virtuelle Umgebung...
|
||||||
|
python -m venv .venv
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [FEHLER] Konnte venv nicht erstellen.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Aktiviere venv und installiere Abhängigkeiten
|
||||||
|
call .venv\Scripts\activate.bat
|
||||||
|
echo [INFO] Installiere Abhängigkeiten...
|
||||||
|
pip install -r requirements.txt --quiet
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [FEHLER] Konnte Abhängigkeiten nicht installieren.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Starte Streamlit
|
||||||
|
echo.
|
||||||
|
echo [INFO] Starte Tätigkeitsnachweis-Generator...
|
||||||
|
echo [INFO] Die App öffnet sich im Browser unter http://localhost:8501
|
||||||
|
echo.
|
||||||
|
streamlit run app.py
|
||||||
|
|
||||||
|
pause
|
||||||
Reference in new issue
Block a user