Compare commits
21
Commits
a4a678d11e
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635323ae50 | ||
|
|
2c7d69cf78 | ||
|
|
c9dac7b9b8 | ||
|
|
94e245a3a0 | ||
|
|
fa3e99dec0 | ||
|
|
8fde588b8f | ||
|
|
87f8150bd3 | ||
|
|
b498af39d9 | ||
|
|
2247c42e81 | ||
|
|
23cb3c35b3 | ||
|
|
dbbd86ece0 | ||
|
|
557f987ee1 | ||
|
|
b084120c0d | ||
|
|
9f0e3cc8fc | ||
|
|
932585882e | ||
|
|
3e9cd39859 | ||
|
|
9bdd90c073 | ||
|
|
e50f423304 | ||
|
|
ef219e00ba | ||
|
|
30f3ffaded | ||
|
|
93c31655bd |
No files matched your search
@@ -126,6 +126,7 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
||||
| `tests/test_e2e.py` | Tests für PDF-Output anpassen |
|
||||
| `.copilot/PLAN.md` | Dieser Plan – aktualisiert |
|
||||
| `.copilot/TASKS.md` | Aufgabenliste – aktualisiert |
|
||||
| `CHANGELOG.md` | Changelog – wird bei jedem Version-Build erweitert |
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
@@ -154,9 +155,26 @@ In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche
|
||||
├── coldrun.py ← CLI Cold-Run
|
||||
├── start.bat ← Windows batch zum Start der App
|
||||
├── README.md ← README für Programmbenutzer
|
||||
├── CHANGELOG.md ← Changelog (wird bei jedem Version-Build erweitert)
|
||||
└── requirements.txt ← streamlit, openpyxl, PyMuPDF
|
||||
```
|
||||
|
||||
## Versionierung & Changelog
|
||||
|
||||
Immer wenn das Signal zum Build einer neuen Version gegeben wird (z. B. „Build Version X.Y“ oder „neue Version bauen“), wird das **Changelog-Logfile** (`CHANGELOG.md` im Projekt-Root) um die **most recent changes** erweitert. Der Eintrag enthält:
|
||||
|
||||
- **Versionsnummer** (z. B. `v1.2.0`)
|
||||
- **Datum** des Builds
|
||||
- **Neue Funktionen** (Features)
|
||||
- **Behobene Fehler** (Bugfixes)
|
||||
- **Sonstige Änderungen** (z. B. Refactoring, Doku-Updates)
|
||||
|
||||
Der Changelog wird im [Keep a Changelog](https://keepachangelog.com/)-Format gepflegt und nach Semantic Versioning (`MAJOR.MINOR.PATCH`) versioniert.
|
||||
|
||||
> **Wichtig**: Der Changelog-Eintrag wird **vor** dem eigentlichen Build-Vorgang erstellt, sodass die neue Version dokumentiert ist, bevor der Code ausgeliefert wird.
|
||||
|
||||
---
|
||||
|
||||
## Verifikation
|
||||
|
||||
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
|
||||
|
||||
@@ -1,47 +1 @@
|
||||
# Aufgaben: Tätigkeitsnachweis-Generator (Streamlit App) – PDF-Migration
|
||||
|
||||
> **Migration**: Das XLSX-Template war schreibgeschützt und wurde durch ein PDF-Template ersetzt.
|
||||
> Alle `_Field`-Platzhalter im PDF werden durch echte Daten ersetzt.
|
||||
|
||||
## 🔄 Migration: XLSX → PDF Template ✅
|
||||
|
||||
- [x] **M1**: `requirements.txt` → `PyMuPDF` hinzufügen
|
||||
- [x] **M2**: `config/settings.ini` → `filename_pattern` auf `.pdf`-Endung ändern
|
||||
- [x] **M3**: `config/output-fields.json` → `target_cell` entfernen, `pdf_field` hinzufügen (Mapping source_field → PDF-Platzhalter-Name)
|
||||
- [x] **M4**: `src/config.py` → `get_available_templates()` auf `.pdf`-Dateien umstellen
|
||||
- [x] **M5**: `src/validator.py` → `validate_template()` für PDF angepasst (prüft `_Field`-Platzhalter via PyMuPDF)
|
||||
- [x] **M6**: `src/writer.py` → Komplett neu: PDF-basierte Generierung mit PyMuPDF
|
||||
- Pre-Scan: Alle `_Field`-Platzhalter im Template-PDF finden (Position, Schriftgröße)
|
||||
- Für jeden Mitarbeiter: neues PDF, pro Monat eine Seite (Template-Kopie), `_Field`-Texte redacten + ersetzen
|
||||
- Dateiname: `{nachname}_{vorname}_Tätigkeitsnachweis.pdf`
|
||||
- [x] **M7**: `app.py` → Template-Validierung für PDF angepasst (Dropdown zeigt `.pdf`, Validierung prüft `_Field`-Marker)
|
||||
- [x] **M8**: `coldrun.py` → An PDF-Template angepasst
|
||||
- [x] **M9**: `tests/test_e2e.py` → Tests für PDF-Output umgeschrieben (PyMuPDF statt openpyxl zur Output-Validierung)
|
||||
- [x] **M10**: `.copilot/PLAN.md` und `.copilot/TASKS.md` aktualisiert
|
||||
|
||||
## 🔍 Analyse (abgeschlossen)
|
||||
- [x] **A1**: Input-Datei `input/2025 JM.xlsx` analysiert
|
||||
- [x] **A2**: PDF-Template `template/Tätigkeitsnachweise Stellenförderung.pdf` liegt vor (mit `_Field`-Platzhaltern)
|
||||
|
||||
## ⚙️ Konfiguration (migriert)
|
||||
- [x] **K1**: `config/input-fields.json` — unverändert gültig
|
||||
- [x] **K2**: `config/output-fields.json` — `target_cell` entfernt, `pdf_field` hinzugefügt
|
||||
- [x] **K3**: `config/settings.ini` — filename_pattern auf `.pdf`
|
||||
|
||||
## 🧱 Core-Module
|
||||
- [x] **C1**: `requirements.txt` — streamlit, openpyxl, PyMuPDF
|
||||
- [x] **C2**: `src/__init__.py` + `src/models.py` — `OutputMapping` um `pdf_field` erweitert
|
||||
- [x] **C3**: `src/config.py` — Template-Suche auf `.pdf` umgestellt
|
||||
- [x] **C4**: `src/parser.py` — unverändert
|
||||
- [x] **C5**: `src/validator.py` — `validate_template()` für PDF via PyMuPDF
|
||||
- [x] **C6**: `src/writer.py` — Komplett neu mit PyMuPDF (redact + insert_text)
|
||||
|
||||
## 🖥️ UI & Start
|
||||
- [x] **U1**: `app.py` — PDF-Template-Validierung angepasst
|
||||
- [x] **U2**: `start.bat` — unverändert
|
||||
- [x] **U3**: `coldrun.py` — An PDF angepasst, funktioniert
|
||||
- [x] **U4**: `README.md` — vorhanden
|
||||
|
||||
## ✅ Test
|
||||
- [x] **T1**: E2E-Test mit realer Input-Datei und PDF-Template → 5/5 Tests bestanden
|
||||
- [x] **T2**: Edge Cases — Umlaute korrekt im PDF, fehlende Marker (Ort/Datum) werden übersprungen
|
||||
+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,115 @@
|
||||
# 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.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.
@@ -8,6 +8,13 @@ from pathlib import Path
|
||||
import streamlit as st
|
||||
|
||||
# Projekt-Root zum Python-Path hinzufügen
|
||||
# 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))
|
||||
|
||||
@@ -239,7 +246,7 @@ if st.session_state.input_validated and st.session_state.parsed_records:
|
||||
key = f"sel_{emp}"
|
||||
if key not in st.session_state:
|
||||
st.session_state[key] = True
|
||||
if st.checkbox(emp, value=st.session_state[key], key=key):
|
||||
if st.checkbox(emp, key=key):
|
||||
selected_employees.append(emp)
|
||||
|
||||
st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt")
|
||||
@@ -289,6 +296,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:
|
||||
@@ -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,7 +22,7 @@ from src.config import (
|
||||
get_available_templates,
|
||||
)
|
||||
from src.parser import parse_input_file, group_by_employee, get_unique_employees
|
||||
from src.validator import validate_template
|
||||
from src.validator import validate_input_file, validate_template
|
||||
from src.writer import generate_activity_reports
|
||||
|
||||
|
||||
@@ -70,6 +70,18 @@ def main():
|
||||
total = tpl_result.info.get("total_fields", "?")
|
||||
print(f"[OK] {found}/{total} Platzhalter im Template gefunden.")
|
||||
|
||||
# ── Input validieren ──────────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Validiere Input-Datei…")
|
||||
inp_result = validate_input_file(input_path, config.input_schema, config.month_sheet_pattern)
|
||||
if not inp_result.is_valid:
|
||||
for err in inp_result.errors:
|
||||
print(f"[FEHLER] {err}")
|
||||
return 1
|
||||
if inp_result.warnings:
|
||||
for warn in inp_result.warnings:
|
||||
print(f"[WARN] {warn}")
|
||||
|
||||
# ── Input parsen ──────────────────────────────────────────────────
|
||||
print()
|
||||
print("[INFO] Parse Input-Datei…")
|
||||
@@ -101,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.
@@ -1,13 +1,10 @@
|
||||
[
|
||||
{"source_field": "Antragsnummer", "pdf_field": "Auftragsnummer_Field", "format": null},
|
||||
{"source_field": "Antragsnummer", "pdf_field": "Antragsnummer_Field", "format": null},
|
||||
{"source_field": "Name", "pdf_field": "Name_Field", "format": null},
|
||||
{"source_field": "Stellenanteil in %", "pdf_field": "Stellenanteil_Field", "format": null},
|
||||
{"source_field": "Qualifikation", "pdf_field": "Qualifikation_Field", "format": null},
|
||||
{"source_field": "Monat (MM.JJ)", "pdf_field": "Monat_Field", "format": null},
|
||||
{"source_field": "Funktion", "pdf_field": "Funktion_Field", "format": null},
|
||||
{"source_field": "Tätigkeit", "pdf_field": "Tätigkeiten_Field", "format": null},
|
||||
{"source_field": "Tätigkeit", "pdf_field": "Tätigkeiten-inkl-Reisen_Field","format": null},
|
||||
{"source_field": "Ort", "pdf_field": "Ort_Field", "format": null},
|
||||
{"source_field": "Ort 2", "pdf_field": "Ort2_Field", "format": null},
|
||||
{"source_field": "Datum", "pdf_field": "Datum_Field", "format": null}
|
||||
]
|
||||
@@ -6,6 +6,10 @@ output_dir = output
|
||||
[output]
|
||||
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
|
||||
# Use * as wildcard. Sheets not matching are ignored (e.g. "Alle Rollen").
|
||||
File renamed without changes.
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
@@ -81,6 +81,49 @@ def validate_input_file(
|
||||
info["sheets_with_data"] = sheets_with_data
|
||||
wb.close()
|
||||
|
||||
# ── Dateninhalts-Prüfung: leere Pflichtfelder ───────────────────
|
||||
required_fields = [f for f in schema if f.required]
|
||||
if required_fields and sheets_with_data > 0:
|
||||
# Mapping field_name → column index
|
||||
field_col_map: dict[str, int] = {}
|
||||
for f in required_fields:
|
||||
field_col_map[f.field_name] = column_index_from_string(f.column) - 1
|
||||
max_col = max(field_col_map.values()) + 1
|
||||
|
||||
# {field_name: {name1, name2, ...}} für leere Pflichtwerte
|
||||
empty_by_field: dict[str, set[str]] = {f.field_name: set() for f in required_fields}
|
||||
|
||||
wb2 = load_workbook(filepath, data_only=True)
|
||||
for sheet_name in wb2.sheetnames:
|
||||
if not is_month_sheet(sheet_name, month_sheet_pattern):
|
||||
continue
|
||||
ws = wb2[sheet_name]
|
||||
if ws.max_row < 2:
|
||||
continue
|
||||
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=1, max_col=max_col, values_only=True):
|
||||
name_val = str(row[field_col_map.get("Name", 0)] or "").strip() if len(row) > field_col_map.get("Name", 0) else ""
|
||||
if not name_val:
|
||||
continue
|
||||
for f in required_fields:
|
||||
col_idx = field_col_map[f.field_name]
|
||||
if col_idx < len(row):
|
||||
cell_val = str(row[col_idx] or "").strip() if row[col_idx] is not None else ""
|
||||
if not cell_val:
|
||||
empty_by_field[f.field_name].add(name_val)
|
||||
wb2.close()
|
||||
|
||||
for field_name, names in empty_by_field.items():
|
||||
if names:
|
||||
sorted_names = sorted(names)
|
||||
total = len(sorted_names)
|
||||
preview = sorted_names[:5]
|
||||
suffix = " …" if total > 5 else ""
|
||||
warnings.append(
|
||||
f"Feld '{field_name}' ist leer bei {total} Person(en): "
|
||||
f"{', '.join(preview)}{suffix}"
|
||||
)
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if sheets_with_data == 0:
|
||||
errors.append("Kein Sheet mit den erwarteten Spalten gefunden.")
|
||||
|
||||
@@ -67,7 +67,11 @@ def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: s
|
||||
if source_field == "Monat (MM.JJ)":
|
||||
return _format_month_for_template(record.monat)
|
||||
elif source_field == "Stellenanteil in %":
|
||||
return record.stellenanteil
|
||||
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":
|
||||
@@ -101,10 +105,11 @@ def _get_field_value(record: EmployeeRecord, employee_name: str, source_field: s
|
||||
def _scan_template_fields(
|
||||
template_path: Path,
|
||||
mapping: list[OutputMapping],
|
||||
) -> dict[str, dict]:
|
||||
fontsize: float,
|
||||
) -> dict[str, list[dict]]:
|
||||
"""
|
||||
Scannt ALLE Seiten des PDF-Templates nach _Field-Platzhaltern.
|
||||
Rückgabe: dict[pdf_field] → {source_field, page_num, rect, fontsize, fontname, ...}
|
||||
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)
|
||||
@@ -115,43 +120,23 @@ def _scan_template_fields(
|
||||
if m.pdf_field is not None:
|
||||
pdf_to_source[m.pdf_field] = m.source_field
|
||||
|
||||
field_info: dict[str, dict] = {}
|
||||
field_info: dict[str, list[dict]] = {}
|
||||
|
||||
# 1. Per search_for die Rechtecke aller PDF-Feld-Marker finden (alle Seiten)
|
||||
# Alle Vorkommen aller PDF-Feld-Marker finden (alle Seiten)
|
||||
for pdf_field, source_field in pdf_to_source.items():
|
||||
found_any = False
|
||||
occurrences: list[dict] = []
|
||||
for page_num in range(doc.page_count):
|
||||
page = doc[page_num]
|
||||
found = page.search_for(pdf_field)
|
||||
if found:
|
||||
field_info[pdf_field] = {
|
||||
for rect in found:
|
||||
occurrences.append({
|
||||
"source_field": source_field,
|
||||
"page_num": page_num,
|
||||
"rect": found[0],
|
||||
"fontsize": 11, # Fallback
|
||||
"rect": rect,
|
||||
"fontsize": fontsize,
|
||||
"fontname": "helv",
|
||||
}
|
||||
found_any = True
|
||||
break # erstes Vorkommen (seitenübergreifend)
|
||||
if not found_any:
|
||||
field_info[pdf_field] = None # nicht gefunden
|
||||
|
||||
# 2. Schriftgrößen aus den Text-Details extrahieren (alle Seiten)
|
||||
for page_num in range(doc.page_count):
|
||||
page = doc[page_num]
|
||||
text_dict = page.get_text("dict")
|
||||
for block in text_dict.get("blocks", []):
|
||||
if "lines" not in block:
|
||||
continue
|
||||
for line in block["lines"]:
|
||||
for span in line["spans"]:
|
||||
text = span["text"].strip()
|
||||
if text.endswith("_Field") and text in field_info:
|
||||
info = field_info[text]
|
||||
if info is not None:
|
||||
info["fontsize"] = span["size"]
|
||||
info["fontname"] = span.get("font", "helv")
|
||||
info["rect"] = fitz.Rect(span["bbox"])
|
||||
})
|
||||
field_info[pdf_field] = occurrences
|
||||
|
||||
doc.close()
|
||||
return field_info
|
||||
@@ -166,36 +151,82 @@ def _fill_pdf_pages(
|
||||
record: EmployeeRecord,
|
||||
employee_name: str,
|
||||
mapping: list[OutputMapping],
|
||||
field_info: dict[str, dict],
|
||||
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
|
||||
|
||||
info = field_info.get(m.pdf_field)
|
||||
if info is None:
|
||||
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"]
|
||||
fontsize = info.get("fontsize", 11)
|
||||
|
||||
value = _get_field_value(record, employee_name, m.source_field)
|
||||
formatted = _format_value(value, m.format)
|
||||
|
||||
# Platzhalter redacten
|
||||
page.add_redact_annot(rect)
|
||||
page.apply_redactions()
|
||||
|
||||
# Neuen Text einfügen
|
||||
if formatted:
|
||||
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.bl,
|
||||
(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,
|
||||
@@ -212,6 +243,7 @@ def generate_activity_reports(
|
||||
mapping: list[OutputMapping],
|
||||
output_dir: Path,
|
||||
filename_pattern: str,
|
||||
fontsize: float = 11.0,
|
||||
) -> list[Path]:
|
||||
"""
|
||||
Erzeugt für jeden Mitarbeiter ein PDF.
|
||||
@@ -221,7 +253,7 @@ def generate_activity_reports(
|
||||
generated_files: list[Path] = []
|
||||
|
||||
# Pre-Scan: Feld-Positionen auf allen Template-Seiten ermitteln
|
||||
field_info = _scan_template_fields(template_path, mapping)
|
||||
field_info = _scan_template_fields(template_path, mapping, fontsize)
|
||||
|
||||
for employee_name, records in employee_records.items():
|
||||
if not records:
|
||||
@@ -244,7 +276,7 @@ def generate_activity_reports(
|
||||
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)
|
||||
_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)
|
||||
File renamed without changes.
Executable → Regular
File renamed without changes.
Executable → Regular
File renamed without changes.
Binary file not shown.
Binary file not shown.
File renamed without changes.
Binary file not shown.
Executable → Regular
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1 @@
|
||||
1.2.2
|
||||
@@ -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,371 @@
|
||||
"""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",
|
||||
)
|
||||
|
||||
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()
|
||||
Binary file not shown.
Reference in new issue
Block a user