Compare commits

...

5 Commits

12 changed files with 2892 additions and 246 deletions
+13 -1
View File
@@ -33,7 +33,17 @@ Dieses Repo (`docu_build`) dient als Entwicklungs- und Planungsumgebung. Das Zie
### Technologie-Stack ### Technologie-Stack
Sphinx + reStructuredText + Jinja2-Templating → LaTeX → PDF. Konfiguration via JSON (ERP-Export) + YAML (Modul-Metadaten). Bildkonvertierung: ezdxf → SVG, Pillow. CI: Gitea Actions oder n8n auf Hetzner-Server. | Komponente | Technologie |
|------------------|--------------------------------------------------|
| Build-System | Sphinx (Python) |
| Textformat | reStructuredText (rST) |
| Templating | Jinja2 (Rahmenkapitel, Titelseite) |
| PDF-Erzeugung | LaTeX (pdflatex/lualatex) |
| Versionierung | Git (Gitea auf Hetzner) |
| Konfiguration | JSON (ERP-Export) + YAML (Modul-Metadaten) |
| CI/Automatisierung | Gitea Actions oder n8n |
| Bildkonvertierung | ezdxf → SVG, Pillow (Optimierung) |
### Build-Pipeline (zu implementieren) ### Build-Pipeline (zu implementieren)
@@ -49,6 +59,8 @@ Sphinx + reStructuredText + Jinja2-Templating → LaTeX → PDF. Konfiguration v
**meta.yaml** (pro Modul): Definiert `display_name` (mehrsprachig), `chapters` (Kapitelzuordnung mit Sections), `images` (shared/localized/module_local), `requires` (Abhängigkeiten). **meta.yaml** (pro Modul): Definiert `display_name` (mehrsprachig), `chapters` (Kapitelzuordnung mit Sections), `images` (shared/localized/module_local), `requires` (Abhängigkeiten).
Details siehe `doc/Kernkonzepte.md`.
### Kapitelstruktur (`chapters/`) ### Kapitelstruktur (`chapters/`)
```text ```text
+25
View File
@@ -0,0 +1,25 @@
@echo off
REM ================================================================
REM JSON-Baumstruktur aus Excel-Stueckliste erzeugen
REM
REM Liest eine Excel-Datei aus examples/ und baut aus der
REM Positionsspalte eine hierarchische JSON-Datei.
REM
REM Verwendung:
REM jsonFromXlsx.bat --filename Fortna --tab soll
REM jsonFromXlsx.bat --filename Fortna --tab ist
REM ================================================================
call "%~dp0setenv.bat"
REM Pruefe ob venv existiert
if not exist "%PROJECT%\.venv" (
echo FEHLER: .venv nicht gefunden. Bitte zuerst install_py.bat ausfuehren.
exit /b 1
)
call "%PROJECT%\.venv\Scripts\activate.bat"
python "%PV_LIB%\jsonFromXlsx.py" %*
deactivate
+15 -15
View File
@@ -66,21 +66,21 @@ echo ================================================================
echo DOCU_BUILD ENVIRONMENT SETUP COMPLETE echo DOCU_BUILD ENVIRONMENT SETUP COMPLETE
echo ================================================================ echo ================================================================
echo PROJECT = %PROJECT% echo PROJECT = %PROJECT%
echo PV_BIN = %PV_BIN% REM echo PV_BIN = %PV_BIN%
echo PV_CFG = %PV_CFG% REM echo PV_CFG = %PV_CFG%
echo PV_LIB = %PV_LIB% REM echo PV_LIB = %PV_LIB%
echo PV_DATA = %PV_DATA% REM echo PV_DATA = %PV_DATA%
echo PV_RESULTS = %PV_RESULTS% REM echo PV_RESULTS = %PV_RESULTS%
echo PV_LOG = %PV_LOG% REM echo PV_LOG = %PV_LOG%
echo PV_EXAMPLES = %PV_EXAMPLES% REM echo PV_EXAMPLES = %PV_EXAMPLES%
echo PV_EXT = %PV_EXT% REM echo PV_EXT = %PV_EXT%
echo PV_TEMPLATES = %PV_TEMPLATES% REM echo PV_TEMPLATES = %PV_TEMPLATES%
echo PV_ASSETS = %PV_ASSETS% REM echo PV_ASSETS = %PV_ASSETS%
echo PV_MODULES = %PV_MODULES% REM echo PV_MODULES = %PV_MODULES%
echo PV_CHAPTERS = %PV_CHAPTERS% REM echo PV_CHAPTERS = %PV_CHAPTERS%
echo PV_GLOSSARY = %PV_GLOSSARY% REM echo PV_GLOSSARY = %PV_GLOSSARY%
echo PV_STYLES = %PV_STYLES% REM echo PV_STYLES = %PV_STYLES%
echo PV_SCRIPTS = %PV_SCRIPTS% REM echo PV_SCRIPTS = %PV_SCRIPTS%
echo PYTHONPATH = %PYTHONPATH% echo PYTHONPATH = %PYTHONPATH%
echo ================================================================ echo ================================================================
echo. echo.
File diff suppressed because it is too large Load Diff
+280
View File
@@ -1,5 +1,162 @@
# Kernkonzepte # Kernkonzepte
## Projektübersicht
Sphinx-basiertes Dokumentationssystem für Transport- und Hängefördersysteme.
JSON-gesteuerter Build erzeugt aus modularen Textbausteinen mehrsprachige PDF-Dokumentationen (250+ Seiten).
Zwei-Repo-Architektur: allgemeine Ressourcen (Base-Repo) + projektspezifische Anpassungen (Projekt-Repo).
Auftraggeber: Firma Schönenberger
Umsetzung: Mista GmbH
## Architektur
### Zwei-Repo-Trennung
- **transport-docs-base** (Gitea): Alle wiederverwendbaren Inhalte Module, Kapitelgerüste, Assets, Glossar, Sphinx-Extensions, LaTeX-Templates, Hilfsskripte. Semver-versioniert (VERSION-Datei).
- **projekt-kunde-xyz** (pro Anlage): anlage.json (ERP-Export), projektspezifische Bilder, Overrides, Zusatzkapitel, conf.py, build.py. Das Base-Repo wird beim Build als Mount/Share unter `transport-docs-base/` bereitgestellt.
### Technologie-Stack
| Komponente | Technologie |
|------------------|--------------------------------------------------|
| Build-System | Sphinx (Python) |
| Textformat | reStructuredText (rST) |
| Templating | Jinja2 (Rahmenkapitel, Titelseite) |
| PDF-Erzeugung | LaTeX (pdflatex/lualatex) |
| Versionierung | Git (Gitea auf Hetzner) |
| Konfiguration | JSON (ERP-Export) + YAML (Modul-Metadaten) |
| CI/Automatisierung | Gitea Actions oder n8n |
| Bildkonvertierung | ezdxf → SVG, Pillow (Optimierung) |
## Repo-Struktur
### Base-Repo: transport-docs-base
```
transport-docs-base/
├── conf_base.py # Sphinx-Basiskonfiguration (wird importiert)
├── VERSION # Semver der Dokumentationsbasis
├── _ext/
│ ├── anlagen_builder.py # Hauptextension: JSON → Buchstruktur
│ ├── localized_figure.py # Sprachauflösung für Bilder
│ └── glossary_loader.py # CSV → Glossar-Direktive
├── _templates/
│ ├── latex/
│ │ ├── preamble.tex # Firmen-CI: Fonts, Farben, Kopfzeilen
│ │ ├── titlepage.tex # Titelseite-Template (Jinja2)
│ │ └── maketitle.tex
│ └── common/
│ └── header_footer.rst
├── assets/
│ ├── shared/ # Sprachneutrale Bilder (~90%)
│ │ ├── photos/
│ │ │ ├── weiche_typ_a_foto_01.jpg
│ │ │ ├── hubstation_foto_01.jpg
│ │ │ └── antrieb_sew_foto_01.jpg
│ │ ├── drawings/ # Technische Zeichnungen (DXF → SVG/PDF)
│ │ │ ├── weiche_typ_a_layout.svg
│ │ │ └── hubstation_schnitt.pdf
│ │ ├── symbols/ # ISO 7010 Sicherheitssymbole
│ │ │ ├── W001_warnung.svg
│ │ │ └── M004_augenschutz.svg
│ │ └── diagrams/ # Schaltpläne, Pneumatik
│ │ └── stromlaufplan_weiche_a.pdf
│ └── localized/ # Sprachspezifische Bilder
│ ├── de/
│ │ ├── screenshots/
│ │ │ └── hmi_hauptmenu.png
│ │ └── diagramme/
│ │ └── risikograph_weiche_a.svg
│ ├── en/
│ │ ├── screenshots/
│ │ │ └── hmi_hauptmenu.png
│ │ └── diagramme/
│ │ └── risikograph_weiche_a.svg
│ └── fr/
│ └── ...
├── modules/ # Textbausteine pro Maschinentyp
│ ├── weiche_typ_a/
│ │ ├── meta.yaml # Abhängigkeiten + Kapitelzuordnung
│ │ ├── beschreibung.de.rst
│ │ ├── beschreibung.en.rst
│ │ ├── risikomatrix.de.rst
│ │ ├── risikomatrix.en.rst
│ │ ├── wartung.de.rst
│ │ └── images/
│ │ └── montage_detail_01.jpg
│ ├── hubstation/
│ │ ├── meta.yaml
│ │ ├── beschreibung.de.rst
│ │ ├── technische_daten.de.rst
│ │ ├── wartung.de.rst
│ │ └── images/
│ │ └── hubmechanik_detail.jpg
│ ├── antrieb_sew/
│ │ ├── meta.yaml
│ │ └── ...
│ └── steuerung_siemens_s7/
│ ├── meta.yaml
│ └── ...
├── chapters/ # Rahmenkapitel (Gerüst)
│ ├── 01_einleitung/
│ │ ├── template.de.rst # Jinja2-Template mit Platzhaltern
│ │ └── template.en.rst
│ ├── 02_sicherheit/
│ │ ├── allgemein.de.rst # Immer dabei
│ │ ├── allgemein.en.rst
│ │ ├── normen_ce.de.rst # EU-Konformität
│ │ └── normen_ukca.en.rst # UK-spezifisch
│ ├── 03_anlagenbeschreibung/
│ │ └── template.de.rst # Hier werden Module eingefügt
│ ├── 04_betrieb/
│ │ └── template.de.rst
│ ├── 05_wartung/
│ │ ├── allgemein.de.rst
│ │ └── schmierplan_template.de.rst
│ ├── 06_ersatzteile/
│ │ └── template.de.rst
│ └── 07_anhang/
│ └── template.de.rst
├── glossary/
│ ├── terms.de.csv # "Begriff;Definition;Kategorie"
│ ├── terms.en.csv
│ └── terms.fr.csv
├── styles/
│ ├── firma_logo.pdf
│ ├── firma_logo_sw.pdf
│ └── color_scheme.yaml # CI-Farben für LaTeX und SVG
├── scripts/
│ ├── dxf2svg.py # DXF → SVG Konvertierung
│ ├── optimize_images.py # Bildoptimierung vor Commit
│ └── validate_modules.py # Prüft meta.yaml Konsistenz
```
### Projekt-Repo: projekt-kunde-xyz
```
projekt-kunde-xyz-2026/
├── conf.py # Importiert conf_base.py vom Base-Repo
├── anlage.json # ERP-Export: Maschinenauswahl + Metadaten
├── build.py # Hauptbuild-Skript
├── Makefile
├── README.md
├── images/ # Projektspezifische Bilder
│ ├── aufstellplan_halle3.pdf
│ ├── foto_abnahme_01.jpg
│ └── kundenlogo.pdf
├── overrides/ # Projektspezifische Überschreibungen
│ ├── modules/
│ │ └── weiche_typ_a/
│ │ └── beschreibung.de.rst # Überschreibt Basis wenn vorhanden
│ └── chapters/
│ └── 01_einleitung/
│ └── template.de.rst # Kundenspezifische Einleitung
├── additional/ # Kapitel die NUR dieses Projekt hat
│ ├── sonderausfuehrung_xyz.de.rst
│ └── sonderausfuehrung_xyz.en.rst
└── transport-docs-base/ ──────────── # Symlink/Mount zum Base-Repo (Share)
```
### anlage.json (ERP-Export) ### anlage.json (ERP-Export)
Steuert die Zusammenstellung. Enthält Projektmetadaten, Zielsprachen, Normenregion, Liste verbauter Maschinen und Referenzen auf Zusatzkapitel. Steuert die Zusammenstellung. Enthält Projektmetadaten, Zielsprachen, Normenregion, Liste verbauter Maschinen und Referenzen auf Zusatzkapitel.
@@ -220,3 +377,126 @@ Ausgabe:
- `scripts/dxf2svg.py` DXF → SVG Konvertierung (ezdxf) - `scripts/dxf2svg.py` DXF → SVG Konvertierung (ezdxf)
- `scripts/optimize_images.py` Bildoptimierung vor Commit (Pillow, max 2000px) - `scripts/optimize_images.py` Bildoptimierung vor Commit (Pillow, max 2000px)
- `scripts/validate_modules.py` Prüft meta.yaml-Konsistenz, fehlende Dateien/Übersetzungen - `scripts/validate_modules.py` Prüft meta.yaml-Konsistenz, fehlende Dateien/Übersetzungen
## Infrastruktur
- Hetzner-Server: Gitea (Repos), n8n (CI-Automatisierung), Nextcloud (Dateiablage)
- Build-Umgebung: Python 3.11+, Sphinx, LaTeX-Distribution (texlive)
- CI: Gitea Actions oder n8n-Webhook → Build → PDF nach Nextcloud
## Umsetzungsphasen
### Methodik (nach Alistair Cockburn)
Jeder Use Case durchläuft sieben Schritte bis zur Umsetzungsreife:
| Schritt | Aktivität | Verantwortung |
|---------|--------------------------------------------------------|---------------|
| 1 | Titel / Teaser, Prosa-Story | C (Kunde/PO) |
| 2 | Stakeholder & Interessen / Anwender-Ziele identifizieren | T (Dev Team) |
| 3 | Standard-Ablauf entwerfen (primäres Erfolgsszenario) | M (Member) |
| 4 | Ausnahmen und Erweiterungen sammeln | T (Dev Team) |
| 5 | Ausnahmen detaillieren, Schneiden in User Stories | M (Member) |
| 6 | Business Value schätzen / Aufwand schätzen | T (Dev Team) |
| 7 | Review: Prüfe alle Interessen, Definition of Ready | C (Kunde/PO) |
**Legende Verantwortung:**
- **C** = Kunde / Product Owner (N.Sch, M.Ro)
- **T** = Dev Team (F.Ve, A.Sch, M.St, D.Fe)
- **M** = Einzelnes Dev Team Member
### Personen
| Kürzel | Rolle | Schwerpunkt |
|--------|------------------------|-----------------------|
| N.Sch | Stakeholder / PO | Fachliche Anforderungen |
| M.Ro | Stakeholder / PO | Fachliche Anforderungen |
| F.Ve | Dev Team | IT und KI |
| A.Sch | Dev Team | Redaktion / Inhalte |
| M.St | Dev Team | IT und KI |
| D.Fe | Dev Team | Redaktion / Inhalte |
### Produktprogramm-Umfang
Basis für die Aufwandsschätzung: **1388 eindeutige Produktnummern** in 70 Produktgruppen über 5 Produktfamilien (CPC, GERÜST, ILS 2100, OMNIFLO, Trolleyprogramm). 129 Nummern erscheinen in mehreren Kapiteln.
### Projektplan
| Phase | Beschreibung | Aufwand | Beteiligte |
|-------|--------------|---------|------------|
| 0 | **Use-Case-Definition & Anforderungen** | | |
| 0.1 | Story & Teaser: Zieldokumentation beschreiben, Prosa-Story je Produktfamilie | 2 Tage | D.Fe, A.Sch, M.St |
| 0.2 | Stakeholder-Analyse: Alle Akteure und deren Interessen/Ziele auflisten | 1 Tag | F.Ve, A.Sch, M.St, D.Fe |
| 0.3 | Standard-Ablauf entwerfen: Build-Pipeline, Redaktionsworkflow, Freigabeprozess | 2 Tage | M.St |
| 0.4 | Ausnahmen sammeln: Sonderfälle (Mehrfachverwendung der 129 Nummern, Varianten, fehlende Übersetzungen) | 1 Tag | F.Ve, A.Sch, M.St, D.Fe |
| 0.5 | Ausnahmen detaillieren, in User Stories schneiden | 2 Tage | F.Ve, M.St |
| 0.6 | Aufwandsschätzung & Business Value für alle Stories | 1 Tag | F.Ve, A.Sch, M.St, D.Fe |
| 0.7 | Review & DOR: Prüfung aller Interessen, Freigabe für Umsetzung | 1 Tag | N.Sch, M.Ro |
| | | | |
| 1 | **PoC: Proof of Concept** | | |
| 1.1 | Ein Modul (z.B. OMNIFLO Weiche), ein Kapitel, zwei Sprachen (DE/EN), PDF-Ausgabe | 3 Tage | M.St, F.Ve |
| 1.2 | LaTeX-Template mit Firmen-CI erstellen und validieren | 2 Tage | M.St, F.Ve, A.Sch |
| 1.3 | PoC-Review mit Stakeholdern | 0,5 Tage | N.Sch, M.Ro, A.Sch |
| | | | |
| 2 | **Base-Repo & Infrastruktur** | | |
| 2.1 | Repo-Struktur anlegen (transport-docs-base), Sphinx-Projekt initialisieren | 1 Tag | M.St |
| 2.2 | Sphinx-Extensions implementieren (anlagen_builder, localized_figure, glossary_loader) | 4 Tage | M.St |
| 2.3 | build.py: JSON-Parsing, Abhängigkeitsauflösung, Override-Mechanismus | 3 Tage | F.Ve, M.St |
| 2.4 | CI/CD: Gitea Actions für automatischen Build | 2 Tage | M.St |
| | | | |
| 3 | **Produktprogramm-Strukturierung** | | |
| 3.1 | Modulstruktur & meta.yaml-Schema je Produktfamilie definieren (5 Familien) | 3 Tage | A.Sch, M.St, D.Fe |
| 3.2 | Nummernkreis-Mapping: 1388 Produkte auf Module abbilden, Gruppierung festlegen | 3 Tage | A.Sch, F.Ve, M.St |
| 3.3 | Vorlage-Templates pro Kapiteltyp erstellen (Beschreibung, Wartung, Ersatzteile) | 2 Tage | A.Sch, M.St |
| | | | |
| 4 | **Content-Migration (Hauptaufwand)** | | |
| 4.1 | CPC-Familie: 126 Produkte in 11 Gruppen rST-Bausteine erstellen | 2 Wochen | A.Sch, F.Ve |
| 4.2 | GERÜST-Familie: 263 Produkte in 14 Gruppen rST-Bausteine erstellen | 1 Wochen | A.Sch, F.Ve |
| 4.3 | ILS 2100-Familie: 396 Produkte in 27 Gruppen rST-Bausteine erstellen | 3 Wochen | A.Sch, F.Ve |
| 4.4 | OMNIFLO-Familie: 453 Produkte in 14 Gruppen rST-Bausteine erstellen | 4 Wochen | A.Sch, F.Ve |
| 4.5 | Trolleyprogramm: 191 Produkte in 4 Gruppen rST-Bausteine erstellen | 2 Wochen | A.Sch, F.Ve |
| 4.6 | KI-gestützte Textgenerierung: Vorlagen automatisiert befüllen (Beschreibungen, techn. Daten, Logische Schalter) | parallel | F.Ve, M.St |
| 4.7 | Bildmaterial: Fotos/Zeichnungen zuordnen, optimieren, Pfadstruktur aufbauen | parallel | A.Sch, F.Ve |
| | | | |
| 5 | **Glossar & Mehrsprachigkeit** | | |
| 5.1 | Glossar DE aufbauen (Fachbegriffe aus allen 5 Produktfamilien) | 1 Woche | A.Sch, D.Fe, M.St |
| 5.2 | Glossar EN übersetzen | 1 Woche | A.Sch, D.Fe, M.St, V.Fe |
| 5.3 | Stichproben-Übersetzung: 10% der Module auf EN migrieren | 2 Wochen | A.Sch, D.Fe, F.Ve |
| | | | |
| 6 | **ERP-Integration & Automatisierung** | | |
| 6.1 | JSON-Export-Format mit ERP abstimmen (anlage.json-Schema) | 2 Tage | M.St, A.Jakob |
| 6.2 | Validierungsskripte: meta.yaml-Konsistenz, fehlende Dateien/Übersetzungen | 2 Tage | M.St |
| 6.3 | Hilfsskripte: dxf2svg, optimize_images | 2 Tage | F.Ve |
| | | | |
| 7 | **Pilotprojekt** | | |
| 7.1 | Erste vollständige Anlagendokumentation (reales Kundenprojekt) generieren | 1 Woche | A.Sch, M.St, F.Ve |
| 7.2 | Review mit Stakeholdern, Korrekturrunde | 3 Tage | N.Sch, M.Ro, A.Sch |
| 7.3 | Lessons Learned, Backlog-Pflege | 1 Tag | F.Ve, A.Sch, M.St, D.Fe |
### Aufwandszusammenfassung
| Phase | Dauer (geschätzt) |
|-------|-------------------|
| 0 Use-Case-Definition | ~2 Wochen |
| 1 PoC | ~1 Woche |
| 2 Base-Repo & Infrastruktur | ~2 Wochen |
| 3 Produktprogramm-Strukturierung | ~2 Wochen |
| 4 Content-Migration | ~10-12 Wochen (2 Redakteure parallel + KI-Unterstützung) |
| 5 Glossar & Mehrsprachigkeit | ~4 Wochen |
| 6 ERP-Integration | ~1 Woche |
| 7 Pilotprojekt | ~2 Wochen |
| **Gesamt** | **~24 Wochen (6 Monate)** |
**Anmerkungen:**
- Phase 4 ist der Hauptaufwand. Bei 1388 Produkten und 2 Redakteurinnen (A.Sch, F.Ve) parallel ergibt sich ca. 10 Wochen netto für die Content-Erstellung.
- KI-gestützte Textgenerierung (F.Ve, M.St) kann den Aufwand in Phase 4 um geschätzt 3050% reduzieren.
- Phasen 4, 5 und 6 können teilweise parallelisiert werden, wodurch die Gesamtlaufzeit auf ca. 5 Monate verkürzt werden kann.
- Die Schätzung basiert auf ~20 Produkte/Woche pro Redakteur bei mittlerer Komplexität (Beschreibung, techn. Daten, Wartungshinweise als rST-Bausteine).
## Coding-Konventionen
- Python: Type Hints, Docstrings, pyright-kompatibel
- rST: Eine Datei pro Section, Dateiname = Section-Slug
- YAML: 2 Spaces Einrückung, keine Tabs
- Commits: Conventional Commits (`feat:`, `fix:`, `docs:`)
- Bilder: Dateinamen lowercase, Underscores, beschreibend (`weiche_typ_a_foto_01.jpg`)
+106
View File
@@ -148,3 +148,109 @@ Neues Konzept: Pro Sprache ein kompletter Dokumentenbaum auf Top-Level:
- Zukuenftig: `chapters_en/`, `chapters_cz/` etc. als eigenstaendige Kopien - Zukuenftig: `chapters_en/`, `chapters_cz/` etc. als eigenstaendige Kopien
Fallback-Mechanismus: Fehlt ein Dokument in einer Fremdsprache, wird die deutsche Version verwendet. Fallback-Mechanismus: Fehlt ein Dokument in einer Fremdsprache, wird die deutsche Version verwendet.
## Heutige Checkliste für die nötigen Dokumente aus der Automation
### 1. Layout Elektrik
- Elektrolayout
- Layout Kabelpritsche und Erdung
### 2. Schaltplan & Pneumatikplan
- Schaltplan (WSCAD Elektrisch)
- Pneumatikplan (WSCAD Elektrisch)
- PDF-Ausgabe
- Projektsicherung im WSCAD-Format
### 3. Layout Pneumatik
- Verschlauchungsplan
### 4. Ersatzteilliste Elektrik
- Ersatzteilliste für elektrische Komponenten als PDF-Dokument
### 5. Dokumentationen Elektrolieferanten
- Ablage der Datenblätter
- Dokumentationen der Elektrolieferanten
### 6. Netzwerkstrukturplanung
- Netzwerkstrukturplanung (ProNetPlan / WSCAD)
### 7. Betriebsanleitung Steuerung
- Bedienhandbuch
- Steuerungsanleitung
### 8. Software-Daten SPS
- SPS-Projektdaten mit Sicherung
- PDF-Ausdruck des SPS-Programms
### 9. Sicherheitsausdruck SPS
- Sicherheitsausdruck der SPS-Software
### 10. Prüfprotokoll externe Gewerke
- Prüfprotokolle für externe Gewerke im eigenen Lieferumfang (z. B. Brandschutztüre)
### 11. Sistema Sicherheitsbetrachtung
- Sicherheitsbetrachtung gemäß Sistema als PDF-Dokument
### 12. Prüfprotokolle Schaltschränke
- Prüfprotokolle der Schaltschrankprüfung als PDF-Dokument
### 13. Lizenzen
- Softwarelizenzen (z. B. Sinamics, TIA Portal, OPC UA)
### 14. Abschaltmatrix
- Emergency-Stop-Matrix (Abschaltmatrix) als PDF-Dokument
### 15. Layout Sicherheitsbereiche
- Layout der Sicherheitsbereiche zur Abschaltmatrix
### 16. Validierungsprotokolle
Unterschriebene Validierungsprotokolle, bestehend aus:
#### 16.1 Prüfprotokoll ortsfester Arbeitsmittel
- Elektrische Prüfung ortsfester Arbeitsmittel
#### 16.2 Erprobung Wiederanlauf
- Schutz gegen automatischen Wiederanlauf
#### 16.3 Erprobung Drehrichtung Motoren
- Test der Drehrichtung der Asynchronmotoren
#### 16.4 Berechnung Fehlerschleifenimpedanz / Validierung Erdung
- Berechnung der Fehlerschleifenimpedanz
- Erdungsvalidierung
#### 16.5 Erprobung Anzeige- und Bediengeräte
- Test der Anzeige- und Bediengeräte
# Fragen und TODOs
- [ ] Wie ist der Standardablauf der Dokumentation bisher?
- [ ] Wer ist wann daran bei einem einfach Projekt daran beteiligt?
- [ ] Was wäre bei einer Automatisierung der Dokumentations- Erstellung für die wünschenswert?
- [ ] Welche Ausnahmen sind üblich, fehlt oft oder ist nicht immer dabei?
- [ ] für wen ist die Dokumentation gedacht, bzw. was erwarten diese Leute zu finden?
- [ ] Welche Detailierungstiefe bei den Produkten ist nötig oder gewollt?
- [ ] soll die aktuelle Kapitelstruktur mit nur 4 Kapiteln beibehalten werden oder könnten wir dies auf ca. 7 - 10 Kapitel erweitern (siehe Vorschlag)?
- [ ] in welchem Kapitel sollen die jeweiligen Dokumente aus der Automation landen?
- [ ] Wie läuft die Ersatzteilfindung nach der Dokumentation statt?
- [ ] Wie die Behandlung einer Anlage bei Fehler oder Störungen?
- [ ] Welche Daten des Produktprogrammes sollen in die Dokumentation eingang finden?
- [ ] Welche Abteilung liefert normalerweise was in der Dokumentation (Liste Automation ist vorhanden, Was passiert bei Sonderkonstruktionen)?
Binary file not shown.
-197
View File
@@ -1,197 +0,0 @@
"""
create_examples.py - Liest Stuecklisten-Daten aus Excel-Dateien im Examples-Ordner.
Verwendung:
python create_examples.py [--example-dir <pfad>]
"""
from __future__ import annotations
import argparse
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
@dataclass
class StkItem:
position: str
teilenummer: str
menge: Any
def _cell_str(value: Any) -> str:
return str(value).strip() if value is not None else ""
def _find_col_index(header_row: tuple[Any, ...], candidates: list[str]) -> int | None:
normalized = [_cell_str(v).lower() for v in header_row]
for candidate in candidates:
candidate_norm = candidate.lower()
if candidate_norm in normalized:
return normalized.index(candidate_norm)
return None
def _extract_stk_liste_ist(xlsx_path: Path) -> list[StkItem]:
import openpyxl
wb = openpyxl.load_workbook(str(xlsx_path), read_only=True, data_only=True)
if "Stk.-Liste IST" not in wb.sheetnames:
wb.close()
raise ValueError("Reiter 'Stk.-Liste IST' nicht gefunden")
ws = wb["Stk.-Liste IST"]
header_idx: tuple[int | None, int | None, int | None] | None = None
items: list[StkItem] = []
for row in ws.iter_rows(values_only=True):
if header_idx is None:
pos_idx = _find_col_index(list(row), ["position", "pos", "pos."])
part_idx = _find_col_index(
list(row),
["teilenummer", "teilnummer", "article", "artikelnummer"],
)
qty_idx = _find_col_index(list(row), ["menge", "anzahl", "qty", "quantity"])
if pos_idx is not None and part_idx is not None and qty_idx is not None:
header_idx = (pos_idx, part_idx, qty_idx)
continue
pos_idx, part_idx, qty_idx = header_idx
assert pos_idx is not None and part_idx is not None and qty_idx is not None
position = _cell_str(row[pos_idx]) if pos_idx < len(row) else ""
teilenummer = _cell_str(row[part_idx]) if part_idx < len(row) else ""
menge = row[qty_idx] if qty_idx < len(row) else None
# Leere Zeilen ueberspringen
if not position and not teilenummer and menge in ("", None):
continue
if not position or not teilenummer:
continue
items.append(StkItem(position=position, teilenummer=teilenummer, menge=menge))
wb.close()
return items
def _process_single_folder(folder: Path, example_dir: Path, result: dict, log) -> None:
xlsx_files = sorted(folder.glob("*.xlsx"))
if not xlsx_files:
return
xlsx_path = xlsx_files[0]
rel_folder = str(folder.relative_to(example_dir))
try:
items = _extract_stk_liste_ist(xlsx_path)
except ValueError as exc:
log.warning("%s/%s uebersprungen: %s", rel_folder, xlsx_path.name, exc)
return
result[rel_folder] = [
{
"position": item.position,
"teilenummer": item.teilenummer,
"menge": item.menge,
}
for item in items
]
def process_all(example_dir: Path) -> dict[str, list[dict[str, Any]]]:
"""
Durchsucht rekursiv alle Unterverzeichnisse in example_dir.
Pro Unterverzeichnis wird die erste gefundene .xlsx-Datei gelesen und aus
dem Reiter "Stk.-Liste IST" werden Position, Teilenummer, Menge extrahiert.
"""
if not example_dir.exists():
log.error("Beispiel-Verzeichnis existiert nicht: %s", example_dir)
sys.exit(1)
subdirs = sorted([p for p in example_dir.rglob("*") if p.is_dir()])
if not subdirs:
log.error("Keine Unterverzeichnisse in %s gefunden", example_dir)
sys.exit(1)
log.info("Gefunden: %d Unterverzeichnisse in %s", len(subdirs), example_dir)
result: dict[str, list[dict[str, Any]]] = {}
for folder in subdirs:
log.info("Verarbeite %s", folder)
_process_single_folder(folder, example_dir, result, log)
log.info("Fertig. Gelesene Verzeichnisse mit Daten: %d", len(result))
return result
def main() -> None:
parser = argparse.ArgumentParser(
description="Liest Position/Teilenummer/Menge aus Example-Exceldateien"
)
parser.add_argument(
"--example-dir",
type=Path,
default=None,
help=(
"Unterordnername in PV_EXAMPLES oder direkter Verzeichnispfad "
"(default: alle Unterordner in PV_EXAMPLES)"
),
)
parser.add_argument("--verbose", "-v", action="store_true",
help="Ausfuehrliche Ausgabe")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)-7s %(message)s",
)
import os
pv_examples = os.environ.get("PV_EXAMPLES")
if not pv_examples:
log.error("PV_EXAMPLES ist nicht gesetzt")
sys.exit(1)
examples_root = Path(pv_examples).resolve()
if not examples_root.exists() or not examples_root.is_dir():
log.error("PV_EXAMPLES ist kein gueltiges Verzeichnis: %s", examples_root)
sys.exit(1)
if args.example_dir is None:
log.info("Beispiel-Verzeichnis (alle Unterordner): %s", examples_root)
data = process_all(examples_root)
log.info("Objekt erzeugt mit %d Schluesseln", len(data))
return
# --example-dir: entweder direkter Verzeichnispfad oder Unterordnername in PV_EXAMPLES
candidate = args.example_dir
if candidate.is_absolute():
target_dir = candidate.resolve()
else:
as_path = candidate.resolve()
if as_path.exists() and as_path.is_dir():
target_dir = as_path
else:
target_dir = (examples_root / candidate).resolve()
if not target_dir.exists() or not target_dir.is_dir():
log.error(
"--example-dir muss ein gueltiger Verzeichnispfad oder Unterordner in PV_EXAMPLES sein: %s",
args.example_dir,
)
sys.exit(1)
log.info("Beispiel-Verzeichnis (einzeln): %s", target_dir)
result: dict[str, list[dict[str, Any]]] = {}
_process_single_folder(target_dir, target_dir.parent, result, log)
log.info("Objekt erzeugt mit %d Schluesseln", len(result))
if __name__ == "__main__":
main()
+201
View File
@@ -0,0 +1,201 @@
"""Extract 9-digit Sivas IDs from JSON tree files generated by jsonFromXlsx.py.
Collects Sivas numbers that sit one hierarchy level above the leaves
and writes them as 'nummer: bezeichnung' into a text file.
Usage:
python extract_ids.py --filename KA135776
python extract_ids.py --filename KA135776 --tab soll
python extract_ids.py --all
python extract_ids.py --all --sum2json
python extract_ids.py --all --sum2txt
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
RE_SIVAS = re.compile(r"^(\d{9})")
def is_leaf_value(v) -> bool:
"""True if v is a leaf entry (string, anzahl-dict, or laenge-dict)."""
if isinstance(v, str):
return True
if isinstance(v, dict) and ("anzahl" in v or "laenge" in v):
return True
return False
def extract_ids_from_tree(tree: dict) -> dict[str, str]:
"""Collect 9-digit Sivas numbers one level above leaves.
Returns dict of nummer -> bezeichnung.
"""
ids: dict[str, str] = {}
def walk(node: dict):
for key, value in node.items():
if not isinstance(value, dict) or "anzahl" in value or "laenge" in value:
continue # leaf, skip
# Check if all children are leaves
all_leaves = all(is_leaf_value(v) for v in value.values())
m = RE_SIVAS.match(key)
if all_leaves and value and m:
nummer = m.group(1)
# Bezeichnung from key: part after "nummer-", underscores back to spaces
if len(key) > 9:
bez = key[10:].replace("_", " ")
else:
bez = nummer
ids[nummer] = bez
else:
walk(value)
walk(tree)
return ids
def get_project_name(json_path: Path) -> str:
"""Derive project name from JSON filename by stripping _ist/_soll suffix."""
project_name = json_path.stem
for suffix in ("_ist", "_soll"):
if project_name.endswith(suffix):
project_name = project_name[: -len(suffix)]
break
return project_name
def process_json(json_path: Path, tab: str, results_dir: Path) -> tuple[dict[str, str], str, Path]:
"""Extract IDs from one JSON file. Returns (ids dict, project_name, ids_path)."""
with open(json_path, encoding="utf-8") as f:
tree = json.load(f)
ids = extract_ids_from_tree(tree)
project_name = get_project_name(json_path)
ids_path = results_dir / f"{project_name}_ids_{tab}.txt"
with open(ids_path, "w", encoding="utf-8") as f:
for nummer, bez in sorted(ids.items()):
f.write(f"{nummer}: {bez}\n")
print(f" {json_path.name} -> {ids_path.name} ({len(ids)} IDs)")
return ids, project_name, ids_path
def write_sum2json(all_ids: dict[str, str], projekte: dict[str, list[str]], tab: str, results_dir: Path):
"""Write aggregated summary as JSON file."""
# projekte dict: convert lists to comma-separated strings
projekte_str = {k: ", ".join(v) for k, v in sorted(projekte.items())}
data = {
"ids": dict(sorted(all_ids.items())),
"projekte": projekte_str,
}
out_path = results_dir / f"summary_ids_{tab}.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"\n Summary JSON -> {out_path.name} ({len(all_ids)} IDs, {len(projekte)} mit Projektzuordnung)")
def write_sum2txt(all_ids: dict[str, str], projekte: dict[str, list[str]], tab: str, results_dir: Path):
"""Write aggregated summary as TXT file with [Sivasnummern] and [Anzahl] sections."""
out_path = results_dir / f"summary_ids_{tab}.txt"
with open(out_path, "w", encoding="utf-8") as f:
f.write("[Sivasnummern]\n")
for nummer, bez in sorted(all_ids.items()):
f.write(f"{nummer}: {bez}\n")
f.write(f"\n[Anzahl]\n")
for nummer, prj_list in sorted(projekte.items(), key=lambda x: len(x[1]), reverse=True):
f.write(f"{nummer}: {len(prj_list)}\n")
print(f"\n Summary TXT -> {out_path.name} ({len(all_ids)} IDs)")
def main():
parser = argparse.ArgumentParser(
description="Extrahiert 9-stellige Sivas-Nummern aus JSON-Baeumen im results/-Ordner."
)
parser.add_argument(
"--filename",
default=None,
help="Suchmuster fuer JSON-Dateien im results/-Ordner (z.B. 'KA135776')",
)
parser.add_argument(
"--tab",
choices=["soll", "ist"],
default="ist",
help="Welches Tab gelesen wird: 'ist' oder 'soll' (default: ist)",
)
parser.add_argument(
"--all",
action="store_true",
dest="process_all",
help="Alle *_<tab>.json Dateien im results/-Ordner verarbeiten",
)
parser.add_argument(
"--sum2json",
action="store_true",
help="Alle IDs in eine summary_ids_<tab>.json zusammenfassen (ids + projekte)",
)
parser.add_argument(
"--sum2txt",
action="store_true",
help="Alle IDs in eine summary_ids_<tab>.txt zusammenfassen ([Sivasnummern] + [Anzahl])",
)
args = parser.parse_args()
if not args.process_all and not args.filename:
parser.error("Entweder --filename oder --all muss angegeben werden.")
project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent))
results_dir = project_root / "results"
if not results_dir.exists():
print(f"FEHLER: results/-Ordner nicht gefunden: {results_dir}")
sys.exit(1)
pattern = f"*_{args.tab}.json"
json_files = sorted(results_dir.glob(pattern))
if args.filename:
json_files = [f for f in json_files if args.filename.lower() in f.name.lower()]
if not json_files:
print(f"FEHLER: Keine JSON-Dateien mit Muster '{pattern}' in {results_dir} gefunden.")
if args.filename:
print(f" (Filter: '{args.filename}')")
sys.exit(1)
print(f"Verarbeite {len(json_files)} JSON-Dateien aus {results_dir}\n")
total_ids = 0
# Aggregation dicts for --sum2json / --sum2txt
all_ids: dict[str, str] = {}
projekte: dict[str, list[str]] = {}
individual_txt_files: list[Path] = []
for json_path in json_files:
ids, project_name, ids_path = process_json(json_path, args.tab, results_dir)
total_ids += len(ids)
individual_txt_files.append(ids_path)
for nummer, bez in ids.items():
all_ids[nummer] = bez
projekte.setdefault(nummer, []).append(project_name)
print(f"\nFertig: {total_ids} IDs aus {len(json_files)} Dateien extrahiert.")
if args.sum2json:
write_sum2json(all_ids, projekte, args.tab, results_dir)
if args.sum2txt:
write_sum2txt(all_ids, projekte, args.tab, results_dir)
# Einzelne _ids_ txt-Dateien loeschen wenn Summary erzeugt wurde
if args.sum2json or args.sum2txt:
for txt_path in individual_txt_files:
txt_path.unlink(missing_ok=True)
print(f" {len(individual_txt_files)} einzelne _ids_ Dateien geloescht.")
if __name__ == "__main__":
main()
+70 -33
View File
@@ -81,19 +81,17 @@ def _extract_toc_numbers(doc) -> dict[str, str]:
"""Extrahiert Kapitelnummern aus dem Inhaltsverzeichnis des PDFs. """Extrahiert Kapitelnummern aus dem Inhaltsverzeichnis des PDFs.
Sucht TOC-Seiten (Seiten mit '....' Punkt-Fuellern) und ordnet Sucht TOC-Seiten (Seiten mit '....' Punkt-Fuellern) und ordnet
Ueberschriftentexte ihren Kapitelnummern zu. Ueberschriftentexte ihren Kapitelnummern zu. Nur Eintraege mit
expliziter Nummer werden erfasst (bis Level 2).
Returns: Returns:
Dict title_text -> kapitelnummer, z.B. {"Typen von Weichen mit Bögen": "2.3.1"} Dict title_text -> kapitelnummer, z.B. {"Weichen Omniflo": "2.3"}
""" """
import fitz
toc_entries: dict[str, str] = {} toc_entries: dict[str, str] = {}
for page_num in range(len(doc)): for page_num in range(len(doc)):
page = doc[page_num] page = doc[page_num]
text = page.get_text() text = page.get_text()
# Nur TOC-Seiten verarbeiten (enthalten "....")
if "...." not in text: if "...." not in text:
continue continue
@@ -106,8 +104,7 @@ def _extract_toc_numbers(doc) -> dict[str, str]:
if not line: if not line:
continue continue
# TOC-Zeile: "Text ........... Seitennr" # Kapitelnummer auf separater Zeile
# Kapitelnummer steht oft auf separater Zeile davor
num_match = re.match(r"^(\d+(?:\.\d+)*)\s*$", line) num_match = re.match(r"^(\d+(?:\.\d+)*)\s*$", line)
if num_match: if num_match:
current_number = num_match.group(1) current_number = num_match.group(1)
@@ -115,32 +112,59 @@ def _extract_toc_numbers(doc) -> dict[str, str]:
# Zeile mit Punkt-Fuellern = TOC-Eintrag # Zeile mit Punkt-Fuellern = TOC-Eintrag
dot_match = re.search(r"\.{4,}", line) dot_match = re.search(r"\.{4,}", line)
if dot_match: if not dot_match:
# Text vor den Punkten extrahieren continue
title = line[:dot_match.start()].strip()
# Seitennummer nach den Punkten
if title and current_number:
toc_entries[title] = current_number
current_number = ""
elif title:
# Nummer koennte am Anfang des Titels stehen
nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title)
if nm:
toc_entries[nm.group(2).strip()] = nm.group(1)
# Auch inline Nummern: "1.4 Abkürzungen ..... 8" title = line[:dot_match.start()].strip()
for line in lines: if not title:
line = line.strip() continue
dot_match = re.search(r"\.{4,}", line)
if dot_match: # Inline-Nummer am Anfang? "1.4 Abkürzungen ....."
title_part = line[:dot_match.start()].strip() nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title)
nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title_part) if nm:
if nm: toc_entries[nm.group(2).strip()] = nm.group(1)
toc_entries[nm.group(2).strip()] = nm.group(1) current_number = ""
elif current_number:
toc_entries[title] = current_number
current_number = ""
return toc_entries return toc_entries
RE_DATE = re.compile(r"^\d{2}\.\d{2}\.\d{4}$")
RE_COPYRIGHT = re.compile(r"^Copyright\s+\d{4}", re.IGNORECASE)
RE_VERSION_LINE = re.compile(r"^Version\s*[:.]?\s*\d", re.IGNORECASE)
# Bekannte Rausch-Muster: Autorinnen, Metadaten, Copyright-Zeilen
_NOISE_KEYWORDS = {
"annette schopper", "erstellt:", "freigabe:", "nicht freigegeben",
"nur zur information", "schönenberger systeme gmbh",
"justus-von-liebig-str", "info@schoenenberger.de",
"www.schoenenberger.de",
}
def _is_noise(text: str) -> bool:
"""Erkennt Rausch-Zeilen die nicht ins RST gehoeren."""
lower = text.lower().strip()
if RE_COPYRIGHT.match(text):
return True
if RE_DATE.match(text.strip()):
return True
if RE_VERSION_LINE.match(text):
return True
for kw in _NOISE_KEYWORDS:
if kw in lower:
return True
# Seitenzahlen
if re.match(r"^Seite\s+\d+$", text, re.IGNORECASE):
return True
# Einzelne kurze Jahreszahlen mit Autorin: "2020 Annette Schopper"
if re.match(r"^\d{4}\s+[A-Z][a-z]+\s+[A-Z][a-z]+$", text):
return True
return False
def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int: def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
"""Extrahiert Text aus PDF und schreibt ihn als .rst. """Extrahiert Text aus PDF und schreibt ihn als .rst.
@@ -188,6 +212,8 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
text = text.strip() text = text.strip()
if not text: if not text:
continue continue
if _is_noise(text):
continue
raw_lines.append({ raw_lines.append({
"text": text, "text": text,
@@ -246,7 +272,12 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
# --- Phase 3: Ueberschriften erkennen und RST generieren --- # --- Phase 3: Ueberschriften erkennen und RST generieren ---
# Heading: Nummerierung wie "1 Text", "2.3 Text", "2.3.1.4 Text" # Heading: Nummerierung wie "1 Text", "2.3 Text", "2.3.1.4 Text"
# Text muss mit einem Buchstaben beginnen (verhindert "610 014 001" etc.) # Text muss mit einem Buchstaben beginnen (verhindert "610 014 001" etc.)
RE_HEADING = re.compile(r"^(\d+(?:\.\d+)*)\s+([A-Za-zÄÖÜäöüß].+)$") # Jeder Nummerierungsteil max 3 Stellen (verhindert Datumsangaben wie "10.12.2020")
# Titeltext muss mit einem Wort von mind. 3 Buchstaben beginnen
# (verhindert "2 B. def.", "19.8 mm (LW13)")
RE_HEADING = re.compile(
r"^(\d{1,3}(?:\.\d{1,3})*)\s+([A-Za-zÄÖÜäöüß]{3,}.*)$"
)
rst_lines: list[str] = [] rst_lines: list[str] = []
title = pdf_path.stem title = pdf_path.stem
rst_lines.append(RST_HEADING_CHARS[1] * len(title)) rst_lines.append(RST_HEADING_CHARS[1] * len(title))
@@ -284,10 +315,12 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
toc_num = toc_numbers.get(text) toc_num = toc_numbers.get(text)
if toc_num: if toc_num:
# Nummer aus TOC voranstellen # Nummer aus TOC voranstellen
text = f"{toc_num} {text}" candidate = f"{toc_num} {text}"
hm = RE_HEADING.match(text) hm = RE_HEADING.match(candidate)
is_heading = True if hm:
log.debug(" TOC-Nummer ergaenzt: %s", text) text = candidate
is_heading = True
log.debug(" TOC-Nummer ergaenzt: %s", text)
if is_heading: if is_heading:
num = hm.group(1) num = hm.group(1)
@@ -311,6 +344,10 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
# Mehrfache Leerzeilen reduzieren # Mehrfache Leerzeilen reduzieren
rst = "\n".join(rst_lines) rst = "\n".join(rst_lines)
rst = re.sub(r"\n{3,}", "\n\n", rst) rst = re.sub(r"\n{3,}", "\n\n", rst)
# 9-stellige Nummern mit Leerzeichen zusammenfuegen: "834 372 007" -> "834372007"
rst = re.sub(r"\b(\d{3})\s(\d{3})\s(\d{3})\b", r"\1\2\3", rst)
rst = re.sub(r"\b(\d{3})\s(\d{6})\b", r"\1\2", rst)
rst = re.sub(r"\b(\d{6})\s(\d{3})\b", r"\1\2", rst)
rst = rst.strip() + "\n" rst = rst.strip() + "\n"
rst_path.parent.mkdir(parents=True, exist_ok=True) rst_path.parent.mkdir(parents=True, exist_ok=True)
+303
View File
@@ -0,0 +1,303 @@
"""Builds a JSON hierarchy tree from the Position column of an Excel Stk.-Liste.
Usage:
python jsonFromXlsx.py --filename Fortna --tab soll
python jsonFromXlsx.py --filename Fortna --tab ist --output result.json
python jsonFromXlsx.py --all
"""
import argparse
import json
import os
import re
import sys
from collections import Counter
from pathlib import Path
import openpyxl
SHEET_NAMES = {
"soll": "Stk.-Liste SOLL",
"ist": "Stk.-Liste IST",
}
# Patterns to match sheet names (case-insensitive), ordered by priority
# Matches: Stk.-Liste SOLL, Stck.-Liste Soll, Stückliste SOLL, Stk. Liste IST, etc.
SHEET_PATTERNS = {
"soll": [
re.compile(r"st[ck]+[\.\-]?\s*liste[n]?\s+soll", re.IGNORECASE),
re.compile(r"st[üu]ckliste[n]?\s+soll", re.IGNORECASE),
],
"ist": [
re.compile(r"st[ck]+[\.\-]?\s*liste[n]?\s+ist", re.IGNORECASE),
re.compile(r"st[üu]ckliste[n]?\s+ist", re.IGNORECASE),
],
}
# Column indices (0-based) in the header row
COL_POSITION = 0
COL_TEILENUMMER = 1
COL_BEZEICHNUNG = 2
COL_MENGE = 3
HEADER_ROW = 3 # 1-based row number of the header (minimum, actual may be later)
RE_POSITION = re.compile(r"^\d+(/\d+)*$") # valid position pattern: "10" or "10/5/1"
# Sivas numbers where Menge represents length (meters) instead of piece count
LAENGENABHAENGIG = {
"821106001", "821106002",
"825084000", "825084007",
"825104000", "825104007",
"825144000", "825144007",
"821006026", "821006034", "821006035", "821006045",
"825004000",
}
def find_xlsx(examples_dir: str, pattern: str) -> Path:
"""Find an .xlsx file in examples/ whose name contains the given pattern."""
matches = []
for f in Path(examples_dir).glob("*.xlsx"):
if pattern.lower() in f.name.lower():
matches.append(f)
if not matches:
print(f"FEHLER: Keine .xlsx-Datei mit Muster '{pattern}' in {examples_dir} gefunden.")
sys.exit(1)
if len(matches) > 1:
print(f"Mehrere Dateien gefunden fuer Muster '{pattern}':")
for m in sorted(matches):
print(f" {m.name}")
print(f"Verwende: {matches[0].name}")
return sorted(matches)[0]
def make_key(teilenummer: str, bezeichnung: str, is_unique: bool) -> str:
"""Build a node key from Teilenummer, adding Bezeichnung if not unique."""
if is_unique:
return teilenummer
combined = f"{teilenummer}-{bezeichnung}"
return combined.replace(" ", "_")
def unique_key(parent: dict, key: str) -> str:
"""Return key with _1, _2, ... suffix if it already exists in parent."""
if key not in parent:
return key
suffix = 1
while f"{key}_{suffix}" in parent:
suffix += 1
return f"{key}_{suffix}"
def build_tree(ws, sheet_type: str) -> dict:
"""Read the worksheet and build a nested dict from Position paths.
Keys are Teilenummer-based (with Bezeichnung if non-unique).
Position paths are used only for hierarchy, not as keys.
"""
# First pass: collect all Teilenummern to determine uniqueness
teil_counter: Counter = Counter()
rows_data = []
for row in ws.iter_rows(min_row=HEADER_ROW + 1, max_row=ws.max_row, values_only=True):
pos = row[COL_POSITION]
teil = row[COL_TEILENUMMER]
bez = row[COL_BEZEICHNUNG]
menge = row[COL_MENGE] if len(row) > COL_MENGE else None
if pos is None or teil is None:
continue
pos_str = str(pos).strip()
if not RE_POSITION.match(pos_str):
continue # skip header rows and non-position entries
teil_str = str(teil).strip()
bez_str = str(bez).strip() if bez else ""
try:
menge_val = float(menge) if menge is not None else 0.0
except (ValueError, TypeError):
menge_val = 0.0
teil_counter[teil_str] += 1
rows_data.append((pos_str, teil_str, bez_str, menge_val))
unique_teile = {t for t, count in teil_counter.items() if count == 1}
# Second pass: build tree with Teilenummer-based keys
tree: dict = {}
pos_to_node: dict[str, dict] = {} # maps position path -> dict node
# Track metadata per node for leaf post-processing
node_meta: dict[int, tuple[str, str, float]] = {} # id(child) -> (teil_str, bez_str, menge)
for pos_str, teil_str, bez_str, menge_val in rows_data:
key = make_key(teil_str, bez_str, teil_str in unique_teile)
segments = pos_str.split("/")
# Find parent dict
if len(segments) == 1:
parent = tree
else:
parent_pos = "/".join(segments[:-1])
if parent_pos not in pos_to_node:
# Parent row missing in data, create placeholder
pos_to_node[parent_pos] = {}
parent = pos_to_node[parent_pos]
# Insert with unique key
final_key = unique_key(parent, key)
child: dict = {}
parent[final_key] = child
pos_to_node[pos_str] = child
node_meta[id(child)] = (teil_str, bez_str, menge_val)
is_sivas = re.compile(r"^\d{9}$").match
# Post-process: leaves (empty dicts) -> key=Teilenummer, value=Bezeichnung
def simplify_leaves(node: dict) -> dict:
result: dict = {}
for key, value in node.items():
if isinstance(value, dict) and len(value) == 0:
# Leaf node: use only Teilenummer as key, Bezeichnung as value
teil, bez, menge = node_meta.get(id(value), ("", "", 0.0))
if teil in LAENGENABHAENGIG:
# Length-dependent part: sum up Menge as laenge
if teil in result and isinstance(result[teil], dict) and "laenge" in result[teil]:
result[teil]["laenge"] += menge
elif teil in result and isinstance(result[teil], str):
result[teil] = {"laenge": menge, "beschreibung": result[teil]}
else:
leaf_key = unique_key(result, teil)
result[leaf_key] = {"laenge": menge, "beschreibung": bez}
elif teil in result and is_sivas(teil):
# Aggregate duplicate 9-digit Sivas numbers
existing = result[teil]
if isinstance(existing, str):
result[teil] = {"anzahl": 2, "beschreibung": existing}
else:
existing["anzahl"] += 1
else:
leaf_key = unique_key(result, teil)
result[leaf_key] = bez
elif isinstance(value, dict):
# Branch node: enrich bare 9-digit keys with Bezeichnung
branch_key = key
if is_sivas(key):
teil, bez, _ = node_meta.get(id(value), ("", "", 0.0))
if bez:
branch_key = f"{teil}-{bez}".replace(" ", "_")
branch_key = unique_key(result, branch_key)
else:
branch_key = unique_key(result, key)
result[branch_key] = simplify_leaves(value)
else:
result[key] = value
return result
return simplify_leaves(tree)
def process_file(xlsx_path: Path, sheet_name: str, tab: str, results_dir: Path,
out_path: Path | None = None) -> bool:
"""Process a single xlsx file. Returns True on success."""
print(f"Lade: {xlsx_path.name}")
print(f"Sheet: {sheet_name}")
wb = openpyxl.load_workbook(str(xlsx_path), data_only=True)
# Find matching sheet: try exact (case-insensitive), then pattern matching
actual_name = None
for name in wb.sheetnames:
if name.lower() == sheet_name.lower():
actual_name = name
break
if actual_name is None:
for pattern in SHEET_PATTERNS.get(tab, []):
for name in wb.sheetnames:
if pattern.search(name):
actual_name = name
break
if actual_name:
break
if actual_name is None:
print(f" SKIP: Kein passendes Sheet fuer '{tab}' gefunden. Vorhanden: {wb.sheetnames}")
return False
ws = wb[actual_name]
tree = build_tree(ws, tab)
if out_path is None:
stem = xlsx_path.stem.replace(" ", "_")
out_path = results_dir / f"{stem}_{tab}.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(tree, f, indent=2, ensure_ascii=False)
print(f" Ausgabe: {out_path}")
return True
def main():
parser = argparse.ArgumentParser(
description="Erzeugt eine JSON-Baumstruktur aus der Positionsspalte einer Excel-Stueckliste."
)
parser.add_argument(
"--filename",
default=None,
help="Suchmuster fuer die .xlsx-Datei im examples/-Ordner (z.B. 'Fortna')",
)
parser.add_argument(
"--tab",
choices=["soll", "ist"],
default="soll",
help="Welches Tabellenblatt gelesen wird: 'soll' oder 'ist' (default: soll)",
)
parser.add_argument(
"--output",
default=None,
help="Ausgabedatei (default: <filename>_<tab>.json im results/-Ordner)",
)
parser.add_argument(
"--all",
action="store_true",
dest="process_all",
help="Alle .xlsx-Dateien im examples/-Ordner in _ist und _soll JSON konvertieren",
)
args = parser.parse_args()
if not args.process_all and not args.filename:
parser.error("Entweder --filename oder --all muss angegeben werden.")
# Resolve paths relative to project root
project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent))
examples_dir = project_root / "examples"
results_dir = project_root / "results"
results_dir.mkdir(exist_ok=True)
tab = args.tab
if args.process_all:
xlsx_files = sorted(f for f in Path(examples_dir).glob("*.xlsx")
if not f.name.startswith("~$"))
print(f"Verarbeite {len(xlsx_files)} Dateien aus {examples_dir}\n")
ok, skip = 0, 0
tabs = [tab] if tab != "soll" or "--tab" in sys.argv else ["soll", "ist"]
for xlsx_path in xlsx_files:
for t in tabs:
sheet_name = SHEET_NAMES[t]
if process_file(xlsx_path, sheet_name, t, results_dir):
ok += 1
else:
skip += 1
print()
print(f"Fertig: {ok} JSON erzeugt, {skip} uebersprungen.")
else:
xlsx_path = find_xlsx(str(examples_dir), args.filename)
sheet_name = SHEET_NAMES[tab]
out_path = None
if args.output:
out_path = Path(args.output)
if not out_path.is_absolute():
out_path = results_dir / out_path
if not process_file(xlsx_path, sheet_name, tab, results_dir, out_path):
sys.exit(1)
if __name__ == "__main__":
main()
+460
View File
@@ -0,0 +1,460 @@
"""
create_examples.py - Liest Stuecklisten-Daten aus Excel-Dateien im Examples-Ordner.
Verwendung:
python create_examples.py [--example-dir <pfad>]
"""
from __future__ import annotations
import argparse
import csv
import json
import logging
import re
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
@dataclass
class StkItem:
position: str
teilenummer: str
bezeichnung: str
menge: Any
def _cell_str(value: Any) -> str:
return str(value).strip() if value is not None else ""
def _find_col_index(header_row: tuple[Any, ...], candidates: list[str]) -> int | None:
normalized = [_cell_str(v).lower() for v in header_row]
for candidate in candidates:
candidate_norm = candidate.lower()
if candidate_norm in normalized:
return normalized.index(candidate_norm)
return None
def _extract_stk_liste_ist(xlsx_path: Path) -> list[StkItem]:
import openpyxl
wb = openpyxl.load_workbook(str(xlsx_path), read_only=True, data_only=True)
if "Stk.-Liste IST" not in wb.sheetnames:
wb.close()
raise ValueError("Reiter 'Stk.-Liste IST' nicht gefunden")
ws = wb["Stk.-Liste IST"]
header_idx: tuple[int | None, int | None, int | None, int | None] | None = None
items: list[StkItem] = []
for row in ws.iter_rows(values_only=True):
if header_idx is None:
pos_idx = _find_col_index(list(row), ["position", "pos", "pos."])
part_idx = _find_col_index(
list(row),
["teilenummer", "teilnummer", "article", "artikelnummer"],
)
desc_idx = _find_col_index(
list(row),
["bezeichnung", "beschreibung", "description", "benennung"],
)
qty_idx = _find_col_index(list(row), ["menge", "anzahl", "qty", "quantity"])
if (
pos_idx is not None
and part_idx is not None
and desc_idx is not None
and qty_idx is not None
):
header_idx = (pos_idx, part_idx, desc_idx, qty_idx)
continue
pos_idx, part_idx, desc_idx, qty_idx = header_idx
assert (
pos_idx is not None
and part_idx is not None
and desc_idx is not None
and qty_idx is not None
)
position = _cell_str(row[pos_idx]) if pos_idx < len(row) else ""
teilenummer = _cell_str(row[part_idx]) if part_idx < len(row) else ""
bezeichnung = _cell_str(row[desc_idx]) if desc_idx < len(row) else ""
menge = row[qty_idx] if qty_idx < len(row) else None
# Leere Zeilen ueberspringen
if not position and not teilenummer and not bezeichnung and menge in ("", None):
continue
if not position or not teilenummer:
continue
items.append(
StkItem(
position=position,
teilenummer=teilenummer,
bezeichnung=bezeichnung,
menge=menge,
)
)
wb.close()
return items
def _to_number(value: Any) -> float:
if value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", ".")
if not text:
return 0.0
try:
return float(text)
except ValueError:
return 0.0
def _common_path_prefix(paths: list[str]) -> str:
parts_list = [p.split("/") for p in paths if p]
if not parts_list:
return ""
common = parts_list[0]
for parts in parts_list[1:]:
i = 0
max_i = min(len(common), len(parts))
while i < max_i and common[i] == parts[i]:
i += 1
common = common[:i]
if not common:
break
return "/".join(common)
def _load_product_numbers(cfg_path: Path) -> dict[str, str]:
"""
Laedt 9-stellige Produktnummern aus cfg/product_numbers.cfg.
Erwartetes Format:
[Sektion]
123456789
987654321
"""
allowed_sections = {"omniflo", "ils", "cpc", "trolley", "geruest"}
products: dict[str, str] = {}
current_section = ""
if not cfg_path.exists():
return products
for raw_line in cfg_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1].strip().lower()
continue
if current_section not in allowed_sections:
continue
if re.match(r"^\d{9}$", line):
products[line] = current_section
return products
def write_results(
xlsx_path: Path,
filtered_items: list[dict[str, Any]],
log: logging.Logger,
write_json: bool = False,
write_csv: bool = False,
write_xlsx: bool = False,
) -> None:
if write_json:
json_path = xlsx_path.with_suffix(".json")
with json_path.open("w", encoding="utf-8") as fh:
json.dump(filtered_items, fh, indent=2, ensure_ascii=False, default=str)
log.info("JSON geschrieben: %s", json_path)
if write_csv:
csv_path = xlsx_path.with_suffix(".csv")
with csv_path.open("w", encoding="utf-8-sig", newline="") as fh:
writer = csv.writer(fh, delimiter=";")
writer.writerow(["position", "teilenummer", "bezeichnung", "menge"])
for row in filtered_items:
writer.writerow(
[
row.get("position", ""),
row.get("teilenummer", ""),
row.get("bezeichnung", ""),
row.get("menge", ""),
]
)
log.info("CSV geschrieben: %s", csv_path)
if write_xlsx:
import openpyxl
xlsx_out = xlsx_path.with_name(f"{xlsx_path.stem}_result.xlsx")
wb = openpyxl.Workbook()
ws_3 = wb.active
ws_3.title = "3er"
ws_4 = wb.create_sheet("4er")
ws_result = wb.create_sheet("Result")
ws_product = wb.create_sheet("product")
headers = ["position", "teilenummer", "bezeichnung", "menge"]
ws_3.append(headers)
ws_4.append(headers)
aggregated: dict[str, dict[str, Any]] = defaultdict(
lambda: {
"teilenummer": "",
"bezeichnung": "",
"gesamtmenge": 0.0,
"positions": [],
}
)
for row in filtered_items:
slash_count = str(row.get("position", "")).count("/")
if slash_count == 3:
ws_3.append([row.get(h, "") for h in headers])
elif slash_count == 4:
ws_4.append([row.get(h, "") for h in headers])
tn = str(row.get("teilenummer", "")).strip()
if not tn:
continue
if not aggregated[tn]["teilenummer"]:
aggregated[tn]["teilenummer"] = tn
aggregated[tn]["bezeichnung"] = row.get("bezeichnung", "")
aggregated[tn]["gesamtmenge"] += _to_number(row.get("menge"))
aggregated[tn]["positions"].append(str(row.get("position", "")).strip())
ws_result.append(["teilenummer", "bezeichnung", "gesamtmenge", "mainpath", "endpath"])
project_root = Path(__file__).resolve().parent.parent
product_cfg = project_root / "cfg" / "product_numbers.cfg"
product_numbers = _load_product_numbers(product_cfg)
ws_product.append(["teilenummer", "bezeichnung", "gesamtmenge", "mainpath", "endpath"])
for tn in sorted(aggregated.keys()):
positions = [p for p in aggregated[tn]["positions"] if p]
mainpath = _common_path_prefix(positions)
variable_parts: list[str] = []
for pos in positions:
if mainpath:
suffix = pos[len(mainpath):].lstrip("/")
else:
suffix = pos
if suffix and suffix not in variable_parts:
variable_parts.append(suffix)
endpath = ",".join(variable_parts)
result_row = [
aggregated[tn]["teilenummer"],
aggregated[tn]["bezeichnung"],
aggregated[tn]["gesamtmenge"],
mainpath,
endpath,
]
ws_result.append(result_row)
if tn in product_numbers:
ws_product.append(result_row)
wb.save(str(xlsx_out))
wb.close()
log.info("XLSX geschrieben: %s", xlsx_out)
def _process_single_folder(
folder: Path,
example_dir: Path,
result: dict[str, list[dict[str, Any]]],
log: logging.Logger,
write_json: bool = False,
write_csv: bool = False,
write_xlsx: bool = False,
) -> None:
xlsx_files = sorted(folder.glob("*.xlsx"))
if not xlsx_files:
return
xlsx_path = xlsx_files[0]
rel_folder = str(folder.relative_to(example_dir))
try:
items = _extract_stk_liste_ist(xlsx_path)
except ValueError as exc:
log.warning("%s/%s uebersprungen: %s", rel_folder, xlsx_path.name, exc)
return
size_path = (3, 4)
filtered_items = [
{
"position": item.position,
"teilenummer": item.teilenummer,
"bezeichnung": item.bezeichnung,
"menge": item.menge,
}
for item in items
if item.position.count("/") in size_path
]
result[rel_folder] = filtered_items
write_results(
xlsx_path,
filtered_items,
log,
write_json=write_json,
write_csv=write_csv,
write_xlsx=write_xlsx,
)
def process_all(
example_dir: Path,
write_json: bool = False,
write_csv: bool = False,
write_xlsx: bool = False,
) -> dict[str, list[dict[str, Any]]]:
"""
Durchsucht rekursiv alle Unterverzeichnisse in example_dir.
Pro Unterverzeichnis wird die erste gefundene .xlsx-Datei gelesen und aus
dem Reiter "Stk.-Liste IST" werden Position, Teilenummer, Menge extrahiert.
"""
if not example_dir.exists():
log.error("Beispiel-Verzeichnis existiert nicht: %s", example_dir)
sys.exit(1)
subdirs = sorted([p for p in example_dir.rglob("*") if p.is_dir()])
if not subdirs:
log.error("Keine Unterverzeichnisse in %s gefunden", example_dir)
sys.exit(1)
log.info("Gefunden: %d Unterverzeichnisse in %s", len(subdirs), example_dir)
result: dict[str, list[dict[str, Any]]] = {}
for folder in subdirs:
log.info("Verarbeite %s", folder)
_process_single_folder(
folder,
example_dir,
result,
log,
write_json=write_json,
write_csv=write_csv,
write_xlsx=write_xlsx,
)
log.info("Fertig. Gelesene Verzeichnisse mit Daten: %d", len(result))
return result
def main() -> None:
parser = argparse.ArgumentParser(
description="Liest Position/Teilenummer/Menge aus Example-Exceldateien"
)
parser.add_argument(
"--example-dir",
type=Path,
default=None,
help=(
"Unterordnername in PV_EXAMPLES oder direkter Verzeichnispfad "
"(default: alle Unterordner in PV_EXAMPLES)"
),
)
parser.add_argument("--verbose", "-v", action="store_true",
help="Ausfuehrliche Ausgabe")
parser.add_argument(
"--2json",
dest="to_json",
action="store_true",
help="Schreibt Ausgabe als .json neben die jeweilige .xlsx",
)
parser.add_argument(
"--2csv",
dest="to_csv",
action="store_true",
help="Schreibt Ausgabe als .csv neben die jeweilige .xlsx",
)
parser.add_argument(
"--2xlsx",
dest="to_xlsx",
action="store_true",
help="Schreibt Ausgabe als _result.xlsx neben die jeweilige .xlsx",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)-7s %(message)s",
)
import os
pv_examples = os.environ.get("PV_EXAMPLES")
if not pv_examples:
log.error("PV_EXAMPLES ist nicht gesetzt")
sys.exit(1)
examples_root = Path(pv_examples).resolve()
if not examples_root.exists() or not examples_root.is_dir():
log.error("PV_EXAMPLES ist kein gueltiges Verzeichnis: %s", examples_root)
sys.exit(1)
if args.example_dir is None:
log.info("Beispiel-Verzeichnis (alle Unterordner): %s", examples_root)
data = process_all(
examples_root,
write_json=args.to_json,
write_csv=args.to_csv,
write_xlsx=args.to_xlsx,
)
log.info("Objekt erzeugt mit %d Schluesseln", len(data))
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
return
# --example-dir: entweder direkter Verzeichnispfad oder Unterordnername in PV_EXAMPLES
candidate = args.example_dir
if candidate.is_absolute():
target_dir = candidate.resolve()
else:
as_path = candidate.resolve()
if as_path.exists() and as_path.is_dir():
target_dir = as_path
else:
target_dir = (examples_root / candidate).resolve()
if not target_dir.exists() or not target_dir.is_dir():
log.error(
"--example-dir muss ein gueltiger Verzeichnispfad oder Unterordner in PV_EXAMPLES sein: %s",
args.example_dir,
)
sys.exit(1)
log.info("Beispiel-Verzeichnis (einzeln): %s", target_dir)
result: dict[str, list[dict[str, Any]]] = {}
_process_single_folder(
target_dir,
target_dir.parent,
result,
log,
write_json=args.to_json,
write_csv=args.to_csv,
write_xlsx=args.to_xlsx,
)
log.info("Objekt erzeugt mit %d Schluesseln", len(result))
#print(json.dumps(result, indent=2, ensure_ascii=False, default=str))
if __name__ == "__main__":
main()