Compare commits
10 Commits
cfc494d0dd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8998cd3e02 | |||
| 038b51d35d | |||
| d10d7c8e01 | |||
| 6e64a82c70 | |||
| a7bc331a7f | |||
| 1f9193d332 | |||
| 1abd469043 | |||
| 61f1430821 | |||
| c4581f8da3 | |||
| ae761b407a |
@@ -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
|
||||||
|
|||||||
@@ -1,43 +1,136 @@
|
|||||||
# docu_build
|
# docu_build
|
||||||
|
|
||||||
> Kurze Projektbeschreibung hier eintragen.
|
Sphinx-basiertes Dokumentationssystem fuer Transport- und Hängeförderanlagen. JSON-gesteuerter Build erzeugt aus modularen Textbausteinen mehrsprachige PDF-Dokumentationen (250+ Seiten). Entwickelt von Mista GmbH fuer Schönenberger.
|
||||||
|
|
||||||
|
Das Projekt befindet sich in der Planungs- und Aufbauphase.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
Das Zielsystem besteht aus zwei Repos:
|
||||||
|
|
||||||
|
- **transport-docs-base** (dieses Repo): Wiederverwendbare Inhalte -- Sphinx-Extensions (`_ext/`), Module (`modules/`), Kapitelgerüste (`chapters/`), Assets, Glossar, LaTeX-Templates.
|
||||||
|
- **projekt-kunde-xyz** (pro Anlage): `anlage.json` (ERP-Export), projektspezifische Bilder, Overrides, `conf.py`, `build.py`. Bindet das Base-Repo per Symlink/Mount ein.
|
||||||
|
|
||||||
|
### 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) |
|
||||||
|
|
||||||
|
|
||||||
|
### Build-Pipeline (zu implementieren)
|
||||||
|
|
||||||
|
```text
|
||||||
|
anlage.json → build.py → Versionscheck → Abhängigkeiten auflösen (meta.yaml)
|
||||||
|
→ Overrides anwenden → Sphinx-Quellen generieren → sphinx-build -b latex
|
||||||
|
→ pdflatex → PDF pro Sprache
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```text
|
||||||
docu_build/
|
docu_build/
|
||||||
|
├── _ext/ # Sphinx-Extensions (zu implementieren)
|
||||||
|
├── _templates/ # Sphinx/LaTeX-Templates
|
||||||
|
├── assets/
|
||||||
|
│ ├── shared/ # Sprachunabhängige Bilder/Symbole
|
||||||
|
│ └── localized/ # Sprachspezifische Bilder
|
||||||
├── bin/ # Skripte zur Umgebungsverwaltung
|
├── bin/ # Skripte zur Umgebungsverwaltung
|
||||||
│ ├── setenv.bat/.sh # Umgebungsvariablen setzen
|
│ ├── setenv.bat/.sh # Umgebungsvariablen setzen
|
||||||
│ ├── install_py.bat/.sh # venv erstellen + pip install
|
│ ├── install_py.bat/.sh # venv erstellen + pip install
|
||||||
│ ├── activate_venv.bat/.sh # venv aktivieren
|
│ ├── activate_venv.bat/.sh # venv aktivieren
|
||||||
│ └── get_cmd.bat/.sh # Shell mit Umgebung oeffnen
|
│ ├── get_cmd.bat/.sh # Shell mit Umgebung oeffnen
|
||||||
├── cfg/ # Konfigurationsdateien (INI/JSON)
|
│ ├── extract_products.* # Bilder/Text aus Produkt-PDFs extrahieren
|
||||||
|
│ └── convert_fortna.* # Fortna-Konvertierung
|
||||||
|
├── cfg/ # Konfigurationsdateien
|
||||||
|
├── chapters/ # Kapitelgerüste (rST)
|
||||||
|
│ ├── 00_inhaltsverzeichnis/
|
||||||
|
│ ├── 01_zertifikate/
|
||||||
|
│ ├── 02_sicherheit/
|
||||||
|
│ ├── 03_anlagenbeschreibung/
|
||||||
|
│ ├── 04_betrieb/
|
||||||
|
│ ├── 05_wartung/
|
||||||
|
│ ├── 06_steuerung/
|
||||||
|
│ ├── 07_ersatzteile/
|
||||||
|
│ ├── 08_lieferantendoku/
|
||||||
|
│ ├── 09_technische_unterlagen/
|
||||||
|
│ └── 10_anhang/
|
||||||
├── data/ # Eingabedaten (nicht im Git)
|
├── data/ # Eingabedaten (nicht im Git)
|
||||||
├── doc/ # Dokumentation
|
├── doc/ # Projektdokumentation
|
||||||
├── examples/ # Beispieldateien
|
├── glossary/ # Glossar-Quelldaten (CSV)
|
||||||
├── lib/ # Python-Quellcode / Bibliothek
|
├── lib/ # Python-Bibliotheken
|
||||||
|
├── modules/ # Modulbeschreibungen + meta.yaml
|
||||||
|
├── scripts/ # Hilfsskripte (dxf2svg, optimize_images, ...)
|
||||||
|
├── styles/ # LaTeX-Styles
|
||||||
|
├── tests/ # Tests
|
||||||
├── log/ # Log-Dateien (nicht im Git)
|
├── log/ # Log-Dateien (nicht im Git)
|
||||||
├── results/ # Ergebnisse / Ausgaben (nicht im Git)
|
└── results/ # Build-Ausgaben (nicht im Git)
|
||||||
├── tests/ # Unit Tests
|
|
||||||
├── .gitignore
|
|
||||||
├── LICENSE
|
|
||||||
├── README.md
|
|
||||||
└── requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ein Kundenprojekt enthält nur noch die Verweise auf die verbauten Teile, overrides und additionals, also das Überschreiben von vorhandenen Texten oder Ergänzungen
|
||||||
|
|
||||||
|
```
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
Details zur Konfiguration und Aufbau einer Doku siehe [Kernkonzepte](doc/Kernkonzepte.md).
|
||||||
|
|
||||||
|
### Kapitelstruktur
|
||||||
|
|
||||||
|
| Kapitel | Inhalt |
|
||||||
|
| ------------------------- | --------------------------------------------- |
|
||||||
|
| `00_inhaltsverzeichnis` | Wird generiert |
|
||||||
|
| `01_zertifikate` | CE/UKCA-Konformität, Konfigurationsbeschreibung |
|
||||||
|
| `02_sicherheit` | Sicherheitshinweise, Normen, Risikobewertung |
|
||||||
|
| `03_anlagenbeschreibung` | Module (via meta.yaml eingefügt) |
|
||||||
|
| `04_betrieb` | Betriebsanleitung |
|
||||||
|
| `05_wartung` | Wartungsanleitung, Schmierplan |
|
||||||
|
| `06_steuerung` | Funktionsbeschreibungen pro Steuerungssystem |
|
||||||
|
| `07_ersatzteile` | Ersatzteillisten |
|
||||||
|
| `08_lieferantendoku` | Antriebe, Elektrik, Pneumatik, Stahlbau |
|
||||||
|
| `09_technische_unterlagen` | Layouts, Schaltpläne, Notstopp |
|
||||||
|
| `10_anhang` | Prüfberichte, Abnahmeprotokolle |
|
||||||
|
|
||||||
## Umgebungsvariablen
|
## Umgebungsvariablen
|
||||||
|
|
||||||
| Variable | Beschreibung |
|
| Variable | Beschreibung |
|
||||||
|----------------|---------------------------|
|
|----------------|---------------------------|
|
||||||
| `PROJECT` | Wurzelverzeichnis |
|
| `PROJECT` | Wurzelverzeichnis |
|
||||||
| `PV_BIN` | Skriptverzeichnis |
|
| `PV_BIN` | Skriptverzeichnis |
|
||||||
| `PV_LIB` | Python-Quellcode |
|
| `PV_LIB` | Python-Quellcode |
|
||||||
| `PV_CFG` | Konfigurationsdateien |
|
| `PV_CFG` | Konfigurationsdateien |
|
||||||
| `PV_DATA` | Eingabedaten |
|
| `PV_DATA` | Eingabedaten |
|
||||||
| `PV_LOG` | Log-Dateien |
|
| `PV_LOG` | Log-Dateien |
|
||||||
| `PV_RESULTS` | Ergebnisse |
|
| `PV_RESULTS` | Ergebnisse |
|
||||||
| `PV_EXAMPLES` | Beispieldateien |
|
| `PV_EXAMPLES` | Beispieldateien |
|
||||||
| `PYTHONPATH` | Erweitert um `PV_LIB` |
|
| `PYTHONPATH` | Erweitert um `PV_LIB` |
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -59,13 +152,14 @@ bash bin/install_py.sh
|
|||||||
|
|
||||||
## Nutzung
|
## Nutzung
|
||||||
|
|
||||||
### Umgebung setzen
|
### Umgebung aktivieren
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
bin\setenv.bat # Windows
|
bin\activate_venv.bat # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source bin/setenv.sh # Linux / macOS
|
source bin/activate_venv.sh # Linux / macOS
|
||||||
```
|
```
|
||||||
|
|
||||||
### Shell mit gesetzten Variablen oeffnen
|
### Shell mit gesetzten Variablen oeffnen
|
||||||
@@ -73,22 +167,22 @@ source bin/setenv.sh # Linux / macOS
|
|||||||
```bat
|
```bat
|
||||||
bin\get_cmd.bat # Windows
|
bin\get_cmd.bat # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
source bin/get_cmd.sh # Linux / macOS
|
source bin/get_cmd.sh # Linux / macOS
|
||||||
```
|
```
|
||||||
|
|
||||||
### venv aktivieren
|
### Produkt-PDFs extrahieren
|
||||||
|
|
||||||
|
Extrahiert Bilder und Text (als rST) aus den PDFs in `data/Produktprogramm/`:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
bin\activate_venv.bat # Windows
|
bin\extract_products.bat # Windows
|
||||||
```
|
|
||||||
```bash
|
|
||||||
source bin/activate_venv.sh # Linux / macOS
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
MIT License — siehe [LICENSE](LICENSE)
|
MIT License -- siehe [LICENSE](LICENSE)
|
||||||
|
|
||||||
## Autor
|
## Autor
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -0,0 +1,502 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
Steuert die Zusammenstellung. Enthält Projektmetadaten, Zielsprachen, Normenregion, Liste verbauter Maschinen und Referenzen auf Zusatzkapitel.
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projekt": {
|
||||||
|
"nummer": "P-2026-0042",
|
||||||
|
"kunde": "Müller Automotive GmbH",
|
||||||
|
"kunde_logo": "images/kundenlogo.pdf",
|
||||||
|
"standort": "Werk Ingolstadt, Halle 3",
|
||||||
|
"sprachen": ["de", "en"],
|
||||||
|
"normen_region": "EU",
|
||||||
|
"base_version": ">=1.0.0"
|
||||||
|
},
|
||||||
|
"maschinen": [
|
||||||
|
{
|
||||||
|
"modul": "weiche_typ_a",
|
||||||
|
"anzahl": 4,
|
||||||
|
"positionen": ["W01", "W02", "W03", "W04"],
|
||||||
|
"variante": "standard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modul": "hubstation",
|
||||||
|
"anzahl": 2,
|
||||||
|
"positionen": ["H01", "H02"],
|
||||||
|
"variante": "schwerlast",
|
||||||
|
"optionen": ["hydraulisch"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modul": "antrieb_sew",
|
||||||
|
"anzahl": 12,
|
||||||
|
"positionen": ["A01-A12"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modul": "steuerung_siemens_s7",
|
||||||
|
"anzahl": 1,
|
||||||
|
"positionen": ["SPS01"],
|
||||||
|
"optionen": ["profinet", "hmi_tp700"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"zusatz_kapitel": [
|
||||||
|
"additional/sonderausfuehrung_xyz"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### meta.yaml (Modul-Metadaten)
|
||||||
|
|
||||||
|
Pro Maschinentyp-Modul. Definiert Bezeichner, mehrsprachigen Namen, Kapitelzuordnung, Bildreferenzen und Abhängigkeiten.
|
||||||
|
|
||||||
|
**Beispiel** (`modules/weiche_typ_a/meta.yaml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
module: weiche_typ_a
|
||||||
|
display_name:
|
||||||
|
de: "Weiche Typ A"
|
||||||
|
en: "Switch Type A"
|
||||||
|
|
||||||
|
chapters:
|
||||||
|
- 02_sicherheit:
|
||||||
|
sections: [risikomatrix]
|
||||||
|
- 03_anlagenbeschreibung:
|
||||||
|
sections: [beschreibung, technische_daten]
|
||||||
|
- 05_wartung:
|
||||||
|
sections: [wartung, schmierplan]
|
||||||
|
|
||||||
|
images:
|
||||||
|
shared:
|
||||||
|
- photos/weiche_typ_a_foto_01.jpg
|
||||||
|
- drawings/weiche_typ_a_layout.svg
|
||||||
|
- diagrams/stromlaufplan_weiche_a.pdf
|
||||||
|
localized:
|
||||||
|
- diagramme/risikograph_weiche_a.svg
|
||||||
|
- screenshots/hmi_hauptmenu.png
|
||||||
|
module_local:
|
||||||
|
- montage_detail_01.jpg
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- antrieb_sew
|
||||||
|
```
|
||||||
|
|
||||||
|
### conf.py (Projekt-Konfiguration)
|
||||||
|
|
||||||
|
Importiert die Basiskonfiguration vom gemounteten Base-Repo und setzt projektspezifische Overrides.
|
||||||
|
|
||||||
|
**Beispiel** (`projekt-kunde-xyz-2026/conf.py`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ══ Pfad zum Base-Repo (Share/Mount) ══
|
||||||
|
BASE_DOCS = Path(os.environ.get(
|
||||||
|
"DOCS_BASE_PATH",
|
||||||
|
Path(__file__).parent / "transport-docs-base"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Extensions und Basiskonfiguration importieren
|
||||||
|
sys.path.insert(0, str(BASE_DOCS / "_ext"))
|
||||||
|
sys.path.insert(0, str(BASE_DOCS))
|
||||||
|
from conf_base import * # noqa: F401, F403
|
||||||
|
|
||||||
|
# ══ Projektspezifische Overrides ══
|
||||||
|
project = "Anlagendokumentation P-2026-0042"
|
||||||
|
release = "1.0"
|
||||||
|
author = "Mista GmbH"
|
||||||
|
copyright = "2026, Mista GmbH"
|
||||||
|
|
||||||
|
# Bildpfade: Projekt → Overrides → Base (Reihenfolge = Priorität)
|
||||||
|
image_search_path = [
|
||||||
|
"images", # Projektbilder
|
||||||
|
"overrides", # Überschreibungen
|
||||||
|
str(BASE_DOCS / "assets" / "shared"), # Sprachneutrale Basis
|
||||||
|
str(BASE_DOCS / "assets" / "localized"), # Sprachspezifische Basis
|
||||||
|
str(BASE_DOCS / "modules"), # Modulbilder
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sphinx-Extensions aus Base-Repo laden
|
||||||
|
extensions += [
|
||||||
|
"anlagen_builder",
|
||||||
|
"localized_figure",
|
||||||
|
"glossary_loader",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Glossar-Pfad
|
||||||
|
glossary_csv_path = str(BASE_DOCS / "glossary")
|
||||||
|
|
||||||
|
# LaTeX-Templates aus Base-Repo
|
||||||
|
templates_path.insert(0, str(BASE_DOCS / "_templates"))
|
||||||
|
|
||||||
|
# LaTeX-spezifisch
|
||||||
|
latex_elements = {
|
||||||
|
"preamble": open(BASE_DOCS / "_templates" / "latex" / "preamble.tex").read(),
|
||||||
|
"maketitle": open(BASE_DOCS / "_templates" / "latex" / "maketitle.tex").read(),
|
||||||
|
"papersize": "a4paper",
|
||||||
|
"pointsize": "11pt",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Override-Mechanismus
|
||||||
|
|
||||||
|
Suchreihenfolge beim Build:
|
||||||
|
1. `projekt/overrides/` (projektspezifisch)
|
||||||
|
2. `transport-docs-base/modules/` bzw. `chapters/` (allgemein)
|
||||||
|
|
||||||
|
Ermöglicht kundenspezifische Anpassungen ohne Base-Repo-Änderung.
|
||||||
|
|
||||||
|
### Versionskontrolle
|
||||||
|
|
||||||
|
Base-Repo hat Semver in `VERSION`. Projekt-Repo kann `"base_version": ">=2.1.0"` in anlage.json setzen. build.py prüft Kompatibilität vor dem Build.
|
||||||
|
|
||||||
|
## Mehrsprachigkeit
|
||||||
|
|
||||||
|
### Drei Ebenen
|
||||||
|
|
||||||
|
1. **Textbausteine**: Dateinamenskonvention `{name}.{lang}.rst`. Fallback auf `de` bei fehlender Übersetzung.
|
||||||
|
2. **Bilder**: `localized-figure`-Direktive löst `assets/localized/{lang}/pfad/bild.png` auf. Fallback auf `de`. Optional: Jinja2-SVG-Templates für Diagramme mit Text.
|
||||||
|
3. **Glossar**: CSV pro Sprache (`terms.{lang}.csv`), `glossary_loader.py` erzeugt Sphinx-Glossardirektiven. Im Text via `:term:`Fachbegriff`` verlinkt.
|
||||||
|
|
||||||
|
### Sprachspezifische Normen
|
||||||
|
|
||||||
|
Gesteuert über `normen_region` in anlage.json. Kapitel 02_sicherheit enthält z.B. `normen_ce.de.rst` (EU) und `normen_ukca.en.rst` (UK), Build wählt nach Region.
|
||||||
|
|
||||||
|
## Bildverwaltung
|
||||||
|
|
||||||
|
| Kategorie | Pfad | Beispiele |
|
||||||
|
|------------------|-----------------------------------|----------------------------------------------|
|
||||||
|
| Sprachneutral | `assets/shared/{typ}/` | Fotos, DXF/SVG-Zeichnungen, ISO-Symbole |
|
||||||
|
| Sprachspezifisch | `assets/localized/{lang}/{typ}/` | HMI-Screenshots, Risikographen, Typenschilder|
|
||||||
|
| Modulspezifisch | `modules/{modul}/images/` | Montagedetails |
|
||||||
|
| Projektspezifisch| `projekt/images/` | Aufstellpläne, Abnahmefotos, Kundenlogo |
|
||||||
|
|
||||||
|
Formate: SVG/PDF für Zeichnungen (vektoriell), JPG für Fotos (max 2000px, 85% Qualität).
|
||||||
|
|
||||||
|
## Build-Prozess
|
||||||
|
|
||||||
|
```
|
||||||
|
anlage.json ──→ build.py
|
||||||
|
│
|
||||||
|
├─ Phase 1: Konfiguration einlesen, Versionscheck
|
||||||
|
├─ Phase 2: Abhängigkeiten auflösen (meta.yaml, transitiv)
|
||||||
|
│ Overrides anwenden (Projekt > Base)
|
||||||
|
│ Fehlende Module/Bilder/Übersetzungen melden
|
||||||
|
├─ Phase 3: Sphinx-Quellen generieren (index.rst, toctrees)
|
||||||
|
│ Glossar aus CSV erzeugen
|
||||||
|
│ Jinja2-Templates mit Projektdaten füllen
|
||||||
|
└─ Phase 4: sphinx-build -b latex → pdflatex → PDF (pro Sprache)
|
||||||
|
|
||||||
|
Ausgabe:
|
||||||
|
_output/P-2026-0042_Dokumentation_DE.pdf
|
||||||
|
_output/P-2026-0042_Documentation_EN.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sphinx-Extensions (zu implementieren)
|
||||||
|
|
||||||
|
### anlagen_builder.py
|
||||||
|
- Liest anlage.json + meta.yaml aller referenzierten Module
|
||||||
|
- Löst Abhängigkeiten auf (topologisch, transitiv)
|
||||||
|
- Generiert toctree-Einträge pro Kapitel
|
||||||
|
- Wendet Override-Logik an
|
||||||
|
|
||||||
|
### localized_figure.py
|
||||||
|
- Neue Direktive `.. localized-figure::`
|
||||||
|
- Löst Bildpfad nach Build-Sprache auf
|
||||||
|
- Fallback: `de` wenn Sprachversion fehlt
|
||||||
|
|
||||||
|
### glossary_loader.py
|
||||||
|
- Liest `terms.{lang}.csv` (Semikolon-getrennt)
|
||||||
|
- Erzeugt `.. glossary::` Blöcke
|
||||||
|
- Ermöglicht `:term:` Verlinkung im gesamten Dokument
|
||||||
|
|
||||||
|
## Hilfsskripte
|
||||||
|
|
||||||
|
- `scripts/dxf2svg.py` – DXF → SVG Konvertierung (ezdxf)
|
||||||
|
- `scripts/optimize_images.py` – Bildoptimierung vor Commit (Pillow, max 2000px)
|
||||||
|
- `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 30–50% 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`)
|
||||||
@@ -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.
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
collect_partnumbers.py - Sammelt 9-stellige Teilenummern aus Produktprogramm-RSTs.
|
||||||
|
|
||||||
|
Erkennt auch Bereiche wie 834372007-011 und expandiert diese zu
|
||||||
|
834372007, 834372008, 834372009, 834372010, 834372011.
|
||||||
|
|
||||||
|
Gleicht die Nummern gegen eine Excel-Stueckliste ab und schreibt
|
||||||
|
eine CSV mit Teilenummer, Quelldatei und Bezeichnung.
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
python collect_partnumbers.py [--data-dir <pfad>]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_ranges(text: str) -> list[str]:
|
||||||
|
"""Findet alle 9-stelligen Nummern und Bereiche in einem Text.
|
||||||
|
|
||||||
|
Unterstuetzte Formate:
|
||||||
|
- Einzeln: 834372007
|
||||||
|
- Bereich: 834372007-011 -> 834372007..834372011
|
||||||
|
- OCR-verklebt: "834372001-005834372 007-011" bedeutet eigentlich
|
||||||
|
"834372001-005 834372007-011" -> zwei Bereiche mit gleicher Stammnummer
|
||||||
|
- Kurzbereich mit Stammnummer: "834372 007-011" -> 834372007..834372011
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste der gefundenen/expandierten 9-stelligen Nummern.
|
||||||
|
"""
|
||||||
|
results: list[str] = []
|
||||||
|
|
||||||
|
# Phase 1: OCR-verklebte Nummern trennen.
|
||||||
|
# "005834372" -> "005 834372" (3 Suffix-Ziffern gefolgt von 6-stelliger Stammnr)
|
||||||
|
# Wiederhole bis stabil.
|
||||||
|
cleaned = text
|
||||||
|
for _ in range(10):
|
||||||
|
prev = cleaned
|
||||||
|
# Nach einem Bereichs-Ende (-NNN) klebt eine Stammnummer (6 Ziffern) dran
|
||||||
|
cleaned = re.sub(r'-(\d{3})(\d{6})(?=\s|\d{3}-|\d{3}\s|$)', r'-\1 \2', cleaned)
|
||||||
|
if cleaned == prev:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Phase 2: Tokenisieren und mit Kontext parsen.
|
||||||
|
# Tokens: 9-stellige Nummern, 6-stellige Stammnummern, NNN-NNN Bereiche,
|
||||||
|
# einzelne NNN Suffixe.
|
||||||
|
# Die Stammnummer wird aus dem letzten vollstaendigen 9-Steller oder
|
||||||
|
# expliziten 6-Steller uebernommen.
|
||||||
|
|
||||||
|
current_prefix = "" # 6-stellige Stammnummer
|
||||||
|
|
||||||
|
# Tokenize: Zifferngruppen und Bindestriche extrahieren
|
||||||
|
# Wir verarbeiten die Zeile sequentiell
|
||||||
|
token_pat = re.compile(r'\d+')
|
||||||
|
dash_pat = re.compile(r'(\d+)-(\d+)')
|
||||||
|
|
||||||
|
# Strategie: alle Zifferngruppen und ihre Kontexte finden
|
||||||
|
# Zuerst alle "Nummer-Nummer" Paare und einzelne Nummern identifizieren
|
||||||
|
segments: list[str] = []
|
||||||
|
# Aufteilen an Nicht-Ziffern-nicht-Bindestrich Grenzen
|
||||||
|
# aber Bindestrich zwischen Ziffern beibehalten
|
||||||
|
seg_pat = re.compile(r'[\d]+(?:-[\d]+)*')
|
||||||
|
segments = seg_pat.findall(cleaned)
|
||||||
|
|
||||||
|
for seg in segments:
|
||||||
|
# Fall 1: "834372007-011" (9 Stellen - 3 Stellen)
|
||||||
|
m = re.match(r'^(\d{9})-(\d{3})$', seg)
|
||||||
|
if m:
|
||||||
|
base = m.group(1)
|
||||||
|
current_prefix = base[:6]
|
||||||
|
start = int(base[6:])
|
||||||
|
end = int(m.group(2))
|
||||||
|
if end >= start:
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
results.append(f"{current_prefix}{i:03d}")
|
||||||
|
else:
|
||||||
|
results.append(base)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 2: "834372007" (einzelne 9-stellige Nummer)
|
||||||
|
m = re.match(r'^(\d{9})$', seg)
|
||||||
|
if m:
|
||||||
|
num = m.group(1)
|
||||||
|
current_prefix = num[:6]
|
||||||
|
if num not in results:
|
||||||
|
results.append(num)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 3: "834372" (6-stellige Stammnummer, setzt Prefix)
|
||||||
|
m = re.match(r'^(\d{6})$', seg)
|
||||||
|
if m:
|
||||||
|
current_prefix = m.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 4: "007-011" (3-3 Bereich mit bekanntem Prefix)
|
||||||
|
m = re.match(r'^(\d{3})-(\d{3})$', seg)
|
||||||
|
if m and current_prefix:
|
||||||
|
start = int(m.group(1))
|
||||||
|
end = int(m.group(2))
|
||||||
|
if end >= start:
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
results.append(f"{current_prefix}{i:03d}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 5: "420" (einzelnes 3-stelliges Suffix mit bekanntem Prefix)
|
||||||
|
m = re.match(r'^(\d{3})$', seg)
|
||||||
|
if m and current_prefix:
|
||||||
|
num = f"{current_prefix}{m.group(1)}"
|
||||||
|
if num not in results:
|
||||||
|
results.append(num)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Fall 6: "200-202834372" -> nach Phase 1 Trennung sollte das
|
||||||
|
# nicht mehr vorkommen, aber als Fallback:
|
||||||
|
# Mehrere Bereiche hintereinander
|
||||||
|
sub_segs = re.findall(r'(\d{9})-(\d{3})|(\d{9})|(\d{3})-(\d{3})', seg)
|
||||||
|
for ss in sub_segs:
|
||||||
|
if ss[0] and ss[1]: # 9-3 Bereich
|
||||||
|
base = ss[0]
|
||||||
|
current_prefix = base[:6]
|
||||||
|
start = int(base[6:])
|
||||||
|
end = int(ss[1])
|
||||||
|
if end >= start:
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
results.append(f"{current_prefix}{i:03d}")
|
||||||
|
elif ss[2]: # Einzelne 9-stellige
|
||||||
|
current_prefix = ss[2][:6]
|
||||||
|
if ss[2] not in results:
|
||||||
|
results.append(ss[2])
|
||||||
|
elif ss[3] and ss[4] and current_prefix: # 3-3 Bereich
|
||||||
|
start = int(ss[3])
|
||||||
|
end = int(ss[4])
|
||||||
|
if end >= start:
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
results.append(f"{current_prefix}{i:03d}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
RE_UNDERLINE = re.compile(r"^([=\-~\^])\1{2,}$")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_from_rst(rst_path: Path) -> dict[str, str]:
|
||||||
|
"""Sammelt alle 9-stelligen Teilenummern aus einer RST-Datei.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict nummer -> kapitelueberschrift in der die Nummer gefunden wurde.
|
||||||
|
"""
|
||||||
|
lines = rst_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
num_to_chapter: dict[str, str] = {}
|
||||||
|
current_heading = ""
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i].rstrip()
|
||||||
|
|
||||||
|
# RST-Ueberschriften erkennen:
|
||||||
|
# Variante 1: overline + title + underline (Level 1)
|
||||||
|
# Variante 2: title + underline (Level 2+)
|
||||||
|
if RE_UNDERLINE.match(line):
|
||||||
|
# Pruefen ob dies eine Underline nach einem Titel ist
|
||||||
|
if i > 0 and lines[i - 1].strip():
|
||||||
|
title = lines[i - 1].strip()
|
||||||
|
# Nur nummerierte Ueberschriften als Kapitel verwenden
|
||||||
|
if re.match(r"^\d+(\.\d+)*\s+", title):
|
||||||
|
current_heading = title
|
||||||
|
# Oder eine Overline vor einem Titel
|
||||||
|
elif i + 1 < len(lines) and lines[i + 1].strip():
|
||||||
|
title = lines[i + 1].strip()
|
||||||
|
if re.match(r"^\d+(\.\d+)*\s+", title):
|
||||||
|
current_heading = title
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Teilenummern aus der Zeile extrahieren
|
||||||
|
nums = expand_ranges(line)
|
||||||
|
for n in nums:
|
||||||
|
if n[0] in ("8", "9") and n not in num_to_chapter:
|
||||||
|
num_to_chapter[n] = current_heading
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return num_to_chapter
|
||||||
|
|
||||||
|
|
||||||
|
def load_excel_descriptions(xlsx_path: Path) -> dict[str, str]:
|
||||||
|
"""Laedt Teilenummer -> Bezeichnung aus Excel-Stueckliste."""
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(str(xlsx_path), read_only=True)
|
||||||
|
ws = wb["Stk.-Liste SOLL"]
|
||||||
|
desc_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
for row in ws.iter_rows(min_row=5, values_only=True):
|
||||||
|
tn = str(row[1]).strip() if row[1] else ""
|
||||||
|
if re.match(r"^\d{9}$", tn) and tn not in desc_map:
|
||||||
|
bez = str(row[2]).strip() if row[2] else ""
|
||||||
|
if bez:
|
||||||
|
desc_map[tn] = bez
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return desc_map
|
||||||
|
|
||||||
|
|
||||||
|
def process(data_dir: Path) -> None:
|
||||||
|
pp_dir = data_dir / "Produktprogramm"
|
||||||
|
rst_files = sorted(pp_dir.glob("*.de.rst"))
|
||||||
|
|
||||||
|
if not rst_files:
|
||||||
|
log.error("Keine RST-Dateien in %s gefunden", pp_dir)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Teilenummern sammeln: nummer -> list[(quelldatei, kapitel)]
|
||||||
|
numbers: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
for rst_path in rst_files:
|
||||||
|
basename = rst_path.name
|
||||||
|
num_chapters = collect_from_rst(rst_path)
|
||||||
|
log.info("%s: %d Teilenummern gefunden", basename, len(num_chapters))
|
||||||
|
for num, chapter in num_chapters.items():
|
||||||
|
numbers.setdefault(num, []).append((basename, chapter))
|
||||||
|
|
||||||
|
log.info("Gesamt: %d eindeutige Teilenummern", len(numbers))
|
||||||
|
|
||||||
|
# Excel-Bezeichnungen laden
|
||||||
|
xlsx_files = list((data_dir).glob("KA*.xlsx"))
|
||||||
|
desc_map: dict[str, str] = {}
|
||||||
|
if xlsx_files:
|
||||||
|
xlsx_path = xlsx_files[0]
|
||||||
|
log.info("Lade Excel: %s", xlsx_path.name)
|
||||||
|
desc_map = load_excel_descriptions(xlsx_path)
|
||||||
|
log.info(" %d Bezeichnungen geladen", len(desc_map))
|
||||||
|
else:
|
||||||
|
log.warning("Keine KA*.xlsx Datei gefunden in %s", data_dir)
|
||||||
|
|
||||||
|
# CSV schreiben
|
||||||
|
outfile = pp_dir / "teilenummern.csv"
|
||||||
|
matched = 0
|
||||||
|
with open(outfile, "w", encoding="utf-8-sig", newline="") as fh:
|
||||||
|
writer = csv.writer(fh, delimiter=";")
|
||||||
|
writer.writerow(["Teilenummer", "Quelldatei", "Kapitel", "Bezeichnung"])
|
||||||
|
for num in sorted(numbers.keys()):
|
||||||
|
entries = sorted(numbers[num], key=lambda e: e[0])
|
||||||
|
sources = ", ".join(e[0] for e in entries)
|
||||||
|
chapters = ", ".join(e[1] for e in entries if e[1])
|
||||||
|
bez = desc_map.get(num, "")
|
||||||
|
if bez:
|
||||||
|
matched += 1
|
||||||
|
writer.writerow([num, sources, chapters, bez])
|
||||||
|
|
||||||
|
log.info("CSV geschrieben: %s", outfile)
|
||||||
|
log.info(" %d Nummern, %d mit Bezeichnung, %d ohne",
|
||||||
|
len(numbers), matched, len(numbers) - matched)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Sammelt 9-stellige Teilenummern aus Produktprogramm-RSTs"
|
||||||
|
)
|
||||||
|
parser.add_argument("--data-dir", type=Path, default=None,
|
||||||
|
help="data/ Verzeichnis (default: aus PV_DATA oder ./data)")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||||
|
format="%(levelname)-7s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.data_dir:
|
||||||
|
data_dir = args.data_dir.resolve()
|
||||||
|
elif os.environ.get("PV_DATA"):
|
||||||
|
data_dir = Path(os.environ["PV_DATA"]).resolve()
|
||||||
|
else:
|
||||||
|
data_dir = Path("data").resolve()
|
||||||
|
|
||||||
|
log.info("Data-Verzeichnis: %s", data_dir)
|
||||||
|
process(data_dir)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
+128
-18
@@ -77,6 +77,94 @@ def extract_images(pdf_path: Path, output_dir: Path) -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_toc_numbers(doc) -> dict[str, str]:
|
||||||
|
"""Extrahiert Kapitelnummern aus dem Inhaltsverzeichnis des PDFs.
|
||||||
|
|
||||||
|
Sucht TOC-Seiten (Seiten mit '....' Punkt-Fuellern) und ordnet
|
||||||
|
Ueberschriftentexte ihren Kapitelnummern zu. Nur Eintraege mit
|
||||||
|
expliziter Nummer werden erfasst (bis Level 2).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict title_text -> kapitelnummer, z.B. {"Weichen Omniflo": "2.3"}
|
||||||
|
"""
|
||||||
|
toc_entries: dict[str, str] = {}
|
||||||
|
|
||||||
|
for page_num in range(len(doc)):
|
||||||
|
page = doc[page_num]
|
||||||
|
text = page.get_text()
|
||||||
|
if "...." not in text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines = text.splitlines()
|
||||||
|
current_number = ""
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i].strip()
|
||||||
|
i += 1
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Kapitelnummer auf separater Zeile
|
||||||
|
num_match = re.match(r"^(\d+(?:\.\d+)*)\s*$", line)
|
||||||
|
if num_match:
|
||||||
|
current_number = num_match.group(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Zeile mit Punkt-Fuellern = TOC-Eintrag
|
||||||
|
dot_match = re.search(r"\.{4,}", line)
|
||||||
|
if not dot_match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = line[:dot_match.start()].strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Inline-Nummer am Anfang? "1.4 Abkürzungen ....."
|
||||||
|
nm = re.match(r"^(\d+(?:\.\d+)*)\s+(.+)", title)
|
||||||
|
if nm:
|
||||||
|
toc_entries[nm.group(2).strip()] = nm.group(1)
|
||||||
|
current_number = ""
|
||||||
|
elif current_number:
|
||||||
|
toc_entries[title] = current_number
|
||||||
|
current_number = ""
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -86,16 +174,31 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
|||||||
import fitz
|
import fitz
|
||||||
|
|
||||||
doc = fitz.open(str(pdf_path))
|
doc = fitz.open(str(pdf_path))
|
||||||
|
|
||||||
|
# TOC-Nummern vorab extrahieren
|
||||||
|
toc_numbers = _extract_toc_numbers(doc)
|
||||||
|
|
||||||
raw_lines: list[dict] = []
|
raw_lines: list[dict] = []
|
||||||
|
|
||||||
for page_num in range(len(doc)):
|
for page_num in range(len(doc)):
|
||||||
page = doc[page_num]
|
page = doc[page_num]
|
||||||
|
page_height = page.rect.height
|
||||||
page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
|
page_dict = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)
|
||||||
|
|
||||||
for block in page_dict.get("blocks", []):
|
for block in page_dict.get("blocks", []):
|
||||||
if block.get("type") != 0:
|
if block.get("type") != 0:
|
||||||
continue
|
continue
|
||||||
for line in block.get("lines", []):
|
for line in block.get("lines", []):
|
||||||
|
bbox = line.get("bbox", [0, 0, 0, 0])
|
||||||
|
|
||||||
|
# Positionsbasierte Kopf-/Fusszeilen-Erkennung
|
||||||
|
# Kopfzeile: Y < 100pt (Logos, Kapitelname, OMNIFLO etc.)
|
||||||
|
if bbox[1] < 100:
|
||||||
|
continue
|
||||||
|
# Fusszeile: Y > page_height - 50pt
|
||||||
|
if bbox[3] > page_height - 50:
|
||||||
|
continue
|
||||||
|
|
||||||
text = ""
|
text = ""
|
||||||
max_size = 0.0
|
max_size = 0.0
|
||||||
for span in line.get("spans", []):
|
for span in line.get("spans", []):
|
||||||
@@ -109,7 +212,7 @@ 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_header_footer(text, page_num + 1):
|
if _is_noise(text):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raw_lines.append({
|
raw_lines.append({
|
||||||
@@ -169,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))
|
||||||
@@ -200,6 +308,20 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
|||||||
has_dots = "." in num
|
has_dots = "." in num
|
||||||
is_heading = has_dots or entry["size"] >= 12.0
|
is_heading = has_dots or entry["size"] >= 12.0
|
||||||
|
|
||||||
|
# Phase 3b: Nicht-nummerierte Texte mit grosser Schrift pruefen,
|
||||||
|
# ob sie im TOC eine Kapitelnummer haben (fehlende PDF-Nummerierung)
|
||||||
|
if not is_heading and entry["size"] >= 12.0:
|
||||||
|
# Text gegen TOC abgleichen
|
||||||
|
toc_num = toc_numbers.get(text)
|
||||||
|
if toc_num:
|
||||||
|
# Nummer aus TOC voranstellen
|
||||||
|
candidate = f"{toc_num} {text}"
|
||||||
|
hm = RE_HEADING.match(candidate)
|
||||||
|
if hm:
|
||||||
|
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)
|
||||||
heading_text = f"{num} {hm.group(2)}"
|
heading_text = f"{num} {hm.group(2)}"
|
||||||
@@ -222,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)
|
||||||
@@ -229,22 +355,6 @@ def extract_text_to_rst(pdf_path: Path, rst_path: Path) -> int:
|
|||||||
return block_count
|
return block_count
|
||||||
|
|
||||||
|
|
||||||
def _is_header_footer(text: str, page_num: int) -> bool:
|
|
||||||
"""Erkennt typische Kopf-/Fusszeilen."""
|
|
||||||
lower = text.lower().strip()
|
|
||||||
# Copyright-Zeilen
|
|
||||||
if "copyright" in lower and "schönenberger" in lower:
|
|
||||||
return True
|
|
||||||
# Seitenzahlen
|
|
||||||
if re.match(r"^seite\s+\d+$", lower):
|
|
||||||
return True
|
|
||||||
# Version/Datum Fusszeilen
|
|
||||||
if re.match(r"^version\s+\d+", lower):
|
|
||||||
return True
|
|
||||||
# "Erstellt:" / "Freigabe:" Fusszeilen
|
|
||||||
if lower.startswith("erstellt:") or lower.startswith("freigabe:"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+38
-10
@@ -1,10 +1,38 @@
|
|||||||
# Python-Abhaengigkeiten
|
annotated-doc==0.0.4
|
||||||
# Installieren mit: pip install -r requirements.txt
|
annotated-types==0.7.0
|
||||||
|
cffi==2.0.0
|
||||||
# Dokumenten-Konvertierung
|
charset-normalizer==3.4.7
|
||||||
mammoth >= 1.8.0
|
click==8.3.3
|
||||||
pdfminer.six >= 20231228
|
cobble==0.1.4
|
||||||
PyMuPDF >= 1.24.0
|
colorama==0.4.6
|
||||||
|
cryptography==47.0.0
|
||||||
# reStructuredText / Sphinx
|
doc8==2.0.0
|
||||||
docutils >= 0.21
|
docutils==0.21.2
|
||||||
|
et_xmlfile==2.0.0
|
||||||
|
mammoth==1.12.0
|
||||||
|
markdown-it-py==4.0.0
|
||||||
|
mdurl==0.1.2
|
||||||
|
numpy==2.4.4
|
||||||
|
openpyxl==3.1.5
|
||||||
|
pandas==3.0.2
|
||||||
|
pdfminer.six==20251230
|
||||||
|
pdfplumber==0.11.9
|
||||||
|
pillow==12.2.0
|
||||||
|
pycparser==3.0
|
||||||
|
pydantic==2.13.3
|
||||||
|
pydantic_core==2.46.3
|
||||||
|
Pygments==2.20.0
|
||||||
|
PyMuPDF==1.27.2.3
|
||||||
|
pypdfium2==5.7.1
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
restructuredtext_lint==2.0.2
|
||||||
|
rich==15.0.0
|
||||||
|
rstcheck==6.2.5
|
||||||
|
rstcheck-core==1.2.2
|
||||||
|
shellingham==1.5.4
|
||||||
|
six==1.17.0
|
||||||
|
stevedore==5.7.0
|
||||||
|
typer==0.25.0
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
tzdata==2026.2
|
||||||
|
|||||||
Reference in New Issue
Block a user