12 Commits
Author SHA1 Message Date
Rob 23cb3c35b3 version v1.1 2026-06-24 08:38:50 +02:00
Rob dbbd86ece0 add: changelog zum Plan 2026-06-24 08:35:31 +02:00
Rob 557f987ee1 fix konsole error message on app start 2026-06-24 08:14:54 +02:00
Rob b084120c0d fix: empty field: Qualifikation and Funktion 2026-06-24 08:14:29 +02:00
Rob 9f0e3cc8fc fix: nur die erste Person hatte im Tätigkeitenfeld etwas stehen 2026-06-23 12:40:29 +02:00
Rob 932585882e Merge branch 'pdf-as-template'
# Conflicts:
#	template/Tätigkeitsnachweise Stellenförderung.pdf
2026-06-23 11:54:09 +02:00
Rob 3e9cd39859 unnötiges Feld entfernt 2026-06-23 11:51:00 +02:00
Rob 9bdd90c073 ouput pdf: text styling 2026-06-23 11:50:44 +02:00
Rob e50f423304 input validierung für Qualifikationsfeld. Wenn keine Daten -> Platzhalter im template löschen 2026-06-23 11:49:54 +02:00
Rob ef219e00ba add; coldrun.bat 2026-06-23 11:00:36 +02:00
Rob 30f3ffaded neues cleanes template.pdf 2026-06-23 09:58:32 +02:00
Rob 93c31655bd nimn Johannes Datei als xlsx template 2026-06-16 21:21:43 +02:00
13 changed files with 256 additions and 10 deletions

No files matched your search

