Compare commits
29
Commits
dbe325d305
...
v1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34730276d7 | ||
|
|
c12b15e021 | ||
|
|
d027bedcb8 | ||
|
|
eb1dd45e57 | ||
|
|
6f1d807f3c | ||
|
|
635323ae50 | ||
|
|
2c7d69cf78 | ||
|
|
c9dac7b9b8 | ||
|
|
94e245a3a0 | ||
|
|
fa3e99dec0 | ||
|
|
8fde588b8f | ||
|
|
87f8150bd3 | ||
|
|
b498af39d9 | ||
|
|
2247c42e81 | ||
|
|
23cb3c35b3 | ||
|
|
dbbd86ece0 | ||
|
|
557f987ee1 | ||
|
|
b084120c0d | ||
|
|
9f0e3cc8fc | ||
|
|
932585882e | ||
|
|
3e9cd39859 | ||
|
|
9bdd90c073 | ||
|
|
e50f423304 | ||
|
|
ef219e00ba | ||
|
|
30f3ffaded | ||
|
|
a4a678d11e | ||
|
|
7a6d35d8a1 | ||
|
|
93c31655bd | ||
|
|
169d2167e0 |
No files matched your search
+86
-65
@@ -1,24 +1,37 @@
|
||||
# Ziel
|
||||
|
||||
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).
|
||||
Eine Python + Streamlit App für Windows, die aus einer Input-Excel-Datei (ein Sheet pro Monat mit Mitarbeiterdaten) und einem **PDF-Template** pro Mitarbeiter eine **PDF-Datei** generiert (eine Seite pro Monat, befüllt mit dessen Daten).
|
||||
|
||||
**Ansatz**: Verwendung von openpyxl für Single-Input-File + Streamlit-UI.
|
||||
**Ansatz**: openpyxl für Input-Parsing + PyMuPDF (fitz) für PDF-Generierung + Streamlit-UI.
|
||||
|
||||
> **Hintergrund**: Das ursprüngliche Excel-Template war teilweise schreibgeschützt und konnte nicht mit openpyxl befüllt werden.
|
||||
> Daher wurde das Template als PDF exportiert. Im PDF wurden alle zu befüllenden Textstellen mit einem Platzhalter
|
||||
> im Format `{Feldname}_Field` markiert (z. B. `Antragsnummer_Field`, `Name_Field`). Die App sucht nach diesen
|
||||
> `_Field`-Markern und ersetzt sie durch die echten Daten.
|
||||
|
||||
# Aufgabe
|
||||
|
||||
Erstelle eine Windows App in dessen Oberfläche aus folgenden Bestandteilen aufgebaut ist:
|
||||
- mittels einem DropDown-Menü kann eine Datei aus dem inputfolder gewählt werden
|
||||
- mittels einem DropDown-Menü kann ein Template aus dem Templatefolder gewählt werden
|
||||
- mittels einem DropDown-Menü kann ein PDF-Template aus dem Templatefolder gewählt werden
|
||||
- ein Button um den Vorgang zu starten
|
||||
|
||||
Die Funktion der App besteht darin, das Templatefile mit Daten aus dem Input-File zu befüllen.
|
||||
Im Inputfile ist für alle Monate eines Jahres eine Liste aller Mitarbeitenden und in den Spalten alle Daten zur Befüllung des Template files.
|
||||
Die Funktion der App besteht darin, das Template-PDF mit Daten aus dem Input-File zu befüllen.
|
||||
Im Inputfile ist für alle Monate eines Jahres eine Liste aller Mitarbeitenden und in den Spalten alle Daten zur Befüllung des Templates.
|
||||
|
||||
# Aufbau Input Datei
|
||||
|
||||
- In der Input Datei ist für jeden Monat eines Jahres ein Sheet gespeichert.
|
||||
- In jedem Sheet befindet sich eine Tabelle (felder speichern in Input Schema json) mit Mitarbeitenden.
|
||||
- Für jeden Mitarbeitenden soll aus dem Template eine neue ExcelDatei erzeugt werden, welche auch wieder für jeden Monat ein Sheet des Templates enthält und alle Daten des Mitarbeiters für dieses Monat aus der Inputliste enthält.
|
||||
- Für jeden Mitarbeitenden wird ein PDF erzeugt, das für jeden Monat eine Seite enthält, befüllt mit den Daten des Mitarbeiters für diesen Monat.
|
||||
|
||||
# Aufbau PDF-Template
|
||||
|
||||
- Das PDF-Template ist eine einseitige PDF-Datei, generiert aus der ursprünglichen XLSX-Vorlage.
|
||||
- Jede zu befüllende Textstelle enthält einen Platzhalter: den Feldnamen gefolgt von `_Field`
|
||||
(z. B. `Antragsnummer_Field`, `Name_Field`, `Stellenanteil in %_Field`, …).
|
||||
- Die App sucht im PDF nach allen `_Field`-Texten, ermittelt deren Position und Schriftgröße,
|
||||
und ersetzt sie durch die tatsächlichen Datenwerte.
|
||||
|
||||
# README
|
||||
|
||||
@@ -30,15 +43,14 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
||||
|
||||
### Phase 1: Projekt-Grundgerüst & Konfiguration
|
||||
|
||||
1. **`requirements.txt` befüllen** — `streamlit`, `openpyxl`
|
||||
1. **`requirements.txt` befüllen** — `streamlit`, `openpyxl`, `PyMuPDF`
|
||||
2. **`config/settings.ini` befüllen** — Pfade, Standardwerte
|
||||
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern)
|
||||
- Abschnitte: `[paths]` (input_dir, template_dir, output_dir), `[output]` (filename_pattern → `.pdf`)
|
||||
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?)
|
||||
4. **`config/output-fields.json` Mapping definieren** — Welche Input-Felder in das PDF geschrieben werden
|
||||
- JSON-Array mit `source_field` (exakte Spaltenüberschrift aus der Input-Tabelle), `format` (optional, z. B. "dd.mm.yyyy")
|
||||
- Der Platzhalter im PDF ist immer `{source_field}_Field` – kein explizites `target_cell` mehr nötig
|
||||
|
||||
### Phase 2: Core-Module (`src/`)
|
||||
|
||||
@@ -50,62 +62,52 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
||||
- `load_input_schema()`: liest `config/input-fields.json`
|
||||
- `load_output_mapping()`: liest `config/output-fields.json`
|
||||
- `get_available_input_files()`: listet `.xlsx`-Dateien in `input/`
|
||||
- `get_available_templates()`: listet `.xlsx`-Dateien in `template/`
|
||||
7. **`src/parser.py`** — Input-Datei einlesen
|
||||
- `get_available_templates()`: listet `.pdf`-Dateien in `template/`
|
||||
7. **`src/parser.py`** — Input-Datei einlesen (unverändert: openpyxl für XLSX-Input)
|
||||
- `parse_input_file(filepath, schema)`: öffnet Workbook, iteriert über alle Sheets (Monate)
|
||||
- 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)
|
||||
- Rückgabe: `list[EmployeeRecord]`
|
||||
|
||||
### Phase 3: Writer & Validierung
|
||||
### Phase 3: Writer & Validierung (PDF-basiert)
|
||||
|
||||
8. **`src/writer.py`** — Output generieren
|
||||
8. **`src/writer.py`** — PDF-Output generieren (NEU: PyMuPDF)
|
||||
- `generate_activity_reports(employee_records, template_path, mapping, config)`:
|
||||
- **Pre-Scan**: Öffnet das Template-PDF einmal und sucht nach allen `_Field`-Platzhaltern,
|
||||
ermittelt deren Position (bounding box) und Schriftgröße
|
||||
- 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}_{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
|
||||
- Für jeden Mitarbeiter:
|
||||
- Erstellt ein neues PDF
|
||||
- Für jeden Monats-Record: fügt eine Kopie der Template-Seite ein
|
||||
- Ersetzt alle `_Field`-Platzhalter durch die tatsächlichen Daten (redact + insert_text)
|
||||
- Speichert als `{nachname}_{vorname}_Tätigkeitsnachweis.pdf` in `output/`
|
||||
9. **`src/validator.py`** — Eingabevalidierung (angepasst für PDF)
|
||||
- `validate_input_file(filepath, schema)`: unverändert (prüft XLSX-Input)
|
||||
- `validate_template(template_path, mapping)`: prüft ob alle `_Field`-Platzhalter im PDF gefunden werden
|
||||
|
||||
### Phase 4: Streamlit UI
|
||||
|
||||
10. **`app.py`** — Streamlit-Oberfläche
|
||||
10. **`app.py`** — Streamlit-Oberfläche (angepasst für PDF-Templates)
|
||||
- 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
|
||||
- `st.selectbox` für PDF-Template (aus `template/`-Folder)
|
||||
- **Sofort bei Auswahl**: Validierung des PDF-Templates (Prüfung aller `_Field`-Marker)
|
||||
- Anzeige: Anzahl gefundener Platzhalter-Felder
|
||||
- **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. 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`
|
||||
2. Generiere PDFs nur für selektierte Mitarbeiter und speichere in `output/`
|
||||
3. Zeige Erfolgsmeldung mit Liste der erstellten PDF-Dateien
|
||||
|
||||
### Phase 5: Start-Skript & Doku
|
||||
|
||||
11. **`start.bat`** — Windows Batch-Startdatei
|
||||
- Prüft ob Python verfügbar ist
|
||||
- Installiert Requirements (falls nötig)
|
||||
- Startet `streamlit run app.py`
|
||||
11. **`start.bat`** — Windows Batch-Startdatei (unverändert)
|
||||
12. **`README.md` aktualisieren** — Vollständige Doku
|
||||
- Setup-Anleitung (Python, venv, pip install)
|
||||
- Konfiguration (settings.ini, input-fields.json, output-fields.json)
|
||||
- Bedienung (Datei auswählen, Template wählen, Button klicken)
|
||||
- Erwartetes Input-Format (Spalten, monatliche Sheets)
|
||||
- Output-Beschreibung (Dateiname, Struktur)
|
||||
|
||||
---
|
||||
|
||||
@@ -113,19 +115,18 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
||||
|
||||
| Datei | Aktion |
|
||||
|-------|--------|
|
||||
| `requirements.txt` | Neu befüllen |
|
||||
| `config/settings.ini` | Neu befüllen |
|
||||
| `config/input-fields.json` | Neu befüllen (nach Analyse Input-Datei) |
|
||||
| `config/output-fields.json` | Neu befüllen (nach Analyse Template) |
|
||||
| `src/__init__.py` | Neu (leer) |
|
||||
| `src/models.py` | Neu |
|
||||
| `src/config.py` | Neu |
|
||||
| `src/parser.py` | Neu |
|
||||
| `src/writer.py` | Neu |
|
||||
| `src/validator.py` | Neu |
|
||||
| `app.py` | Neu |
|
||||
| `start.bat` | Neu |
|
||||
| `README.md` | Überschreiben mit vollständiger Doku |
|
||||
| `requirements.txt` | `PyMuPDF` hinzufügen |
|
||||
| `config/settings.ini` | filename_pattern auf `.pdf` ändern |
|
||||
| `config/output-fields.json` | `target_cell` entfernen (wird durch `_Field`-Marker ersetzt) |
|
||||
| `src/writer.py` | Komplett neu: PDF-basierte Generierung mit PyMuPDF |
|
||||
| `src/validator.py` | `validate_template` für PDF anpassen |
|
||||
| `src/config.py` | `get_available_templates` auf `.pdf` umstellen |
|
||||
| `app.py` | Template-Validierung für PDF anpassen |
|
||||
| `coldrun.py` | An PDF-Template anpassen |
|
||||
| `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
|
||||
|
||||
@@ -139,25 +140,45 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
||||
├── config/
|
||||
│ ├── settings.ini ← Einstellungen der App
|
||||
│ ├── input-fields.json ← Schema der Inputdaten-Tabelle
|
||||
│ └── output-fields.json ← Schema der Outputdaten-Tabelle
|
||||
├── output/ ← Zielordner für generierte Dateien
|
||||
│ └── output-fields.json ← Mapping: Input-Feld → PDF-Platzhalter
|
||||
├── output/ ← Zielordner für generierte PDF-Dateien
|
||||
├── template/
|
||||
│ └── *.pdf ← PDF-Template mit _Field-Platzhaltern
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── models.py
|
||||
│ ├── config.py
|
||||
│ ├── parser.py
|
||||
│ ├── writer.py
|
||||
│ ├── writer.py ← PDF-basierte Generierung (PyMuPDF)
|
||||
│ └── validator.py
|
||||
├── app.py ← Streamlit-App
|
||||
├── coldrun.py ← CLI Cold-Run
|
||||
├── start.bat ← Windows batch zum Start der App
|
||||
├── README.md ← README für Programmbenutzer
|
||||
└── requirements.txt ← streamlit, openpyxl
|
||||
├── 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, 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
|
||||
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
|
||||
2. **PDF-Template-Analyse**: `Tätigkeitsnachweise Stellenförderung.pdf` mit PyMuPDF öffnen, alle `_Field`-Platzhalter suchen → mit `output-fields.json` abgleichen
|
||||
3. **Unit-Test**: App mit vorhandener Input-Datei und PDF-Template ausführen, prüfen ob Output-PDFs korrekt befüllt sind
|
||||
4. **Edge Cases**: Leere Input-Zeilen, fehlende Monate, Mitarbeiter ohne Vorname, Sonderzeichen in Namen, Umlaute im PDF
|
||||
```
|
||||
@@ -1,27 +1 @@
|
||||
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App)
|
||||
|
||||
## 🔍 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 ✅
|
||||
- [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 ✅
|
||||
- [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 ✅
|
||||
- [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 ✅
|
||||
- [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
|
||||
+62
-7
@@ -1,8 +1,63 @@
|
||||
input/
|
||||
output/
|
||||
.venv/
|
||||
__pycache__/
|
||||
### macOS
|
||||
# Finder metadata
|
||||
.DS_Store
|
||||
.~lock.*#
|
||||
*.xlsx#
|
||||
~*.xlsx
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Custom folder icons
|
||||
Icon␍
|
||||
|
||||
# Volume root files
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
### Windows
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
### Linux
|
||||
# Backup files
|
||||
*~
|
||||
|
||||
# Temporary files from deleted open files
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder
|
||||
.Trash-*
|
||||
|
||||
# NFS temporary files
|
||||
.nfs*
|
||||
|
||||
### VS Code
|
||||
# VSCode settings (keep shared configuration)
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
3_Archiv
|
||||
2_build
|
||||
@@ -0,0 +1,9 @@
|
||||
input/
|
||||
output/
|
||||
.venv/
|
||||
__pycache__/
|
||||
.DS_Store
|
||||
.~lock.*#
|
||||
*.xlsx#
|
||||
~*.xlsx
|
||||
/versions
|
||||
@@ -0,0 +1,140 @@
|
||||
# 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.3] – 2026-08-11
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Monatsauswahl pro Mitarbeiter**: Tätigkeitsnachweise können nun für individuell gewählte Monate erzeugt werden.
|
||||
- Die Monatsauswahl erscheint rechts neben der Mitarbeiterliste für die zuletzt angeklickte Person.
|
||||
- Die Auswahl wird in Kurzform direkt hinter dem Namen angezeigt, z. B. `(Jan, Mär, Dez)`.
|
||||
- Standardmäßig sind die letzten zwei vorhandenen Monate ausgewählt.
|
||||
- Schaltflächen zum Auswählen bzw. Abwählen aller Monate ergänzen die Monatsliste.
|
||||
|
||||
- **Versionsprüfung**: Die App prüft beim Start im zentralen SMB-Verzeichnis auf neuere
|
||||
`Tätigkeitsnachweis-Generator vX.Y.Z.zip`- und künftig auch `.exe`-Dateien.
|
||||
|
||||
- **Versionsanzeige**: Die aktuell installierte Programmversion wird dezent am unteren rechten Rand der App angezeigt.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Mitarbeiterauswahl**: Mitarbeiter sind nach dem Einlesen einer Input-Datei nicht mehr standardmäßig ausgewählt.
|
||||
Die Buttons zum Auswählen bzw. Abwählen aller Mitarbeiter befinden sich direkt über der Mitarbeiterliste.
|
||||
|
||||
- **Update-Hinweis**: Bei einer neueren zentralen Version wird ein schwebender Hinweis oben rechts angezeigt,
|
||||
einschließlich Schließen- und Ordner-Schaltfläche.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.1] – 2026-06-30
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Build-Pipeline**: fix broken server start and page call
|
||||
|
||||
---
|
||||
|
||||
## [1.2] – 2026-06-30
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Build-Pipeline** (`1_build-pipeline/`): Skripte zum Erstellen einer standalone `.exe`:
|
||||
- `build.bat` / `build.py`: Orchestrierter Build mit **Semantic Versioning** – liest aktuelle Version
|
||||
aus `CHANGELOG.md`, bump't nach Wahl (major/minor/patch) und aktualisiert das Changelog automatisch
|
||||
- `build.spec`: PyInstaller-Spec-Datei mit allen Hidden Imports für Streamlit, openpyxl und PyMuPDF
|
||||
- `run.py`: Einstiegspunkt für die gebundelte `.exe` – setzt Pfade via `APP_BASE_DIR`,
|
||||
startet den Streamlit-Server und öffnet den Browser
|
||||
- Ausgabe nach `2_build/` mit fertiger Ordnerstruktur (`config/`, `input/`, `template/`, `output/`)
|
||||
|
||||
- **`app.py`** unterstützt jetzt `APP_BASE_DIR`-Umgebungsvariable für Pfadauflösung im gebundelten Modus
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Statische Schriftgröße** (`src/writer.py`): Schriftgröße wird nicht mehr dynamisch aus dem PDF-Template extrahiert,
|
||||
sondern statisch aus `settings.ini` (`[font]` → `fontsize`) gelesen, einheitlich für alle Felder.
|
||||
`AppConfig` um `fontsize` erweitert, `config.py` lädt den Wert.
|
||||
|
||||
- **Autoskalierung entfernt** (`src/writer.py`): Die automatische Schriftverkleinerung für lange Texte
|
||||
(`Qualifikation_Field`, `Funktion_Field`) wurde ersatzlos gestrichen – alle Texte haben exakt dieselbe Größe.
|
||||
|
||||
- **Vertikale Textzentrierung** (`src/writer.py`): Einzeilige Felder werden jetzt vertikal mittig im
|
||||
Marker-Rechteck platziert. Mehrzeilige Felder (`Tätigkeiten_Field`, `Dienstreisen_Field`) bleiben
|
||||
oben ausgerichtet.
|
||||
|
||||
- **Doppelte Felder unterstützt** (`src/writer.py`): `Datum_Field` und andere Marker, die mehrfach im
|
||||
PDF-Template vorkommen, werden jetzt alle mit demselben Wert befüllt.
|
||||
|
||||
- **Stellenanteil-Formatierung** (`src/writer.py`): `Stellenanteil in %` wird mit deutschem Dezimalkomma
|
||||
und zwei Nachkommastellen ausgegeben (z. B. `75` → `75,00`).
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
File renamed without changes.
@@ -0,0 +1,57 @@
|
||||
# Tätigkeitsnachweis-Generator
|
||||
|
||||
Programm zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
|
||||
Die aktuellesten Programmversion find sich immer hier:
|
||||
"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren\"
|
||||
|
||||
Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
|
||||
|
||||
## 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 ist keiner ausgewählt
|
||||
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
|
||||
4. **Mitarbeitermonate auswählen** – Multiselect-Liste aller verfügbaren Monate im Dokument. Vorselektiert sind die aktuell letzten beiden Monate.
|
||||
5. **"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`
|
||||
+198
-24
@@ -8,7 +8,14 @@ from pathlib import Path
|
||||
import streamlit as st
|
||||
|
||||
# Projekt-Root zum Python-Path hinzufügen
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
# Im gebundelten Modus (PyInstaller) zeigt APP_BASE_DIR auf das Verzeichnis der .exe,
|
||||
# wo config/, input/, template/, output/ liegen. Im Entwicklungsmodus bleibt es None
|
||||
# und PROJECT_ROOT wird aus __file__ abgeleitet.
|
||||
_BASE_DIR = os.environ.get("APP_BASE_DIR")
|
||||
if _BASE_DIR:
|
||||
PROJECT_ROOT = Path(_BASE_DIR).resolve()
|
||||
else:
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.config import (
|
||||
@@ -22,6 +29,27 @@ 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
|
||||
from version_check import UPDATE_DIR, get_latest_version
|
||||
|
||||
|
||||
def set_active_employee(employee_name: str):
|
||||
"""Merkt die zuletzt angeklickte Person für die Monatsliste."""
|
||||
st.session_state.active_month_employee = employee_name
|
||||
|
||||
|
||||
def sync_employee_months(employee_name: str, months: list[str]):
|
||||
"""Übernimmt Monats-Checkboxen vor dem nächsten UI-Rendern."""
|
||||
st.session_state.employee_months[employee_name] = {
|
||||
month for month in months
|
||||
if st.session_state.get(f"month_{employee_name}_{month}", False)
|
||||
}
|
||||
|
||||
|
||||
def set_all_employee_months(employee_name: str, months: list[str], selected: bool):
|
||||
"""Setzt alle Monats-Checkboxen einer Person gesammelt."""
|
||||
st.session_state.employee_months[employee_name] = set(months) if selected else set()
|
||||
for month in months:
|
||||
st.session_state[f"month_{employee_name}_{month}"] = selected
|
||||
|
||||
# --- Seite konfigurieren ---
|
||||
st.set_page_config(
|
||||
@@ -49,6 +77,60 @@ except Exception as e:
|
||||
st.error(f"Fehler beim Laden der Konfiguration: {e}")
|
||||
st.stop()
|
||||
|
||||
# --- Versionsinformation für die dezente Anzeige am Seitenende ---
|
||||
current_version_text = ""
|
||||
try:
|
||||
local_version, latest_version = get_latest_version()
|
||||
current_version_text = f"v{'.'.join(map(str, local_version))}"
|
||||
if latest_version:
|
||||
available_version, _ = latest_version
|
||||
version_text = ".".join(map(str, available_version))
|
||||
st.html(
|
||||
f'''<style>
|
||||
.update-notice {{
|
||||
position: fixed; top: 4rem; right: 1rem; z-index: 10000;
|
||||
width: 290px; padding: 8px 12px; color: #5f4700;
|
||||
background: #fff8e1; border: 1px solid #e0b84c; border-radius: 8px;
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, .18); font-size: .85rem;
|
||||
}}
|
||||
#update-notice-dismiss {{ display:none; }}
|
||||
#update-notice-dismiss:checked + .update-notice {{ display:none; }}
|
||||
body:has(#update-notice-dismiss:checked) div.st-key-update_notice_open {{ display:none!important; }}
|
||||
.update-notice__actions {{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }}
|
||||
.update-notice__actions--footer {{ min-height:26px; }}
|
||||
.update-notice__button {{
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
width:26px; height:26px; color:#5f4700; background:#fffdf3;
|
||||
border:1px solid #d9bd66; border-radius:5px; text-decoration:none;
|
||||
font-size:1rem; line-height:1;
|
||||
}}
|
||||
.update-notice__button:hover {{ background:#f6e7b2; }}
|
||||
div.st-key-update_notice_open {{
|
||||
position:fixed; top:8.5rem; right:3.75rem; z-index:10001; width:26px;
|
||||
}}
|
||||
div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }}
|
||||
div.st-key-update_notice_open div[data-testid="stButton"] button {{
|
||||
min-width:200px; min-height:26px; height:26px; padding:0; color:#5f4700;
|
||||
background:#fffdf3; border:1px solid #d9bd66; border-radius:5px;
|
||||
}}
|
||||
</style>
|
||||
<input id="update-notice-dismiss" type="checkbox">
|
||||
<aside class="update-notice" role="status">
|
||||
<div class="update-notice__actions">
|
||||
<label class="update-notice__button" for="update-notice-dismiss" title="Hinweis schließen">×</label>
|
||||
</div>
|
||||
<strong>Neue Version v{version_text} verfügbar</strong>
|
||||
<div class="update-notice__actions update-notice__actions--footer">
|
||||
</div>
|
||||
</aside>'''
|
||||
)
|
||||
with st.container(key="update_notice_open"):
|
||||
if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"):
|
||||
if sys.platform == "win32":
|
||||
os.startfile(str(UPDATE_DIR))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Session State initialisieren ---
|
||||
if "parsed_records" not in st.session_state:
|
||||
st.session_state.parsed_records = []
|
||||
@@ -65,7 +147,8 @@ if "cached_val_result" not in st.session_state:
|
||||
st.session_state.cached_val_result = None
|
||||
if "cached_tpl_result" not in st.session_state:
|
||||
st.session_state.cached_tpl_result = None
|
||||
|
||||
if "employee_months" not in st.session_state:
|
||||
st.session_state.employee_months = {}
|
||||
# --- Layout: zwei Spalten ---
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
@@ -157,7 +240,7 @@ 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.")
|
||||
st.warning("Keine .pdf-Dateien im template/-Ordner gefunden.")
|
||||
selected_template = None
|
||||
else:
|
||||
# Lock-Files herausfiltern
|
||||
@@ -202,9 +285,9 @@ with col2:
|
||||
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")
|
||||
found = tpl_result.info.get("found_fields", 0)
|
||||
total = tpl_result.info.get("total_fields", 0)
|
||||
st.success(f"✅ {found}/{total} Platzhalter-Felder gefunden")
|
||||
if tpl_result.warnings:
|
||||
st.markdown(
|
||||
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
|
||||
@@ -223,24 +306,94 @@ if st.session_state.input_validated and st.session_state.parsed_records:
|
||||
employees = get_unique_employees(st.session_state.parsed_records)
|
||||
st.caption(f"{len(employees)} Mitarbeiter gefunden – wähle aus, für wen Tätigkeitsnachweise erstellt werden sollen:")
|
||||
|
||||
# "Alle" / "Keine" Buttons
|
||||
btn_col1, btn_col2 = st.columns(2)
|
||||
if btn_col1.button("☑ Alle auswählen", use_container_width=True):
|
||||
for emp in employees:
|
||||
st.session_state[f"sel_{emp}"] = True
|
||||
if btn_col2.button("☐ Keine auswählen", use_container_width=True):
|
||||
for emp in employees:
|
||||
st.session_state[f"sel_{emp}"] = False
|
||||
# Monatsbezeichnungen für die kompakte Anzeige hinter dem Namen
|
||||
month_labels = {
|
||||
"01": "Jan", "02": "Feb", "03": "Mär", "04": "Apr",
|
||||
"05": "Mai", "06": "Jun", "07": "Jul", "08": "Aug",
|
||||
"09": "Sep", "10": "Okt", "11": "Nov", "12": "Dez",
|
||||
}
|
||||
|
||||
# Mitarbeiter-Liste mit Rahmen
|
||||
with st.container(border=True):
|
||||
selected_employees = []
|
||||
for emp in employees:
|
||||
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):
|
||||
selected_employees.append(emp)
|
||||
all_groups = group_by_employee(st.session_state.parsed_records)
|
||||
available_months = {}
|
||||
for emp in employees:
|
||||
months = sorted({
|
||||
record.monat.split(".", 1)[0].zfill(2)
|
||||
for record in all_groups.get(emp, [])
|
||||
if record.monat and record.monat.split(".", 1)[0].isdigit()
|
||||
}, key=lambda value: int(value))
|
||||
available_months[emp] = months
|
||||
if emp not in st.session_state.employee_months:
|
||||
st.session_state.employee_months[emp] = set(months[-2:])
|
||||
|
||||
# Mitarbeiter- und Monatsliste teilen sich die verfügbare Breite.
|
||||
employee_col, month_col = st.columns(2)
|
||||
with employee_col:
|
||||
employee_btn_col1, employee_btn_col2 = st.columns(2)
|
||||
if employee_btn_col1.button("☑ Alle auswählen", use_container_width=True):
|
||||
for emp in employees:
|
||||
st.session_state[f"sel_{emp}"] = True
|
||||
if employee_btn_col2.button("☐ Keine auswählen", use_container_width=True):
|
||||
for emp in employees:
|
||||
st.session_state[f"sel_{emp}"] = False
|
||||
|
||||
with st.container(border=True):
|
||||
selected_employees = []
|
||||
for emp in employees:
|
||||
key = f"sel_{emp}"
|
||||
if key not in st.session_state:
|
||||
st.session_state[key] = False
|
||||
selected_months = st.session_state.employee_months.get(emp, set())
|
||||
short_months = ", ".join(month_labels[m] for m in available_months[emp] if m in selected_months)
|
||||
label = f"{emp} ({short_months})" if short_months else f"{emp} (**keine Monate**)"
|
||||
if st.checkbox(
|
||||
label,
|
||||
key=key,
|
||||
on_change=set_active_employee,
|
||||
args=(emp,),
|
||||
):
|
||||
selected_employees.append(emp)
|
||||
|
||||
with month_col:
|
||||
selected_for_months = st.session_state.get("active_month_employee")
|
||||
if selected_for_months not in employees:
|
||||
selected_for_months = None
|
||||
|
||||
month_btn_col1, month_btn_col2 = st.columns(2)
|
||||
month_btn_col1.button(
|
||||
"☑ Alle Monate",
|
||||
use_container_width=True,
|
||||
disabled=not selected_for_months,
|
||||
on_click=set_all_employee_months,
|
||||
args=(selected_for_months, available_months[selected_for_months], True) if selected_for_months else ("", [], True),
|
||||
)
|
||||
month_btn_col2.button(
|
||||
"☐ Keine Monate",
|
||||
use_container_width=True,
|
||||
disabled=not selected_for_months,
|
||||
on_click=set_all_employee_months,
|
||||
args=(selected_for_months, available_months[selected_for_months], False) if selected_for_months else ("", [], False),
|
||||
)
|
||||
|
||||
with st.container(border=True):
|
||||
st.caption("Monate für den angeklickten Mitarbeiter")
|
||||
if selected_for_months:
|
||||
st.write(selected_for_months)
|
||||
month_values = {}
|
||||
for month in available_months[selected_for_months]:
|
||||
month_key = f"month_{selected_for_months}_{month}"
|
||||
if month_key not in st.session_state:
|
||||
st.session_state[month_key] = month in st.session_state.employee_months[selected_for_months]
|
||||
month_values[month] = st.checkbox(
|
||||
f"{month_labels[month]} ({month})",
|
||||
key=month_key,
|
||||
on_change=sync_employee_months,
|
||||
args=(selected_for_months, available_months[selected_for_months]),
|
||||
)
|
||||
st.session_state.employee_months[selected_for_months] = {
|
||||
month for month, checked in month_values.items() if checked
|
||||
}
|
||||
else:
|
||||
st.info("Einen Mitarbeiter links auswählen, um seine Monate festzulegen.")
|
||||
|
||||
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
|
||||
else:
|
||||
@@ -276,7 +429,20 @@ if generate_clicked:
|
||||
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}
|
||||
selected_groups = {
|
||||
name: [
|
||||
record for record in all_groups[name]
|
||||
if record.monat.split(".", 1)[0].zfill(2)
|
||||
in st.session_state.employee_months.get(name, set())
|
||||
]
|
||||
for name in selected_employees
|
||||
if name in all_groups
|
||||
}
|
||||
selected_groups = {name: records for name, records in selected_groups.items() if records}
|
||||
|
||||
if not selected_groups:
|
||||
st.error("Für die ausgewählten Mitarbeiter sind keine Monate ausgewählt.")
|
||||
st.stop()
|
||||
|
||||
template_path = Path(app_config.template_dir) / selected_template
|
||||
output_dir = Path(app_config.output_dir)
|
||||
@@ -289,6 +455,7 @@ if generate_clicked:
|
||||
mapping=app_config.output_mapping,
|
||||
output_dir=output_dir,
|
||||
filename_pattern=app_config.filename_pattern,
|
||||
fontsize=app_config.fontsize,
|
||||
)
|
||||
st.session_state.generated_files = generated
|
||||
except Exception as e:
|
||||
@@ -320,3 +487,10 @@ if "generated_files" in st.session_state and st.session_state.generated_files:
|
||||
help="Ordner im Finder öffnen",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
if current_version_text:
|
||||
st.markdown(
|
||||
f'<div style="text-align:right; color:#888; font-size:0.8em; margin-top:2em;">'
|
||||
f'Programmversion {current_version_text}</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
@@ -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
|
||||
Executable → Regular
File renamed without changes.
@@ -22,6 +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_input_file, validate_template
|
||||
from src.writer import generate_activity_reports
|
||||
|
||||
|
||||
@@ -50,13 +51,37 @@ def main():
|
||||
# ── Erstes Template ermitteln ─────────────────────────────────────
|
||||
template_files = get_available_templates(config)
|
||||
if not template_files:
|
||||
print("[FEHLER] Keine .xlsx-Dateien im template/-Ordner gefunden.")
|
||||
print("[FEHLER] Keine .pdf-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}")
|
||||
|
||||
# ── Template validieren ───────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Validiere PDF-Template…")
|
||||
tpl_result = validate_template(template_path, config.output_mapping)
|
||||
if not tpl_result.is_valid:
|
||||
for err in tpl_result.errors:
|
||||
print(f"[FEHLER] {err}")
|
||||
return 1
|
||||
found = tpl_result.info.get("found_fields", "?")
|
||||
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…")
|
||||
@@ -88,6 +113,7 @@ def main():
|
||||
mapping=config.output_mapping,
|
||||
output_dir=output_dir,
|
||||
filename_pattern=config.filename_pattern,
|
||||
fontsize=config.fontsize,
|
||||
)
|
||||
|
||||
output_file = generated[0]
|
||||
File renamed without changes.
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{"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": "Datum", "pdf_field": "Datum_Field", "format": null}
|
||||
]
|
||||
@@ -4,7 +4,11 @@ template_dir = template
|
||||
output_dir = output
|
||||
|
||||
[output]
|
||||
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.xlsx
|
||||
filename_pattern = {last_name}_{first_name}_Tätigkeitsnachweis.pdf
|
||||
|
||||
[font]
|
||||
# Statische Schriftgröße (in Punkt) für alle Felder im PDF
|
||||
fontsize = 9
|
||||
|
||||
[parsing]
|
||||
# Sheets whose names match these patterns are treated as monthly data sheets
|
||||
@@ -1,2 +1,3 @@
|
||||
streamlit>=1.28,<2.0
|
||||
openpyxl>=3.1,<4.0
|
||||
PyMuPDF>=1.23,<2.0
|
||||
File renamed without changes.
@@ -21,6 +21,7 @@ def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
|
||||
paths = parser["paths"] if "paths" in parser else {}
|
||||
output = parser["output"] if "output" in parser else {}
|
||||
parsing = parser["parsing"] if "parsing" in parser else {}
|
||||
font = parser["font"] if "font" in parser else {}
|
||||
|
||||
return AppConfig(
|
||||
input_dir=str(project_root / paths.get("input_dir", "input")),
|
||||
@@ -30,6 +31,7 @@ def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
|
||||
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),
|
||||
fontsize=font.getfloat("fontsize", 11.0),
|
||||
)
|
||||
|
||||
|
||||
@@ -74,8 +76,8 @@ def get_available_input_files(config: AppConfig) -> list[str]:
|
||||
|
||||
|
||||
def get_available_templates(config: AppConfig) -> list[str]:
|
||||
"""Listet alle .xlsx-Dateien im template-Ordner."""
|
||||
return get_available_files(Path(config.template_dir))
|
||||
"""Listet alle .pdf-Dateien im template-Ordner."""
|
||||
return get_available_files(Path(config.template_dir), extension=".pdf")
|
||||
|
||||
|
||||
def is_month_sheet(sheet_name: str, pattern: str) -> bool:
|
||||
@@ -47,8 +47,8 @@ class FieldSchema:
|
||||
class OutputMapping:
|
||||
"""Definition eines Mappings aus output-fields.json."""
|
||||
source_field: str
|
||||
target_cell: str
|
||||
format: Optional[str] = None
|
||||
pdf_field: Optional[str] = None # Name des _Field-Platzhalters im PDF (z.B. "Auftragsnummer_Field")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -63,6 +63,7 @@ class AppConfig:
|
||||
month_sheet_pattern: str = "* 20??"
|
||||
skip_empty_name: bool = True
|
||||
skip_empty_antragsnummer: bool = True
|
||||
fontsize: float = 11.0
|
||||
|
||||
|
||||
@dataclass
|
||||
File renamed without changes.
@@ -3,6 +3,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import fitz # PyMuPDF
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
@@ -80,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.")
|
||||
|
||||
@@ -96,9 +140,9 @@ def validate_template(
|
||||
mapping: list[OutputMapping],
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Validiert ein Template:
|
||||
Validiert ein PDF-Template:
|
||||
- Datei existiert
|
||||
- Alle target_cells aus dem Mapping sind im Template vorhanden
|
||||
- Alle _Field-Platzhalter (pdf_field) sind im PDF auf irgendeiner Seite vorhanden
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
@@ -109,37 +153,51 @@ def validate_template(
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
try:
|
||||
wb = load_workbook(template_path, data_only=True)
|
||||
doc = fitz.open(template_path)
|
||||
except Exception as e:
|
||||
errors.append(f"Template konnte nicht geöffnet werden: {e}")
|
||||
errors.append(f"PDF-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()
|
||||
if doc.page_count == 0:
|
||||
errors.append("PDF-Template enthält keine Seiten.")
|
||||
doc.close()
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
found_count = 0
|
||||
missing_fields = []
|
||||
|
||||
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)
|
||||
if m.pdf_field is None:
|
||||
continue # Kein Marker erwartet
|
||||
|
||||
info["found_target_cells"] = found_cells
|
||||
info["total_target_cells"] = len(mapping)
|
||||
# Suche auf allen Seiten
|
||||
found_any = False
|
||||
for page_num in range(doc.page_count):
|
||||
found = doc[page_num].search_for(m.pdf_field)
|
||||
if found:
|
||||
found_any = True
|
||||
break
|
||||
|
||||
if missing_cells:
|
||||
errors.append(f"Zielfelder nicht im Template gefunden: {', '.join(missing_cells)}")
|
||||
if found_any:
|
||||
found_count += 1
|
||||
else:
|
||||
missing_fields.append(m.source_field)
|
||||
|
||||
# Zähle unique pdf_fields (nicht die source_fields, da Tätigkeit doppelt vorkommt)
|
||||
unique_pdf_fields = set(m.pdf_field for m in mapping if m.pdf_field is not None)
|
||||
total_pages = doc.page_count
|
||||
|
||||
doc.close()
|
||||
|
||||
info["found_fields"] = found_count
|
||||
info["total_fields"] = len(unique_pdf_fields)
|
||||
info["page_count"] = total_pages
|
||||
|
||||
if missing_fields:
|
||||
errors.append(
|
||||
f"Platzhalter nicht im PDF-Template gefunden: {', '.join(missing_fields)}"
|
||||
)
|
||||
|
||||
wb.close()
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
@@ -0,0 +1,289 @@
|
||||
"""PDF-basierte Activity-Report-Generierung.
|
||||
|
||||
Verwendet ein mehrseitiges PDF-Template mit _Field-Platzhaltern.
|
||||
Für jeden Mitarbeiter wird ein PDF erzeugt (alle Template-Seiten pro Monat).
|
||||
Die _Field-Marker werden per PyMuPDF (fitz) gesucht und durch echte Daten ersetzt.
|
||||
"""
|
||||
|
||||
import calendar
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
from .models import EmployeeRecord, OutputMapping
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Namens-Helfer
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
if "%" in fmt:
|
||||
try:
|
||||
return fmt % float(value)
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _format_month_for_template(monat_str: str) -> str:
|
||||
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Daten-Extraktion aus EmployeeRecord
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: str) -> str:
|
||||
"""Liefert den formatierten Wert für ein Feld im PDF."""
|
||||
if source_field == "Monat (MM.JJ)":
|
||||
return _format_month_for_template(record.monat)
|
||||
elif source_field == "Stellenanteil in %":
|
||||
try:
|
||||
value = int(float(str(record.stellenanteil).strip()))
|
||||
return f"{value},00"
|
||||
except (ValueError, TypeError):
|
||||
return str(record.stellenanteil)
|
||||
elif source_field == "Name":
|
||||
return employee_name
|
||||
elif source_field == "Antragsnummer":
|
||||
return record.antragsnummer
|
||||
elif source_field == "Qualifikation":
|
||||
return record.qualifikation
|
||||
elif source_field == "Funktion":
|
||||
return record.funktion
|
||||
elif source_field == "Tätigkeit":
|
||||
return record.taetigkeit
|
||||
elif source_field in ("Ort", "Ort 2"):
|
||||
return "Dresden"
|
||||
elif source_field == "Datum":
|
||||
parts = record.monat.split(".")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
month = int(parts[0])
|
||||
year = int(parts[1])
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
return f"{last_day:02d}.{month:02d}.{year}"
|
||||
except (ValueError, IndexError):
|
||||
return ""
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PDF-Template Pre-Scan: _Field-Positionen auf ALLEN Seiten ermitteln
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _scan_template_fields(
|
||||
template_path: Path,
|
||||
mapping: list[OutputMapping],
|
||||
fontsize: float,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""
|
||||
Scannt ALLE Seiten des PDF-Templates nach _Field-Platzhaltern.
|
||||
Rückgabe: dict[pdf_field] → Liste von {source_field, page_num, rect, fontsize, fontname, ...}
|
||||
Felder mit pdf_field=None werden übersprungen.
|
||||
"""
|
||||
doc = fitz.open(template_path)
|
||||
|
||||
# Sammle alle pdf_field → source_field Zuordnungen
|
||||
pdf_to_source: dict[str, str] = {}
|
||||
for m in mapping:
|
||||
if m.pdf_field is not None:
|
||||
pdf_to_source[m.pdf_field] = m.source_field
|
||||
|
||||
field_info: dict[str, list[dict]] = {}
|
||||
|
||||
# Alle Vorkommen aller PDF-Feld-Marker finden (alle Seiten)
|
||||
for pdf_field, source_field in pdf_to_source.items():
|
||||
occurrences: list[dict] = []
|
||||
for page_num in range(doc.page_count):
|
||||
page = doc[page_num]
|
||||
found = page.search_for(pdf_field)
|
||||
for rect in found:
|
||||
occurrences.append({
|
||||
"source_field": source_field,
|
||||
"page_num": page_num,
|
||||
"rect": rect,
|
||||
"fontsize": fontsize,
|
||||
"fontname": "helv",
|
||||
})
|
||||
field_info[pdf_field] = occurrences
|
||||
|
||||
doc.close()
|
||||
return field_info
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Template-Seiten mit Daten befüllen
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _fill_pdf_pages(
|
||||
pages: list[fitz.Page], # eine Page pro Template-Seite
|
||||
record: EmployeeRecord,
|
||||
employee_name: str,
|
||||
mapping: list[OutputMapping],
|
||||
field_info: dict[str, list[dict]],
|
||||
fontsize: float,
|
||||
) -> 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
|
||||
|
||||
for m in mapping:
|
||||
if m.pdf_field is None:
|
||||
continue
|
||||
|
||||
occurrences = field_info.get(m.pdf_field, [])
|
||||
if not occurrences:
|
||||
continue # Feld nicht im Template gefunden
|
||||
|
||||
value = _get_field_value(record, employee_name, m.source_field)
|
||||
formatted = _format_value(value, m.format)
|
||||
|
||||
# Jedes Vorkommen des Feldes im PDF befüllen
|
||||
for info in occurrences:
|
||||
page_num = info["page_num"]
|
||||
if page_num >= len(pages):
|
||||
continue
|
||||
|
||||
page = pages[page_num]
|
||||
rect = info["rect"]
|
||||
|
||||
# Platzhalter redacten
|
||||
page.add_redact_annot(rect)
|
||||
page.apply_redactions()
|
||||
|
||||
if not formatted:
|
||||
continue
|
||||
|
||||
# Mehrzeilige Felder: Zeilenweise per insert_text schreiben
|
||||
if m.pdf_field in MULTILINE_FIELDS:
|
||||
# ... (unverändert)
|
||||
clean_text = "\n".join(
|
||||
line for line in formatted.split("\n") if line.strip()
|
||||
)
|
||||
if not clean_text:
|
||||
continue
|
||||
|
||||
lines = clean_text.split("\n")
|
||||
line_spacing = fontsize * 1.2
|
||||
|
||||
output_lines: list[str] = []
|
||||
for line in lines:
|
||||
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:
|
||||
output_lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
output_lines.append(current_line)
|
||||
|
||||
y = rect.y0 + fontsize
|
||||
for current_line in output_lines:
|
||||
page.insert_text(
|
||||
(rect.x0, y), current_line,
|
||||
fontname="helv", fontsize=fontsize,
|
||||
)
|
||||
y += line_spacing
|
||||
else:
|
||||
# Einzeilige Felder: vertikal zentriert platzieren
|
||||
mid_y = (rect.y0 + rect.y1) / 2
|
||||
baseline_y = mid_y + fontsize * 0.35
|
||||
page.insert_text(
|
||||
(rect.x0, baseline_y),
|
||||
formatted,
|
||||
fontname="helv",
|
||||
fontsize=fontsize,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Hauptfunktion: Reports generieren
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def generate_activity_reports(
|
||||
employee_records: dict[str, list[EmployeeRecord]],
|
||||
template_path: Path,
|
||||
mapping: list[OutputMapping],
|
||||
output_dir: Path,
|
||||
filename_pattern: str,
|
||||
fontsize: float = 11.0,
|
||||
) -> list[Path]:
|
||||
"""
|
||||
Erzeugt für jeden Mitarbeiter ein PDF.
|
||||
Pro Monat werden ALLE Template-Seiten kopiert und mit Daten befüllt.
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
generated_files: list[Path] = []
|
||||
|
||||
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
|
||||
field_info = _scan_template_fields(template_path, mapping, fontsize)
|
||||
|
||||
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
|
||||
|
||||
sorted_records = sorted(records, key=lambda r: r.month_index)
|
||||
|
||||
# Ergebnis-PDF erstellen
|
||||
result_doc = fitz.open()
|
||||
|
||||
for record in sorted_records:
|
||||
# Template frisch öffnen für jeden Monat
|
||||
temp_doc = fitz.open(template_path)
|
||||
pages = [temp_doc[i] for i in range(temp_doc.page_count)]
|
||||
|
||||
_fill_pdf_pages(pages, record, employee_name, mapping, field_info, fontsize)
|
||||
|
||||
# Alle modifizierten Seiten ins Ergebnis-PDF einfügen
|
||||
result_doc.insert_pdf(temp_doc)
|
||||
temp_doc.close()
|
||||
|
||||
result_doc.save(output_path)
|
||||
result_doc.close()
|
||||
generated_files.append(output_path)
|
||||
|
||||
return generated_files
|
||||
File renamed without changes.
Executable → Regular
File renamed without changes.
Executable → Regular
File renamed without changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable → Regular
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,10 +1,10 @@
|
||||
"""End-to-End-Test: Reale Input-Datei + reales Template → Excel-Output für erste Person."""
|
||||
"""End-to-End-Test: Reale Input-Datei + reales PDF-Template → PDF-Output für erste Person."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# Projekt-Root zum Python-Path hinzufügen
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -21,7 +21,7 @@ from src.writer import generate_activity_reports
|
||||
|
||||
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||
INPUT_FILE = PROJECT_ROOT / "input" / "2025 JM.xlsx"
|
||||
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.xlsx"
|
||||
TEMPLATE_FILE = PROJECT_ROOT / "template" / "Tätigkeitsnachweise Stellenförderung.pdf"
|
||||
|
||||
|
||||
def _load_app_config():
|
||||
@@ -62,20 +62,62 @@ def output_dir(tmp_path):
|
||||
return out
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Hilfsfunktion: Text aus PDF extrahieren
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _get_pdf_text(pdf_path: Path) -> str:
|
||||
"""Extrahiert den gesamten Text aus einem PDF."""
|
||||
doc = fitz.open(pdf_path)
|
||||
text = ""
|
||||
for page in doc:
|
||||
text += page.get_text()
|
||||
doc.close()
|
||||
return text
|
||||
|
||||
|
||||
def _get_page_text(pdf_path: Path, page_num: int = 0) -> str:
|
||||
"""Extrahiert Text von einer bestimmten PDF-Seite."""
|
||||
doc = fitz.open(pdf_path)
|
||||
text = doc[page_num].get_text() if page_num < doc.page_count else ""
|
||||
doc.close()
|
||||
return text
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# End-to-End Tests
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class TestEndToEndFirstPerson:
|
||||
"""End-to-End: Reale Input-Datei + reales Template → nur erste Person."""
|
||||
"""End-to-End: Reale Input-Datei + reales PDF-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}"
|
||||
"""Stellt sicher, dass das reale PDF-Template vorhanden ist."""
|
||||
assert TEMPLATE_FILE.exists(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
|
||||
|
||||
def test_template_has_field_markers(self, app_config):
|
||||
"""Stellt sicher, dass alle _Field-Platzhalter im PDF-Template vorhanden sind."""
|
||||
doc = fitz.open(TEMPLATE_FILE)
|
||||
missing = []
|
||||
for m in app_config.output_mapping:
|
||||
if m.pdf_field is None:
|
||||
continue
|
||||
# Suche auf allen Seiten
|
||||
found_any = False
|
||||
for page_num in range(doc.page_count):
|
||||
if doc[page_num].search_for(m.pdf_field):
|
||||
found_any = True
|
||||
break
|
||||
if not found_any:
|
||||
missing.append(m.pdf_field)
|
||||
doc.close()
|
||||
assert len(missing) == 0, (
|
||||
f"Folgende _Field-Platzhalter fehlen im PDF: {missing}"
|
||||
)
|
||||
|
||||
def test_parse_real_input(self, parsed_records):
|
||||
"""Validiert das Parsen der realen Input-Datei."""
|
||||
@@ -128,86 +170,51 @@ class TestEndToEndFirstPerson:
|
||||
)
|
||||
output_file = generated[0]
|
||||
assert output_file.exists(), f"Output-Datei nicht gefunden: {output_file}"
|
||||
assert output_file.suffix == ".xlsx"
|
||||
assert output_file.suffix == ".pdf"
|
||||
|
||||
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.xlsx"
|
||||
erwarteter_name = "Draßdo_Cornelia_Tätigkeitsnachweis.pdf"
|
||||
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)}"
|
||||
# 5. PDF öffnen und Inhalt validieren
|
||||
doc = fitz.open(output_file)
|
||||
assert doc.page_count == 24, (
|
||||
f"Erwartet 24 Seiten (12 Monate × 2 Seiten), aber {doc.page_count}"
|
||||
)
|
||||
|
||||
# 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}"
|
||||
# 6. Prüfe, dass KEINE _Field-Platzhalter mehr im Output sind
|
||||
full_text = ""
|
||||
for page in doc:
|
||||
full_text += page.get_text()
|
||||
assert "_Field" not in full_text, (
|
||||
"Es sind noch _Field-Platzhalter im Output-PDF vorhanden!"
|
||||
)
|
||||
|
||||
# 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}'"
|
||||
)
|
||||
# 7. Prüfe Seite 0 (Januar, Seite 1 – Stammdaten)
|
||||
jan0_text = doc[0].get_text()
|
||||
assert "100740439" in jan0_text, "Antragsnummer fehlt auf Januar-Seite 1"
|
||||
assert "Draßdo, Cornelia" in jan0_text, "Name fehlt auf Januar-Seite 1"
|
||||
assert "50" in jan0_text, "Stellenanteil fehlt auf Januar-Seite 1"
|
||||
assert "Meisterabschluß Küche" in jan0_text, "Qualifikation fehlt auf Januar-Seite 1"
|
||||
assert "01.2025" in jan0_text, "Monat fehlt auf Januar-Seite 1"
|
||||
assert "Anleiterin / Ausbilderin" in jan0_text, "Funktion fehlt auf Januar-Seite 1"
|
||||
assert "Tätigkeiten im Praxisbereich Garten" in jan0_text, "Tätigkeit fehlt auf Januar-Seite 1"
|
||||
|
||||
# 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}'"
|
||||
)
|
||||
# 8. Prüfe Seite 1 (Januar, Seite 2 – Unterschriftsblock mit Ort/Datum)
|
||||
jan1_text = doc[1].get_text()
|
||||
assert "100740439" not in jan1_text, "Antragsnummer sollte nicht auf Seite 2 sein"
|
||||
assert "Dresden" in jan1_text, "Ort 'Dresden' fehlt auf Januar-Seite 2"
|
||||
assert "31.01.2025" in jan1_text, "Datum '31.01.2025' fehlt auf Januar-Seite 2"
|
||||
# Tätigkeit wiederholt auf Seite 2
|
||||
assert "Tätigkeiten im Praxisbereich Garten" in jan1_text, "Tätigkeit fehlt auf Januar-Seite 2"
|
||||
|
||||
# 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}'"
|
||||
)
|
||||
# 9. Prüfe Juli (Seite 12+13) – Stellenanteil wechselt auf 40%
|
||||
jul0_text = doc[12].get_text()
|
||||
assert "40" in jul0_text, "Stellenanteil 40% fehlt auf Juli-Seite 1"
|
||||
assert "07.2025" in jul0_text, "Monat 07.2025 fehlt auf Juli-Seite 1"
|
||||
|
||||
wb.close()
|
||||
jul1_text = doc[13].get_text()
|
||||
assert "31.07.2025" in jul1_text, "Datum 31.07.2025 fehlt auf Juli-Seite 2"
|
||||
|
||||
doc.close()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Prüft auf neuere Programmversionen im zentralen SMB-Verzeichnis."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
UPDATE_DIR = Path(r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren")
|
||||
VERSION_PATTERN = re.compile(r"Tätigkeitsnachweis-Generator v(\d+)\.(\d+)\.(\d+)(?:\.\d+)?\.(zip|exe)$", re.IGNORECASE)
|
||||
CHANGELOG_PATTERN = re.compile(r"^## \[(\d+)\.(\d+)(?:\.(\d+))?\]", re.MULTILINE)
|
||||
|
||||
|
||||
def get_latest_version():
|
||||
match = CHANGELOG_PATTERN.search(Path(__file__).with_name("CHANGELOG.md").read_text(encoding="utf-8"))
|
||||
current = tuple(int(part or 0) for part in match.groups()) if match else (0, 0, 0)
|
||||
try:
|
||||
available = UPDATE_DIR.exists()
|
||||
except OSError:
|
||||
available = False
|
||||
if not available:
|
||||
return current, None
|
||||
newer = []
|
||||
try:
|
||||
paths = UPDATE_DIR.iterdir()
|
||||
for path in paths:
|
||||
found = VERSION_PATTERN.fullmatch(path.name)
|
||||
if found:
|
||||
version = tuple(int(found.group(i)) for i in range(1, 4))
|
||||
if version > current:
|
||||
newer.append((version, path))
|
||||
except OSError:
|
||||
return current, None
|
||||
if newer:
|
||||
return current, max(newer)
|
||||
return current, None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
current, latest = get_latest_version()
|
||||
print(f"[INFO] Lokale Version: v{'.'.join(map(str, current))}")
|
||||
if latest:
|
||||
version, path = latest
|
||||
print(f"[HINWEIS] Neuere Version verfügbar: v{'.'.join(map(str, version))}")
|
||||
print(f"[HINWEIS] Datei: {path}")
|
||||
else:
|
||||
print("[INFO] Keine neuere Version gefunden oder Update-Verzeichnis nicht erreichbar.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
1.3
|
||||
@@ -0,0 +1,46 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
cd /d "%~dp0"
|
||||
cd ..
|
||||
|
||||
echo ============================================================
|
||||
echo Tätigkeitsnachweis-Generator – Build-Pipeline
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
REM ── Virtuelle Umgebung aktivieren ────────────────────────────
|
||||
if exist "0_application\.venv\Scripts\python.exe" (
|
||||
set PYTHON=0_application\.venv\Scripts\python.exe
|
||||
echo [OK] Virtuelle Umgebung gefunden
|
||||
) else if exist ".venv\Scripts\python.exe" (
|
||||
set PYTHON=.venv\Scripts\python.exe
|
||||
echo [OK] Virtuelle Umgebung gefunden
|
||||
) else (
|
||||
echo [WARN] Keine virtuelle Umgebung gefunden, nutze System-Python
|
||||
set PYTHON=python
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
REM ── Parameter durchreichen ──────────────────────────────────
|
||||
set ARGS=%*
|
||||
if "%ARGS%"=="" (
|
||||
echo Starte interaktiven Build …
|
||||
echo.
|
||||
)
|
||||
|
||||
REM ── Build ausführen ─────────────────────────────────────────
|
||||
%PYTHON% "1_build-pipeline\build.py" %ARGS%
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [FEHLER] Build fehlgeschlagen!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Build abgeschlossen.
|
||||
pause
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Build-Orchestrierung mit Semantic Versioning.
|
||||
|
||||
Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß
|
||||
Benutzereingabe (major/minor/patch), aktualisiert das Changelog
|
||||
und baut via PyInstaller eine standalone .exe nach 2_build/.
|
||||
|
||||
Verwendung:
|
||||
python build.py # interaktiver Modus
|
||||
python build.py --bump minor # nicht-interaktiv
|
||||
python build.py --bump patch --message "Hotfix: PDF-Schriftgröße korrigiert"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# ── Pfade ───────────────────────────────────────────────────────
|
||||
BUILD_PIPELINE_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = BUILD_PIPELINE_DIR.parent
|
||||
APP_DIR = PROJECT_ROOT / "0_application"
|
||||
BUILD_OUTPUT_DIR = PROJECT_ROOT / "2_build"
|
||||
CHANGELOG_PATH = APP_DIR / "CHANGELOG.md"
|
||||
SPEC_PATH = BUILD_PIPELINE_DIR / "build.spec"
|
||||
VERSION_FILE = BUILD_PIPELINE_DIR / ".last_version"
|
||||
|
||||
# ── Versions-Helfer ─────────────────────────────────────────────
|
||||
|
||||
def _read_changelog() -> str:
|
||||
"""Liest das gesamte CHANGELOG.md."""
|
||||
if not CHANGELOG_PATH.exists():
|
||||
return ""
|
||||
return CHANGELOG_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _write_changelog(content: str) -> None:
|
||||
"""Schreibt CHANGELOG.md."""
|
||||
CHANGELOG_PATH.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def get_current_version() -> Optional[str]:
|
||||
"""Ermittelt die aktuellste Version aus CHANGELOG.md.
|
||||
|
||||
Sucht nach dem ersten Vorkommen von '## [X.Y.Z]' oder '## [X.Y]'.
|
||||
"""
|
||||
content = _read_changelog()
|
||||
if not content:
|
||||
return None
|
||||
match = re.search(r"##\s*\[(\d+\.\d+(?:\.\d+)?)\]", content)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def bump_version(current: str, bump_type: str) -> str:
|
||||
"""Erhöht eine Semantic-Version.
|
||||
|
||||
Args:
|
||||
current: Aktuelle Version (z.B. '1.1' oder '1.1.0')
|
||||
bump_type: 'major', 'minor' oder 'patch'
|
||||
|
||||
Returns:
|
||||
Neue Version als String.
|
||||
"""
|
||||
parts = current.split(".")
|
||||
# Normalisiere auf 3 Teile (MAJOR.MINOR.PATCH)
|
||||
while len(parts) < 3:
|
||||
parts.append("0")
|
||||
major, minor, patch = map(int, parts[:3])
|
||||
|
||||
if bump_type == "major":
|
||||
major += 1
|
||||
minor = 0
|
||||
patch = 0
|
||||
elif bump_type == "minor":
|
||||
minor += 1
|
||||
patch = 0
|
||||
elif bump_type == "patch":
|
||||
patch += 1
|
||||
else:
|
||||
raise ValueError(f"Unbekannter Bump-Typ: {bump_type}")
|
||||
|
||||
# Gib kurzes Format zurück wenn Patch=0
|
||||
if patch == 0:
|
||||
return f"{major}.{minor}"
|
||||
return f"{major}.{minor}.{patch}"
|
||||
|
||||
|
||||
def update_changelog(
|
||||
new_version: str,
|
||||
bump_type: str,
|
||||
message: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Fügt einen neuen Versionseintrag am Anfang des Changelogs ein.
|
||||
|
||||
Args:
|
||||
new_version: Die neue Version (z.B. '1.2')
|
||||
bump_type: Art des Bumps (für die Sektions-Vorlage)
|
||||
message: Optionale Beschreibung der Änderungen.
|
||||
"""
|
||||
today = date.today().isoformat()
|
||||
old_content = _read_changelog()
|
||||
|
||||
# Sektionen-Vorlage je nach Bump-Typ
|
||||
sections = _build_sections(bump_type, message)
|
||||
|
||||
header = (
|
||||
f"## [{new_version}] – {today}\n\n"
|
||||
f"{sections}\n"
|
||||
f"---\n\n"
|
||||
)
|
||||
|
||||
# Nach dem ersten '---' einfügen (nach dem Dateikopf von Keep a Changelog)
|
||||
# oder einfach am Anfang nach der ersten Zeile
|
||||
lines = old_content.splitlines(keepends=True)
|
||||
|
||||
# Finde die erste Version-Header-Zeile (## [X.Y...)
|
||||
insert_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r"##\s*\[\d+\.\d+", line):
|
||||
insert_idx = i
|
||||
break
|
||||
|
||||
if insert_idx is not None:
|
||||
new_content = "".join(lines[:insert_idx]) + header + "".join(lines[insert_idx:])
|
||||
else:
|
||||
# Fallback: nach dem Dateikopf (nach dem ersten '---')
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "---":
|
||||
insert_idx = i + 1
|
||||
break
|
||||
if insert_idx is not None:
|
||||
new_content = "".join(lines[:insert_idx]) + "\n" + header + "".join(lines[insert_idx:])
|
||||
else:
|
||||
new_content = old_content + "\n" + header
|
||||
|
||||
_write_changelog(new_content)
|
||||
print(f" ✓ CHANGELOG.md aktualisiert → [{new_version}]")
|
||||
|
||||
|
||||
def _build_sections(bump_type: str, message: Optional[str]) -> str:
|
||||
"""Baut die Sektionen für den Changelog-Eintrag."""
|
||||
if message:
|
||||
if bump_type == "major":
|
||||
return f"### Hinzugefügt\n\n- {message}\n"
|
||||
elif bump_type == "minor":
|
||||
return f"### Hinzugefügt\n\n- {message}\n"
|
||||
else:
|
||||
return f"### Behoben\n\n- {message}\n"
|
||||
|
||||
# Standard-Vorlagen
|
||||
if bump_type == "major":
|
||||
return (
|
||||
"### Hinzugefügt\n\n"
|
||||
"- \n\n"
|
||||
"### Geändert\n\n"
|
||||
"- \n\n"
|
||||
"### Entfernt\n\n"
|
||||
"- \n"
|
||||
)
|
||||
elif bump_type == "minor":
|
||||
return (
|
||||
"### Hinzugefügt\n\n"
|
||||
"- \n\n"
|
||||
"### Geändert\n\n"
|
||||
"- \n"
|
||||
)
|
||||
else:
|
||||
return (
|
||||
"### Behoben\n\n"
|
||||
"- \n"
|
||||
)
|
||||
|
||||
|
||||
# ── Build ───────────────────────────────────────────────────────
|
||||
|
||||
def _ensure_pyinstaller() -> None:
|
||||
"""Stellt sicher, dass PyInstaller installiert ist."""
|
||||
try:
|
||||
import PyInstaller # noqa: F401
|
||||
except ImportError:
|
||||
print("⚙ PyInstaller wird installiert …")
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "pyinstaller"])
|
||||
print(" ✓ PyInstaller installiert")
|
||||
|
||||
|
||||
def _prepare_build_dir() -> None:
|
||||
"""Bereitet das 2_build/-Verzeichnis vor."""
|
||||
BUILD_OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Erstelle/aktualisiere Unterordner
|
||||
for folder in ["input", "output", "template", "config"]:
|
||||
target = BUILD_OUTPUT_DIR / folder
|
||||
target.mkdir(exist_ok=True)
|
||||
|
||||
# Kopiere config-Dateien, falls noch nicht vorhanden
|
||||
config_src = APP_DIR / "config"
|
||||
config_dst = BUILD_OUTPUT_DIR / "config"
|
||||
if config_src.exists():
|
||||
for f in config_src.iterdir():
|
||||
if f.is_file() and not (config_dst / f.name).exists():
|
||||
shutil.copy2(f, config_dst / f.name)
|
||||
|
||||
# README für input-Ordner
|
||||
input_readme = BUILD_OUTPUT_DIR / "input" / "_DATEIEN_HIER_ABLEGEN_.txt"
|
||||
if not input_readme.exists():
|
||||
input_readme.write_text(
|
||||
"Excel-Dateien (.xlsx) in diesen Ordner legen.\n"
|
||||
"Die App erkennt sie automatisch.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# README für template-Ordner
|
||||
tpl_readme = BUILD_OUTPUT_DIR / "template" / "_DATEIEN_HIER_ABLEGEN_.txt"
|
||||
if not tpl_readme.exists():
|
||||
tpl_readme.write_text(
|
||||
"PDF-Templates (.pdf) mit _Field-Platzhaltern in diesen Ordner legen.\n"
|
||||
"Die App erkennt sie automatisch.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# README.md aus 0_application/ kopieren
|
||||
readme_src = APP_DIR / "README.md"
|
||||
if readme_src.exists():
|
||||
shutil.copy2(readme_src, BUILD_OUTPUT_DIR / "README.md")
|
||||
print(" ✓ README.md nach 2_build/ kopiert")
|
||||
|
||||
print(f" ✓ 2_build/ vorbereitet")
|
||||
|
||||
|
||||
def _run_pyinstaller() -> Path:
|
||||
"""Führt PyInstaller mit der Spec-Datei aus.
|
||||
|
||||
Returns:
|
||||
Pfad zur erstellten .exe.
|
||||
"""
|
||||
print("\n🔨 PyInstaller baut .exe …")
|
||||
print(" (Dies kann einige Minuten dauern)\n")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
str(SPEC_PATH),
|
||||
"--distpath", str(BUILD_PIPELINE_DIR / "dist"),
|
||||
"--workpath", str(BUILD_PIPELINE_DIR / "build_temp"),
|
||||
"--noconfirm",
|
||||
"--clean",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
capture_output=False,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("\n❌ PyInstaller-Build fehlgeschlagen!")
|
||||
sys.exit(1)
|
||||
|
||||
exe_path = BUILD_PIPELINE_DIR / "dist" / "Tätigkeitsnachweis-Generator.exe"
|
||||
if not exe_path.exists():
|
||||
print(f"\n❌ .exe nicht gefunden: {exe_path}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" ✓ .exe erstellt: {exe_path.name}")
|
||||
return exe_path
|
||||
|
||||
|
||||
def _copy_to_build(exe_path: Path) -> Path:
|
||||
"""Kopiert die .exe nach 2_build/."""
|
||||
dest = BUILD_OUTPUT_DIR / exe_path.name
|
||||
shutil.copy2(exe_path, dest)
|
||||
print(f" ✓ .exe nach 2_build/ kopiert")
|
||||
return dest
|
||||
|
||||
|
||||
def _cleanup() -> None:
|
||||
"""Räumt temporäre Build-Artefakte auf."""
|
||||
for d in ["dist", "build_temp"]:
|
||||
path = BUILD_PIPELINE_DIR / d
|
||||
if path.exists():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
print(" ✓ Temporäre Dateien entfernt")
|
||||
|
||||
|
||||
def _save_version(version: str) -> None:
|
||||
"""Speichert die Version für spätere Referenz."""
|
||||
VERSION_FILE.write_text(version)
|
||||
|
||||
|
||||
def _print_summary(version: str, exe_path: Path) -> None:
|
||||
"""Gibt eine Build-Zusammenfassung aus."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" ✅ Build {version} erfolgreich!")
|
||||
print(f" 📂 {exe_path}")
|
||||
print(f"{'='*60}\n")
|
||||
print("Auslieferungs-Struktur (2_build/):")
|
||||
print(" ├── Tätigkeitsnachweis-Generator.exe")
|
||||
print(" ├── config/ ← Konfiguration (editierbar)")
|
||||
print(" ├── input/ ← Excel-Dateien hier ablegen")
|
||||
print(" ├── template/ ← PDF-Templates hier ablegen")
|
||||
print(" └── output/ ← Generierte PDFs erscheinen hier")
|
||||
|
||||
|
||||
# ── CLI ─────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build mit Semantic Versioning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bump",
|
||||
choices=["major", "minor", "patch"],
|
||||
help="Version bump-Typ (ohne Angabe: interaktiv)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--message", "-m",
|
||||
help="Changelog-Beschreibung für diese Version",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Nur anzeigen, was passieren würde (kein Build)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# ── Version ermitteln ────────────────────────────────────
|
||||
current = get_current_version()
|
||||
if not current:
|
||||
print("❌ Keine Version in CHANGELOG.md gefunden. Beginne mit 0.1.")
|
||||
current = "0.1"
|
||||
|
||||
print(f"\n📋 Aktuelle Version: {current}")
|
||||
|
||||
# Bump-Typ bestimmen
|
||||
bump_type = args.bump
|
||||
if not bump_type:
|
||||
print("\nWelche Art von Änderung?")
|
||||
print(" [1] major – inkompatible API-Änderungen")
|
||||
print(" [2] minor – neue Funktionalität (abwärtskompatibel)")
|
||||
print(" [3] patch – Bugfixes")
|
||||
try:
|
||||
choice = input(" → ").strip()
|
||||
bump_type = {"1": "major", "2": "minor", "3": "patch"}.get(choice, "minor")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nAbgebrochen.")
|
||||
sys.exit(0)
|
||||
|
||||
new_version = bump_version(current, bump_type)
|
||||
print(f"🚀 Neue Version: {new_version}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n[Trockenlauf – kein Build]")
|
||||
print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]")
|
||||
print(f" Würde PyInstaller-Build starten")
|
||||
return
|
||||
|
||||
# ── CHANGELOG aktualisieren ──────────────────────────────
|
||||
print("\n📝 Changelog …")
|
||||
update_changelog(new_version, bump_type, args.message)
|
||||
|
||||
# ── Build ────────────────────────────────────────────────
|
||||
_ensure_pyinstaller()
|
||||
_prepare_build_dir()
|
||||
|
||||
try:
|
||||
exe_path = _run_pyinstaller()
|
||||
final_exe = _copy_to_build(exe_path)
|
||||
finally:
|
||||
_cleanup()
|
||||
|
||||
_save_version(new_version)
|
||||
_print_summary(new_version, final_exe)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller Spec-Datei für den Tätigkeitsnachweis-Generator.
|
||||
|
||||
Baut eine standalone .exe aus run.py, die Streamlit, openpyxl und
|
||||
PyMuPDF (fitz) enthält.
|
||||
|
||||
Verwendung:
|
||||
pyinstaller build.spec
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Pfade relativ zum Speicherort dieser Spec-Datei
|
||||
# PyInstaller stellt SPECPATH bereit (__file__ existiert in exec()-Kontext nicht)
|
||||
_SPEC_DIR = Path(SPECPATH).resolve()
|
||||
_PROJECT_ROOT = _SPEC_DIR.parent
|
||||
_APP_DIR = _PROJECT_ROOT / "0_application"
|
||||
|
||||
# ── Streamlit & PyMuPDF Hidden Imports ──────────────────────────
|
||||
# Streamlit hat viele dynamische Imports; collect_all holt alles.
|
||||
# PyMuPDF (fitz) braucht die native DLL.
|
||||
|
||||
hiddenimports = [
|
||||
# Streamlit-Komponenten
|
||||
"streamlit",
|
||||
"streamlit.web.bootstrap",
|
||||
"streamlit.runtime",
|
||||
"streamlit.runtime.scriptrunner",
|
||||
"streamlit.runtime.state",
|
||||
"streamlit.runtime.caching",
|
||||
"streamlit.watcher",
|
||||
"streamlit.watcher.local_sources_watcher",
|
||||
"streamlit.watcher.poll_path_watcher",
|
||||
"streamlit.watcher.event_based_path_watcher",
|
||||
"streamlit.elements",
|
||||
"streamlit.elements.arrow",
|
||||
"streamlit.elements.pyplot",
|
||||
"streamlit.elements.altair",
|
||||
"streamlit.elements.plotly",
|
||||
"streamlit.proto",
|
||||
# openpyxl
|
||||
"openpyxl",
|
||||
"openpyxl.cell",
|
||||
"openpyxl.styles",
|
||||
# PyMuPDF
|
||||
"fitz",
|
||||
# Standard-Libs die manchmal übersehen werden
|
||||
"tornado",
|
||||
"watchdog",
|
||||
"pandas",
|
||||
"numpy",
|
||||
"PIL",
|
||||
"altair",
|
||||
"pyarrow",
|
||||
"git",
|
||||
"blinker",
|
||||
"rich",
|
||||
"pydeck",
|
||||
]
|
||||
|
||||
# ── Binaries (PyMuPDF DLL) ──────────────────────────────────────
|
||||
binaries = []
|
||||
|
||||
# Finde die PyMuPDF-DLL
|
||||
try:
|
||||
import fitz
|
||||
fitz_dir = Path(fitz.__file__).parent
|
||||
for f in fitz_dir.iterdir():
|
||||
if f.suffix.lower() in (".dll", ".pyd", ".so", ".dylib"):
|
||||
binaries.append((str(f), "fitz"))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── Data Files ──────────────────────────────────────────────────
|
||||
datas = [
|
||||
(str(_APP_DIR / "app.py"), "."),
|
||||
(str(_APP_DIR / "src"), "src"),
|
||||
(str(_APP_DIR / "config"), "config"),
|
||||
]
|
||||
|
||||
# ── Alle Streamlit-Abhängigkeiten & Metadaten VOR Analysis() sammeln ─
|
||||
from PyInstaller.utils.hooks import collect_all, copy_metadata
|
||||
|
||||
streamlit_datas, streamlit_binaries, streamlit_hidden = collect_all("streamlit")
|
||||
datas.extend(streamlit_datas)
|
||||
binaries.extend(streamlit_binaries)
|
||||
hiddenimports.extend(streamlit_hidden)
|
||||
|
||||
# Package-Metadaten: streamlit & Co. nutzen importlib.metadata.version(),
|
||||
# das die METADATA-Dateien aus dem dist-info-Ordner benötigt.
|
||||
for pkg in ["streamlit", "pyarrow", "numpy", "pandas", "altair", "openpyxl"]:
|
||||
try:
|
||||
datas += copy_metadata(pkg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Deduplizieren (Datas und Hiddenimports)
|
||||
hiddenimports = list(set(hiddenimports))
|
||||
seen = set()
|
||||
_unique_datas = []
|
||||
for src, dst in datas:
|
||||
key = (src, dst)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
_unique_datas.append((src, dst))
|
||||
datas = _unique_datas
|
||||
|
||||
# ── Analyse ─────────────────────────────────────────────────────
|
||||
a = Analysis(
|
||||
[str(_SPEC_DIR / "run.py")],
|
||||
pathex=[str(_APP_DIR)],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
"tkinter",
|
||||
"matplotlib",
|
||||
"scipy",
|
||||
"IPython",
|
||||
"jupyter",
|
||||
"notebook",
|
||||
"sqlalchemy",
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=None,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
# ── PyZ ─────────────────────────────────────────────────────────
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
||||
|
||||
# ── EXE ─────────────────────────────────────────────────────────
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name="Tätigkeitsnachweis-Generator",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # True = Konsolenfenster sichtbar (zeigt Streamlit-Logs)
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Einstiegspunkt für die gebundelte Tätigkeitsnachweis-Generator .exe.
|
||||
|
||||
Wird von PyInstaller als Entry-Point verwendet. Im gebundelten Modus
|
||||
(sys.frozen = True) liegen die Python-Dateien (app.py, src/) in sys._MEIPASS,
|
||||
während die Daten-Ordner (config/, input/, template/, output/) neben der .exe
|
||||
erwartet werden.
|
||||
|
||||
Dieses Skript:
|
||||
1. Setzt APP_BASE_DIR auf das Verzeichnis der .exe
|
||||
2. Fügt sys._MEIPASS zum Python-Pfad hinzu (für src/-Imports)
|
||||
3. Startet den Streamlit-Server und öffnet den Browser
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _get_base_dir() -> Path:
|
||||
"""Verzeichnis der .exe (bzw. des Skripts im Entwicklungsmodus)."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
else:
|
||||
# Entwicklungsmodus: run.py liegt in 1_build-pipeline/,
|
||||
# die App in 0_application/
|
||||
return Path(__file__).resolve().parent.parent / "0_application"
|
||||
|
||||
|
||||
def _get_app_path() -> str:
|
||||
"""Pfad zur app.py (gebundelt oder im Entwicklungsmodus)."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.join(sys._MEIPASS, "app.py")
|
||||
else:
|
||||
return str(Path(__file__).resolve().parent.parent / "0_application" / "app.py")
|
||||
|
||||
|
||||
def _ensure_dirs(base_dir: Path) -> None:
|
||||
"""Stellt sicher, dass input/, output/, template/, config/ existieren."""
|
||||
for folder in ["input", "output", "template", "config"]:
|
||||
(base_dir / folder).mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
base_dir = _get_base_dir()
|
||||
|
||||
# Im gebundelten Modus: sys._MEIPASS enthält src/ und app.py
|
||||
if getattr(sys, 'frozen', False):
|
||||
if sys._MEIPASS not in sys.path:
|
||||
sys.path.insert(0, sys._MEIPASS)
|
||||
|
||||
# APP_BASE_DIR teilt app.py mit, wo die Daten-Ordner liegen
|
||||
os.environ["APP_BASE_DIR"] = str(base_dir)
|
||||
|
||||
_ensure_dirs(base_dir)
|
||||
|
||||
# ── Streamlit-Konfiguration ──────────────────────────
|
||||
os.environ["STREAMLIT_SERVER_ADDRESS"] = "localhost"
|
||||
os.environ["STREAMLIT_SERVER_PORT"] = "8501"
|
||||
os.environ["STREAMLIT_SERVER_HEADLESS"] = "true"
|
||||
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
||||
os.environ["STREAMLIT_SERVER_ENABLE_CORS"] = "false"
|
||||
os.environ["STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION"] = "false"
|
||||
|
||||
# Browser manuell öffnen
|
||||
def _open_browser() -> None:
|
||||
time.sleep(2)
|
||||
webbrowser.open("http://localhost:8501")
|
||||
|
||||
threading.Thread(target=_open_browser, daemon=True).start()
|
||||
|
||||
# ── Streamlit via CLI-Codepfad starten ───────────────
|
||||
# Dieser Weg ist identisch zu 'streamlit run app.py' und
|
||||
# funktioniert im PyInstaller-Bundle zuverlässiger als
|
||||
# bootstrap.run() mit Streamlit >= 1.58.
|
||||
sys.argv = [
|
||||
"streamlit", "run", _get_app_path(),
|
||||
"--server.port=8501",
|
||||
"--server.address=localhost",
|
||||
"--server.headless=true",
|
||||
"--browser.gatherUsageStats=false",
|
||||
"--server.enableCORS=false",
|
||||
"--server.enableXsrfProtection=false",
|
||||
"--global.developmentMode=false",
|
||||
]
|
||||
try:
|
||||
from streamlit.cli import main as cli_main
|
||||
except ImportError:
|
||||
from streamlit.web.cli import main as cli_main
|
||||
cli_main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,12 +0,0 @@
|
||||
[
|
||||
{"source_field": "Antragsnummer", "target_cell": "B3", "format": null},
|
||||
{"source_field": "Name", "target_cell": "B17", "format": null},
|
||||
{"source_field": "Stellenanteil in %", "target_cell": "R17", "format": null},
|
||||
{"source_field": "Qualifikation", "target_cell": "B21", "format": null},
|
||||
{"source_field": "Monat (MM.JJ)", "target_cell": "R21", "format": null},
|
||||
{"source_field": "Funktion", "target_cell": "B25", "format": null},
|
||||
{"source_field": "Tätigkeit", "target_cell": "B31", "format": null},
|
||||
{"source_field": "Ort", "target_cell": "B95", "format": null},
|
||||
{"source_field": "Ort 2", "target_cell": "B103", "format": null},
|
||||
{"source_field": "Datum", "target_cell": "J95", "format": null}
|
||||
]
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
"""Template-basierte Activity-Report-Generierung.
|
||||
|
||||
Verwendet ein Template mit bereits vor-duplizierten Sheets.
|
||||
Es werden KEINE Sheets programmatisch erzeugt oder kopiert –
|
||||
ausschließlich die vorhandenen Sheets mit Daten befüllt.
|
||||
"""
|
||||
|
||||
import calendar
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.cell.cell import MergedCell
|
||||
|
||||
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)
|
||||
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]
|
||||
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
|
||||
|
||||
|
||||
def _format_month_for_template(monat_str: str) -> str:
|
||||
"""Konvertiert 'MM.JJ' zu 'MM.JJJJ'."""
|
||||
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 _month_name(mi: int) -> str:
|
||||
names = ["", "Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember"]
|
||||
return names[mi] if 1 <= mi <= 12 else ""
|
||||
|
||||
|
||||
def _fill_sheet(ws, record: EmployeeRecord, employee_name: str, field_map: dict) -> None:
|
||||
"""Befüllt ein einzelnes Sheet mit den Daten eines EmployeeRecords."""
|
||||
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 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
|
||||
elif source_field in ("Ort", "Ort 2"):
|
||||
value = "Dresden"
|
||||
elif source_field == "Datum":
|
||||
parts = record.monat.split(".")
|
||||
if len(parts) == 2:
|
||||
month = int(parts[0])
|
||||
year = int(parts[1])
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
value = f"{last_day:02d}.{month:02d}.{year}"
|
||||
else:
|
||||
value = ""
|
||||
else:
|
||||
continue
|
||||
formatted = _format_value(value, m.format)
|
||||
_set_cell_value(ws, m.target_cell, formatted)
|
||||
|
||||
|
||||
def generate_activity_reports(
|
||||
employee_records: dict[str, list[EmployeeRecord]],
|
||||
template_path: Path,
|
||||
mapping: list[OutputMapping],
|
||||
output_dir: Path,
|
||||
filename_pattern: str,
|
||||
) -> list[Path]:
|
||||
"""
|
||||
Befüllt vor-duplizierte Template-Sheets mit Mitarbeiterdaten.
|
||||
Erzeugt KEINE neuen Sheets – verwendet ausschließlich die im Template
|
||||
bereits vorhandenen Sheet-Kopien.
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
generated_files: list[Path] = []
|
||||
|
||||
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
|
||||
|
||||
wb = load_workbook(template_path)
|
||||
sorted_records = sorted(records, key=lambda r: r.month_index)
|
||||
total_needed = len(sorted_records)
|
||||
available_sheets = wb.worksheets
|
||||
|
||||
if len(available_sheets) < total_needed:
|
||||
wb.close()
|
||||
raise RuntimeError(
|
||||
f"Template hat nur {len(available_sheets)} Sheets, "
|
||||
f"aber {total_needed} werden benötigt für '{employee_name}'."
|
||||
)
|
||||
|
||||
for idx, record in enumerate(sorted_records):
|
||||
sheet = available_sheets[idx]
|
||||
mi = record.month_index
|
||||
func_abbr = record.funktion[:3] if record.funktion else ""
|
||||
sheet.title = f"{mi:02d}_{_month_name(mi)}_{func_abbr}"[:31]
|
||||
_fill_sheet(sheet, record, employee_name, field_map)
|
||||
|
||||
# Lösche überzählige Sheets (von hinten nach vorne)
|
||||
for extra in reversed(available_sheets[total_needed:]):
|
||||
wb.remove(extra)
|
||||
|
||||
wb.save(output_path)
|
||||
wb.close()
|
||||
generated_files.append(output_path)
|
||||
|
||||
return generated_files
|
||||
Binary file not shown.
Reference in new issue
Block a user