Compare commits
55
Commits
70873f3726
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb059ac20d | ||
|
|
5e2fab7fd3 | ||
|
|
0ec5de50bb | ||
|
|
fb191449d1 | ||
|
|
4e3b2237ff | ||
|
|
7de6984ebc | ||
|
|
2237779e56 | ||
|
|
7d63063024 | ||
|
|
2aad2c9d99 | ||
|
|
971459c9bf | ||
|
|
1519b96c7b | ||
|
|
eaffa78e9c | ||
|
|
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 | ||
|
|
dbe325d305 | ||
|
|
6e541a04df | ||
|
|
6fda6aea6b | ||
|
|
9a44f8904e | ||
|
|
b05849e673 | ||
|
|
0d390f79b4 | ||
|
|
f4afd4b2e5 | ||
|
|
31cdc7747f | ||
|
|
1e44aaab9d | ||
|
|
39d3ee8baf | ||
|
|
56d605c0cb | ||
|
|
19dfc06272 | ||
|
|
044e4df783 | ||
|
|
6c60a72e0f |
No files matched your search
@@ -0,0 +1,184 @@
|
|||||||
|
# Ziel
|
||||||
|
|
||||||
|
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**: 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 PDF-Template aus dem Templatefolder gewählt werden
|
||||||
|
- ein Button um den Vorgang zu starten
|
||||||
|
|
||||||
|
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 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
|
||||||
|
|
||||||
|
In der README.md ist ein kleine Doku wie das Programm zu benutzen ist und welche Konfigurationsparameter es gibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phasen-Plan
|
||||||
|
|
||||||
|
### Phase 1: Projekt-Grundgerüst & Konfiguration
|
||||||
|
|
||||||
|
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 → `.pdf`)
|
||||||
|
3. **`config/input-fields.json` Schema definieren** — Spalten der Input-Tabelle mit ihren exakten Überschriften
|
||||||
|
- 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 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/`)
|
||||||
|
|
||||||
|
5. **`src/__init__.py` + `src/models.py`** — Datenklassen
|
||||||
|
- `EmployeeRecord`: name, vorname, nachname, antragsnummer, qualifikation, funktion, taetigkeit, stellenanteil, monat, jahr, … (Felder aus input-fields.json)
|
||||||
|
- `AppConfig`: geladene settings.ini + input/output-schemas
|
||||||
|
6. **`src/config.py`** — Konfigurations-Loader
|
||||||
|
- `load_settings()`: liest `config/settings.ini` mit `configparser`
|
||||||
|
- `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 `.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: `list[EmployeeRecord]`
|
||||||
|
|
||||||
|
### Phase 3: Writer & Validierung (PDF-basiert)
|
||||||
|
|
||||||
|
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:
|
||||||
|
- 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 (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
|
||||||
|
- `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
|
||||||
|
- `st.button("Tätigkeitsnachweise generieren")`
|
||||||
|
- Bei Button-Click:
|
||||||
|
1. Filtere Employee Records auf selektierte Mitarbeiter
|
||||||
|
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 (unverändert)
|
||||||
|
12. **`README.md` aktualisieren** — Vollständige Doku
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevante Dateien (zu erstellen/ändern)
|
||||||
|
|
||||||
|
| Datei | Aktion |
|
||||||
|
|-------|--------|
|
||||||
|
| `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
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── .copilot/
|
||||||
|
│ ├── PLAN.md ← diese Datei
|
||||||
|
│ └── TASKS.md ← Aufgabenliste
|
||||||
|
├── input/
|
||||||
|
│ └── *.xlsx ← Tabelle mit den Input-daten
|
||||||
|
├── config/
|
||||||
|
│ ├── settings.ini ← Einstellungen der App
|
||||||
|
│ ├── input-fields.json ← Schema der Inputdaten-Tabelle
|
||||||
|
│ └── 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 ← 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
|
||||||
|
├── CHANGELOG.md ← Changelog (wird bei jedem Version-Build erweitert)
|
||||||
|
└── requirements.txt ← streamlit, openpyxl, PyMuPDF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Versionierung & Changelog
|
||||||
|
|
||||||
|
Immer wenn das Signal zum Build einer neuen Version gegeben wird (z. B. „Build Version X.Y“ oder „neue Version bauen“), wird das **Changelog-Logfile** (`CHANGELOG.md` im Projekt-Root) um die **most recent changes** erweitert. Der Eintrag enthält:
|
||||||
|
|
||||||
|
- **Versionsnummer** (z. B. `v1.2.0`)
|
||||||
|
- **Datum** des Builds
|
||||||
|
- **Neue Funktionen** (Features)
|
||||||
|
- **Behobene Fehler** (Bugfixes)
|
||||||
|
- **Sonstige Änderungen** (z. B. Refactoring, Doku-Updates)
|
||||||
|
|
||||||
|
Der Changelog wird im [Keep a Changelog](https://keepachangelog.com/)-Format gepflegt und nach Semantic Versioning (`MAJOR.MINOR.PATCH`) versioniert.
|
||||||
|
|
||||||
|
> **Wichtig**: Der Changelog-Eintrag wird **vor** dem eigentlichen Build-Vorgang erstellt, sodass die neue Version dokumentiert ist, bevor der Code ausgeliefert wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
1. **Input-Datei-Analyse**: `2025 JM.xlsx` mit openpyxl öffnen, Sheet-Namen prüfen, Spaltenüberschriften validieren
|
||||||
|
2. **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
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Binary file not shown.
Whitespace-only changes.
Whitespace-only changes.
@@ -1,108 +0,0 @@
|
|||||||
# SAB Taetigkeitsnachweise Generator - Implementierungsplan
|
|
||||||
|
|
||||||
## Ziel
|
|
||||||
Monatliche SAB-Taetigkeitsnachweise automatisiert erzeugen auf Basis von:
|
|
||||||
- PS_PW.xlsx (monatliche gefoerderte Stunden je Projektanteil)
|
|
||||||
- Stellenbeschreibungen.xlsx (Stammdaten je Mitarbeiter/Funktion)
|
|
||||||
- Taetigkeitsnachweis Stellenfoerderung.xlsx (Ausgabevorlage)
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
- Nur Verzeichnisse unter Tool verwenden.
|
|
||||||
- Eingaben nur aus input.
|
|
||||||
- Vorlage nur aus template.
|
|
||||||
- Ausgaben nur nach output.
|
|
||||||
|
|
||||||
## Annahmen
|
|
||||||
- PS_PW.xlsx und Stellenbeschreibungen.xlsx enthalten jeweils Tabelle1.
|
|
||||||
- Monatsmarker in PS_PW.xlsx stehen in Zeile 2 im Format ab 01.MM.YYYY.
|
|
||||||
- Name ist primaerer Match-Schluessel zwischen beiden Quellen.
|
|
||||||
- Template hat genau ein Quellblatt, das geklont wird.
|
|
||||||
|
|
||||||
## Architektur
|
|
||||||
- cli: Kommandozeilen-Einstieg und Orchestrierung
|
|
||||||
- parser: Einlesen und Normalisierung der beiden Quelldateien
|
|
||||||
- matcher: Zuordnung und Disambiguierung
|
|
||||||
- validator: Source- und Business-Validierung
|
|
||||||
- writer: Template-Klonen, Zellen befuellen, Speichern
|
|
||||||
- model: gemeinsame Datentypen
|
|
||||||
- util: Konvertierung, Datums-/Text-Helfer, Logging
|
|
||||||
|
|
||||||
## Reihenfolge (Task-Flow)
|
|
||||||
1. T1 Plan finalisieren
|
|
||||||
2. T2 Konfiguration definieren
|
|
||||||
3. T3 Projektgeruest anlegen
|
|
||||||
4. T4 Abhaengigkeiten einrichten
|
|
||||||
5. T5 Parser PS_PW
|
|
||||||
6. T6 Parser Stellenbeschreibungen
|
|
||||||
7. T7 Foerderquote berechnen
|
|
||||||
8. T8 Matching-Engine
|
|
||||||
9. T9 Source-Validierung
|
|
||||||
10. T10 Business-Validierung
|
|
||||||
11. T11 Template-Verarbeitung
|
|
||||||
12. T12 Dateinamen + Persistenz
|
|
||||||
13. T13 CLI Pipeline
|
|
||||||
14. T14 Vorschau + Bestaetigung
|
|
||||||
15. T15 Smoke-Test
|
|
||||||
16. T16 Fachtests
|
|
||||||
17. T17 Ergebnisabnahme
|
|
||||||
|
|
||||||
## Definition of Done je Task
|
|
||||||
|
|
||||||
### T1 Plan final
|
|
||||||
- DoD: Dieser Plan ist vollstaendig, nachvollziehbar und in Reihenfolge mit klarer DoD-Liste dokumentiert.
|
|
||||||
|
|
||||||
### T2 config.json
|
|
||||||
- DoD: Alle Pfade, Dateinamen, Sheetnamen, Spalten, Zielzellen und Dateinamenregeln sind extern konfigurierbar.
|
|
||||||
|
|
||||||
### T3 Projektgeruest
|
|
||||||
- DoD: Ordner-/Modulstruktur steht, zentrale Datentypen und Fehler-/Log-Basis sind vorhanden.
|
|
||||||
|
|
||||||
### T4 Abhaengigkeiten
|
|
||||||
- DoD: requirements gepflegt, lokale Installation dokumentiert, Ausfuehrung startet ohne Importfehler.
|
|
||||||
|
|
||||||
### T5 Parser PS_PW
|
|
||||||
- DoD: Monatsspalte wird eindeutig gefunden, Records sind normalisiert und Mitarbeiterkontext korrekt vererbt.
|
|
||||||
|
|
||||||
### T6 Parser Stellenbeschreibungen
|
|
||||||
- DoD: Stammdaten sauber eingelesen, Prozentwerte robust konvertiert, Mehrfacheintraege bleiben erhalten.
|
|
||||||
|
|
||||||
### T7 Foerderquote
|
|
||||||
- DoD: Formel korrekt implementiert, Grenz-/Rundungsfaelle dokumentiert und testsicher.
|
|
||||||
|
|
||||||
### T8 Matching
|
|
||||||
- DoD: Eindeutige Zuordnung pro aktivem Record oder klarer Fehlerstatus (missing/ambiguous).
|
|
||||||
|
|
||||||
### T9 Source-Validierung
|
|
||||||
- DoD: Alle Struktur- und Formatpruefungen laufen vor Verarbeitung und liefern konkrete Fehlermeldungen.
|
|
||||||
|
|
||||||
### T10 Business-Validierung
|
|
||||||
- DoD: Aktivitaet, Eindeutigkeit, Template-Regeln und Dateinamenkollisionen werden vor dem Schreiben geprueft.
|
|
||||||
|
|
||||||
### T11 Template-Verarbeitung
|
|
||||||
- DoD: Ein Quellblatt wird pro Zuordnung geklont und korrekt mit assignment-spezifischen Werten befuellt.
|
|
||||||
|
|
||||||
### T12 Dateinamen/Speichern
|
|
||||||
- DoD: Ausgabedateien folgen Muster Taetigkeitsnachweis_YYYY_MM_Vorname_Nachname.xlsx und werden konfliktfrei gespeichert.
|
|
||||||
|
|
||||||
### T13 CLI
|
|
||||||
- DoD: generate.py akzeptiert month/year, orchestriert End-to-End und liefert sinnvolle Exit-Codes.
|
|
||||||
|
|
||||||
### T14 Vorschau/Bestaetigung
|
|
||||||
- DoD: Vor Schreibzugriff wird Vorschau angezeigt; Abbruch ist ohne Dateiaenderung moeglich.
|
|
||||||
|
|
||||||
### T15 Smoke-Test
|
|
||||||
- DoD: Mindestens ein realer Monatslauf erzeugt valide Dateien in output.
|
|
||||||
|
|
||||||
### T16 Fachtests
|
|
||||||
- DoD: Abdeckung fuer Mehrfachzuordnung, fehlende Matches, ungueltige Monatsmarker und fehlende Struktur.
|
|
||||||
|
|
||||||
### T17 Ergebnisabnahme
|
|
||||||
- DoD: Pflichtfelder, Monats-/Datumswerte und Taetigkeitstexte stimmen mit Template- und Fachregeln ueberein.
|
|
||||||
|
|
||||||
## Risiko- und Entscheidungslog
|
|
||||||
- Name-Matching kann uneinheitliche Schreibweisen enthalten; ggf. Normalisierung (Whitespace/Kommas) zentral in util.
|
|
||||||
- Prozentvergleich kann Rundungsabweichungen haben; toleranzbasierten Vergleich definieren.
|
|
||||||
- Mehrdeutige Matches nicht still aufloesen; immer sichtbar validieren.
|
|
||||||
|
|
||||||
## Start der Umsetzung
|
|
||||||
- Aktiver Schritt: T2 (Konfiguration als naechste technische Umsetzung)
|
|
||||||
Whitespace-only changes.
@@ -1,114 +0,0 @@
|
|||||||
# SAB Taetigkeitsnachweise Generator - Aufgabenliste
|
|
||||||
|
|
||||||
## Ziel
|
|
||||||
Projekt vollstaendig umsetzen: aus PS_PW.xlsx und Stellenbeschreibungen.xlsx monatliche Taetigkeitsnachweise per Template erzeugen.
|
|
||||||
|
|
||||||
## Arbeitsmodus
|
|
||||||
- [ ] Immer nur genau eine Aufgabe aktiv bearbeiten.
|
|
||||||
- [ ] Nach Abschluss jeder Aufgabe: Ergebnis pruefen und abhaken.
|
|
||||||
- [ ] Bei Blockern: Fehler dokumentieren und naechsten konkreten Schritt notieren.
|
|
||||||
- [ ] Nach jedem erledigten task einen git commit erzeugen
|
|
||||||
|
|
||||||
## Phase 1 - Planung und Konfiguration
|
|
||||||
- [x] T1: Plan final im Plan-Dokument pflegen (Scope, Annahmen, Reihenfolge, DoD pro Aufgabe).
|
|
||||||
- [x] T2: `config.json` Struktur festlegen
|
|
||||||
- [ ] Pfade fuer `input/`, `template/`, `output/`
|
|
||||||
- [ ] Dateinamen der drei Excel-Dateien
|
|
||||||
- [ ] Sheetnamen und Spaltenzuordnung je Quelle
|
|
||||||
- [ ] Zielzellen-Mapping fuer Template
|
|
||||||
- [ ] Dateinamenmuster fuer Ausgaben
|
|
||||||
- [ ] Regeln fuer Monatsmarker und Datumsformat
|
|
||||||
|
|
||||||
## Phase 2 - Projektgeruest und Basiskomponenten
|
|
||||||
- [x] T3: Python-Projektstruktur anlegen
|
|
||||||
- [x] Modulaufteilung (cli, parser, matcher, validator, writer, model, util)
|
|
||||||
- [x] Gemeinsame Datenmodelle (z. B. dataclasses) definieren
|
|
||||||
- [x] Zentrales Logging/Fehlermodell vorbereiten
|
|
||||||
- [x] T4: Abhaengigkeiten und Laufzeit vorbereiten
|
|
||||||
- [x] benoetigte Pakete festlegen (z. B. openpyxl, click)
|
|
||||||
- [x] requirements-Datei pflegen
|
|
||||||
- [x] Startkommando fuer lokale Ausfuehrung dokumentieren
|
|
||||||
|
|
||||||
## Phase 3 - Einlesen der Quelldaten
|
|
||||||
- [x] T5: Parser fuer PS_PW.xlsx implementieren
|
|
||||||
- [x] Monatsmarker in Zeile 2 erkennen und parsen (`ab 01.MM.YYYY`)
|
|
||||||
- [x] Zielspalte fuer requested month/year eindeutig bestimmen
|
|
||||||
- [x] Mitarbeiterkontext ueber Mehrzeilen-Eintraege vererben
|
|
||||||
- [x] Werte normalisieren (`-`, `0`, leer, numerisch)
|
|
||||||
- [x] Normalisierte Records erzeugen
|
|
||||||
- [x] T6: Parser fuer Stellenbeschreibungen.xlsx implementieren
|
|
||||||
- [x] Felder ID, Antragsnummer, Name, Qualifikation, Stellenanteil, Funktion, Taetigkeit einlesen
|
|
||||||
- [x] Prozentwerte (deutsches Dezimalformat) robust in Zahlen umwandeln
|
|
||||||
- [x] Mehrfache Eintraege je Name korrekt behalten
|
|
||||||
|
|
||||||
## Phase 4 - Matching und Fachlogik
|
|
||||||
- [x] T7: Foerderquote berechnen
|
|
||||||
- [x] Formel: funded_weekly_hours / contract_weekly_hours * 100
|
|
||||||
- [x] Rundungs-/Vergleichsregeln definieren
|
|
||||||
- [x] T8: Matching-Engine bauen
|
|
||||||
- [x] Primaerschluessel Name anwenden
|
|
||||||
- [x] Kandidaten via Stellenanteil/Foerderquote aufloesen
|
|
||||||
- [x] Ambigue und fehlende Treffer sauber markieren
|
|
||||||
- [x] Hinweise aus Bemerkungsspalte als Validierungshinweis weitergeben
|
|
||||||
|
|
||||||
## Phase 5 - Validierung
|
|
||||||
- [x] T9: Source-Validierung implementieren
|
|
||||||
- [x] Dateien vorhanden und lesbar
|
|
||||||
- [x] Benoetigte Sheets vorhanden
|
|
||||||
- [x] Spalten vorhanden
|
|
||||||
- [x] Monatsmarker parsebar
|
|
||||||
- [x] Gewuenschter Monat exakt einmal vorhanden
|
|
||||||
- [x] Stunden-/Vertragswerte gueltig
|
|
||||||
- [ ] T10: Business-Validierung implementieren
|
|
||||||
- [ ] Mindestens ein aktiver Fall vorhanden
|
|
||||||
- [ ] Jeder aktive Fall hat eindeutigen Match oder klaren Fehler
|
|
||||||
- [ ] Template enthaelt genau ein Quellblatt
|
|
||||||
- [ ] Dateinamenkollisionen vermeiden
|
|
||||||
- [ ] Pflicht-Zielzellen vollstaendig konfiguriert
|
|
||||||
|
|
||||||
## Phase 6 - Ausgabeerzeugung
|
|
||||||
- [ ] T11: Template-Verarbeitung bauen
|
|
||||||
- [ ] Template-Workbook laden
|
|
||||||
- [ ] Sheet pro aktiver Zuordnung klonen
|
|
||||||
- [ ] Inhalte assignment-spezifisch in Zielzellen schreiben
|
|
||||||
- [ ] T12: Dateinamen und Speichern
|
|
||||||
- [ ] Muster: `Taetigkeitsnachweis_YYYY_MM_Vorname_Nachname.xlsx`
|
|
||||||
- [ ] Pro Mitarbeiter genau eine Datei pro Monat erzeugen
|
|
||||||
- [ ] Bei mehreren Zuordnungen: mehrere Sheets in derselben Datei
|
|
||||||
|
|
||||||
## Phase 7 - CLI und Benutzerfluss
|
|
||||||
- [ ] T13: CLI in `generate.py` implementieren
|
|
||||||
- [ ] Parameter `--month` und `--year`
|
|
||||||
- [ ] Pipeline orchestrieren: parse -> match -> validate -> preview -> write
|
|
||||||
- [ ] Kontrollierter Exit-Code bei Fehlern
|
|
||||||
- [ ] T14: Vorschau + Bestaetigung vor Schreiben
|
|
||||||
- [ ] Uebersicht zu Berichtsmonat, aktiven Faellen, Fehlern
|
|
||||||
- [ ] Liste der geplanten Ausgabedateien
|
|
||||||
- [ ] Interaktive Bestaetigung (Abbruch ohne Schreibzugriff moeglich)
|
|
||||||
|
|
||||||
## Phase 8 - Tests und Abnahme
|
|
||||||
- [ ] T15: Smoke-Test fuer End-to-End-Lauf
|
|
||||||
- [ ] Beispielmonat ausfuehren
|
|
||||||
- [ ] Dateien in `output/` entstehen erwartungsgemaess
|
|
||||||
- [ ] T16: Fachtests definieren und ausfuehren
|
|
||||||
- [ ] Mehrfachzuordnung eines Mitarbeiters
|
|
||||||
- [ ] Fehlender Match
|
|
||||||
- [ ] Ungueltige Monatsmarker
|
|
||||||
- [ ] Fehlende Pflichtspalte/Sheet
|
|
||||||
- [ ] T17: Ergebnispruefung gegen Template-Regeln
|
|
||||||
- [ ] Pflichtfelder korrekt befuellt
|
|
||||||
- [ ] Taetigkeitstext inkl. Zeilenumbrueche korrekt
|
|
||||||
- [ ] Datums- und Monatsanzeige korrekt
|
|
||||||
|
|
||||||
## Umsetzungsreihenfolge
|
|
||||||
1. T1 -> T2
|
|
||||||
2. T3 -> T4
|
|
||||||
3. T5 und T6 (danach zusammenfuehren)
|
|
||||||
4. T7 -> T8
|
|
||||||
5. T9 -> T10
|
|
||||||
6. T11 -> T12
|
|
||||||
7. T13 -> T14
|
|
||||||
8. T15 -> T16 -> T17
|
|
||||||
|
|
||||||
## Aktueller Startpunkt
|
|
||||||
- [ ] Naechste aktive Aufgabe: T10 (Business-Validierung implementieren)
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Use when implementing, extending, or reviewing the application. Contains the full application masterplan, data sources, workbook mapping rules, and generation logic for SAB Tätigkeitsnachweise."
|
|
||||||
applyTo: "**/*.py"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Application Masterplan: SAB Taetigkeitsnachweis Generator
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
A Python program that reads personnel allocation data from two Excel workbooks and generates monthly SAB Taetigkeitsnachweise as Excel files based on a dedicated template workbook.
|
|
||||||
|
|
||||||
The application exists to replace a manual process that currently causes two recurring problems:
|
|
||||||
|
|
||||||
1. Incomplete monthly Taetigkeitsnachweise because employees submit them late or not at all.
|
|
||||||
2. Incorrect entries because staffing shares, project assignments, and role changes vary over time and are no longer reliable to maintain manually.
|
|
||||||
|
|
||||||
The system must therefore produce monthly, auditable, and consistent output from a central data basis.
|
|
||||||
|
|
||||||
## Project Scope and Paths
|
|
||||||
|
|
||||||
The tool must operate exclusively below `Tool/`.
|
|
||||||
|
|
||||||
- Input files are read only from `Tool/input/`.
|
|
||||||
- Template files are read only from `Tool/template/`.
|
|
||||||
- Generated files are written only to `Tool/output/`.
|
|
||||||
- No runtime reads/writes are allowed outside `Tool/`.
|
|
||||||
|
|
||||||
## Source Workbooks
|
|
||||||
|
|
||||||
The implementation is based on three concrete Excel files.
|
|
||||||
|
|
||||||
### 1. Allocation Overview: `PS_PW.xlsx`
|
|
||||||
|
|
||||||
This workbook is the monthly allocation matrix that determines how many funded hours a person has in each project for each month.
|
|
||||||
|
|
||||||
Observed structure in `Tabelle1`:
|
|
||||||
|
|
||||||
- Column `A`: Employee name in `Last name, First name` format.
|
|
||||||
- Column `B`: Contracted weekly hours / AV.
|
|
||||||
- Column `C`: Project or assignment key, e.g. `PS`, `PW`, `BeWo`, `NB`, `PS / NB`.
|
|
||||||
- Columns `D` to at least `H`: One funded-hours column per month.
|
|
||||||
- Row `2`: Month markers per column, e.g. `ab 01.01.2026`, `ab 01.02.2026`, `ab 01.03.2026`, `ab 01.04.2026`, `ab 01.05.2026`.
|
|
||||||
- Column `I` (or next free column after month values): Free-text remarks describing assignment changes.
|
|
||||||
|
|
||||||
Interpretation rules:
|
|
||||||
|
|
||||||
- Each person can span multiple consecutive rows when they have multiple project shares.
|
|
||||||
- The first row of a person contains the name and total weekly hours.
|
|
||||||
- Following rows without a name belong to the same person and describe additional project shares.
|
|
||||||
- Numeric values in the month columns are funded weekly hours for the respective month.
|
|
||||||
- zero (0) or `-` means no funded share in that project for that month.
|
|
||||||
- Free-text remarks may contain clarifying timing details and must be surfaced in validation output, but the primary logic is driven by the structured columns, not by parsing remarks.
|
|
||||||
|
|
||||||
### 2. Central Master Data: `Stellenbeschreibungen.xlsx`
|
|
||||||
|
|
||||||
This workbook is the central basis data source for descriptive person/project content used to fill the SAB form.
|
|
||||||
|
|
||||||
Observed structure in `Tabelle1`:
|
|
||||||
|
|
||||||
| Column | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `A` | `ID` |
|
|
||||||
| `B` | `Antragsnummer` |
|
|
||||||
| `C` | `Name` |
|
|
||||||
| `D` | `Qualifikation` |
|
|
||||||
| `E` | `Stellenanteil` |
|
|
||||||
| `F` | `Funktion` |
|
|
||||||
| `G` | `Taetigkeit` |
|
|
||||||
|
|
||||||
Interpretation rules:
|
|
||||||
|
|
||||||
- One employee can appear multiple times with different `Stellenanteil` and `Funktion` values.
|
|
||||||
- `Stellenanteil` is stored as a percentage-like value such as `25,00` or `75,00`.
|
|
||||||
- `Tätigkeit` contains multi-line free text that must be written into the SAB template unchanged except for Excel-compatible line break handling.
|
|
||||||
- `Antragsnummer` is sourced from this workbook when a matching employee/function combination exists.
|
|
||||||
|
|
||||||
### 3. SAB Output Template: `Tätigkeitsnachweis Stellenförderung.xlsx`
|
|
||||||
|
|
||||||
This workbook is the Excel output format mandated by SAB and is the dedicated template base for generated files.
|
|
||||||
|
|
||||||
Template rule:
|
|
||||||
|
|
||||||
- The template workbook contains exactly one sheet.
|
|
||||||
- This one sheet is the blueprint that must be copied for generated sheets.
|
|
||||||
|
|
||||||
Observed content blocks in the workbook:
|
|
||||||
|
|
||||||
- `Antragsnummer`
|
|
||||||
- `Name, Vorname`
|
|
||||||
- `Qualifikation`
|
|
||||||
- `Funktion`
|
|
||||||
- `Taetigkeiten inkl. Angaben zu Dienstreisen fuer das Projekt im Abrechnungszeitraum`
|
|
||||||
- final declaration / signature section
|
|
||||||
|
|
||||||
The generator must treat this workbook as the authoritative structure for the output file. Placeholder values in the template, if present, are ignored and overwritten during generation. Exact target cells or named ranges are implementation details and must be externalized in configuration once confirmed.
|
|
||||||
|
|
||||||
## Core Business Goal
|
|
||||||
|
|
||||||
For a requested reporting month, generate one SAB Taetigkeitsnachweis per active employee assignment so that the document shows the correct funded staff share for that month.
|
|
||||||
|
|
||||||
The generated workbook must combine:
|
|
||||||
|
|
||||||
- allocation hours from `PS_PW.xlsx`
|
|
||||||
- descriptive employee master data from `Stellenbeschreibungen.xlsx`
|
|
||||||
- layout and target cells from `Tätigkeitsnachweis Stellenförderung.xlsx`
|
|
||||||
|
|
||||||
## Reporting Model
|
|
||||||
|
|
||||||
### Reporting Period
|
|
||||||
|
|
||||||
The CLI works on a single target month and year, for example `04/2026`.
|
|
||||||
|
|
||||||
The application must determine the single month column from `PS_PW.xlsx` that matches the requested month.
|
|
||||||
|
|
||||||
### Monthly Column Resolution
|
|
||||||
|
|
||||||
The date markers in row `2` of `PS_PW.xlsx` define one column per month. For a target month:
|
|
||||||
|
|
||||||
1. Read all month markers from row `2`.
|
|
||||||
2. Parse each marker as first day of month (`ab 01.MM.YYYY`).
|
|
||||||
3. Select the column whose parsed date exactly matches the requested month and year.
|
|
||||||
4. Use this single column value as the funded weekly hours for that employee/project row.
|
|
||||||
5. If the value is `-`, `0`, or empty, the employee/project combination is not active for that month.
|
|
||||||
|
|
||||||
### Employee Grouping in `PS_PW.xlsx`
|
|
||||||
|
|
||||||
Because some rows omit the employee name, the parser must normalize the sheet into records with an inherited person context.
|
|
||||||
|
|
||||||
Normalized record shape:
|
|
||||||
|
|
||||||
| Field | Source |
|
|
||||||
|---|---|
|
|
||||||
| `employee_name` | current non-empty value from column `A` |
|
|
||||||
| `contract_hours_weekly` | current non-empty value from column `B` |
|
|
||||||
| `project_key` | column `C` |
|
|
||||||
| `monthly_hours` | single month column selected from `D+` via row `2` |
|
|
||||||
| `notes` | remarks column when present |
|
|
||||||
|
|
||||||
Rows without a project key are ignored.
|
|
||||||
|
|
||||||
## Matching Logic Across Workbooks
|
|
||||||
|
|
||||||
The generator must join records across the two source workbooks.
|
|
||||||
|
|
||||||
### Primary Match Key
|
|
||||||
|
|
||||||
- `Name` in `Stellenbeschreibungen.xlsx` matches `employee_name` from normalized `PS_PW.xlsx` records.
|
|
||||||
|
|
||||||
### Secondary Disambiguation
|
|
||||||
|
|
||||||
If one person has multiple master-data rows in `Stellenbeschreibungen.xlsx`, choose the row whose `Stellenanteil` best matches the funded share derived from `PS_PW.xlsx` for the requested month.
|
|
||||||
|
|
||||||
Derived funded share formula:
|
|
||||||
|
|
||||||
$$
|
|
||||||
Foerderquote = (funded_weekly_hours / contract_weekly_hours) * 100
|
|
||||||
$$
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
- contract weekly hours = `20`
|
|
||||||
- funded weekly hours = `10`
|
|
||||||
- Foerderquote = `50 %`
|
|
||||||
|
|
||||||
Matching rule:
|
|
||||||
|
|
||||||
- Convert `Stellenbeschreibungen.xlsx`.`Stellenanteil` from German decimal text to a numeric percentage.
|
|
||||||
- Choose the row whose percentage equals the derived Foerderquote.
|
|
||||||
- If several rows match, prefer the one whose `Funktion` best matches the assignment context or require explicit disambiguation in validation output.
|
|
||||||
|
|
||||||
### Missing Match Handling
|
|
||||||
|
|
||||||
If no master-data row can be matched for an active allocation record:
|
|
||||||
|
|
||||||
- log a validation error
|
|
||||||
- do not silently invent qualification, function, or activity text
|
|
||||||
- depending on CLI mode, either skip that record or abort the full generation run
|
|
||||||
|
|
||||||
## Output Workbook Structure
|
|
||||||
|
|
||||||
Each generated file is an Excel workbook derived from the SAB template. One file corresponds to one employee for one reporting month.
|
|
||||||
|
|
||||||
Sheet rule for multiple assignments:
|
|
||||||
|
|
||||||
- If an employee has one active assignment in the month, the output workbook contains one sheet.
|
|
||||||
- If an employee has multiple active assignments (multiple Stellen/Funktionen) in the month, the output workbook must contain one separate sheet per assignment.
|
|
||||||
- Every generated sheet is a copy of the single template sheet and is then filled with the assignment-specific data.
|
|
||||||
|
|
||||||
Required logical output fields:
|
|
||||||
|
|
||||||
| Output field | Source |
|
|
||||||
|---|---|
|
|
||||||
| `Antragsnummer` | `Stellenbeschreibungen.xlsx`.`Antragsnummer` |
|
|
||||||
| `Name, Vorname` | matched employee name |
|
|
||||||
| `Qualifikation` | `Stellenbeschreibungen.xlsx`.`Qualifikation` |
|
|
||||||
| `Funktion` | `Stellenbeschreibungen.xlsx`.`Funktion` |
|
|
||||||
| `Taetigkeiten ...` | `Stellenbeschreibungen.xlsx`.`Taetigkeit` |
|
|
||||||
| reporting month label | derived from CLI month/year |
|
|
||||||
| Foerderquote / Stellenanteil for the month | derived from `PS_PW.xlsx` and contract hours |
|
|
||||||
| generated-on date | current system date |
|
|
||||||
|
|
||||||
If the SAB sheet requires additional header or footer fields that are not yet represented in the example extraction, those mappings must be added to configuration, not hardcoded into business logic.
|
|
||||||
|
|
||||||
## File Generation Strategy
|
|
||||||
|
|
||||||
- The generator creates one output workbook per employee and month.
|
|
||||||
- The generator copies the single configured template sheet as many times as needed.
|
|
||||||
- It writes the matched values into configured target cells.
|
|
||||||
- It saves one output file per employee for the target month.
|
|
||||||
|
|
||||||
Recommended default filename pattern:
|
|
||||||
|
|
||||||
`Tätigkeitsnachweis_<YYYY>_<MM>_<FirstName>_<LastName>.xlsx`
|
|
||||||
|
|
||||||
If an employee has multiple active assignments in the same month, the resulting Excel file contains one separate sheet per assignment.
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Before any workbook is written, the application must run a full validation phase.
|
|
||||||
|
|
||||||
### Source Validation
|
|
||||||
|
|
||||||
| Check | Description |
|
|
||||||
|---|---|
|
|
||||||
| Source files exist | `Tool/input/PS_PW.xlsx`, `Tool/input/Stellenbeschreibungen.xlsx`, and `Tool/template/Tätigkeitsnachweis Stellenförderung.xlsx` are readable |
|
|
||||||
| Required sheets exist | `Tabelle1` exists in both source workbooks; configured template sheet exists |
|
|
||||||
| Month-marker row valid | Row `2` in `PS_PW.xlsx` contains parseable monthly markers `ab 01.MM.YYYY` |
|
|
||||||
| Requested month exists | Exactly one month column in `PS_PW.xlsx` matches the requested month/year |
|
|
||||||
| Required columns exist | all configured columns are present in both source workbooks |
|
|
||||||
| Hour values valid | resolved funded hour values are numeric, `0`, or `-` |
|
|
||||||
| Contract hours valid | total weekly hours are numeric and greater than `0` |
|
|
||||||
| Share values valid | derived Förderquote is between `0` and `100` unless explicitly allowed otherwise |
|
|
||||||
| Master-data match exists | every active record can be matched to one descriptive row or is reported as ambiguous/missing |
|
|
||||||
|
|
||||||
### Business Validation
|
|
||||||
|
|
||||||
| Check | Description |
|
|
||||||
|---|---|
|
|
||||||
| Active assignment exists | at least one normalized allocation record resolves to funded hours > `0` in the requested month |
|
|
||||||
| Match uniqueness | each generated output uses exactly one master-data row |
|
|
||||||
| Template has one sheet | configured template workbook contains exactly one source sheet to clone |
|
|
||||||
| Output filename unique | no two generated files resolve to the same path |
|
|
||||||
| Required template targets configured | all mandatory target cells are configured before writing |
|
|
||||||
|
|
||||||
Validation errors are logged and summarized. They must never be silently ignored.
|
|
||||||
|
|
||||||
## Console Preview and Confirmation
|
|
||||||
|
|
||||||
After validation and before writing files, show a preview similar to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
=== Vorschau der ermittelten Werte ===
|
|
||||||
Berichtsmonat : 04/2026
|
|
||||||
PS_PW-Datei : PS_PW.xlsx
|
|
||||||
Stammdaten : Stellenbeschreibungen.xlsx
|
|
||||||
Vorlage : Tätigkeitsnachweis Stellenförderung.xlsx
|
|
||||||
Aktive Faelle : 8
|
|
||||||
Fehler : 0
|
|
||||||
|
|
||||||
--- Zu erzeugende Taetigkeitsnachweise ---
|
|
||||||
Mitarbeitende(r) Projekt Funktion Stunden Quote
|
|
||||||
Meyer, Johannes PS Produktionsschulleiter 10 / 40 25,00 %
|
|
||||||
Meyer, Johannes PW Soz. Paed 30 / 40 75,00 %
|
|
||||||
...
|
|
||||||
|
|
||||||
Moechten Sie die Dateien erzeugen? [j/N]
|
|
||||||
```
|
|
||||||
|
|
||||||
- If validation errors exist, print them before the prompt.
|
|
||||||
- Continue only if the user explicitly confirms with `j`.
|
|
||||||
- Any other input aborts the run without writing output files.
|
|
||||||
|
|
||||||
## Configuration Rules
|
|
||||||
|
|
||||||
All environment-specific details must be stored in `config.json`.
|
|
||||||
|
|
||||||
Configuration must include at least:
|
|
||||||
|
|
||||||
- path to `Tool/input/PS_PW.xlsx`
|
|
||||||
- path to `Tool/input/Stellenbeschreibungen.xlsx`
|
|
||||||
- path to `Tool/template/Tätigkeitsnachweis Stellenförderung.xlsx`
|
|
||||||
- source sheet names
|
|
||||||
- source column mappings
|
|
||||||
- row number for month markers in `PS_PW.xlsx`
|
|
||||||
- template sheet selection strategy
|
|
||||||
- output cell mappings
|
|
||||||
- output directory (`Tool/output/`)
|
|
||||||
- filename pattern
|
|
||||||
|
|
||||||
No workbook-specific coordinates may be buried directly inside business logic.
|
|
||||||
|
|
||||||
## CLI Requirements
|
|
||||||
|
|
||||||
The CLI entry point is `generate.py` using Click.
|
|
||||||
|
|
||||||
Required arguments:
|
|
||||||
|
|
||||||
- `--month` target month as `MM`
|
|
||||||
- `--year` target year as `YYYY`
|
|
||||||
|
|
||||||
Optional arguments:
|
|
||||||
|
|
||||||
- `--employee` limit generation to one employee name
|
|
||||||
- `--project` limit generation to one project key
|
|
||||||
- `--dry-run` run validation and preview without writing files
|
|
||||||
|
|
||||||
## Testing Requirements
|
|
||||||
|
|
||||||
All business logic must be covered by tests in `tests/`.
|
|
||||||
|
|
||||||
At minimum, tests must cover:
|
|
||||||
|
|
||||||
- inheritance of employee context across multiple `PS_PW.xlsx` rows
|
|
||||||
- resolution of the correct month column for a requested month
|
|
||||||
- conversion of German decimal percentages such as `25,00`
|
|
||||||
- Foerderquote calculation
|
|
||||||
- matching of one employee to multiple master-data rows by `Stellenanteil`
|
|
||||||
- skipping inactive `-` assignments
|
|
||||||
- validation for missing and ambiguous matches
|
|
||||||
- generation of collision-free output filenames
|
|
||||||
|
|
||||||
Tests must not depend on real employee names or on the exact attached workbooks.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
applyTo: '**'
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- skill: caveman | description: Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, wenyan-lite, wenyan-full, wenyan-ultra. Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. -->
|
|
||||||
|
|
||||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
|
||||||
|
|
||||||
## Persistence
|
|
||||||
|
|
||||||
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active
|
|
||||||
if unsure. Off only: "stop caveman" / "normal mode".
|
|
||||||
|
|
||||||
Default: full. Switch: `/caveman lite|full|ultra`.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
Drop: articles (a/an/the), filler (just/really/basically/actually/simply),
|
|
||||||
pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short
|
|
||||||
synonyms (big not extensive, fix not "implement a solution for"). Technical terms
|
|
||||||
exact. Code blocks unchanged. Errors quoted exact.
|
|
||||||
|
|
||||||
Pattern: `[thing] [action] [reason]. [next step].`
|
|
||||||
|
|
||||||
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is
|
|
||||||
likely caused by..." Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
|
||||||
|
|
||||||
## Intensity
|
|
||||||
|
|
||||||
| Level | Behavior |
|
|
||||||
|-------|----------|
|
|
||||||
| lite | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
|
||||||
| full | Drop articles, fragments OK, short synonyms. Classic caveman |
|
|
||||||
| ultra | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
|
|
||||||
| wenyan-lite | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
|
||||||
| wenyan-full | Maximum classical terseness. Fully 文言文. 80-90% character reduction |
|
|
||||||
| wenyan-ultra | Extreme abbreviation while keeping classical Chinese feel. Maximum compression |
|
|
||||||
|
|
||||||
## Auto-Clarity
|
|
||||||
|
|
||||||
Drop caveman for: security warnings, irreversible action confirmations,
|
|
||||||
multi-step sequences where fragment order risks misread, user asks to clarify or repeats
|
|
||||||
question. Resume caveman after clear part done.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level
|
|
||||||
persist until changed or session end.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
applyTo: '**'
|
|
||||||
---
|
|
||||||
|
|
||||||
This is a very professional terminal project on Windows.
|
|
||||||
The outline and the structure of the project is very well planned and organized written down in the application masterplan instruction file.
|
|
||||||
|
|
||||||
# Write Code
|
|
||||||
|
|
||||||
You must always make a plan before you write a programm or implement something!
|
|
||||||
|
|
||||||
# Make a Plan
|
|
||||||
|
|
||||||
Write down your plan into the planning file at: .github\copilot-plan.md
|
|
||||||
|
|
||||||
# Create Tasks
|
|
||||||
|
|
||||||
- If your plan contains more than 3 issues to work on, create Tasks for every single issue.
|
|
||||||
- Write down the tasks into the task file: .github\copilot-tasks.md
|
|
||||||
- Work only a a single task at a time. Work every single task step by step.
|
|
||||||
|
|
||||||
# Questions
|
|
||||||
|
|
||||||
If the user or I do ask a question, you think about the question and if nessesary ask importand questions back to clarify the context. Than try to find information about the aked question and additional informations.
|
|
||||||
|
|
||||||
If I ask a question, don't implement some solution directly, but ask me what to do and ask questions back to clarify the context if nessesary.
|
|
||||||
|
|
||||||
# Commit changes in git
|
|
||||||
|
|
||||||
Git commits must be always atomic and be written in plain english.
|
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
### macOS
|
||||||
|
# Finder metadata
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Python-Test- und Build-Artefakte
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
input/
|
||||||
|
output/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.DS_Store
|
||||||
|
.~lock.*#
|
||||||
|
*.xlsx#
|
||||||
|
~*.xlsx
|
||||||
|
/versions
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# 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.4] – 2026-11-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- automatische Update Funktion
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.4] – 2026-11-03
|
||||||
|
|
||||||
|
### Aktualisiert
|
||||||
|
- Template PDF erneuert. Neues und altes PDF sind nun enthalten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.3] – 2026-10-07
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- internal development changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.2] – 2026-10-07
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Input-Dateien hinzufügen**: Bei leerem Input-Ordner können `.xlsx`-Dateien per Drag-and-drop oder Dateiauswahl in den Input-Ordner kopiert werden
|
||||||
|
- **Input-Ordner öffnen**: Ein Button öffnet den Input-Ordner direkt im Explorer
|
||||||
|
- **Input-Ordner neu einlesen**: Dateiliste kann über Button aktualisiert werden
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- EXE erkennt laufende Entwicklungsinstanz und fragt vor dem Neustart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.1] – 2026-10-07
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Python script erkennt jetzt laufende Entwicklungsinstanz und fragt vor dem Neustart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
|
||||||
|
# Tätigkeitsnachweis-Generator
|
||||||
|
|
||||||
|
Streamlit-App zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Python 3.9+ installieren** (falls nicht vorhanden): https://python.org
|
||||||
|
2. **Virtuelle Umgebung erstellen und Abhängigkeiten installieren:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate # Windows
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **App starten:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
streamlit run app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder einfach `start.bat` (Windows) bzw. `./start.sh` (macOS/Linux) ausführen.
|
||||||
|
|
||||||
|
## Cold Run – Headless-Generierung
|
||||||
|
|
||||||
|
`coldrun.py` erzeugt ohne GUI einen Tätigkeitsnachweis für die erste Person –
|
||||||
|
automatisch mit der ersten Input- und Template-Datei. Ergebnis wird auf macOS
|
||||||
|
im Finder geöffnet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kommandozeile
|
||||||
|
.venv/bin/python coldrun.py
|
||||||
|
|
||||||
|
# oder per Doppelklick (macOS)
|
||||||
|
coldrun.command
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
|
||||||
|
- 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 sind alle ausgewählt
|
||||||
|
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
|
||||||
|
4. **"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`
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Beim Start über `start.bat` oder die neu gebaute EXE erscheint bei einer bereits
|
||||||
|
laufenden Programminstanz ein Ja/Nein-Dialog. „Ja“ beendet die bisherige Instanz
|
||||||
|
und startet die neue; „Nein“ lässt die bisherige Instanz geöffnet und bricht den
|
||||||
|
neuen Start ab. Aktuelle Auswahlen gehen beim Beenden verloren. Ein fremdes
|
||||||
|
Programm auf Port 8501 wird nicht beendet.
|
||||||
|
|
||||||
|
Unter Windows führt `test.bat` alle Tests im Ordner `tests/` aus. Das Skript
|
||||||
|
verwendet die Projektumgebung `.venv` und installiert bei Bedarf `pytest`.
|
||||||
|
Per Doppelklick bleibt das Ergebnisfenster geöffnet. Für automatisierte Aufrufe:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\0_application\test.bat --no-pause
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieser Aufruf erfolgt aus dem Projekt-Hauptordner. Bei fehlgeschlagenen Tests
|
||||||
|
liefert das Skript einen Fehlercode und führt die übrigen Tests trotzdem aus.
|
||||||
|
Beim Build laufen die Tests automatisch vor der Versionierung und dem Bauen.
|
||||||
|
Beim Programmstart werden sie nicht ausgeführt.
|
||||||
|
|
||||||
|
End-to-End-Tests parsen die feste Testdatei (`tests/fixture/2026 JM.xlsx`) und das
|
||||||
|
PDF-Template (`template/Tätigkeitsnachweise Stellenförderung.pdf`) und validieren
|
||||||
|
den gesamten Generierungs-Workflow.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test-Abhängigkeiten installieren
|
||||||
|
.venv/bin/pip install pytest
|
||||||
|
|
||||||
|
# Tests ausführen
|
||||||
|
.venv/bin/python -m pytest tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tests prüfen:
|
||||||
|
- Vorhandensein der Input- und Template-Dateien
|
||||||
|
- Korrekte Parsierung (100 Datensätze, 17 Personen)
|
||||||
|
- Vollständige PDF-Pipeline für die erste Person („Bardaus, Madalina“): fünf Monate
|
||||||
|
von Januar bis Mai 2026 mit zehn Seiten, Antragsnummer, Name, Stellenanteil,
|
||||||
|
Monat, Funktion, Tätigkeit, Ort und korrekten Monatsenddaten
|
||||||
|
- Neustartlogik unter Windows mit simulierten Prozessen
|
||||||
|
|
||||||
|
## Build und Deployment unter Windows
|
||||||
|
|
||||||
|
Alle folgenden Befehle werden im Projekt-Hauptordner ausgeführt:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Interaktiv: Versionsänderung auswählen
|
||||||
|
.\1_build-pipeline\build.bat
|
||||||
|
|
||||||
|
# Bugfix-Version mit Changelog-Beschreibung
|
||||||
|
.\1_build-pipeline\build.bat --bump patch --message "Fehler beim Programmstart behoben"
|
||||||
|
|
||||||
|
# Ablauf anzeigen, ohne Tests, Build oder Deployment auszuführen
|
||||||
|
.\1_build-pipeline\build.bat --bump patch --dry-run
|
||||||
|
|
||||||
|
# Aktuelle Version mit aktualisiertem Changelog erneut als EXE bauen
|
||||||
|
.\1_build-pipeline\build.bat --no-bump --no-deploy
|
||||||
|
|
||||||
|
# Nur den letzten erfolgreichen Build bereitstellen (kein neuer Build)
|
||||||
|
.\1_build-pipeline\deploy.bat
|
||||||
|
|
||||||
|
# Deployment ohne abschließende Tasteneingabe
|
||||||
|
.\1_build-pipeline\deploy.bat --no-pause
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Pipeline führt zuerst alle Tests aus. Bei einem Testfehler bleiben Version
|
||||||
|
und Changelog unverändert; Build und Deployment werden nicht gestartet.
|
||||||
|
Nach erfolgreichen Tests aktualisiert sie die Version, erstellt die EXE und
|
||||||
|
legt sie mit Konfiguration, PDF-Template, README und Changelog in `2_build/` ab.
|
||||||
|
`release.json` hält die Version und Prüfsummen des erfolgreichen Builds fest.
|
||||||
|
Bei einem Buildfehler wird der Changelog zurückgesetzt.
|
||||||
|
|
||||||
|
Danach erzeugt das Deployment `Tätigkeitsnachweis-Generator vX.Y.Z.zip` und
|
||||||
|
überträgt es an:
|
||||||
|
|
||||||
|
```text
|
||||||
|
j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren\
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Ziel muss bereits vorhanden und über VPN/SMB erreichbar sein. Ist es nicht
|
||||||
|
erreichbar oder fehlen Schreibrechte, bricht das Deployment verständlich ab.
|
||||||
|
Der lokale Build bleibt erhalten. Nach Wiederherstellung der VPN-Verbindung
|
||||||
|
kann `deploy.bat` die Bereitstellung ohne erneutes Testen, Bauen oder Erhöhen
|
||||||
|
der Versionsnummer wiederholen. Das Skript akzeptiert nur einen bestätigten,
|
||||||
|
unveränderten Build mit `release.json`; ältere Builds müssen einmal neu gebaut
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Das ZIP enthält leere `input/`- und `output/`-Ordner; Arbeitsdateien und
|
||||||
|
Testdaten werden nicht mit ausgeliefert. Es wird zunächst als temporäre Datei
|
||||||
|
übertragen, per Prüfsumme geprüft und erst dann unter dem Versionsnamen
|
||||||
|
sichtbar gemacht. Bestehende Versionen mit anderem Inhalt werden nicht
|
||||||
|
überschrieben. Ein identisches Paket kann erneut bereitgestellt werden.
|
||||||
|
Die Neustarttests simulieren Prozesse und Dialogantworten; ein manueller
|
||||||
|
Starttest der fertigen EXE bleibt vor der Weitergabe sinnvoll.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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
|
||||||
|
- Bei leerem Input-Ordner: „Input-Ordner öffnen“ öffnet den Explorer.
|
||||||
|
- Excel-Dateien können per Drag-and-drop hochgeladen werden. Sie werden in den
|
||||||
|
Input-Ordner kopiert; danach wird die Dateiliste automatisch neu eingelesen.
|
||||||
|
- Nach manuellem Kopieren im Explorer „Input-Ordner neu einlesen“ anklicken.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
Liegt eine neuere Version auf dem Laufwerk J:, erscheint oben rechts ein Hinweis.
|
||||||
|
|
||||||
|
- **„Anwendung ersetzen“** richtet die neue Version in einem eigenen Ordner neben der
|
||||||
|
aktuellen ein (z. B. `Tätigkeitsnachweis-Generator v1.4.0`), übernimmt `input/`,
|
||||||
|
`output/` und eigene Template-Dateien und startet sie. Das folgende Rückfragefenster
|
||||||
|
mit „Ja“ bestätigen, dann wird die alte Version beendet.
|
||||||
|
- Die alte Version bleibt als Ordner erhalten und kann gelöscht werden, sobald die neue läuft.
|
||||||
|
- Wurde `settings.ini` angepasst, liegt die bisherige Datei als
|
||||||
|
`config/settings.vorherige-version.ini` daneben.
|
||||||
|
- Desktop-Verknüpfungen zeigen weiterhin auf den alten Ordner und müssen neu angelegt werden.
|
||||||
|
- **„Update Ordner öffnen“** öffnet das Verzeichnis auf J: für ein manuelles Update.
|
||||||
|
|
||||||
|
## 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`
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
"""Tätigkeitsnachweis-Generator – Streamlit App."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
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))
|
||||||
|
|
||||||
|
from src.config import (
|
||||||
|
load_settings,
|
||||||
|
load_input_schema,
|
||||||
|
load_output_mapping,
|
||||||
|
get_available_input_files,
|
||||||
|
get_available_templates,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
from update_installer import can_replace_application, install_update, start_application
|
||||||
|
|
||||||
|
|
||||||
|
def open_input_folder(directory: Path):
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
if sys.platform == "win32":
|
||||||
|
os.startfile(str(directory))
|
||||||
|
else:
|
||||||
|
subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", str(directory)], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
page_title="Tätigkeitsnachweis-Generator",
|
||||||
|
page_icon="📋",
|
||||||
|
layout="wide",
|
||||||
|
)
|
||||||
|
|
||||||
|
st.title("📋 Tätigkeitsnachweis-Generator")
|
||||||
|
st.markdown("Erstellt Tätigkeitsnachweise aus Input-Daten und einem Excel-Template.")
|
||||||
|
|
||||||
|
# --- Konfiguration laden ---
|
||||||
|
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||||
|
|
||||||
|
@st.cache_data
|
||||||
|
def load_app_config():
|
||||||
|
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
|
||||||
|
config.input_schema = load_input_schema(CONFIG_DIR)
|
||||||
|
config.output_mapping = load_output_mapping(CONFIG_DIR)
|
||||||
|
return config
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_config = load_app_config()
|
||||||
|
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, update_path = 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:60px; }}
|
||||||
|
.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:calc(1rem + 13px); z-index:10001;
|
||||||
|
width:264px; gap:6px;
|
||||||
|
}}
|
||||||
|
div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }}
|
||||||
|
div.st-key-update_notice_open div[data-testid="stButton"] button {{
|
||||||
|
width:100%; 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))
|
||||||
|
replace_supported = can_replace_application()
|
||||||
|
replace_clicked = st.button(
|
||||||
|
"Anwendung ersetzen 🔄", key="replace_application",
|
||||||
|
disabled=not replace_supported or st.session_state.get("update_started", False),
|
||||||
|
help=("Neue Version neben der aktuellen einrichten, input/ und output/ übernehmen "
|
||||||
|
"und starten. Die aktuelle Version bleibt als Ordner erhalten."
|
||||||
|
if replace_supported else "Nur in der installierten Windows-Anwendung verfügbar."))
|
||||||
|
if replace_clicked:
|
||||||
|
try:
|
||||||
|
with st.spinner(f"Version v{version_text} wird eingerichtet …"):
|
||||||
|
result = install_update(update_path, PROJECT_ROOT)
|
||||||
|
start_application(result.target_dir)
|
||||||
|
except Exception as exc: # Der umgebende Block verschluckt sonst jeden Fehler.
|
||||||
|
st.error(f"Update fehlgeschlagen: {exc}\n\n"
|
||||||
|
"Alternativ das Update manuell aus dem Update-Ordner einrichten.")
|
||||||
|
else:
|
||||||
|
st.session_state.update_started = True
|
||||||
|
details = [f"Neuer Programmordner: `{result.target_dir}`"]
|
||||||
|
if result.already_installed:
|
||||||
|
details.append("Die Version war dort bereits eingerichtet und wurde nur gestartet.")
|
||||||
|
else:
|
||||||
|
details.append("Die Ordner input/ und output/ wurden übernommen.")
|
||||||
|
if result.previous_settings:
|
||||||
|
details.append(f"Die bisherige settings.ini wurde als `{result.previous_settings.name}` aufbewahrt.")
|
||||||
|
details.append("Bitte im folgenden Fenster bestätigen, dass die alte Version beendet wird. "
|
||||||
|
"Desktop-Verknüpfungen zeigen noch auf den alten Ordner.")
|
||||||
|
st.success(f"Version v{version_text} wird gestartet.\n\n" + "\n\n".join(details))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Session State initialisieren ---
|
||||||
|
if "parsed_records" not in st.session_state:
|
||||||
|
st.session_state.parsed_records = []
|
||||||
|
if "input_validated" not in st.session_state:
|
||||||
|
st.session_state.input_validated = False
|
||||||
|
if "template_validated" not in st.session_state:
|
||||||
|
st.session_state.template_validated = False
|
||||||
|
# Caches für Validierung/Parsing – nur bei Dateiwechsel neu ausführen
|
||||||
|
if "last_input_file" not in st.session_state:
|
||||||
|
st.session_state.last_input_file = None
|
||||||
|
if "last_template_file" not in st.session_state:
|
||||||
|
st.session_state.last_template_file = None
|
||||||
|
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)
|
||||||
|
|
||||||
|
with col1:
|
||||||
|
st.subheader("1. Input-Datei")
|
||||||
|
input_files = get_available_input_files(app_config)
|
||||||
|
for message in st.session_state.pop("input_upload_messages", []):
|
||||||
|
st.success(message)
|
||||||
|
for message in st.session_state.pop("input_upload_errors", []):
|
||||||
|
st.error(message)
|
||||||
|
if not input_files:
|
||||||
|
with st.container(border=True, key="input_file_panel"):
|
||||||
|
st.markdown("**Excel-Datei hinzufügen**")
|
||||||
|
st.caption("Keine .xlsx-Dateien im Input/-Ordner gefunden.")
|
||||||
|
input_directory = Path(app_config.input_dir)
|
||||||
|
uploaded_files = st.file_uploader(
|
||||||
|
"Excel-Dateien hierher ziehen oder auswählen",
|
||||||
|
type=["xlsx"], accept_multiple_files=True,
|
||||||
|
key=f"input_upload_{st.session_state.get('input_upload_generation', 0)}",
|
||||||
|
label_visibility="collapsed",
|
||||||
|
)
|
||||||
|
if uploaded_files:
|
||||||
|
saved, errors = [], []
|
||||||
|
for uploaded_file in uploaded_files:
|
||||||
|
try:
|
||||||
|
name = uploaded_file.name
|
||||||
|
if (Path(name).name != name or "/" in name or "\\" in name
|
||||||
|
or Path(name).suffix.lower() != ".xlsx" or name.startswith("~")):
|
||||||
|
raise ValueError("Bitte eine .xlsx-Datei mit gültigem Dateinamen auswählen.")
|
||||||
|
input_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination = input_directory / name
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with destination.open("xb") as output:
|
||||||
|
output.write(uploaded_file.getbuffer())
|
||||||
|
break
|
||||||
|
except FileExistsError:
|
||||||
|
counter += 1
|
||||||
|
destination = input_directory / f"{Path(name).stem} ({counter}).xlsx"
|
||||||
|
saved.append(f"{destination.name} wurde in den Input-Ordner kopiert.")
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
errors.append(f"{uploaded_file.name} konnte nicht übernommen werden: {exc}")
|
||||||
|
st.session_state.input_upload_messages = saved
|
||||||
|
st.session_state.input_upload_errors = errors
|
||||||
|
st.session_state.input_upload_generation = st.session_state.get("input_upload_generation", 0) + 1
|
||||||
|
st.session_state.last_input_file = None
|
||||||
|
st.rerun()
|
||||||
|
st.caption("Dateien hierher ziehen oder auswählen. Sie werden automatisch im Input-Ordner gespeichert.")
|
||||||
|
folder_action, refresh_action = st.columns(2)
|
||||||
|
with folder_action:
|
||||||
|
if st.button("Ordner öffnen", icon="📁", key="open_input_directory", use_container_width=True):
|
||||||
|
try:
|
||||||
|
open_input_folder(input_directory)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
st.error(f"Der Input-Ordner konnte nicht geöffnet werden: {exc}")
|
||||||
|
with refresh_action:
|
||||||
|
if st.button("Neu einlesen", icon="🔄", key="refresh_input_directory", use_container_width=True,
|
||||||
|
help="Dateiliste aktualisieren, nachdem Dateien im Explorer hinzugefügt wurden."):
|
||||||
|
st.rerun()
|
||||||
|
selected_input = None
|
||||||
|
st.session_state.parsed_records = []
|
||||||
|
st.session_state.input_validated = False
|
||||||
|
st.session_state.last_input_file = None
|
||||||
|
else:
|
||||||
|
# Bei nur einer Datei: automatisch selektieren (keine Leerauswahl)
|
||||||
|
if len(input_files) == 1:
|
||||||
|
selected_input = st.selectbox(
|
||||||
|
"Input-Datei wählen:",
|
||||||
|
options=input_files,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
selected_input = st.selectbox(
|
||||||
|
"Input-Datei wählen:",
|
||||||
|
options=[""] + input_files,
|
||||||
|
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_input:
|
||||||
|
input_path = Path(app_config.input_dir) / selected_input
|
||||||
|
|
||||||
|
# Nur validieren/parsen wenn sich die Datei geändert hat
|
||||||
|
if selected_input != st.session_state.last_input_file:
|
||||||
|
with st.spinner("Validiere Input-Datei…"):
|
||||||
|
val_result = validate_input_file(input_path, app_config.input_schema, app_config.month_sheet_pattern)
|
||||||
|
|
||||||
|
if val_result.is_valid:
|
||||||
|
records = parse_input_file(
|
||||||
|
input_path,
|
||||||
|
app_config.input_schema,
|
||||||
|
app_config.month_sheet_pattern,
|
||||||
|
app_config.skip_empty_name,
|
||||||
|
app_config.skip_empty_antragsnummer,
|
||||||
|
)
|
||||||
|
st.session_state.parsed_records = records
|
||||||
|
st.session_state.input_validated = True
|
||||||
|
else:
|
||||||
|
st.session_state.parsed_records = []
|
||||||
|
st.session_state.input_validated = False
|
||||||
|
|
||||||
|
st.session_state.cached_val_result = val_result
|
||||||
|
st.session_state.last_input_file = selected_input
|
||||||
|
else:
|
||||||
|
val_result = st.session_state.cached_val_result
|
||||||
|
|
||||||
|
if not val_result.is_valid:
|
||||||
|
st.markdown(
|
||||||
|
'<div style="border:2px solid #e74c3c; border-radius:8px; padding:12px; '
|
||||||
|
'background:#fdedec; margin-bottom:12px">'
|
||||||
|
'<span style="color:#b03a2e; font-weight:bold; font-size:1.1em">'
|
||||||
|
'❌ Validierung fehlgeschlagen</span></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
for err in val_result.errors:
|
||||||
|
st.error(f"🔴 {err}")
|
||||||
|
st.session_state.input_validated = False
|
||||||
|
else:
|
||||||
|
if val_result.warnings:
|
||||||
|
st.markdown(
|
||||||
|
'<div style="border:2px solid #f39c12; border-radius:8px; padding:8px 12px; '
|
||||||
|
'background:#fef9e7; margin-bottom:8px">'
|
||||||
|
'<span style="color:#7d6608; font-weight:bold">'
|
||||||
|
'⚠️ Hinweise</span></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
for warn in val_result.warnings:
|
||||||
|
st.warning(warn)
|
||||||
|
|
||||||
|
# Zeige Zusammenfassung aus gecachten Records
|
||||||
|
records = st.session_state.parsed_records
|
||||||
|
employees = get_unique_employees(records)
|
||||||
|
antragsnummern = get_unique_antragsnummern(records)
|
||||||
|
|
||||||
|
st.success(f"✅ {len(records)} Einträge in {val_result.info.get('sheets_with_data', 0)} Monats-Sheets")
|
||||||
|
pills = " ".join(
|
||||||
|
f'<span style="display:inline-block; background:#e8f0fe; color:#1a73e8; '
|
||||||
|
f'border-radius:16px; padding:4px 12px; margin:3px; font-size:0.9em; '
|
||||||
|
f'white-space:nowrap">{an}</span>'
|
||||||
|
for an in antragsnummern
|
||||||
|
)
|
||||||
|
st.markdown(f"**Antragsnummern:**<br>{pills}", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
with col2:
|
||||||
|
st.subheader("2. Template")
|
||||||
|
template_files = get_available_templates(app_config)
|
||||||
|
if not template_files:
|
||||||
|
st.warning("Keine .pdf-Dateien im template/-Ordner gefunden.")
|
||||||
|
selected_template = None
|
||||||
|
else:
|
||||||
|
# Lock-Files herausfiltern
|
||||||
|
real_templates = [t for t in template_files if not t.startswith("~")]
|
||||||
|
if not real_templates:
|
||||||
|
real_templates = template_files
|
||||||
|
# Bei nur einem Template: automatisch selektieren (keine Leerauswahl)
|
||||||
|
if len(real_templates) == 1:
|
||||||
|
selected_template = st.selectbox(
|
||||||
|
"Template wählen:",
|
||||||
|
options=real_templates,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
selected_template = st.selectbox(
|
||||||
|
"Template wählen:",
|
||||||
|
options=[""] + real_templates,
|
||||||
|
format_func=lambda x: "– Bitte wählen –" if x == "" else x,
|
||||||
|
)
|
||||||
|
|
||||||
|
if selected_template:
|
||||||
|
# Nur validieren wenn sich das Template geändert hat
|
||||||
|
if selected_template != st.session_state.last_template_file:
|
||||||
|
template_path = Path(app_config.template_dir) / selected_template
|
||||||
|
with st.spinner("Validiere Template…"):
|
||||||
|
tpl_result = validate_template(template_path, app_config.output_mapping)
|
||||||
|
st.session_state.cached_tpl_result = tpl_result
|
||||||
|
st.session_state.last_template_file = selected_template
|
||||||
|
st.session_state.template_validated = tpl_result.is_valid
|
||||||
|
else:
|
||||||
|
tpl_result = st.session_state.cached_tpl_result
|
||||||
|
|
||||||
|
if not tpl_result.is_valid:
|
||||||
|
st.markdown(
|
||||||
|
'<div style="border:2px solid #e74c3c; border-radius:8px; padding:12px; '
|
||||||
|
'background:#fdedec; margin-bottom:12px">'
|
||||||
|
'<span style="color:#b03a2e; font-weight:bold; font-size:1.1em">'
|
||||||
|
'❌ Validierung fehlgeschlagen</span></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
for err in tpl_result.errors:
|
||||||
|
st.error(f"🔴 {err}")
|
||||||
|
st.session_state.template_validated = False
|
||||||
|
else:
|
||||||
|
st.session_state.template_validated = True
|
||||||
|
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; '
|
||||||
|
'background:#fef9e7; margin-bottom:8px">'
|
||||||
|
'<span style="color:#7d6608; font-weight:bold">'
|
||||||
|
'⚠️ Hinweise</span></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
for warn in tpl_result.warnings:
|
||||||
|
st.warning(warn)
|
||||||
|
|
||||||
|
# --- Mitarbeiter-Auswahl ---
|
||||||
|
st.subheader("3. Mitarbeiter auswählen")
|
||||||
|
|
||||||
|
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:")
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
selected_employees = []
|
||||||
|
if not st.session_state.input_validated:
|
||||||
|
st.info("Bitte zuerst eine gültige Input-Datei auswählen.")
|
||||||
|
|
||||||
|
# --- Hilfsfunktion zum Öffnen von Dateien/Ordnern ---
|
||||||
|
def open_path(path_str: str):
|
||||||
|
"""Öffnet eine Datei mit dem Standardprogramm oder einen Ordner im Dateimanager.
|
||||||
|
Funktioniert plattformübergreifend: macOS, Windows, Linux."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
os.startfile(path_str)
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
subprocess.run(["open", path_str])
|
||||||
|
else:
|
||||||
|
subprocess.run(["xdg-open", path_str])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Generate Button ---
|
||||||
|
st.divider()
|
||||||
|
gen_col1, gen_col2, gen_col3 = st.columns([1, 1, 2])
|
||||||
|
with gen_col1:
|
||||||
|
generate_clicked = st.button(
|
||||||
|
"🚀 Tätigkeitsnachweise generieren",
|
||||||
|
type="primary",
|
||||||
|
disabled=not (st.session_state.input_validated and st.session_state.template_validated and selected_employees),
|
||||||
|
)
|
||||||
|
|
||||||
|
if generate_clicked:
|
||||||
|
if not selected_employees:
|
||||||
|
st.error("Keine Mitarbeiter ausgewählt.")
|
||||||
|
else:
|
||||||
|
# Filtere Records auf selektierte Mitarbeiter
|
||||||
|
all_groups = group_by_employee(st.session_state.parsed_records)
|
||||||
|
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)
|
||||||
|
|
||||||
|
with st.spinner(f"Generiere {len(selected_groups)} Tätigkeitsnachweise…"):
|
||||||
|
try:
|
||||||
|
generated = generate_activity_reports(
|
||||||
|
employee_records=selected_groups,
|
||||||
|
template_path=template_path,
|
||||||
|
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:
|
||||||
|
st.error(f"Fehler bei der Generierung: {e}")
|
||||||
|
|
||||||
|
# --- Generierte Dateien anzeigen (aus Session State, übersteht Reruns) ---
|
||||||
|
if "generated_files" in st.session_state and st.session_state.generated_files:
|
||||||
|
generated = st.session_state.generated_files
|
||||||
|
st.success(f"✅ {len(generated)} Tätigkeitsnachweise erstellt:")
|
||||||
|
for i, fpath in enumerate(generated):
|
||||||
|
col_file, col_open, col_folder = st.columns([0.78, 0.11, 0.11])
|
||||||
|
with col_file:
|
||||||
|
st.markdown(f"📄 `{fpath.name}`")
|
||||||
|
with col_open:
|
||||||
|
st.button(
|
||||||
|
"🔗 Öffnen",
|
||||||
|
key=f"open_file_{i}",
|
||||||
|
on_click=open_path,
|
||||||
|
args=(str(fpath),),
|
||||||
|
help="Datei mit Standardprogramm öffnen",
|
||||||
|
use_container_width=True,
|
||||||
|
)
|
||||||
|
with col_folder:
|
||||||
|
st.button(
|
||||||
|
"📂",
|
||||||
|
key=f"open_folder_{i}",
|
||||||
|
on_click=open_path,
|
||||||
|
args=(str(fpath.parent),),
|
||||||
|
help="Ordner im Explorer ö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,48 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
set "PYTHONUTF8=1"
|
||||||
|
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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Doppelklickbares Cold-Run-Script für macOS
|
||||||
|
# Nimmt automatisch die erste Input-Datei + erstes Template und erzeugt
|
||||||
|
# einen Tätigkeitsnachweis für die erste gelistete Person – ohne GUI.
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
VENV_DIR=".venv"
|
||||||
|
|
||||||
|
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||||
|
echo "[FEHLER] Virtuelle Umgebung nicht gefunden."
|
||||||
|
echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten."
|
||||||
|
read -p "Drücke Enter zum Beenden..."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Stelle sicher, dass Abhängigkeiten installiert sind…"
|
||||||
|
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
"$VENV_DIR/bin/python" coldrun.py
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
read -p "Drücke Enter zum Beenden..."
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cold-Run Tätigkeitsnachweis-Generator.
|
||||||
|
|
||||||
|
Nimmt automatisch die erste Input-Datei und das erste Template,
|
||||||
|
erzeugt einen Tätigkeitsnachweis für die erste gelistete Person
|
||||||
|
und öffnet das Ergebnis (macOS) – ohne GUI, ohne Fragen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Projekt-Root
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.config import (
|
||||||
|
load_settings,
|
||||||
|
load_input_schema,
|
||||||
|
load_output_mapping,
|
||||||
|
get_available_input_files,
|
||||||
|
get_available_templates,
|
||||||
|
)
|
||||||
|
from src.parser import parse_input_file, group_by_employee, get_unique_employees
|
||||||
|
from src.validator import validate_input_file, validate_template
|
||||||
|
from src.writer import generate_activity_reports
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 50)
|
||||||
|
print(" Tätigkeitsnachweis-Generator (Cold Run)")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── Konfiguration laden ──────────────────────────────────────────
|
||||||
|
config_dir = PROJECT_ROOT / "config"
|
||||||
|
config = load_settings(config_dir, PROJECT_ROOT)
|
||||||
|
config.input_schema = load_input_schema(config_dir)
|
||||||
|
config.output_mapping = load_output_mapping(config_dir)
|
||||||
|
|
||||||
|
# ── Erste Input-Datei ermitteln ───────────────────────────────────
|
||||||
|
input_files = get_available_input_files(config)
|
||||||
|
if not input_files:
|
||||||
|
print("[FEHLER] Keine .xlsx-Dateien im input/-Ordner gefunden.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
input_file = input_files[0]
|
||||||
|
input_path = Path(config.input_dir) / input_file
|
||||||
|
print(f"[INFO] Input-Datei: {input_file}")
|
||||||
|
|
||||||
|
# ── Erstes Template ermitteln ─────────────────────────────────────
|
||||||
|
template_files = get_available_templates(config)
|
||||||
|
if not template_files:
|
||||||
|
print("[FEHLER] Keine .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…")
|
||||||
|
records = parse_input_file(
|
||||||
|
filepath=input_path,
|
||||||
|
schema=config.input_schema,
|
||||||
|
month_sheet_pattern=config.month_sheet_pattern,
|
||||||
|
skip_empty_name=config.skip_empty_name,
|
||||||
|
skip_empty_antragsnummer=config.skip_empty_antragsnummer,
|
||||||
|
)
|
||||||
|
|
||||||
|
employees = get_unique_employees(records)
|
||||||
|
print(f"[INFO] {len(records)} Einträge für {len(employees)} Personen gefunden.")
|
||||||
|
|
||||||
|
# ── Erste Person ermitteln ────────────────────────────────────────
|
||||||
|
grouped = group_by_employee(records)
|
||||||
|
first_person = sorted(grouped.keys())[0]
|
||||||
|
first_records = grouped[first_person]
|
||||||
|
print(f"[INFO] Erste Person: {first_person} ({len(first_records)} Monate)")
|
||||||
|
|
||||||
|
# ── Reports generieren ────────────────────────────────────────────
|
||||||
|
print()
|
||||||
|
print("[INFO] Generiere Tätigkeitsnachweis…")
|
||||||
|
single_person = {first_person: first_records}
|
||||||
|
output_dir = Path(config.output_dir)
|
||||||
|
generated = generate_activity_reports(
|
||||||
|
employee_records=single_person,
|
||||||
|
template_path=template_path,
|
||||||
|
mapping=config.output_mapping,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename_pattern=config.filename_pattern,
|
||||||
|
fontsize=config.fontsize,
|
||||||
|
)
|
||||||
|
|
||||||
|
output_file = generated[0]
|
||||||
|
print(f"[OK] Erstellt: {output_file.name}")
|
||||||
|
print(f" Pfad: {output_file}")
|
||||||
|
|
||||||
|
# ── Datei öffnen (macOS) ──────────────────────────────────────────
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
print()
|
||||||
|
print("[INFO] Öffne Datei im Explorer")
|
||||||
|
subprocess.run(["open", str(output_file)], check=False)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 50)
|
||||||
|
print(" Fertig.")
|
||||||
|
print("=" * 50)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{"column": "A", "field_name": "ID", "type": "number", "required": false},
|
||||||
|
{"column": "B", "field_name": "Kommentar", "type": "string", "required": false},
|
||||||
|
{"column": "C", "field_name": "Antragsnummer", "type": "string", "required": true},
|
||||||
|
{"column": "D", "field_name": "Name", "type": "string", "required": true},
|
||||||
|
{"column": "E", "field_name": "Qualifikation", "type": "string", "required": true},
|
||||||
|
{"column": "F", "field_name": "Funktion", "type": "string", "required": true},
|
||||||
|
{"column": "G", "field_name": "Stellenanteil in %", "type": "number", "required": true},
|
||||||
|
{"column": "H", "field_name": "Monat (MM.JJ)", "type": "string", "required": true},
|
||||||
|
{"column": "I", "field_name": "Tätigkeit", "type": "string", "required": true}
|
||||||
|
]
|
||||||
@@ -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}
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[paths]
|
||||||
|
input_dir = input
|
||||||
|
template_dir = template
|
||||||
|
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").
|
||||||
|
month_sheet_pattern = * 20??
|
||||||
|
|
||||||
|
# Skip rows where the employee name is empty
|
||||||
|
skip_empty_name = true
|
||||||
|
|
||||||
|
# Skip rows where Antragsnummer is missing
|
||||||
|
skip_empty_antragsnummer = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
streamlit>=1.28,<2.0
|
||||||
|
openpyxl>=3.1,<4.0
|
||||||
|
PyMuPDF>=1.23,<2.0
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Stop an existing instance of this application's Windows server."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_CONFIRM_SCRIPT = r'''
|
||||||
|
function Confirm-ServerRestart {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$answer = [System.Windows.Forms.MessageBox]::Show(
|
||||||
|
"Es läuft bereits eine Version des Tätigkeitsnachweis-Generators.`n`nSoll das alte Programm beendet und die neue Version gestartet werden?`nDie aktuellen Auswahlen gehen dabei verloren.",
|
||||||
|
'Tätigkeitsnachweis-Generator',
|
||||||
|
[System.Windows.Forms.MessageBoxButtons]::YesNo,
|
||||||
|
[System.Windows.Forms.MessageBoxIcon]::Question,
|
||||||
|
[System.Windows.Forms.MessageBoxDefaultButton]::Button2
|
||||||
|
)
|
||||||
|
return $answer -eq [System.Windows.Forms.DialogResult]::Yes
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def stop_existing_server(port: int = 8501) -> bool:
|
||||||
|
"""Ask before stopping this app. Return False if the user declines."""
|
||||||
|
if os.name != "nt":
|
||||||
|
raise RuntimeError("Der kontrollierte Server-Neustart unterstützt nur Windows.")
|
||||||
|
|
||||||
|
app_path = Path(__file__).resolve().with_name("app.py")
|
||||||
|
app_paths = [str(app_path)]
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
# A locally built EXE in 2_build/ must also recognize the sibling
|
||||||
|
# development application, whose path differs from _MEIPASS/app.py.
|
||||||
|
exe_dir = Path(sys.executable).resolve().parent
|
||||||
|
development_app = exe_dir.parent / "0_application" / "app.py"
|
||||||
|
if exe_dir.name.lower() == "2_build" and development_app.is_file():
|
||||||
|
app_paths.append(str(development_app))
|
||||||
|
# Pass paths through the environment, never interpolate them into shell code.
|
||||||
|
env = dict(os.environ, APP_RESTART_PATH=str(app_path),
|
||||||
|
APP_RESTART_PATHS=json.dumps(app_paths), APP_RESTART_PID=str(os.getpid()),
|
||||||
|
APP_RESTART_PORT=str(port),
|
||||||
|
APP_RESTART_VENV=str(Path(app_path).parent / ".venv" / "Scripts"))
|
||||||
|
script = _CONFIRM_SCRIPT + r'''
|
||||||
|
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
try {
|
||||||
|
$port = [int]$env:APP_RESTART_PORT
|
||||||
|
$listeners = @(Get-NetTCPConnection -State Listen -ErrorAction Stop |
|
||||||
|
Where-Object { $_.LocalPort -eq $port })
|
||||||
|
$processIds = @($listeners | Select-Object -ExpandProperty OwningProcess -Unique)
|
||||||
|
$targets = @()
|
||||||
|
foreach ($processId in $processIds) {
|
||||||
|
if ($processId -eq [int]$env:APP_RESTART_PID) {
|
||||||
|
throw "Der aktuelle Prozess belegt bereits Port $port."
|
||||||
|
}
|
||||||
|
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId"
|
||||||
|
if (-not $process) { continue }
|
||||||
|
$isExe = $process.Name -eq 'Tätigkeitsnachweis-Generator.exe'
|
||||||
|
$command = $process.CommandLine
|
||||||
|
$matchesApp = $false
|
||||||
|
foreach ($appPath in ($env:APP_RESTART_PATHS | ConvertFrom-Json)) {
|
||||||
|
$appArgument = '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?' +
|
||||||
|
[regex]::Escape($appPath) + '"?(?:\s|$)'
|
||||||
|
if ($command -match $appArgument) { $matchesApp = $true }
|
||||||
|
}
|
||||||
|
$isDev = $process.Name -match '^(python(?:w)?(?:\d+(?:\.\d+)*)?(?:w)?|streamlit)\.exe$' -and
|
||||||
|
$command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+' -and
|
||||||
|
($matchesApp -or
|
||||||
|
($command -match '(?i)\bstreamlit(?:\.exe)?"?\s+run\s+"?app\.py"?(?:\s|$)' -and
|
||||||
|
[IO.Path]::GetDirectoryName($process.ExecutablePath) -eq $env:APP_RESTART_VENV))
|
||||||
|
if (-not ($isExe -or $isDev)) {
|
||||||
|
throw "Port $port wird von einem anderen Programm verwendet (PID $processId, $($process.Name)). Bitte manuell prüfen."
|
||||||
|
}
|
||||||
|
$targets += $processId
|
||||||
|
}
|
||||||
|
if ($targets.Count -gt 0 -and -not (Confirm-ServerRestart)) {
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
foreach ($processId in $targets) {
|
||||||
|
Stop-Process -Id $processId -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
$deadline = (Get-Date).AddSeconds(10)
|
||||||
|
do {
|
||||||
|
$remaining = @(Get-NetTCPConnection -State Listen -ErrorAction Stop |
|
||||||
|
Where-Object { $_.LocalPort -eq $port })
|
||||||
|
if ($remaining.Count -eq 0) { break }
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
} while ((Get-Date) -lt $deadline)
|
||||||
|
if ($remaining.Count -gt 0) { throw "Port $port wurde nicht rechtzeitig freigegeben." }
|
||||||
|
ConvertTo-Json -Compress -InputObject @($targets)
|
||||||
|
} catch {
|
||||||
|
[Console]::Error.WriteLine($_.Exception.Message)
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
result = subprocess.run(
|
||||||
|
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||||
|
env=env, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||||
|
)
|
||||||
|
if result.returncode == 2:
|
||||||
|
return False
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError((result.stderr or "").strip() or "Der bestehende Server konnte nicht beendet werden.")
|
||||||
|
stopped = json.loads(result.stdout.strip() or "[]")
|
||||||
|
if stopped:
|
||||||
|
print(f"[INFO] Vorherige Instanz beendet (PID: {stopped}).", flush=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
if not stop_existing_server():
|
||||||
|
print("[INFO] Die bisherige Version bleibt geöffnet. Keine neue Instanz gestartet.")
|
||||||
|
raise SystemExit(2)
|
||||||
|
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
||||||
|
print(f"[FEHLER] {exc}")
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tätigkeitsnachweis-Generator package."""
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tätigkeitsnachweis-Generator – Konfigurations-Loader."""
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
import json
|
||||||
|
import fnmatch
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .models import AppConfig, FieldSchema, OutputMapping
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings(config_dir: Path, project_root: Path) -> AppConfig:
|
||||||
|
"""Lädt settings.ini und gibt ein AppConfig-Objekt zurück."""
|
||||||
|
ini_path = config_dir / "settings.ini"
|
||||||
|
if not ini_path.exists():
|
||||||
|
raise FileNotFoundError(f"settings.ini nicht gefunden: {ini_path}")
|
||||||
|
|
||||||
|
parser = configparser.ConfigParser()
|
||||||
|
parser.read(ini_path, encoding="utf-8")
|
||||||
|
|
||||||
|
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")),
|
||||||
|
template_dir=str(project_root / paths.get("template_dir", "template")),
|
||||||
|
output_dir=str(project_root / paths.get("output_dir", "output")),
|
||||||
|
filename_pattern=output.get("filename_pattern", "{last_name}_{first_name}_Tätigkeitsnachweis.xlsx"),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_input_schema(config_dir: Path) -> list[FieldSchema]:
|
||||||
|
"""Lädt input-fields.json."""
|
||||||
|
schema_path = config_dir / "input-fields.json"
|
||||||
|
if not schema_path.exists():
|
||||||
|
raise FileNotFoundError(f"input-fields.json nicht gefunden: {schema_path}")
|
||||||
|
|
||||||
|
with open(schema_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
return [FieldSchema(**item) for item in data]
|
||||||
|
|
||||||
|
|
||||||
|
def load_output_mapping(config_dir: Path) -> list[OutputMapping]:
|
||||||
|
"""Lädt output-fields.json."""
|
||||||
|
mapping_path = config_dir / "output-fields.json"
|
||||||
|
if not mapping_path.exists():
|
||||||
|
raise FileNotFoundError(f"output-fields.json nicht gefunden: {mapping_path}")
|
||||||
|
|
||||||
|
with open(mapping_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
return [OutputMapping(**item) for item in data]
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_files(directory: Path, extension: str = ".xlsx") -> list[str]:
|
||||||
|
"""Listet alle Dateien mit der angegebenen Erweiterung im Verzeichnis auf."""
|
||||||
|
if not directory.exists():
|
||||||
|
return []
|
||||||
|
files = sorted(
|
||||||
|
f.name for f in directory.iterdir()
|
||||||
|
if f.is_file() and f.suffix.lower() == extension and not f.name.startswith("~")
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_input_files(config: AppConfig) -> list[str]:
|
||||||
|
"""Listet alle .xlsx-Dateien im input-Ordner."""
|
||||||
|
return get_available_files(Path(config.input_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_templates(config: AppConfig) -> list[str]:
|
||||||
|
"""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:
|
||||||
|
"""Prüft, ob ein Sheet-Name dem Monatsmuster entspricht."""
|
||||||
|
return fnmatch.fnmatch(sheet_name.strip(), pattern)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Tätigkeitsnachweis-Generator – Datenmodelle."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmployeeRecord:
|
||||||
|
"""Eine Zeile aus einem Monats-Sheet der Input-Datei."""
|
||||||
|
name: str
|
||||||
|
antragsnummer: str
|
||||||
|
qualifikation: str
|
||||||
|
funktion: str
|
||||||
|
stellenanteil: str
|
||||||
|
monat: str # z. B. "01.2025"
|
||||||
|
taetigkeit: str
|
||||||
|
id: Optional[int] = None
|
||||||
|
kommentar: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def month_index(self) -> int:
|
||||||
|
"""Extrahiere den Monatsindex (1–12) aus dem Monatsfeld."""
|
||||||
|
try:
|
||||||
|
return int(self.monat.split(".")[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def year(self) -> int:
|
||||||
|
"""Extrahiere das Jahr aus dem Monatsfeld."""
|
||||||
|
try:
|
||||||
|
return int(self.monat.split(".")[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FieldSchema:
|
||||||
|
"""Definition eines Feldes aus input-fields.json."""
|
||||||
|
column: str
|
||||||
|
field_name: str
|
||||||
|
type: str
|
||||||
|
required: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OutputMapping:
|
||||||
|
"""Definition eines Mappings aus output-fields.json."""
|
||||||
|
source_field: str
|
||||||
|
format: Optional[str] = None
|
||||||
|
pdf_field: Optional[str] = None # Name des _Field-Platzhalters im PDF (z.B. "Auftragsnummer_Field")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppConfig:
|
||||||
|
"""Gesamte App-Konfiguration."""
|
||||||
|
input_dir: str
|
||||||
|
template_dir: str
|
||||||
|
output_dir: str
|
||||||
|
filename_pattern: str
|
||||||
|
input_schema: list = field(default_factory=list)
|
||||||
|
output_mapping: list = field(default_factory=list)
|
||||||
|
month_sheet_pattern: str = "* 20??"
|
||||||
|
skip_empty_name: bool = True
|
||||||
|
skip_empty_antragsnummer: bool = True
|
||||||
|
fontsize: float = 11.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationResult:
|
||||||
|
"""Ergebnis einer Validierung."""
|
||||||
|
is_valid: bool
|
||||||
|
errors: list = field(default_factory=list)
|
||||||
|
warnings: list = field(default_factory=list)
|
||||||
|
info: dict = field(default_factory=dict)
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Input-Datei-Parser: liest monatliche Sheets und erzeugt EmployeeRecords."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.utils import column_index_from_string
|
||||||
|
|
||||||
|
from .config import is_month_sheet
|
||||||
|
from .models import EmployeeRecord, FieldSchema
|
||||||
|
|
||||||
|
|
||||||
|
def _to_text(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int_or_none(value: Any) -> Optional[int]:
|
||||||
|
if value is None or str(value).strip() == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_percent_string(value: Any) -> str:
|
||||||
|
"""Wandelt einen Prozentwert in einen String ohne %-Zeichen um.
|
||||||
|
|
||||||
|
Excel speichert Prozentwerte als Dezimalbruch (50 % = 0.5).
|
||||||
|
Falls der Rohwert eine Zahl ≤ 1 ist, wird er als Excel-Prozentwert
|
||||||
|
interpretiert und mit 100 multipliziert.
|
||||||
|
"""
|
||||||
|
if value is None or str(value).strip() == "":
|
||||||
|
return ""
|
||||||
|
# Falls es bereits ein String mit %-Zeichen ist
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().rstrip("% ")
|
||||||
|
# Numerischer Wert: Excel-Prozentwert (0.5 = 50 %) oder Ganzzahl (50 = 50 %)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
if 0 < value <= 1:
|
||||||
|
return str(int(value * 100))
|
||||||
|
if value == 0:
|
||||||
|
return "0"
|
||||||
|
# Wert > 1: bereits als Ganzzahl gemeint (z. B. 50)
|
||||||
|
return str(int(value))
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_column_map(schema: list[FieldSchema]) -> dict[str, int]:
|
||||||
|
"""Baut ein Mapping von field_name → 0-basierter Spaltenindex."""
|
||||||
|
col_map: dict[str, int] = {}
|
||||||
|
for field in schema:
|
||||||
|
col_map[field.field_name] = column_index_from_string(field.column) - 1
|
||||||
|
return col_map
|
||||||
|
|
||||||
|
|
||||||
|
def parse_input_file(
|
||||||
|
filepath: Path,
|
||||||
|
schema: list[FieldSchema],
|
||||||
|
month_sheet_pattern: str = "* 20??",
|
||||||
|
skip_empty_name: bool = True,
|
||||||
|
skip_empty_antragsnummer: bool = True,
|
||||||
|
) -> list[EmployeeRecord]:
|
||||||
|
"""
|
||||||
|
Öffnet die Input-Datei, iteriert über alle Monats-Sheets
|
||||||
|
und gibt eine Liste von EmployeeRecords zurück.
|
||||||
|
"""
|
||||||
|
wb = load_workbook(filepath, data_only=True)
|
||||||
|
col_map = _build_column_map(schema)
|
||||||
|
records: list[EmployeeRecord] = []
|
||||||
|
|
||||||
|
for sheet_name in wb.sheetnames:
|
||||||
|
if not is_month_sheet(sheet_name, month_sheet_pattern):
|
||||||
|
continue
|
||||||
|
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
# Header ist Zeile 1, Daten ab Zeile 2
|
||||||
|
# min_col=1 explizit setzen für korrekte Spaltenzuordnung
|
||||||
|
schema_col_count = len(schema)
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=1, max_col=schema_col_count, values_only=True):
|
||||||
|
name = _to_text(row[col_map.get("Name", 0)]) if len(row) > col_map.get("Name", 0) else ""
|
||||||
|
antragsnummer = _to_text(row[col_map.get("Antragsnummer", 0)]) if len(row) > col_map.get("Antragsnummer", 0) else ""
|
||||||
|
|
||||||
|
if skip_empty_name and not name:
|
||||||
|
continue
|
||||||
|
if skip_empty_antragsnummer and not antragsnummer:
|
||||||
|
continue
|
||||||
|
|
||||||
|
qualifikation = _to_text(row[col_map.get("Qualifikation", 0)]) if len(row) > col_map.get("Qualifikation", 0) else ""
|
||||||
|
funktion = _to_text(row[col_map.get("Funktion", 0)]) if len(row) > col_map.get("Funktion", 0) else ""
|
||||||
|
stellenanteil_raw = row[col_map.get("Stellenanteil in %", 0)] if len(row) > col_map.get("Stellenanteil in %", 0) else None
|
||||||
|
stellenanteil = _to_percent_string(stellenanteil_raw)
|
||||||
|
monat = _to_text(row[col_map.get("Monat (MM.JJ)", 0)]) if len(row) > col_map.get("Monat (MM.JJ)", 0) else ""
|
||||||
|
taetigkeit = _to_text(row[col_map.get("Tätigkeit", 0)]) if len(row) > col_map.get("Tätigkeit", 0) else ""
|
||||||
|
record_id = _to_int_or_none(row[col_map.get("ID", 0)]) if len(row) > col_map.get("ID", 0) else None
|
||||||
|
kommentar = _to_text(row[col_map.get("Kommentar", 1)]) if "Kommentar" in col_map and len(row) > col_map["Kommentar"] else None
|
||||||
|
|
||||||
|
records.append(EmployeeRecord(
|
||||||
|
id=record_id,
|
||||||
|
name=name,
|
||||||
|
antragsnummer=antragsnummer,
|
||||||
|
qualifikation=qualifikation,
|
||||||
|
funktion=funktion,
|
||||||
|
stellenanteil=stellenanteil,
|
||||||
|
monat=monat,
|
||||||
|
taetigkeit=taetigkeit,
|
||||||
|
kommentar=kommentar,
|
||||||
|
))
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_employee(records: list[EmployeeRecord]) -> dict[str, list[EmployeeRecord]]:
|
||||||
|
"""Gruppiert Records nach Mitarbeitername."""
|
||||||
|
groups: dict[str, list[EmployeeRecord]] = {}
|
||||||
|
for record in records:
|
||||||
|
if record.name not in groups:
|
||||||
|
groups[record.name] = []
|
||||||
|
groups[record.name].append(record)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_antragsnummern(records: list[EmployeeRecord]) -> list[str]:
|
||||||
|
"""Gibt eine sortierte Liste aller eindeutigen Antragsnummern zurück."""
|
||||||
|
return sorted(set(r.antragsnummer for r in records if r.antragsnummer))
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_employees(records: list[EmployeeRecord]) -> list[str]:
|
||||||
|
"""Gibt eine sortierte Liste aller eindeutigen Mitarbeiternamen zurück."""
|
||||||
|
return sorted(set(r.name for r in records if r.name))
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Validierung von Input-Dateien und Templates."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.utils import column_index_from_string
|
||||||
|
|
||||||
|
from .config import is_month_sheet
|
||||||
|
from .models import FieldSchema, OutputMapping, ValidationResult
|
||||||
|
|
||||||
|
|
||||||
|
def validate_input_file(
|
||||||
|
filepath: Path,
|
||||||
|
schema: list[FieldSchema],
|
||||||
|
month_sheet_pattern: str = "* 20??",
|
||||||
|
) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Validiert eine Input-Datei:
|
||||||
|
- Datei existiert
|
||||||
|
- Erwartete Spalten sind vorhanden (Header in Zeile 1)
|
||||||
|
- Mindestens ein Monats-Daten-Sheet enthält Daten
|
||||||
|
- Nicht-Monats-Sheets (z. B. "Alle Rollen") werden übersprungen
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
info: dict = {}
|
||||||
|
|
||||||
|
if not filepath.exists():
|
||||||
|
errors.append(f"Input-Datei nicht gefunden: {filepath}")
|
||||||
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath, data_only=True)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Datei konnte nicht geöffnet werden: {e}")
|
||||||
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
|
sheet_count = len(wb.sheetnames)
|
||||||
|
info["sheet_count"] = sheet_count
|
||||||
|
info["sheet_names"] = wb.sheetnames
|
||||||
|
|
||||||
|
# Prüfe nur Monats-Sheets (überspringe z. B. "Alle Rollen")
|
||||||
|
schema_col_count = len(schema)
|
||||||
|
sheets_with_data = 0
|
||||||
|
for sheet_name in wb.sheetnames:
|
||||||
|
if not is_month_sheet(sheet_name, month_sheet_pattern):
|
||||||
|
continue
|
||||||
|
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
if ws.max_row < 2:
|
||||||
|
continue # Kein Header + Daten
|
||||||
|
|
||||||
|
# min_col=1 explizit setzen, um Verschiebungen bei
|
||||||
|
# ws.min_column > 1 zu vermeiden
|
||||||
|
header_row = list(ws.iter_rows(
|
||||||
|
min_row=1, max_row=1, min_col=1, max_col=schema_col_count,
|
||||||
|
values_only=True,
|
||||||
|
))[0]
|
||||||
|
missing_cols = []
|
||||||
|
for field in schema:
|
||||||
|
col_idx = column_index_from_string(field.column) - 1
|
||||||
|
if col_idx >= len(header_row):
|
||||||
|
missing_cols.append(f"{field.column} ({field.field_name})")
|
||||||
|
continue
|
||||||
|
header_val = str(header_row[col_idx]).strip() if header_row[col_idx] is not None else ""
|
||||||
|
if header_val != field.field_name:
|
||||||
|
warnings.append(
|
||||||
|
f"Sheet '{sheet_name}', Spalte {field.column}: "
|
||||||
|
f"Erwartet '{field.field_name}', gefunden '{header_val}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not missing_cols:
|
||||||
|
sheets_with_data += 1
|
||||||
|
|
||||||
|
# Zähle Datenzeilen
|
||||||
|
data_rows = ws.max_row - 1
|
||||||
|
info[f"rows_{sheet_name}"] = data_rows
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
return ValidationResult(
|
||||||
|
is_valid=len(errors) == 0,
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
info=info,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_template(
|
||||||
|
template_path: Path,
|
||||||
|
mapping: list[OutputMapping],
|
||||||
|
) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Validiert ein PDF-Template:
|
||||||
|
- Datei existiert
|
||||||
|
- Alle _Field-Platzhalter (pdf_field) sind im PDF auf irgendeiner Seite vorhanden
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
info: dict = {}
|
||||||
|
|
||||||
|
if not template_path.exists():
|
||||||
|
errors.append(f"Template-Datei nicht gefunden: {template_path}")
|
||||||
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = fitz.open(template_path)
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"PDF-Template konnte nicht geöffnet werden: {e}")
|
||||||
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
|
if doc.page_count == 0:
|
||||||
|
errors.append("PDF-Template enthält keine Seiten.")
|
||||||
|
doc.close()
|
||||||
|
return ValidationResult(is_valid=False, errors=errors)
|
||||||
|
|
||||||
|
found_count = 0
|
||||||
|
missing_fields = []
|
||||||
|
|
||||||
|
for m in mapping:
|
||||||
|
if m.pdf_field is None:
|
||||||
|
continue # Kein Marker erwartet
|
||||||
|
|
||||||
|
# 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 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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ValidationResult(
|
||||||
|
is_valid=len(errors) == 0,
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
info=info,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
set "PYTHONUTF8=1"
|
||||||
|
cd /d "%~dp0"
|
||||||
|
title Tätigkeitsnachweis-Generator
|
||||||
|
echo ========================================
|
||||||
|
echo Tätigkeitsnachweis-Generator
|
||||||
|
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 [INFO] Erstelle virtuelle Umgebung...
|
||||||
|
python -m venv .venv
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [FEHLER] Konnte venv nicht erstellen.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Aktiviere venv und installiere Abhängigkeiten
|
||||||
|
call .venv\Scripts\activate.bat
|
||||||
|
echo [INFO] Installiere Abhängigkeiten...
|
||||||
|
pip install -r requirements.txt --quiet
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [FEHLER] Konnte Abhängigkeiten nicht installieren.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: Starte Streamlit
|
||||||
|
echo.
|
||||||
|
echo [INFO] Starte Tätigkeitsnachweis-Generator...
|
||||||
|
echo [INFO] Die App öffnet sich im Browser unter http://localhost:8501
|
||||||
|
echo.
|
||||||
|
".venv\Scripts\python.exe" server_lifecycle.py
|
||||||
|
if %ERRORLEVEL% equ 2 exit /b 0
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
".venv\Scripts\python.exe" -m streamlit run "%~dp0app.py" --server.port=8501 --server.address=localhost
|
||||||
|
|
||||||
|
pause
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Doppelklickbares Start-Script für macOS (.command wird von Finder automatisch im Terminal geöffnet)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " Tätigkeitsnachweis-Generator"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python"
|
||||||
|
else
|
||||||
|
echo "[FEHLER] Python wurde nicht gefunden."
|
||||||
|
echo "Bitte installiere Python 3.9+ von https://python.org"
|
||||||
|
read -p "Drücke Enter zum Beenden..."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Python: $("$PYTHON_BIN" --version)"
|
||||||
|
|
||||||
|
VENV_DIR=".venv"
|
||||||
|
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||||
|
if [[ -d "$VENV_DIR" ]]; then
|
||||||
|
echo "[INFO] Erstelle venv neu..."
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
echo "[INFO] Erstelle virtuelle Umgebung..."
|
||||||
|
"$PYTHON_BIN" -m venv "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Installiere Abhängigkeiten..."
|
||||||
|
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[INFO] Starte Tätigkeitsnachweis-Generator..."
|
||||||
|
echo "[INFO] Browser: http://localhost:8501"
|
||||||
|
echo ""
|
||||||
|
"$VENV_DIR/bin/streamlit" run app.py
|
||||||
|
|
||||||
|
read -p "Drücke Enter zum Beenden..."
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Always run from this script's directory.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " Tätigkeitsnachweis-Generator"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe ob Python verfügbar ist
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python"
|
||||||
|
else
|
||||||
|
echo "[FEHLER] Python wurde nicht gefunden."
|
||||||
|
echo "Bitte installiere Python 3.9 oder neuer von https://python.org"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Python gefunden:"
|
||||||
|
"$PYTHON_BIN" --version
|
||||||
|
|
||||||
|
# Prüfe ob venv existiert, sonst erstelle
|
||||||
|
VENV_DIR=".venv"
|
||||||
|
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||||
|
if [[ -d "$VENV_DIR" ]]; then
|
||||||
|
echo "[INFO] Vorhandene venv ist nicht kompatibel. Erstelle neu..."
|
||||||
|
rm -rf "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
echo "[INFO] Erstelle virtuelle Umgebung..."
|
||||||
|
"$PYTHON_BIN" -m venv "$VENV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Aktiviere venv und installiere Abhängigkeiten
|
||||||
|
echo "[INFO] Installiere Abhängigkeiten..."
|
||||||
|
"$VENV_DIR/bin/pip" install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
# Starte Streamlit
|
||||||
|
echo ""
|
||||||
|
echo "[INFO] Starte Tätigkeitsnachweis-Generator..."
|
||||||
|
echo "[INFO] Die App öffnet sich im Browser unter http://localhost:8501"
|
||||||
|
echo ""
|
||||||
|
"$VENV_DIR/bin/streamlit" run app.py
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
|
|||||||
|
# Aktualisieren der PDF Datei
|
||||||
|
|
||||||
|
Wenn sich die PDF-Datei welche als Vorlagen genutzt wird geändert hat, kann diese nicht direkt vom Programm verwendet werden, da es die zu verwendenden Felder nicht erkennen kann. Wir müssen ihm kurz zeigen wo die zu nutzenden Felder sind.
|
||||||
|
|
||||||
|
Öffne Dazu eine bereits bestehende *.odg Datei im Ordner template und schau Dir die Textfelder an, welche in die entsprechenden Felder gelegt, damit das Programm diese später erkennen kann.
|
||||||
|
|
||||||
|
Öffne nun das neue PDF mit dem Programm Libre Office Writer und erzeuge in jedem Formularfeld ein Textfeld mit dem entspechenden Feldnamen wie im Beispiel-odg. Die Felder heißen einfach so wie sie im PDF genutzt werden plus Unterstrich Field. Also:
|
||||||
|
- Antragsnummer_Field
|
||||||
|
- Name_Field
|
||||||
|
- Qulifikation_Field
|
||||||
|
- Funktion_Field
|
||||||
|
- Stellenanteil_Field
|
||||||
|
- Monat_Field
|
||||||
|
|
||||||
|
In das Feld Tätigkeiten muss kein neues Textfeld eingefügt werden, weil es schon ein Textfeld ist, daher kann in dieses direkt
|
||||||
|
- Tätigkeiten_Field
|
||||||
|
|
||||||
|
eingetragen werden.
|
||||||
|
|
||||||
|
Auf Seite zwei muss nur ein Feld
|
||||||
|
- Datum_Field
|
||||||
|
|
||||||
|
erzeugt werden. Denn dieses kann einfach kopiert und im zweiten Datumfeld plaziert werden.
|
||||||
|
Für die beiden Ortsfelder muss auch jeweils ein Textfeld eingefügt werden, dieses enthält aber einfach den Text:
|
||||||
|
- Dresden
|
||||||
|
|
||||||
|
da es vom Programm nicht geändert wird
|
||||||
|
|
||||||
|
# Styling
|
||||||
|
Um einen einheitlichen look zu garantieren müssen alle eingefügten Textfelder wie folgt gestyled sein:
|
||||||
|
- das Textfeld selber füllt das komplette Feld bis an alle 4 Ränder aus
|
||||||
|
- Textgröße: 12 pt
|
||||||
|
- Font/Schriftart: Arial
|
||||||
|
- Absatz Textausrichtung: mittig
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,45 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
setlocal
|
||||||
|
set "PYTHONUTF8=1"
|
||||||
|
cd /d "%~dp0"
|
||||||
|
title Taetigkeitsnachweis-Generator - Tests
|
||||||
|
|
||||||
|
echo ========================================
|
||||||
|
echo Alle Tests ausfuehren
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
if not exist ".venv\Scripts\python.exe" (
|
||||||
|
echo [FEHLER] Die virtuelle Umgebung fehlt. Bitte zuerst start.bat ausfuehren.
|
||||||
|
set "TEST_EXIT_CODE=1"
|
||||||
|
goto finish
|
||||||
|
)
|
||||||
|
|
||||||
|
".venv\Scripts\python.exe" -c "import pytest" >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [INFO] Installiere pytest in der Projektumgebung...
|
||||||
|
".venv\Scripts\python.exe" -m pip install pytest
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [FEHLER] pytest konnte nicht installiert werden.
|
||||||
|
set "TEST_EXIT_CODE=1"
|
||||||
|
goto finish
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Keine Abbruchoption: Auch nach fehlgeschlagenen Tests laeuft die Suite weiter.
|
||||||
|
REM Ohne Cache funktioniert der Testlauf auch bei gesperrtem pytest-Cache.
|
||||||
|
".venv\Scripts\python.exe" -m pytest tests -v -p no:cacheprovider
|
||||||
|
set "TEST_EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
if "%TEST_EXIT_CODE%"=="0" (
|
||||||
|
echo [OK] Alle Tests bestanden.
|
||||||
|
) else (
|
||||||
|
echo [FEHLER] Testlauf nicht erfolgreich. Details stehen oben.
|
||||||
|
)
|
||||||
|
|
||||||
|
:finish
|
||||||
|
REM Fuer automatisierte Aufrufe: test.bat --no-pause
|
||||||
|
if /I not "%~1"=="--no-pause" pause
|
||||||
|
exit /b %TEST_EXIT_CODE%
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Doppelklickbares Test-Script für macOS (.command wird von Finder automatisch im Terminal geöffnet)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " Tätigkeitsnachweis-Generator – Tests"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
VENV_DIR=".venv"
|
||||||
|
|
||||||
|
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
||||||
|
echo "[FEHLER] Virtuelle Umgebung nicht gefunden."
|
||||||
|
echo "Bitte zuerst start.command ausführen, um das Projekt einzurichten."
|
||||||
|
read -p "Drücke Enter zum Beenden..."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Stelle sicher, dass pytest installiert ist..."
|
||||||
|
"$VENV_DIR/bin/pip" install pytest --quiet 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[INFO] Führe End-to-End-Tests aus…"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
"$VENV_DIR/bin/python" -m pytest tests -v
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
read -p "Drücke Enter zum Beenden..."
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests für den Tätigkeitsnachweis-Generator."""
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,191 @@
|
|||||||
|
"""Verify pipeline gates and deployment against temporary local directories."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch, Mock
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PIPELINE_DIR = Path(__file__).resolve().parents[2] / "1_build-pipeline"
|
||||||
|
sys.path.insert(0, str(PIPELINE_DIR))
|
||||||
|
import deploy
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("build_pipeline", PIPELINE_DIR / "build.py")
|
||||||
|
build = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(build)
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATE_NAMES = ["template/A Vorlage.pdf", "template/B Vorlage.PDF"]
|
||||||
|
IGNORED_NAMES = ["template/_alt.pdf", "template/.versteckt.pdf", "template/A Vorlage.odg"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def release(tmp_path):
|
||||||
|
directory = tmp_path / "build"
|
||||||
|
directory.mkdir()
|
||||||
|
for name in deploy.PACKAGE_FILES + TEMPLATE_NAMES:
|
||||||
|
path = directory / name
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"test release payload")
|
||||||
|
deploy.write_release_manifest(directory, "1.3")
|
||||||
|
target = tmp_path / "share"
|
||||||
|
target.mkdir()
|
||||||
|
return directory, target
|
||||||
|
|
||||||
|
|
||||||
|
def test_deployment_packages_release_without_work_files(release):
|
||||||
|
directory, target = release
|
||||||
|
for name in IGNORED_NAMES:
|
||||||
|
(directory / name).write_bytes(b"ignored")
|
||||||
|
for folder in ["input", "output"]:
|
||||||
|
(directory / folder).mkdir()
|
||||||
|
(directory / folder / "private.xlsx").write_bytes(b"private")
|
||||||
|
result = deploy.deploy(directory, target)
|
||||||
|
assert result.name == "Tätigkeitsnachweis-Generator v1.3.0.zip"
|
||||||
|
with zipfile.ZipFile(result) as archive:
|
||||||
|
assert set(archive.namelist()) == set(deploy.PACKAGE_FILES + TEMPLATE_NAMES + ["input/", "output/"])
|
||||||
|
assert archive.testzip() is None
|
||||||
|
assert not list(target.glob(".upload-*"))
|
||||||
|
# An unchanged release can be retried without overwriting the published ZIP.
|
||||||
|
original_time = result.stat().st_mtime_ns
|
||||||
|
assert deploy.deploy(directory, target) == result
|
||||||
|
assert result.stat().st_mtime_ns == original_time
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_vpn_target_is_not_created(release):
|
||||||
|
directory, target = release
|
||||||
|
missing = target / "missing-share"
|
||||||
|
with pytest.raises(OSError, match="VPN"):
|
||||||
|
deploy.deploy(directory, missing)
|
||||||
|
assert not missing.exists()
|
||||||
|
assert not list(directory.glob("*.zip"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_payload_is_not_deployed(release):
|
||||||
|
directory, target = release
|
||||||
|
(directory / deploy.EXE_NAME).write_bytes(b"different executable")
|
||||||
|
with pytest.raises(ValueError, match="verändert"):
|
||||||
|
deploy.deploy(directory, target)
|
||||||
|
assert not list(target.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfirmed_old_build_is_not_deployed(release):
|
||||||
|
directory, target = release
|
||||||
|
(directory / deploy.MANIFEST_NAME).unlink()
|
||||||
|
with pytest.raises(ValueError, match="bestätigter Build"):
|
||||||
|
deploy.deploy(directory, target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_different_release_is_preserved(release):
|
||||||
|
directory, target = release
|
||||||
|
existing = target / "Tätigkeitsnachweis-Generator v1.3.0.zip"
|
||||||
|
existing.write_bytes(b"already published")
|
||||||
|
with pytest.raises(FileExistsError, match="anderem Inhalt"):
|
||||||
|
deploy.deploy(directory, target)
|
||||||
|
assert existing.read_bytes() == b"already published"
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_upload_is_not_published(release):
|
||||||
|
directory, target = release
|
||||||
|
with patch.object(deploy.shutil, "copyfile", side_effect=OSError("VPN disconnected")):
|
||||||
|
with pytest.raises(OSError, match="lokale Build bleibt erhalten"):
|
||||||
|
deploy.deploy(directory, target)
|
||||||
|
assert not list(target.iterdir())
|
||||||
|
assert list(directory.glob("*.zip"))
|
||||||
|
assert deploy.validate_release(directory) == "1.3.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_upload_is_not_published(release):
|
||||||
|
directory, target = release
|
||||||
|
def corrupt_copy(source, destination):
|
||||||
|
Path(destination).write_bytes(b"corrupted")
|
||||||
|
with patch.object(deploy.shutil, "copyfile", side_effect=corrupt_copy):
|
||||||
|
with pytest.raises(OSError):
|
||||||
|
deploy.deploy(directory, target)
|
||||||
|
assert not list(target.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pipeline(tmp_path, monkeypatch):
|
||||||
|
changelog = tmp_path / "CHANGELOG.md"
|
||||||
|
changelog.write_text("## [1.3]\n\nOriginal release\n", encoding="utf-8")
|
||||||
|
version_file = tmp_path / ".last_version"
|
||||||
|
version_file.write_text("1.3", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(build, "CHANGELOG_PATH", changelog)
|
||||||
|
monkeypatch.setattr(build, "VERSION_FILE", version_file)
|
||||||
|
monkeypatch.setattr(build, "BUILD_OUTPUT_DIR", tmp_path)
|
||||||
|
monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--message", "Fix"])
|
||||||
|
events = []
|
||||||
|
names = ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller",
|
||||||
|
"_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"]
|
||||||
|
mocks = {}
|
||||||
|
for name in names:
|
||||||
|
mocks[name] = Mock(side_effect=lambda *args, _name=name: events.append(_name))
|
||||||
|
monkeypatch.setattr(build, name, mocks[name])
|
||||||
|
return changelog, version_file, events, mocks
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_tests_build_then_deploy(pipeline):
|
||||||
|
changelog, version_file, events, mocks = pipeline
|
||||||
|
build.main()
|
||||||
|
assert events == ["_run_tests", "_ensure_pyinstaller", "_prepare_build_dir", "_run_pyinstaller",
|
||||||
|
"_copy_to_build", "write_release_manifest", "_cleanup", "_print_summary", "deploy"]
|
||||||
|
assert version_file.read_text() == "1.3.1"
|
||||||
|
assert "## [1.3.1]" in changelog.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_failing_tests_prevent_version_change_build_and_deploy(pipeline):
|
||||||
|
changelog, version_file, events, mocks = pipeline
|
||||||
|
original = changelog.read_bytes()
|
||||||
|
mocks["_run_tests"].side_effect = SystemExit(1)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
build.main()
|
||||||
|
assert changelog.read_bytes() == original
|
||||||
|
assert version_file.read_text() == "1.3"
|
||||||
|
mocks["_run_pyinstaller"].assert_not_called()
|
||||||
|
mocks["deploy"].assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_failure_rolls_back_changelog_and_prevents_deployment(pipeline):
|
||||||
|
changelog, version_file, events, mocks = pipeline
|
||||||
|
original = changelog.read_text(encoding="utf-8")
|
||||||
|
mocks["_run_pyinstaller"].side_effect = SystemExit(1)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
build.main()
|
||||||
|
assert changelog.read_text(encoding="utf-8") == original
|
||||||
|
assert version_file.read_text() == "1.3"
|
||||||
|
mocks["deploy"].assert_not_called()
|
||||||
|
mocks["_cleanup"].assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_deploy_failure_keeps_successful_build_version(pipeline):
|
||||||
|
changelog, version_file, events, mocks = pipeline
|
||||||
|
mocks["deploy"].side_effect = OSError("VPN unavailable")
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
build.main()
|
||||||
|
assert version_file.read_text() == "1.3.1"
|
||||||
|
assert "## [1.3.1]" in changelog.read_text(encoding="utf-8")
|
||||||
|
mocks["write_release_manifest"].assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_does_not_test_build_or_deploy(pipeline, monkeypatch):
|
||||||
|
changelog, version_file, events, mocks = pipeline
|
||||||
|
monkeypatch.setattr(sys, "argv", ["build.py", "--bump", "patch", "--dry-run"])
|
||||||
|
build.main()
|
||||||
|
assert not events
|
||||||
|
assert version_file.read_text() == "1.3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rebuild_preserves_changelog_and_version(pipeline, monkeypatch):
|
||||||
|
changelog, version_file, events, mocks = pipeline
|
||||||
|
original = changelog.read_bytes()
|
||||||
|
monkeypatch.setattr(sys, "argv", ["build.py", "--no-bump", "--no-deploy"])
|
||||||
|
build.main()
|
||||||
|
assert changelog.read_bytes() == original
|
||||||
|
assert version_file.read_text() == "1.3"
|
||||||
|
mocks["write_release_manifest"].assert_called_once_with(build.BUILD_OUTPUT_DIR, "1.3")
|
||||||
|
mocks["_run_tests"].assert_called_once()
|
||||||
|
mocks["_run_pyinstaller"].assert_called_once()
|
||||||
|
mocks["deploy"].assert_not_called()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""End-to-end checks using the fixed 2026 workbook and the PDF template."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from src.config import load_settings, load_input_schema, load_output_mapping
|
||||||
|
from src.parser import parse_input_file, group_by_employee, get_unique_employees
|
||||||
|
from src.writer import generate_activity_reports
|
||||||
|
|
||||||
|
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||||
|
INPUT_FILE = Path(__file__).resolve().parent / "fixture" / "2026 JM.xlsx"
|
||||||
|
TEMPLATE_FILE = PROJECT_ROOT / "template" / "06-2026 Tätigkeitsnachweise Stellenförderung.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_config():
|
||||||
|
config = load_settings(CONFIG_DIR, PROJECT_ROOT)
|
||||||
|
config.input_schema = load_input_schema(CONFIG_DIR)
|
||||||
|
config.output_mapping = load_output_mapping(CONFIG_DIR)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def parsed_records(app_config):
|
||||||
|
assert INPUT_FILE.is_file(), f"Testdatei nicht gefunden: {INPUT_FILE}"
|
||||||
|
return parse_input_file(
|
||||||
|
filepath=INPUT_FILE,
|
||||||
|
schema=app_config.input_schema,
|
||||||
|
month_sheet_pattern=app_config.month_sheet_pattern,
|
||||||
|
skip_empty_name=app_config.skip_empty_name,
|
||||||
|
skip_empty_antragsnummer=app_config.skip_empty_antragsnummer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def output_dir(tmp_path):
|
||||||
|
out = tmp_path / "output"
|
||||||
|
out.mkdir()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndToEndFirstPerson:
|
||||||
|
def test_input_file_exists(self):
|
||||||
|
assert INPUT_FILE.is_file(), f"Testdatei nicht gefunden: {INPUT_FILE}"
|
||||||
|
|
||||||
|
def test_template_file_exists(self):
|
||||||
|
assert TEMPLATE_FILE.is_file(), f"PDF-Template nicht gefunden: {TEMPLATE_FILE}"
|
||||||
|
|
||||||
|
def test_template_has_field_markers(self, app_config):
|
||||||
|
with fitz.open(TEMPLATE_FILE) as doc:
|
||||||
|
missing = [
|
||||||
|
mapping.pdf_field
|
||||||
|
for mapping in app_config.output_mapping
|
||||||
|
if mapping.pdf_field is not None
|
||||||
|
and not any(page.search_for(mapping.pdf_field) for page in doc)
|
||||||
|
]
|
||||||
|
assert not missing, f"Folgende _Field-Platzhalter fehlen im PDF: {missing}"
|
||||||
|
|
||||||
|
def test_parse_real_input(self, parsed_records):
|
||||||
|
# Fixed expectations for tests/fixture/2026 JM.xlsx.
|
||||||
|
assert len(parsed_records) == 100
|
||||||
|
employees = get_unique_employees(parsed_records)
|
||||||
|
assert len(employees) == 17
|
||||||
|
assert employees[0] == "Bardaus, Madalina"
|
||||||
|
first_records = group_by_employee(parsed_records)[employees[0]]
|
||||||
|
assert [record.monat for record in first_records] == [
|
||||||
|
"01.2026", "02.2026", "03.2026", "04.2026", "05.2026"
|
||||||
|
]
|
||||||
|
for record in first_records:
|
||||||
|
assert record.antragsnummer == "100781516"
|
||||||
|
assert record.funktion == "Psychologin"
|
||||||
|
assert record.stellenanteil == "50"
|
||||||
|
assert record.qualifikation == ""
|
||||||
|
assert record.taetigkeit.startswith("Psychologische Betreuung")
|
||||||
|
|
||||||
|
def test_full_pipeline_first_person(self, app_config, parsed_records, output_dir):
|
||||||
|
grouped = group_by_employee(parsed_records)
|
||||||
|
assert len(grouped) == 17
|
||||||
|
first_person = sorted(grouped)[0]
|
||||||
|
assert first_person == "Bardaus, Madalina"
|
||||||
|
assert len(grouped[first_person]) == 5
|
||||||
|
generated = generate_activity_reports(
|
||||||
|
employee_records={first_person: grouped[first_person]},
|
||||||
|
template_path=TEMPLATE_FILE,
|
||||||
|
mapping=app_config.output_mapping,
|
||||||
|
output_dir=output_dir,
|
||||||
|
filename_pattern=app_config.filename_pattern,
|
||||||
|
fontsize=app_config.fontsize,
|
||||||
|
)
|
||||||
|
assert len(generated) == 1
|
||||||
|
output_file = generated[0]
|
||||||
|
assert output_file.is_file()
|
||||||
|
assert output_file.name == "Bardaus_Madalina_Tätigkeitsnachweis.pdf"
|
||||||
|
with fitz.open(output_file) as doc:
|
||||||
|
assert doc.page_count == 10 # Five months, two pages per month.
|
||||||
|
assert "_Field" not in "".join(page.get_text() for page in doc)
|
||||||
|
# Independently specified month ends include February's 28 days.
|
||||||
|
expected_months = [
|
||||||
|
("01.2026", "31.01.2026"),
|
||||||
|
("02.2026", "28.02.2026"),
|
||||||
|
("03.2026", "31.03.2026"),
|
||||||
|
("04.2026", "30.04.2026"),
|
||||||
|
("05.2026", "31.05.2026"),
|
||||||
|
]
|
||||||
|
for index, (month, month_end) in enumerate(expected_months):
|
||||||
|
data_text = doc[index * 2].get_text()
|
||||||
|
signature_text = doc[index * 2 + 1].get_text()
|
||||||
|
for value in [
|
||||||
|
"100781516", "Bardaus, Madalina", "50,00", month,
|
||||||
|
"Psychologin", "Psychologische Betreuung",
|
||||||
|
]:
|
||||||
|
assert value in data_text, f"{value!r} fehlt für {month}"
|
||||||
|
assert "100781516" not in signature_text
|
||||||
|
assert "Dresden" in signature_text
|
||||||
|
assert month_end in signature_text
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Exercise the actual PowerShell logic with simulated Windows processes."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import server_lifecycle
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == "nt", "Windows process management")
|
||||||
|
class ServerLifecycleTests(unittest.TestCase):
|
||||||
|
def simulate(self, process, listening=True, stop_error=False, confirm=True, frozen=False):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def capture(command, **kwargs):
|
||||||
|
captured.update(script=command[-1], env=kwargs["env"])
|
||||||
|
return subprocess.CompletedProcess(command, 0, "[]", "")
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
with patch.object(server_lifecycle.subprocess, "run", side_effect=capture), \
|
||||||
|
patch.object(server_lifecycle.sys, "frozen", frozen, create=True), \
|
||||||
|
patch.object(server_lifecycle.sys, "executable", str(root / "2_build" / "Tätigkeitsnachweis-Generator.exe")), \
|
||||||
|
patch.object(server_lifecycle, "__file__", str(root / "_MEI-test" / "server_lifecycle.py") if frozen else server_lifecycle.__file__):
|
||||||
|
server_lifecycle.stop_existing_server()
|
||||||
|
|
||||||
|
setup = r'''
|
||||||
|
$script:occupied = LISTENING
|
||||||
|
function Get-NetTCPConnection {
|
||||||
|
param($State, $ErrorAction)
|
||||||
|
if ($script:occupied) { [pscustomobject]@{ LocalPort=8501; OwningProcess=12345 } }
|
||||||
|
}
|
||||||
|
function Get-CimInstance {
|
||||||
|
param($ClassName, $Filter)
|
||||||
|
PROCESS
|
||||||
|
}
|
||||||
|
function Stop-Process {
|
||||||
|
param($Id, [switch]$Force, $ErrorAction)
|
||||||
|
[Console]::Error.WriteLine('STOP_CALLED')
|
||||||
|
STOP
|
||||||
|
$script:occupied = $false
|
||||||
|
}
|
||||||
|
'''.replace("LISTENING", "$true" if listening else "$false")
|
||||||
|
setup = setup.replace("PROCESS", process).replace(
|
||||||
|
"\n STOP\n", "\n throw 'simulated access denied'\n" if stop_error else "\n")
|
||||||
|
confirm_mock = """
|
||||||
|
function Confirm-ServerRestart {
|
||||||
|
[Console]::Error.WriteLine('CONFIRM_CALLED')
|
||||||
|
return ANSWER
|
||||||
|
}
|
||||||
|
""".replace("ANSWER", "$true" if confirm else "$false")
|
||||||
|
script = captured["script"].replace(server_lifecycle._CONFIRM_SCRIPT, confirm_mock)
|
||||||
|
command = [
|
||||||
|
"powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
setup + script,
|
||||||
|
]
|
||||||
|
# Explicit pipes keep both outputs available independently of pytest's
|
||||||
|
# own console capture. communicate() drains both pipes concurrently.
|
||||||
|
with subprocess.Popen(
|
||||||
|
command, env=captured["env"], stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE, text=True, errors="replace",
|
||||||
|
) as child:
|
||||||
|
try:
|
||||||
|
stdout, stderr = child.communicate(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
child.kill()
|
||||||
|
child.communicate()
|
||||||
|
raise
|
||||||
|
self.assertIsInstance(stdout, str, "stdout wurde nicht erfasst")
|
||||||
|
self.assertIsInstance(stderr, str, "stderr wurde nicht erfasst")
|
||||||
|
return subprocess.CompletedProcess(command, child.returncode, stdout, stderr)
|
||||||
|
|
||||||
|
def test_free_port(self):
|
||||||
|
result = self.simulate("throw 'should not inspect a process'", listening=False)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout.strip(), "[]")
|
||||||
|
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
|
||||||
|
def test_previous_exe_is_stopped(self):
|
||||||
|
result = self.simulate(
|
||||||
|
"[pscustomobject]@{ Name='Tätigkeitsnachweis-Generator.exe'; CommandLine=''; ExecutablePath='' }")
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout.strip(), "[12345]")
|
||||||
|
self.assertIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
self.assertIn("STOP_CALLED", result.stderr)
|
||||||
|
|
||||||
|
def test_declining_preserves_previous_instance(self):
|
||||||
|
result = self.simulate(
|
||||||
|
"[pscustomobject]@{ Name='Tätigkeitsnachweis-Generator.exe'; CommandLine=''; ExecutablePath='' }",
|
||||||
|
confirm=False)
|
||||||
|
self.assertEqual(result.returncode, 2, result.stderr)
|
||||||
|
self.assertIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||||
|
|
||||||
|
def test_declining_returns_false_to_launcher(self):
|
||||||
|
with patch.object(server_lifecycle.subprocess, "run", return_value=
|
||||||
|
subprocess.CompletedProcess([], 2, "", "")):
|
||||||
|
self.assertFalse(server_lifecycle.stop_existing_server())
|
||||||
|
|
||||||
|
def test_development_absolute_path_is_stopped(self):
|
||||||
|
result = self.simulate(r'''[pscustomobject]@{
|
||||||
|
Name='python.exe'; ExecutablePath='C:\Python\python.exe';
|
||||||
|
CommandLine=('python -m streamlit run "' + $env:APP_RESTART_PATH + '"')
|
||||||
|
}''')
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_legacy_development_start_is_stopped(self):
|
||||||
|
result = self.simulate(r'''[pscustomobject]@{
|
||||||
|
Name='python.exe'; CommandLine='streamlit.exe run app.py';
|
||||||
|
ExecutablePath=($env:APP_RESTART_VENV + '\python.exe')
|
||||||
|
}''')
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
def test_versioned_python_prompts_before_stopping(self):
|
||||||
|
# Windows Store Python reports the real interpreter name even when
|
||||||
|
# launched through the venv's python.exe.
|
||||||
|
process = r'''[pscustomobject]@{
|
||||||
|
Name='python3.13.exe';
|
||||||
|
ExecutablePath='C:\Program Files\WindowsApps\Python\python3.13.exe';
|
||||||
|
CommandLine=('".venv\Scripts\python.exe" -m streamlit run "' +
|
||||||
|
$env:APP_RESTART_PATH + '" --server.port=8501 --server.address=localhost')
|
||||||
|
}'''
|
||||||
|
with self.subTest(answer="yes"):
|
||||||
|
result = self.simulate(process, confirm=True)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
self.assertIn("STOP_CALLED", result.stderr)
|
||||||
|
with self.subTest(answer="no"):
|
||||||
|
result = self.simulate(process, confirm=False)
|
||||||
|
self.assertEqual(result.returncode, 2, result.stderr)
|
||||||
|
self.assertIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||||
|
|
||||||
|
def test_unrelated_versioned_python_is_preserved(self):
|
||||||
|
result = self.simulate(r'''[pscustomobject]@{
|
||||||
|
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
|
||||||
|
CommandLine='python -m streamlit run "C:\other-project\app.py"'
|
||||||
|
}''')
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("PID 12345", result.stderr)
|
||||||
|
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||||
|
|
||||||
|
def test_exe_recognizes_sibling_development_server(self):
|
||||||
|
process = r'''[pscustomobject]@{
|
||||||
|
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
|
||||||
|
CommandLine=('".venv\Scripts\python.exe" -m streamlit run "' +
|
||||||
|
(($env:APP_RESTART_PATHS | ConvertFrom-Json)[1]) +
|
||||||
|
'" --server.port=8501 --server.address=localhost')
|
||||||
|
}'''
|
||||||
|
for answer, expected_code in [(True, 0), (False, 2)]:
|
||||||
|
with self.subTest(confirm=answer):
|
||||||
|
result = self.simulate(process, frozen=True, confirm=answer)
|
||||||
|
self.assertEqual(result.returncode, expected_code, result.stderr)
|
||||||
|
self.assertIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
if answer:
|
||||||
|
self.assertIn("STOP_CALLED", result.stderr)
|
||||||
|
else:
|
||||||
|
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||||
|
|
||||||
|
def test_exe_preserves_unrelated_development_server(self):
|
||||||
|
result = self.simulate(r'''[pscustomobject]@{
|
||||||
|
Name='python3.13.exe'; ExecutablePath='C:\Python\python3.13.exe';
|
||||||
|
CommandLine='python -m streamlit run "C:\other-project\app.py"'
|
||||||
|
}''', frozen=True)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||||
|
|
||||||
|
def test_unrelated_server_is_preserved(self):
|
||||||
|
result = self.simulate(r'''[pscustomobject]@{
|
||||||
|
Name='python.exe'; CommandLine='streamlit run app.py';
|
||||||
|
ExecutablePath='C:\other-project\.venv\Scripts\python.exe'
|
||||||
|
}''')
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("PID 12345", result.stderr)
|
||||||
|
self.assertNotIn("STOP_CALLED", result.stderr)
|
||||||
|
self.assertNotIn("CONFIRM_CALLED", result.stderr)
|
||||||
|
|
||||||
|
def test_stop_failure_aborts_start(self):
|
||||||
|
result = self.simulate(
|
||||||
|
"[pscustomobject]@{ Name='Tätigkeitsnachweis-Generator.exe'; CommandLine=''; ExecutablePath='' }",
|
||||||
|
stop_error=True)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("simulated access denied", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Tests für das Einrichten einer neuen Version neben der aktuellen Installation."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import update_installer
|
||||||
|
from update_installer import EXE_NAME, UpdateError, install_update
|
||||||
|
|
||||||
|
|
||||||
|
class InstallUpdateTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self._temporary.name).resolve()
|
||||||
|
self.current = self.root / "Tätigkeitsnachweis-Generator v1.3.3"
|
||||||
|
for folder in ["input", "output/2026", "config", "template"]:
|
||||||
|
(self.current / folder).mkdir(parents=True)
|
||||||
|
(self.current / EXE_NAME).write_bytes(b"old")
|
||||||
|
(self.current / "input" / "daten.xlsx").write_bytes(b"input")
|
||||||
|
(self.current / "output" / "2026" / "nachweis.pdf").write_bytes(b"output")
|
||||||
|
(self.current / "config" / "settings.ini").write_bytes(b"[a]\nx=1\n")
|
||||||
|
(self.current / "template" / "vorlage.pdf").write_bytes(b"old template")
|
||||||
|
(self.current / "template" / "eigene.pdf").write_bytes(b"own template")
|
||||||
|
self.share = self.root / "share"
|
||||||
|
self.share.mkdir()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._temporary.cleanup()
|
||||||
|
|
||||||
|
def make_zip(self, files=None, version="1.4.0"):
|
||||||
|
files = files if files is not None else {
|
||||||
|
EXE_NAME: b"new",
|
||||||
|
"config/settings.ini": b"[a]\nx=1\n",
|
||||||
|
"template/vorlage.pdf": b"new template",
|
||||||
|
"input/": b"", "output/": b"",
|
||||||
|
}
|
||||||
|
path = self.share / f"Tätigkeitsnachweis-Generator v{version}.zip"
|
||||||
|
with zipfile.ZipFile(path, "w") as package:
|
||||||
|
for name, content in files.items():
|
||||||
|
package.writestr(name, content)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_installs_next_to_current_and_copies_user_data(self):
|
||||||
|
result = install_update(self.make_zip(), self.current)
|
||||||
|
target = self.root / "Tätigkeitsnachweis-Generator v1.4.0"
|
||||||
|
self.assertEqual(result.target_dir, target)
|
||||||
|
self.assertFalse(result.already_installed)
|
||||||
|
self.assertIsNone(result.previous_settings)
|
||||||
|
self.assertEqual((target / EXE_NAME).read_bytes(), b"new")
|
||||||
|
self.assertEqual((target / "input" / "daten.xlsx").read_bytes(), b"input")
|
||||||
|
self.assertEqual((target / "output" / "2026" / "nachweis.pdf").read_bytes(), b"output")
|
||||||
|
self.assertEqual((target / "template" / "vorlage.pdf").read_bytes(), b"new template")
|
||||||
|
self.assertEqual((target / "template" / "eigene.pdf").read_bytes(), b"own template")
|
||||||
|
# Die alte Installation bleibt unverändert.
|
||||||
|
self.assertEqual((self.current / EXE_NAME).read_bytes(), b"old")
|
||||||
|
self.assertEqual([p.name for p in self.root.iterdir() if p.name.startswith(".")], [])
|
||||||
|
|
||||||
|
def test_keeps_changed_settings_next_to_new_ones(self):
|
||||||
|
(self.current / "config" / "settings.ini").write_text("[a]\nx=2\n", encoding="utf-8")
|
||||||
|
result = install_update(self.make_zip(), self.current)
|
||||||
|
config = result.target_dir / "config"
|
||||||
|
self.assertEqual((config / "settings.ini").read_text(encoding="utf-8"), "[a]\nx=1\n")
|
||||||
|
self.assertEqual(result.previous_settings, config / "settings.vorherige-version.ini")
|
||||||
|
self.assertEqual(result.previous_settings.read_text(encoding="utf-8"), "[a]\nx=2\n")
|
||||||
|
|
||||||
|
def test_existing_installation_is_only_reused(self):
|
||||||
|
zip_path = self.make_zip()
|
||||||
|
install_update(zip_path, self.current)
|
||||||
|
result = install_update(zip_path, self.current)
|
||||||
|
self.assertTrue(result.already_installed)
|
||||||
|
|
||||||
|
def test_rejects_package_without_exe(self):
|
||||||
|
with self.assertRaisesRegex(UpdateError, "keine"):
|
||||||
|
install_update(self.make_zip({"README.md": b"x"}), self.current)
|
||||||
|
self.assertFalse((self.root / "Tätigkeitsnachweis-Generator v1.4.0").exists())
|
||||||
|
|
||||||
|
def test_rejects_path_traversal(self):
|
||||||
|
with self.assertRaisesRegex(UpdateError, "ungültigen Pfad"):
|
||||||
|
install_update(self.make_zip({EXE_NAME: b"x", "../evil.txt": b"x"}), self.current)
|
||||||
|
self.assertFalse((self.root / "evil.txt").exists())
|
||||||
|
|
||||||
|
def test_rejects_broken_zip_and_cleans_up(self):
|
||||||
|
broken = self.share / "Tätigkeitsnachweis-Generator v1.4.0.zip"
|
||||||
|
broken.write_bytes(b"no zip")
|
||||||
|
with self.assertRaisesRegex(UpdateError, "keine gültige ZIP"):
|
||||||
|
install_update(broken, self.current)
|
||||||
|
self.assertEqual(sorted(p.name for p in self.root.iterdir()), sorted([self.current.name, "share"]))
|
||||||
|
|
||||||
|
def test_unreachable_share_gives_vpn_hint(self):
|
||||||
|
with self.assertRaisesRegex(UpdateError, "VPN"):
|
||||||
|
install_update(self.share / "Tätigkeitsnachweis-Generator v1.4.0.zip", self.current)
|
||||||
|
|
||||||
|
def test_refuses_existing_folder_without_program(self):
|
||||||
|
(self.root / "Tätigkeitsnachweis-Generator v1.4.0").mkdir()
|
||||||
|
with self.assertRaisesRegex(UpdateError, "existiert bereits"):
|
||||||
|
install_update(self.make_zip(), self.current)
|
||||||
|
|
||||||
|
|
||||||
|
class StartApplicationTests(unittest.TestCase):
|
||||||
|
@unittest.skipUnless(sys.platform == "win32", "Windows-Prozessflags")
|
||||||
|
def test_starts_new_exe_with_clean_bundle_environment(self):
|
||||||
|
target = Path("C:/neu")
|
||||||
|
environment = {"PATH": "x", "APP_BASE_DIR": "alt", "_MEIPASS2": "alt",
|
||||||
|
"_PYI_APPLICATION_HOME_DIR": "alt", "STREAMLIT_SERVER_PORT": "8501"}
|
||||||
|
with patch.dict(update_installer.os.environ, environment, clear=True), \
|
||||||
|
patch.object(update_installer.subprocess, "Popen") as popen:
|
||||||
|
update_installer.start_application(target)
|
||||||
|
args, kwargs = popen.call_args
|
||||||
|
self.assertEqual(args[0], [str(target / EXE_NAME)])
|
||||||
|
self.assertEqual(kwargs["cwd"], target)
|
||||||
|
self.assertEqual(kwargs["env"], {"PATH": "x", "PYINSTALLER_RESET_ENVIRONMENT": "1"})
|
||||||
|
self.assertTrue(kwargs["creationflags"] & subprocess.CREATE_NEW_CONSOLE)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Richtet eine neue Programmversion neben der aktuellen Installation ein.
|
||||||
|
|
||||||
|
Ablauf von "Anwendung ersetzen":
|
||||||
|
1. ZIP vom SMB-Laufwerk lokal zwischenspeichern und prüfen.
|
||||||
|
2. In einen Zwischenordner neben der aktuellen Installation entpacken.
|
||||||
|
3. input/ und output/ sowie eigene Template-PDFs übernehmen.
|
||||||
|
4. Zwischenordner in "Tätigkeitsnachweis-Generator vX.Y.Z" umbenennen.
|
||||||
|
5. Neue EXE starten; sie beendet die alte Instanz nach Rückfrage selbst.
|
||||||
|
|
||||||
|
Die alte Installation bleibt unverändert als Rückfallebene erhalten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
EXE_NAME = "Tätigkeitsnachweis-Generator.exe"
|
||||||
|
USER_DATA_DIRS = ("input", "output")
|
||||||
|
SETTINGS_FILE = Path("config") / "settings.ini"
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateError(Exception):
|
||||||
|
"""Fehler mit einer Meldung, die direkt angezeigt werden kann."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateResult:
|
||||||
|
target_dir: Path
|
||||||
|
already_installed: bool = False
|
||||||
|
previous_settings: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def can_replace_application() -> bool:
|
||||||
|
"""Nur die gebaute Windows-EXE kann sich selbst ersetzen."""
|
||||||
|
return sys.platform == "win32" and bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_members(package: zipfile.ZipFile) -> None:
|
||||||
|
names = package.namelist()
|
||||||
|
for name in names:
|
||||||
|
path = PurePosixPath(name.replace("\\", "/"))
|
||||||
|
if path.is_absolute() or ".." in path.parts or ":" in name:
|
||||||
|
raise UpdateError(f"Das Update-Paket enthält einen ungültigen Pfad: {name}")
|
||||||
|
if EXE_NAME not in names:
|
||||||
|
raise UpdateError(f"Das Update-Paket enthält keine {EXE_NAME}.")
|
||||||
|
if package.testzip() is not None:
|
||||||
|
raise UpdateError("Das Update-Paket ist beschädigt. Bitte später erneut versuchen.")
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_user_data(current_dir: Path, staging_dir: Path) -> None:
|
||||||
|
for folder in USER_DATA_DIRS:
|
||||||
|
source = current_dir / folder
|
||||||
|
if source.is_dir():
|
||||||
|
shutil.copytree(source, staging_dir / folder, dirs_exist_ok=True)
|
||||||
|
# Eigene Templates übernehmen, ohne die mitgelieferten zu überschreiben.
|
||||||
|
templates = current_dir / "template"
|
||||||
|
if templates.is_dir():
|
||||||
|
(staging_dir / "template").mkdir(exist_ok=True)
|
||||||
|
for path in templates.iterdir():
|
||||||
|
destination = staging_dir / "template" / path.name
|
||||||
|
if path.is_file() and not destination.exists():
|
||||||
|
shutil.copy2(path, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def _keep_previous_settings(current_dir: Path, staging_dir: Path) -> Path | None:
|
||||||
|
"""Neue settings.ini gilt; abweichende alte Einstellungen bleiben daneben erhalten."""
|
||||||
|
old, new = current_dir / SETTINGS_FILE, staging_dir / SETTINGS_FILE
|
||||||
|
if not old.is_file() or (new.is_file() and old.read_bytes() == new.read_bytes()):
|
||||||
|
return None
|
||||||
|
new.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
backup = new.with_name("settings.vorherige-version.ini")
|
||||||
|
shutil.copy2(old, backup)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
|
||||||
|
def install_update(zip_path: Path, current_dir: Path) -> UpdateResult:
|
||||||
|
"""Entpackt zip_path neben current_dir und übernimmt die Nutzerdaten."""
|
||||||
|
current_dir = current_dir.resolve()
|
||||||
|
target_dir = current_dir.parent / zip_path.stem
|
||||||
|
if target_dir == current_dir:
|
||||||
|
raise UpdateError("Diese Version ist bereits die laufende Installation.")
|
||||||
|
if (target_dir / EXE_NAME).is_file():
|
||||||
|
return UpdateResult(target_dir, already_installed=True)
|
||||||
|
if target_dir.exists():
|
||||||
|
raise UpdateError(
|
||||||
|
f"Der Ordner {target_dir} existiert bereits, enthält aber kein Programm. "
|
||||||
|
"Bitte den Ordner entfernen oder umbenennen und erneut versuchen.")
|
||||||
|
|
||||||
|
staging_dir = current_dir.parent / f".{zip_path.stem}.update"
|
||||||
|
try:
|
||||||
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="taetigkeitsnachweis-update-") as temporary:
|
||||||
|
# Erst lokal kopieren: ein VPN-Abbruch trifft so nur die Kopie.
|
||||||
|
local_zip = Path(temporary) / zip_path.name
|
||||||
|
try:
|
||||||
|
shutil.copyfile(zip_path, local_zip)
|
||||||
|
except OSError as exc:
|
||||||
|
raise UpdateError(
|
||||||
|
"Das Update konnte nicht vom Netzlaufwerk geladen werden. "
|
||||||
|
"Bitte VPN-Verbindung und Laufwerk J: prüfen.") from exc
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(local_zip) as package:
|
||||||
|
_check_members(package)
|
||||||
|
package.extractall(staging_dir)
|
||||||
|
except zipfile.BadZipFile as exc:
|
||||||
|
raise UpdateError("Das Update-Paket ist keine gültige ZIP-Datei.") from exc
|
||||||
|
_copy_user_data(current_dir, staging_dir)
|
||||||
|
previous_settings = _keep_previous_settings(current_dir, staging_dir)
|
||||||
|
staging_dir.rename(target_dir)
|
||||||
|
except UpdateError:
|
||||||
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
except OSError as exc:
|
||||||
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
||||||
|
raise UpdateError(
|
||||||
|
f"Die neue Version konnte nicht eingerichtet werden ({exc}). "
|
||||||
|
f"Bitte Schreibrechte für {current_dir.parent} prüfen.") from exc
|
||||||
|
if previous_settings is not None:
|
||||||
|
previous_settings = target_dir / SETTINGS_FILE.parent / previous_settings.name
|
||||||
|
return UpdateResult(target_dir, previous_settings=previous_settings)
|
||||||
|
|
||||||
|
|
||||||
|
def start_application(target_dir: Path) -> None:
|
||||||
|
"""Startet die neue EXE in einem eigenen Konsolenfenster wie per Doppelklick."""
|
||||||
|
env = {key: value for key, value in os.environ.items()
|
||||||
|
if key != "APP_BASE_DIR" and not key.startswith(("STREAMLIT_", "_MEIPASS", "_PYI_"))}
|
||||||
|
# Sonst übernimmt die neue EXE Pfade aus dem Bundle der laufenden alten EXE.
|
||||||
|
env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
|
||||||
|
subprocess.Popen(
|
||||||
|
[str(target_dir / EXE_NAME)], cwd=target_dir, env=env, close_fds=True,
|
||||||
|
creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
)
|
||||||
@@ -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.4
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
set "PYTHONUTF8=1"
|
||||||
|
|
||||||
|
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] Pipeline fehlgeschlagen. Details stehen oben.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Tests, Build und Deployment abgeschlossen.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
"""Build-Orchestrierung mit Semantic Versioning.
|
||||||
|
|
||||||
|
Liest die aktuelle Version aus CHANGELOG.md, bump't sie gemäß
|
||||||
|
Benutzereingabe (major/minor/patch), aktualisiert das Changelog
|
||||||
|
führt zuerst alle Tests aus, baut via PyInstaller eine standalone .exe
|
||||||
|
nach 2_build/ und stellt sie anschließend als ZIP auf dem SMB-Laufwerk bereit.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
from deploy import deploy, is_template_pdf, write_release_manifest
|
||||||
|
|
||||||
|
# ── 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 _wait_for_changelog_edit() -> None:
|
||||||
|
"""Gibt dem Benutzer Zeit, den neuen Changelog-Eintrag zu bearbeiten.
|
||||||
|
|
||||||
|
Der Build liest CHANGELOG.md erst nach dieser Pause; Änderungen in der
|
||||||
|
Datei fließen also in den Build ein.
|
||||||
|
"""
|
||||||
|
print(f"\n✏️ Bitte den neuen Eintrag jetzt bearbeiten und speichern:\n {CHANGELOG_PATH}")
|
||||||
|
try:
|
||||||
|
input(" Danach Enter drücken, um den Build fortzusetzen (Strg+C bricht ab) … ")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("\nAbgebrochen.")
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
|
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 _run_tests() -> None:
|
||||||
|
"""Test with the same interpreter used to build the application."""
|
||||||
|
try:
|
||||||
|
import pytest # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
subprocess.check_call([sys.executable, "-m", "pip", "install", "pytest"])
|
||||||
|
print("\n[INFO] Führe alle Tests aus ...", flush=True)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "pytest", str(APP_DIR / "tests"), "-v", "-p", "no:cacheprovider"],
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("[FEHLER] Tests fehlgeschlagen. Kein Build und kein Deployment.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Liefere die zum aktuellen Quellstand gehörende Konfiguration aus.
|
||||||
|
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():
|
||||||
|
shutil.copy2(f, config_dst / f.name)
|
||||||
|
|
||||||
|
shutil.copy2(CHANGELOG_PATH, BUILD_OUTPUT_DIR / "CHANGELOG.md")
|
||||||
|
|
||||||
|
# Alle Template-PDFs ausliefern (ohne Dateien mit '_' oder '.' am Anfang).
|
||||||
|
# Veraltete PDFs aus früheren Builds werden entfernt.
|
||||||
|
tpl_src = APP_DIR / "template"
|
||||||
|
tpl_dst = BUILD_OUTPUT_DIR / "template"
|
||||||
|
wanted = {p.name for p in tpl_src.iterdir() if is_template_pdf(p)}
|
||||||
|
for old_pdf in tpl_dst.iterdir():
|
||||||
|
if is_template_pdf(old_pdf) and old_pdf.name not in wanted:
|
||||||
|
old_pdf.unlink()
|
||||||
|
for name in sorted(wanted):
|
||||||
|
shutil.copy2(tpl_src / name, tpl_dst / 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",
|
||||||
|
)
|
||||||
|
version_options = parser.add_mutually_exclusive_group()
|
||||||
|
version_options.add_argument(
|
||||||
|
"--bump",
|
||||||
|
choices=["major", "minor", "patch"],
|
||||||
|
help="Version bump-Typ (ohne Angabe: interaktiv)",
|
||||||
|
)
|
||||||
|
version_options.add_argument("--no-bump", action="store_true",
|
||||||
|
help="Aktuelle Version erneut bauen; Changelog unverändert übernehmen")
|
||||||
|
parser.add_argument("--no-deploy", action="store_true",
|
||||||
|
help="Nur lokal bauen, ohne anschließendes Deployment")
|
||||||
|
parser.add_argument(
|
||||||
|
"--message", "-m",
|
||||||
|
help="Changelog-Beschreibung für diese Version",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-pause", action="store_true",
|
||||||
|
help="Nicht auf die Bearbeitung des Changelogs warten (z.B. für CI)",
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
if args.no_bump:
|
||||||
|
parser.error("Für --no-bump muss eine Version im CHANGELOG.md vorhanden sein.")
|
||||||
|
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 and not args.no_bump:
|
||||||
|
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 = current if args.no_bump else bump_version(current, bump_type)
|
||||||
|
print(f"🚀 Neue Version: {new_version}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n[Trockenlauf – kein Build]")
|
||||||
|
print(" Würde zuerst alle Tests ausführen")
|
||||||
|
if not args.no_bump:
|
||||||
|
print(f" Würde CHANGELOG.md aktualisieren: [{new_version}]")
|
||||||
|
print(f" Würde PyInstaller-Build starten")
|
||||||
|
if not args.no_deploy:
|
||||||
|
print(" Würde anschließend ein versioniertes ZIP auf dem SMB-Laufwerk bereitstellen")
|
||||||
|
return
|
||||||
|
|
||||||
|
_run_tests()
|
||||||
|
|
||||||
|
previous_changelog = _read_changelog()
|
||||||
|
try:
|
||||||
|
_ensure_pyinstaller()
|
||||||
|
(BUILD_OUTPUT_DIR / "release.json").unlink(missing_ok=True)
|
||||||
|
if not args.no_bump:
|
||||||
|
print("\n📝 Changelog …")
|
||||||
|
update_changelog(new_version, bump_type, args.message)
|
||||||
|
if not args.no_pause and sys.stdin.isatty():
|
||||||
|
_wait_for_changelog_edit()
|
||||||
|
_prepare_build_dir()
|
||||||
|
exe_path = _run_pyinstaller()
|
||||||
|
final_exe = _copy_to_build(exe_path)
|
||||||
|
write_release_manifest(BUILD_OUTPUT_DIR, new_version)
|
||||||
|
except (Exception, SystemExit, KeyboardInterrupt):
|
||||||
|
_write_changelog(previous_changelog)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
_save_version(new_version)
|
||||||
|
_print_summary(new_version, final_exe)
|
||||||
|
if args.no_deploy:
|
||||||
|
print("[INFO] Lokaler Build abgeschlossen; Deployment nicht angefordert.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
deploy()
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
print(f"[FEHLER] {exc}")
|
||||||
|
print("[INFO] Der Build ist fertig. Deployment später mit 1_build-pipeline\\deploy.bat wiederholen.")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (OSError, ValueError, subprocess.CalledProcessError) as exc:
|
||||||
|
print(f"[FEHLER] Pipeline abgebrochen: {exc}")
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# -*- 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 / "version_check.py"), "."),
|
||||||
|
(str(_APP_DIR / "update_installer.py"), "."),
|
||||||
|
(str(_APP_DIR / "CHANGELOG.md"), "."),
|
||||||
|
(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,18 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
setlocal
|
||||||
|
set "PYTHONUTF8=1"
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
|
||||||
|
if exist "0_application\.venv\Scripts\python.exe" (
|
||||||
|
set "DEPLOY_PYTHON=0_application\.venv\Scripts\python.exe"
|
||||||
|
) else if exist ".venv\Scripts\python.exe" (
|
||||||
|
set "DEPLOY_PYTHON=.venv\Scripts\python.exe"
|
||||||
|
) else (
|
||||||
|
set "DEPLOY_PYTHON=python"
|
||||||
|
)
|
||||||
|
|
||||||
|
"%DEPLOY_PYTHON%" "1_build-pipeline\deploy.py"
|
||||||
|
set "DEPLOY_EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
if /I not "%~1"=="--no-pause" pause
|
||||||
|
exit /b %DEPLOY_EXIT_CODE%
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Package and publish the last successful build to the SMB update directory."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
BUILD_DIR = PROJECT_ROOT / "2_build"
|
||||||
|
DEPLOY_DIR = Path(r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren")
|
||||||
|
EXE_NAME = "Tätigkeitsnachweis-Generator.exe"
|
||||||
|
MANIFEST_NAME = "release.json"
|
||||||
|
PACKAGE_FILES = [
|
||||||
|
EXE_NAME, "README.md", "CHANGELOG.md",
|
||||||
|
"config/settings.ini", "config/input-fields.json", "config/output-fields.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_template_pdf(path: Path) -> bool:
|
||||||
|
"""Nur *.pdf, die nicht mit '_' oder '.' beginnen, gelten als Template."""
|
||||||
|
return path.is_file() and path.suffix.lower() == ".pdf" and not path.name.startswith(("_", "."))
|
||||||
|
|
||||||
|
|
||||||
|
def template_files(base_dir: Path) -> list[str]:
|
||||||
|
"""Relative Pfade aller Template-PDFs in base_dir/template (sortiert)."""
|
||||||
|
folder = base_dir / "template"
|
||||||
|
if not folder.is_dir():
|
||||||
|
return []
|
||||||
|
return [f"template/{p.name}" for p in sorted(folder.iterdir()) if is_template_pdf(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def package_files(build_dir: Path) -> list[str]:
|
||||||
|
"""Alle Dateien des Release-Pakets: feste Dateien plus alle Template-PDFs."""
|
||||||
|
return PACKAGE_FILES + template_files(build_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_version(version: str) -> str:
|
||||||
|
if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", version):
|
||||||
|
raise ValueError(f"Ungültige Versionsnummer: {version}")
|
||||||
|
return ".".join(str(int(part)) for part in (version.split(".") + ["0"])[:3])
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as source:
|
||||||
|
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(block)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def write_release_manifest(build_dir: Path, version: str) -> None:
|
||||||
|
"""Record the version and all payload hashes after a successful build."""
|
||||||
|
manifest = {
|
||||||
|
"version": normalize_version(version),
|
||||||
|
"files": {name: file_hash(build_dir / name) for name in package_files(build_dir)},
|
||||||
|
}
|
||||||
|
(build_dir / MANIFEST_NAME).write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_release(build_dir: Path) -> str:
|
||||||
|
if not (build_dir / MANIFEST_NAME).is_file():
|
||||||
|
raise ValueError("Kein bestätigter Build vorhanden. Bitte zuerst build.bat ausführen.")
|
||||||
|
manifest = json.loads((build_dir / MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||||
|
version = normalize_version(manifest["version"])
|
||||||
|
for name in package_files(build_dir):
|
||||||
|
path = build_dir / name
|
||||||
|
if not path.is_file() or file_hash(path) != manifest.get("files", {}).get(name):
|
||||||
|
raise ValueError(f"Build-Datei fehlt oder wurde nach dem Build verändert: {name}. Bitte neu bauen.")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def check_deploy_target(target_dir: Path) -> None:
|
||||||
|
"""Never create the SMB destination: a missing mount must be an error."""
|
||||||
|
try:
|
||||||
|
if not target_dir.is_dir():
|
||||||
|
raise OSError("Verzeichnis ist nicht verfügbar")
|
||||||
|
# Actually access the directory; exists() alone does not check read access.
|
||||||
|
with os.scandir(target_dir) as entries:
|
||||||
|
next(entries, None)
|
||||||
|
except OSError as exc:
|
||||||
|
raise OSError(
|
||||||
|
f"Deployment-Ziel nicht erreichbar: {target_dir}\n"
|
||||||
|
"Bitte VPN-Verbindung und Netzlaufwerk J: prüfen und deploy.bat erneut starten."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def deploy(build_dir: Path = BUILD_DIR, target_dir: Path = DEPLOY_DIR) -> Path:
|
||||||
|
version = validate_release(build_dir)
|
||||||
|
check_deploy_target(target_dir)
|
||||||
|
archive_name = f"Tätigkeitsnachweis-Generator v{version}.zip"
|
||||||
|
archive = build_dir / archive_name
|
||||||
|
descriptor, temporary = tempfile.mkstemp(prefix="release-", suffix=".zip", dir=build_dir)
|
||||||
|
os.close(descriptor)
|
||||||
|
temporary = Path(temporary)
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED) as package:
|
||||||
|
for name in package_files(build_dir):
|
||||||
|
package.write(build_dir / name, name)
|
||||||
|
for directory in ["input/", "output/"]:
|
||||||
|
# Fixed timestamps keep repeated deployments byte-identical.
|
||||||
|
package.writestr(zipfile.ZipInfo(directory, date_time=(2020, 1, 1, 0, 0, 0)), b"")
|
||||||
|
with zipfile.ZipFile(temporary) as package:
|
||||||
|
if package.testzip() is not None:
|
||||||
|
raise ValueError("Das erzeugte ZIP-Paket ist beschädigt.")
|
||||||
|
temporary.replace(archive)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
destination = target_dir / archive_name
|
||||||
|
if destination.exists():
|
||||||
|
if file_hash(destination) == file_hash(archive):
|
||||||
|
print(f"[OK] Diese Version ist bereits unverändert bereitgestellt: {destination}")
|
||||||
|
return destination
|
||||||
|
raise FileExistsError(f"{destination} existiert bereits mit anderem Inhalt. Bitte eine neue Version bauen.")
|
||||||
|
|
||||||
|
# Write access is checked by creating the staging file on the actual share.
|
||||||
|
remote_temporary = None
|
||||||
|
try:
|
||||||
|
descriptor, name = tempfile.mkstemp(prefix=".upload-", suffix=".tmp", dir=target_dir)
|
||||||
|
os.close(descriptor)
|
||||||
|
remote_temporary = Path(name)
|
||||||
|
shutil.copyfile(archive, remote_temporary)
|
||||||
|
if file_hash(remote_temporary) != file_hash(archive):
|
||||||
|
raise OSError("Die Prüfsumme nach der SMB-Übertragung stimmt nicht überein.")
|
||||||
|
# On Windows rename refuses to overwrite another published release.
|
||||||
|
remote_temporary.rename(destination)
|
||||||
|
except OSError as exc:
|
||||||
|
raise OSError(
|
||||||
|
"Deployment fehlgeschlagen. VPN-Verbindung und Schreibrechte prüfen. "
|
||||||
|
"Der erfolgreiche lokale Build bleibt erhalten; erneut mit deploy.bat starten."
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
if remote_temporary is not None:
|
||||||
|
try:
|
||||||
|
remote_temporary.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass # The VPN may have disconnected during transfer.
|
||||||
|
print(f"[OK] Deployment abgeschlossen: {destination}")
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Letzten erfolgreichen Build auf dem SMB-Laufwerk bereitstellen")
|
||||||
|
parser.parse_args()
|
||||||
|
try:
|
||||||
|
deploy()
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
print(f"[FEHLER] {exc}")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""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 subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if not getattr(sys, "frozen", False):
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "0_application"))
|
||||||
|
from server_lifecycle import stop_existing_server
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Only open the browser/start Streamlit once the previous server has stopped.
|
||||||
|
try:
|
||||||
|
if not stop_existing_server():
|
||||||
|
print("[INFO] Die bisherige Version bleibt geöffnet. Keine neue Instanz gestartet.")
|
||||||
|
return
|
||||||
|
except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
|
||||||
|
print(f"[FEHLER] Server-Neustart fehlgeschlagen: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
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,20 +0,0 @@
|
|||||||
# SAB Taetigkeitsnachweise Tool
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Create and activate a virtual environment.
|
|
||||||
2. Install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Local run command
|
|
||||||
|
|
||||||
Use module execution from the project root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m src.taetigkeitsnachweis.cli --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Current CLI is a scaffold. Full generation pipeline is implemented in later tasks.
|
|
||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
{
|
|
||||||
"paths": {
|
|
||||||
"input_dir": "input",
|
|
||||||
"template_dir": "template",
|
|
||||||
"output_dir": "output"
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"allocation_workbook": "PS_PW.xlsx",
|
|
||||||
"masterdata_workbook": "Stellenbeschreibungen.xlsx",
|
|
||||||
"template_workbook": "Tätigkeitsnachweis Stellenförderung.xlsx"
|
|
||||||
},
|
|
||||||
"sheets": {
|
|
||||||
"allocation": "Tabelle1",
|
|
||||||
"masterdata": "Tabelle1",
|
|
||||||
"template_source": "Tabelle1"
|
|
||||||
},
|
|
||||||
"allocation": {
|
|
||||||
"header_month_marker_row": 2,
|
|
||||||
"columns": {
|
|
||||||
"employee_name": "A",
|
|
||||||
"contract_hours_weekly": "B",
|
|
||||||
"project_key": "C",
|
|
||||||
"first_month_column": "D",
|
|
||||||
"remarks": "I"
|
|
||||||
},
|
|
||||||
"month_marker": {
|
|
||||||
"prefix": "ab ",
|
|
||||||
"date_format": "%d.%m.%Y",
|
|
||||||
"exact_first_day_required": true
|
|
||||||
},
|
|
||||||
"inactive_values": [
|
|
||||||
"",
|
|
||||||
"-",
|
|
||||||
"0",
|
|
||||||
0
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"masterdata": {
|
|
||||||
"columns": {
|
|
||||||
"id": "A",
|
|
||||||
"antragsnummer": "B",
|
|
||||||
"name": "C",
|
|
||||||
"qualifikation": "D",
|
|
||||||
"stellenanteil": "E",
|
|
||||||
"funktion": "F",
|
|
||||||
"taetigkeit": "G"
|
|
||||||
},
|
|
||||||
"decimal": {
|
|
||||||
"decimal_separator": ",",
|
|
||||||
"thousands_separator": "."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"matching": {
|
|
||||||
"name_normalization": {
|
|
||||||
"trim": true,
|
|
||||||
"collapse_spaces": true,
|
|
||||||
"case_insensitive": true
|
|
||||||
},
|
|
||||||
"share_tolerance_percent": 0.5,
|
|
||||||
"prefer_function_hint": true
|
|
||||||
},
|
|
||||||
"output": {
|
|
||||||
"filename_pattern": "Taetigkeitsnachweis_{year}_{month}_{first_name}_{last_name}.xlsx",
|
|
||||||
"one_workbook_per_employee": true,
|
|
||||||
"one_sheet_per_assignment": true,
|
|
||||||
"sheet_name_pattern": "{index}_{funktion}"
|
|
||||||
},
|
|
||||||
"template_targets": {
|
|
||||||
"antragsnummer": "",
|
|
||||||
"name_vorname": "",
|
|
||||||
"qualifikation": "",
|
|
||||||
"funktion": "",
|
|
||||||
"taetigkeiten": "",
|
|
||||||
"reporting_month_label": "",
|
|
||||||
"foerderquote": "",
|
|
||||||
"generated_on": ""
|
|
||||||
},
|
|
||||||
"runtime": {
|
|
||||||
"abort_on_validation_errors": true,
|
|
||||||
"preview_before_write": true,
|
|
||||||
"confirm_before_write": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
click>=8.1,<9.0
|
|
||||||
openpyxl>=3.1,<4.0
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
"""SAB Taetigkeitsnachweis generator package."""
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"models",
|
|
||||||
"errors",
|
|
||||||
"logging_utils",
|
|
||||||
]
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""CLI entrypoint and orchestration layer."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="taetigkeitsnachweis",
|
|
||||||
description="SAB Taetigkeitsnachweise generator",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--version",
|
|
||||||
action="store_true",
|
|
||||||
help="Show tool version and exit.",
|
|
||||||
)
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = build_parser()
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.version:
|
|
||||||
print("taetigkeitsnachweis 0.1.0")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
print("CLI scaffold ready. Full pipeline will be added in Task T13.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
class TaetigkeitsnachweisError(Exception):
|
|
||||||
"""Base exception for all domain-specific failures."""
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationError(TaetigkeitsnachweisError):
|
|
||||||
"""Raised when configuration is missing or invalid."""
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(TaetigkeitsnachweisError):
|
|
||||||
"""Raised when source or business validation fails."""
|
|
||||||
|
|
||||||
|
|
||||||
class MatchingError(TaetigkeitsnachweisError):
|
|
||||||
"""Raised when records cannot be matched uniquely."""
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: int = logging.INFO) -> None:
|
|
||||||
logging.basicConfig(
|
|
||||||
level=level,
|
|
||||||
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
||||||
)
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""Record matching logic across allocation and master data."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .errors import ValidationError
|
|
||||||
from .models import AllocationRecord, MasterDataRecord, MatchedAssignment, ValidationIssue
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_foerderquote_percent(allocation: AllocationRecord, precision: int = 2) -> float:
|
|
||||||
if allocation.contract_hours_weekly <= 0:
|
|
||||||
raise ValidationError(
|
|
||||||
"contract_hours_weekly must be greater than 0 for foerderquote calculation."
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_share = (allocation.funded_hours_weekly / allocation.contract_hours_weekly) * 100.0
|
|
||||||
return round(raw_share, precision)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_name(name: str) -> str:
|
|
||||||
compact = re.sub(r"\s+", " ", name.strip())
|
|
||||||
return compact.casefold()
|
|
||||||
|
|
||||||
|
|
||||||
def _with_tolerance(
|
|
||||||
value: float,
|
|
||||||
target: float,
|
|
||||||
tolerance: float,
|
|
||||||
) -> bool:
|
|
||||||
return abs(value - target) <= tolerance
|
|
||||||
|
|
||||||
|
|
||||||
def match_assignments(
|
|
||||||
allocation_records: list[AllocationRecord],
|
|
||||||
masterdata_records: list[MasterDataRecord],
|
|
||||||
config: dict[str, Any],
|
|
||||||
) -> tuple[list[MatchedAssignment], list[ValidationIssue]]:
|
|
||||||
matching_cfg = config.get("matching", {})
|
|
||||||
tolerance = float(matching_cfg.get("share_tolerance_percent", 0.5))
|
|
||||||
prefer_function_hint = bool(matching_cfg.get("prefer_function_hint", True))
|
|
||||||
|
|
||||||
master_by_name: dict[str, list[MasterDataRecord]] = {}
|
|
||||||
for record in masterdata_records:
|
|
||||||
master_by_name.setdefault(_normalize_name(record.name), []).append(record)
|
|
||||||
|
|
||||||
matched: list[MatchedAssignment] = []
|
|
||||||
issues: list[ValidationIssue] = []
|
|
||||||
|
|
||||||
for allocation in allocation_records:
|
|
||||||
normalized_name = _normalize_name(allocation.employee_name)
|
|
||||||
candidates = master_by_name.get(normalized_name, [])
|
|
||||||
if not candidates:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="MISSING_MASTERDATA",
|
|
||||||
message=(
|
|
||||||
f"No master-data match for employee '{allocation.employee_name}' "
|
|
||||||
f"(project '{allocation.project_key}')."
|
|
||||||
),
|
|
||||||
severity="error",
|
|
||||||
context=allocation.employee_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
foerderquote = calculate_foerderquote_percent(allocation)
|
|
||||||
share_matches = [
|
|
||||||
candidate
|
|
||||||
for candidate in candidates
|
|
||||||
if _with_tolerance(candidate.stellenanteil_percent, foerderquote, tolerance)
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(share_matches) == 1:
|
|
||||||
selected = share_matches[0]
|
|
||||||
elif len(share_matches) > 1 and prefer_function_hint:
|
|
||||||
key = allocation.project_key.casefold()
|
|
||||||
hinted = [c for c in share_matches if key in c.funktion.casefold()]
|
|
||||||
if len(hinted) == 1:
|
|
||||||
selected = hinted[0]
|
|
||||||
else:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="AMBIGUOUS_MASTERDATA",
|
|
||||||
message=(
|
|
||||||
f"Ambiguous match for employee '{allocation.employee_name}' "
|
|
||||||
f"(project '{allocation.project_key}', foerderquote {foerderquote:.2f}%)."
|
|
||||||
),
|
|
||||||
severity="error",
|
|
||||||
context=allocation.employee_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
elif len(share_matches) > 1:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="AMBIGUOUS_MASTERDATA",
|
|
||||||
message=(
|
|
||||||
f"Ambiguous match for employee '{allocation.employee_name}' "
|
|
||||||
f"(project '{allocation.project_key}', foerderquote {foerderquote:.2f}%)."
|
|
||||||
),
|
|
||||||
severity="error",
|
|
||||||
context=allocation.employee_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="NO_SHARE_MATCH",
|
|
||||||
message=(
|
|
||||||
f"No Stellenanteil match for employee '{allocation.employee_name}' "
|
|
||||||
f"with foerderquote {foerderquote:.2f}% (project '{allocation.project_key}')."
|
|
||||||
),
|
|
||||||
severity="error",
|
|
||||||
context=allocation.employee_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if allocation.notes:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="ALLOCATION_NOTE",
|
|
||||||
message=(
|
|
||||||
f"Allocation note for '{allocation.employee_name}' / "
|
|
||||||
f"'{allocation.project_key}': {allocation.notes}"
|
|
||||||
),
|
|
||||||
severity="warning",
|
|
||||||
context=allocation.employee_name,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
matched.append(
|
|
||||||
MatchedAssignment(
|
|
||||||
allocation=allocation,
|
|
||||||
masterdata=selected,
|
|
||||||
foerderquote_percent=foerderquote,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return matched, issues
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AllocationRecord:
|
|
||||||
employee_name: str
|
|
||||||
contract_hours_weekly: float
|
|
||||||
project_key: str
|
|
||||||
funded_hours_weekly: float
|
|
||||||
notes: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MasterDataRecord:
|
|
||||||
record_id: str
|
|
||||||
antragsnummer: str
|
|
||||||
name: str
|
|
||||||
qualifikation: str
|
|
||||||
stellenanteil_percent: float
|
|
||||||
funktion: str
|
|
||||||
taetigkeit: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MatchedAssignment:
|
|
||||||
allocation: AllocationRecord
|
|
||||||
masterdata: MasterDataRecord
|
|
||||||
foerderquote_percent: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ValidationIssue:
|
|
||||||
code: str
|
|
||||||
message: str
|
|
||||||
severity: str = "error"
|
|
||||||
context: Optional[str] = None
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
"""Parsing and normalization for source workbooks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from openpyxl import load_workbook
|
|
||||||
from openpyxl.utils.cell import column_index_from_string
|
|
||||||
|
|
||||||
from .errors import ValidationError
|
|
||||||
from .models import AllocationRecord, MasterDataRecord
|
|
||||||
|
|
||||||
|
|
||||||
def _to_text(value: Any) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
return str(value).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _to_float(value: Any, field_name: str) -> float:
|
|
||||||
if value is None or str(value).strip() == "":
|
|
||||||
raise ValidationError(f"Missing numeric value for {field_name}.")
|
|
||||||
|
|
||||||
text = str(value).strip().replace(",", ".")
|
|
||||||
try:
|
|
||||||
return float(text)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError(f"Invalid numeric value for {field_name}: {value}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_german_decimal(
|
|
||||||
value: Any,
|
|
||||||
field_name: str,
|
|
||||||
decimal_separator: str,
|
|
||||||
thousands_separator: str,
|
|
||||||
) -> float:
|
|
||||||
if value is None or str(value).strip() == "":
|
|
||||||
raise ValidationError(f"Missing numeric value for {field_name}.")
|
|
||||||
|
|
||||||
text = str(value).strip()
|
|
||||||
if thousands_separator:
|
|
||||||
text = text.replace(thousands_separator, "")
|
|
||||||
if decimal_separator:
|
|
||||||
text = text.replace(decimal_separator, ".")
|
|
||||||
|
|
||||||
try:
|
|
||||||
return float(text)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValidationError(f"Invalid numeric value for {field_name}: {value}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_month_marker(value: Any, prefix: str, date_format: str) -> datetime | None:
|
|
||||||
marker = _to_text(value)
|
|
||||||
if not marker:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not marker.lower().startswith(prefix.lower()):
|
|
||||||
return None
|
|
||||||
|
|
||||||
date_text = marker[len(prefix) :].strip()
|
|
||||||
return datetime.strptime(date_text, date_format)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_month_column_index(
|
|
||||||
worksheet: Any,
|
|
||||||
header_row: int,
|
|
||||||
first_month_col: str,
|
|
||||||
target_month: int,
|
|
||||||
target_year: int,
|
|
||||||
month_prefix: str,
|
|
||||||
month_date_format: str,
|
|
||||||
) -> int:
|
|
||||||
start_index = column_index_from_string(first_month_col)
|
|
||||||
matched_columns: list[int] = []
|
|
||||||
|
|
||||||
for column_index in range(start_index, worksheet.max_column + 1):
|
|
||||||
value = worksheet.cell(row=header_row, column=column_index).value
|
|
||||||
parsed = _parse_month_marker(value, month_prefix, month_date_format)
|
|
||||||
if parsed is None:
|
|
||||||
continue
|
|
||||||
if parsed.month == target_month and parsed.year == target_year:
|
|
||||||
matched_columns.append(column_index)
|
|
||||||
|
|
||||||
if len(matched_columns) == 0:
|
|
||||||
raise ValidationError(
|
|
||||||
f"No month column found for {target_month:02d}/{target_year} in allocation workbook."
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(matched_columns) > 1:
|
|
||||||
raise ValidationError(
|
|
||||||
f"Multiple month columns found for {target_month:02d}/{target_year} in allocation workbook."
|
|
||||||
)
|
|
||||||
|
|
||||||
return matched_columns[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _is_inactive_value(value: Any, inactive_values: list[Any]) -> bool:
|
|
||||||
normalized = _to_text(value)
|
|
||||||
if normalized in {"", "-"}:
|
|
||||||
return True
|
|
||||||
|
|
||||||
for inactive in inactive_values:
|
|
||||||
if str(inactive).strip() == normalized:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def parse_allocation_records(
|
|
||||||
config: dict[str, Any],
|
|
||||||
report_month: int,
|
|
||||||
report_year: int,
|
|
||||||
project_root: Path,
|
|
||||||
) -> list[AllocationRecord]:
|
|
||||||
allocation_cfg = config["allocation"]
|
|
||||||
columns_cfg = allocation_cfg["columns"]
|
|
||||||
files_cfg = config["files"]
|
|
||||||
sheets_cfg = config["sheets"]
|
|
||||||
paths_cfg = config["paths"]
|
|
||||||
|
|
||||||
workbook_path = (
|
|
||||||
project_root
|
|
||||||
/ paths_cfg["input_dir"]
|
|
||||||
/ files_cfg["allocation_workbook"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not workbook_path.exists():
|
|
||||||
raise ValidationError(f"Allocation workbook not found: {workbook_path}")
|
|
||||||
|
|
||||||
workbook = load_workbook(workbook_path, data_only=True)
|
|
||||||
sheet_name = sheets_cfg["allocation"]
|
|
||||||
if sheet_name not in workbook.sheetnames:
|
|
||||||
raise ValidationError(f"Sheet '{sheet_name}' not found in {workbook_path.name}")
|
|
||||||
worksheet = workbook[sheet_name]
|
|
||||||
|
|
||||||
header_row = allocation_cfg["header_month_marker_row"]
|
|
||||||
month_marker_cfg = allocation_cfg["month_marker"]
|
|
||||||
month_column = _resolve_month_column_index(
|
|
||||||
worksheet=worksheet,
|
|
||||||
header_row=header_row,
|
|
||||||
first_month_col=columns_cfg["first_month_column"],
|
|
||||||
target_month=report_month,
|
|
||||||
target_year=report_year,
|
|
||||||
month_prefix=month_marker_cfg["prefix"],
|
|
||||||
month_date_format=month_marker_cfg["date_format"],
|
|
||||||
)
|
|
||||||
|
|
||||||
employee_col = column_index_from_string(columns_cfg["employee_name"])
|
|
||||||
contract_col = column_index_from_string(columns_cfg["contract_hours_weekly"])
|
|
||||||
project_col = column_index_from_string(columns_cfg["project_key"])
|
|
||||||
remarks_col = column_index_from_string(columns_cfg["remarks"])
|
|
||||||
inactive_values = allocation_cfg.get("inactive_values", ["", "-", "0", 0])
|
|
||||||
|
|
||||||
current_employee = ""
|
|
||||||
current_contract_hours: float | None = None
|
|
||||||
normalized_records: list[AllocationRecord] = []
|
|
||||||
|
|
||||||
for row_index in range(header_row + 1, worksheet.max_row + 1):
|
|
||||||
row_employee = _to_text(worksheet.cell(row=row_index, column=employee_col).value)
|
|
||||||
row_contract = worksheet.cell(row=row_index, column=contract_col).value
|
|
||||||
row_project = _to_text(worksheet.cell(row=row_index, column=project_col).value)
|
|
||||||
|
|
||||||
if row_employee:
|
|
||||||
current_employee = row_employee
|
|
||||||
current_contract_hours = _to_float(
|
|
||||||
row_contract,
|
|
||||||
f"contract_hours_weekly row {row_index}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not row_project:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not current_employee or current_contract_hours is None:
|
|
||||||
raise ValidationError(
|
|
||||||
f"Project row {row_index} has no inherited employee context."
|
|
||||||
)
|
|
||||||
|
|
||||||
month_value = worksheet.cell(row=row_index, column=month_column).value
|
|
||||||
if _is_inactive_value(month_value, inactive_values):
|
|
||||||
continue
|
|
||||||
|
|
||||||
funded_hours = _to_float(month_value, f"funded_hours row {row_index}")
|
|
||||||
if funded_hours <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if current_contract_hours <= 0:
|
|
||||||
raise ValidationError(
|
|
||||||
f"Contract hours must be > 0 for employee '{current_employee}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
notes = _to_text(worksheet.cell(row=row_index, column=remarks_col).value)
|
|
||||||
normalized_records.append(
|
|
||||||
AllocationRecord(
|
|
||||||
employee_name=current_employee,
|
|
||||||
contract_hours_weekly=current_contract_hours,
|
|
||||||
project_key=row_project,
|
|
||||||
funded_hours_weekly=funded_hours,
|
|
||||||
notes=notes,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
workbook.close()
|
|
||||||
return normalized_records
|
|
||||||
|
|
||||||
|
|
||||||
def parse_masterdata_records(
|
|
||||||
config: dict[str, Any],
|
|
||||||
project_root: Path,
|
|
||||||
) -> list[MasterDataRecord]:
|
|
||||||
files_cfg = config["files"]
|
|
||||||
sheets_cfg = config["sheets"]
|
|
||||||
paths_cfg = config["paths"]
|
|
||||||
master_cfg = config["masterdata"]
|
|
||||||
columns_cfg = master_cfg["columns"]
|
|
||||||
decimal_cfg = master_cfg["decimal"]
|
|
||||||
|
|
||||||
workbook_path = (
|
|
||||||
project_root
|
|
||||||
/ paths_cfg["input_dir"]
|
|
||||||
/ files_cfg["masterdata_workbook"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if not workbook_path.exists():
|
|
||||||
raise ValidationError(f"Master data workbook not found: {workbook_path}")
|
|
||||||
|
|
||||||
workbook = load_workbook(workbook_path, data_only=True)
|
|
||||||
sheet_name = sheets_cfg["masterdata"]
|
|
||||||
if sheet_name not in workbook.sheetnames:
|
|
||||||
raise ValidationError(f"Sheet '{sheet_name}' not found in {workbook_path.name}")
|
|
||||||
worksheet = workbook[sheet_name]
|
|
||||||
|
|
||||||
id_col = column_index_from_string(columns_cfg["id"])
|
|
||||||
antragsnummer_col = column_index_from_string(columns_cfg["antragsnummer"])
|
|
||||||
name_col = column_index_from_string(columns_cfg["name"])
|
|
||||||
qualifikation_col = column_index_from_string(columns_cfg["qualifikation"])
|
|
||||||
stellenanteil_col = column_index_from_string(columns_cfg["stellenanteil"])
|
|
||||||
funktion_col = column_index_from_string(columns_cfg["funktion"])
|
|
||||||
taetigkeit_col = column_index_from_string(columns_cfg["taetigkeit"])
|
|
||||||
|
|
||||||
records: list[MasterDataRecord] = []
|
|
||||||
|
|
||||||
for row_index in range(2, worksheet.max_row + 1):
|
|
||||||
name = _to_text(worksheet.cell(row=row_index, column=name_col).value)
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
|
|
||||||
stellenanteil = _parse_german_decimal(
|
|
||||||
worksheet.cell(row=row_index, column=stellenanteil_col).value,
|
|
||||||
field_name=f"stellenanteil row {row_index}",
|
|
||||||
decimal_separator=decimal_cfg.get("decimal_separator", ","),
|
|
||||||
thousands_separator=decimal_cfg.get("thousands_separator", "."),
|
|
||||||
)
|
|
||||||
|
|
||||||
records.append(
|
|
||||||
MasterDataRecord(
|
|
||||||
record_id=_to_text(worksheet.cell(row=row_index, column=id_col).value),
|
|
||||||
antragsnummer=_to_text(
|
|
||||||
worksheet.cell(row=row_index, column=antragsnummer_col).value
|
|
||||||
),
|
|
||||||
name=name,
|
|
||||||
qualifikation=_to_text(
|
|
||||||
worksheet.cell(row=row_index, column=qualifikation_col).value
|
|
||||||
),
|
|
||||||
stellenanteil_percent=stellenanteil,
|
|
||||||
funktion=_to_text(worksheet.cell(row=row_index, column=funktion_col).value),
|
|
||||||
taetigkeit=_to_text(
|
|
||||||
worksheet.cell(row=row_index, column=taetigkeit_col).value
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
workbook.close()
|
|
||||||
return records
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Shared utility helpers."""
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
"""Source and business rule validation."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from openpyxl import load_workbook
|
|
||||||
from openpyxl.utils.cell import column_index_from_string
|
|
||||||
|
|
||||||
from .models import ValidationIssue
|
|
||||||
|
|
||||||
|
|
||||||
def _to_text(value: Any) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
return str(value).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _to_float_or_none(value: Any) -> float | None:
|
|
||||||
text = _to_text(value)
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
text = text.replace(",", ".")
|
|
||||||
try:
|
|
||||||
return float(text)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_month_marker(value: Any, prefix: str, date_format: str) -> datetime | None:
|
|
||||||
marker = _to_text(value)
|
|
||||||
if not marker or not marker.lower().startswith(prefix.lower()):
|
|
||||||
return None
|
|
||||||
|
|
||||||
date_text = marker[len(prefix) :].strip()
|
|
||||||
try:
|
|
||||||
return datetime.strptime(date_text, date_format)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_sources(
|
|
||||||
config: dict[str, Any],
|
|
||||||
report_month: int,
|
|
||||||
report_year: int,
|
|
||||||
project_root: Path,
|
|
||||||
) -> list[ValidationIssue]:
|
|
||||||
issues: list[ValidationIssue] = []
|
|
||||||
|
|
||||||
paths_cfg = config["paths"]
|
|
||||||
files_cfg = config["files"]
|
|
||||||
sheets_cfg = config["sheets"]
|
|
||||||
allocation_cfg = config["allocation"]
|
|
||||||
allocation_cols = allocation_cfg["columns"]
|
|
||||||
month_cfg = allocation_cfg["month_marker"]
|
|
||||||
|
|
||||||
input_dir = project_root / paths_cfg["input_dir"]
|
|
||||||
template_dir = project_root / paths_cfg["template_dir"]
|
|
||||||
|
|
||||||
allocation_path = input_dir / files_cfg["allocation_workbook"]
|
|
||||||
masterdata_path = input_dir / files_cfg["masterdata_workbook"]
|
|
||||||
template_path = template_dir / files_cfg["template_workbook"]
|
|
||||||
|
|
||||||
for required_path in [allocation_path, masterdata_path, template_path]:
|
|
||||||
if not required_path.exists():
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="MISSING_FILE",
|
|
||||||
message=f"Required file not found: {required_path}",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if any(issue.code == "MISSING_FILE" for issue in issues):
|
|
||||||
return issues
|
|
||||||
|
|
||||||
allocation_wb = load_workbook(allocation_path, data_only=True)
|
|
||||||
master_wb = load_workbook(masterdata_path, data_only=True)
|
|
||||||
|
|
||||||
allocation_sheet_name = sheets_cfg["allocation"]
|
|
||||||
master_sheet_name = sheets_cfg["masterdata"]
|
|
||||||
|
|
||||||
if allocation_sheet_name not in allocation_wb.sheetnames:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="MISSING_SHEET",
|
|
||||||
message=f"Sheet '{allocation_sheet_name}' missing in {allocation_path.name}",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if master_sheet_name not in master_wb.sheetnames:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="MISSING_SHEET",
|
|
||||||
message=f"Sheet '{master_sheet_name}' missing in {masterdata_path.name}",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if any(issue.code == "MISSING_SHEET" for issue in issues):
|
|
||||||
allocation_wb.close()
|
|
||||||
master_wb.close()
|
|
||||||
return issues
|
|
||||||
|
|
||||||
allocation_ws = allocation_wb[allocation_sheet_name]
|
|
||||||
master_ws = master_wb[master_sheet_name]
|
|
||||||
|
|
||||||
required_allocation_columns = [
|
|
||||||
allocation_cols["employee_name"],
|
|
||||||
allocation_cols["contract_hours_weekly"],
|
|
||||||
allocation_cols["project_key"],
|
|
||||||
allocation_cols["first_month_column"],
|
|
||||||
allocation_cols["remarks"],
|
|
||||||
]
|
|
||||||
max_allocation_col = allocation_ws.max_column
|
|
||||||
for col_name in required_allocation_columns:
|
|
||||||
if column_index_from_string(col_name) > max_allocation_col:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="MISSING_COLUMN",
|
|
||||||
message=f"Allocation column '{col_name}' is not present.",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
master_cols = config["masterdata"]["columns"]
|
|
||||||
required_master_columns = [
|
|
||||||
master_cols["id"],
|
|
||||||
master_cols["antragsnummer"],
|
|
||||||
master_cols["name"],
|
|
||||||
master_cols["qualifikation"],
|
|
||||||
master_cols["stellenanteil"],
|
|
||||||
master_cols["funktion"],
|
|
||||||
master_cols["taetigkeit"],
|
|
||||||
]
|
|
||||||
max_master_col = master_ws.max_column
|
|
||||||
for col_name in required_master_columns:
|
|
||||||
if column_index_from_string(col_name) > max_master_col:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="MISSING_COLUMN",
|
|
||||||
message=f"Master-data column '{col_name}' is not present.",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
header_row = allocation_cfg["header_month_marker_row"]
|
|
||||||
first_month_col = column_index_from_string(allocation_cols["first_month_column"])
|
|
||||||
month_matches: list[int] = []
|
|
||||||
parseable_markers = 0
|
|
||||||
|
|
||||||
for col_idx in range(first_month_col, allocation_ws.max_column + 1):
|
|
||||||
marker_value = allocation_ws.cell(row=header_row, column=col_idx).value
|
|
||||||
parsed = _parse_month_marker(
|
|
||||||
marker_value,
|
|
||||||
month_cfg["prefix"],
|
|
||||||
month_cfg["date_format"],
|
|
||||||
)
|
|
||||||
if parsed is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
parseable_markers += 1
|
|
||||||
if parsed.month == report_month and parsed.year == report_year:
|
|
||||||
month_matches.append(col_idx)
|
|
||||||
|
|
||||||
if parseable_markers == 0:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="INVALID_MONTH_MARKERS",
|
|
||||||
message="No parseable month marker found in allocation header row.",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(month_matches) == 0:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="MONTH_NOT_FOUND",
|
|
||||||
message=f"Requested month {report_month:02d}/{report_year} not found in allocation sheet.",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif len(month_matches) > 1:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="MONTH_AMBIGUOUS",
|
|
||||||
message=f"Requested month {report_month:02d}/{report_year} appears in multiple columns.",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(month_matches) == 1:
|
|
||||||
month_column = month_matches[0]
|
|
||||||
contract_col = column_index_from_string(allocation_cols["contract_hours_weekly"])
|
|
||||||
project_col = column_index_from_string(allocation_cols["project_key"])
|
|
||||||
|
|
||||||
for row_idx in range(header_row + 1, allocation_ws.max_row + 1):
|
|
||||||
project_key = _to_text(allocation_ws.cell(row=row_idx, column=project_col).value)
|
|
||||||
if not project_key:
|
|
||||||
continue
|
|
||||||
|
|
||||||
contract_value = _to_float_or_none(
|
|
||||||
allocation_ws.cell(row=row_idx, column=contract_col).value
|
|
||||||
)
|
|
||||||
if contract_value is None or contract_value <= 0:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="INVALID_CONTRACT_HOURS",
|
|
||||||
message=f"Invalid contract weekly hours at row {row_idx}.",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
monthly_value = allocation_ws.cell(row=row_idx, column=month_column).value
|
|
||||||
monthly_text = _to_text(monthly_value)
|
|
||||||
if monthly_text in {"", "-", "0"}:
|
|
||||||
continue
|
|
||||||
|
|
||||||
monthly_hours = _to_float_or_none(monthly_value)
|
|
||||||
if monthly_hours is None:
|
|
||||||
issues.append(
|
|
||||||
ValidationIssue(
|
|
||||||
code="INVALID_HOUR_VALUE",
|
|
||||||
message=f"Invalid funded hour value at row {row_idx}.",
|
|
||||||
severity="error",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
allocation_wb.close()
|
|
||||||
master_wb.close()
|
|
||||||
return issues
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Template-based output workbook writer."""
|
|
||||||
Binary file not shown.
Reference in new issue
Block a user