diff --git a/.copilot/PLAN.md b/.copilot/PLAN.md index 000035c..10b5d03 100644 --- a/.copilot/PLAN.md +++ b/.copilot/PLAN.md @@ -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 ``` \ No newline at end of file diff --git a/.copilot/TASKS.md b/.copilot/TASKS.md index b666e2b..1346138 100644 --- a/.copilot/TASKS.md +++ b/.copilot/TASKS.md @@ -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 diff --git a/.gitignore b/.gitignore index cf7aacc..695a574 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ input/ +output/ +.venv/ +__pycache__/ .DS_Store -.~lock.*# \ No newline at end of file +.~lock.*# +*.xlsx# +~*.xlsx \ No newline at end of file diff --git a/README.md b/README.md index 6c1929c..99f3b6c 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file +```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` \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..b4e2379 --- /dev/null +++ b/app.py @@ -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}") diff --git a/config/input-fields.json b/config/input-fields.json index e69de29..f7b8cd1 100644 --- a/config/input-fields.json +++ b/config/input-fields.json @@ -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} +] diff --git a/config/output-fields.json b/config/output-fields.json index e69de29..7aee486 100644 --- a/config/output-fields.json +++ b/config/output-fields.json @@ -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} +] diff --git a/config/settings.ini b/config/settings.ini index e69de29..22e9def 100644 --- a/config/settings.ini +++ b/config/settings.ini @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cbdaa71 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +streamlit>=1.28,<2.0 +openpyxl>=3.1,<4.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..b96d3e1 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Tätigkeitsnachweis-Generator package.""" diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..8e2bc78 --- /dev/null +++ b/src/config.py @@ -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) diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..5948a9e --- /dev/null +++ b/src/models.py @@ -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) diff --git a/src/parser.py b/src/parser.py new file mode 100644 index 0000000..de3b39b --- /dev/null +++ b/src/parser.py @@ -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)) diff --git a/src/validator.py b/src/validator.py new file mode 100644 index 0000000..2b038f6 --- /dev/null +++ b/src/validator.py @@ -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, + ) diff --git a/src/writer.py b/src/writer.py new file mode 100644 index 0000000..6937100 --- /dev/null +++ b/src/writer.py @@ -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 diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..ab2d3e3 --- /dev/null +++ b/start.bat @@ -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