Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbe325d305 | ||
|
|
6e541a04df |
No files matched your search
@@ -22,6 +22,20 @@ streamlit run app.py
|
||||
|
||||
Oder einfach `start.bat` (Windows) bzw. `./start.sh` (macOS/Linux) ausführen.
|
||||
|
||||
## Cold Run – Headless-Generierung
|
||||
|
||||
`coldrun.py` erzeugt ohne GUI einen Tätigkeitsnachweis für die erste Person –
|
||||
automatisch mit der ersten Input- und Template-Datei. Ergebnis wird auf macOS
|
||||
im Finder geöffnet.
|
||||
|
||||
```bash
|
||||
# Kommandozeile
|
||||
.venv/bin/python coldrun.py
|
||||
|
||||
# oder per Doppelklick (macOS)
|
||||
coldrun.command
|
||||
```
|
||||
|
||||
## Bedienung
|
||||
|
||||
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
|
||||
@@ -70,3 +84,24 @@ Mapping von Input-Feldern auf Template-Zellen:
|
||||
- Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt
|
||||
- Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt
|
||||
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
|
||||
|
||||
## Tests
|
||||
|
||||
End-to-End-Tests parsen die reale Input-Datei (`input/2025 JM.xlsx`) und das reale
|
||||
Template (`template/Tätigkeitsnachweise Stellenförderung.xlsx`) und validieren
|
||||
den gesamten Generierungs-Workflow.
|
||||
|
||||
```bash
|
||||
# Test-Abhängigkeiten installieren
|
||||
.venv/bin/pip install pytest
|
||||
|
||||
# Tests ausführen
|
||||
.venv/bin/python -m pytest tests/test_e2e.py -v
|
||||
```
|
||||
|
||||
Die Tests prüfen:
|
||||
- Vorhandensein der Input- und Template-Dateien
|
||||
- Korrekte Parsierung (168 Datensätze, 19 Personen)
|
||||
- Vollständige Pipeline für die erste Person („Draßdo, Cornelia"): 12 Monats-Sheets
|
||||
mit allen Zellinhalten (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat,
|
||||
Funktion, Tätigkeit, Ort, Datum)
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Doppelklickbares Cold-Run-Script für macOS
|
||||
# Nimmt automatisch die erste Input-Datei + erstes Template und erzeugt
|
||||
# einen Tätigkeitsnachweis für die erste gelistete Person – ohne GUI.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
VENV_DIR=".venv"
|
||||
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
echo "[FEHLER] Virtuelle Umgebung nicht gefunden."
|
||||
echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten."
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Stelle sicher, dass Abhängigkeiten installiert sind…"
|
||||
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
"$VENV_DIR/bin/python" coldrun.py
|
||||
|
||||
echo ""
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cold-Run Tätigkeitsnachweis-Generator.
|
||||
|
||||
Nimmt automatisch die erste Input-Datei und das erste Template,
|
||||
erzeugt einen Tätigkeitsnachweis für die erste gelistete Person
|
||||
und öffnet das Ergebnis (macOS) – ohne GUI, ohne Fragen.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt-Root
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.config import (
|
||||
load_settings,
|
||||
load_input_schema,
|
||||
load_output_mapping,
|
||||
get_available_input_files,
|
||||
get_available_templates,
|
||||
)
|
||||
from src.parser import parse_input_file, group_by_employee, get_unique_employees
|
||||
from src.writer import generate_activity_reports
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" Tätigkeitsnachweis-Generator (Cold Run)")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# ── Konfiguration laden ──────────────────────────────────────────
|
||||
config_dir = PROJECT_ROOT / "config"
|
||||
config = load_settings(config_dir, PROJECT_ROOT)
|
||||
config.input_schema = load_input_schema(config_dir)
|
||||
config.output_mapping = load_output_mapping(config_dir)
|
||||
|
||||
# ── Erste Input-Datei ermitteln ───────────────────────────────────
|
||||
input_files = get_available_input_files(config)
|
||||
if not input_files:
|
||||
print("[FEHLER] Keine .xlsx-Dateien im input/-Ordner gefunden.")
|
||||
return 1
|
||||
|
||||
input_file = input_files[0]
|
||||
input_path = Path(config.input_dir) / input_file
|
||||
print(f"[INFO] Input-Datei: {input_file}")
|
||||
|
||||
# ── Erstes Template ermitteln ─────────────────────────────────────
|
||||
template_files = get_available_templates(config)
|
||||
if not template_files:
|
||||
print("[FEHLER] Keine .xlsx-Dateien im template/-Ordner gefunden.")
|
||||
return 1
|
||||
|
||||
template_file = template_files[0]
|
||||
template_path = Path(config.template_dir) / template_file
|
||||
print(f"[INFO] Template: {template_file}")
|
||||
|
||||
# ── Input parsen ──────────────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Parse Input-Datei…")
|
||||
records = parse_input_file(
|
||||
filepath=input_path,
|
||||
schema=config.input_schema,
|
||||
month_sheet_pattern=config.month_sheet_pattern,
|
||||
skip_empty_name=config.skip_empty_name,
|
||||
skip_empty_antragsnummer=config.skip_empty_antragsnummer,
|
||||
)
|
||||
|
||||
employees = get_unique_employees(records)
|
||||
print(f"[INFO] {len(records)} Einträge für {len(employees)} Personen gefunden.")
|
||||
|
||||
# ── Erste Person ermitteln ────────────────────────────────────────
|
||||
grouped = group_by_employee(records)
|
||||
first_person = sorted(grouped.keys())[0]
|
||||
first_records = grouped[first_person]
|
||||
print(f"[INFO] Erste Person: {first_person} ({len(first_records)} Monate)")
|
||||
|
||||
# ── Reports generieren ────────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Generiere Tätigkeitsnachweis…")
|
||||
single_person = {first_person: first_records}
|
||||
output_dir = Path(config.output_dir)
|
||||
generated = generate_activity_reports(
|
||||
employee_records=single_person,
|
||||
template_path=template_path,
|
||||
mapping=config.output_mapping,
|
||||
output_dir=output_dir,
|
||||
filename_pattern=config.filename_pattern,
|
||||
)
|
||||
|
||||
output_file = generated[0]
|
||||
print(f"[OK] Erstellt: {output_file.name}")
|
||||
print(f" Pfad: {output_file}")
|
||||
|
||||
# ── Datei öffnen (macOS) ──────────────────────────────────────────
|
||||
if sys.platform == "darwin":
|
||||
print()
|
||||
print("[INFO] Öffne Datei im Finder…")
|
||||
subprocess.run(["open", str(output_file)], check=False)
|
||||
|
||||
print()
|
||||
print("=" * 50)
|
||||
print(" Fertig.")
|
||||
print("=" * 50)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Doppelklickbares Test-Script für macOS (.command wird von Finder automatisch im Terminal geöffnet)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "========================================"
|
||||
echo " Tätigkeitsnachweis-Generator – Tests"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
VENV_DIR=".venv"
|
||||
|
||||
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||
echo "[FEHLER] Virtuelle Umgebung nicht gefunden."
|
||||
echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten."
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Stelle sicher, dass pytest installiert ist..."
|
||||
"$VENV_DIR/bin/pip" install pytest --quiet 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "[INFO] Führe End-to-End-Tests aus…"
|
||||
echo ""
|
||||
|
||||
"$VENV_DIR/bin/python" -m pytest tests/test_e2e.py -v
|
||||
|
||||
echo ""
|
||||
read -p "Drücke Enter zum Beenden..."
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests für den Tätigkeitsnachweis-Generator."""
|
||||
Binary file not shown.
@@ -0,0 +1,213 @@
|
||||
"""End-to-End-Test: Reale Input-Datei + reales Template → Excel-Output für erste Person."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# Projekt-Root zum Python-Path hinzufügen
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.config import load_settings, load_input_schema, load_output_mapping
|
||||
from src.parser import parse_input_file, group_by_employee, get_unique_employees
|
||||
from src.writer import generate_activity_reports
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Pfade zu den realen Fixture-Dateien
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx"
|
||||
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.xlsx"
|
||||
|
||||
|
||||
def _load_app_config():
|
||||
"""Lädt die echte App-Konfiguration (Schema, Mapping, Settings)."""
|
||||
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
|
||||
config.input_schema = load_input_schema(CONFIG_DIR)
|
||||
config.output_mapping = load_output_mapping(CONFIG_DIR)
|
||||
return config
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_config():
|
||||
"""Reale App-Konfiguration aus config/."""
|
||||
return _load_app_config()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parsed_records(app_config):
|
||||
"""Geparste Records aus der realen Input-Datei."""
|
||||
return parse_input_file(
|
||||
filepath=INPUT_FILE,
|
||||
schema=app_config.input_schema,
|
||||
month_sheet_pattern=app_config.month_sheet_pattern,
|
||||
skip_empty_name=app_config.skip_empty_name,
|
||||
skip_empty_antragsnummer=app_config.skip_empty_antragsnummer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def output_dir(tmp_path):
|
||||
"""Temporäres Output-Verzeichnis."""
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
return out
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# End-to-End Tests
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class TestEndToEndFirstPerson:
|
||||
"""End-to-End: Reale Input-Datei + reales Template → nur erste Person."""
|
||||
|
||||
def test_input_file_exists(self):
|
||||
"""Stellt sicher, dass die reale Input-Datei vorhanden ist."""
|
||||
assert INPUT_FILE.exists(), f"Input-Datei nicht gefunden: {INPUT_FILE}"
|
||||
|
||||
def test_template_file_exists(self):
|
||||
"""Stellt sicher, dass das reale Template vorhanden ist."""
|
||||
assert TEMPLATE_FILE.exists(), f"Template nicht gefunden: {TEMPLATE_FILE}"
|
||||
|
||||
def test_parse_real_input(self, parsed_records):
|
||||
"""Validiert das Parsen der realen Input-Datei."""
|
||||
# Sollte 168 Einträge über 12 Monate für 19 Personen ergeben
|
||||
assert len(parsed_records) == 168, (
|
||||
f"Erwartet 168 Records, aber {len(parsed_records)} gefunden"
|
||||
)
|
||||
|
||||
employees = get_unique_employees(parsed_records)
|
||||
assert len(employees) == 19, (
|
||||
f"Erwartet 19 Mitarbeiter, aber {len(employees)} gefunden"
|
||||
)
|
||||
|
||||
# Erste Person alphabetisch: Draßdo, Cornelia
|
||||
assert employees[0] == "Draßdo, Cornelia", (
|
||||
f"Erwartet 'Draßdo, Cornelia' als erste Person, aber '{employees[0]}'"
|
||||
)
|
||||
|
||||
def test_full_pipeline_first_person(
|
||||
self, app_config, parsed_records, output_dir
|
||||
):
|
||||
"""Kompletter Workflow für die erste gefundene Person (Draßdo, Cornelia)."""
|
||||
# 1. Nach Mitarbeiter gruppieren
|
||||
grouped = group_by_employee(parsed_records)
|
||||
assert len(grouped) == 19
|
||||
|
||||
# 2. Erste Person alphabetisch
|
||||
first_person = sorted(grouped.keys())[0]
|
||||
assert first_person == "Draßdo, Cornelia"
|
||||
|
||||
first_records = grouped[first_person]
|
||||
# Draßdo hat 12 Einträge (einen pro Monat)
|
||||
assert len(first_records) == 12, (
|
||||
f"Erwartet 12 Records für {first_person}, aber {len(first_records)}"
|
||||
)
|
||||
|
||||
# 3. Reports NUR für die erste Person generieren
|
||||
single_person_group = {first_person: first_records}
|
||||
generated = generate_activity_reports(
|
||||
employee_records=single_person_group,
|
||||
template_path=TEMPLATE_FILE,
|
||||
mapping=app_config.output_mapping,
|
||||
output_dir=output_dir,
|
||||
filename_pattern=app_config.filename_pattern,
|
||||
)
|
||||
|
||||
# 4. Ergebnisse prüfen
|
||||
assert len(generated) == 1, (
|
||||
f"Erwartet 1 generierte Datei, aber {len(generated)}"
|
||||
)
|
||||
output_file = generated[0]
|
||||
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}"
|
||||
assert output_file.suffix == ".xlsx"
|
||||
|
||||
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.xlsx"
|
||||
assert output_file.name == erwarteter_name, (
|
||||
f"Erwartet '{erwarteter_name}', aber '{output_file.name}'"
|
||||
)
|
||||
|
||||
# 5. Output-Datei öffnen und Inhalt validieren
|
||||
wb = load_workbook(output_file, data_only=True)
|
||||
|
||||
# 12 sichtbare Sheets, Rest (36 - 12 = 24) ausgeblendet
|
||||
visible_sheets = [s for s in wb.worksheets if s.sheet_state == "visible"]
|
||||
assert len(visible_sheets) == 12, (
|
||||
f"Erwartet 12 sichtbare Sheets, aber {len(visible_sheets)}"
|
||||
)
|
||||
|
||||
# Prüfe Sheet-Namen für alle 12 Monate
|
||||
expected_titles = {
|
||||
"01_Januar_Anl", "02_Februar_Anl", "03_März_Anl",
|
||||
"04_April_Anl", "05_Mai_Anl", "06_Juni_Anl",
|
||||
"07_Juli_Anl", "08_August_Anl", "09_September_Anl",
|
||||
"10_Oktober_Anl", "11_November_Anl", "12_Dezember_Anl",
|
||||
}
|
||||
actual_titles = {ws.title for ws in visible_sheets}
|
||||
assert actual_titles == expected_titles, (
|
||||
f"Erwartet {expected_titles},\n aber {actual_titles}"
|
||||
)
|
||||
|
||||
# Prüfe exemplarisch das Januar-Sheet (01_Januar_Anl)
|
||||
jan = wb["01_Januar_Anl"]
|
||||
assert jan["B3"].value == "100740439", (
|
||||
f"Erwartet '100740439' in B3, aber '{jan['B3'].value}'"
|
||||
)
|
||||
assert jan["B17"].value == "Draßdo, Cornelia", (
|
||||
f"Erwartet 'Draßdo, Cornelia' in B17, aber '{jan['B17'].value}'"
|
||||
)
|
||||
# Stellenanteil: 0.5 → vom Parser zu "50" konvertiert (0 < x ≤ 1 → *100)
|
||||
assert jan["R17"].value == "50", (
|
||||
f"Erwartet '50' in R17, aber '{jan['R17'].value}'"
|
||||
)
|
||||
assert jan["B21"].value == "Meisterabschluß Küche", (
|
||||
f"Falsche Qualifikation in B21: '{jan['B21'].value}'"
|
||||
)
|
||||
assert jan["R21"].value == "01.2025", (
|
||||
f"Erwartet '01.2025' in R21, aber '{jan['R21'].value}'"
|
||||
)
|
||||
assert jan["B25"].value == "Anleiterin / Ausbilderin", (
|
||||
f"Falsche Funktion in B25: '{jan['B25'].value}'"
|
||||
)
|
||||
# Tätigkeit beginnt mit "Tätigkeiten im Praxisbereich Garten…"
|
||||
taetigkeit = jan["B31"].value or ""
|
||||
assert taetigkeit.startswith("Tätigkeiten im Praxisbereich Garten"), (
|
||||
f"Tätigkeit in B31 beginnt nicht wie erwartet: '{taetigkeit[:60]}…'"
|
||||
)
|
||||
# Ort wird hartkodiert auf "Dresden" gesetzt
|
||||
assert jan["B95"].value == "Dresden", (
|
||||
f"Erwartet 'Dresden' in B95, aber '{jan['B95'].value}'"
|
||||
)
|
||||
# Datum: letzter Tag des Monats Januar → 31.01.2025
|
||||
assert jan["J95"].value == "31.01.2025", (
|
||||
f"Erwartet '31.01.2025' in J95, aber '{jan['J95'].value}'"
|
||||
)
|
||||
|
||||
# Prüfe exemplarisch das Juli-Sheet (Stellenanteil wechselt auf 40%)
|
||||
jul = wb["07_Juli_Anl"]
|
||||
assert jul["R17"].value == "40", (
|
||||
f"Erwartet '40' in R17 (Juli), aber '{jul['R17'].value}'"
|
||||
)
|
||||
assert jul["R21"].value == "07.2025", (
|
||||
f"Erwartet '07.2025' in R21, aber '{jul['R21'].value}'"
|
||||
)
|
||||
# Datum Juli → 31.07.2025
|
||||
assert jul["J95"].value == "31.07.2025", (
|
||||
f"Erwartet '31.07.2025' in J95, aber '{jul['J95'].value}'"
|
||||
)
|
||||
|
||||
# Prüfe Dezember-Sheet (Datum: 31.12.2025)
|
||||
dez = wb["12_Dezember_Anl"]
|
||||
assert dez["J95"].value == "31.12.2025", (
|
||||
f"Erwartet '31.12.2025' in J95, aber '{dez['J95'].value}'"
|
||||
)
|
||||
|
||||
wb.close()
|
||||
Reference in new issue
Block a user