+18
View File
@@ -126,6 +126,7 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
| `tests/test_e2e.py` | Tests für PDF-Output anpassen |
| `.copilot/PLAN.md` | Dieser Plan – aktualisiert |
| `.copilot/TASKS.md` | Aufgabenliste – aktualisiert |
| `CHANGELOG.md` | Changelog – wird bei jedem Version-Build erweitert |
## Projektstruktur
@@ -154,9 +155,26 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
├── coldrun.py ← CLI Cold-Run
├── start.bat ← Windows batch zum Start der App
├── README.md ← README für Programmbenutzer
├── CHANGELOG.md ← Changelog (wird bei jedem Version-Build erweitert)
└── requirements.txt ← streamlit, openpyxl, PyMuPDF
```
## Versionierung & Changelog
Immer wenn das Signal zum Build einer neuen Version gegeben wird (z. B. „Build Version X.Y“ oder „neue Version bauen“), wird das **Changelog-Logfile** (`CHANGELOG.md` im Projekt-Root) um die **most recent changes** erweitert. Der Eintrag enthält:
- **Versionsnummer** (z. B. `v1.2.0`)
- **Datum** des Builds
- **Neue Funktionen** (Features)
- **Behobene Fehler** (Bugfixes)
- **Sonstige Änderungen** (z. B. Refactoring, Doku-Updates)
Der Changelog wird im [Keep a Changelog](https://keepachangelog.com/)-Format gepflegt und nach Semantic Versioning (`MAJOR.MINOR.PATCH`) versioniert.
> **Wichtig**: Der Changelog-Eintrag wird **vor** dem eigentlichen Build-Vorgang erstellt, sodass die neue Version dokumentiert ist, bevor der Code ausgeliefert wird.
---
## Verifikation
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
+2 -1
View File
@@ -5,4 +5,5 @@ __pycache__/
.DS_Store
.~lock.*#
*.xlsx#
~*.xlsx
~*.xlsx
/versions
+72
View File
@@ -0,0 +1,72 @@
# Changelog
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
---
## [1.1] – 2026-06-24
### Hinzugefügt
- **Streamlit-Web-UI** (`app.py`) mit:
- Dropdown zur Auswahl der Input-Datei (`.xlsx`) aus dem `input/`-Ordner – mit Sofort-Validierung und Anzeige von Mitarbeiteranzahl, Antragsnummern und Einträgen pro Monat
- Dropdown zur Auswahl des PDF-Templates aus dem `template/`-Ordner – mit Sofort-Validierung und Anzeige gefundener `_Field`-Platzhalter
- Mitarbeiter-Multiselect-Liste mit „Alle auswählen“ / „Keine auswählen“-Buttons
- „Tätigkeitsnachweise generieren“-Button mit Fortschrittsanzeige
- Session-State-Caching für Validierung und Parsing (kein erneutes Einlesen bei UI-Reruns)
- Erfolgsmeldung mit Liste generierter Dateien und Button zum Öffnen des Output-Ordners
- **PDF-basierte Generierung** (`src/writer.py`) via PyMuPDF (fitz):
- Pre-Scan aller `_Field`-Platzhalter im Template-PDF über **alle Seiten**
- Automatische Erkennung von Position (Bounding Box) und Schriftgröße jedes Platzhalters
- Redact + Insert-Text-Verfahren: Platzhalter werden geschwärzt und durch echte Daten ersetzt
- Unterstützung für **mehrseitige Templates** (z. B. 2 Seiten pro Monat)
- **Mehrzeilige Textboxen** (`Tätigkeiten_Field`, `Dienstreisen_Field`) mit automatischem Wortumbruch
- **Automatische Schriftgrößen-Anpassung** bei langen Texten in Qualifikation und Funktion (Autoscale)
- Prozentwerte werden als Ganzzahl ohne `%`-Zeichen ausgegeben
- Datumsformat `dd.mm.yyyy` wird automatisch aus dem Monatsfeld (`MM.JJ`) berechnet
- Ort standardmäßig „Dresden“
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.pdf`
- **Input-Parsing** (`src/parser.py`):
- Einlesen von `.xlsx`-Dateien mit openpyxl – ein Sheet pro Monat
- Monats-Sheet-Erkennung per Wildcard-Pattern (`* 20??`)
- Prozentwert-Konvertierung: Excel-Prozentwerte (0.5 → 50) oder Ganzzahlen (50 → 50)
- Automatisches Überspringen leerer Zeilen (Name oder Antragsnummer fehlt)
- Gruppierung der Records nach Mitarbeitername (`group_by_employee`)
- Hilfsfunktionen: `get_unique_antragsnummern`, `get_unique_employees`
- **Validierung** (`src/validator.py`):
- Input-Datei: Prüft Existenz, Spaltenüberschriften (gegen `input-fields.json`), Datenzeilen pro Monats-Sheet
- Input-Daten: Erkennt leere Pflichtfelder pro Person und meldet betroffene Namen
- PDF-Template: Sucht alle `_Field`-Platzhalter im PDF und meldet fehlende
- Nicht-Monats-Sheets (z. B. „Alle Rollen“) werden ignoriert
- **Konfigurationsdateien** (`config/`):
- `settings.ini`: Pfade für Input/Template/Output, Dateinamen-Pattern, Monats-Sheet-Pattern, Skip-Optionen
- `input-fields.json`: Spaltenschema der Input-Tabelle (9 Felder: ID, Kommentar, Antragsnummer, Name, Qualifikation, Funktion, Stellenanteil in %, Monat, Tätigkeit)
- `output-fields.json`: Mapping Input-Feld → `_Field`-Platzhalter im PDF (8 Felder)
- **Datenmodell** (`src/models.py`):
- `EmployeeRecord`: Dataclass für eine Mitarbeiter-Zeile mit `month_index`- und `year`-Properties
- `FieldSchema`, `OutputMapping`, `AppConfig`, `ValidationResult`
- **Cold Run** (`coldrun.py`): Headless-Generierung für die erste Person ohne GUI, automatisch mit erster Input- und Template-Datei
- **Start-Skripte**: `start.bat` (Windows), `start.sh` / `start.command` (macOS/Linux)
- **End-to-End-Tests** (`tests/test_e2e.py`):
- pytest-basiert mit realer Input-Datei (`2025 JM.xlsx`) und realem PDF-Template
- Prüft: 168 Datensätze, 19 Personen, 12 Monate
- Validiert Output-PDF: Seitenanzahl (24 Seiten = 12 × 2), Inhalte (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat, Funktion, Tätigkeit), keine verbliebenen `_Field`-Platzhalter
- **README.md**: Vollständige Dokumentation zu Setup, Bedienung, Konfiguration, Cold Run und Tests
### Geändert
- Template-Format von `.xlsx` auf **PDF** umgestellt (ursprüngliches Excel-Template war schreibgeschützt)
- `config/output-fields.json`: `target_cell` entfernt – Mapping erfolgt jetzt über `_Field`-Platzhalter im PDF
- `src/config.py`: `get_available_templates` filtert jetzt `.pdf`-Dateien
+1 -1
View File
@@ -239,7 +239,7 @@ if st.session_state.input_validated and st.session_state.parsed_records:
key = f"sel_{emp}"
if key not in st.session_state:
st.session_state[key] = True
if st.checkbox(emp, value=st.session_state[key], key=key):
if st.checkbox(emp, key=key):
selected_employees.append(emp)
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
+46
View File
@@ -0,0 +1,46 @@
@echo off
title Tätigkeitsnachweis-Generator – Cold Run
echo ========================================
echo Cold Run – Headless-Generierung
echo ========================================
echo.
:: Prüfe ob Python verfügbar ist
where python >nul 2>nul
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Python wurde nicht gefunden.
echo Bitte installiere Python 3.9 oder neuer von https://python.org
pause
exit /b 1
)
echo [INFO] Python gefunden:
python --version
:: Prüfe ob venv existiert, sonst erstelle
if not exist ".venv\Scripts\python.exe" (
echo [FEHLER] Virtuelle Umgebung nicht gefunden.
echo Bitte zuerst start.bat ausführen, um das Projekt einzurichten.
pause
exit /b 1
)
:: Aktiviere venv und installiere Abhängigkeiten
call .venv\Scripts\activate.bat
echo [INFO] Stelle sicher, dass Abhängigkeiten installiert sind...
pip install -r requirements.txt --quiet
if %ERRORLEVEL% neq 0 (
echo [FEHLER] Konnte Abhängigkeiten nicht installieren.
pause
exit /b 1
)
:: Starte Cold Run
echo.
echo [INFO] Starte Cold Run – generiere Tätigkeitsnachweis für erste Person...
echo.
python coldrun.py
echo.
echo [INFO] Cold Run abgeschlossen.
pause
+13 -1
View File
@@ -22,7 +22,7 @@ from src.config import (
get_available_templates,
)
from src.parser import parse_input_file, group_by_employee, get_unique_employees
from src.validator import validate_template
from src.validator import validate_input_file, validate_template
from src.writer import generate_activity_reports
@@ -70,6 +70,18 @@ def main():
total = tpl_result.info.get("total_fields", "?")
print(f"[OK] {found}/{total} Platzhalter im Template gefunden.")
# ── Input validieren ──────────────────────────────────────────────
print()
print("[INFO] Validiere Input-Datei…")
inp_result = validate_input_file(input_path, config.input_schema, config.month_sheet_pattern)
if not inp_result.is_valid:
for err in inp_result.errors:
print(f"[FEHLER] {err}")
return 1
if inp_result.warnings:
for warn in inp_result.warnings:
print(f"[WARN] {warn}")
# ── Input parsen ──────────────────────────────────────────────────
print()
print("[INFO] Parse Input-Datei…")
+1 -4
View File
@@ -1,13 +1,10 @@
[
{"source_field": "Antragsnummer", "pdf_field": "Auftragsnummer_Field", "format": null},
{"source_field": "Antragsnummer", "pdf_field": "Antragsnummer_Field", "format": null},
{"source_field": "Name", "pdf_field": "Name_Field", "format": null},
{"source_field": "Stellenanteil in %", "pdf_field": "Stellenanteil_Field", "format": null},
{"source_field": "Qualifikation", "pdf_field": "Qualifikation_Field", "format": null},
{"source_field": "Monat (MM.JJ)", "pdf_field": "Monat_Field", "format": null},
{"source_field": "Funktion", "pdf_field": "Funktion_Field", "format": null},
{"source_field": "Tätigkeit", "pdf_field": "Tätigkeiten_Field", "format": null},
{"source_field": "Tätigkeit", "pdf_field": "Tätigkeiten-inkl-Reisen_Field","format": null},
{"source_field": "Ort", "pdf_field": "Ort_Field", "format": null},
{"source_field": "Ort 2", "pdf_field": "Ort2_Field", "format": null},
{"source_field": "Datum", "pdf_field": "Datum_Field", "format": null}
]
+43
View File
@@ -81,6 +81,49 @@ def validate_input_file(
info["sheets_with_data"] = sheets_with_data
wb.close()
# ── Dateninhalts-Prüfung: leere Pflichtfelder ───────────────────
required_fields = [f for f in schema if f.required]
if required_fields and sheets_with_data > 0:
# Mapping field_name → column index
field_col_map: dict[str, int] = {}
for f in required_fields:
field_col_map[f.field_name] = column_index_from_string(f.column) - 1
max_col = max(field_col_map.values()) + 1
# {field_name: {name1, name2, ...}} für leere Pflichtwerte
empty_by_field: dict[str, set[str]] = {f.field_name: set() for f in required_fields}
wb2 = load_workbook(filepath, data_only=True)
for sheet_name in wb2.sheetnames:
if not is_month_sheet(sheet_name, month_sheet_pattern):
continue
ws = wb2[sheet_name]
if ws.max_row < 2:
continue
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=1, max_col=max_col, values_only=True):
name_val = str(row[field_col_map.get("Name", 0)] or "").strip() if len(row) > field_col_map.get("Name", 0) else ""
if not name_val:
continue
for f in required_fields:
col_idx = field_col_map[f.field_name]
if col_idx < len(row):
cell_val = str(row[col_idx] or "").strip() if row[col_idx] is not None else ""
if not cell_val:
empty_by_field[f.field_name].add(name_val)
wb2.close()
for field_name, names in empty_by_field.items():
if names:
sorted_names = sorted(names)
total = len(sorted_names)
preview = sorted_names[:5]
suffix = " …" if total > 5 else ""
warnings.append(
f"Feld '{field_name}' ist leer bei {total} Person(en): "
f"{', '.join(preview)}{suffix}"
)
# ──────────────────────────────────────────────────────────────────
if sheets_with_data == 0:
errors.append("Kein Sheet mit den erwarteten Spalten gefunden.")
+60 -3
View File
@@ -169,6 +169,15 @@ def _fill_pdf_pages(
field_info: dict[str, dict],
) -> None:
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
# Mehrzeilige Felder, die als Textbox (mit Zeilenumbruch) behandelt werden
MULTILINE_FIELDS = {"Tätigkeiten_Field", "Dienstreisen_Field"}
TEXTBOX_WIDTH_PT = 400 # Breite der Textbox in Punkt
# Felder mit automatischer Schriftgrößen-Anpassung bei langem Text
AUTOSCALE_FIELDS = {"Qualifikation_Field", "Funktion_Field"}
AUTOSCALE_MAX_CHARS = 35 # ab dieser Länge wird verkleinert
AUTOSCALE_MIN_FACTOR = 0.75 # minimale Schriftgröße relativ zum Original
for m in mapping:
if m.pdf_field is None:
continue
@@ -188,12 +197,60 @@ def _fill_pdf_pages(
value = _get_field_value(record, employee_name, m.source_field)
formatted = _format_value(value, m.format)
# Platzhalter redacten
# Platzhalter immer redacten, auch wenn kein Wert zum Einsetzen da ist
page.add_redact_annot(rect)
page.apply_redactions()
# Neuen Text einfügen
if formatted:
if not formatted:
continue
# Automatische Schriftgrößen-Anpassung für lange Texte
if m.pdf_field in AUTOSCALE_FIELDS and len(formatted) > AUTOSCALE_MAX_CHARS:
text_width = fitz.get_text_length(formatted, fontname="helv", fontsize=fontsize)
available_width = rect.width
if text_width > available_width:
factor = available_width / text_width
factor = max(factor, AUTOSCALE_MIN_FACTOR)
fontsize = fontsize * factor
# Mehrzeilige Felder: Zeilenweise per insert_text schreiben
if m.pdf_field in MULTILINE_FIELDS:
# Doppelte Leerzeilen entfernen
clean_text = "\n".join(
line for line in formatted.split("\n") if line.strip()
)
if not clean_text:
continue
# Zeilenweise mit automatischem Umbruch schreiben
lines = clean_text.split("\n")
line_spacing = fontsize * 1.2 # Zeilenabstand
y = rect.y0 + fontsize # Startposition (unterhalb Marker-Oberkante)
for line in lines:
# Wort-Umbruch: Zeile in Stücke passend zur Textbox-Breite aufteilen
max_width = TEXTBOX_WIDTH_PT
words = line.split(" ")
current_line = ""
for word in words:
test_line = current_line + (" " if current_line else "") + word
text_width = fitz.get_text_length(test_line, fontname="helv", fontsize=fontsize)
if text_width <= max_width or not current_line:
current_line = test_line
else:
page.insert_text(
(rect.x0, y), current_line,
fontname="helv", fontsize=fontsize,
)
y += line_spacing
current_line = word
if current_line:
page.insert_text(
(rect.x0, y), current_line,
fontname="helv", fontsize=fontsize,
)
y += line_spacing
else:
page.insert_text(
rect.bl,
formatted,
Binary file not shown.
Binary file not shown.
Binary file not shown.