Compare commits
5
Commits
932585882e
..
v1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23cb3c35b3 | ||
|
|
dbbd86ece0 | ||
|
|
557f987ee1 | ||
|
|
b084120c0d | ||
|
|
9f0e3cc8fc |
No files matched your search
@@ -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 |
|
| `tests/test_e2e.py` | Tests für PDF-Output anpassen |
|
||||||
| `.copilot/PLAN.md` | Dieser Plan – aktualisiert |
|
| `.copilot/PLAN.md` | Dieser Plan – aktualisiert |
|
||||||
| `.copilot/TASKS.md` | Aufgabenliste – aktualisiert |
|
| `.copilot/TASKS.md` | Aufgabenliste – aktualisiert |
|
||||||
|
| `CHANGELOG.md` | Changelog – wird bei jedem Version-Build erweitert |
|
||||||
|
|
||||||
## Projektstruktur
|
## 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
|
├── coldrun.py ← CLI Cold-Run
|
||||||
├── start.bat ← Windows batch zum Start der App
|
├── start.bat ← Windows batch zum Start der App
|
||||||
├── README.md ← README für Programmbenutzer
|
├── README.md ← README für Programmbenutzer
|
||||||
|
├── CHANGELOG.md ← Changelog (wird bei jedem Version-Build erweitert)
|
||||||
└── requirements.txt ← streamlit, openpyxl, PyMuPDF
|
└── 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
|
## Verifikation
|
||||||
|
|
||||||
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
|
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ __pycache__/
|
|||||||
.~lock.*#
|
.~lock.*#
|
||||||
*.xlsx#
|
*.xlsx#
|
||||||
~*.xlsx
|
~*.xlsx
|
||||||
|
/versions
|
||||||
@@ -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
|
||||||
@@ -239,7 +239,7 @@ if st.session_state.input_validated and st.session_state.parsed_records:
|
|||||||
key = f"sel_{emp}"
|
key = f"sel_{emp}"
|
||||||
if key not in st.session_state:
|
if key not in st.session_state:
|
||||||
st.session_state[key] = True
|
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)
|
selected_employees.append(emp)
|
||||||
|
|
||||||
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
|
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
|
||||||
|
|||||||
+43
-18
@@ -171,9 +171,12 @@ def _fill_pdf_pages(
|
|||||||
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
|
"""Befüllt alle Template-Seiten: redacted _Field-Platzhalter und fügt echte Daten ein."""
|
||||||
# Mehrzeilige Felder, die als Textbox (mit Zeilenumbruch) behandelt werden
|
# Mehrzeilige Felder, die als Textbox (mit Zeilenumbruch) behandelt werden
|
||||||
MULTILINE_FIELDS = {"Tätigkeiten_Field", "Dienstreisen_Field"}
|
MULTILINE_FIELDS = {"Tätigkeiten_Field", "Dienstreisen_Field"}
|
||||||
TEXTBOX_FONT_SCALE = 1 # Schriftgrößen-Faktor für Textboxen
|
|
||||||
TEXTBOX_WIDTH_PT = 400 # Breite der Textbox in Punkt
|
TEXTBOX_WIDTH_PT = 400 # Breite der Textbox in Punkt
|
||||||
TEXTBOX_HEIGHT_PT = 120 # Höhe 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:
|
for m in mapping:
|
||||||
if m.pdf_field is None:
|
if m.pdf_field is None:
|
||||||
@@ -201,7 +204,16 @@ def _fill_pdf_pages(
|
|||||||
if not formatted:
|
if not formatted:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Mehrzeilige Felder: insert_textbox mit Zeilenumbruch
|
# 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:
|
if m.pdf_field in MULTILINE_FIELDS:
|
||||||
# Doppelte Leerzeilen entfernen
|
# Doppelte Leerzeilen entfernen
|
||||||
clean_text = "\n".join(
|
clean_text = "\n".join(
|
||||||
@@ -210,21 +222,34 @@ def _fill_pdf_pages(
|
|||||||
if not clean_text:
|
if not clean_text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Rechteck für die Textbox: ausgehend vom Marker, nach rechts & unten erweitert
|
# Zeilenweise mit automatischem Umbruch schreiben
|
||||||
textbox_rect = fitz.Rect(
|
lines = clean_text.split("\n")
|
||||||
rect.x0, # links
|
line_spacing = fontsize * 1.2 # Zeilenabstand
|
||||||
rect.y0, # oben
|
y = rect.y0 + fontsize # Startposition (unterhalb Marker-Oberkante)
|
||||||
rect.x0 + TEXTBOX_WIDTH_PT, # rechts
|
|
||||||
rect.y0 + TEXTBOX_HEIGHT_PT, # unten
|
for line in lines:
|
||||||
)
|
# Wort-Umbruch: Zeile in Stücke passend zur Textbox-Breite aufteilen
|
||||||
page.insert_textbox(
|
max_width = TEXTBOX_WIDTH_PT
|
||||||
textbox_rect,
|
words = line.split(" ")
|
||||||
clean_text,
|
current_line = ""
|
||||||
fontname="helv",
|
for word in words:
|
||||||
fontsize=fontsize * TEXTBOX_FONT_SCALE,
|
test_line = current_line + (" " if current_line else "") + word
|
||||||
lineheight=1.5,
|
text_width = fitz.get_text_length(test_line, fontname="helv", fontsize=fontsize)
|
||||||
align=fitz.TEXT_ALIGN_LEFT,
|
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:
|
else:
|
||||||
page.insert_text(
|
page.insert_text(
|
||||||
rect.bl,
|
rect.bl,
|
||||||
|
|||||||
Reference in new issue
Block a user