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).
|
||||
|
||||
**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
|
||||
|
||||
@@ -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`
|
||||
2. **`config/settings.ini` befüllen** — Pfade, Standardwerte
|
||||
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern)
|
||||
3. **`config/input-fields.json` Schema definieren** — Spaltennamen und Typen der Input-Tabelle
|
||||
- Muss durch Analyse der Datei `input/2025 JM.xlsx` ermittelt werden (Spalten A–?)
|
||||
- JSON-Array mit `name`, `column`, `type`, `required`
|
||||
4. **`config/output-fields.json` Mapping definieren** — Welche Input-Felder in welche Template-Zellen
|
||||
- JSON-Array mit `source_field`, `target_cell`, `format` (optional)
|
||||
- Muss durch Analyse des Templates `template/Tätigkeitsnachweise Stellenförderung.xlsx` ermittelt werden
|
||||
3. **`config/input-fields.json` Schema definieren** — Spalten der Input-Tabelle mit ihren exakten Überschriften
|
||||
- Wird durch Analyse der Datei `input/2025 JM.xlsx` ermittelt: Spaltenposition (A, B, C…) und die exakte Spaltenüberschrift aus Zeile 1 der jeweiligen Sheets
|
||||
- JSON-Array mit `column` (Spaltenbuchstabe), `field_name` (exakte Überschrift aus der Excel-Tabelle), `type` (string|number|date), `required` (true|false)
|
||||
4. **`config/output-fields.json` Mapping definieren** — Welche Input-Felder (exakte Spaltenüberschrift) in welche Template-Zellen geschrieben werden
|
||||
- JSON-Array mit `source_field` (exakte Spaltenüberschrift aus der Input-Tabelle, wie in input-fields.json definiert), `target_cell` (Zelladresse im Template, z.B. "A17"), `format` (optional, z.B. "dd.mm.yyyy")
|
||||
- Muss durch Analyse des Templates `template/Tätigkeitsnachweise Stellenförderung.xlsx` ermittelt werden (welche Zellen sind zu befüllen?)
|
||||
|
||||
### 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
|
||||
- Referenz: `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/models.py`
|
||||
6. **`src/config.py`** — Konfigurations-Loader
|
||||
- `load_settings()`: liest `config/settings.ini` mit `configparser`
|
||||
- `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
|
||||
- Für jedes Sheet: liest Zeilen anhand des Schemas, erzeugt `EmployeeRecord`-Liste
|
||||
- Rückgabe: `dict[str, list[EmployeeRecord]]` (key = Mitarbeiter-Name)
|
||||
- Referenz: `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/parser.py`
|
||||
|
||||
### Phase 3: Writer & Validierung
|
||||
|
||||
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
|
||||
- Für jeden Mitarbeiter: lädt Template, erstellt für jeden Monat ein Sheet (kopiert Template-Sheet)
|
||||
- Befüllt Zielzellen gemäß output-fields.json Mapping
|
||||
- Benennt Sheets nach Monat (z.B. "01_Januar", "02_Februar", …)
|
||||
- Speichert als `{nachname}_{vorname}_Tätigkeitsnachweis.xlsx` in `output/`
|
||||
- Referenz: `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/writer.py`
|
||||
- Speichert als `{nachname}_{vorname}_{YYYY}_Tätigkeitsnachweis.xlsx` in `output/`
|
||||
9. **`src/validator.py`** — Eingabevalidierung
|
||||
- `validate_input_file(filepath, schema)`: prüft Datei-Existenz, Sheet-Anzahl, Spalten laut Schema
|
||||
- `validate_template(template_path, mapping)`: prüft ob alle target_cells im Template existieren
|
||||
- Referenz: `1_Erstes-Tool-Komplizert/src/taetigkeitsnachweis/validator.py`
|
||||
|
||||
### 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:
|
||||
- Titel + Beschreibung
|
||||
- `st.selectbox` für Input-Datei (aus `input/`-Folder)
|
||||
- **Sofort bei Auswahl**: Validierung + Parsing der Datei
|
||||
- Anzeige: Anzahl gefundener Mitarbeiter, Auflistung aller enthaltenen Antragsnummern
|
||||
- Bei Fehlern: `st.error` mit Details
|
||||
- `st.selectbox` für Template (aus `template/`-Folder)
|
||||
- **Sofort bei Auswahl**: Validierung des Templates gegen `output-fields.json`
|
||||
- Anzeige: Anzahl gefundener Zielfelder (target_cells), fehlende/fehlerhafte Zellen
|
||||
- Bei Fehlern: `st.warning` mit Details
|
||||
- **Mitarbeiter-Liste** (erscheint nach erfolgreicher Input-Validierung)
|
||||
- `st.multiselect` mit allen gefundenen Mitarbeiternamen, standardmäßig alle vorselektiert
|
||||
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
|
||||
- Zeigt: „X von Y Mitarbeitern ausgewählt"
|
||||
- `st.button("Tätigkeitsnachweise generieren")`
|
||||
- Bei Button-Click:
|
||||
1. Lade Konfiguration
|
||||
2. Validiere Input-Datei + Template (zeige Fehler/Warnungen via `st.warning`/`st.error`)
|
||||
3. Parse Input → Employee Records
|
||||
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`
|
||||
1. Filtere Employee Records auf selektierte Mitarbeiter
|
||||
2. Generiere Activity Reports nur für selektierte Mitarbeiter und speichere in `output/`
|
||||
3. Zeige Erfolgsmeldung mit Liste der erstellten Dateien via `st.success`
|
||||
- Status-Anzeige mit `st.spinner` und `st.progress`
|
||||
|
||||
### 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 |
|
||||
| `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
|
||||
|
||||
```
|
||||
@@ -161,8 +156,8 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
||||
|
||||
## Verifikation
|
||||
|
||||
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften ausgeben → `input-fields.json` ableiten
|
||||
2. **Template-Analyse**: `Tätigkeitsnachweise Stellenförderung.xlsx` öffnen, belegte Zellen inspizieren → `output-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/freie Zellen inspizieren, die befüllt werden sollen → `output-fields.json` ableiten (Mapping: Input-Spaltenüberschrift → Template-Zelladresse)
|
||||
3. **Unit-Test**: App mit vorhandener Input-Datei und Template ausführen, prüfen ob Output-Dateien korrekt befüllt sind
|
||||
4. **Edge Cases**: Leere Input-Zeilen, fehlende Monate, Mitarbeiter ohne Vorname, Sonderzeichen in Namen
|
||||
```
|
||||
+21
-21
@@ -1,27 +1,27 @@
|
||||
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App)
|
||||
|
||||
## 🔍 Analyse (aktuell)
|
||||
- [ ] **A1**: Input-Datei `input/2025 JM.xlsx` analysieren → Sheet-Namen, Spaltenstruktur, Beispiel-Daten
|
||||
- [ ] **A2**: Template `template/Tätigkeitsnachweise Stellenförderung.xlsx` analysieren → Zielzellen identifizieren
|
||||
## 🔍 Analyse ✅
|
||||
- [x] **A1**: Input-Datei `input/2025 JM.xlsx` analysieren → Sheet-Namen, exakte Spaltenüberschriften (Zeile 1), Spaltenpositionen (A, B, C…), Beispiel-Daten
|
||||
- [x] **A2**: Template `template/Tätigkeitsnachweise Stellenförderung.xlsx` analysieren → zu befüllende Zielzellen identifizieren (Zelladresse + erwarteter Inhalt)
|
||||
|
||||
## ⚙️ Konfiguration
|
||||
- [ ] **K1**: `config/input-fields.json` mit Spaltenschema befüllen (basierend auf A1)
|
||||
- [ ] **K2**: `config/output-fields.json` mit Zell-Mappings befüllen (basierend auf A2)
|
||||
- [ ] **K3**: `config/settings.ini` mit Pfaden und Einstellungen befüllen
|
||||
## ⚙️ Konfiguration ✅
|
||||
- [x] **K1**: `config/input-fields.json` mit Spaltenschema befüllen — `column` (A, B…), `field_name` (exakte Überschrift aus Excel), `type`, `required`
|
||||
- [x] **K2**: `config/output-fields.json` mit Zell-Mappings befüllen — `source_field` (exakte Spaltenüberschrift aus input-fields.json), `target_cell`, `format`
|
||||
- [x] **K3**: `config/settings.ini` mit Pfaden und Einstellungen befüllen
|
||||
|
||||
## 🧱 Core-Module
|
||||
- [ ] **C1**: `requirements.txt` befüllen (streamlit, openpyxl)
|
||||
- [ ] **C2**: `src/__init__.py` + `src/models.py` — Datenklassen
|
||||
- [ ] **C3**: `src/config.py` — Konfigurations-Loader
|
||||
- [ ] **C4**: `src/parser.py` — Input-Datei parsen
|
||||
- [ ] **C5**: `src/validator.py` — Eingabevalidierung
|
||||
- [ ] **C6**: `src/writer.py` — Output generieren
|
||||
## 🧱 Core-Module ✅
|
||||
- [x] **C1**: `requirements.txt` befüllen (streamlit, openpyxl)
|
||||
- [x] **C2**: `src/__init__.py` + `src/models.py` — Datenklassen
|
||||
- [x] **C3**: `src/config.py` — Konfigurations-Loader
|
||||
- [x] **C4**: `src/parser.py` — Input-Datei parsen
|
||||
- [x] **C5**: `src/validator.py` — Eingabevalidierung
|
||||
- [x] **C6**: `src/writer.py` — Output generieren
|
||||
|
||||
## 🖥️ UI & Start
|
||||
- [ ] **U1**: `app.py` — Streamlit-Oberfläche
|
||||
- [ ] **U2**: `start.bat` — Windows Batch-Startdatei
|
||||
- [ ] **U3**: `README.md` — vollständige Dokumentation
|
||||
## 🖥️ UI & Start ✅
|
||||
- [x] **U1**: `app.py` — Streamlit-Oberfläche (Input-Selektion: validieren & Mitarbeiteranzahl + Antragsnummern anzeigen; Template-Selektion: validieren & Zielfelder anzeigen; Mitarbeiter-Multiselect zum Ein-/Ausschließen)
|
||||
- [x] **U2**: `start.bat` — Windows Batch-Startdatei
|
||||
- [x] **U3**: `README.md` — vollständige Dokumentation
|
||||
|
||||
## ✅ Test
|
||||
- [ ] **T1**: App mit vorhandener Input-Datei und Template testen
|
||||
- [ ] **T2**: Edge Cases prüfen (leere Zeilen, Sonderzeichen, fehlende Monate)
|
||||
## ✅ Test ✅
|
||||
- [x] **T1**: App mit vorhandener Input-Datei und Template testen — 19 Tätigkeitsnachweise erfolgreich generiert
|
||||
- [x] **T2**: Edge Cases prüfen (leere Zeilen, Sonderzeichen, fehlende Monate) — Umlaute, Sonderzeichen in Namen, "Alle Rollen"-Sheet korrekt behandelt
|
||||
+6
-1
@@ -1,3 +1,8 @@
|
||||
input/
|
||||
output/
|
||||
.venv/
|
||||
__pycache__/
|
||||
.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