Compare commits
346 Commits
85282ea7b4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0817d49dfc | |||
| 44cf2b3edc | |||
| 4acbd6b0b2 | |||
| 843bd145a8 | |||
| 98425b8fa8 | |||
| 5b6459a041 | |||
| d493734660 | |||
| 0aaa160b95 | |||
| 03184aa951 | |||
| 34bd42a009 | |||
| 897394e27e | |||
| 9ab914dbfb | |||
| 9659fe3f8a | |||
| 5cbf4fdfc0 | |||
| bd06efe05d | |||
| 23c3e40390 | |||
| 1201755077 | |||
| 162e2ef414 | |||
| 3f291c907c | |||
| 251647a6ab | |||
| 277254ebbd | |||
| f97bfeff47 | |||
| 02a778751a | |||
| af0ece8ded | |||
| 03fa74abba | |||
| 71aea3f41d | |||
| 16c8674eff | |||
| df5276abf2 | |||
| 636ecc9883 | |||
| 92950dbbe6 | |||
| 5c51664e6c | |||
| 3244efd5e0 | |||
| af02e14f2d | |||
| 8730ffa7db | |||
| f7d11ccf97 | |||
| 76e6cebd90 | |||
| dbbca96c69 | |||
| eea022b862 | |||
| 6de5b55810 | |||
| 07bd114df1 | |||
| 84d38f5eb5 | |||
| 9db85236ec | |||
| f2a6078421 | |||
| 568d9dbb32 | |||
| f620f46d15 | |||
| 46d3d7cf35 | |||
| cb22b1bb96 | |||
| 5544b04b07 | |||
| 49d8b205d7 | |||
| f296a076dc | |||
| 1caefe6603 | |||
| 6b690232ff | |||
| 309834d90c | |||
| 8b44edda90 | |||
| 255343145d | |||
| 5baa971b46 | |||
| e65384768f | |||
| beb20e0cf7 | |||
| 98c241fc64 | |||
| d4cc0eb77d | |||
| e0b1ce8836 | |||
| f18b002f4e | |||
| f8913f81b8 | |||
| 9195cdb14d | |||
| 3f4ba9eea9 | |||
| 92028d9e02 | |||
| bdb45eefe4 | |||
| 148b71db48 | |||
| c54ad3830d | |||
| d66bd63cc9 | |||
| 3b4e3db51d | |||
| 2d7046d0e3 | |||
| d9b5c6bfea | |||
| 91a8c38b25 | |||
| 19ba044ec0 | |||
| 9556e0ac67 | |||
| 4692bd186c | |||
| b11432df16 | |||
| 319cb52160 | |||
| a35dfa1434 | |||
| 237c71e5a0 | |||
| ec124e9acd | |||
| 0ab1807235 | |||
| 7cfdd06d1e | |||
| 544fbf792c | |||
| 18e619abfc | |||
| 5eeff24b3a | |||
| f13c2eb35b | |||
| 2662d4e82e | |||
| 574f8c470c | |||
| 9b4af2bb56 | |||
| 1a295c18c8 | |||
| 01bf440f21 | |||
| 7acd9ea4c2 | |||
| 30b53584f8 | |||
| c1327f3186 | |||
| 7a2c5700f9 | |||
| 5b8ef5ea2d | |||
| db58c24613 | |||
| edfe05cbe3 | |||
| 6feb139a46 | |||
| b94e0f2d9d | |||
| 8806d11e3c | |||
| a1bf93342e | |||
| 5887ac7b6c | |||
| 8aef46bba1 | |||
| 2489beab59 | |||
| f8820847fa | |||
| 345c329350 | |||
| d4aeba4666 | |||
| 9fe889b2c1 | |||
| 85ac1cae9c | |||
| dfaa2e8545 | |||
| bcabb86841 | |||
| 189ebc6565 | |||
| 83adb4ae07 | |||
| 54f91c7309 | |||
| a645bb4dbc | |||
| 691861a706 | |||
| ef5d4fdc81 | |||
| afad3c5a02 | |||
| 512eb730b0 | |||
| 8c1abaebad | |||
| 3428261bff | |||
| 34cab61567 | |||
| 4419e55ee1 | |||
| bd8899a829 | |||
| 8148ceb318 | |||
| 58454ec9af | |||
| 2e7078424d | |||
| 56ecee4cba | |||
| 9222ae7a1c | |||
| 9578b92e7a | |||
| f02e172ff0 | |||
| cef579f91b | |||
| c8655bfc7f | |||
| 28a7c5dc44 | |||
| b19f7cadb8 | |||
| cb6db36adb | |||
| 0e694341b8 | |||
| 2ab1840237 | |||
| 96bdc92723 | |||
| cee0a8437f | |||
| 2b05eebad9 | |||
| 9037b6ce1c | |||
| ec861b8f81 | |||
| 767d78af27 | |||
| 8a3ef98c44 | |||
| dc66dfb537 | |||
| ae39eb4637 | |||
| 64d749be3a | |||
| 1b20e480f4 | |||
| c29c8179a1 | |||
| 2bd2a26ab9 | |||
| fb520c6607 | |||
| bad6f44122 | |||
| 280debce09 | |||
| fb77a5065b | |||
| e91b10daa3 | |||
| 7bbb991e69 | |||
| 315517f03f | |||
| 88983f2b4e | |||
| 8f6044abe3 | |||
| 8857d52f44 | |||
| 3949ab21db | |||
| 0128f98164 | |||
| 4b6a242372 | |||
| a1194adeac | |||
| 26b3b193ca | |||
| dd76ad6d14 | |||
| cfc412878f | |||
| 0426d4ee9a | |||
| 8f45544fe1 | |||
| edd33c34dc | |||
| b8bd2744ac | |||
| b2e6c2427b | |||
| 3b7abc55a4 | |||
| 29c35c524b | |||
| f3d5651ab7 | |||
| ba812e230d | |||
| cb4f2f855c | |||
| 10f9e82718 | |||
| eb0fac5989 | |||
| 82a4a13505 | |||
| f98a9075ae | |||
| 7581f15dfb | |||
| 67d7b38d79 | |||
| 6d631acce6 | |||
| 1cefc26be9 | |||
| 18e41a90b6 | |||
| d026e7f83c | |||
| 26ac3007b9 | |||
| a6fcb81594 | |||
| a5f5e7a24b | |||
| d0b756694b | |||
| 8c804832d8 | |||
| c542094196 | |||
| b4c400efea | |||
| 03f0c3a90b | |||
| da3b57a91d | |||
| 4de44623c2 | |||
| adfa97978e | |||
| 5f87eed86a | |||
| cfe12e4dd0 | |||
| 2a1508c6a5 | |||
| a15cc5971f | |||
| f961b6e771 | |||
| 7e3a5aa49e | |||
| bef09791ae | |||
| 2ee9ccf8e9 | |||
| d4509d6c5a | |||
| 19934e2a96 | |||
| 8e40d13954 | |||
| 43a98ec9ef | |||
| 8d0d8898cb | |||
| fb1c1ee4ce | |||
| 76d7019d30 | |||
| 9b9c068e7f | |||
| f719764914 | |||
| 5c7ba28b1e | |||
| a2efe8a7f6 | |||
| 126522e606 | |||
| 5eb2dd6904 | |||
| 9754f3e36b | |||
| 03950f8b0c | |||
| 0f2060fc14 | |||
| 11abbf0179 | |||
| 5b207a2b9d | |||
| 62aaf6100e | |||
| c380537520 | |||
| a79e612693 | |||
| 6e99bc97fd | |||
| 4ad9b274e8 | |||
| 9c520d1b71 | |||
| eb06c85013 | |||
| b07d5d7386 | |||
| f82d06f3e7 | |||
| 4ca25b6417 | |||
| 2d6ff49629 | |||
| 15b3f17d1d | |||
| edfbbb805f | |||
| 92aecf9abf | |||
| d224e2c521 | |||
| 3515d40fcb | |||
| bc46054412 | |||
| 52bc8f3fbe | |||
| b91d1953a4 | |||
| ccefcd4588 | |||
| eda18a8ff2 | |||
| 84128432e3 | |||
| 7480aed4d1 | |||
| 0aa1a1b9b7 | |||
| 97ed8ad20a | |||
| 1ba4845f6c | |||
| a7e1872d10 | |||
| 02c6da146e | |||
| c1fadac944 | |||
| eef17b3067 | |||
| 21f3a57e6e | |||
| bab95d14f4 | |||
| e7d7e43ccf | |||
| 22c631ec43 | |||
| 0d75c9b664 | |||
| 8726129b96 | |||
| 6b6965bbbb | |||
| 721d991c5e | |||
| c06eb79cba | |||
| fbed4d34cc | |||
| 363aa80fe4 | |||
| a3007b01ee | |||
| ffe73b9061 | |||
| 452c50c31b | |||
| 4d6a1d5f16 | |||
| 276e3cc3dd | |||
| c11bffef22 | |||
| f8662e973e | |||
| d7095bef47 | |||
| 6ba6192684 | |||
| ee520073f0 | |||
| f2dff2a4d8 | |||
| e83b09fd63 | |||
| 5d1c89438c | |||
| bb4b5924d1 | |||
| 84403287a1 | |||
| e4f22096ed | |||
| 8b6ea11d46 | |||
| 2d42578378 | |||
| 085656a85b | |||
| d91d88855e | |||
| da7afec9d7 | |||
| 8bc6f8e1df | |||
| df8bce4277 | |||
| 2e4cb6d042 | |||
| 3ce085ea18 | |||
| 8f0640810b | |||
| 7d8596570a | |||
| 31eb8f083d | |||
| 9ff543dc14 | |||
| 92f22faf2f | |||
| 6b9177e818 | |||
| 7bf89c58d3 | |||
| b7fa2d26a9 | |||
| abaaeddaaf | |||
| bc13a58a14 | |||
| 1b6f8e7c59 | |||
| 0ae9a1f1b8 | |||
| 1a6f2ea7ad | |||
| e219116609 | |||
| 9223305613 | |||
| 3cab4c4f47 | |||
| 9237882437 | |||
| c35869f8ee | |||
| 933ef9cd6c | |||
| aa9e2da3a3 | |||
| e94dc5a803 | |||
| f50d4deb16 | |||
| 1e5fa3d053 | |||
| a61dda69d1 | |||
| aba7b58dd4 | |||
| 8771c7ce27 | |||
| ef234747bc | |||
| 7150622e1d | |||
| c6f28462eb | |||
| dbe7c74a9c | |||
| 6e484ee9a1 | |||
| 6c64444a98 | |||
| 35f8b46e6c | |||
| b9ec070993 | |||
| ed3d327c82 | |||
| 52f2a54e0b | |||
| 59f7f8d4ad | |||
| 7ff48ed3d7 | |||
| c696b8c50e | |||
| 1f9f528554 | |||
| f4844eb428 | |||
| 2270f9602f | |||
| 2dd5453365 | |||
| 236876a043 | |||
| 14b458860c | |||
| 2c8d16b27f | |||
| f82dbd64a5 | |||
| a5c1fb5bae | |||
| 62c0d9d75c | |||
| 2b3e2d8c1b | |||
| 48ffadaaa2 | |||
| c483f4925d |
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
## 🚀 Identität & Arbeitsmodus (Chamäleon-Modus)
|
||||
Du bist ein hochqualifizierter KI-Assistent für das Softwareprojekt "Meldestelle" von Stefan.
|
||||
Ich weise dir in meinen Prompts Aufgaben zu. Nimm sofort die entsprechende Rolle an, beginne deine Antwort zwingend mit dem passenden Badge und passe dein Vokabular an:
|
||||
|
||||
* 🏗️ **[Lead Architect]:** System-Design, Gradle-Build-Logik, Modulstruktur.
|
||||
* 📜 **[Rulebook Expert]:** Validiert Business-Rules gegen das ÖTO/FEI Regelwerk.
|
||||
* 👷 **[Backend Developer]:** Kotlin & Spring Boot Experte.
|
||||
* 🎨 **[Frontend Expert]:** KMP & Compose Desktop Spezialist.
|
||||
* 🐧 **[DevOps Engineer]:** Infrastruktur (Docker, CI/CD, Proxmox).
|
||||
|
||||
**Arbeitsanweisung:** Bearbeite pro Antwort immer nur EINE fachliche Aufgabe.
|
||||
|
||||
|
||||
|
||||
## 🏗️ Projekt-Strategie (Reality-Reset)
|
||||
1. **Desktop-First & Offline-First:** Das Primärziel ist eine autarke Compose Desktop App (KMP). Sie muss auf Turnieren ohne Internet funktionieren (lokale Persistenz).
|
||||
2. **Optionales Backend:** Ein Spring Boot Stack (PostgreSQL, Valkey, Keycloak) wird nur für Multi-Tenant-Verwaltung, Registrierung und P2P-Sync genutzt.
|
||||
3. **Domain-Driven Design (DDD):** Die absolute Business-Hierarchie lautet: Veranstaltung -> Turnier -> Bewerb/Abteilung.
|
||||
4. **Der System-Akteur:** Der primäre "Actor" in allen Use-Cases ist *nicht* der Veranstalter, sondern zwingend die Person, die die Meldestelle betreut (Actor = Meldestelle).
|
||||
|
||||
|
||||
|
||||
## 🛠️ Der verbindliche Tech-Stack
|
||||
Generiere Code ausschließlich für diese exakten Versionen und Paradigmen:
|
||||
* **Frontend (KMP):** Kotlin 2.3.21, Compose Multiplatform 1.10.3, Ktor Client 3.4.1, SQLDelight.
|
||||
* **Backend:** Spring Boot 3.5.9 (JDK 25), Ktor Server (wo spezifiziert), Exposed 1.1.1.
|
||||
* **Infrastruktur:** Gitea (CI/CD), Docker, Pangolin Tunnel. (KEIN GitHub, KEIN Cloudflare).
|
||||
|
||||
|
||||
|
||||
## 👁️ Anti-Halluzinations-Protokoll
|
||||
Du bist an strikte, evidenzbasierte Entwicklung gebunden:
|
||||
1. **Kein "Erledigt" ohne Beweis:** Ein Task ist erst abgeschlossen, wenn Test-Logs oder ein Build vorliegen.
|
||||
2. **Verifikation ausstehend:** Generierter, ungetesteter Code muss diesen Vermerk zwingend tragen.
|
||||
3. **Fakten-Check:** Wenn du den Code nicht im Kontext hast (z.B. eine spezifische Gradle-Datei), fordere sie aktiv vom User an, anstatt blind zu raten.
|
||||
|
||||
|
||||
|
||||
## 🛡️ DSGVO & Lokale Ausführung (Nolik-Spezifika)
|
||||
* Dein Name ist "Nolik". Du bist ein lokal gehosteter, datenschutzkonformer Senior-Architekt auf dem Server "Simka" (Proxmox VM 101).
|
||||
* **Datensouveränität:** Du bist der Hüter der lokalen Daten. Generiere niemals Code, der Telemetrie, Tracking oder Logging an externe Cloud-Anbieter sendet.
|
||||
@@ -0,0 +1,11 @@
|
||||
## 🚀 Identität & Arbeitsmodus (Chamäleon-Modus)
|
||||
Du bist ein hochqualifizierter KI-Assistent für das Softwareprojekt "Meldestelle" von Stefan.
|
||||
Ich weise dir in meinen Prompts Aufgaben zu. Nimm sofort die entsprechende Rolle an, beginne deine Antwort zwingend mit dem passenden Badge und passe dein Vokabular an:
|
||||
|
||||
* 🏗️ **[Lead Architect]:** System-Design, Gradle-Build-Logik, Modulstruktur.
|
||||
* 📜 **[Rulebook Expert]:** Validiert Business-Rules gegen das ÖTO/FEI Regelwerk.
|
||||
* 👷 **[Backend Developer]:** Kotlin & Spring Boot Experte.
|
||||
* 🎨 **[Frontend Expert]:** KMP & Compose Desktop Spezialist.
|
||||
* 🐧 **[DevOps Engineer]:** Infrastruktur (Docker, CI/CD, Proxmox).
|
||||
|
||||
**Arbeitsanweisung:** Bearbeite pro Antwort immer nur EINE fachliche Aufgabe.
|
||||
@@ -0,0 +1,3 @@
|
||||
## ⚙️ Provider-Spezifika (Google Gemini / Web-Meta-Modus)
|
||||
* Du agierst als "Gemini" über die Web-Oberfläche. Deine primäre Aufgabe ist die strategische Meta-Ebene, Architektur-Analyse, Review von CI/CD-Pipelines und das Sparring bei komplexen Refactoring-Plänen.
|
||||
* **Antwort-Stil:** Antworte prägnant, strukturiert und nutze das bereitgestellte Formatierungstoolkit (Markdown, klare Hierarchien, Code-Blöcke). Vermeide unnötige Floskeln und komm direkt auf den technischen Punkt.
|
||||
@@ -0,0 +1,4 @@
|
||||
## ⚙️ Provider-Spezifika (JetBrains Junie / IDE-Modus)
|
||||
* Dein Name ist "Junie". Du arbeitest als hochintegrierter KI-Assistent direkt innerhalb von IntelliJ IDEA.
|
||||
* **Kontext-Fokus:** Nutze die lokalen Projektdateien, Indizes und das Git-Log intensiv. Wenn Refactorings oder Code-Generierungen anstehen, achte penibel darauf, dass bestehende Datei-Imports (Kotlin-Packages) nicht zerschossen werden.
|
||||
* **Generierungs-Gate:** Halte dich strikt an die im Projekt hinterlegten Formatierungsregeln für Detekt und Ktlint.
|
||||
@@ -0,0 +1,3 @@
|
||||
## 🛡️ DSGVO & Lokale Ausführung (Nolik-Spezifika)
|
||||
* Dein Name ist "Nolik". Du bist ein lokal gehosteter, datenschutzkonformer Senior-Architekt auf dem Server "Simka" (Proxmox VM 101).
|
||||
* **Datensouveränität:** Du bist der Hüter der lokalen Daten. Generiere niemals Code, der Telemetrie, Tracking oder Logging an externe Cloud-Anbieter sendet.
|
||||
@@ -0,0 +1,5 @@
|
||||
## 🏗️ Projekt-Strategie (Reality-Reset)
|
||||
1. **Desktop-First & Offline-First:** Das Primärziel ist eine autarke Compose Desktop App (KMP). Sie muss auf Turnieren ohne Internet funktionieren (lokale Persistenz).
|
||||
2. **Optionales Backend:** Ein Spring Boot Stack (PostgreSQL, Valkey, Keycloak) wird nur für Multi-Tenant-Verwaltung, Registrierung und P2P-Sync genutzt.
|
||||
3. **Domain-Driven Design (DDD):** Die absolute Business-Hierarchie lautet: Veranstaltung -> Turnier -> Bewerb/Abteilung.
|
||||
4. **Der System-Akteur:** Der primäre "Actor" in allen Use-Cases ist *nicht* der Veranstalter, sondern zwingend die Person, die die Meldestelle betreut (Actor = Meldestelle).
|
||||
@@ -0,0 +1,5 @@
|
||||
## 🛠️ Der verbindliche Tech-Stack
|
||||
Generiere Code ausschließlich für diese exakten Versionen und Paradigmen:
|
||||
* **Frontend (KMP):** Kotlin 2.3.21, Compose Multiplatform 1.10.3, Ktor Client 3.4.1, SQLDelight.
|
||||
* **Backend:** Spring Boot 3.5.9 (JDK 25), Ktor Server (wo spezifiziert), Exposed 1.1.1.
|
||||
* **Infrastruktur:** Gitea (CI/CD), Docker, Pangolin Tunnel. (KEIN GitHub, KEIN Cloudflare).
|
||||
@@ -0,0 +1,5 @@
|
||||
## 👁️ Anti-Halluzinations-Protokoll
|
||||
Du bist an strikte, evidenzbasierte Entwicklung gebunden:
|
||||
1. **Kein "Erledigt" ohne Beweis:** Ein Task ist erst abgeschlossen, wenn Test-Logs oder ein Build vorliegen.
|
||||
2. **Verifikation ausstehend:** Generierter, ungetesteter Code muss diesen Vermerk zwingend tragen.
|
||||
3. **Fakten-Check:** Wenn du den Code nicht im Kontext hast (z.B. eine spezifische Gradle-Datei), fordere sie aktiv vom User an, anstatt blind zu raten.
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Nutze Junies robuste Pfad-Ermittlung
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
|
||||
REPO_ROOT="$(resolve_repo_root)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
AI_DIR=".ai"
|
||||
DIST_DIR="$AI_DIR/dist"
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
echo "[INFO] Generiere System-Prompts aus den Core-Rules..."
|
||||
|
||||
for PROVIDER_DIR in "$AI_DIR/providers/"*; do
|
||||
if [ -d "$PROVIDER_DIR" ]; then
|
||||
PROVIDER_NAME=$(basename "$PROVIDER_DIR")
|
||||
OUTPUT_FILE="$DIST_DIR/${PROVIDER_NAME}-system-prompt.md"
|
||||
|
||||
echo "-> Baue Prompt für: $PROVIDER_NAME"
|
||||
|
||||
# 1. Basis-Identität schreiben
|
||||
cat "$AI_DIR/prompts/system/base.md" > "$OUTPUT_FILE"
|
||||
echo -e "\n\n" >> "$OUTPUT_FILE"
|
||||
|
||||
# 2. Alle globalen Regeln anhängen
|
||||
for RULE_FILE in "$AI_DIR/rules/"*.md; do
|
||||
if [ -f "$RULE_FILE" ]; then
|
||||
cat "$RULE_FILE" >> "$OUTPUT_FILE"
|
||||
echo -e "\n\n" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. Provider-Spezifika anhängen
|
||||
if [ -f "$PROVIDER_DIR/overlay.md" ]; then
|
||||
cat "$PROVIDER_DIR/overlay.md" >> "$OUTPUT_FILE"
|
||||
fi
|
||||
|
||||
echo "[OK] $OUTPUT_FILE erfolgreich erstellt."
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
REPO_ROOT="$(resolve_repo_root)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# check-docs-drift.sh
|
||||
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
|
||||
# - Kein Guidelines-System mehr.
|
||||
# - Single Source of Truth: `docs/`
|
||||
|
||||
err=0
|
||||
|
||||
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
|
||||
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
|
||||
|
||||
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
|
||||
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
|
||||
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
|
||||
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
|
||||
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
|
||||
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
|
||||
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
|
||||
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
|
||||
|
||||
exit $err
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Common helpers for AI guardrail scripts
|
||||
|
||||
# Robustly resolve the repository root directory.
|
||||
# Strategy: prefer Git; fallback to marker search upwards; last resort: current dir.
|
||||
resolve_repo_root() {
|
||||
local start
|
||||
start="${1:-$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)}"
|
||||
if command -v git >/dev/null 2>&1; then
|
||||
if git -C "$start" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
git -C "$start" rev-parse --show-toplevel
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
local dir
|
||||
dir="$(cd "$start" && pwd)"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -f "$dir/gradlew" ] || [ -f "$dir/settings.gradle.kts" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
pwd
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
REPO_ROOT="$(resolve_repo_root)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
mkdir -p build/diagrams
|
||||
shopt -s nullglob
|
||||
for f in docs/architecture/c4/*.puml; do
|
||||
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
|
||||
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
|
||||
done
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
# shellcheck disable=SC1091
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
|
||||
REPO_ROOT="$(resolve_repo_root)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
QUICK_MODE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--quick)
|
||||
QUICK_MODE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat << 'EOF'
|
||||
Docs Link-Validierung
|
||||
|
||||
USAGE:
|
||||
./.ai/scripts/validate-links.sh [--quick]
|
||||
|
||||
BESCHREIBUNG:
|
||||
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
|
||||
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
|
||||
|
||||
OPTIONEN:
|
||||
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] Unbekannter Parameter: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
root = Path.cwd()
|
||||
docs_dir = root / "docs"
|
||||
|
||||
if not docs_dir.is_dir():
|
||||
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Veraltete Pfad-Prüfungen wurden entfernt; Fokus auf Link-Integrität.
|
||||
FORBIDDEN_SUBSTRINGS = []
|
||||
|
||||
md_files = sorted(docs_dir.rglob("*.md"))
|
||||
|
||||
link_pattern = re.compile(r"\]\(([^)]+)\)")
|
||||
|
||||
errors = 0
|
||||
|
||||
def is_external(target: str) -> bool:
|
||||
t = target.lower()
|
||||
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
|
||||
|
||||
def strip_fragment_and_query(target: str) -> str:
|
||||
target = target.split("#", 1)[0]
|
||||
target = target.split("?", 1)[0]
|
||||
return target
|
||||
|
||||
for f in md_files:
|
||||
text = f.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
for forbidden in FORBIDDEN_SUBSTRINGS:
|
||||
if forbidden in text:
|
||||
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
|
||||
errors += 1
|
||||
|
||||
for match in link_pattern.finditer(text):
|
||||
target = match.group(1).strip()
|
||||
|
||||
if not target:
|
||||
continue
|
||||
if is_external(target):
|
||||
continue
|
||||
if target.startswith("#"):
|
||||
continue
|
||||
|
||||
if target.startswith("<") and target.endswith(">"):
|
||||
target = target[1:-1]
|
||||
|
||||
target = unquote(strip_fragment_and_query(target))
|
||||
|
||||
if target.startswith("/"):
|
||||
continue
|
||||
|
||||
if ":" in target.split("/", 1)[0]:
|
||||
# z.B. "vscode:..."
|
||||
continue
|
||||
|
||||
resolved = (f.parent / target).resolve()
|
||||
|
||||
try:
|
||||
resolved.relative_to(root.resolve())
|
||||
except ValueError:
|
||||
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if resolved.is_dir():
|
||||
if not (resolved / "README.md").is_file():
|
||||
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if not resolved.exists():
|
||||
print(f"[ERROR] Broken link: {f} -> {target}")
|
||||
errors += 1
|
||||
|
||||
if errors:
|
||||
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
|
||||
PY
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
apply: always
|
||||
---
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ secrets/
|
||||
# ===================================================================
|
||||
TODO*.md
|
||||
NOTES*.md
|
||||
**/.junie/
|
||||
.junie/
|
||||
|
||||
# ===================================================================
|
||||
# Keep essential files (override exclusions)
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# ==========================================
|
||||
# Meldestelle – Docker Compose Environment
|
||||
# Single Source of Truth (SSoT)
|
||||
# ==========================================
|
||||
# WARNING: This file contains secrets (passwords).
|
||||
# Do NOT commit this file to version control if it contains production secrets.
|
||||
|
||||
# --- PROJECT ---
|
||||
PROJECT_NAME=meldestelle
|
||||
|
||||
# --- BACKUP ---
|
||||
BACKUP_DIR=/home/stefan/backups/meldestelle
|
||||
BACKUP_RETENTION_DAYS=7
|
||||
|
||||
# Docker build versions (optional overrides)
|
||||
DOCKER_VERSION=1.0.0-SNAPSHOT
|
||||
DOCKER_REGISTRY=git.mo-code.at/mocode-software/meldestelle
|
||||
DOCKER_BUILD_DATE=2026-03-16T12:00:00Z
|
||||
DOCKER_GRADLE_VERSION=9.3.1
|
||||
DOCKER_JAVA_VERSION=25
|
||||
DOCKER_NODE_VERSION=24.12.0
|
||||
DOCKER_NGINX_VERSION=1.28.0-alpine
|
||||
DOCKER_CADDY_VERSION=2.11-alpine
|
||||
|
||||
# JVM Power Flags (Lokal leer lassen, da Intel/AMD Architektur)
|
||||
JVM_OPTS_ARM64=
|
||||
|
||||
# --- POSTGRES ---
|
||||
POSTGRES_IMAGE=postgres:16-alpine
|
||||
POSTGRES_SHARED_BUFFERS=256MB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=768MB
|
||||
POSTGRES_USER=pg-user
|
||||
POSTGRES_PASSWORD=pg-password
|
||||
POSTGRES_DB=pg-meldestelle-db
|
||||
POSTGRES_PORT=5432:5432
|
||||
POSTGRES_DB_URL=jdbc:postgresql://postgres:5432/pg-meldestelle-db
|
||||
|
||||
# --- VALKEY (formerly Redis) ---
|
||||
VALKEY_IMAGE=valkey/valkey:9-alpine
|
||||
VALKEY_PASSWORD=valkey-password
|
||||
VALKEY_PORT=6379:6379
|
||||
VALKEY_SERVER_HOSTNAME=valkey
|
||||
VALKEY_SERVER_PORT=6379
|
||||
VALKEY_SERVER_CONNECT_TIMEOUT=5s
|
||||
VALKEY_POLICY=allkeys-lru
|
||||
VALKEY_MAX_MEMORY=256MB
|
||||
SPRING_DATA_VALKEY_HOST=localhost
|
||||
SPRING_DATA_VALKEY_PORT=6379
|
||||
SPRING_DATA_VALKEY_PASSWORD=valkey-password
|
||||
|
||||
# --- KEYCLOAK ---
|
||||
KEYCLOAK_IMAGE_TAG=latest
|
||||
KC_HEAP_MIN=512M
|
||||
KC_HEAP_MAX=1024M
|
||||
# Lokale Entwicklung: start-dev (kein Pre-Build nötig, kein --optimized)
|
||||
# Server/Produktion: start --optimized --import-realm (nutzt das pre-built Registry-Image)
|
||||
KC_COMMAND=start-dev --import-realm
|
||||
# System-Admin (Master Console)
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME=kc-admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD=kc-password
|
||||
# Fach-Admin User Passwort (wird im Realm Import genutzt)
|
||||
# Hinweis: Wenn du das hier änderst, müsstest du auch die JSON anpassen
|
||||
# oder dort eine Variable nutzen.
|
||||
|
||||
KC_DB=postgres
|
||||
KC_DB_SCHEMA=keycloak
|
||||
KC_DB_URL=jdbc:postgresql://postgres:5432/pg-meldestelle-db
|
||||
KC_DB_USERNAME=pg-user
|
||||
KC_DB_PASSWORD=meldestelle
|
||||
|
||||
# Lokal: localhost | Server: echte IP oder Domain (z.B. 10.0.0.50 oder auth.meldestelle.at)
|
||||
# WICHTIG: Nur den Hostnamen angeben, OHNE Port (Keycloak 26.x hostname v2)
|
||||
KC_HOSTNAME=localhost
|
||||
# false = Zugriff über beliebige Hostnamen erlaubt (nötig ohne TLS / für HTTP-Betrieb)
|
||||
KC_HOSTNAME_STRICT=false
|
||||
KC_HOSTNAME_STRICT_HTTPS=false
|
||||
KC_PORT=8180:8080
|
||||
KC_MANAGEMENT_PORT=9000:9000
|
||||
|
||||
KC_HTTP_ENABLE=true
|
||||
|
||||
KC_API_GATEWAY_CLIENT_SECRET=K5RqonwVOaxPKaXVH4mbthSRbjRh5tOK
|
||||
# KC_POSTMAN_CLIENT_SECRET=postman-secret-123
|
||||
# KC_BOOTSTRAP_ADMIN_PASSWORD=Admin#1234
|
||||
KC_FRONTEND_URL=http://localhost:8180
|
||||
KC_PROXY_HEADERS=xforwarded
|
||||
|
||||
# --- KEYCLOAK TOKEN VALIDATION ---
|
||||
# Public Issuer URI (must match the token issuer from browser/postman)
|
||||
# Lokal: http://localhost:8180 | Produktion: http://10.0.0.50:8180
|
||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=http://localhost:8180/realms/meldestelle
|
||||
# Internal JWK Set URI (for service-to-service communication within Docker)
|
||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI=http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs
|
||||
|
||||
# --- CONSUL ---
|
||||
CONSUL_IMAGE=hashicorp/consul:1.22.1
|
||||
CONSUL_PORT=8500:8500
|
||||
CONSUL_UDP_PORT=8600:8600/udp
|
||||
CONSUL_HOST=consul
|
||||
CONSUL_HTTP_PORT=8500
|
||||
SPRING_CLOUD_CONSUL_HOST=consul
|
||||
SPRING_CLOUD_CONSUL_PORT=8500
|
||||
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME=api-gateway
|
||||
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS=true
|
||||
|
||||
# --- Zipkin ---
|
||||
ZIPKIN_IMAGE=openzipkin/zipkin:3
|
||||
ZIPKIN_MIN_HEAP=256M
|
||||
ZIPKIN_MAX_HEAP=512M
|
||||
ZIPKIN_PORT=9411:9411
|
||||
ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
|
||||
ZIPKIN_SAMPLING_PROBABILITY=1.0
|
||||
|
||||
# --- Mailpit ---
|
||||
MAILPIT_IMAGE=axllent/mailpit:v1.29
|
||||
MAILPIT_WEB_PORT=8025:8025
|
||||
MAILPIT_SMTP_PORT=1025:1025
|
||||
|
||||
# --- PGADMIN ---
|
||||
PGADMIN_IMAGE=dpage/pgadmin4:8
|
||||
PGADMIN_EMAIL=meldestelle@mo-code.at
|
||||
PGADMIN_PASSWORD=pgadmin
|
||||
PGADMIN_PORT=8888:80
|
||||
|
||||
# --- POSTGRES-EXPORTER ---
|
||||
POSTGRES_EXPORTER_IMAGE=prometheuscommunity/postgres-exporter:v0.18.0
|
||||
|
||||
# --- ALERTMANAGER ---
|
||||
ALERTMANAGER_IMAGE=prom/alertmanager:v0.29.0
|
||||
ALERTMANAGER_PORT=9093:9093
|
||||
|
||||
# --- PROMETHEUS ---
|
||||
PROMETHEUS_IMAGE=prom/prometheus:v3.7.3
|
||||
PROMETHEUS_PORT=9090:9090
|
||||
|
||||
# --- GRAFANA ---
|
||||
GF_IMAGE=grafana/grafana:12.3
|
||||
GF_ADMIN_USER=gf-admin
|
||||
GF_ADMIN_PASSWORD=gf-password
|
||||
GF_PORT=3000:3000
|
||||
|
||||
# --- API-GATEWAY ---
|
||||
GATEWAY_PORT=8081:8081
|
||||
GATEWAY_DEBUG_PORT=5005:5005
|
||||
GATEWAY_SERVER_PORT=8081
|
||||
GATEWAY_SPRING_PROFILES_ACTIVE=docker
|
||||
GATEWAY_DEBUG=true
|
||||
GATEWAY_SERVICE_NAME=api-gateway
|
||||
GATEWAY_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- PING-SERVICE ---
|
||||
PING_SPRING_PROFILES_ACTIVE=docker
|
||||
PING_PORT=8082:8082
|
||||
PING_DEBUG_PORT=5006:5006
|
||||
PING_SERVER_PORT=8082
|
||||
PING_DEBUG=true
|
||||
PING_SERVICE_NAME=ping-service
|
||||
PING_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- MAIL-SERVICE ---
|
||||
MAIL_PORT=8083:8083
|
||||
MAIL_DEBUG_PORT=5014:5014
|
||||
MAIL_SERVER_PORT=8083
|
||||
MAIL_SERVICE_URL=http://10.0.0.50:8092
|
||||
|
||||
MAIL_SPRING_PROFILES_ACTIVE=docker
|
||||
MAIL_DEBUG=true
|
||||
MAIL_SERVICE_NAME=mail-service
|
||||
MAIL_CONSUL_PREFER_IP=true
|
||||
MAIL_SMTP_HOST=smtp.world4you.com
|
||||
MAIL_SMTP_PORT=587
|
||||
MAIL_SMTP_USER=online-nennen@mo-code.at
|
||||
MAIL_SMTP_PASSWORD=Mogi#2reiten
|
||||
MAIL_SMTP_AUTH=true
|
||||
MAIL_SMTP_STARTTLS=true
|
||||
|
||||
SPRING_MAIL_HOST=smtp.world4you.com
|
||||
SPRING_MAIL_PORT=587
|
||||
SPRING_MAIL_USERNAME=online-nennen@mo-code.at
|
||||
SPRING_MAIL_PASSWORD=Mogi#2reiten
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||
SPRING_CLOUD_CONSUL_DISCOVERY_ENABLED=false
|
||||
SPRING_CLOUD_CONSUL_ENABLED=false
|
||||
MAIL_POLLING_ENABLED=false
|
||||
|
||||
|
||||
# --- MASTERDATA-SERVICE ---
|
||||
MASTERDATA_PORT=8086:8086
|
||||
MASTERDATA_DEBUG_PORT=5007:5007
|
||||
MASTERDATA_SERVER_PORT=8086
|
||||
MASTERDATA_KTOR_PORT=8091
|
||||
MASTERDATA_SPRING_PROFILES_ACTIVE=docker
|
||||
MASTERDATA_DEBUG=true
|
||||
MASTERDATA_SERVICE_NAME=masterdata-service
|
||||
MASTERDATA_CONSUL_PREFER_IP=true
|
||||
MASTERDATA_SERVICE_HOSTNAME=masterdata-service
|
||||
|
||||
# --- EVENTS-SERVICE ---
|
||||
EVENTS_PORT=8085:8085
|
||||
EVENTS_DEBUG_PORT=5008:5008
|
||||
EVENTS_SERVER_PORT=8085
|
||||
EVENTS_SPRING_PROFILES_ACTIVE=docker
|
||||
EVENTS_DEBUG=true
|
||||
EVENTS_SERVICE_NAME=events-service
|
||||
EVENTS_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- ZNS-IMPORT-SERVICE ---
|
||||
ZNS_IMPORT_PORT=8095:8095
|
||||
ZNS_IMPORT_DEBUG_PORT=5009:5009
|
||||
ZNS_IMPORT_SERVER_PORT=8095
|
||||
ZNS_IMPORT_SPRING_PROFILES_ACTIVE=docker
|
||||
ZNS_IMPORT_DEBUG=true
|
||||
ZNS_IMPORT_SERVICE_NAME=zns-import-service
|
||||
ZNS_IMPORT_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- RESULTS-SERVICE ---
|
||||
RESULTS_PORT=8088:8088
|
||||
RESULTS_DEBUG_PORT=5010:5010
|
||||
RESULTS_SERVER_PORT=8088
|
||||
RESULTS_SPRING_PROFILES_ACTIVE=docker
|
||||
RESULTS_DEBUG=true
|
||||
RESULTS_SERVICE_NAME=results-service
|
||||
RESULTS_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- BILLING-SERVICE ---
|
||||
BILLING_PORT=8087:8087
|
||||
BILLING_DEBUG_PORT=5012:5012
|
||||
BILLING_SERVER_PORT=8087
|
||||
BILLING_SPRING_PROFILES_ACTIVE=docker
|
||||
BILLING_DEBUG=true
|
||||
BILLING_SERVICE_NAME=billing-service
|
||||
BILLING_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- SCHEDULING-SERVICE ---
|
||||
SCHEDULING_PORT=8084:8084
|
||||
SCHEDULING_DEBUG_PORT=5013:5013
|
||||
SCHEDULING_SERVER_PORT=8084
|
||||
SCHEDULING_SPRING_PROFILES_ACTIVE=docker
|
||||
SCHEDULING_DEBUG=true
|
||||
SCHEDULING_SERVICE_NAME=scheduling-service
|
||||
SCHEDULING_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- SERIES-SERVICE ---
|
||||
SERIES_PORT=8089:8089
|
||||
SERIES_DEBUG_PORT=5011:5011
|
||||
SERIES_SERVER_PORT=8089
|
||||
SERIES_SPRING_PROFILES_ACTIVE=docker
|
||||
SERIES_DEBUG=true
|
||||
SERIES_SERVICE_NAME=series-service
|
||||
SERIES_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- WEB-APP ---
|
||||
CADDY_VERSION=2.11-alpine
|
||||
WEB_APP_PORT=8080:80
|
||||
WEB_BUILD_PROFILE=dev
|
||||
# Lokal: http://localhost:8081 | Produktion: http://10.0.0.50:8081
|
||||
WEB_APP_API_URL=http://localhost:8081
|
||||
WEB_APP_KEYCLOAK_URL=http://auth.mo-code.at
|
||||
|
||||
# --- DESKTOP-APP ---
|
||||
DESKTOP_APP_VNC_PORT=5901:5901
|
||||
DESKTOP_APP_NOVNC_PORT=6080:6080
|
||||
@@ -120,6 +120,13 @@ MAILPIT_IMAGE=axllent/mailpit:v1.29
|
||||
MAILPIT_WEB_PORT=8025:8025
|
||||
MAILPIT_SMTP_PORT=1025:1025
|
||||
|
||||
# --- SPRING MAIL CONFIG (Lokal / Mailpit) ---
|
||||
# Für lokale Entwicklung mit Mailpit (Docker Compose)
|
||||
SPRING_MAIL_HOST=mailpit
|
||||
SPRING_MAIL_PORT=1025
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||
|
||||
# --- PGADMIN ---
|
||||
PGADMIN_IMAGE=dpage/pgadmin4:8
|
||||
PGADMIN_EMAIL=meldestelle@mo-code.at
|
||||
@@ -149,6 +156,8 @@ GATEWAY_DEBUG_PORT=5005:5005
|
||||
GATEWAY_SERVER_PORT=8081
|
||||
GATEWAY_SPRING_PROFILES_ACTIVE=docker
|
||||
GATEWAY_DEBUG=true
|
||||
GATEWAY_SERVICE_NAME=api-gateway
|
||||
GATEWAY_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- PING-SERVICE ---
|
||||
PING_SPRING_PROFILES_ACTIVE=docker
|
||||
@@ -159,6 +168,84 @@ PING_DEBUG=true
|
||||
PING_SERVICE_NAME=ping-service
|
||||
PING_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- MAIL-SERVICE ---
|
||||
MAIL_PORT=8083:8083
|
||||
MAIL_DEBUG_PORT=5014:5014
|
||||
MAIL_SERVER_PORT=8083
|
||||
MAIL_SPRING_PROFILES_ACTIVE=docker
|
||||
MAIL_DEBUG=true
|
||||
MAIL_SERVICE_NAME=mail-service
|
||||
MAIL_CONSUL_PREFER_IP=true
|
||||
MAIL_SMTP_HOST=smtp.world4you.com
|
||||
MAIL_SMTP_PORT=587
|
||||
MAIL_SMTP_USER=online-nennen@mo-code.at
|
||||
MAIL_SMTP_PASSWORD=<DEIN_WORLD4YOU_PASSWORT>
|
||||
MAIL_SMTP_AUTH=true
|
||||
MAIL_SMTP_STARTTLS=true
|
||||
|
||||
# --- MASTERDATA-SERVICE ---
|
||||
MASTERDATA_PORT=8086:8086
|
||||
MASTERDATA_DEBUG_PORT=5007:5007
|
||||
MASTERDATA_SERVER_PORT=8086
|
||||
MASTERDATA_SPRING_PROFILES_ACTIVE=docker
|
||||
MASTERDATA_DEBUG=true
|
||||
MASTERDATA_SERVICE_NAME=masterdata-service
|
||||
MASTERDATA_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- EVENTS-SERVICE ---
|
||||
EVENTS_PORT=8085:8085
|
||||
EVENTS_DEBUG_PORT=5008:5008
|
||||
EVENTS_SERVER_PORT=8085
|
||||
EVENTS_SPRING_PROFILES_ACTIVE=docker
|
||||
EVENTS_DEBUG=true
|
||||
EVENTS_SERVICE_NAME=events-service
|
||||
EVENTS_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- ZNS-IMPORT-SERVICE ---
|
||||
ZNS_IMPORT_PORT=8095:8095
|
||||
ZNS_IMPORT_DEBUG_PORT=5009:5009
|
||||
ZNS_IMPORT_SERVER_PORT=8095
|
||||
ZNS_IMPORT_SPRING_PROFILES_ACTIVE=docker
|
||||
ZNS_IMPORT_DEBUG=true
|
||||
ZNS_IMPORT_SERVICE_NAME=zns-import-service
|
||||
ZNS_IMPORT_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- RESULTS-SERVICE ---
|
||||
RESULTS_PORT=8088:8088
|
||||
RESULTS_DEBUG_PORT=5010:5010
|
||||
RESULTS_SERVER_PORT=8088
|
||||
RESULTS_SPRING_PROFILES_ACTIVE=docker
|
||||
RESULTS_DEBUG=true
|
||||
RESULTS_SERVICE_NAME=results-service
|
||||
RESULTS_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- BILLING-SERVICE ---
|
||||
BILLING_PORT=8087:8087
|
||||
BILLING_DEBUG_PORT=5012:5012
|
||||
BILLING_SERVER_PORT=8087
|
||||
BILLING_SPRING_PROFILES_ACTIVE=docker
|
||||
BILLING_DEBUG=true
|
||||
BILLING_SERVICE_NAME=billing-service
|
||||
BILLING_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- SCHEDULING-SERVICE ---
|
||||
SCHEDULING_PORT=8084:8084
|
||||
SCHEDULING_DEBUG_PORT=5013:5013
|
||||
SCHEDULING_SERVER_PORT=8084
|
||||
SCHEDULING_SPRING_PROFILES_ACTIVE=docker
|
||||
SCHEDULING_DEBUG=true
|
||||
SCHEDULING_SERVICE_NAME=scheduling-service
|
||||
SCHEDULING_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- SERIES-SERVICE ---
|
||||
SERIES_PORT=8089:8089
|
||||
SERIES_DEBUG_PORT=5011:5011
|
||||
SERIES_SERVER_PORT=8089
|
||||
SERIES_SPRING_PROFILES_ACTIVE=docker
|
||||
SERIES_DEBUG=true
|
||||
SERIES_SERVICE_NAME=series-service
|
||||
SERIES_CONSUL_PREFER_IP=true
|
||||
|
||||
# --- WEB-APP ---
|
||||
WEB_APP_PORT=4000:4000
|
||||
# URL für API-Zugriffe vom Browser (Public URL via Pangolin)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Desktop CI — Headless Tests & Build
|
||||
|
||||
on:
|
||||
# Nur ausführen, wenn explizit das Desktop-Shell-Modul geändert wurde
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'frontend/shells/meldestelle-desktop/**'
|
||||
- '.gitea/workflows/desktop-tests.yml'
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'frontend/shells/meldestelle-desktop/**'
|
||||
# Manuell startbar, falls benötigt
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
desktop-tests:
|
||||
# Komplett deaktivierbar über Repo-Variable: Settings → Variables → DESKTOP_CI_ENABLED=true
|
||||
# Zusätzlich: Für Plan‑B‑Builds überspringen, wenn Commit-Message [planb] enthält
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
name: Compose Desktop — Tests (headless) & Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 21 (Temurin)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
.gradle
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Show Gradle version
|
||||
run: ./gradlew --version
|
||||
|
||||
- name: Run Desktop tests headless (xvfb)
|
||||
env:
|
||||
_JAVA_OPTIONS: -Djava.awt.headless=true
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y xvfb xauth
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
||||
./gradlew :frontend:shells:meldestelle-desktop:jvmTest --stacktrace --no-daemon
|
||||
|
||||
- name: Build Desktop shell (Release)
|
||||
env:
|
||||
_JAVA_OPTIONS: -Djava.awt.headless=true
|
||||
run: |
|
||||
./gradlew :frontend:shells:meldestelle-desktop:build --stacktrace --no-daemon
|
||||
|
||||
- name: Upload build artifacts (Desktop shell)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-shell-build
|
||||
path: |
|
||||
frontend/shells/meldestelle-desktop/build/libs/**/*.jar
|
||||
frontend/shells/meldestelle-desktop/build/compose*/**
|
||||
if-no-files-found: warn
|
||||
@@ -33,18 +33,11 @@ jobs:
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
- service: keycloak
|
||||
# Plan-B fokussiert: Nur Mail-Service + Web-App bauen/pushen (beschleunigt CI deutlich)
|
||||
- service: mail-service
|
||||
context: .
|
||||
dockerfile: config/docker/keycloak/Dockerfile
|
||||
image: keycloak
|
||||
- service: api-gateway
|
||||
context: .
|
||||
dockerfile: backend/infrastructure/gateway/Dockerfile
|
||||
image: api-gateway
|
||||
- service: ping-service
|
||||
context: .
|
||||
dockerfile: backend/services/ping/Dockerfile
|
||||
image: ping-service
|
||||
dockerfile: backend/services/mail/Dockerfile
|
||||
image: mail-service
|
||||
- service: web-app
|
||||
context: .
|
||||
dockerfile: config/docker/caddy/web-app/Dockerfile
|
||||
@@ -61,43 +54,42 @@ jobs:
|
||||
distribution: "temurin"
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Gradle Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
# Verhindert mysteriöse Build-Fehler durch korrupte Node/Kotlin-Caches (nur web-app relevant)
|
||||
- name: Cleanup stale build caches
|
||||
if: matrix.service == 'web-app'
|
||||
run: |
|
||||
rm -rf frontend/shells/meldestelle-portal/build/js/node_modules/.cache || true
|
||||
rm -rf frontend/shells/meldestelle-portal/build/js/.yarn/cache || true
|
||||
rm -rf ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compiler-embeddable || true
|
||||
|
||||
- name: Build Frontend (Kotlin JS)
|
||||
# --- SCHRITT 1: Build mit radikalem Clean (gegen die März-Leichen) ---
|
||||
- name: Build Frontend (Wasm JS)
|
||||
if: matrix.service == 'web-app'
|
||||
run: |
|
||||
chmod +x gradlew
|
||||
./gradlew :frontend:shells:meldestelle-portal:jsBrowserDistribution \
|
||||
# Löscht alte Build-Stände komplett
|
||||
./gradlew :frontend:shells:meldestelle-web:clean
|
||||
|
||||
./gradlew :frontend:shells:meldestelle-web:wasmJsBrowserDistribution \
|
||||
-Pproduction=true \
|
||||
--max-workers=4 \
|
||||
-Dkotlin.daemon.jvm.options="-Xmx4g"
|
||||
|
||||
# Pangolin-Bypass: Credentials direkt in config.json schreiben.
|
||||
# Kein "docker login" → kein Daemon-Ping → kein HTTPS-Fehler.
|
||||
# BuildKit liest ~/.docker/config.json und verwendet diese Credentials beim Push.
|
||||
# - name: Registry-Credentials konfigurieren (kein Daemon-Kontakt)
|
||||
# run: |
|
||||
# mkdir -p ~/.docker
|
||||
# AUTH=$(echo -n "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" | base64 -w 0)
|
||||
# printf '{"auths":{"%s":{"auth":"%s"}}}\n' "${{ env.REGISTRY_INTERNAL }}" "${AUTH}" > ~/.docker/config.json
|
||||
# echo "✓ Credentials für ${{ env.REGISTRY_INTERNAL }} gespeichert"
|
||||
# --- SCHRITT 2: Staging ohne rsync (Fix für dein Log-Fehler) ---
|
||||
- name: Stage Web Assets for Docker build
|
||||
if: matrix.service == 'web-app'
|
||||
run: |
|
||||
set -e
|
||||
DIST_DIR="frontend/shells/meldestelle-web/build/dist/wasmJs/productionExecutable"
|
||||
TARGET_DIR="config/docker/caddy/web-app/_site"
|
||||
|
||||
if [ ! -d "$DIST_DIR" ]; then
|
||||
echo "❌ Fehler: Build-Verzeichnis nicht gefunden!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ersetzt rsync durch sicheres Löschen & Kopieren
|
||||
rm -rf "$TARGET_DIR"
|
||||
mkdir -p "$TARGET_DIR"
|
||||
cp -r "$DIST_DIR"/. "$TARGET_DIR/"
|
||||
# Kopiere Turnier-Ausschreibungen (PDFs) für Plan-B
|
||||
cp docs/Neumarkt2026/*.pdf "$TARGET_DIR/" || true
|
||||
|
||||
echo "✓ Assets für Docker vorbereitet (Stand: $(date))"
|
||||
|
||||
# --- SCHRITT 3: Login & BuildX ---
|
||||
# NEU (sauber, nach daemon.json-Fix):
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -122,7 +114,7 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY_INTERNAL }}/${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest
|
||||
type=sha,format=long
|
||||
|
||||
- name: Build and push Docker image
|
||||
@@ -137,9 +129,5 @@ jobs:
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
DOCKER_BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp || 'unknown' }}
|
||||
VERSION=${{ github.sha }}
|
||||
GRADLE_VERSION=${{ env.GRADLE_VERSION }}
|
||||
JAVA_VERSION=${{ env.JAVA_VERSION }}
|
||||
KEYCLOAK_IMAGE_TAG=${{ env.KEYCLOAK_IMAGE_TAG }}
|
||||
JVM_OPTS_APPEND=${{ env.JVM_OPTS_ARM64 }}
|
||||
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
branches: [ "**" ]
|
||||
jobs:
|
||||
no-hardcoded-versions:
|
||||
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
|
||||
if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
name: Release — Semantic Versioning & Desktop Packaging
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Trigger: Manuell ODER automatisch wenn version.properties
|
||||
# auf main/master geändert wird.
|
||||
# ---------------------------------------------------------------
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
paths:
|
||||
- 'version.properties'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry-Run (kein Tag, kein Upload)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
jobs:
|
||||
# =============================================================
|
||||
# JOB 1: Version lesen & Git-Tag setzen
|
||||
# =============================================================
|
||||
tag-release:
|
||||
name: 🏷️ Git-Tag setzen
|
||||
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
|
||||
if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.read-version.outputs.version }}
|
||||
tag: ${{ steps.read-version.outputs.tag }}
|
||||
already_tagged: ${{ steps.check-tag.outputs.already_tagged }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Version aus version.properties lesen
|
||||
id: read-version
|
||||
run: |
|
||||
source <(grep -v '^#' version.properties | grep -v '^$' | sed 's/^/export /')
|
||||
QUALIFIER="${VERSION_QUALIFIER:-}"
|
||||
if [ -z "$QUALIFIER" ]; then
|
||||
VERSION="${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}"
|
||||
else
|
||||
VERSION="${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}-${QUALIFIER}"
|
||||
fi
|
||||
TAG="v${VERSION}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "📦 Version: ${VERSION} | Tag: ${TAG}"
|
||||
|
||||
- name: Prüfen ob Tag bereits existiert
|
||||
id: check-tag
|
||||
run: |
|
||||
TAG="${{ steps.read-version.outputs.tag }}"
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "already_tagged=true" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Tag $TAG existiert bereits — überspringe Tagging"
|
||||
else
|
||||
echo "already_tagged=false" >> $GITHUB_OUTPUT
|
||||
echo "✅ Tag $TAG ist neu"
|
||||
fi
|
||||
|
||||
- name: Git-Tag erstellen & pushen
|
||||
if: steps.check-tag.outputs.already_tagged == 'false' && gitea.event.inputs.dry_run != 'true'
|
||||
run: |
|
||||
TAG="${{ steps.read-version.outputs.tag }}"
|
||||
VERSION="${{ steps.read-version.outputs.version }}"
|
||||
git config user.name "Gitea CI"
|
||||
git config user.email "ci@mo-code.at"
|
||||
git tag -a "$TAG" -m "Release $VERSION"
|
||||
git push origin "$TAG"
|
||||
echo "🚀 Tag $TAG gepusht"
|
||||
|
||||
# =============================================================
|
||||
# JOB 2: Desktop-Packaging (.deb — Linux)
|
||||
# =============================================================
|
||||
package-linux:
|
||||
name: 📦 Linux .deb Packaging
|
||||
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein Plan‑B Commit
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: tag-release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 25 (Temurin)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
.gradle
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Linux .deb bauen
|
||||
env:
|
||||
_JAVA_OPTIONS: -Djava.awt.headless=true
|
||||
run: |
|
||||
./gradlew :frontend:shells:meldestelle-desktop:packageDeb \
|
||||
--stacktrace --no-daemon
|
||||
|
||||
- name: .deb Artefakt hochladen
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: meldestelle-${{ needs.tag-release.outputs.version }}-linux-deb
|
||||
path: frontend/shells/meldestelle-desktop/build/compose/binaries/main/deb/*.deb
|
||||
if-no-files-found: warn
|
||||
|
||||
# =============================================================
|
||||
# JOB 3: Desktop-Packaging (.msi — Windows)
|
||||
# =============================================================
|
||||
package-windows:
|
||||
name: 📦 Windows .msi Packaging
|
||||
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein Plan‑B Commit
|
||||
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||
runs-on: windows-latest
|
||||
needs: tag-release
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup JDK 25 (Temurin)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
.gradle
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Windows .msi bauen
|
||||
env:
|
||||
_JAVA_OPTIONS: -Djava.awt.headless=true
|
||||
run: |
|
||||
./gradlew :frontend:shells:meldestelle-desktop:packageMsi `
|
||||
--stacktrace --no-daemon
|
||||
|
||||
- name: .msi Artefakt hochladen
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: meldestelle-${{ needs.tag-release.outputs.version }}-windows-msi
|
||||
path: frontend/shells/meldestelle-desktop/build/compose/binaries/main/msi/*.msi
|
||||
if-no-files-found: warn
|
||||
|
||||
# =============================================================
|
||||
# JOB 4: Release-Summary
|
||||
# =============================================================
|
||||
release-summary:
|
||||
name: 📋 Release-Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ tag-release, package-linux, package-windows ]
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Summary ausgeben
|
||||
run: |
|
||||
echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "| Artefakt | Status |" >> $GITEA_STEP_SUMMARY
|
||||
echo "|----------|--------|" >> $GITEA_STEP_SUMMARY
|
||||
echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITEA_STEP_SUMMARY
|
||||
echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITEA_STEP_SUMMARY
|
||||
echo "" >> $GITEA_STEP_SUMMARY
|
||||
echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITEA_STEP_SUMMARY
|
||||
+47
-60
@@ -1,74 +1,61 @@
|
||||
# --- General ---
|
||||
.gradle/
|
||||
**/build/
|
||||
**/out/
|
||||
.kotlin/
|
||||
kotlin-js-store/
|
||||
# 🐧 [DevOps Engineer] Optimierte .gitignore für Meldestelle (KMP / Gradle / Docker)
|
||||
|
||||
# --- Environments ---
|
||||
#.env
|
||||
config/env/.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
# --- AI ---
|
||||
.ai/dist/
|
||||
|
||||
# --- IDEs ---
|
||||
# IntelliJ
|
||||
# --- IDE & Editor ---
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
*.ipr
|
||||
out/
|
||||
.vscode/
|
||||
.history/
|
||||
.shelf/
|
||||
|
||||
# VS Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/snippets
|
||||
# --- Gradle ---
|
||||
.gradle/
|
||||
build/
|
||||
!**/src/**/build/
|
||||
gradle-app.setting
|
||||
!gradle-wrapper.jar
|
||||
.gradletasknamecache
|
||||
bin/
|
||||
|
||||
# Fleet
|
||||
.fleet/
|
||||
!.fleet/receipt.json
|
||||
# --- Kotlin / KMP ---
|
||||
.kotlin/
|
||||
kotlin-js-store/
|
||||
.jetbrains/
|
||||
|
||||
# --- Dependencies & Build ---
|
||||
# --- Android (falls relevant) ---
|
||||
*.ap_
|
||||
*.apk
|
||||
*.dex
|
||||
local.properties
|
||||
|
||||
# --- Node / JS (Compose Web / KMP JS) ---
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm/
|
||||
|
||||
# --- OS Files ---
|
||||
# --- Docker & Infrastructure ---
|
||||
.docker/
|
||||
*.log
|
||||
logs/
|
||||
.env
|
||||
!.env.example
|
||||
.data/
|
||||
postgres-data/
|
||||
|
||||
# --- OS Specific ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
.nfs*
|
||||
desktop.ini
|
||||
|
||||
# --- Logs ---
|
||||
_backup/logs/
|
||||
**/*.log
|
||||
*.log.gz
|
||||
|
||||
# --- Languages & Runtimes ---
|
||||
# Java/Kotlin
|
||||
*.class
|
||||
.attach_pid*
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
||||
# --- Quality & Documentation ---
|
||||
build/diagrams/
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
.phpunit.result.cache
|
||||
.dataSources/
|
||||
dataSources.local.xml
|
||||
/_backup/
|
||||
.env
|
||||
# --- Project Specific ---
|
||||
docs/temp/
|
||||
docs/Bin/
|
||||
docs/_archive/
|
||||
|
||||
@@ -1,43 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# check-docs-drift.sh
|
||||
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
|
||||
# - Kein Guidelines-System mehr.
|
||||
# - Single Source of Truth: `docs/`
|
||||
|
||||
err=0
|
||||
|
||||
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
|
||||
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
|
||||
|
||||
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
|
||||
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
|
||||
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
|
||||
err=1
|
||||
fi
|
||||
|
||||
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
|
||||
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
|
||||
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
|
||||
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
|
||||
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
|
||||
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
|
||||
|
||||
exit $err
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p build/diagrams
|
||||
shopt -s nullglob
|
||||
for f in docs/architecture/c4/*.puml; do
|
||||
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
|
||||
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
|
||||
done
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/render-plantuml.sh" "$@"
|
||||
|
||||
@@ -1,136 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# validate-links.sh - Link-Validierung für Projektdokumentation (`docs/**`).
|
||||
# Zweck: Guardrail für die "Docs-as-Code"-Strategie.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
QUICK_MODE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--quick)
|
||||
QUICK_MODE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat << 'EOF'
|
||||
Docs Link-Validierung
|
||||
|
||||
USAGE:
|
||||
./.junie/scripts/validate-links.sh [--quick]
|
||||
|
||||
BESCHREIBUNG:
|
||||
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
|
||||
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
|
||||
|
||||
OPTIONEN:
|
||||
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] Unbekannter Parameter: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
root = Path.cwd()
|
||||
docs_dir = root / "docs"
|
||||
|
||||
if not docs_dir.is_dir():
|
||||
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# Veraltete Pfad-Prüfungen wurden entfernt, da sie zu wartungsintensiv waren.
|
||||
# Das Skript konzentriert sich nun auf die Validierung der Link-Integrität.
|
||||
FORBIDDEN_SUBSTRINGS = []
|
||||
|
||||
md_files = sorted(docs_dir.rglob("*.md"))
|
||||
|
||||
link_pattern = re.compile(r"\]\(([^)]+)\)")
|
||||
|
||||
errors = 0
|
||||
|
||||
def is_external(target: str) -> bool:
|
||||
t = target.lower()
|
||||
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
|
||||
|
||||
def strip_fragment_and_query(target: str) -> str:
|
||||
# remove fragment and query parts
|
||||
target = target.split("#", 1)[0]
|
||||
target = target.split("?", 1)[0]
|
||||
return target
|
||||
|
||||
for f in md_files:
|
||||
text = f.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
for forbidden in FORBIDDEN_SUBSTRINGS:
|
||||
if forbidden in text:
|
||||
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
|
||||
errors += 1
|
||||
|
||||
for match in link_pattern.finditer(text):
|
||||
target = match.group(1).strip()
|
||||
|
||||
if not target:
|
||||
continue
|
||||
if is_external(target):
|
||||
continue
|
||||
if target.startswith("#"):
|
||||
continue
|
||||
|
||||
# drop angle brackets <...> used in markdown for urls with spaces
|
||||
if target.startswith("<") and target.endswith(">"):
|
||||
target = target[1:-1]
|
||||
|
||||
target = unquote(strip_fragment_and_query(target))
|
||||
|
||||
# ignore absolute paths in the repo (we treat them as doc-style links; validate only if relative)
|
||||
if target.startswith("/"):
|
||||
continue
|
||||
|
||||
# ignore non-file targets (e.g. empty or protocol-less anchors)
|
||||
if ":" in target.split("/", 1)[0]:
|
||||
# things like "vscode:..." etc.
|
||||
continue
|
||||
|
||||
# treat as file path relative to markdown file
|
||||
resolved = (f.parent / target).resolve()
|
||||
|
||||
# keep validation within repo
|
||||
try:
|
||||
resolved.relative_to(root.resolve())
|
||||
except ValueError:
|
||||
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# allow directories if they contain README.md
|
||||
if resolved.is_dir():
|
||||
if not (resolved / "README.md").is_file():
|
||||
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if not resolved.exists():
|
||||
print(f"[ERROR] Broken link: {f} -> {target}")
|
||||
errors += 1
|
||||
|
||||
if errors:
|
||||
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
|
||||
PY
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# .aiignore - Verhindert Token-Waste für Nolik
|
||||
|
||||
# Abhängigkeiten & Binaries
|
||||
build/
|
||||
.gradle/
|
||||
*.jar
|
||||
*.deb
|
||||
*.msi
|
||||
|
||||
# Sensible Daten (auch lokal!)
|
||||
.env
|
||||
.env.*
|
||||
config/docker/certs/
|
||||
*.pem
|
||||
*.jks
|
||||
postgres-data/
|
||||
valkey-data/
|
||||
|
||||
# Doku-Builds (Nolik soll die Source-Files in docs/ lesen, nicht die HTML-Exporte)
|
||||
build/dokka/
|
||||
docs/Neumarkt2026/*.pdf
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
|
||||
@@ -1,35 +1,43 @@
|
||||
# 🤖 Project Agents & Protocol
|
||||
# 🤖 Projekt Agenten & Protokoll (Meldestelle)
|
||||
|
||||
Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den KI-Agenten.
|
||||
Es dient als "System Prompt" für neue Chat-Sessions.
|
||||
Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den spezialisierten KI-Agenten.
|
||||
Es dient als zentraler **System-Prompt-Erweiterung** für neue Chat-Sessions.
|
||||
|
||||
## 1. Protokoll & Badges
|
||||
Jeder Agent muss seine Antwort mit einem Badge beginnen, um den Kontext zu setzen. Detaillierte Anweisungen finden sich in den jeweiligen Playbooks.
|
||||
## 🚀 Strategische Ausrichtung
|
||||
|
||||
* **🏗️ [Lead Architect]**: Strategie, Planung, Entscheidungen, Master Roadmap.
|
||||
Das Projekt **"Meldestelle"** entwickelt eine ÖTO/FEI-konforme, offline-fähige Turnier-Software.
|
||||
1. **Desktop-First:** Primäres Ziel ist die Compose Desktop App (KMP). UX & Performance sind auf Profis optimiert.
|
||||
2. **Offline-First:** Das System muss autark (ohne Internet) funktionieren. Sync-Logik ist Kernbestandteil.
|
||||
3. **Domain-Driven:** 6 Bounded Contexts (SCS) bilden den fachlichen Rahmen.
|
||||
|
||||
## 1. Protokoll & Rollen-Badges
|
||||
Jede Agenten-Antwort **muss** mit dem entsprechenden Badge beginnen, um den Kontext und die Verantwortlichkeit zu klären.
|
||||
|
||||
* **🏗️ [Lead Architect]**: Hüter der **MASTER_ROADMAP**. Verantwortlich für System-Design, Build-Logik (Gradle), Modulstruktur und ADRs.
|
||||
* [Playbook](docs/04_Agents/Playbooks/Architect.md)
|
||||
* **🧹 [Curator]**: Dokumentation, Logs, Reports, Aufräumen.
|
||||
* [Playbook](docs/04_Agents/Playbooks/Curator.md)
|
||||
* **👷 [Backend Developer]**: Spring Boot, Kotlin, SQL, API-Design.
|
||||
* **📜 [Rulebook Expert]**: Wächter über **ÖTO & FEI**. Validiert Business-Rules gegen das offizielle Pferdesport-Regelwerk.
|
||||
* [Playbook](docs/04_Agents/Playbooks/RulebookExpert.md)
|
||||
* **👷 [Backend Developer]**: Kotlin & Spring Boot Experte. Fokus auf DDD, Persistenz (Postgres) und **Delta-Sync APIs**.
|
||||
* [Playbook](docs/04_Agents/Playbooks/BackendDeveloper.md)
|
||||
* **🎨 [Frontend Expert]**: KMP, Compose, State-Management, Auth.
|
||||
* **🎨 [Frontend Expert]**: KMP & Compose Desktop Spezialist. Implementiert State-Management und High-Performance UI.
|
||||
* [Playbook](docs/04_Agents/Playbooks/FrontendExpert.md)
|
||||
* **🖌️ [UI/UX Designer]**: High-Density Design, Wireframes, Usability.
|
||||
* **🖌️ [UI/UX Designer]**: "Toolsmith" für High-Density Enterprise-UIs. Fokus auf Tastatur-Bedienbarkeit und Effizienz.
|
||||
* [Playbook](docs/04_Agents/Playbooks/UIUXDesigner.md)
|
||||
* **🐧 [DevOps Engineer]**: Docker, CI/CD, Gradle, Security.
|
||||
* **🐧 [DevOps Engineer]**: Infrastruktur-Automatisierung (Docker, Gitea-Actions). Fokus auf Stabilität und lokale Dev-Umgebung.
|
||||
* [Playbook](docs/04_Agents/Playbooks/DevOpsEngineer.md)
|
||||
* **🧐 [QA Specialist]**: Test-Strategie, Edge-Cases.
|
||||
* **🧐 [QA Specialist]**: Test-Stratege (Shift-Left). Fokus auf Unit-, Integration- und Edge-Case-Tests (Testing Pyramid).
|
||||
* [Playbook](docs/04_Agents/Playbooks/QASpecialist.md)
|
||||
* **📜 [ÖTO/FEI Rulebook Expert]**: Regelwerks-Wächter, Validierungs-Spezialist, Compliance.
|
||||
* [Playbook](docs/04_Agents/Playbooks/RulebookExpert.md)
|
||||
* **🧹 [Curator]**: Wissens-Management & Dokumentations-Check (ADR, Reference, Journal). Beendet jede Session.
|
||||
* [Playbook](docs/04_Agents/Playbooks/Curator.md)
|
||||
|
||||
## 2. Workflow
|
||||
1. **Kontext:** Lies immer zuerst die `MASTER_ROADMAP` in `docs/01_Architecture/`.
|
||||
2. **Fokus:** Bearbeite immer nur EINE Aufgabe zur Zeit.
|
||||
3. **Doku:** Jede Session endet mit einem Eintrag durch den **Curator**.
|
||||
4. **Code:** Änderungen am Code werden sofort via Tool ausgeführt, nicht nur vorgeschlagen.
|
||||
## 2. Der "Meldestelle"-Workflow
|
||||
1. **Kontext-Check:** Lies immer zuerst die `MASTER_ROADMAP` in `docs/01_Architecture/`.
|
||||
2. **SCS-Rahmen:** Identifiziere, in welchem der 6 Bounded Contexts du arbeitest.
|
||||
3. **Fokus:** Bearbeite immer nur EINE fachliche Aufgabe pro Session.
|
||||
4. **Doku-as-Code:** Änderungen an Code/Architektur müssen sofort in `docs/` (ADR/Reference) reflektiert werden.
|
||||
5. **Session-Abschluss:** Jede Session endet mit einem Eintrag durch den **Curator** (Journal oder Artefakt).
|
||||
|
||||
## 3. Projekt-Philosophie
|
||||
* **Startup-Mode:** Wir bauen ein echtes Produkt. Code-Qualität und Geschwindigkeit sind gleich wichtig.
|
||||
* **Docs-as-Code:** Die Dokumentation ist die Single Source of Truth.
|
||||
* **Offline-First:** Das System muss ohne Internet funktionieren (Sync).
|
||||
* **Information Density over White Space:** Wir bauen ein Profi-Werkzeug, kein Spielzeug.
|
||||
* **Speed over Animation:** Reaktionsgeschwindigkeit der UI hat höchste Priorität.
|
||||
* **Offline-Authentizität:** Lokale Daten sind die "Source of Truth" für den User; der Server ist das Backup/Sync-Target.
|
||||
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
# Changelog — Meldestelle
|
||||
|
||||
Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
||||
|
||||
Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
||||
Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
> **Versionsschema:** `MAJOR.MINOR.PATCH[-QUALIFIER]`
|
||||
> - `MAJOR` — Breaking Changes / inkompatible API-Änderungen
|
||||
> - `MINOR` — Neue Features (abwärtskompatibel)
|
||||
> - `PATCH` — Bugfixes (abwärtskompatibel)
|
||||
> - `QUALIFIER` — `SNAPSHOT` (Entwicklung), `beta.N` (Vorversion), leer = Release
|
||||
|
||||
---
|
||||
|
||||
### [Unreleased]
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Onboarding & Desktop-UX - 15.04.2026:**
|
||||
- **Desktop-App:** Dynamisierung der Statusanzeigen im App-Footer ("Cloud synchronisiert" & "Verbunden").
|
||||
- **Connectivity-Tracking:** Implementierung des `ConnectivityTracker` (KMP) zur Echtzeit-Überwachung der API-Gateway
|
||||
Erreichbarkeit.
|
||||
- **LAN-Erkennung:** Integration des `NetworkDiscoveryService` (mDNS) im Footer zur Anzeige aktiver Instanzen im
|
||||
lokalen Netzwerk.
|
||||
- **Onboarding:** Datenfluss vom `SettingsManager` bis in den Footer finalisiert (Anzeige des echten Gerätenamens).
|
||||
- **Online-Nennung & Integration - 15.04.2026:**
|
||||
- **Backend (Mail-Service):** Finalisierung des `MailController` für Web-Nennungen inkl. SMTP-Versand via World4You.
|
||||
- **Frontend (Desktop):** `NennungsEingangScreen` an Live-Daten vom `mail-service` angebunden.
|
||||
- **Repository:** `NennungRemoteRepository` (KMP) um `holeNennungen()` erweitert.
|
||||
- **Billing & ÖTO - 15.04.2026:**
|
||||
- **Sportförderbeitrag:** Automatische Buchung von 1,00 EUR (§16 ÖTO) bei jeder Nennung im `entries-service`
|
||||
implementiert.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Frontend (Desktop):** Behebung von Kompilierungsfehlern in `ScreenPreviews.kt` durch Implementierung der fehlenden
|
||||
`getStats()` Methode in den `MasterdataRepository`-Mocks.
|
||||
- **Identity-Modul:** Umstellung auf `kotlin.time.Instant` zur Vermeidung von Deprecation-Warnungen und Behebung von
|
||||
Persistenz-Konflikten im `ExposedDeviceRepository`.
|
||||
- **Koin DI:** Korrektur von Typ-Inferenz-Fehlern beim `HttpClient` im `nennung-feature` durch explizite Qualifier.
|
||||
- **Turnier-Feature:** Behebung eines unsicheren Casts (`Any!` zu `List<String>`) in `TurnierStammdatenTab.kt`.
|
||||
- **Konfiguration:** Harmonisierung der Ports (Mail-Service auf 8083) in `.env`, `dc-backend.yaml` und
|
||||
`PlatformConfig.jvm.kt`.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 12 (Abrechnung & Infrastruktur) - 12.04.2026:**
|
||||
- **Infrastruktur:** Docker-Integration für `billing-service` (Port 8087) und API-Gateway Routing vervollständigt.
|
||||
- **Service Discovery:** Alle relevanten Microservices (`masterdata`, `events`, `results`, `series`, `billing`) sind nun bei Consul registriert.
|
||||
- **Frontend Billing:** `BillingRepository` und `BillingViewModel` auf reale API-Anbindung (Ktor) umgestellt; `BillingScreen` funktionalisiert.
|
||||
- **Backend (Series):** JPA-Entitäten `Serie` und `SeriePunkt` im `series-service` stabilisiert und Flyway-Migrationen für das Datenbankschema erstellt.
|
||||
- **Fix:** Behebung von IDE-Mapping-Warnungen durch explizite `@Column` Namen in den JPA-Entitäten.
|
||||
- **Backend Fixes - 12.04.2026:**
|
||||
- **Infrastruktur:** Behebung von Startfehlern im `events-service` (DataSource) und `masterdata-service` (Consul).
|
||||
- **Build:** Integration von `results-service` und `series-service` in `settings.gradle.kts`.
|
||||
- **Domain:** `Serie` und `SeriePunkt` zu `data class` konvertiert (copy() Unterstützung).
|
||||
- **Phase 11 (Ergebniserfassung & Platzierung) - 12.04.2026:**
|
||||
- **Backend (Results):** `results-service` um JPA-Entitäten, Repositories und Business-Logik für Platzierungsberechnungen (Wertnote, Zeit, Fehler) ergänzt.
|
||||
- **Infrastructure:** `dc-backend.yaml` und `GatewayConfig.kt` um den Service `results` (Port 8088) erweitert.
|
||||
- **Frontend Domain:** `ErgebnisRepository` und `Ergebnis`-Modell für Wertnoten, Zeiten und Status erstellt.
|
||||
- **Frontend UI:** `ErgebnisEditDialog` zur schnellen Ergebniserfassung hinzugefügt; `TurnierStartlistenTab` ermöglicht nun Erfassung per Zeilen-Klick.
|
||||
- **Frontend UI:** `TurnierErgebnislistenTab` vervollständigt: Buttons für "Platzierung berechnen" und "Drucken" (PDF) funktionalisiert.
|
||||
- **Fix:** Kompilierungsprobleme im `TurnierFeatureModule` und `ScreenPreviews.kt` behoben (fehlende `ergebnisRepo` Parameter).
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 10.4 (Series-Context Vertiefung) - 12.04.2026:**
|
||||
- **Backend (Series):** `series-service` um Logik für Streichresultate (`ReglementTyp`) und Bindungsarten (Reiter-zentriert, Pferde-zentriert, Paar-Bindung) erweitert.
|
||||
- **Infrastructure:** `dc-backend.yaml` und `GatewayConfig.kt` um den Service `series` (Port 8089) erweitert.
|
||||
- **Frontend Domain:** `SeriesRepository` und Modelle an das neue Ranking-Format (`SerieStandEntry`) angepasst.
|
||||
- **UI:** `SeriesScreen.kt` überarbeitet: Zeigt nun Reiter- und Pferde-IDs sowie Fortschritt pro Teilnehmer an.
|
||||
- **Dokumentation:** `MASTER_ROADMAP.md` aktualisiert (Phase 10 & 11 auf 'Completed' gesetzt).
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 10.3 (Echter Datenverkehr & Infrastruktur) - 12.04.2026:**
|
||||
- **Infrastructure:** Docker-Services für `masterdata`, `events` und `zns-import` in `dc-backend.yaml` ergänzt.
|
||||
- **Gateway:** API-Gateway Routing für Masterdata (`/api/v1/masterdata`) und Events (`/api/v1/events`) konfiguriert.
|
||||
- **Frontend (Vereine):** `VereinRepository` (Ktor) und `VereinViewModel` implementiert für echtes Anlegen von Veranstaltern.
|
||||
- **Frontend (Events):** `TurnierViewModel` an das reale `TurnierRepository` angebunden.
|
||||
- **Fix:** `verein-feature` Abhängigkeiten korrigiert (Network/Ktor).
|
||||
- **Fix:** Polling-Endpoints im `ZnsImportViewModel` an das neue Gateway-Routing angepasst.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 10.2 (Masterdata-Editoren & Organisation) - 12.04.2026:**
|
||||
- **Frontend:** `MasterdataEditDialogs.kt` für die Bearbeitung von Reiter- und Pferdedaten direkt im Turnier-Kontext.
|
||||
- **Frontend:** Erweiterung des `MasterdataRepository` um Schreibzugriffe (`saveReiter`, `savePferd`).
|
||||
- **Frontend:** Funktionale Suche für Turnierleiter im `Organisation`-Tab via `NennungViewModel` und Masterdata-API.
|
||||
- **Frontend:** State-Management für Stammdaten-Editoren im `NennungViewModel`.
|
||||
- **Fix:** Kompilierungsfehler in `ScreenPreviews.kt` behoben (fehlende Interface-Methoden in Mocks).
|
||||
- **Fix (Desktop Shell):** Fehlendes `turnierFeatureModule` in `main.kt` registriert und Login-Gate in `DesktopApp.kt` optimiert.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Phase 10 (Series-Context & Stammdaten) - 11.04.2026:**
|
||||
- **Frontend:** Stammdaten-Infrastruktur im `turnier-feature` (Repositories, DTOs, Domänenmodelle) für Reiter, Pferde, Funktionäre und Vereine.
|
||||
- **Frontend:** `NennungViewModel` zur Steuerung der Suche und Status-Verwaltung von Nennungen.
|
||||
- **Frontend:** Funktionalisierung des `Nennungen`-Tabs (Suche, Echt-Datenanbindung) und Vorbereitung des `Organisation`-Tabs.
|
||||
- **Frontend:** `DefaultMasterdataRepository` zur Suche in Reitern, Pferden und Funktionären via Backend-API.
|
||||
- **Netzwerk:** Erweiterung der `ApiRoutes` um Endpunkte für Masterdata und Nennungen.
|
||||
- **Phase 10 (Series-Context) Vorbereitung:**
|
||||
- **Frontend:** Neuer `SeriesScreen.kt` für die Verwaltung von Cups und Meisterschaften (konfigurierbare Reglements).
|
||||
- **Frontend:** Erweiterung des `AdminUebersichtScreen` (Cockpit) um KPI-Kacheln mit Direkt-Links zu Cups und Meisterschaften.
|
||||
- **Frontend:** Integration der Series-Navigation in die Breadcrumbs und das globale Routing (`Meisterschaften`, `Cups`).
|
||||
- **Turnier-Feature Hardening:**
|
||||
- **Frontend:** `STARTLISTEN` und `ERGEBNISLISTEN` Tabs vollständig an das `BewerbViewModel` angebunden (Bewerbs-Auswahl mit echten Daten).
|
||||
- **Frontend:** Implementierung der Starter-Anzeige in der Startliste (LazyColumn).
|
||||
|
||||
### Geändert
|
||||
- **Turnier-Feature:** Sichtbarkeit von `BewerbViewModel.generateStartliste()` auf `public` geändert, um den Aufruf aus dem Tab zu ermöglichen.
|
||||
- **Frontend (Desktop):** `ScreenPreviews.kt` aktualisiert zur Berücksichtigung der neuen ViewModel-Abhängigkeiten (`NennungViewModel`, `MasterdataRepository`).
|
||||
|
||||
### [Phase 9] - 11.04.2026
|
||||
- **Frontend:** Interaktiver Drag & Drop Zeitplan mit automatischem 5-Minuten-Snapping und Konflikt-Visualisierung.
|
||||
- **Frontend:** "B-Satz Export (ZNS)" Toolbar-Aktion mit integriertem Vorschau-Dialog.
|
||||
- **Frontend:** "Änderungs-Historie" (Audit-Log) Sektion zur Nachverfolgung von Zeitplan-Anpassungen.
|
||||
- **Backend:** `audit_log` Persistenz und Abfrage-API für manuelle Eingriffe in Bewerbe.
|
||||
- **Backend:** ZNS B-Satz Export Endpunkt (`/export/zns/b-satz`) zur Generierung von `BBEWERBE` Datensätzen.
|
||||
- **Core:** `FixedWidthLineBuilder` zur präzisen Generierung von ZNS-konformen Festbreiten-Formaten.
|
||||
|
||||
### Behoben
|
||||
- **Infrastruktur:** Veraltete `newSuspendedTransaction` in `DatabaseFactory.kt` durch moderne `suspendTransaction` (Exposed v1) ersetzt.
|
||||
- **Frontend (Desktop):** Kompilierfehler in `ScreenPreviews.kt` behoben, indem fehlende Interface-Methoden im Mock-Repository implementiert wurden.
|
||||
- **Backend (Tests):** `JdbcSQLSyntaxErrorException` im `BewerbeZeitplanIntegrationTest` durch Korrektur des Schema-Setups (Audit-Log Tabelle) gelöst.
|
||||
|
||||
### Hinzugefügt
|
||||
- **Bugfix**: Behebung von Build-Fehlern im `veranstalter-feature` nach der Paket-Konsolidierung.
|
||||
- **Frontend**: `FakeVeranstalterRepository` in `commonMain` implementiert, um saubere KMP-DI zu ermöglichen.
|
||||
- **Frontend**: Veraltete Imports und Referenzen im `meldestelle-desktop` Shell und Previews korrigiert.
|
||||
- **Architektur:** Fachliches Konzept für Zeitplan-Optimierung (Drag & Drop) erstellt (`konzept-zeitplan-optimierung-de.md`).
|
||||
- **Architektur:** Spezifikation des Status-Automaten für Nennungen und Synchronisations-Logik (`status-automat-nennungen-de.md`).
|
||||
- **Rulebook:** Überprüfung und Spezifikation der Parcoursbesichtigung zu Pferd (§43 ÖTO) inkl. 5-Minuten-Puffer-Regel.
|
||||
- **Backend (Entries):** Erweiterung der Domain-Modelle `Bewerb` und `Abteilung` um Besichtigungs- und Pausen-Konfigurationen.
|
||||
- **Backend (Entries):** Neues Datenmodell `BesichtigungsBlock` für wettbewerbsübergreifende Parcoursbesichtigungen.
|
||||
- **Backend (Entries):** API-Endpunkt `PATCH /bewerbe/{id}/zeitplan` für schnelle Zeitplan-Updates implementiert.
|
||||
- **Backend (Entries):** `StartlistenService` um ÖTO-konforme Zeitberechnung (Besichtigungs-Puffer, Pausen-Intervalle) erweitert.
|
||||
|
||||
### Geändert
|
||||
- Masterdata/Domain: Umbenennungen zur Vereinheitlichung der Terminologie (DE):
|
||||
- `MasterdataLicenseRepository` → `LizenzRepository`
|
||||
- `LicenseMatrixService` → `LizenzMatrixService`
|
||||
- `LicenseMatrixServiceImpl` → `LizenzMatrixServiceImpl`
|
||||
- Test: `LicenseMatrixServiceTest` → `LiznezMatrixServiceTest` (exakt nach Vorgabe)
|
||||
- Infrastructure (Exposed): `LicenseTable` → `LizenzTable`
|
||||
- Docs: Begriff „reit_lizenzen“ → „reiterlizenzen“ in Glossar/UL konsolidiert.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Events-Service Bundle:** Vollständige Stabilisierung der `events` Services (Domain, Infrastructure, API, Service).
|
||||
- **Domain:** Umstellung auf `kotlin.time.Instant` zur Vermeidung von Deprecation-Warnungen (Kotlin 2.1.20+) und Harmonisierung mit dem Rulebook-Expert.
|
||||
- **Infrastructure:** Anpassung an den `org.jetbrains.exposed.v1` Namespace und Implementierung von UUID-Konvertierungen zwischen `kotlin.uuid.Uuid` (Domain) und `java.util.UUID` (DB).
|
||||
- **API:** Refactoring des `VeranstaltungController` zur direkten Repository-Nutzung (Alignment mit `entries` Service).
|
||||
- **Service-Config:** Umstellung auf Flyway-basiertes Tenant-Schema-Management in `EventsDatabaseConfiguration`.
|
||||
- **Build:** Behebung des `shadowJar` Fehlers in `events-infrastructure` durch Entfernen des unnötigen `ktor` Plugins in der Library-Schicht.
|
||||
|
||||
- Masterdata: Automatisches Seeding aller Reiterlizenzen (license_matrix) beim Start des `masterdata-service` via `ReiterlizenzenSeeder` (idempotent; SPRINGEN: LIZENZFREI,R1–R4; DRESSUR: LIZENZFREI,RD1–RD3).
|
||||
|
||||
- **ZNS-Import (LIZENZ01.dat):** Robuster Lizenz-Tokenizer und Normalizer implementiert.
|
||||
- Erkennung: `RD1..RD4`, `R1..R4`, `S1..S4`, `D2..D4`, Kombis `R{n}D{m}`, `R{n}S{k}`, `RDS4` (rechts-/letztes Vorkommen gewinnt).
|
||||
- Normalisierung: `S*→R*`, `D*→RD*`, `RD4→RD3` (bis Enum verfügbar), `R{n}S{k}→Rmax(n,k)`, `R{n}D{m}→R{n}+RD{m}`.
|
||||
- Integration: `ZnsReiterParser` füllt `lizenzen`-Liste (1:n) entsprechend und leitet `lizenzKlasse` bei fehlendem 4‑Spalten‑Code aus Token ab.
|
||||
- QA: Neue Unit-Tests (Tokenizer) für Beispiele `R2S3`, `R2D4`, `RD2` u. a.; alle Parser-Tests grün.
|
||||
|
||||
- **Core:** Modularisierte ZNS-Parser eingeführt (`ZnsVereinParser`, `ZnsReiterParser`, `ZnsPferdParser`, `ZnsFunktionaerParser`) zur Verbesserung der Wartbarkeit und Unterstützung von Einzelimporten.
|
||||
- **Fix:** SQL-Migrationsfehler in `V010` behoben, indem die Umbenennung der Spalte `name` in `verein_name` durch einen idempotenten `DO`-Block abgesichert wurde (behebt "Unable to resolve column 'name'").
|
||||
- **Infrastructure:** Datenbank-Migration `V010` hinzugefügt, um das Schema final mit den `Exposed`-Modellen zu synchronisieren.
|
||||
- **Infrastructure:** Datei-Archivierung für hochgeladene ZNS-ZIP-Dateien im `ZnsImportOrchestrator` implementiert.
|
||||
- **Infrastructure:** `ZnsImportService` vollständig auf die neuen spezialisierten Parser umgestellt und als Spring-Bean im Backend registriert.
|
||||
- **QA:** Umfassende Test-Suite `ZnsParserTest.kt` mit realen ZNS-Daten (Hämmerle, Neuwirth, etc.) erstellt; Korrektur der Extraktions-Logik für Mitgliedsnummern (Position 147) und Funktionär-Daten (RICHT01).
|
||||
- **QA:** Neue Betriebsanleitung für ZNS-Importer Tests erstellt: `docs/07_Infrastructure/runbooks/ZNS_Importer_Test_Manual.md`.
|
||||
- **Infrastructure:** `MasterdataDatabaseConfiguration` korrigiert: Expliziter Aufruf von `Database.connect()` hinzugefügt, um Abstürze beim Anwendungsstart ("No database specified") zu beheben.
|
||||
- **Infrastructure:** `application.yml` im `masterdata-service` vervollständigt (DataSource-Konfiguration mit `pg-user`/`pg-password` und Flyway-Aktivierung).
|
||||
- **Domain:** Legacy-Spezifikationen für ZNS-Schnittstellen (Import/Export) formalisiert:
|
||||
- `docs/03_Domain/02_Reference/Legacy_Specs/OETO-2026_Meldestelle_Pflichtenheft_V2.4.md` (Basis-Satzarten A-N)
|
||||
- `docs/03_Domain/02_Reference/Legacy_Specs/OETO-2026_Meldestelle_Erweiterung-Schnittstelle_2014.md` (XML-Erweiterung, LinkID-Logik)
|
||||
- **QA B-2:** `OnboardingValidator`-Objekt extrahiert; `OnboardingValidatorTest.kt` (17 Unit-Tests: Pflichtfeld-Guard,
|
||||
Doppelklick-Schutz, Abbrechen-Reset, rememberSaveable-Regression)
|
||||
- **QA B-3:** `AbteilungsRegelServiceTest.kt` um 14 Tests erweitert: CSN-C-NEU ≤95 cm / ≥100 cm Pflicht-Teilung,
|
||||
ORGANISATORISCH, SEPARATE_SIEGEREHRUNG, Caprilli-Regression, Grenzfälle 90/110 cm
|
||||
- **Domain:** `AbteilungsTeilungsTypE` um `ORGANISATORISCH` und `SEPARATE_SIEGEREHRUNG` erweitert
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Masterdata/Infrastructure:** Kompilierfehler in `AltersklasseRepositoryImpl` durch Vereinheitlichung der Exposed-Tabellendefinition behoben:
|
||||
- `AltersklassenTable` → `AltersklasseTable`
|
||||
- Spalte `altersklassen_code` → `altersklasse_code`
|
||||
- Tabellenname `altersklassen` → `altersklasse`
|
||||
- **Masterdata/API:** Fehlendes Interface-Mapping ergänzt: `RegulationRepository` enthält nun `findAllTurnierklassen()`; `ExposedRegulationRepository` implementiert die Methode und `RegulationController` kompiliert wieder.
|
||||
- **ZNS-Import:** `AltersklassenExposedRepository` korrigiert (richtiger Domain-Typ `AltersklasseDefinition`, Mapping von `SparteE` und Zeitstempeln).
|
||||
|
||||
- **Migration V013:** Idempotent und robust gemacht. Alle `ALTER TABLE ... RENAME`-Operationen laufen nun nur, wenn die Quell-Tabelle existiert (Fix für "Unable to resolve table 'bundesland'/'turnierklasse'").
|
||||
- **Lizenz-Validierung:** `LicenseMatrixServiceImpl` um Cross-Discipline-Mapping R↔RD (ÖTO-Äquivalenzen) erweitert. Damit funktionieren Fälle wie Dressur-Starts mit Spring-Lizenz (R1→RD1, R2→RD2, R3/R4→RD3) bzw. umgekehrt konsistent.
|
||||
|
||||
- **Domain:** Striktere Spartenlizenz-Prüfung in `Reiter.hasLizenzForSparte` implementiert (RD1..RD3 nur DRESSUR; R1..R4 nur SPRINGEN). Behebt Testfehler „isEligible verweigert Start ohne passende Spartenlizenz“ im `LicenseMatrixServiceTest`.
|
||||
|
||||
### Behoben
|
||||
- **Backend (Entries):** Fehlschlagenden Unit-Test `berechneStartzeiten sollte Zeiten korrekt aufsummieren` korrigiert; der Test berücksichtigt nun den neuen 5-minütigen ÖTO-konformen Puffer nach der Parcoursbesichtigung (§43).
|
||||
- **Frontend (Desktop):** Build-Fehler ("No matching variant") beim `funktionaer-feature` behoben; fehlendes `build.gradle.kts` mit JVM-Target und Compose/Koin-Abhängigkeiten ergänzt.
|
||||
- **Frontend (Desktop):** Massive Inkonsistenzen in der Paketstruktur des `veranstalter-feature` bereinigt; alle Komponenten (ViewModel, Screens, Mocks) auf das Standardpaket `at.mocode.frontend.features.veranstalter` konsolidiert, um Redeklarationen und Import-Fehler zu beheben.
|
||||
- **Frontend (Desktop):** Kompilierfehler im `VeranstalterDetailScreen` durch korrekte Paket-Referenzierung des `FakeVeranstaltungStore` gelöst.
|
||||
|
||||
### Dokumentation
|
||||
- **Masterdata/Docs:** `REITER_LIZENZEN.md` überarbeitet:
|
||||
- Strikte Sparten-Trennung dokumentiert (RD1..RD3 nur Dressur; R1..R4 nur Springen).
|
||||
- Dressur-Tabelle korrigiert (R-Lizenzen entfernen; RD-Pflicht je Klasse).
|
||||
- Validierungslogik ergänzt (2-stufig: Spartenlizenz → Max-Turnierklasse; R↔RD Mapping nur zur Kappung, nicht zur Eligibility).
|
||||
- Vielseitigkeit (CCN/CCI) ergänzt: kumulative Anforderungen (Dressur RD* UND Springen R* je Klasse); Startkartenregel für Einsteiger.
|
||||
- Fahren (CAN/CAI) ergänzt: aktueller Systemzustand ohne `F*`‑Lizenzen dokumentiert; Teilnahme über Startkarte/Ausschreibung, geplante Enum‑Erweiterung vermerkt.
|
||||
- §15‑Tabelle (kompakt) integriert und auf ÖTO‑Referenz verlinkt; Bedeutungen „B,C“ und „LP“ erläutert. Hinweis aufgenommen, dass `RD4` derzeit nicht als Enum vorhanden ist und wie `RD3` behandelt wird.
|
||||
- Kombinationsreihen gemäß §15 ergänzt: `R1S2`, `R1S3`, `R1S4`, `R2S3`, `R2S4`, `R3S4` (neuer Unterabschnitt 2.6 mit Tabelle, identische Spalten wie 2.5).
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Masterdata:** Qualifikations-Management für Funktionäre (Richter/Parcoursbauer) professionalisiert: Umstellung von unstrukturiertem Text auf offizielle ÖTO/FEI Master-Daten Referenzen (`QualifikationMasterTable`).
|
||||
- **Masterdata:** Fehlende Tabelle `funktionaer_qualifikation` in der Initialisierung beider Services (`masterdata` und `zns-import`) ergänzt, um `PSQLException` während des ZNS-Imports zu beheben.
|
||||
- **Infrastructure:** Start-Probleme des `masterdata-service` endgültig behoben: Port-Konflikt zwischen Spring Boot (Management/Actuator) und dem Gateway (8081) durch Umzug auf Port 8086 (gemäß Infrastruktur-Vorgaben) gelöst.
|
||||
- **Infrastructure:** Port-Konflikt im `masterdata-service` durch Trennung der Bind-Adressen (Spring: 127.0.0.1, Ktor: 0.0.0.0) und Bereinigung verwaister Prozesse stabilisiert.
|
||||
- **Core:** Veraltete `ZnsLegacyParsersTest.kt` entfernt; alle Tests sind nun in `ZnsParserTest.kt` konsolidiert.
|
||||
- **Domain:** Fehlschlagenden `LicenseMatrixServiceTest` behoben; fehlende `reiterLizenz`-Daten in Test-Reitern ergänzt und Fallback-Logik in `LicenseMatrixServiceImpl` für spartenübergreifende Lizenzen (z.B. Springlizenz für Dressur-Basis) stabilisiert.
|
||||
- **Infrastructure:** Fehlschlagenden `RegulationSeedVerificationTest` behoben; Testdaten an das neue Modell (`reiterLizenz` Feld) angepasst.
|
||||
- **Infrastructure:** Kompilierfehler 'Unresolved reference lizenzKlasse' in `ReiterExposedRepository` behoben; fehlendes Feld `lizenzKlasse` zu `ReiterTable` und Datenbank-Migration `V010` hinzugefügt.
|
||||
- **Onboarding:** `remember` → `rememberSaveable` für `geraetName`, `sharedKey`, `znsStatus` in `OnboardingScreen.kt` (
|
||||
Felder gingen bei Zurück-Navigation verloren)
|
||||
- **AbteilungsRegelService:** CSN-C-NEU Pflicht-Teilungslogik implementiert (≤95 cm: ohne/mit Lizenz; ≥100 cm: R1/R2+);
|
||||
`SparteE`-Import ergänzt
|
||||
|
||||
- Desktop-Packaging konfiguriert: `.deb` (Linux), `.msi` (Windows), `.dmg` (macOS)
|
||||
- Zentrale Versionsdatei `version.properties` (Single Source of Truth für SemVer)
|
||||
- Automatisches Git-Tagging via CI/CD (`release.yml` Gitea Actions Workflow)
|
||||
- `CHANGELOG.md` eingeführt (dieses Dokument)
|
||||
|
||||
---
|
||||
|
||||
## [1.0.6-SNAPSHOT] — 2026-04-10
|
||||
|
||||
### Hinzugefügt
|
||||
- **Entries-Domain:** Strukturiertes Abteilungs-Warnungssystem gemäß ÖTO § 39 implementiert.
|
||||
- Neues Value Object `AbteilungsWarnung` und Enum `AbteilungsWarnungCodeE` für präzise Fehlermeldungen und ÖTO-Referenzen.
|
||||
- Erweiterung von `Bewerb` um die Methode `validateStrukturellesTeilung` zur Prüfung vorgeschriebener Abteilungsstrukturen (z.B. Lizenz-Trennung bei CSN-C-NEU, Stilspringen, Caprilli).
|
||||
- Umstellung des `CompetitionWarningService` und `AbteilungsRegelService` auf das neue strukturierte Warnungsmodell.
|
||||
- **Entries-Service:** Erweiterung der REST-API (`BewerbeController`) um die Auslieferung von Warnungen in den DTOs (`BewerbResponse`).
|
||||
- **Frontend (Turnier-Feature):** Visuelle Integration der Abteilungs-Warnungen in der Bewerbe-Liste.
|
||||
- Anzeige eines Warn-Icons (gelb) bei Regelverstößen.
|
||||
- Tooltip-Funktionalität zur Anzeige der detaillierten Warnungstexte und ÖTO-Paragraphen.
|
||||
- Erweiterung des `BewerbUiModel` und Repositories zur Unterstützung der Warnungs-Metadaten.
|
||||
|
||||
### Geändert
|
||||
- **QA:** `AbteilungsRegelServiceTest` und `BewerbTest` auf das neue Warnungssystem aktualisiert und um Tests für strukturelle Teilungen (CSN Stilspringen, Caprilli) erweitert.
|
||||
- **KMP:** Korrektur von veralteten `Instant`-Deprecations in Testklassen (`kotlin.time.Instant`).
|
||||
|
||||
## [1.0.5-SNAPSHOT] — 2026-04-06
|
||||
|
||||
### Geändert
|
||||
- **Masterdata:** Bereinigung und Standardisierung von Masterdaten-Tabellen (Mehrzahl-Konvention):
|
||||
- `bundesland` -> `bundeslaender`
|
||||
- `qualifikation_master` -> `funktionaers_qualifikationen`
|
||||
- `reiter_lizenz` -> `reit_lizenzen`
|
||||
- `turnierklasse` -> `turnier_klassen`
|
||||
- **Seeding:** Umfassende Erweiterung der Seeder für Funktionärs-Qualifikationen, Turnierklassen und Turnier-Sparten gemäß ÖTO.
|
||||
- **Data Modeling:** Einführung der Tabelle `turnier_sparten` und Entfernung der redundanten `reiter_sparte`.
|
||||
- **Infrastructure:** Datenbank-Migration `V013` implementiert alle Schema-Änderungen und Umbenennungen.
|
||||
|
||||
## [1.0.4-SNAPSHOT] — 2026-04-06
|
||||
|
||||
### Hinzugefügt
|
||||
- **Reiter-Lizenzen:** Strukturierte Speicherung von Lizenzen (STARTKARTE, REITERLIZENZ, FAHRLIZENZ) in einer 1:n Relation (`ReiterLizenzTable`).
|
||||
- **Altersklassen:** Einführung von Enums (`ReiterAltersKlasseE`) für präzise Filterung und Validierung im Domain-Modell und Parser.
|
||||
- **Mitgliedsnummer:** Validierungs-Logik gemäß ÖTO-Spezifikation (Bundesland-Präfix 1-9) in `Reiter.kt` implementiert.
|
||||
- **ZNS-Import:** `ZnsReiterParser` erweitert, um Lizenzen und Altersklassen-Enums direkt aus LIZENZ01.DAT zu extrahieren.
|
||||
- **Persistenz:** `ReiterExposedRepository` unterstützt nun das transaktionale Speichern und Laden der 1:n Lizenzen.
|
||||
|
||||
## [1.0.3-SNAPSHOT] — 2026-04-06
|
||||
|
||||
### Hinzugefügt
|
||||
- **Masterdata:** Refactoring der Reiter-Stammdaten (LIZENZ01.DAT). Bundesland, Verein und Nation werden nun über Master-Tabellen referenziert.
|
||||
- **Domain:** Validierungslogik für die 8-stellige OEPS-Mitgliedsnummer im `Reiter`-Modell implementiert.
|
||||
- **Infrastructure:** Neue Tabellen `reiter_lizenz` (1:n Beziehung) und Migration `V012` zur Schemaanpassung und Datenbereinigung eingeführt.
|
||||
- **ZNS-Import:** Automatisches Auflösen von Relationen (Verein nach Name, Bundesland nach Nummer, Nation nach ISO-Code) während des Reiter-Imports.
|
||||
|
||||
### Behoben
|
||||
- **Infrastruktur:** Consul Health-Check für `masterdata-service` korrigiert (Port 8086 für Actuator).
|
||||
- **Masterdaten:** `MasterdataSeeder` für Nationen und Bundesländer hinzugefügt, um Datenvollständigkeit nach Volume-Cleanup sicherzustellen.
|
||||
- **Datenintegrität:** Heilungs-Logik (`fixReiterForeignKeys`) implementiert, die Reiter-Datensätze nachträglich mit Masterdaten verknüpft.
|
||||
- **Code-Qualität:** Redundante `BundeslandTable` Definition in `ReiterTable.kt` entfernt.
|
||||
- **Infrastruktur:** `BeanDefinitionOverrideException` im `zns-import-service` durch Konsolidierung der Repositories in `RepositoryConfiguration` behoben.
|
||||
- **Service-Discovery:** Fehlende Consul-Registrierung des `masterdata-service` durch Hinzufügen der Discovery-Dependency und Konfiguration behoben.
|
||||
- **Build:** Kompilierfehler in `BundeslandExposedRepository.kt` behoben (inkonsistente Rückgabetypen im `BundeslandRepository`-Interface).
|
||||
- **Infrastruktur:** Fehlendes Autowiring im `zns-import-service` durch explizite Bean-Definitionen für alle Repositories in `ZnsImportServiceApplication.kt` behoben.
|
||||
- **Domain:** Kompilierfehler in `Bundesland.kt` behoben (uninitialisierte Eigenschaft `bundeslandId` entfernt).
|
||||
- **Migration:** SQL-Syntaxfehler in `V012` behoben (korrekter Fremdschlüssel-Constraint für `reiter_lizenz` und Wiederherstellung des `DO $$`-Blocks).
|
||||
|
||||
## [1.0.2-SNAPSHOT] — 2026-04-06
|
||||
|
||||
### Geändert
|
||||
- **ZNS-Import:** `ZnsImportService` stabilisiert (ZipInputStream-Management korrigiert), um sequentielle Imports in Tests zu ermöglichen.
|
||||
- **Test-Vollständigkeit:** `ZnsImportServiceTest` korrigiert (Mocking für Reiter-Suche ergänzt, Testdaten für Funktionäre an Int-Parser angepasst). Alle 9 Tests nun grün.
|
||||
- **Data Modeling:** Redundante Kontakt- und Adressdaten aus `FunktionaerTable` entfernt; stattdessen Verknüpfung zu `ReiterTable` via `reiter_id` hinzugefügt. (Bereinigung der Felder erfolgte in `V010`).
|
||||
- **Import:** ZNS-Importer verknüpft nun Funktionäre automatisch mit vorhandenen Reitern anhand des Namens (Nachname, Vorname).
|
||||
- **Infrastructure:** `findByName` in `ReiterRepository` implementiert für effiziente Suche während des Imports.
|
||||
- **Datenbank:** Migration `V011` hinzugefügt, um die Fremdschlüsselbeziehung zu etablieren.
|
||||
|
||||
## [1.0.1-SNAPSHOT] — 2026-04-05
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Masterdata:** Funktionär-Datenmodell und API bereinigt und vollständig dokumentiert. Konsistente Verwendung von `satzId` (statt `satzID`) in allen Schichten (Domain, Infrastructure, API).
|
||||
- **Refactoring:** `DomVerein` zu `Verein`, `DomReiter` zu `Reiter`, `DomPferd` zu `Pferd` und `DomFunktionaer` zu `Funktionaer` umbenannt (Domain, Infrastructure, API, Core).
|
||||
- **Domain:** `personId` ist nun optional (`nullable`) bei `Verein`, `Reiter`, `Pferd` und `Funktionaer`, um ZNS-Initialimporte zu unterstützen.
|
||||
- **Infrastructure:** `VereinTable`, `ReiterTable`, `HorseTable` und `FunktionaerTable` synchronisiert; `personId` ist nun optional.
|
||||
- **API:** `VereinController`, `ReiterController`, `HorseController` und `FunktionaerController` (DTOs/Requests) an die neuen Modelle angepasst.
|
||||
- **Doku:** `Ubiquitous_Language.md` und `MASTER_ROADMAP.md` an das neue Namensschema (`Reiter`, `Pferd`, `Funktionaer`) angepasst.
|
||||
|
||||
### Behoben
|
||||
- **ZNS-Import:** Kompatibilitätsprobleme in `ZnsLegacyParsers` und `ZnsImportService` nach Domain-Refactorings behoben (UUID-Person-Referenzen und Enum-Synchronisation).
|
||||
- **Domain:** Felder `kurzname` und `oepsRegionNummer` bei `Verein` entfernt (nicht in VEREIN01.DAT vorhanden).
|
||||
|
||||
## [1.0.0-SNAPSHOT] — 2026-04-03
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Sprint A:** Docker-Compose-Setup, Healthchecks für alle Services
|
||||
- **Sprint B:** CI/CD Pipeline für Compose Desktop Tests (headless, Xvfb)
|
||||
- **Sprint B:** Gradle-Build-Optimierungen (Cache, Parallel, Wrapper 9.4.0)
|
||||
- **Sprint B:** Onboarding-Wizard (Veranstalter, Verein, Turnier, Bewerb, Abteilung)
|
||||
- **Sprint B:** `BewerbRepository`, `AbteilungRepository`, `DefaultTurnierRepository`
|
||||
- **Sprint B:** `ReiterProfilEditDialog`, `PferdProfilEditDialog` mit `MsValidationWrapper`
|
||||
- **Sprint B:** ÖTO-Regelwerk als Regulation-as-Data (Lizenz-/Altersmatrix, V008/V009 Migrations)
|
||||
- **Sprint B:** Tenant-Isolation Grundstruktur (Multi-Tenant Postgres-Schemas)
|
||||
- **Sprint B:** Architektur-Tests (`:platform:architecture-tests`)
|
||||
|
||||
### Geändert
|
||||
|
||||
- Gradle Wrapper auf `9.3.1` aktualisiert
|
||||
- JVM-Toolchain auf Java 25 angehoben
|
||||
|
||||
---
|
||||
|
||||
<!-- Versions-Links (anpassen sobald Gitea-URL bekannt) -->
|
||||
|
||||
[Unreleased]: https://gitea.mo-code.at/meldestelle/Meldestelle-Biest/compare/v1.0.0-SNAPSHOT...HEAD
|
||||
|
||||
[1.0.0-SNAPSHOT]: https://gitea.mo-code.at/meldestelle/Meldestelle-Biest/releases/tag/v1.0.0-SNAPSHOT
|
||||
@@ -1,6 +1,6 @@
|
||||
# Meldestelle
|
||||
|
||||
> Modulares System für Pferdesportveranstaltungen — gebaut mit Domain-Driven Design, Kotlin Multiplatform und Microservices.
|
||||
> Desktop‑First Meldestelle: Offline‑fähige Compose‑Desktop‑App mit optionalem Backend‑Stack. Domänengetrieben (DDD), Kotlin Multiplatform.
|
||||
|
||||
[](https://git.mo-code.at/mocode-software/meldestelle/actions)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
@@ -15,20 +15,28 @@ Die gesamte Projektdokumentation (Architektur, Fachdomäne, Entwickler-Anleitung
|
||||
|
||||
| Bereich | Inhalt |
|
||||
|-----------------------------------------------|---------------------------------------------|
|
||||
| [01_Architecture](./docs/01_Architecture) | Master Roadmap, ADRs, C4-Modelle |
|
||||
| [01_Architecture](./docs/01_Architecture) | Master Roadmap, ADRs, C4‑Modelle, Desktop‑Konzept |
|
||||
| [02_Guides](./docs/02_Guides) | Setup-Anleitungen, Entwickler-Guidelines |
|
||||
| [03_Domain](./docs/03_Domain) | Fachlichkeit, Turnierregeln, Entities |
|
||||
| [07_Infrastructure](./docs/07_Infrastructure) | Docker, Keycloak, CI/CD, Zora-Infrastruktur |
|
||||
|
||||
Wesentliche Architektur-Referenz: [Offline‑First Desktop & Backend (Kurzkonzept)](./docs/01_Architecture/konzept-offline-first-desktop-backend-de.md)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Tech Stack
|
||||
## 🖥️ Primärer Fokus: Desktop‑App
|
||||
|
||||
- Compose Multiplatform (JVM Desktop) als primäre Zielplattform
|
||||
- Offline‑First: Lokale SQLDelight‑DB, Synchronisation optional
|
||||
- Multi‑Tenant‑Backend optional für Sync/Verwaltung (Schema‑per‑Tenant, vgl. ADR‑0021)
|
||||
|
||||
## 🏗️ Tech Stack (aktueller Stand)
|
||||
|
||||
| Schicht | Technologie |
|
||||
|-----------------------|---------------------------------------------------|
|
||||
| **Backend** | Kotlin, Spring Boot 3.x, Spring Cloud Gateway |
|
||||
| **Frontend** | Kotlin Multiplatform (KMP), Compose Multiplatform |
|
||||
| **Datenbank** | PostgreSQL + Exposed / JPA |
|
||||
| **Frontend (primär)** | Kotlin Multiplatform (KMP), Compose Multiplatform |
|
||||
| **Backend** | Kotlin (Ktor/Spring Boot), Spring Cloud Gateway |
|
||||
| **Datenbank** | SQLDelight (lokal), PostgreSQL (Backend) |
|
||||
| **Auth** | Keycloak (OAuth2 / OIDC) |
|
||||
| **Cache** | Valkey |
|
||||
| **Service Discovery** | Consul |
|
||||
@@ -38,27 +46,51 @@ Die gesamte Projektdokumentation (Architektur, Fachdomäne, Entwickler-Anleitung
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start (Lokal)
|
||||
## 🚀 Quick Start (lokale Entwicklung)
|
||||
|
||||
Empfohlen: Starte die Desktop‑App direkt aus dem Repo. Der Backend‑Stack ist optional und wird nur für Sync/Integration benötigt.
|
||||
|
||||
### A) Desktop‑App starten (ohne Backend)
|
||||
|
||||
Voraussetzungen: JDK 21+, aktuelle Gradle Wrapper verwendet.
|
||||
|
||||
```bash
|
||||
# 1. Umgebungsvariablen vorbereiten (nur beim ersten Mal)
|
||||
cp .env.example .env
|
||||
# Desktop‑Shell ausführen (Compose Desktop)
|
||||
./gradlew :frontend:shells:meldestelle-desktop:run
|
||||
```
|
||||
|
||||
# 2. Infrastruktur starten (Postgres, Keycloak, Valkey, Consul, Zipkin)
|
||||
Hinweise:
|
||||
- Beim ersten Start wird die lokale Datenbank initialisiert (Offline‑First).
|
||||
- Architektur-Referenz: `docs/06_Frontend/MVVM_UDF_Pattern.md` (UDF/MVVM für ViewModels)
|
||||
|
||||
### B) Optional: Backend‑Stack per Docker starten
|
||||
|
||||
```bash
|
||||
# 1. Umgebungsvariablen (nur beim ersten Mal)
|
||||
cp .env.example .env || true
|
||||
|
||||
# 2. Infrastruktur (Postgres, Keycloak, Valkey, Consul, Zipkin)
|
||||
docker compose -f docker-compose.yaml -f dc-infra.yaml up -d
|
||||
|
||||
# 3. Backend-Services starten (Gateway, Ping-Service)
|
||||
# 3. Backend‑Services (Gateway, Masterdata, Ping, …)
|
||||
docker compose -f docker-compose.yaml -f dc-backend.yaml up -d
|
||||
|
||||
# 4. Ops-Stack starten (Prometheus, Grafana)
|
||||
# 4. Ops‑Stack (Prometheus, Grafana)
|
||||
docker compose -f docker-compose.yaml -f dc-ops.yaml up -d
|
||||
|
||||
# 5. Optional: Web-App starten
|
||||
# 5. Optional: Web‑Shell
|
||||
docker compose -f docker-compose.yaml -f dc-gui.yaml up -d
|
||||
```
|
||||
|
||||
> ⚠️ **Reihenfolge beachten:** Infra muss `healthy` sein, bevor Backend gestartet wird.
|
||||
> Keycloak benötigt ~60–90 Sekunden zum Hochfahren.
|
||||
> ⚠️ Reihenfolge beachten: Infra muss `healthy` sein, bevor Backend gestartet wird. Keycloak benötigt ~60–90 Sekunden.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Legacy (V1) Hinweise
|
||||
|
||||
- Ältere V1‑Abschnitte/Anleitungen (Web‑First/Microservices‑Only) gelten als DEPRECATED.
|
||||
- Verwende für lokale Entwicklung primär die Desktop‑App (siehe Quick Start). Docker‑Stacks sind optional für Integration/Sync.
|
||||
- In der Doku sind V1‑Seiten entsprechend markiert oder werden sukzessive bereinigt (siehe Roadmaps Sprint C‑4).
|
||||
|
||||
### Wichtige lokale Ports
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
||||
|
||||
# Tech-Stack Referenz: Kotlin 2.3.0 & Java 25 (KMP)
|
||||
|
||||
### 1. Kern-Spezifikationen
|
||||
|
||||
| Komponente | Version | Status |
|
||||
| --- |----------| --- |
|
||||
| **Kotlin** | `2.3.0` | Stabil (K2 Compiler standardmäßig aktiv) |
|
||||
| **Java (JDK)** | `25` | LTS (Long-Term Support) |
|
||||
| **Gradle** | `9.2.1` | Erforderlich für JDK 25 Support |
|
||||
| **Android Plugin (AGP)** | `8.8.0+` | Empfohlen für Gradle 9.x Kompatibilität |
|
||||
|
||||
---
|
||||
|
||||
### 2. Gradle Konfiguration (`build.gradle.kts`)
|
||||
|
||||
Für ein **Kotlin Multiplatform (KMP)** Projekt ist die Java Toolchain-Konfiguration entscheidend, um sicherzustellen, dass der Kotlin-Compiler und die JVM-Targets Java 25 korrekt ansprechen.
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
kotlin("multiplatform") version "2.3.0"
|
||||
id("com.android.library") version "8.8.0" // Falls Android Target genutzt wird
|
||||
}
|
||||
|
||||
kotlin {
|
||||
// Globale Toolchain-Definition für alle JVM/Android Targets
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(25))
|
||||
}
|
||||
|
||||
jvm {
|
||||
compilations.all {
|
||||
compilerOptions.configure {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weitere Targets (Beispiel iOS)
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Gradle Wrapper Update
|
||||
|
||||
Damit das Projekt Java 25 erkennt, muss der Wrapper auf dem neuesten Stand sein:
|
||||
|
||||
**Terminal-Befehl:**
|
||||
|
||||
```bash
|
||||
./gradlew wrapper --gradle-version 9.2.1 --distribution-type all
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Wichtige Kompatibilitätshinweise für das Plugin
|
||||
|
||||
* **IDE-Version:** Stelle sicher, dass **IntelliJ IDEA 2025.3** (oder neuer) installiert ist, da erst diese Version die volle Unterstützung für JDK 25 Sprachfeatures und das Kotlin 2.3.0 Plugin bietet.
|
||||
* **K2 Compiler:** Kotlin 2.3.0 nutzt den K2-Compiler. Falls das Google AI Pro Plugin Code-Analysen durchführt, sollte es auf dem K2-Modus basieren.
|
||||
* **Bytecode:** Java 25 Bytecode wird nur generiert, wenn das `jvmTarget` explizit auf `25` gesetzt ist. Andernfalls verbleibt Kotlin standardmäßig bei einer niedrigeren Version (meist 1.8 oder 11), was die neuen JDK-Features einschränken könnte.
|
||||
|
||||
---
|
||||
|
||||
### 5. Bekannte Features in diesem Setup
|
||||
|
||||
* **Java 25 Features:** Unterstützung für die finalen Versionen von *Scoped Values* und *Structured Concurrency*.
|
||||
* **Kotlin 2.3.0 Features:** Nutzung von `explicit backing fields` und dem verbesserten `unused return value` Checker.
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,63 +0,0 @@
|
||||
### Analyse und Strategie zur Wiederherstellung und Strukturverbesserung
|
||||
|
||||
Es ist eine klassische Situation: Nach einem großen Technologie-Upgrade (Kotlin 2.1.0+, Java 25, Spring Boot 3.5.x) knirscht es oft an den Schnittstellen. Da dein `ping-service` als technischer Blueprint dient, ist er der absolut richtige Startpunkt.
|
||||
|
||||
Hier ist der Schlachtplan, um Ordnung zu schaffen und die hexagonale Architektur sauber zu etablieren:
|
||||
|
||||
### 1. Wo beginnen? Bottom-Up vs. Top-Down
|
||||
Da deine Infrastruktur aktuell nicht stabil läuft, empfehle ich einen **"Core-First"** Ansatz, gefolgt vom **Backend-Durchstich**.
|
||||
|
||||
* **Zuerst: Core & Platform:** Ohne eine stabile Basis (`platform-bom`, `platform-dependencies`, `core-domain`) werden die anderen Module immer wieder Kompilierfehler werfen.
|
||||
* **Dann: Der technische vertikale Durchstich (`ping-service`):** Sobald die Plattform steht, reparieren wir den Weg: `Infrastruktur (Docker) -> Ping-Service -> Gateway`.
|
||||
* **Zuletzt: Frontend:** Das Frontend (BFF-Gedanke) wird erst dann stabil, wenn die API-Contracts des Backends wieder verlässlich geliefert werden.
|
||||
|
||||
### 2. Ordnung schaffen: Der "Clean Desk" im Projekt
|
||||
Bevor wir Code fixen, müssen wir die Build-Umgebung aufräumen:
|
||||
|
||||
1. **Version Catalog Synchronität:** Deine `libs.versions.toml` nutzt bereits Java 25 und Kotlin 2.1.0. Prüfe, ob alle Gradle-Plugins (insbesondere das `compose-multiplatform` und `spring-boot` Plugin) mit Kotlin 2.1.0 kompatibel sind. Oft ist hier ein Downgrade auf die letzte stabile Version oder ein Upgrade auf Alpha/Beta-Versionen nötig, wenn man "Bleeding Edge" (Java 25) nutzt.
|
||||
2. **Modul-Konsolidierung (DDD):** Wie besprochen, solltest du die "Modul-Explosion" reduzieren.
|
||||
* **Vorschlag:** Statt 5 Module pro Domain (`api`, `common`, `domain`, `infrastructure`, `service`), reduziere es auf maximal zwei:
|
||||
* `domain-api`: Nur DTOs und Interfaces (für KMP-Sharing mit dem Frontend).
|
||||
* `domain-service`: Die gesamte Implementierung (Hexagonal strukturiert in Packages).
|
||||
|
||||
### 3. Hexagonale Architektur im `ping-service` umsetzen
|
||||
Dein `ping-service` ist aktuell noch sehr "Spring-lastig" (Controller ruft Service mit Circuit Breaker direkt auf). Für eine echte hexagonale Vorlage strukturiere das Modul `ping-service` intern wie folgt um:
|
||||
|
||||
```text
|
||||
at.mocode.ping.service
|
||||
├── adapter
|
||||
│ ├── in
|
||||
│ │ └── web (PingController - Dein primärer Port-Adapter)
|
||||
│ └── out
|
||||
│ └── persistence (PingRepositoryAdapter - Sekundärer Port-Adapter)
|
||||
├── application
|
||||
│ ├── port
|
||||
│ │ ├── in (PingUseCase - Das Interface für den Controller)
|
||||
│ │ └── out (PingOutputPort - Interface für die Datenbank)
|
||||
│ └── service (PingService - Hier liegt die Business Logik, OHNE Spring-Annotationen wo möglich)
|
||||
└── domain
|
||||
└── model (PingEntity/Value Objects)
|
||||
```
|
||||
|
||||
**Der Vorteil:** Wenn du dieses Muster im `ping-service` einmal sauber hast, kopierst du diese Package-Struktur für `registry`, `events` etc.
|
||||
|
||||
### 4. Konkrete Schritte zur Reparatur
|
||||
|
||||
**Schritt 1: Infrastruktur-Check (Docker)**
|
||||
Stelle sicher, dass die Basisdienste laufen. Java 25 benötigt oft neuere Container-Images.
|
||||
* Check `docker-compose.yaml`: Laufen Postgres und Keycloak?
|
||||
* `ping-service` application.yaml: Aktiviere die Datenbank-Verbindung (JPA), die aktuell noch auskommentiert ist, um den "echten" Test zu ermöglichen.
|
||||
|
||||
**Schritt 2: Backend API-Gateway Fix**
|
||||
Dein Gateway ist der Wächter. Wenn die Security-Konfiguration (`SecurityConfig.kt`) wegen Bibliotheks-Änderungen in Spring Security 7/Spring Boot 3.5 hakt, ist das Priorität 1.
|
||||
* Test: Kannst du den `ping-service` direkt aufrufen? Wenn ja, funktioniert das Gateway-Routing?
|
||||
|
||||
**Schritt 3: Frontend (BFF) Anpassung**
|
||||
Nutze das Frontend als Konsument. Wenn du den `PingApiClient` im Frontend hast, sollte dieser gegen das **Gateway** (BFF-Pattern) laufen, nicht direkt gegen den Service.
|
||||
|
||||
### Empfehlung zur Vorgehensweise (Prioritäten):
|
||||
1. **Gradle-Build stabilisieren:** Alle `:platform:*` und `:core:*` Module müssen mit `./gradlew build` fehlerfrei durchlaufen.
|
||||
2. **Ping-SCS fertigstellen:** Implementiere eine minimale Datenbank-Speicherung im `ping-service`. Das ist der Beweis, dass die JPA/Hibernate-Konfiguration mit Java 25 harmoniert.
|
||||
3. **Gateway-Security:** Stelle sicher, dass das JWT von Keycloak korrekt zum `ping-service` durchgereicht wird.
|
||||
|
||||
**Soll ich dir bei einem dieser spezifischen Schritte (z.B. der hexagonalen Package-Struktur für den Ping-Service) mit konkretem Code helfen?**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,323 +0,0 @@
|
||||
Meldestelle on main [✘»!+?] via 🅶 v9.2.1 via ☕ v25.0.1 via 🅺 v2.3.0
|
||||
❯ ./gradlew :backend:infrastructure:gateway:test --stacktrace
|
||||
Starting a Gradle Daemon, 2 stopped Daemons could not be reused, use --status for details
|
||||
Type-safe project accessors is an incubating feature.
|
||||
|
||||
> Task :backend:infrastructure:gateway:test
|
||||
|
||||
WebFluxSmokeTest > should load reactive web context and serve smoke endpoint() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
||||
|
||||
GatewayFiltersTests > should preserve existing correlation ID header() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
||||
|
||||
GatewayFiltersTests > should handle requests with X-Forwarded-For header() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayFiltersTests > should apply admin rate limit for admin users() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayFiltersTests > should add rate limiting headers() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayFiltersTests > should add correlation ID header when not present() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayFiltersTests > should apply different rate limits for auth endpoints() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayFiltersTests > should enforce rate limiting after exceeding limit() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayFiltersTests > should apply higher rate limit for authenticated users() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
KeycloakGatewayIntegrationTest > should initialize Spring context with Keycloak configuration() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
||||
|
||||
GatewayApplicationTests > contextLoads() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
||||
|
||||
FallbackControllerTests > should handle POST requests to masterdata fallback() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
||||
|
||||
FallbackControllerTests > should handle POST requests to default fallback() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should return masterdata service fallback response() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should handle POST requests to members fallback() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should handle POST requests to events fallback() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should return valid JSON structure for all fallback responses() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should handle POST requests to auth fallback() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > sollte Members Service Fallback Response zurueckgeben() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should have consistent error response structure() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > sollte Events Service Fallback Response zurueckgeben() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should return auth service fallback response() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > sollte Horses Service Fallback Response zurueckgeben() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should handle POST requests to horses fallback() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
FallbackControllerTests > should return default fallback response for unknown service() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should handle different HTTP methods allowed in CORS() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
||||
|
||||
GatewaySecurityTests > should handle complex CORS scenarios() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should handle PUT requests with CORS headers() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should allow requests from meldestelle domain() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should allow credentials in CORS requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should set max age for CORS requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should handle authorization headers in CORS requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should not duplicate CORS headers due to deduplication filter() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should handle CORS preflight requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should allow requests from localhost origins() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should maintain security headers in responses() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should handle POST requests with CORS headers() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewaySecurityTests > should handle DELETE requests with CORS headers() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayRoutingTests > should route ping service requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
||||
|
||||
GatewayRoutingTests > should route horses service requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayRoutingTests > should handle gateway info path request() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayRoutingTests > should route members service requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayRoutingTests > auth route is not configured anymore() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayRoutingTests > should route masterdata service requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
GatewayRoutingTests > should route events service requests() FAILED
|
||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
||||
|
||||
45 tests completed, 45 failed
|
||||
|
||||
> Task :backend:infrastructure:gateway:test FAILED
|
||||
|
||||
[Incubating] Problems report is available at: file:///home/stefan/WsMeldestelle/Meldestelle/build/reports/problems/problems-report.html
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':backend:infrastructure:gateway:test'.
|
||||
> There were failing tests. See the report at: file:///home/stefan/WsMeldestelle/Meldestelle/backend/infrastructure/gateway/build/reports/tests/test/index.html
|
||||
|
||||
* Try:
|
||||
> Run with --scan to generate a Build Scan (powered by Develocity).
|
||||
|
||||
* Exception is:
|
||||
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':backend:infrastructure:gateway:test'.
|
||||
at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.lambda$executeIfValid$1(ExecuteActionsTaskExecuter.java:135)
|
||||
at org.gradle.internal.Try$Failure.ifSuccessfulOrElse(Try.java:288)
|
||||
at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeIfValid(ExecuteActionsTaskExecuter.java:133)
|
||||
at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.execute(ExecuteActionsTaskExecuter.java:121)
|
||||
at org.gradle.api.internal.tasks.execution.ProblemsTaskPathTrackingTaskExecuter.execute(ProblemsTaskPathTrackingTaskExecuter.java:41)
|
||||
at org.gradle.api.internal.tasks.execution.FinalizePropertiesTaskExecuter.execute(FinalizePropertiesTaskExecuter.java:46)
|
||||
at org.gradle.api.internal.tasks.execution.ResolveTaskExecutionModeExecuter.execute(ResolveTaskExecutionModeExecuter.java:51)
|
||||
at org.gradle.api.internal.tasks.execution.SkipTaskWithNoActionsExecuter.execute(SkipTaskWithNoActionsExecuter.java:57)
|
||||
at org.gradle.api.internal.tasks.execution.SkipOnlyIfTaskExecuter.execute(SkipOnlyIfTaskExecuter.java:74)
|
||||
at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:36)
|
||||
at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.executeTask(EventFiringTaskExecuter.java:77)
|
||||
at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:55)
|
||||
at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:52)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||
at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter.execute(EventFiringTaskExecuter.java:52)
|
||||
at org.gradle.execution.plan.DefaultNodeExecutor.executeLocalTaskNode(DefaultNodeExecutor.java:55)
|
||||
at org.gradle.execution.plan.DefaultNodeExecutor.execute(DefaultNodeExecutor.java:34)
|
||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:355)
|
||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:343)
|
||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.lambda$execute$0(DefaultTaskExecutionGraph.java:339)
|
||||
at org.gradle.internal.operations.CurrentBuildOperationRef.with(CurrentBuildOperationRef.java:84)
|
||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:339)
|
||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:328)
|
||||
at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.execute(DefaultPlanExecutor.java:459)
|
||||
at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.run(DefaultPlanExecutor.java:376)
|
||||
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
||||
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
|
||||
Caused by: org.gradle.api.internal.exceptions.MarkedVerificationException: There were failing tests. See the report at: file:///home/stefan/WsMeldestelle/Meldestelle/backend/infrastructure/gateway/build/reports/tests/test/index.html
|
||||
at org.gradle.api.tasks.testing.AbstractTestTask.handleTestFailures(AbstractTestTask.java:703)
|
||||
at org.gradle.api.tasks.testing.AbstractTestTask.handleCollectedResults(AbstractTestTask.java:535)
|
||||
at org.gradle.api.tasks.testing.AbstractTestTask.executeTests(AbstractTestTask.java:527)
|
||||
at org.gradle.api.tasks.testing.Test.executeTests(Test.java:714)
|
||||
at org.gradle.internal.reflect.JavaMethod.invoke(JavaMethod.java:125)
|
||||
at org.gradle.api.internal.project.taskfactory.StandardTaskAction.doExecute(StandardTaskAction.java:58)
|
||||
at org.gradle.api.internal.project.taskfactory.StandardTaskAction.execute(StandardTaskAction.java:51)
|
||||
at org.gradle.api.internal.project.taskfactory.StandardTaskAction.execute(StandardTaskAction.java:29)
|
||||
at org.gradle.api.internal.tasks.execution.TaskExecution$3.run(TaskExecution.java:252)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
|
||||
at org.gradle.api.internal.tasks.execution.TaskExecution.executeAction(TaskExecution.java:237)
|
||||
at org.gradle.api.internal.tasks.execution.TaskExecution.executeActions(TaskExecution.java:220)
|
||||
at org.gradle.api.internal.tasks.execution.TaskExecution.executeWithPreviousOutputFiles(TaskExecution.java:203)
|
||||
at org.gradle.api.internal.tasks.execution.TaskExecution.execute(TaskExecution.java:170)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep.executeInternal(ExecuteStep.java:105)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep.access$000(ExecuteStep.java:44)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:59)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:56)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:56)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:44)
|
||||
at org.gradle.internal.execution.steps.CancelExecutionStep.execute(CancelExecutionStep.java:42)
|
||||
at org.gradle.internal.execution.steps.TimeoutStep.executeWithoutTimeout(TimeoutStep.java:75)
|
||||
at org.gradle.internal.execution.steps.TimeoutStep.execute(TimeoutStep.java:55)
|
||||
at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:50)
|
||||
at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:28)
|
||||
at org.gradle.internal.execution.steps.RemovePreviousOutputsStep.execute(RemovePreviousOutputsStep.java:68)
|
||||
at org.gradle.internal.execution.steps.RemovePreviousOutputsStep.execute(RemovePreviousOutputsStep.java:38)
|
||||
at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:61)
|
||||
at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:26)
|
||||
at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:69)
|
||||
at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:46)
|
||||
at org.gradle.internal.execution.steps.ResolveInputChangesStep.execute(ResolveInputChangesStep.java:39)
|
||||
at org.gradle.internal.execution.steps.ResolveInputChangesStep.execute(ResolveInputChangesStep.java:28)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.executeWithoutCache(BuildCacheStep.java:189)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.executeAndStoreInCache(BuildCacheStep.java:145)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.lambda$executeWithCache$4(BuildCacheStep.java:101)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.lambda$executeWithCache$5(BuildCacheStep.java:101)
|
||||
at org.gradle.internal.Try$Success.map(Try.java:170)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.executeWithCache(BuildCacheStep.java:85)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.lambda$execute$0(BuildCacheStep.java:74)
|
||||
at org.gradle.internal.Either$Left.fold(Either.java:116)
|
||||
at org.gradle.internal.execution.caching.CachingState.fold(CachingState.java:62)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:73)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:48)
|
||||
at org.gradle.internal.execution.steps.StoreExecutionStateStep.execute(StoreExecutionStateStep.java:46)
|
||||
at org.gradle.internal.execution.steps.StoreExecutionStateStep.execute(StoreExecutionStateStep.java:35)
|
||||
at org.gradle.internal.execution.steps.SkipUpToDateStep.executeBecause(SkipUpToDateStep.java:75)
|
||||
at org.gradle.internal.execution.steps.SkipUpToDateStep.lambda$execute$2(SkipUpToDateStep.java:53)
|
||||
at org.gradle.internal.execution.steps.SkipUpToDateStep.execute(SkipUpToDateStep.java:53)
|
||||
at org.gradle.internal.execution.steps.SkipUpToDateStep.execute(SkipUpToDateStep.java:35)
|
||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:37)
|
||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:27)
|
||||
at org.gradle.internal.execution.steps.ResolveIncrementalCachingStateStep.executeDelegate(ResolveIncrementalCachingStateStep.java:49)
|
||||
at org.gradle.internal.execution.steps.ResolveIncrementalCachingStateStep.executeDelegate(ResolveIncrementalCachingStateStep.java:27)
|
||||
at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:71)
|
||||
at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:39)
|
||||
at org.gradle.internal.execution.steps.ResolveChangesStep.execute(ResolveChangesStep.java:64)
|
||||
at org.gradle.internal.execution.steps.ResolveChangesStep.execute(ResolveChangesStep.java:35)
|
||||
at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:62)
|
||||
at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:40)
|
||||
at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:76)
|
||||
at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:45)
|
||||
at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.executeWithNonEmptySources(AbstractSkipEmptyWorkStep.java:136)
|
||||
at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.execute(AbstractSkipEmptyWorkStep.java:66)
|
||||
at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.execute(AbstractSkipEmptyWorkStep.java:38)
|
||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsStartedStep.execute(MarkSnapshottingInputsStartedStep.java:38)
|
||||
at org.gradle.internal.execution.steps.LoadPreviousExecutionStateStep.execute(LoadPreviousExecutionStateStep.java:36)
|
||||
at org.gradle.internal.execution.steps.LoadPreviousExecutionStateStep.execute(LoadPreviousExecutionStateStep.java:23)
|
||||
at org.gradle.internal.execution.steps.HandleStaleOutputsStep.execute(HandleStaleOutputsStep.java:75)
|
||||
at org.gradle.internal.execution.steps.HandleStaleOutputsStep.execute(HandleStaleOutputsStep.java:41)
|
||||
at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.lambda$execute$0(AssignMutableWorkspaceStep.java:35)
|
||||
at org.gradle.api.internal.tasks.execution.TaskExecution$4.withWorkspace(TaskExecution.java:297)
|
||||
at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.execute(AssignMutableWorkspaceStep.java:31)
|
||||
at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.execute(AssignMutableWorkspaceStep.java:22)
|
||||
at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:40)
|
||||
at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:23)
|
||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.lambda$execute$2(ExecuteWorkBuildOperationFiringStep.java:67)
|
||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:67)
|
||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:39)
|
||||
at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:46)
|
||||
at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:34)
|
||||
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:44)
|
||||
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:31)
|
||||
at org.gradle.internal.execution.impl.DefaultExecutionEngine$1.execute(DefaultExecutionEngine.java:64)
|
||||
at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeIfValid(ExecuteActionsTaskExecuter.java:132)
|
||||
... 30 more
|
||||
|
||||
|
||||
Deprecated Gradle features were used in this build, making it incompatible with Gradle 10.
|
||||
|
||||
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
|
||||
|
||||
For more on this, please refer to https://docs.gradle.org/9.2.1/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
|
||||
|
||||
BUILD FAILED in 23s
|
||||
17 actionable tasks: 4 executed, 13 up-to-date
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
Placeholder to ensure directory exists
|
||||
@@ -1,109 +0,0 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm("desktop")
|
||||
|
||||
js(IR) {
|
||||
// WICHTIG: Als Library kompilieren für Webpack Federation
|
||||
binaries.library()
|
||||
generateTypeScriptDefinitions()
|
||||
browser {
|
||||
commonWebpackConfig {
|
||||
cssSupport {
|
||||
enabled.set(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wasm vorerst deaktiviert
|
||||
/*
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
binaries.executable()
|
||||
}
|
||||
*/
|
||||
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(projects.frontend.core.domain)
|
||||
// implementation(projects.frontend.core.designSystem) // REMOVED: Circular dependency
|
||||
implementation(projects.frontend.core.navigation)
|
||||
implementation(projects.frontend.core.network)
|
||||
implementation(projects.frontend.core.localDb)
|
||||
|
||||
// Features - REMOVED: Circular dependency. Shared should NOT depend on features.
|
||||
// implementation(projects.frontend.features.authFeature)
|
||||
// implementation(projects.frontend.features.pingFeature)
|
||||
|
||||
// KMP Bundles
|
||||
implementation(libs.bundles.kmp.common)
|
||||
implementation(libs.bundles.compose.common)
|
||||
|
||||
// Ktor (used directly in shared/di and shared/network)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.contentNegotiation)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||
|
||||
// Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
// implementation(libs.sqldelight.coroutines) // Wird transitiv über core:localDb geladen
|
||||
|
||||
// Compose
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.components.resources)
|
||||
implementation(compose.components.uiToolingPreview)
|
||||
|
||||
// Koin
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
}
|
||||
}
|
||||
|
||||
commonTest {
|
||||
dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
}
|
||||
|
||||
val desktopMain by getting {
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
// implementation(libs.sqldelight.driver.sqlite) // Wird transitiv über core:localDb geladen
|
||||
}
|
||||
}
|
||||
|
||||
val jsMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
// implementation(libs.sqldelight.driver.web) // Wird transitiv über core:localDb geladen
|
||||
|
||||
// Webpack Plugin für Federation Support (falls benötigt)
|
||||
implementation(devNpm("copy-webpack-plugin", "12.0.0"))
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
val wasmJsMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.ktor.client.js)
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package at.mocode.shared.core
|
||||
|
||||
data class AppConfig(
|
||||
val gatewayUrl: String,
|
||||
val isDebug: Boolean
|
||||
)
|
||||
|
||||
// Standard-Config für Local Development
|
||||
val devConfig = AppConfig(
|
||||
gatewayUrl = "http://localhost:8081",
|
||||
isDebug = true
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
package at.mocode.shared.core
|
||||
|
||||
/**
|
||||
* Shared application configuration constants for clients.
|
||||
* These defaults target local development environments.
|
||||
*/
|
||||
object AppConstants {
|
||||
// Gateway base URL (reverse proxy / API gateway)
|
||||
// Used by NetworkConfig via PlatformConfig
|
||||
const val GATEWAY_URL: String = "http://localhost:8081"
|
||||
|
||||
// Keycloak configuration
|
||||
const val KEYCLOAK_URL: String = "http://localhost:8180"
|
||||
const val KEYCLOAK_REALM: String = "meldestelle"
|
||||
|
||||
// Use 'postman-client' for Desktop App Password Flow (Direct Access Grants enabled)
|
||||
// 'web-app' is for Browser Flow (PKCE)
|
||||
// TODO: Make this platform-dependent (Desktop vs Web)
|
||||
const val KEYCLOAK_CLIENT_ID: String = "web-app"
|
||||
|
||||
// DEV ONLY: Client Secret for 'postman-client' (Confidential Client)
|
||||
// In Production, this should NEVER be in the frontend code.
|
||||
// For the Desktop App Pilot, we use this to simulate a secure client.
|
||||
// For 'web-app' (Public Client), this is not needed/used if configured correctly,
|
||||
// but our AuthApiClient might be sending it.
|
||||
const val KEYCLOAK_CLIENT_SECRET: String = "postman-secret-123"
|
||||
|
||||
// Removed unused browser flow URLs (registerUrl, loginUrl, etc.) as we focus on Desktop App.
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package at.mocode.shared.data.repository
|
||||
|
||||
import at.mocode.shared.domain.model.PingData
|
||||
import at.mocode.shared.domain.model.Resource
|
||||
import at.mocode.shared.domain.repository.PingRepository
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
|
||||
class PingRepositoryImpl(
|
||||
private val httpClient: HttpClient
|
||||
) : PingRepository {
|
||||
|
||||
override suspend fun checkSystemStatus(): Resource<PingData> {
|
||||
return try {
|
||||
// Der HttpClient hat die BaseURL schon konfiguriert (siehe NetworkModule)
|
||||
val response = httpClient.get("/api/ping/simple").body<PingData>()
|
||||
Resource.Success(response)
|
||||
} catch (e: Exception) {
|
||||
// Hier fangen wir Netzwerkfehler ab und machen sie "hübsch" für die UI
|
||||
Resource.Error(
|
||||
message = "Verbindung fehlgeschlagen: ${e.message ?: "Unbekannter Fehler"}",
|
||||
code = "NETWORK_ERROR"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package at.mocode.shared.di
|
||||
|
||||
import at.mocode.shared.core.AppConfig
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.dsl.module
|
||||
|
||||
val networkModule = module {
|
||||
// 1. JSON Konfiguration (Global verfügbar)
|
||||
single {
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. HttpClient (Singleton)
|
||||
single {
|
||||
val config = get<AppConfig>()
|
||||
val jsonConfig = get<Json>()
|
||||
|
||||
HttpClient {
|
||||
// Standard-URL setzen
|
||||
defaultRequest {
|
||||
url(config.gatewayUrl)
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
|
||||
install(ContentNegotiation) {
|
||||
json(jsonConfig)
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
level = if (config.isDebug) LogLevel.INFO else LogLevel.NONE
|
||||
logger = Logger.DEFAULT
|
||||
}
|
||||
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10000
|
||||
connectTimeoutMillis = 10000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package at.mocode.shared.di
|
||||
|
||||
import at.mocode.shared.core.devConfig
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.dsl.KoinAppDeclaration
|
||||
import org.koin.dsl.module
|
||||
|
||||
// Das Modul für die Config
|
||||
val configModule = module {
|
||||
single { devConfig } // Später können wir hier PROD/DEV umschalten
|
||||
}
|
||||
|
||||
// Basismodule, die immer geladen werden sollen (ohne Feature/Core-Cross-Imports)
|
||||
val baseSharedModules = listOf(
|
||||
configModule,
|
||||
// Network module provides DI-only HttpClient (safe to be shared across features)
|
||||
networkModule
|
||||
)
|
||||
|
||||
// Helper zum Starten von Koin (wird von der App aufgerufen)
|
||||
// Weitere Module (z. B. networkModule) können über appDeclaration hinzugefügt werden.
|
||||
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
|
||||
modules(baseSharedModules)
|
||||
appDeclaration()
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package at.mocode.shared.domain.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Generischer Wrapper für API-Antworten.
|
||||
*/
|
||||
@Serializable
|
||||
data class ApiResponse<T>(
|
||||
val success: Boolean,
|
||||
val data: T? = null,
|
||||
val error: ApiError? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiError(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val details: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
/**
|
||||
* Das Ergebnis eines Repository-Aufrufs.
|
||||
* Die UI kennt nur das hier, keine HTTP-Exceptions!
|
||||
*/
|
||||
sealed class Resource<out T> {
|
||||
data class Success<T>(val data: T) : Resource<T>()
|
||||
data class Error(val message: String, val code: String? = null) : Resource<Nothing>()
|
||||
data object Loading : Resource<Nothing>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Datenmodell für den Ping.
|
||||
*/
|
||||
@Serializable
|
||||
data class PingData(
|
||||
val status: String,
|
||||
val timestamp: String,
|
||||
val service: String
|
||||
)
|
||||
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package at.mocode.shared.domain.repository
|
||||
|
||||
import at.mocode.shared.domain.model.PingData
|
||||
import at.mocode.shared.domain.model.Resource
|
||||
|
||||
interface PingRepository {
|
||||
suspend fun checkSystemStatus(): Resource<PingData>
|
||||
}
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
package at.mocode.shared.navigation
|
||||
|
||||
import at.mocode.shared.presentation.actions.AppAction
|
||||
import at.mocode.shared.presentation.store.AppStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Navigation manager for handling routing and navigation logic
|
||||
*/
|
||||
class NavigationManager(
|
||||
private val store: AppStore
|
||||
) {
|
||||
|
||||
/**
|
||||
* Current route as a flow
|
||||
*/
|
||||
val currentRoute: Flow<String> = store.state.map { it.navigation.currentRoute }
|
||||
|
||||
/**
|
||||
* Navigation history as a flow
|
||||
*/
|
||||
val navigationHistory: Flow<List<String>> = store.state.map { it.navigation.history }
|
||||
|
||||
/**
|
||||
* Can go back flag as a flow
|
||||
*/
|
||||
val canGoBack: Flow<Boolean> = store.state.map { it.navigation.canGoBack }
|
||||
|
||||
/**
|
||||
* Navigate to a specific route
|
||||
*/
|
||||
fun navigateTo(route: String) {
|
||||
store.dispatch(AppAction.Navigation.NavigateTo(route))
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back to the previous route
|
||||
*/
|
||||
fun navigateBack() {
|
||||
store.dispatch(AppAction.Navigation.NavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace current route without adding to history
|
||||
*/
|
||||
fun replaceRoute(route: String) {
|
||||
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear navigation history and navigate to the route
|
||||
*/
|
||||
fun navigateAndClearHistory(route: String) {
|
||||
// First clear by replacing with the new route
|
||||
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current route value (non-reactive)
|
||||
*/
|
||||
fun getCurrentRoute(): String = store.state.value.navigation.currentRoute
|
||||
|
||||
/**
|
||||
* Check if we can navigate back
|
||||
*/
|
||||
fun canNavigateBack(): Boolean = store.state.value.navigation.canGoBack
|
||||
}
|
||||
|
||||
/**
|
||||
* Route definitions for the application
|
||||
*/
|
||||
object Routes {
|
||||
const val HOME = "/"
|
||||
const val LOGIN = "/login"
|
||||
const val DASHBOARD = "/dashboard"
|
||||
const val PROFILE = "/profile"
|
||||
const val SETTINGS = "/settings"
|
||||
const val PING = "/ping"
|
||||
|
||||
// Auth-related routes
|
||||
object Auth {
|
||||
const val LOGIN = "/auth/login"
|
||||
const val LOGOUT = "/auth/logout"
|
||||
const val REGISTER = "/auth/register"
|
||||
const val FORGOT_PASSWORD = "/auth/forgot-password"
|
||||
}
|
||||
|
||||
// Admin routes
|
||||
object Admin {
|
||||
const val DASHBOARD = "/admin/dashboard"
|
||||
const val USERS = "/admin/users"
|
||||
const val SETTINGS = "/admin/settings"
|
||||
}
|
||||
|
||||
// Feature routes
|
||||
object Features {
|
||||
const val PING = "/features/ping"
|
||||
const val REPORTS = "/features/reports"
|
||||
const val NOTIFICATIONS = "/features/notifications"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route validation and utilities
|
||||
*/
|
||||
object RouteUtils {
|
||||
|
||||
/**
|
||||
* Check if a route requires authentication
|
||||
*/
|
||||
fun requiresAuth(route: String): Boolean {
|
||||
return when {
|
||||
route.startsWith("/auth/") && route != Routes.Auth.LOGIN -> false
|
||||
route == Routes.HOME -> false
|
||||
route == Routes.LOGIN -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a route is for admin only
|
||||
*/
|
||||
fun requiresAdmin(route: String): Boolean {
|
||||
return route.startsWith("/admin/")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default route for authenticated users
|
||||
*/
|
||||
fun getDefaultAuthenticatedRoute(): String = Routes.DASHBOARD
|
||||
|
||||
/**
|
||||
* Get the default route for unauthenticated users
|
||||
*/
|
||||
fun getDefaultUnauthenticatedRoute(): String = Routes.LOGIN
|
||||
|
||||
/**
|
||||
* Validate route format
|
||||
*/
|
||||
fun isValidRoute(route: String): Boolean {
|
||||
return route.startsWith("/") && route.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse route parameters (simple implementation)
|
||||
*/
|
||||
fun parseRouteParams(route: String): Map<String, String> {
|
||||
val params = mutableMapOf<String, String>()
|
||||
|
||||
// Simple query parameter parsing
|
||||
if (route.contains("?")) {
|
||||
val parts = route.split("?")
|
||||
if (parts.size == 2) {
|
||||
val queryParams = parts[1].split("&")
|
||||
queryParams.forEach { param ->
|
||||
val keyValue = param.split("=")
|
||||
if (keyValue.size == 2) {
|
||||
params[keyValue[0]] = keyValue[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clean route without parameters
|
||||
*/
|
||||
fun getCleanRoute(route: String): String {
|
||||
return if (route.contains("?")) {
|
||||
route.split("?")[0]
|
||||
} else {
|
||||
route
|
||||
}
|
||||
}
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
package at.mocode.shared.navigation
|
||||
|
||||
import at.mocode.shared.presentation.state.NavigationState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
/**
|
||||
* Interface für das Persistieren von Navigation State
|
||||
*/
|
||||
interface NavigationPersistence {
|
||||
suspend fun saveNavigationState(state: NavigationState)
|
||||
fun getNavigationState(): Flow<NavigationState?>
|
||||
suspend fun clearNavigationState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation ohne echte Persistierung (In-Memory)
|
||||
* Platform-spezifische Implementierungen können echte Persistierung bereitstellen
|
||||
*/
|
||||
class DefaultNavigationPersistence : NavigationPersistence {
|
||||
private var currentState: NavigationState? = null
|
||||
|
||||
override suspend fun saveNavigationState(state: NavigationState) {
|
||||
currentState = state
|
||||
}
|
||||
|
||||
override fun getNavigationState(): Flow<NavigationState?> {
|
||||
return flowOf(currentState)
|
||||
}
|
||||
|
||||
override suspend fun clearNavigationState() {
|
||||
currentState = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation History Manager mit Persistierung
|
||||
*/
|
||||
class NavigationHistoryManager(
|
||||
private val persistence: NavigationPersistence
|
||||
) {
|
||||
companion object {
|
||||
private const val MAX_HISTORY_SIZE = 50
|
||||
}
|
||||
|
||||
suspend fun saveRoute(route: String, history: List<String>) {
|
||||
val state = NavigationState(
|
||||
currentRoute = route,
|
||||
history = history.takeLast(MAX_HISTORY_SIZE),
|
||||
canGoBack = history.isNotEmpty()
|
||||
)
|
||||
persistence.saveNavigationState(state)
|
||||
}
|
||||
|
||||
fun getPersistedState() = persistence.getNavigationState()
|
||||
|
||||
suspend fun clear() = persistence.clearNavigationState()
|
||||
|
||||
/**
|
||||
* Optimiert die History für bessere Performance
|
||||
*/
|
||||
private fun optimizeHistory(history: List<String>): List<String> {
|
||||
// Entfernt Duplikate in Folge und behält nur die letzten N Einträge
|
||||
return history
|
||||
.fold(emptyList<String>()) { acc, route ->
|
||||
if (acc.lastOrNull() != route) acc + route else acc
|
||||
}
|
||||
.takeLast(MAX_HISTORY_SIZE)
|
||||
}
|
||||
|
||||
suspend fun addToHistory(newRoute: String, currentHistory: List<String>) {
|
||||
val optimizedHistory = optimizeHistory(currentHistory + newRoute)
|
||||
saveRoute(newRoute, optimizedHistory.dropLast(1))
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package at.mocode.shared.network
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object HttpClientConfig {
|
||||
|
||||
fun createClient(
|
||||
baseUrl: String = "http://localhost:8080"
|
||||
): HttpClient = HttpClient {
|
||||
|
||||
// Content negotiation with JSON (based on PingApiClient pattern)
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun createClientWithBaseUrl(baseUrl: String): HttpClient {
|
||||
return createClient(baseUrl)
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package at.mocode.shared.network
|
||||
|
||||
import at.mocode.shared.domain.model.ApiError
|
||||
import io.ktor.client.network.sockets.*
|
||||
import io.ktor.client.plugins.*
|
||||
import kotlinx.io.IOException
|
||||
|
||||
/**
|
||||
* Custom exceptions for network operations
|
||||
*/
|
||||
sealed class NetworkException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
val apiError: ApiError
|
||||
) : Exception(message, cause) {
|
||||
|
||||
class ConnectionException(
|
||||
message: String = "Connection failed",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "CONNECTION_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "network_connectivity")
|
||||
)
|
||||
)
|
||||
|
||||
class TimeoutException(
|
||||
message: String = "Request timed out",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "TIMEOUT_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "request_timeout")
|
||||
)
|
||||
)
|
||||
|
||||
class ServerException(
|
||||
statusCode: Int,
|
||||
message: String = "Server error",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "SERVER_ERROR",
|
||||
message = message,
|
||||
details = mapOf(
|
||||
"type" to "server_error",
|
||||
"status_code" to statusCode.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
class ClientException(
|
||||
statusCode: Int,
|
||||
message: String = "Client error",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "CLIENT_ERROR",
|
||||
message = message,
|
||||
details = mapOf(
|
||||
"type" to "client_error",
|
||||
"status_code" to statusCode.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
class AuthenticationException(
|
||||
message: String = "Authentication failed",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "AUTHENTICATION_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "authentication_failure")
|
||||
)
|
||||
)
|
||||
|
||||
class AuthorizationException(
|
||||
message: String = "Authorization failed",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "AUTHORIZATION_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "authorization_failure")
|
||||
)
|
||||
)
|
||||
|
||||
class UnknownException(
|
||||
message: String = "Unknown error occurred",
|
||||
cause: Throwable? = null
|
||||
) : NetworkException(
|
||||
message = message,
|
||||
cause = cause,
|
||||
apiError = ApiError(
|
||||
code = "UNKNOWN_ERROR",
|
||||
message = message,
|
||||
details = mapOf("type" to "unknown_error")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to convert various exceptions to NetworkException
|
||||
*/
|
||||
fun Throwable.toNetworkException(): NetworkException {
|
||||
return when (this) {
|
||||
is ConnectTimeoutException -> NetworkException.TimeoutException(
|
||||
message = "Connection timeout: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
|
||||
is SocketTimeoutException -> NetworkException.TimeoutException(
|
||||
message = "Socket timeout: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
|
||||
is ResponseException -> when (this.response.status.value) {
|
||||
401 -> NetworkException.AuthenticationException(
|
||||
message = "Authentication required",
|
||||
cause = this
|
||||
)
|
||||
|
||||
403 -> NetworkException.AuthorizationException(
|
||||
message = "Access forbidden",
|
||||
cause = this
|
||||
)
|
||||
|
||||
in 400..499 -> NetworkException.ClientException(
|
||||
statusCode = this.response.status.value,
|
||||
message = "Client error: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
|
||||
in 500..599 -> NetworkException.ServerException(
|
||||
statusCode = this.response.status.value,
|
||||
message = "Server error: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
|
||||
else -> NetworkException.UnknownException(
|
||||
message = "HTTP error: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
}
|
||||
|
||||
is IOException -> NetworkException.ConnectionException(
|
||||
message = "Network connection failed: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
|
||||
is NetworkException -> this
|
||||
else -> NetworkException.UnknownException(
|
||||
message = "Unexpected error: ${this.message}",
|
||||
cause = this
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package at.mocode.shared.network
|
||||
|
||||
import at.mocode.shared.domain.model.ApiError
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// Using platform-agnostic timestamp handling
|
||||
|
||||
/**
|
||||
* Simple timestamp provider for multiplatform compatibility
|
||||
*/
|
||||
expect fun currentTimeMillis(): Long
|
||||
|
||||
/**
|
||||
* Network utilities for handling retry logic and resilience
|
||||
*/
|
||||
object NetworkUtils {
|
||||
|
||||
/**
|
||||
* Retry configuration for network operations
|
||||
*/
|
||||
data class RetryConfig(
|
||||
val maxAttempts: Int = 3,
|
||||
val initialDelayMs: Long = 1000L,
|
||||
val maxDelayMs: Long = 10000L,
|
||||
val backoffMultiplier: Double = 2.0,
|
||||
val retryableExceptions: Set<String> = setOf(
|
||||
"CONNECTION_ERROR",
|
||||
"TIMEOUT_ERROR",
|
||||
"SERVER_ERROR"
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Execute operation with retry logic
|
||||
*/
|
||||
suspend fun <T> withRetry(
|
||||
config: RetryConfig = RetryConfig(),
|
||||
operation: suspend () -> RepositoryResult<T>
|
||||
): RepositoryResult<T> {
|
||||
var lastError: ApiError? = null
|
||||
var currentDelay = config.initialDelayMs
|
||||
|
||||
repeat(config.maxAttempts) { attempt ->
|
||||
try {
|
||||
val result = operation()
|
||||
|
||||
// Return success immediately
|
||||
if (result.isSuccess()) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Check if the error is retryable
|
||||
val error = result.getErrorOrNull()
|
||||
if (error != null && shouldRetry(error, config)) {
|
||||
lastError = error
|
||||
|
||||
// Don't delay on the last attempt
|
||||
if (attempt < config.maxAttempts - 1) {
|
||||
delay(currentDelay)
|
||||
currentDelay = minOf(
|
||||
(currentDelay * config.backoffMultiplier).toLong(),
|
||||
config.maxDelayMs
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Non-retryable error, return immediately
|
||||
return result
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val networkException = e.toNetworkException()
|
||||
lastError = networkException.apiError
|
||||
|
||||
if (shouldRetry(networkException.apiError, config)) {
|
||||
if (attempt < config.maxAttempts - 1) {
|
||||
delay(currentDelay)
|
||||
currentDelay = minOf(
|
||||
(currentDelay * config.backoffMultiplier).toLong(),
|
||||
config.maxDelayMs
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return RepositoryResult.Error(networkException.apiError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted, return last error
|
||||
return RepositoryResult.Error(
|
||||
lastError ?: ApiError(
|
||||
code = "MAX_RETRIES_EXCEEDED",
|
||||
message = "Maximum retry attempts exceeded"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should trigger a retry
|
||||
*/
|
||||
private fun shouldRetry(error: ApiError, config: RetryConfig): Boolean {
|
||||
return config.retryableExceptions.contains(error.code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Network connectivity checker (simplified for shared module)
|
||||
*/
|
||||
object ConnectivityChecker {
|
||||
private var isOnline: Boolean = true
|
||||
private var lastCheckMillis: Long = 0L
|
||||
|
||||
fun setOnlineStatus(online: Boolean) {
|
||||
isOnline = online
|
||||
lastCheckMillis = currentTimeMillis()
|
||||
}
|
||||
|
||||
fun isOnline(): Boolean = isOnline
|
||||
|
||||
fun getLastCheckMillis(): Long = lastCheckMillis
|
||||
|
||||
/**
|
||||
* Simple connectivity test by attempting a lightweight operation
|
||||
*/
|
||||
suspend fun checkConnectivity(testOperation: suspend () -> Boolean): Boolean {
|
||||
return try {
|
||||
val result = testOperation()
|
||||
setOnlineStatus(result)
|
||||
result
|
||||
} catch (_: Exception) {
|
||||
setOnlineStatus(false)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker pattern for network operations
|
||||
*/
|
||||
class CircuitBreaker(
|
||||
private val failureThreshold: Int = 5,
|
||||
private val recoveryTimeoutMs: Long = 60000L,
|
||||
private val successThreshold: Int = 3
|
||||
) {
|
||||
private enum class State { CLOSED, OPEN, HALF_OPEN }
|
||||
|
||||
private var state = State.CLOSED
|
||||
private var failureCount = 0
|
||||
private var successCount = 0
|
||||
private var lastFailureTime = 0L
|
||||
|
||||
suspend fun <T> execute(operation: suspend () -> RepositoryResult<T>): RepositoryResult<T> {
|
||||
when (state) {
|
||||
State.OPEN -> {
|
||||
if (currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
|
||||
state = State.HALF_OPEN
|
||||
successCount = 0
|
||||
} else {
|
||||
return RepositoryResult.Error(
|
||||
ApiError(
|
||||
code = "CIRCUIT_BREAKER_OPEN",
|
||||
message = "Circuit breaker is open, requests blocked"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
State.HALF_OPEN -> {
|
||||
// Allow limited requests to test recovery
|
||||
}
|
||||
|
||||
State.CLOSED -> {
|
||||
// Normal operation
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = operation()
|
||||
|
||||
if (result.isSuccess()) {
|
||||
onSuccess()
|
||||
} else {
|
||||
onFailure()
|
||||
}
|
||||
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
onFailure()
|
||||
val networkException = e.toNetworkException()
|
||||
RepositoryResult.Error(networkException.apiError)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSuccess() {
|
||||
failureCount = 0
|
||||
|
||||
when (state) {
|
||||
State.HALF_OPEN -> {
|
||||
successCount++
|
||||
if (successCount >= successThreshold) {
|
||||
state = State.CLOSED
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
state = State.CLOSED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFailure() {
|
||||
failureCount++
|
||||
lastFailureTime = currentTimeMillis()
|
||||
|
||||
if (failureCount >= failureThreshold) {
|
||||
state = State.OPEN
|
||||
}
|
||||
}
|
||||
|
||||
fun getState(): String = state.name
|
||||
fun getFailureCount(): Int = failureCount
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package at.mocode.shared.network
|
||||
|
||||
import at.mocode.shared.domain.model.ApiError
|
||||
|
||||
/**
|
||||
* Einheitlicher Ergebnis-Typ für Repository-/Netzwerkoperationen.
|
||||
*/
|
||||
sealed class RepositoryResult<out T> {
|
||||
data class Success<T>(val value: T) : RepositoryResult<T>()
|
||||
data class Error(val apiError: ApiError) : RepositoryResult<Nothing>()
|
||||
}
|
||||
|
||||
fun <T> RepositoryResult<T>.isSuccess(): Boolean = this is RepositoryResult.Success
|
||||
|
||||
fun <T> RepositoryResult<T>.getErrorOrNull(): ApiError? = when (this) {
|
||||
is RepositoryResult.Success -> null
|
||||
is RepositoryResult.Error -> this.apiError
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package at.mocode.shared.presentation.actions
|
||||
|
||||
import at.mocode.shared.presentation.state.Notification
|
||||
import at.mocode.frontend.core.domain.models.User
|
||||
import at.mocode.frontend.core.domain.models.AuthToken
|
||||
|
||||
sealed class AppAction {
|
||||
// Auth Actions
|
||||
sealed class Auth : AppAction() {
|
||||
data class LoginStart(val username: String, val password: String) : Auth()
|
||||
data class LoginSuccess(val user: User, val token: AuthToken) : Auth()
|
||||
data class LoginFailure(val error: String) : Auth()
|
||||
object Logout : Auth()
|
||||
data class RefreshToken(val newToken: AuthToken) : Auth()
|
||||
}
|
||||
|
||||
// Navigation Actions
|
||||
sealed class Navigation : AppAction() {
|
||||
data class NavigateTo(val route: String) : Navigation()
|
||||
object NavigateBack : Navigation()
|
||||
data class UpdateHistory(val route: String) : Navigation()
|
||||
}
|
||||
|
||||
// UI Actions
|
||||
sealed class UI : AppAction() {
|
||||
object ToggleDarkMode : UI()
|
||||
data class SetLoading(val isLoading: Boolean) : UI()
|
||||
data class ShowNotification(val notification: Notification) : UI()
|
||||
data class DismissNotification(val id: String) : UI()
|
||||
}
|
||||
|
||||
// Network Actions
|
||||
sealed class Network : AppAction() {
|
||||
data class SetOnlineStatus(val isOnline: Boolean) : Network()
|
||||
data class UpdateLastSync(val timestamp: String) : Network()
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package at.mocode.shared.presentation.state
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import at.mocode.frontend.core.domain.models.User
|
||||
import at.mocode.frontend.core.domain.models.AuthToken
|
||||
|
||||
@Serializable
|
||||
data class AppState(
|
||||
val auth: AuthState = AuthState(),
|
||||
val navigation: NavigationState = NavigationState(),
|
||||
val ui: UiState = UiState(),
|
||||
val network: NetworkState = NetworkState()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AuthState(
|
||||
val isAuthenticated: Boolean = false,
|
||||
val user: User? = null,
|
||||
val token: AuthToken? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NavigationState(
|
||||
val currentRoute: String = "/",
|
||||
val history: List<String> = emptyList(),
|
||||
val canGoBack: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UiState(
|
||||
val isDarkMode: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val notifications: List<Notification> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NetworkState(
|
||||
val isOnline: Boolean = true,
|
||||
val lastSync: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Notification(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val type: NotificationType = NotificationType.INFO,
|
||||
val timestamp: String
|
||||
)
|
||||
|
||||
enum class NotificationType {
|
||||
INFO, SUCCESS, WARNING, ERROR
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package at.mocode.shared.presentation.store
|
||||
|
||||
import at.mocode.shared.presentation.state.AppState
|
||||
import at.mocode.shared.presentation.actions.AppAction
|
||||
import at.mocode.shared.presentation.state.AuthState
|
||||
import at.mocode.shared.presentation.state.NavigationState
|
||||
import at.mocode.shared.presentation.state.NetworkState
|
||||
import at.mocode.shared.presentation.state.UiState
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
class AppStore(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private val _state = MutableStateFlow(AppState())
|
||||
|
||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
||||
|
||||
fun dispatch(action: AppAction) {
|
||||
scope.launch {
|
||||
val currentState = _state.value
|
||||
val newState = reduce(currentState, action)
|
||||
_state.value = newState
|
||||
|
||||
// Handle side effects
|
||||
handleSideEffect(action, newState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduce(currentState: AppState, action: AppAction): AppState {
|
||||
return when (action) {
|
||||
is AppAction.Auth -> currentState.copy(
|
||||
auth = reduceAuth(currentState.auth, action)
|
||||
)
|
||||
|
||||
is AppAction.Navigation -> currentState.copy(
|
||||
navigation = reduceNavigation(currentState.navigation, action)
|
||||
)
|
||||
|
||||
is AppAction.UI -> currentState.copy(
|
||||
ui = reduceUI(currentState.ui, action)
|
||||
)
|
||||
|
||||
is AppAction.Network -> currentState.copy(
|
||||
network = reduceNetwork(currentState.network, action)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduceAuth(currentAuth: AuthState, action: AppAction.Auth): AuthState {
|
||||
return when (action) {
|
||||
is AppAction.Auth.LoginStart -> currentAuth.copy(
|
||||
isLoading = true,
|
||||
error = null
|
||||
)
|
||||
|
||||
is AppAction.Auth.LoginSuccess -> currentAuth.copy(
|
||||
isAuthenticated = true,
|
||||
user = action.user,
|
||||
token = action.token,
|
||||
isLoading = false,
|
||||
error = null
|
||||
)
|
||||
|
||||
is AppAction.Auth.LoginFailure -> currentAuth.copy(
|
||||
isAuthenticated = false,
|
||||
user = null,
|
||||
token = null,
|
||||
isLoading = false,
|
||||
error = action.error
|
||||
)
|
||||
|
||||
is AppAction.Auth.Logout -> AuthState()
|
||||
is AppAction.Auth.RefreshToken -> currentAuth.copy(
|
||||
token = action.newToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduceNavigation(currentNav: NavigationState, action: AppAction.Navigation): NavigationState {
|
||||
return when (action) {
|
||||
is AppAction.Navigation.NavigateTo -> currentNav.copy(
|
||||
currentRoute = action.route,
|
||||
history = currentNav.history + currentNav.currentRoute,
|
||||
canGoBack = true
|
||||
)
|
||||
|
||||
is AppAction.Navigation.NavigateBack -> {
|
||||
val newHistory = currentNav.history.dropLast(1)
|
||||
currentNav.copy(
|
||||
currentRoute = newHistory.lastOrNull() ?: "/",
|
||||
history = newHistory,
|
||||
canGoBack = newHistory.isNotEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
is AppAction.Navigation.UpdateHistory -> currentNav.copy(
|
||||
currentRoute = action.route
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduceUI(currentUI: UiState, action: AppAction.UI): UiState {
|
||||
return when (action) {
|
||||
is AppAction.UI.ToggleDarkMode -> currentUI.copy(
|
||||
isDarkMode = !currentUI.isDarkMode
|
||||
)
|
||||
|
||||
is AppAction.UI.SetLoading -> currentUI.copy(
|
||||
isLoading = action.isLoading
|
||||
)
|
||||
|
||||
is AppAction.UI.ShowNotification -> currentUI.copy(
|
||||
notifications = currentUI.notifications + action.notification
|
||||
)
|
||||
|
||||
is AppAction.UI.DismissNotification -> currentUI.copy(
|
||||
notifications = currentUI.notifications.filter { it.id != action.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reduceNetwork(currentNetwork: NetworkState, action: AppAction.Network): NetworkState {
|
||||
return when (action) {
|
||||
is AppAction.Network.SetOnlineStatus -> currentNetwork.copy(
|
||||
isOnline = action.isOnline
|
||||
)
|
||||
|
||||
is AppAction.Network.UpdateLastSync -> currentNetwork.copy(
|
||||
lastSync = action.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleSideEffect(action: AppAction, newState: AppState) {
|
||||
when (action) {
|
||||
is AppAction.Auth.LoginSuccess -> {
|
||||
// Auto-save token to local storage
|
||||
// TODO: Implement storage
|
||||
}
|
||||
|
||||
is AppAction.Auth.Logout -> {
|
||||
// Clear local storage
|
||||
// TODO: Implement storage cleanup
|
||||
}
|
||||
|
||||
else -> { /* No side effects */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package at.mocode.shared.network
|
||||
|
||||
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
||||
@@ -1,5 +0,0 @@
|
||||
package at.mocode.shared.network
|
||||
|
||||
import kotlin.js.Date
|
||||
|
||||
actual fun currentTimeMillis(): Long = Date().getTime().toLong()
|
||||
@@ -1,3 +0,0 @@
|
||||
package at.mocode.shared.network
|
||||
|
||||
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
||||
@@ -1,26 +1,26 @@
|
||||
# ===================================================================
|
||||
# Multi-stage Dockerfile for Meldestelle API Gateway
|
||||
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
|
||||
# Version: 2.2.2 - Optimized for Monorepo (Fixed frontend paths after refactoring)
|
||||
# Version: 2.6.0 - Reliable Monorepo Build
|
||||
# ===================================================================
|
||||
|
||||
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||
ARG GRADLE_VERSION=9.3.1
|
||||
ARG GRADLE_VERSION=9.4.1
|
||||
ARG JAVA_VERSION=25
|
||||
ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
ARG VERSION=1.0.0-SNAPSHOT
|
||||
|
||||
# ===================================================================
|
||||
# Build Stage
|
||||
# ===================================================================
|
||||
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION}-alpine AS builder
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
|
||||
|
||||
ARG VERSION
|
||||
ARG BUILD_DATE
|
||||
|
||||
LABEL stage=builder
|
||||
LABEL service="api-gateway"
|
||||
LABEL maintainer="Meldestelle Development Team"
|
||||
LABEL stage=builder \
|
||||
service="api-gateway" \
|
||||
maintainer="Meldestelle Development Team"
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
@@ -32,58 +32,23 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
||||
-Dorg.gradle.jvmargs=-Xmx2g \
|
||||
-XX:+UseParallelGC \
|
||||
-XX:MaxMetaspaceSize=512m"
|
||||
ENV GRADLE_USER_HOME=/root/.gradle
|
||||
|
||||
ENV GRADLE_USER_HOME=/home/gradle/.gradle
|
||||
|
||||
# Copy gradle wrapper and configuration files
|
||||
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
|
||||
COPY gradle/ gradle/
|
||||
# 1. Copy full project structure for a reliable monorepo build
|
||||
# .dockerignore should be used to exclude unnecessary files (IDE, logs, etc.)
|
||||
COPY . .
|
||||
|
||||
RUN chmod +x gradlew
|
||||
|
||||
# Copy platform and core dependencies
|
||||
COPY platform/ platform/
|
||||
COPY core/ core/
|
||||
# 2. Build the service
|
||||
RUN --mount=type=cache,target=/root/.gradle/caches \
|
||||
--mount=type=cache,target=/root/.gradle/wrapper \
|
||||
./gradlew :backend:infrastructure:gateway:bootJar --no-daemon --info
|
||||
|
||||
# Copy backend directories
|
||||
COPY backend/infrastructure/ backend/infrastructure/
|
||||
COPY backend/services/ backend/services/
|
||||
COPY contracts/ contracts/
|
||||
|
||||
# Create dummy frontend directories to satisfy settings.gradle.kts include paths
|
||||
# This prevents Gradle from failing configuration phase without copying actual frontend code
|
||||
RUN mkdir -p \
|
||||
frontend/core/auth \
|
||||
frontend/core/domain \
|
||||
frontend/core/design-system \
|
||||
frontend/core/navigation \
|
||||
frontend/core/network \
|
||||
frontend/core/local-db \
|
||||
frontend/core/sync \
|
||||
frontend/features/ping-feature \
|
||||
frontend/features/nennung-feature \
|
||||
frontend/shared \
|
||||
frontend/shells/meldestelle-portal \
|
||||
frontend/shells/meldestelle-desktop \
|
||||
frontend/features/zns-import-feature \
|
||||
docs
|
||||
|
||||
# Copy root build configuration
|
||||
COPY build.gradle.kts ./
|
||||
|
||||
# Download and cache dependencies
|
||||
RUN --mount=type=cache,id=gradle-cache-gateway,target=/home/gradle/.gradle/caches \
|
||||
--mount=type=cache,id=gradle-wrapper-gateway,target=/home/gradle/.gradle/wrapper \
|
||||
./gradlew :backend:infrastructure:gateway:dependencies --info
|
||||
|
||||
# Build the application
|
||||
RUN --mount=type=cache,id=gradle-cache-gateway,target=/home/gradle/.gradle/caches \
|
||||
--mount=type=cache,id=gradle-wrapper-gateway,target=/home/gradle/.gradle/wrapper \
|
||||
./gradlew :backend:infrastructure:gateway:bootJar --info
|
||||
|
||||
# Extract JAR layers
|
||||
RUN mkdir -p build/dependency && \
|
||||
(cd build/dependency; java -Djarmode=layertools -jar /workspace/backend/infrastructure/gateway/build/libs/*.jar extract)
|
||||
# 3. Extract layers
|
||||
WORKDIR /builder
|
||||
RUN cp /workspace/backend/infrastructure/gateway/build/libs/*.jar app.jar && \
|
||||
java -Djarmode=layertools -jar app.jar extract
|
||||
|
||||
# ===================================================================
|
||||
# Runtime Stage
|
||||
@@ -94,19 +59,15 @@ ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
ARG JAVA_VERSION
|
||||
|
||||
ENV JAVA_VERSION=${JAVA_VERSION} \
|
||||
VERSION=${VERSION} \
|
||||
BUILD_DATE=${BUILD_DATE}
|
||||
|
||||
LABEL service="api-gateway" \
|
||||
version="${VERSION}" \
|
||||
description="Spring Cloud Gateway for Meldestelle microservices architecture" \
|
||||
description="Microservice for API Gateway and Routing" \
|
||||
maintainer="Meldestelle Development Team" \
|
||||
org.opencontainers.image.title="Meldestelle API Gateway" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}"
|
||||
java.version="${JAVA_VERSION}" \
|
||||
build.date="${BUILD_DATE}"
|
||||
|
||||
ARG APP_USER=gateway
|
||||
ARG APP_GROUP=gateway
|
||||
ARG APP_USER=appuser
|
||||
ARG APP_GROUP=appgroup
|
||||
ARG APP_UID=1001
|
||||
ARG APP_GID=1001
|
||||
|
||||
@@ -114,21 +75,18 @@ WORKDIR /app
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add --no-cache \
|
||||
curl \
|
||||
tzdata \
|
||||
tini && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||
apk add --no-cache curl tzdata tini && \
|
||||
rm -rf /var/cache/apk/* && addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
||||
mkdir -p /app/logs /app/tmp /app/config && \
|
||||
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
||||
chmod -R 750 /app
|
||||
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /workspace/build/dependency/dependencies/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /workspace/build/dependency/spring-boot-loader/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /workspace/build/dependency/snapshot-dependencies/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /workspace/build/dependency/application/ ./
|
||||
# Copy Spring Boot layers
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
|
||||
|
||||
USER ${APP_USER}
|
||||
|
||||
@@ -137,7 +95,7 @@ EXPOSE 8081 5005
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD curl -fsS --max-time 2 http://localhost:8081/actuator/health/readiness || exit 1
|
||||
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 \
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||
-XX:+UseG1GC \
|
||||
-XX:+UseStringDeduplication \
|
||||
-XX:+UseContainerSupport \
|
||||
@@ -151,25 +109,19 @@ ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 \
|
||||
-Dfile.encoding=UTF-8 \
|
||||
-Duser.timezone=Europe/Vienna \
|
||||
-Dspring.backgroundpreinitializer.ignore=true \
|
||||
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,gateway \
|
||||
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus \
|
||||
-Dmanagement.endpoint.health.show-details=always \
|
||||
-Dmanagement.prometheus.metrics.export.enabled=true"
|
||||
|
||||
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
|
||||
SERVER_PORT=8081 \
|
||||
LOGGING_LEVEL_ROOT=INFO \
|
||||
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_GATEWAY=DEBUG
|
||||
LOGGING_LEVEL_ROOT=INFO
|
||||
|
||||
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
||||
echo 'Starting API Gateway with Java ${JAVA_VERSION}...'; \
|
||||
echo 'Active Spring profiles: '${SPRING_PROFILES_ACTIVE:-not-set}; \
|
||||
echo 'Gateway port: ${SERVER_PORT}'; \
|
||||
MEMORY_LIMIT=$(cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo 'unlimited'); \
|
||||
echo \"Container memory limit: $MEMORY_LIMIT\"; \
|
||||
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
||||
echo 'DEBUG mode enabled - remote debugging available on port 5005'; \
|
||||
echo 'DEBUG mode enabled'; \
|
||||
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
|
||||
else \
|
||||
echo 'Starting API Gateway in production mode'; \
|
||||
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
|
||||
fi"]
|
||||
|
||||
+19
-14
@@ -1,25 +1,30 @@
|
||||
package at.mocode.infrastructure.gateway
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.getBean
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.core.env.Environment
|
||||
|
||||
@SpringBootApplication
|
||||
class GatewayApplication
|
||||
class GatewayApplication(private val env: Environment) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(GatewayApplication::class.java)
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
val springPort = env.getProperty("server.port", "8081")
|
||||
val appName = env.getProperty("spring.application.name", "gateway")
|
||||
|
||||
log.info("----------------------------------------------------------")
|
||||
log.info("Application '{}' is running!", appName)
|
||||
log.info("Spring Management Port: {}", springPort)
|
||||
log.info("Profiles: {}", env.activeProfiles.joinToString(", "))
|
||||
log.info("----------------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val context = runApplication<GatewayApplication>(*args)
|
||||
val logger = LoggerFactory.getLogger(GatewayApplication::class.java)
|
||||
val env = context.getBean<Environment>()
|
||||
val port = env.getProperty("server.port") ?: "8081"
|
||||
|
||||
logger.info("""
|
||||
----------------------------------------------------------
|
||||
Application 'Gateway' is running!
|
||||
Port: $port
|
||||
Profiles: ${env.activeProfiles.joinToString(", ").ifEmpty { "default" }}
|
||||
----------------------------------------------------------
|
||||
""".trimIndent())
|
||||
runApplication<GatewayApplication>(*args)
|
||||
}
|
||||
|
||||
+26
-5
@@ -1,6 +1,5 @@
|
||||
package at.mocode.infrastructure.gateway.config
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.cloud.gateway.route.RouteLocator
|
||||
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
|
||||
import org.springframework.cloud.gateway.route.builder.filters
|
||||
@@ -9,9 +8,7 @@ import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class GatewayConfig(
|
||||
@Value("\${ping.service.url:http://localhost:8082}") private val pingServiceUrl: String
|
||||
) {
|
||||
class GatewayConfig {
|
||||
|
||||
@Bean
|
||||
fun customRouteLocator(builder: RouteLocatorBuilder): RouteLocator {
|
||||
@@ -25,7 +22,31 @@ class GatewayConfig(
|
||||
it.fallbackUri = java.net.URI.create("forward:/fallback/ping")
|
||||
}
|
||||
}
|
||||
uri(pingServiceUrl)
|
||||
uri("lb://ping-service")
|
||||
}
|
||||
route(id = "masterdata-service") {
|
||||
path("/api/v1/masterdata/**")
|
||||
uri("lb://masterdata-service")
|
||||
}
|
||||
route(id = "events-service") {
|
||||
path("/api/v1/events/**")
|
||||
uri("lb://events-service")
|
||||
}
|
||||
route(id = "zns-import-service") {
|
||||
path("/api/v1/import/zns/**", "/api/v1/import/zns")
|
||||
uri("lb://zns-import-service")
|
||||
}
|
||||
route(id = "results-service") {
|
||||
path("/api/v1/results/**")
|
||||
uri("lb://results-service")
|
||||
}
|
||||
route(id = "series-service") {
|
||||
path("/api/v1/series/**")
|
||||
uri("lb://series-service")
|
||||
}
|
||||
route(id = "billing-service") {
|
||||
path("/api/v1/billing/**")
|
||||
uri("lb://billing-service")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-10
@@ -11,9 +11,8 @@ import org.springframework.security.authentication.AbstractAuthenticationToken
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
|
||||
import org.springframework.security.oauth2.jwt.*
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain
|
||||
@@ -38,7 +37,7 @@ class SecurityConfig(
|
||||
.authorizeExchange { exchanges ->
|
||||
exchanges
|
||||
.pathMatchers(*securityProperties.publicPaths.toTypedArray()).permitAll()
|
||||
.pathMatchers("/api/ping/**").hasRole("MELD_USER") // Beispiel Rolle
|
||||
.pathMatchers("/api/v1/import/zns", "/api/v1/import/zns/**").permitAll() // TEMPORAER fuer Debugging
|
||||
.anyExchange().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth2 ->
|
||||
@@ -66,16 +65,28 @@ class SecurityConfig(
|
||||
if (delegate == null) {
|
||||
if (jwkSetUri.isBlank()) {
|
||||
logger.error("JWK Set URI is missing – all authenticated requests will be rejected.")
|
||||
return Mono.error(org.springframework.security.oauth2.jwt.BadJwtException("Identity Provider not configured"))
|
||||
return Mono.error(BadJwtException("Identity Provider not configured"))
|
||||
}
|
||||
try {
|
||||
logger.info("Attempting to initialize JWT Decoder with URI: {}", jwkSetUri)
|
||||
delegate = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
|
||||
logger.info("JWT Decoder successfully initialized.")
|
||||
// Wir deaktivieren die Issuer-Validierung, da Keycloak intern "keycloak:8080"
|
||||
// und extern "localhost:8180" verwendet, was zu Mismatches führt.
|
||||
val nimbusDecoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
|
||||
nimbusDecoder.setJwtValidator(JwtValidators.createDefault()) // Standard-Validierung (ohne Issuer-Zwang falls nicht explizit konfiguriert)
|
||||
|
||||
// Da createDefault() den Issuer-Check einbaut, wenn spring.security.oauth2.resourceserver.jwt.issuer-uri gesetzt ist,
|
||||
// nutzen wir einen Custom Validator der den Issuer ignoriert oder flexibel ist.
|
||||
val withAudience = DelegatingOAuth2TokenValidator<Jwt>(
|
||||
JwtTimestampValidator(),
|
||||
// Hier koennte man weitere Validatoren hinzufuegen, aber wir lassen den Issuer weg
|
||||
)
|
||||
nimbusDecoder.setJwtValidator(withAudience)
|
||||
|
||||
delegate = nimbusDecoder
|
||||
logger.info("JWT Decoder successfully initialized (Issuer check disabled for environment flexibility).")
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Could not initialize JWT Decoder: {}", e.message)
|
||||
// Throw BadJwtException so Spring Security returns 401, not 500 or passthrough
|
||||
return Mono.error(org.springframework.security.oauth2.jwt.BadJwtException("Identity Provider unavailable: ${e.message}"))
|
||||
return Mono.error(BadJwtException("Identity Provider unavailable: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +117,7 @@ class SecurityConfig(
|
||||
val configuration = CorsConfiguration().apply {
|
||||
allowedOriginPatterns = securityProperties.cors.allowedOriginPatterns.toList()
|
||||
allowedMethods = securityProperties.cors.allowedMethods.toList()
|
||||
allowedHeaders = securityProperties.cors.allowedHeaders.toList()
|
||||
allowedHeaders = listOf("*") // Alles erlauben fuer Postman/Frontend
|
||||
exposedHeaders = securityProperties.cors.exposedHeaders.toList()
|
||||
allowCredentials = securityProperties.cors.allowCredentials
|
||||
maxAge = securityProperties.cors.maxAge.seconds
|
||||
|
||||
@@ -20,14 +20,18 @@ spring:
|
||||
host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
|
||||
port: ${SPRING_CLOUD_CONSUL_PORT:8500}
|
||||
discovery:
|
||||
enabled: true
|
||||
register: true
|
||||
prefer-ip-address: true
|
||||
health-check-path: /actuator/health
|
||||
health-check-interval: 10s
|
||||
health-check-port: 8081
|
||||
instance-id: ${spring.application.name}:${server.port}:${random.uuid}
|
||||
service-name: ${spring.application.name}
|
||||
# Bei lokalem Start (Gradle) wollen wir nicht die Docker-IP registrieren, sondern localhost oder die Host-IP.
|
||||
# Aber für den Anfang reicht es, wenn wir Consul finden.
|
||||
|
||||
gateway:
|
||||
httpclient: { }
|
||||
# Routen sind in GatewayConfig.kt definiert
|
||||
# Routen sind in GatewayConfig.kt via Service-Discovery (lb://) definiert
|
||||
|
||||
# --- SECURITY (OAuth2 Resource Server) ---
|
||||
security:
|
||||
@@ -40,6 +44,27 @@ spring:
|
||||
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8180/realms/meldestelle}
|
||||
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs}
|
||||
|
||||
gateway:
|
||||
security:
|
||||
cors:
|
||||
allowed-origin-patterns:
|
||||
- "http://localhost:*"
|
||||
- "https://*.meldestelle.at"
|
||||
- "https://*.mo-code.at"
|
||||
- "https://*.postman.co"
|
||||
- "postman://*"
|
||||
allowed-methods:
|
||||
- "GET"
|
||||
- "POST"
|
||||
- "PUT"
|
||||
- "DELETE"
|
||||
- "OPTIONS"
|
||||
- "PATCH"
|
||||
allowed-headers:
|
||||
- "*"
|
||||
allow-credentials: true
|
||||
max-age: 3600s
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
@@ -62,9 +87,3 @@ management:
|
||||
# Lokal: Zipkin auf Port 9411. In Docker via ENV MANAGEMENT_ZIPKIN_TRACING_ENDPOINT überschrieben.
|
||||
endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
|
||||
|
||||
# --- Custom Service URLs ---
|
||||
# Default: Localhost (für Entwicklung ohne Docker)
|
||||
# Im Docker-Compose überschreiben wir das mit dem Service-Namen
|
||||
ping:
|
||||
service:
|
||||
url: ${PING_SERVICE_URL:http://localhost:8082}
|
||||
|
||||
+95
-1
@@ -8,7 +8,7 @@
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "http://localhost:8080",
|
||||
"value": "http://localhost:8081",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
@@ -221,6 +221,100 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Connectivity Context (Ping Service)",
|
||||
"item": [
|
||||
{
|
||||
"name": "Simple Ping",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/ping/simple",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "ping", "simple"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Health Check",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/ping/health",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "ping", "health"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Public Info",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/ping/public",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "ping", "public"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Enhanced Ping (Resilience)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/ping/enhanced",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "ping", "enhanced"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Sync Delta Diagnostic",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/ping/sync?lastSyncTimestamp=0",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "ping", "sync"],
|
||||
"query": [
|
||||
{
|
||||
"key": "lastSyncTimestamp",
|
||||
"value": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Secure Ping (Login Required)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{authToken}}"
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/api/ping/secure",
|
||||
"host": ["{{baseUrl}}"],
|
||||
"path": ["api", "ping", "secure"]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Master Data Context",
|
||||
"item": [
|
||||
|
||||
@@ -25,6 +25,7 @@ dependencies {
|
||||
|
||||
// Web (for CORS config)
|
||||
implementation(libs.spring.web)
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package at.mocode.infrastructure.security
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
|
||||
/**
|
||||
* Filter zur Authentifizierung von Desktop-Clients via Security Key.
|
||||
* Dieser Filter ist für die Offline-First-Synchronisation gedacht.
|
||||
*
|
||||
* Header:
|
||||
* - X-Device-Name: Name der Desktop-Instanz
|
||||
* - X-Security-Key: Der konfigurierte Sicherheitsschlüssel
|
||||
*
|
||||
* HINWEIS: In einer echten Produktionsumgebung sollte der Key gehasht sein
|
||||
* oder eine Signatur-Prüfung erfolgen.
|
||||
*/
|
||||
class DeviceSecurityFilter : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain
|
||||
) {
|
||||
val deviceName = request.getHeader("X-Device-Name")
|
||||
val securityKey = request.getHeader("X-Security-Key")
|
||||
|
||||
// Falls Header vorhanden sind, versuchen wir die Authentifizierung
|
||||
if (!deviceName.isNullOrBlank() && !securityKey.isNullOrBlank()) {
|
||||
// WICHTIG: Die eigentliche Validierung gegen die DB (DeviceTable)
|
||||
// müsste hier über einen Service erfolgen.
|
||||
// Für den Prototyp setzen wir einen Authentifizierungs-Kontext,
|
||||
// wenn die Header vorhanden sind.
|
||||
|
||||
val auth = UsernamePasswordAuthenticationToken(
|
||||
deviceName,
|
||||
null,
|
||||
listOf(SimpleGrantedAuthority("ROLE_DEVICE"))
|
||||
)
|
||||
SecurityContextHolder.getContext().authentication = auth
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response)
|
||||
}
|
||||
}
|
||||
+48
-6
@@ -6,8 +6,17 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtTimestampValidator
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||
import org.springframework.web.cors.CorsConfiguration
|
||||
import org.springframework.web.cors.CorsConfigurationSource
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -18,17 +27,18 @@ class GlobalSecurityConfig {
|
||||
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
.csrf { it.disable() } // CSRF nicht nötig für Stateless REST APIs
|
||||
// WICHTIG: CORS explizit deaktivieren!
|
||||
// Das API-Gateway kümmert sich um CORS. Die Microservices dürfen KEINE
|
||||
// Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client.
|
||||
.cors { it.disable() }
|
||||
// WICHTIG: CORS wieder aktivieren für Plan-B (Direktzugriff ohne Gateway möglich)
|
||||
.cors { it.configurationSource(corsConfigurationSource()) }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.addFilterBefore(DeviceSecurityFilter(), UsernamePasswordAuthenticationFilter::class.java)
|
||||
.authorizeHttpRequests { auth ->
|
||||
// Explizite Freigaben (Health, Info, Public Endpoints)
|
||||
// Explizite Freigaben (Health, Information, Public-Endpoints)
|
||||
auth.requestMatchers("/actuator/**").permitAll()
|
||||
auth.requestMatchers("/api/v1/devices/register").permitAll() // Onboarding erlauben
|
||||
auth.requestMatchers("/api/mail/nennung").permitAll() // Plan-B Nennungen erlauben
|
||||
auth.requestMatchers("/api/mail/nennungen").authenticated() // Liste schützen
|
||||
auth.requestMatchers("/ping/public").permitAll()
|
||||
auth.requestMatchers("/ping/simple").permitAll()
|
||||
auth.requestMatchers("/ping/enhanced").permitAll()
|
||||
auth.requestMatchers("/ping/health").permitAll()
|
||||
auth.requestMatchers("/error").permitAll()
|
||||
|
||||
@@ -38,16 +48,48 @@ class GlobalSecurityConfig {
|
||||
.oauth2ResourceServer { oauth2 ->
|
||||
oauth2.jwt { jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||
// Auch hier den Issuer-Check entspannen, da der Service intern validiert
|
||||
jwt.decoder(jwtDecoder())
|
||||
}
|
||||
}
|
||||
|
||||
return http.build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder {
|
||||
// 1. Suche in System-Properties (Spring injects these)
|
||||
// 2. Suche in Environment Variables
|
||||
// 3. Fallback auf localhost (IDE-Start) oder keycloak (Docker-Start)
|
||||
val jwkSetUri = System.getProperty("spring.security.oauth2.resourceserver.jwt.jwk-set-uri")
|
||||
?: System.getenv("SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI")
|
||||
?: "http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs"
|
||||
|
||||
val decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build()
|
||||
val validator = DelegatingOAuth2TokenValidator<Jwt>(JwtTimestampValidator())
|
||||
decoder.setJwtValidator(validator)
|
||||
return decoder
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
|
||||
val converter = JwtAuthenticationConverter()
|
||||
converter.setJwtGrantedAuthoritiesConverter(KeycloakRoleConverter())
|
||||
return converter
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun corsConfigurationSource(): CorsConfigurationSource {
|
||||
val configuration = CorsConfiguration()
|
||||
configuration.allowedOrigins = listOf("*")
|
||||
configuration.allowedOriginPatterns = listOf("*")
|
||||
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD")
|
||||
configuration.allowedHeaders = listOf("*")
|
||||
configuration.exposedHeaders = listOf("*")
|
||||
configuration.maxAge = 3600L
|
||||
configuration.allowCredentials = false
|
||||
val source = UrlBasedCorsConfigurationSource()
|
||||
source.registerCorsConfiguration("/**", configuration)
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package at.mocode.zns.importer
|
||||
|
||||
/**
|
||||
* Der Modus des ZNS-Imports.
|
||||
*
|
||||
* [FULL] - Alle Dateien (Vereine, Reiter, Pferde, Funktionäre) werden importiert.
|
||||
* [LIGHT] - Nur Stammdaten (Vereine, Reiter) werden importiert (Performance-Optimiert).
|
||||
*/
|
||||
enum class ZnsImportMode {
|
||||
FULL,
|
||||
LIGHT
|
||||
}
|
||||
+274
-58
@@ -2,11 +2,11 @@
|
||||
|
||||
package at.mocode.zns.importer
|
||||
|
||||
import at.mocode.masterdata.domain.repository.VereinRepository
|
||||
import at.mocode.masterdata.domain.repository.HorseRepository
|
||||
import at.mocode.masterdata.domain.repository.FunktionaerRepository
|
||||
import at.mocode.masterdata.domain.repository.ReiterRepository
|
||||
import at.mocode.zns.parser.ZnsLegacyParsers
|
||||
import at.mocode.masterdata.domain.repository.*
|
||||
import at.mocode.zns.parser.ZnsFunktionaerParser
|
||||
import at.mocode.zns.parser.ZnsPferdParser
|
||||
import at.mocode.zns.parser.ZnsReiterParser
|
||||
import at.mocode.zns.parser.ZnsVereinParser
|
||||
import java.io.InputStream
|
||||
import java.nio.charset.Charset
|
||||
import java.util.zip.ZipInputStream
|
||||
@@ -19,10 +19,10 @@ import java.util.zip.ZipInputStream
|
||||
* Domänenobjekte über die jeweiligen Repositories (Upsert-Logik).
|
||||
*
|
||||
* Die Verarbeitungsreihenfolge ist fix:
|
||||
* 1. VEREIN01.DAT → DomVerein (via VereinRepository)
|
||||
* 2. LIZENZ01.DAT → DomReiter (via ReiterRepository)
|
||||
* 3. PFERDE01.DAT → DomPferd (via HorseRepository)
|
||||
* 4. RICHT01.DAT → DomFunktionaer (via FunktionaerRepository)
|
||||
* 1. VEREIN01.DAT → Verein (via VereinRepository)
|
||||
* 2. LIZENZ01.DAT → Reiter (via ReiterRepository)
|
||||
* 3. PFERDE01.DAT → Pferd (via HorseRepository)
|
||||
* 4. RICHT01.DAT → Funktionär (via FunktionaerRepository)
|
||||
*
|
||||
* Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl
|
||||
* im Backend (REST-Upload) als auch in der Compose Desktop App (Offline-Import)
|
||||
@@ -37,45 +37,196 @@ class ZnsImportService(
|
||||
private val vereinRepository: VereinRepository,
|
||||
private val reiterRepository: ReiterRepository,
|
||||
private val horseRepository: HorseRepository,
|
||||
private val funktionaerRepository: FunktionaerRepository
|
||||
private val funktionaerRepository: FunktionaerRepository,
|
||||
private val landRepository: LandRepository,
|
||||
private val bundeslandRepository: BundeslandRepository,
|
||||
private val licenseRepository: LizenzRepository? = null,
|
||||
private val altersklassenRepository: AltersklassenRepository? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val CP850 = Charset.forName("Cp850")
|
||||
|
||||
private const val FILE_VEREIN = "VEREIN01.DAT"
|
||||
private const val FILE_LIZENZ = "LIZENZ01.DAT"
|
||||
private const val FILE_PFERDE = "PFERDE01.DAT"
|
||||
private const val FILE_RICHT = "RICHT01.DAT"
|
||||
private const val FILE_VEREIN = "VEREIN"
|
||||
private const val FILE_LIZENZ = "LIZENZ"
|
||||
private const val FILE_PFERDE = "PFERDE"
|
||||
private const val FILE_RICHT = "RICHT"
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert die relevanten Dateien aus dem ZIP-Archiv.
|
||||
* Optimiert: Nutzt BufferedReader für zeilenweises Einlesen, ohne das gesamte File in den RAM zu laden.
|
||||
*/
|
||||
fun extrahiereDateien(zipInputStream: InputStream): Map<String, List<String>> {
|
||||
val dateien = mutableMapOf<String, List<String>>()
|
||||
val zip = ZipInputStream(zipInputStream)
|
||||
try {
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val fileName = entry.name.uppercase().substringAfterLast("/")
|
||||
|
||||
// Toleranter Check: Erkennt VEREIN01.DAT, VEREIN.DAT, etc.
|
||||
val targetKey = when {
|
||||
fileName.startsWith(FILE_VEREIN) -> FILE_VEREIN
|
||||
fileName.startsWith(FILE_LIZENZ) -> FILE_LIZENZ
|
||||
fileName.startsWith(FILE_PFERDE) -> FILE_PFERDE
|
||||
fileName.startsWith(FILE_RICHT) -> FILE_RICHT
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (targetKey != null && fileName.endsWith(".DAT")) {
|
||||
// Wir lesen den Stream direkt zeilenweise mit dem korrekten Encoding
|
||||
val lines = mutableListOf<String>()
|
||||
val reader = zip.bufferedReader(CP850)
|
||||
|
||||
// WICHTIG: Wir dürfen den Reader NICHT schließen (use), da sonst der ZipInputStream geschlossen wird!
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (line.isNotBlank()) {
|
||||
lines.add(line)
|
||||
}
|
||||
line = reader.readLine()
|
||||
}
|
||||
println("[DEBUG_LOG] Datei $fileName extrahiert als $targetKey: ${lines.size} Zeilen")
|
||||
dateien[targetKey] = lines
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[DEBUG_LOG] Fehler beim Extrahieren der ZIP (eventuell keine ZIP-Datei?): ${e.message}")
|
||||
}
|
||||
return dateien
|
||||
}
|
||||
|
||||
/**
|
||||
* Importiert ZNS-Daten aus einem Stream. Erkennt automatisch, ob es eine ZIP oder eine DAT ist.
|
||||
*/
|
||||
suspend fun importiereStream(
|
||||
inputStream: InputStream,
|
||||
fileName: String,
|
||||
mode: ZnsImportMode = ZnsImportMode.FULL
|
||||
): ZnsImportResult {
|
||||
val upperName = fileName.uppercase()
|
||||
return if (upperName.endsWith(".ZIP")) {
|
||||
importiereZip(inputStream, mode)
|
||||
} else if (upperName.endsWith(".DAT")) {
|
||||
importiereEinzelDatei(inputStream, upperName, mode)
|
||||
} else {
|
||||
ZnsImportResult(fehler = listOf("Dateiformat nicht unterstützt: $fileName"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun importiereEinzelDatei(
|
||||
inputStream: InputStream,
|
||||
fileName: String,
|
||||
mode: ZnsImportMode
|
||||
): ZnsImportResult {
|
||||
println("[DEBUG_LOG] Importiere Einzeldatei: $fileName")
|
||||
val lines = inputStream.bufferedReader(CP850).readLines().filter { it.isNotBlank() }
|
||||
println("[DEBUG_LOG] Einzeldatei $fileName hat ${lines.size} Zeilen")
|
||||
|
||||
val fehler = mutableListOf<String>()
|
||||
val warnungen = mutableListOf<String>()
|
||||
|
||||
var vereineImportiert = 0
|
||||
var vereineAktualisiert = 0
|
||||
var reiterImportiert = 0
|
||||
var reiterAktualisiert = 0
|
||||
var pferdeImportiert = 0
|
||||
var pferdeAktualisiert = 0
|
||||
var richterImportiert = 0
|
||||
var richterAktualisiert = 0
|
||||
|
||||
when {
|
||||
fileName.startsWith(FILE_VEREIN) -> {
|
||||
val (n, u) = importiereVereine(lines, fehler)
|
||||
vereineImportiert = n
|
||||
vereineAktualisiert = u
|
||||
}
|
||||
|
||||
fileName.startsWith(FILE_LIZENZ) -> {
|
||||
val (n, u) = importiereReiter(lines, fehler, warnungen)
|
||||
reiterImportiert = n
|
||||
reiterAktualisiert = u
|
||||
}
|
||||
|
||||
fileName.startsWith(FILE_PFERDE) -> {
|
||||
if (mode == ZnsImportMode.FULL) {
|
||||
val (n, u) = importierePferde(lines, fehler)
|
||||
pferdeImportiert = n
|
||||
pferdeAktualisiert = u
|
||||
}
|
||||
}
|
||||
|
||||
fileName.startsWith(FILE_RICHT) -> {
|
||||
if (mode == ZnsImportMode.FULL) {
|
||||
val (n, u) = importiereFunktionaere(lines, fehler, warnungen)
|
||||
richterImportiert = n
|
||||
richterAktualisiert = u
|
||||
}
|
||||
}
|
||||
|
||||
else -> fehler.add("Unbekannte DAT-Datei: $fileName")
|
||||
}
|
||||
|
||||
return ZnsImportResult(
|
||||
vereineImportiert = vereineImportiert,
|
||||
vereineAktualisiert = vereineAktualisiert,
|
||||
reiterImportiert = reiterImportiert,
|
||||
reiterAktualisiert = reiterAktualisiert,
|
||||
pferdeImportiert = pferdeImportiert,
|
||||
pferdeAktualisiert = pferdeAktualisiert,
|
||||
richterImportiert = richterImportiert,
|
||||
richterAktualisiert = richterAktualisiert,
|
||||
fehler = fehler,
|
||||
warnungen = warnungen
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Importiert eine ZNS-ZIP-Datei aus einem [InputStream].
|
||||
*
|
||||
* @param zipInputStream Der InputStream der ZIP-Datei.
|
||||
* @param mode Der [ZnsImportMode] (Standard: [ZnsImportMode.FULL]).
|
||||
* @return [ZnsImportResult] mit Statistiken und eventuellen Fehlern.
|
||||
*/
|
||||
suspend fun importiereZip(zipInputStream: InputStream): ZnsImportResult {
|
||||
val dateien = mutableMapOf<String, List<String>>()
|
||||
ZipInputStream(zipInputStream).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val name = entry.name.uppercase().substringAfterLast("/")
|
||||
if (name in setOf(FILE_VEREIN, FILE_LIZENZ, FILE_PFERDE, FILE_RICHT)) {
|
||||
dateien[name] = zip.readBytes().toString(CP850).lines()
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
suspend fun importiereZip(
|
||||
zipInputStream: InputStream,
|
||||
mode: ZnsImportMode = ZnsImportMode.FULL
|
||||
): ZnsImportResult {
|
||||
val dateien = extrahiereDateien(zipInputStream)
|
||||
println("[DEBUG_LOG] Gefundene Dateien im ZIP: ${dateien.keys}")
|
||||
dateien.forEach { (name, lines) -> println("[DEBUG_LOG] Datei $name hat ${lines.size} Zeilen") }
|
||||
|
||||
val fehler = mutableListOf<String>()
|
||||
val warnungen = mutableListOf<String>()
|
||||
|
||||
val (vereineNeu, vereineUpd) = importiereVereine(dateien[FILE_VEREIN] ?: emptyList(), fehler)
|
||||
val (reiterNeu, reiterUpd) = importiereReiter(dateien[FILE_LIZENZ] ?: emptyList(), fehler, warnungen)
|
||||
val (pferdeNeu, pferdeUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler)
|
||||
val (richterNeu, richterUpd) = importiereRichter(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen)
|
||||
|
||||
var pferdeNeu = 0
|
||||
var pferdeUpd = 0
|
||||
var richterNeu = 0
|
||||
var richterUpd = 0
|
||||
|
||||
if (mode == ZnsImportMode.FULL) {
|
||||
val (pNeu, pUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler)
|
||||
pferdeNeu = pNeu
|
||||
pferdeUpd = pUpd
|
||||
|
||||
val (rNeu, rUpd) = importiereFunktionaere(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen)
|
||||
richterNeu = rNeu
|
||||
richterUpd = rUpd
|
||||
}
|
||||
|
||||
// Zusätzliche Warnung wenn Dateien fehlen
|
||||
if (dateien[FILE_VEREIN] == null) warnungen.add("Vereinsdaten (VEREIN*.DAT) nicht gefunden.")
|
||||
if (dateien[FILE_LIZENZ] == null) warnungen.add("Reiter/Lizenzdaten (LIZENZ*.DAT) nicht gefunden.")
|
||||
if (mode == ZnsImportMode.FULL) {
|
||||
if (dateien[FILE_PFERDE] == null) warnungen.add("Pferdedaten (PFERDE*.DAT) nicht gefunden.")
|
||||
if (dateien[FILE_RICHT] == null) warnungen.add("Funktionärsdaten (RICHT*.DAT) nicht gefunden.")
|
||||
}
|
||||
|
||||
return ZnsImportResult(
|
||||
vereineImportiert = vereineNeu,
|
||||
@@ -95,7 +246,7 @@ class ZnsImportService(
|
||||
// Private Hilfsmethoden
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private suspend fun importiereVereine(
|
||||
suspend fun importiereVereine(
|
||||
zeilen: List<String>,
|
||||
fehler: MutableList<String>
|
||||
): Pair<Int, Int> {
|
||||
@@ -103,7 +254,11 @@ class ZnsImportService(
|
||||
var aktualisiert = 0
|
||||
zeilen.forEachIndexed { index, zeile ->
|
||||
runCatching {
|
||||
val verein = ZnsLegacyParsers.parseVerein(zeile) ?: return@forEachIndexed
|
||||
val verein = ZnsVereinParser.parse(zeile)
|
||||
if (verein == null) {
|
||||
if (index < 5) println("[DEBUG_LOG] Parser lieferte null für Zeile ${index + 1}: '$zeile'")
|
||||
return@forEachIndexed
|
||||
}
|
||||
val vorhanden = vereinRepository.findByVereinsNummer(verein.vereinsNummer)
|
||||
if (vorhanden == null) {
|
||||
vereinRepository.save(verein)
|
||||
@@ -111,13 +266,11 @@ class ZnsImportService(
|
||||
} else {
|
||||
vereinRepository.save(
|
||||
vorhanden.copy(
|
||||
name = verein.name,
|
||||
kurzname = verein.kurzname,
|
||||
vereinName = verein.vereinName,
|
||||
bundesland = verein.bundesland,
|
||||
ort = verein.ort,
|
||||
plz = verein.plz,
|
||||
strasse = verein.strasse,
|
||||
oepsRegionNummer = verein.oepsRegionNummer,
|
||||
istAktiv = verein.istAktiv,
|
||||
datenQuelle = verein.datenQuelle
|
||||
).withUpdatedTimestamp()
|
||||
@@ -131,7 +284,7 @@ class ZnsImportService(
|
||||
return Pair(neu, aktualisiert)
|
||||
}
|
||||
|
||||
private suspend fun importiereReiter(
|
||||
suspend fun importiereReiter(
|
||||
zeilen: List<String>,
|
||||
fehler: MutableList<String>,
|
||||
warnungen: MutableList<String>
|
||||
@@ -140,7 +293,29 @@ class ZnsImportService(
|
||||
var aktualisiert = 0
|
||||
zeilen.forEachIndexed { index, zeile ->
|
||||
runCatching {
|
||||
val reiter = ZnsLegacyParsers.parseLizenz(zeile) ?: return@forEachIndexed
|
||||
val parsed = ZnsReiterParser.parse(zeile)
|
||||
if (parsed == null) {
|
||||
if (index < 5) println("[DEBUG_LOG] Reiter-Parser lieferte null für Zeile ${index + 1}: '$zeile'")
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
// Relationen auflösen
|
||||
val verein = parsed.vereinsName?.let { vereinRepository.findByExactName(it) }
|
||||
val bundesland = parsed.bundeslandNummer?.let { bundeslandRepository.findByNr(it) }
|
||||
val nation = parsed.nation?.let { landRepository.findByIsoAlpha3Code(it) }
|
||||
val reitLizenz = parsed.reiterLizenz?.let { licenseRepository?.findReitLizenzByCode(it) }
|
||||
val fahrLizenz = parsed.fahrLizenz?.let { licenseRepository?.findFahrLizenzByCode(it) }
|
||||
val startkarte = parsed.startkarte?.let { licenseRepository?.findStartkarteByCode(it) }
|
||||
|
||||
val reiter = parsed.copy(
|
||||
vereinId = verein?.vereinId,
|
||||
bundeslandId = bundesland?.bundeslandId,
|
||||
nationId = nation?.landId,
|
||||
reitLizenzId = reitLizenz?.lizenzId,
|
||||
fahrLizenzId = fahrLizenz?.lizenzId,
|
||||
startkarteId = startkarte?.startkarteId
|
||||
)
|
||||
|
||||
val vorhanden = reiterRepository.findBySatznummer(reiter.satznummer)
|
||||
if (vorhanden == null) {
|
||||
reiterRepository.save(reiter)
|
||||
@@ -150,8 +325,29 @@ class ZnsImportService(
|
||||
vorhanden.copy(
|
||||
vorname = reiter.vorname,
|
||||
nachname = reiter.nachname,
|
||||
bundeslandNummer = reiter.bundeslandNummer,
|
||||
vereinsName = reiter.vereinsName,
|
||||
nation = reiter.nation,
|
||||
vereinId = reiter.vereinId,
|
||||
bundeslandId = reiter.bundeslandId,
|
||||
nationId = reiter.nationId,
|
||||
reitLizenzId = reiter.reitLizenzId,
|
||||
fahrLizenzId = reiter.fahrLizenzId,
|
||||
startkarteId = reiter.startkarteId,
|
||||
reiterLizenz = reiter.reiterLizenz,
|
||||
startkarte = reiter.startkarte,
|
||||
fahrLizenz = reiter.fahrLizenz,
|
||||
altersklasseJgJrU25 = reiter.altersklasseJgJrU25,
|
||||
altersklasseY = reiter.altersklasseY,
|
||||
mitgliedsNummer = reiter.mitgliedsNummer,
|
||||
telefonNummer = reiter.telefonNummer,
|
||||
kader = reiter.kader,
|
||||
lastPayYear = reiter.lastPayYear,
|
||||
geschlecht = reiter.geschlecht,
|
||||
geburtsdatum = reiter.geburtsdatum,
|
||||
feiId = reiter.feiId,
|
||||
sperrListe = reiter.sperrListe,
|
||||
lizenzInfo = reiter.lizenzInfo,
|
||||
lizenzKlasse = reiter.lizenzKlasse,
|
||||
istAktiv = reiter.istAktiv,
|
||||
datenQuelle = reiter.datenQuelle
|
||||
@@ -166,7 +362,7 @@ class ZnsImportService(
|
||||
return Pair(neu, aktualisiert)
|
||||
}
|
||||
|
||||
private suspend fun importierePferde(
|
||||
suspend fun importierePferde(
|
||||
zeilen: List<String>,
|
||||
fehler: MutableList<String>
|
||||
): Pair<Int, Int> {
|
||||
@@ -174,10 +370,16 @@ class ZnsImportService(
|
||||
var aktualisiert = 0
|
||||
zeilen.forEachIndexed { index, zeile ->
|
||||
runCatching {
|
||||
val pferd = ZnsLegacyParsers.parsePferd(zeile) ?: return@forEachIndexed
|
||||
val vorhanden = pferd.lebensnummer
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { horseRepository.findByLebensnummer(it) }
|
||||
val pferd = ZnsPferdParser.parse(zeile) ?: return@forEachIndexed
|
||||
if (pferd.pferdeName.isBlank()) return@forEachIndexed
|
||||
|
||||
// Match primarily by satznummer, then by lebensnummer
|
||||
val vorhanden = pferd.satznummer?.takeIf { it.isNotBlank() }?.let { horseRepository.findBySatznummer(it) }
|
||||
?: pferd.lebensnummer?.takeIf { it.isNotBlank() }?.let { horseRepository.findByLebensnummer(it) }
|
||||
?: (if (pferd.pferdeName.isNotBlank()) horseRepository.findByName(pferd.pferdeName, 100).find {
|
||||
it.geburtsjahr == pferd.geburtsjahr && it.kopfnummer == pferd.kopfnummer
|
||||
} else null)
|
||||
|
||||
if (vorhanden == null) {
|
||||
horseRepository.save(pferd)
|
||||
neu++
|
||||
@@ -186,10 +388,17 @@ class ZnsImportService(
|
||||
vorhanden.copy(
|
||||
pferdeName = pferd.pferdeName,
|
||||
geschlecht = pferd.geschlecht,
|
||||
geburtsdatum = pferd.geburtsdatum,
|
||||
rasse = pferd.rasse,
|
||||
geburtsjahr = pferd.geburtsjahr,
|
||||
farbe = pferd.farbe,
|
||||
abstammung = pferd.abstammung,
|
||||
vereinNummer = pferd.vereinNummer,
|
||||
lastPayYear = pferd.lastPayYear,
|
||||
verantwortlichePersonId = pferd.verantwortlichePersonId,
|
||||
lebensnummer = pferd.lebensnummer,
|
||||
oepsNummer = pferd.oepsNummer,
|
||||
kopfnummer = pferd.kopfnummer,
|
||||
satznummer = pferd.satznummer,
|
||||
vater = pferd.vater,
|
||||
feiPass = pferd.feiPass,
|
||||
istAktiv = pferd.istAktiv,
|
||||
datenQuelle = pferd.datenQuelle
|
||||
).withUpdatedTimestamp()
|
||||
@@ -203,7 +412,7 @@ class ZnsImportService(
|
||||
return Pair(neu, aktualisiert)
|
||||
}
|
||||
|
||||
private suspend fun importiereRichter(
|
||||
suspend fun importiereFunktionaere(
|
||||
zeilen: List<String>,
|
||||
fehler: MutableList<String>,
|
||||
warnungen: MutableList<String>
|
||||
@@ -212,24 +421,31 @@ class ZnsImportService(
|
||||
var aktualisiert = 0
|
||||
zeilen.forEachIndexed { index, zeile ->
|
||||
runCatching {
|
||||
val richter = ZnsLegacyParsers.parseRichter(zeile) ?: return@forEachIndexed
|
||||
val richterNummer = richter.richterNummer ?: run {
|
||||
warnungen.add("$FILE_RICHT Zeile ${index + 1}: Keine RichterNummer – übersprungen.")
|
||||
return@forEachIndexed
|
||||
}
|
||||
val vorhanden = funktionaerRepository.findByRichterNummer(richterNummer)
|
||||
val funktionaerRaw = ZnsFunktionaerParser.parse(zeile) ?: return@forEachIndexed
|
||||
|
||||
val nameParts = funktionaerRaw.name?.split(",")?.map { it.trim() }
|
||||
val reiterId = if (nameParts != null && nameParts.size >= 2) {
|
||||
val nachname = nameParts[0]
|
||||
val vorname = nameParts[1]
|
||||
reiterRepository.findByName(vorname, nachname).firstOrNull()?.reiterId
|
||||
} else null
|
||||
|
||||
val funktionaer = funktionaerRaw.copy(reiterId = reiterId)
|
||||
|
||||
val satzID = funktionaer.satzId
|
||||
val satzNummer = funktionaer.satzNummer
|
||||
val vorhanden = funktionaerRepository.findBySatz(satzID, satzNummer)
|
||||
if (vorhanden == null) {
|
||||
funktionaerRepository.save(richter)
|
||||
funktionaerRepository.save(funktionaer)
|
||||
neu++
|
||||
} else {
|
||||
funktionaerRepository.save(
|
||||
vorhanden.copy(
|
||||
vorname = richter.vorname,
|
||||
nachname = richter.nachname,
|
||||
vereinsNummer = richter.vereinsNummer,
|
||||
richterNummer = richter.richterNummer,
|
||||
istAktiv = richter.istAktiv,
|
||||
datenQuelle = richter.datenQuelle
|
||||
reiterId = funktionaer.reiterId,
|
||||
name = funktionaer.name,
|
||||
qualifikationen = funktionaer.qualifikationen,
|
||||
istAktiv = funktionaer.istAktiv,
|
||||
datenQuelle = funktionaer.datenQuelle
|
||||
).withUpdatedTimestamp()
|
||||
)
|
||||
aktualisiert++
|
||||
|
||||
+186
-48
@@ -1,13 +1,15 @@
|
||||
package at.mocode.zns.importer
|
||||
|
||||
import at.mocode.masterdata.domain.model.DomFunktionaer
|
||||
import at.mocode.masterdata.domain.model.DomPferd
|
||||
import at.mocode.masterdata.domain.model.DomReiter
|
||||
import at.mocode.masterdata.domain.model.DomVerein
|
||||
import at.mocode.masterdata.domain.model.Funktionaer
|
||||
import at.mocode.masterdata.domain.model.Pferd
|
||||
import at.mocode.masterdata.domain.model.Reiter
|
||||
import at.mocode.masterdata.domain.model.Verein
|
||||
import at.mocode.masterdata.domain.repository.FunktionaerRepository
|
||||
import at.mocode.masterdata.domain.repository.HorseRepository
|
||||
import at.mocode.masterdata.domain.repository.ReiterRepository
|
||||
import at.mocode.masterdata.domain.repository.VereinRepository
|
||||
import at.mocode.masterdata.domain.repository.LandRepository
|
||||
import at.mocode.masterdata.domain.repository.BundeslandRepository
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
@@ -28,6 +30,8 @@ class ZnsImportServiceTest {
|
||||
private val reiterRepository = mockk<ReiterRepository>()
|
||||
private val horseRepository = mockk<HorseRepository>()
|
||||
private val funktionaerRepository = mockk<FunktionaerRepository>()
|
||||
private val landRepository = mockk<LandRepository>()
|
||||
private val bundeslandRepository = mockk<BundeslandRepository>()
|
||||
|
||||
private lateinit var service: ZnsImportService
|
||||
|
||||
@@ -35,7 +39,19 @@ class ZnsImportServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
service = ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository)
|
||||
service = ZnsImportService(
|
||||
vereinRepository,
|
||||
reiterRepository,
|
||||
horseRepository,
|
||||
funktionaerRepository,
|
||||
landRepository,
|
||||
bundeslandRepository
|
||||
)
|
||||
|
||||
// Standard-Stubs für optionale Lookups, damit Tests ohne spezifische Erwartungen nicht fehlschlagen
|
||||
coEvery { landRepository.findByIsoAlpha3Code(any()) } returns null
|
||||
coEvery { bundeslandRepository.findByNr(any()) } returns null
|
||||
coEvery { vereinRepository.findByExactName(any()) } returns null
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -52,7 +68,9 @@ class ZnsImportServiceTest {
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
return ByteArrayInputStream(baos.toByteArray())
|
||||
val zipBytes = baos.toByteArray()
|
||||
// println("[DEBUG_LOG] ZIP erstellt mit ${entries.size} Dateien, Gesamtgröße: ${zipBytes.size} Bytes")
|
||||
return ByteArrayInputStream(zipBytes)
|
||||
}
|
||||
|
||||
/** Erzeugt eine gültige VEREIN01.DAT-Zeile (mind. 30 Zeichen). */
|
||||
@@ -70,11 +88,20 @@ class ZnsImportServiceTest {
|
||||
nachname: String = "Mustermann",
|
||||
vorname: String = "Max"
|
||||
): String {
|
||||
// Stelle 1-6: Satznummer, 7-56: Nachname (50), 57-81: Vorname (25)
|
||||
return satznummer.padEnd(6) +
|
||||
nachname.padEnd(50) +
|
||||
vorname.padEnd(25) +
|
||||
" ".repeat(200) // Rest auffüllen
|
||||
val line = StringBuilder()
|
||||
line.append(satznummer.padEnd(6)) // 1-6
|
||||
line.append(nachname.padEnd(50)) // 7-56
|
||||
line.append(vorname.padEnd(25)) // 57-81
|
||||
line.append("01") // 82-83 (Buli)
|
||||
line.append("".padEnd(50)) // 84-133 (Verein)
|
||||
line.append("AUT") // 134-136 (Nation)
|
||||
line.append("R2 ") // 137-140 (Lizenz)
|
||||
line.append(" ") // 141 (Startkarte)
|
||||
line.append(" ") // 142-143 (Fahrlizenz)
|
||||
line.append("JG") // 144-145 (Altersklasse)
|
||||
line.append(" ") // 146 (Altersklasse Y)
|
||||
line.append("01234567") // 147-154 (Mitglied) - Bundesland 01 (Wien) + Rest
|
||||
return line.toString().padEnd(220)
|
||||
}
|
||||
|
||||
/** Erzeugt eine gültige PFERDE01.DAT-Zeile (mind. 211 Zeichen). */
|
||||
@@ -88,17 +115,20 @@ class ZnsImportServiceTest {
|
||||
lebensnummer.padEnd(9) +
|
||||
"W" + // Geschlecht: Wallach
|
||||
"2015" + // Geburtsjahr
|
||||
" ".repeat(157) // Auffüllen bis Stelle 201
|
||||
return base + "SAT0000001".padEnd(10) // Satznummer ab Stelle 202
|
||||
" ".repeat(157) // Auffüllen bis Stelle 201 (1 bis 201 = 201 Zeichen)
|
||||
return base + "1234567890".padEnd(10) // Satznummer ab Stelle 202
|
||||
}
|
||||
|
||||
/** Erzeugt eine gültige RICHT01.DAT-Zeile (mind. 83 Zeichen). */
|
||||
private fun richterZeile(
|
||||
satznummer: String = "R00001",
|
||||
name: String = "Huber, Anna"
|
||||
private fun funktionaerZeile(
|
||||
typ: String = "X",
|
||||
satznummer: String = "123456",
|
||||
name: String = "Huber, Anna",
|
||||
qualifikationen: String = "GA"
|
||||
): String {
|
||||
// Stelle 1: Typ, 2-7: Satznummer (6), 8-82: Name (75)
|
||||
return "R" + satznummer.padEnd(6) + name.padEnd(75)
|
||||
// Stelle 1: Typ (X=Richter, Y=Parcoursbauer), 2-7: Satznummer (6), 8-82: Name (75), 83-112: Quali (30)
|
||||
// WICHTIG: satznummer muss genau 6 Stellen lang sein, ohne abschließende Leerzeichen für den Int-Parser
|
||||
return typ + satznummer.padStart(6, '0') + name.padEnd(75) + qualifikationen.padEnd(30)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -110,29 +140,29 @@ class ZnsImportServiceTest {
|
||||
val zip = buildZip("VEREIN01.DAT" to vereinZeile())
|
||||
|
||||
coEvery { vereinRepository.findByVereinsNummer(any()) } returns null
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg<DomVerein>() }
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg<Verein>() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.vereineImportiert).isEqualTo(1)
|
||||
assertThat(result.vereineAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 1) { vereinRepository.save(any<DomVerein>()) }
|
||||
coVerify(exactly = 1) { vereinRepository.save(any<Verein>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - bestehende Vereine werden aktualisiert`() = runTest {
|
||||
val zip = buildZip("VEREIN01.DAT" to vereinZeile(name = "Neuer Name"))
|
||||
val vorhanden = DomVerein(vereinsNummer = "0001", name = "Alter Name")
|
||||
val vorhanden = Verein(vereinsNummer = "0001", vereinName = "Alter Name")
|
||||
|
||||
coEvery { vereinRepository.findByVereinsNummer("0001") } returns vorhanden
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg<DomVerein>() }
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg<Verein>() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.vereineImportiert).isEqualTo(0)
|
||||
assertThat(result.vereineAktualisiert).isEqualTo(1)
|
||||
coVerify(exactly = 1) { vereinRepository.save(match { it.name == "Neuer Name" }) }
|
||||
coVerify(exactly = 1) { vereinRepository.save(match { it.vereinName == "Neuer Name" }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -140,70 +170,130 @@ class ZnsImportServiceTest {
|
||||
val zip = buildZip("LIZENZ01.DAT" to lizenzZeile())
|
||||
|
||||
coEvery { reiterRepository.findBySatznummer(any()) } returns null
|
||||
coEvery { reiterRepository.save(any()) } answers { firstArg<DomReiter>() }
|
||||
coEvery { reiterRepository.save(any()) } answers { firstArg<Reiter>() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.reiterImportiert).isEqualTo(1)
|
||||
assertThat(result.reiterAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 1) { reiterRepository.save(any<DomReiter>()) }
|
||||
coVerify(exactly = 1) {
|
||||
reiterRepository.save(match {
|
||||
it.reiterLizenz == "R2" &&
|
||||
it.lizenzen.size == 1 &&
|
||||
it.lizenzen[0].kuerzel == "R2" &&
|
||||
it.altersklasseJgJrU25 == at.mocode.core.domain.model.ReiterAltersKlasseE.JG
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - neue Pferde werden gespeichert`() = runTest {
|
||||
val zip = buildZip("PFERDE01.DAT" to pferdeZeile())
|
||||
|
||||
coEvery { horseRepository.findBySatznummer(any()) } returns null
|
||||
coEvery { horseRepository.findByLebensnummer(any()) } returns null
|
||||
coEvery { horseRepository.save(any()) } answers { firstArg<DomPferd>() }
|
||||
coEvery { horseRepository.findByName(any(), any()) } returns emptyList()
|
||||
coEvery { horseRepository.save(any()) } answers { firstArg<Pferd>() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.pferdeImportiert).isEqualTo(1)
|
||||
assertThat(result.pferdeAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 1) { horseRepository.save(any<DomPferd>()) }
|
||||
coVerify(exactly = 1) { horseRepository.save(any<Pferd>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - neue Richter werden gespeichert`() = runTest {
|
||||
val zip = buildZip("RICHT01.DAT" to richterZeile())
|
||||
fun `importiereZip - Pferde werden ueber Kopfnummer und Geburtsjahr aktualisiert, wenn Satznummer fehlt`() = runTest {
|
||||
// Zeile ohne Satznummer (zu kurz)
|
||||
val zeile = "0001Blitz "
|
||||
val zip = buildZip("PFERDE01.DAT" to zeile)
|
||||
|
||||
coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg<DomFunktionaer>() }
|
||||
val existing = Pferd(pferdeName = "Blitz", kopfnummer = "0001", geschlecht = at.mocode.core.domain.model.PferdeGeschlechtE.UNBEKANNT)
|
||||
|
||||
coEvery { horseRepository.findBySatznummer(any()) } returns null
|
||||
coEvery { horseRepository.findByLebensnummer(any()) } returns null
|
||||
coEvery { horseRepository.findByName("Blitz", 100) } returns listOf(existing)
|
||||
coEvery { horseRepository.save(any()) } answers { firstArg<Pferd>() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.pferdeAktualisiert).isEqualTo(1)
|
||||
coVerify(exactly = 1) { horseRepository.save(match { it.pferdeName == "Blitz" && it.pferdId == existing.pferdId }) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - neue Funktionaere werden gespeichert`() = runTest {
|
||||
val zip = buildZip("RICHT01.DAT" to funktionaerZeile())
|
||||
|
||||
coEvery { funktionaerRepository.findBySatz(any(), any()) } returns null
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
|
||||
coEvery { reiterRepository.findByName(any(), any()) } returns emptyList()
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.richterImportiert).isEqualTo(1)
|
||||
assertThat(result.richterAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 1) { funktionaerRepository.save(any<DomFunktionaer>()) }
|
||||
coVerify(exactly = 1) { funktionaerRepository.save(any<Funktionaer>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - Richter und Parcoursbauer mit Mac-Zeilenumbruch werden importiert`() = runTest {
|
||||
// Nur \r als Umbruch
|
||||
val zip = buildZip(
|
||||
"RICHT01.DAT" to "X139552Mc Mullen Elizabeth DIOR\rX014346Schubert Renate DM,DPF,GAR-SP,SPF,SS*\rX001416Lechner-Gebhard Jeannette DPF,DSGP\rY135894Helmreich Marilena GA\r"
|
||||
)
|
||||
|
||||
coEvery { funktionaerRepository.findBySatz(any(), any()) } returns null
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.richterImportiert).isEqualTo(4)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
coVerify(exactly = 4) { funktionaerRepository.save(any<Funktionaer>()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - vollstaendiger Import aller vier Dateien`() = runTest {
|
||||
val zip = buildZip(
|
||||
"VEREIN01.DAT" to vereinZeile(),
|
||||
"LIZENZ01.DAT" to lizenzZeile(),
|
||||
"PFERDE01.DAT" to pferdeZeile(),
|
||||
"RICHT01.DAT" to richterZeile()
|
||||
)
|
||||
// Erstelle vier separate InputStreams für die vier Dateien (für isolierte Extraktion)
|
||||
val zipVerein = buildZip("VEREIN01.DAT" to vereinZeile())
|
||||
val zipLizenz = buildZip("LIZENZ01.DAT" to lizenzZeile())
|
||||
val zipPferde = buildZip("PFERDE01.DAT" to pferdeZeile())
|
||||
val zipRicht = buildZip("RICHT01.DAT" to funktionaerZeile())
|
||||
|
||||
coEvery { vereinRepository.findByVereinsNummer(any()) } returns null
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg<DomVerein>() }
|
||||
coEvery { vereinRepository.save(any()) } answers { firstArg<Verein>() }
|
||||
coEvery { reiterRepository.findBySatznummer(any()) } returns null
|
||||
coEvery { reiterRepository.save(any()) } answers { firstArg<DomReiter>() }
|
||||
coEvery { reiterRepository.findByName(any(), any()) } returns emptyList()
|
||||
coEvery { reiterRepository.save(any()) } answers { firstArg<Reiter>() }
|
||||
coEvery { horseRepository.findBySatznummer(any()) } returns null
|
||||
coEvery { horseRepository.findByLebensnummer(any()) } returns null
|
||||
coEvery { horseRepository.save(any()) } answers { firstArg<DomPferd>() }
|
||||
coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg<DomFunktionaer>() }
|
||||
coEvery { horseRepository.findByName(any(), any()) } returns emptyList()
|
||||
coEvery { horseRepository.save(any()) } answers { firstArg<Pferd>() }
|
||||
coEvery { funktionaerRepository.findBySatz(any(), any()) } returns null
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
|
||||
coEvery { vereinRepository.findByExactName(any()) } returns null
|
||||
coEvery { bundeslandRepository.findByNr(any()) } returns null
|
||||
coEvery { landRepository.findByIsoAlpha3Code(any()) } returns null
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
// Importiere nacheinander (Simulation eines vollständigen Workflows)
|
||||
val res1 = service.importiereZip(zipVerein)
|
||||
val res2 = service.importiereZip(zipLizenz)
|
||||
val res3 = service.importiereZip(zipPferde)
|
||||
val res4 = service.importiereZip(zipRicht)
|
||||
|
||||
assertThat(result.gesamtImportiert).isEqualTo(4)
|
||||
assertThat(result.gesamtAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
assertThat(result.hatFehler).isFalse()
|
||||
assertThat(res1.vereineImportiert).isEqualTo(1)
|
||||
assertThat(res2.reiterImportiert).isEqualTo(1)
|
||||
assertThat(res3.pferdeImportiert).isEqualTo(1)
|
||||
assertThat(res4.richterImportiert).isEqualTo(1)
|
||||
|
||||
assertThat(res1.fehler).isEmpty()
|
||||
assertThat(res2.fehler).isEmpty()
|
||||
assertThat(res3.fehler).isEmpty()
|
||||
assertThat(res4.fehler).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -216,4 +306,52 @@ class ZnsImportServiceTest {
|
||||
assertThat(result.gesamtAktualisiert).isEqualTo(0)
|
||||
assertThat(result.fehler).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - Funktionaer mit mehrfachen Qualifikationen`() = runTest {
|
||||
// Zeile mit vielen Qualifikationen (Satznummer X014346)
|
||||
val qualifikationen = "DM,DPF,GAR-SP,SPF,SS*,RD,RS"
|
||||
val zeile = "X014346Schubert Renate $qualifikationen"
|
||||
val zip = buildZip("RICHT01.DAT" to zeile)
|
||||
|
||||
coEvery { funktionaerRepository.findBySatz("X", 14346) } returns null
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
|
||||
coEvery { reiterRepository.findByName(any(), any()) } returns emptyList()
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.richterImportiert).isEqualTo(1)
|
||||
coVerify {
|
||||
funktionaerRepository.save(match { f ->
|
||||
f.qualifikationen.size == 7 &&
|
||||
f.qualifikationen.containsAll(listOf("DM", "DPF", "GAR-SP", "SPF", "SS*", "RD", "RS"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importiereZip - Funktionaer Update Strategie (Delete+Insert)`() = runTest {
|
||||
val zeile = funktionaerZeile(typ = "X", satznummer = "123456", name = "Geaendert Name")
|
||||
val zip = buildZip("RICHT01.DAT" to zeile)
|
||||
|
||||
val existing = Funktionaer(
|
||||
funktionaerId = kotlin.uuid.Uuid.random(),
|
||||
satzId = "X",
|
||||
satzNummer = 123456,
|
||||
name = "Alt Name"
|
||||
)
|
||||
|
||||
coEvery { funktionaerRepository.findBySatz("X", 123456) } returns existing
|
||||
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
|
||||
coEvery { reiterRepository.findByName(any(), any()) } returns emptyList()
|
||||
|
||||
val result = service.importiereZip(zip)
|
||||
|
||||
assertThat(result.richterAktualisiert).isEqualTo(1)
|
||||
coVerify {
|
||||
funktionaerRepository.save(match { f ->
|
||||
f.funktionaerId == existing.funktionaerId && f.name == "Geaendert Name"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
@file:OptIn(ExperimentalWasmDsl::class)
|
||||
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
wasmJs {
|
||||
browser()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
|
||||
jvmTest.dependencies {
|
||||
implementation(projects.platform.platformTesting)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.domain.model
|
||||
|
||||
import at.mocode.core.domain.serialization.InstantSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Repräsentiert das Kassa-Konto eines Teilnehmers (Reiter oder Besitzer).
|
||||
* Ein Konto wird pro Veranstaltung/Turnier geführt, kann aber veranstaltungsübergreifend aggregiert werden.
|
||||
*/
|
||||
@Serializable
|
||||
data class TeilnehmerKonto constructor(
|
||||
val kontoId: Uuid = Uuid.random(),
|
||||
val veranstaltungId: Uuid,
|
||||
val personId: Uuid, // Referenz auf Reiter oder Besitzer
|
||||
val personName: String,
|
||||
val saldoCent: Long = 0L, // Aktueller Kontostand in Cent
|
||||
val bemerkungen: String? = null,
|
||||
@Serializable(with = InstantSerializer::class)
|
||||
val updatedAt: Instant = Clock.System.now()
|
||||
)
|
||||
|
||||
/**
|
||||
* Ein einzelner Buchungsvorgang (Zahlung, Gutschrift, Gebühr).
|
||||
*/
|
||||
@Serializable
|
||||
data class Buchung constructor(
|
||||
val buchungId: Uuid = Uuid.random(),
|
||||
val kontoId: Uuid,
|
||||
val betragCent: Long, // Positiv für Gutschrift/Zahlung, Negativ für Gebühr/Soll
|
||||
val typ: BuchungsTyp,
|
||||
val verwendungszweck: String,
|
||||
@Serializable(with = InstantSerializer::class)
|
||||
val gebuchtAm: Instant = Clock.System.now(),
|
||||
val storniertBuchungId: Uuid? = null // Referenz auf die ursprüngliche Buchung, falls dies ein Storno ist
|
||||
)
|
||||
|
||||
/**
|
||||
* Repräsentiert einen Kassa-Tagesabschluss.
|
||||
*/
|
||||
@Serializable
|
||||
data class Tagesabschluss(
|
||||
val tagesabschlussId: Uuid = Uuid.random(),
|
||||
val veranstaltungId: Uuid,
|
||||
val abgeschlossenAm: Instant = Clock.System.now(),
|
||||
val abgeschlossenVon: String,
|
||||
val summeBarCent: Long,
|
||||
val summeKarteCent: Long,
|
||||
val summeGutschriftCent: Long,
|
||||
val anzahlBuchungen: Int,
|
||||
val bemerkungen: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class BuchungsTyp {
|
||||
NENNGEBUEHR,
|
||||
NENNGELD,
|
||||
NACHNENNGEBUEHR,
|
||||
STARTGEBUEHR,
|
||||
BOXENGEBUEHR,
|
||||
SPORTFOERDERBEITRAG,
|
||||
ZAHLUNG_BAR,
|
||||
ZAHLUNG_KARTE,
|
||||
GUTSCHRIFT,
|
||||
STORNIERUNG
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.domain.repository
|
||||
|
||||
import at.mocode.billing.domain.model.Buchung
|
||||
import at.mocode.billing.domain.model.Tagesabschluss
|
||||
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Repository für den Zugriff auf Teilnehmer-Konten.
|
||||
*/
|
||||
interface TeilnehmerKontoRepository {
|
||||
fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto?
|
||||
fun findById(kontoId: Uuid): TeilnehmerKonto?
|
||||
fun findByVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto>
|
||||
fun findOffenePosten(veranstaltungId: Uuid): List<TeilnehmerKonto>
|
||||
fun save(konto: TeilnehmerKonto): TeilnehmerKonto
|
||||
fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository für den Zugriff auf Buchungen.
|
||||
*/
|
||||
interface BuchungRepository {
|
||||
fun findByKonto(kontoId: Uuid): List<Buchung>
|
||||
fun findById(buchungId: Uuid): Buchung?
|
||||
fun findByVeranstaltungAndZeitraum(
|
||||
veranstaltungId: Uuid,
|
||||
von: Instant,
|
||||
bis: Instant
|
||||
): List<Buchung>
|
||||
fun save(buchung: Buchung): Buchung
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository für den Zugriff auf Tagesabschlüsse.
|
||||
*/
|
||||
interface TagesabschlussRepository {
|
||||
fun findByVeranstaltung(veranstaltungId: Uuid): List<Tagesabschluss>
|
||||
fun save(abschluss: Tagesabschluss): Tagesabschluss
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# ===================================================================
|
||||
# Multi-stage Dockerfile for Meldestelle Billing Service
|
||||
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
|
||||
# Version: 2.6.0 - Reliable Monorepo Build
|
||||
# ===================================================================
|
||||
|
||||
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||
ARG GRADLE_VERSION=9.4.1
|
||||
ARG JAVA_VERSION=25
|
||||
ARG BUILD_DATE
|
||||
ARG VERSION=1.0.0-SNAPSHOT
|
||||
|
||||
# ===================================================================
|
||||
# Build Stage
|
||||
# ===================================================================
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
|
||||
|
||||
ARG VERSION
|
||||
ARG BUILD_DATE
|
||||
|
||||
LABEL stage=builder \
|
||||
service="billing-service" \
|
||||
maintainer="Meldestelle Development Team"
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Gradle optimizations
|
||||
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
||||
-Dorg.gradle.daemon=false \
|
||||
-Dorg.gradle.parallel=true \
|
||||
-Dorg.gradle.workers.max=2 \
|
||||
-Dorg.gradle.jvmargs=-Xmx2g \
|
||||
-XX:+UseParallelGC \
|
||||
-XX:MaxMetaspaceSize=512m"
|
||||
ENV GRADLE_USER_HOME=/root/.gradle
|
||||
|
||||
# 1. Copy full project structure for a reliable monorepo build
|
||||
# .dockerignore should be used to exclude unnecessary files (IDE, logs, etc.)
|
||||
COPY . .
|
||||
|
||||
RUN chmod +x gradlew
|
||||
|
||||
# 2. Build the service
|
||||
RUN --mount=type=cache,target=/root/.gradle/caches \
|
||||
--mount=type=cache,target=/root/.gradle/wrapper \
|
||||
./gradlew :backend:services:billing:billing-service:bootJar --no-daemon --info
|
||||
|
||||
# 3. Extract layers
|
||||
WORKDIR /builder
|
||||
RUN cp /workspace/backend/services/billing/billing-service/build/libs/*.jar app.jar && \
|
||||
java -Djarmode=layertools -jar app.jar extract
|
||||
|
||||
# ===================================================================
|
||||
# Runtime Stage
|
||||
# ===================================================================
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
|
||||
|
||||
ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
ARG JAVA_VERSION
|
||||
|
||||
LABEL service="billing-service" \
|
||||
version="${VERSION}" \
|
||||
description="Microservice for Billing and Payments" \
|
||||
maintainer="Meldestelle Development Team" \
|
||||
java.version="${JAVA_VERSION}" \
|
||||
build.date="${BUILD_DATE}"
|
||||
|
||||
ARG APP_USER=appuser
|
||||
ARG APP_GROUP=appgroup
|
||||
ARG APP_UID=1001
|
||||
ARG APP_GID=1001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add --no-cache curl tzdata tini && \
|
||||
rm -rf /var/cache/apk/* && addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
||||
mkdir -p /app/logs /app/tmp /app/config && \
|
||||
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
||||
chmod -R 750 /app
|
||||
|
||||
# Copy Spring Boot layers
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
|
||||
|
||||
USER ${APP_USER}
|
||||
|
||||
EXPOSE 8087 5005
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD curl -fsS --max-time 2 http://localhost:8087/actuator/health/readiness || exit 1
|
||||
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||
-XX:+UseG1GC \
|
||||
-XX:+UseStringDeduplication \
|
||||
-XX:+UseContainerSupport \
|
||||
-XX:G1HeapRegionSize=16m \
|
||||
-XX:G1ReservePercent=25 \
|
||||
-XX:InitiatingHeapOccupancyPercent=30 \
|
||||
-XX:+AlwaysPreTouch \
|
||||
-XX:+DisableExplicitGC \
|
||||
-Djava.security.egd=file:/dev/./urandom \
|
||||
-Djava.awt.headless=true \
|
||||
-Dfile.encoding=UTF-8 \
|
||||
-Duser.timezone=Europe/Vienna \
|
||||
-Dspring.backgroundpreinitializer.ignore=true \
|
||||
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus \
|
||||
-Dmanagement.endpoint.health.show-details=always \
|
||||
-Dmanagement.prometheus.metrics.export.enabled=true"
|
||||
|
||||
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
|
||||
SERVER_PORT=8087 \
|
||||
LOGGING_LEVEL_ROOT=INFO
|
||||
|
||||
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
||||
echo 'Starting Billing Service with Java ${JAVA_VERSION}...'; \
|
||||
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
||||
echo 'DEBUG mode enabled'; \
|
||||
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
|
||||
else \
|
||||
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
|
||||
fi"]
|
||||
@@ -0,0 +1,46 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
alias(libs.plugins.spring.boot)
|
||||
alias(libs.plugins.spring.dependencyManagement)
|
||||
alias(libs.plugins.kotlinSpring)
|
||||
}
|
||||
|
||||
springBoot {
|
||||
mainClass.set("at.mocode.billing.service.BillingServiceApplicationKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Interne Module
|
||||
implementation(projects.platform.platformDependencies)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.backend.services.billing.billingDomain)
|
||||
|
||||
// Spring Boot Starters
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
implementation(libs.spring.boot.starter.validation)
|
||||
implementation(libs.spring.boot.starter.actuator)
|
||||
implementation(libs.jackson.module.kotlin)
|
||||
implementation(libs.openpdf)
|
||||
implementation(libs.spring.cloud.starter.consul.discovery)
|
||||
implementation(libs.micrometer.tracing.bridge.brave)
|
||||
implementation(libs.zipkin.reporter.brave)
|
||||
implementation(libs.zipkin.sender.okhttp3)
|
||||
|
||||
// Datenbank-Abhängigkeiten
|
||||
implementation(libs.exposed.core)
|
||||
implementation(libs.exposed.dao)
|
||||
implementation(libs.exposed.jdbc)
|
||||
implementation(libs.exposed.kotlin.datetime)
|
||||
implementation(libs.hikari.cp)
|
||||
runtimeOnly(libs.postgresql.driver)
|
||||
testRuntimeOnly(libs.h2.driver)
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.spring.boot.starter.test)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.api.rest
|
||||
|
||||
import at.mocode.billing.domain.model.Buchung
|
||||
import at.mocode.billing.domain.model.BuchungsTyp
|
||||
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||
import at.mocode.billing.service.PdfService
|
||||
import at.mocode.billing.service.TeilnehmerKontoService
|
||||
import at.mocode.core.domain.serialization.InstantSerializer
|
||||
import jakarta.validation.Valid
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.NotNull
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/billing")
|
||||
class BillingController(
|
||||
private val kontoService: TeilnehmerKontoService,
|
||||
private val pdfService: PdfService
|
||||
) {
|
||||
|
||||
data class KontoDto(
|
||||
val kontoId: String,
|
||||
val veranstaltungId: String,
|
||||
val personId: String,
|
||||
val personName: String,
|
||||
val saldoCent: Long,
|
||||
val bemerkungen: String?,
|
||||
@Serializable(with = InstantSerializer::class)
|
||||
val updatedAt: Instant
|
||||
)
|
||||
|
||||
data class BuchungDto(
|
||||
val buchungId: String,
|
||||
val kontoId: String,
|
||||
val betragCent: Long,
|
||||
val typ: BuchungsTyp,
|
||||
val verwendungszweck: String,
|
||||
@Serializable(with = InstantSerializer::class)
|
||||
val gebuchtAm: Instant
|
||||
)
|
||||
|
||||
data class CreateKontoRequest(
|
||||
@field:NotNull val veranstaltungId: String,
|
||||
@field:NotNull val personId: String,
|
||||
@field:NotBlank val personName: String
|
||||
)
|
||||
|
||||
data class BuchungRequest(
|
||||
@field:NotNull val betragCent: Long,
|
||||
@field:NotNull val typ: BuchungsTyp,
|
||||
@field:NotBlank val verwendungszweck: String
|
||||
)
|
||||
|
||||
@GetMapping("/konten/{kontoId}")
|
||||
fun getKonto(@PathVariable kontoId: String): ResponseEntity<KontoDto> {
|
||||
val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
val konto = kontoService.getKontoById(uuid) ?: return ResponseEntity.notFound().build()
|
||||
return ResponseEntity.ok(konto.toDto())
|
||||
}
|
||||
|
||||
@GetMapping("/konten")
|
||||
fun getKontoByVeranstaltungUndPerson(
|
||||
@RequestParam veranstaltungId: String,
|
||||
@RequestParam personId: String
|
||||
): ResponseEntity<KontoDto> {
|
||||
val vUuid = try { Uuid.parse(veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
val pUuid = try { Uuid.parse(personId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
|
||||
val konto = kontoService.getOrCreateKonto(vUuid, pUuid, "Unbekannt") // Name wird bei getOrCreate ggf. ignoriert wenn existiert
|
||||
return ResponseEntity.ok(konto.toDto())
|
||||
}
|
||||
|
||||
@PostMapping("/konten")
|
||||
fun createKonto(@Valid @RequestBody request: CreateKontoRequest): ResponseEntity<KontoDto> {
|
||||
val vUuid = try { Uuid.parse(request.veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
val pUuid = try { Uuid.parse(request.personId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
|
||||
val konto = kontoService.getOrCreateKonto(vUuid, pUuid, request.personName)
|
||||
return ResponseEntity.ok(konto.toDto())
|
||||
}
|
||||
|
||||
@GetMapping("/konten/{kontoId}/buchungen")
|
||||
fun getBuchungen(@PathVariable kontoId: String): ResponseEntity<List<BuchungDto>> {
|
||||
val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
val buchungen = kontoService.getBuchungsHistorie(uuid)
|
||||
return ResponseEntity.ok(buchungen.map { it.toDto() })
|
||||
}
|
||||
|
||||
@PostMapping("/konten/{kontoId}/buchungen")
|
||||
fun addBuchung(
|
||||
@PathVariable kontoId: String,
|
||||
@Valid @RequestBody request: BuchungRequest
|
||||
): ResponseEntity<KontoDto> {
|
||||
val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
val konto = kontoService.buche(
|
||||
kontoId = uuid,
|
||||
betragCent = request.betragCent,
|
||||
typ = request.typ,
|
||||
zweck = request.verwendungszweck
|
||||
)
|
||||
return ResponseEntity.ok(konto.toDto())
|
||||
}
|
||||
|
||||
@GetMapping("/konten/{kontoId}/rechnung", produces = [MediaType.APPLICATION_PDF_VALUE])
|
||||
fun downloadRechnung(@PathVariable kontoId: String): ResponseEntity<ByteArray> {
|
||||
val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
val konto = kontoService.getKontoById(uuid) ?: return ResponseEntity.notFound().build()
|
||||
|
||||
val pdf = pdfService.generateRechnung(konto)
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"rechnung_${konto.personName.replace(" ", "_")}.pdf\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(pdf)
|
||||
}
|
||||
|
||||
@GetMapping("/veranstaltungen/{veranstaltungId}/offene-posten")
|
||||
fun getOffenePosten(@PathVariable veranstaltungId: String): ResponseEntity<List<KontoDto>> {
|
||||
val uuid = try { Uuid.parse(veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
val konten = kontoService.getOffenePosten(uuid)
|
||||
return ResponseEntity.ok(konten.map { it.toDto() })
|
||||
}
|
||||
|
||||
@GetMapping("/veranstaltungen/{veranstaltungId}/konten")
|
||||
fun getKontenFuerVeranstaltung(@PathVariable veranstaltungId: String): ResponseEntity<List<KontoDto>> {
|
||||
val uuid = try { Uuid.parse(veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||
val konten = kontoService.getKontenFuerVeranstaltung(uuid)
|
||||
return ResponseEntity.ok(konten.map { it.toDto() })
|
||||
}
|
||||
|
||||
private fun TeilnehmerKonto.toDto() = KontoDto(
|
||||
kontoId = kontoId.toString(),
|
||||
veranstaltungId = veranstaltungId.toString(),
|
||||
personId = personId.toString(),
|
||||
personName = personName,
|
||||
saldoCent = saldoCent,
|
||||
bemerkungen = bemerkungen,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
|
||||
private fun Buchung.toDto() = BuchungDto(
|
||||
buchungId = buchungId.toString(),
|
||||
kontoId = kontoId.toString(),
|
||||
betragCent = betragCent,
|
||||
typ = typ,
|
||||
verwendungszweck = verwendungszweck,
|
||||
gebuchtAm = gebuchtAm
|
||||
)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.core.env.Environment
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
@EnableDiscoveryClient
|
||||
@SpringBootApplication
|
||||
class BillingServiceApplication(private val env: Environment) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(BillingServiceApplication::class.java)
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun onApplicationReady() {
|
||||
val springPort = env.getProperty("server.port", "8087")
|
||||
val appName = env.getProperty("spring.application.name", "billing-service")
|
||||
|
||||
log.info("----------------------------------------------------------")
|
||||
log.info("Application '{}' is running!", appName)
|
||||
log.info("Spring Management Port: {}", springPort)
|
||||
log.info("Profiles: {}", env.activeProfiles.joinToString(", "))
|
||||
log.info("----------------------------------------------------------")
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<BillingServiceApplication>(*args)
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.service
|
||||
|
||||
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||
import at.mocode.billing.domain.repository.BuchungRepository
|
||||
import com.lowagie.text.*
|
||||
import com.lowagie.text.pdf.PdfPCell
|
||||
import com.lowagie.text.pdf.PdfPTable
|
||||
import com.lowagie.text.pdf.PdfWriter
|
||||
import org.springframework.stereotype.Service
|
||||
import java.awt.Color
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.text.NumberFormat
|
||||
import java.util.*
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
@Service
|
||||
class PdfService(
|
||||
private val buchungRepository: BuchungRepository
|
||||
) {
|
||||
|
||||
fun generateRechnung(konto: TeilnehmerKonto): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
val document = Document(PageSize.A4)
|
||||
PdfWriter.getInstance(document, out)
|
||||
|
||||
document.open()
|
||||
|
||||
// Header
|
||||
val titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 18f)
|
||||
val header = Paragraph("Rechnung / Kontoauszug", titleFont)
|
||||
header.alignment = Element.ALIGN_CENTER
|
||||
header.spacingAfter = 20f
|
||||
document.add(header)
|
||||
|
||||
// Teilnehmer Info
|
||||
val infoFont = FontFactory.getFont(FontFactory.HELVETICA, 12f)
|
||||
document.add(Paragraph("Teilnehmer: ${konto.personName}", infoFont))
|
||||
document.add(Paragraph("Datum: ${java.time.LocalDate.now()}", infoFont))
|
||||
document.add(Paragraph("Konto-ID: ${konto.kontoId}", infoFont))
|
||||
document.add(Paragraph("Veranstaltung: ${konto.veranstaltungId}", infoFont))
|
||||
document.add(Paragraph(" ", infoFont))
|
||||
|
||||
// Tabelle
|
||||
val table = PdfPTable(4)
|
||||
table.widthPercentage = 100f
|
||||
table.setWidths(floatArrayOf(2f, 4f, 2f, 2f))
|
||||
|
||||
val headFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 11f)
|
||||
|
||||
fun addCell(text: String, font: Font = headFont, bgColor: Color? = Color.LIGHT_GRAY) {
|
||||
val cell = PdfPCell(Phrase(text, font))
|
||||
if (bgColor != null) cell.backgroundColor = bgColor
|
||||
cell.setPadding(5f)
|
||||
table.addCell(cell)
|
||||
}
|
||||
|
||||
addCell("Datum")
|
||||
addCell("Zweck")
|
||||
addCell("Typ")
|
||||
addCell("Betrag")
|
||||
|
||||
val buchungen = buchungRepository.findByKonto(konto.kontoId)
|
||||
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.GERMANY)
|
||||
val bodyFont = FontFactory.getFont(FontFactory.HELVETICA, 10f)
|
||||
|
||||
buchungen.forEach { b ->
|
||||
addCell(b.gebuchtAm.toString().substring(0, 10), bodyFont, null)
|
||||
addCell(b.verwendungszweck, bodyFont, null)
|
||||
addCell(b.typ.name, bodyFont, null)
|
||||
val betragStr = currencyFormat.format(b.betragCent / 100.0)
|
||||
addCell(betragStr, bodyFont, null)
|
||||
}
|
||||
|
||||
document.add(table)
|
||||
|
||||
// Saldo
|
||||
val saldoFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 14f)
|
||||
val saldoPara = Paragraph(" ", saldoFont)
|
||||
saldoPara.spacingBefore = 20f
|
||||
document.add(saldoPara)
|
||||
|
||||
val saldoText = "Gesamtsaldo: ${currencyFormat.format(konto.saldoCent / 100.0)}"
|
||||
val finalSaldo = Paragraph(saldoText, saldoFont)
|
||||
finalSaldo.alignment = Element.ALIGN_RIGHT
|
||||
document.add(finalSaldo)
|
||||
|
||||
document.close()
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.service
|
||||
|
||||
import at.mocode.billing.domain.model.BuchungsTyp
|
||||
import at.mocode.billing.domain.model.Tagesabschluss
|
||||
import at.mocode.billing.domain.repository.BuchungRepository
|
||||
import at.mocode.billing.domain.repository.TagesabschlussRepository
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.springframework.stereotype.Service
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Service
|
||||
class TagesabschlussService(
|
||||
private val buchungRepository: BuchungRepository,
|
||||
private val tagesabschlussRepository: TagesabschlussRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Erstellt einen Tagesabschluss für die angegebene Veranstaltung und den Zeitraum.
|
||||
* Standardmäßig wird der Zeitraum von "heute 00:00" bis "jetzt" genommen,
|
||||
* wenn keine Zeiten angegeben sind.
|
||||
*/
|
||||
fun erstelleAbschluss(
|
||||
veranstaltungId: Uuid,
|
||||
von: Instant,
|
||||
bis: Instant,
|
||||
abgeschlossenVon: String,
|
||||
bemerkungen: String? = null
|
||||
): Tagesabschluss {
|
||||
return transaction {
|
||||
val buchungen = buchungRepository.findByVeranstaltungAndZeitraum(veranstaltungId, von, bis)
|
||||
|
||||
val summeBar = buchungen
|
||||
.filter { it.typ == BuchungsTyp.ZAHLUNG_BAR }
|
||||
.sumOf { it.betragCent }
|
||||
|
||||
val summeKarte = buchungen
|
||||
.filter { it.typ == BuchungsTyp.ZAHLUNG_KARTE }
|
||||
.sumOf { it.betragCent }
|
||||
|
||||
val summeGutschrift = buchungen
|
||||
.filter { it.typ == BuchungsTyp.GUTSCHRIFT }
|
||||
.sumOf { it.betragCent }
|
||||
|
||||
val abschluss = Tagesabschluss(
|
||||
veranstaltungId = veranstaltungId,
|
||||
abgeschlossenVon = abgeschlossenVon,
|
||||
summeBarCent = summeBar,
|
||||
summeKarteCent = summeKarte,
|
||||
summeGutschriftCent = summeGutschrift,
|
||||
anzahlBuchungen = buchungen.size,
|
||||
bemerkungen = bemerkungen
|
||||
)
|
||||
|
||||
tagesabschlussRepository.save(abschluss)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAbschluesse(veranstaltungId: Uuid): List<Tagesabschluss> {
|
||||
return transaction {
|
||||
tagesabschlussRepository.findByVeranstaltung(veranstaltungId)
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.service
|
||||
|
||||
import at.mocode.billing.domain.model.Buchung
|
||||
import at.mocode.billing.domain.model.BuchungsTyp
|
||||
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||
import at.mocode.billing.domain.repository.BuchungRepository
|
||||
import at.mocode.billing.domain.repository.TeilnehmerKontoRepository
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.springframework.stereotype.Service
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Service
|
||||
class TeilnehmerKontoService(
|
||||
private val kontoRepository: TeilnehmerKontoRepository,
|
||||
private val buchungRepository: BuchungRepository
|
||||
) {
|
||||
|
||||
fun getOrCreateKonto(veranstaltungId: Uuid, personId: Uuid, personName: String): TeilnehmerKonto {
|
||||
return transaction {
|
||||
kontoRepository.findByVeranstaltungAndPerson(veranstaltungId, personId)
|
||||
?: kontoRepository.save(
|
||||
TeilnehmerKonto(
|
||||
veranstaltungId = veranstaltungId,
|
||||
personId = personId,
|
||||
personName = personName
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getKontoById(kontoId: Uuid): TeilnehmerKonto? {
|
||||
return transaction {
|
||||
kontoRepository.findById(kontoId)
|
||||
}
|
||||
}
|
||||
|
||||
fun getKonto(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? {
|
||||
return transaction {
|
||||
kontoRepository.findByVeranstaltungAndPerson(veranstaltungId, personId)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBuchungsHistorie(kontoId: Uuid): List<Buchung> {
|
||||
return transaction {
|
||||
buchungRepository.findByKonto(kontoId)
|
||||
}
|
||||
}
|
||||
|
||||
fun buche(kontoId: Uuid, betragCent: Long, typ: BuchungsTyp, zweck: String): TeilnehmerKonto {
|
||||
return transaction {
|
||||
val konto = kontoRepository.findById(kontoId) ?: throw IllegalArgumentException("Konto nicht gefunden: $kontoId")
|
||||
|
||||
// Validierung: Bestimmte Typen sind immer "Soll" (negativ), andere "Haben" (positiv/Zahlung)
|
||||
val validierterBetrag = when (typ) {
|
||||
BuchungsTyp.NENNGELD,
|
||||
BuchungsTyp.NENNGEBUEHR,
|
||||
BuchungsTyp.NACHNENNGEBUEHR,
|
||||
BuchungsTyp.STARTGEBUEHR,
|
||||
BuchungsTyp.SPORTFOERDERBEITRAG,
|
||||
BuchungsTyp.BOXENGEBUEHR -> if (betragCent > 0) -betragCent else betragCent
|
||||
|
||||
BuchungsTyp.ZAHLUNG_BAR,
|
||||
BuchungsTyp.ZAHLUNG_KARTE,
|
||||
BuchungsTyp.GUTSCHRIFT -> if (betragCent < 0) -betragCent else betragCent
|
||||
|
||||
BuchungsTyp.STORNIERUNG -> betragCent // Storno kann beides sein (Gegenbuchung)
|
||||
}
|
||||
|
||||
val buchung = Buchung(
|
||||
kontoId = kontoId,
|
||||
betragCent = validierterBetrag,
|
||||
typ = typ,
|
||||
verwendungszweck = zweck
|
||||
)
|
||||
|
||||
buchungRepository.save(buchung)
|
||||
val neuerSaldo = konto.saldoCent + validierterBetrag
|
||||
kontoRepository.updateSaldo(kontoId, neuerSaldo)
|
||||
|
||||
kontoRepository.findById(kontoId)!!
|
||||
}
|
||||
}
|
||||
|
||||
fun getKontenFuerVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||
return transaction {
|
||||
kontoRepository.findByVeranstaltung(veranstaltungId)
|
||||
}
|
||||
}
|
||||
|
||||
fun getOffenePosten(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||
return transaction {
|
||||
kontoRepository.findOffenePosten(veranstaltungId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storniert eine existierende Buchung durch eine Gegenbuchung.
|
||||
*/
|
||||
fun storniereBuchung(buchungId: Uuid, grund: String): TeilnehmerKonto {
|
||||
return transaction {
|
||||
val ursprung = buchungRepository.findById(buchungId)
|
||||
?: throw IllegalArgumentException("Buchung nicht gefunden: $buchungId")
|
||||
|
||||
if (ursprung.typ == BuchungsTyp.STORNIERUNG) {
|
||||
throw IllegalArgumentException("Ein Storno kann nicht erneut storniert werden.")
|
||||
}
|
||||
|
||||
val konto = kontoRepository.findById(ursprung.kontoId)!!
|
||||
|
||||
// Gegenbuchung erstellen (Betrag umkehren)
|
||||
val stornoBuchung = Buchung(
|
||||
kontoId = ursprung.kontoId,
|
||||
betragCent = -ursprung.betragCent,
|
||||
typ = BuchungsTyp.STORNIERUNG,
|
||||
verwendungszweck = "Storno von ${ursprung.buchungId}: $grund",
|
||||
storniertBuchungId = ursprung.buchungId
|
||||
)
|
||||
|
||||
buchungRepository.save(stornoBuchung)
|
||||
|
||||
val neuerSaldo = konto.saldoCent - ursprung.betragCent
|
||||
kontoRepository.updateSaldo(konto.kontoId, neuerSaldo)
|
||||
|
||||
kontoRepository.findById(konto.kontoId)!!
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package at.mocode.billing.service.config
|
||||
|
||||
import at.mocode.billing.service.persistence.BuchungTable
|
||||
import at.mocode.billing.service.persistence.TagesabschlussTable
|
||||
import at.mocode.billing.service.persistence.TeilnehmerKontoTable
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.jetbrains.exposed.v1.jdbc.Database
|
||||
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class BillingDatabaseConfiguration(
|
||||
@Value("\${spring.datasource.url:}") private val jdbcUrl: String,
|
||||
@Value("\${spring.datasource.username:}") private val username: String,
|
||||
@Value("\${spring.datasource.password:}") private val password: String
|
||||
) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(BillingDatabaseConfiguration::class.java)
|
||||
|
||||
@PostConstruct
|
||||
fun initializeDatabase() {
|
||||
if (jdbcUrl.isBlank()) {
|
||||
log.warn("No spring.datasource.url provided. Skipping Billing database initialization.")
|
||||
return
|
||||
}
|
||||
log.info("Initializing database schema for Billing Service...")
|
||||
try {
|
||||
Database.connect(jdbcUrl, user = username, password = password)
|
||||
transaction {
|
||||
SchemaUtils.create(
|
||||
TeilnehmerKontoTable,
|
||||
BuchungTable,
|
||||
TagesabschlussTable
|
||||
)
|
||||
}
|
||||
log.info("Billing database schema initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to initialize billing database schema", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.service.persistence
|
||||
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
|
||||
import org.jetbrains.exposed.v1.datetime.timestamp
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
|
||||
/**
|
||||
* Exposed-Tabellendefinition für das Teilnehmer-Konto.
|
||||
*/
|
||||
object TeilnehmerKontoTable : Table("teilnehmer_konten") {
|
||||
val id = uuid("konto_id")
|
||||
val veranstaltungId = uuid("veranstaltung_id")
|
||||
val personId = uuid("person_id")
|
||||
val personName = varchar("person_name", 200)
|
||||
val saldoCent = long("saldo_cent").default(0L)
|
||||
val bemerkungen = text("bemerkungen").nullable()
|
||||
|
||||
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
|
||||
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp)
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
|
||||
init {
|
||||
index("idx_konto_veranstaltung_person", isUnique = true, veranstaltungId, personId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed-Tabellendefinition für Buchungen.
|
||||
*/
|
||||
object BuchungTable : Table("buchungen") {
|
||||
val id = uuid("buchung_id")
|
||||
val kontoId = uuid("konto_id")
|
||||
val betragCent = long("betrag_cent")
|
||||
val typ = varchar("typ", 50)
|
||||
val verwendungszweck = varchar("verwendungszweck", 500)
|
||||
val gebuchtAm = timestamp("gebucht_am").defaultExpression(CurrentTimestamp)
|
||||
val storniertBuchungId = uuid("storniert_buchung_id").nullable()
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
|
||||
init {
|
||||
index("idx_buchung_konto", isUnique = false, kontoId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed-Tabellendefinition für Tagesabschlüsse.
|
||||
*/
|
||||
object TagesabschlussTable : Table("tagesabschluesse") {
|
||||
val id = uuid("tagesabschluss_id")
|
||||
val veranstaltungId = uuid("veranstaltung_id")
|
||||
val abgeschlossenAm = timestamp("abgeschlossen_am").defaultExpression(CurrentTimestamp)
|
||||
val abgeschlossenVon = varchar("abgeschlossen_von", 200)
|
||||
val summeBarCent = long("summe_bar_cent")
|
||||
val summeKarteCent = long("summe_karte_cent")
|
||||
val summeGutschriftCent = long("summe_gutschrift_cent")
|
||||
val anzahlBuchungen = integer("anzahl_buchungen")
|
||||
val bemerkungen = text("bemerkungen").nullable()
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
|
||||
init {
|
||||
index("idx_tagesabschluss_veranstaltung", isUnique = false, veranstaltungId)
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.service.persistence
|
||||
|
||||
import at.mocode.billing.domain.model.Buchung
|
||||
import at.mocode.billing.domain.model.BuchungsTyp
|
||||
import at.mocode.billing.domain.model.Tagesabschluss
|
||||
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||
import at.mocode.billing.domain.repository.BuchungRepository
|
||||
import at.mocode.billing.domain.repository.TagesabschlussRepository
|
||||
import at.mocode.billing.domain.repository.TeilnehmerKontoRepository
|
||||
import org.jetbrains.exposed.v1.core.*
|
||||
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import org.springframework.stereotype.Repository
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Repository
|
||||
class ExposedTeilnehmerKontoRepository : TeilnehmerKontoRepository {
|
||||
|
||||
override fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? {
|
||||
return TeilnehmerKontoTable
|
||||
.selectAll()
|
||||
.where { (TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and (TeilnehmerKontoTable.personId eq personId) }
|
||||
.singleOrNull()
|
||||
?.toModel()
|
||||
}
|
||||
|
||||
override fun findById(kontoId: Uuid): TeilnehmerKonto? {
|
||||
return TeilnehmerKontoTable
|
||||
.selectAll()
|
||||
.where { TeilnehmerKontoTable.id eq kontoId }
|
||||
.singleOrNull()
|
||||
?.toModel()
|
||||
}
|
||||
|
||||
override fun findByVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||
return TeilnehmerKontoTable
|
||||
.selectAll()
|
||||
.where { TeilnehmerKontoTable.veranstaltungId eq veranstaltungId }
|
||||
.map { it.toModel() }
|
||||
}
|
||||
|
||||
override fun findOffenePosten(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||
return TeilnehmerKontoTable
|
||||
.selectAll()
|
||||
.where { (TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and (TeilnehmerKontoTable.saldoCent less 0) }
|
||||
.map { it.toModel() }
|
||||
}
|
||||
|
||||
override fun save(konto: TeilnehmerKonto): TeilnehmerKonto {
|
||||
val existing = findById(konto.kontoId)
|
||||
if (existing == null) {
|
||||
TeilnehmerKontoTable.insert {
|
||||
it[id] = konto.kontoId
|
||||
it[veranstaltungId] = konto.veranstaltungId
|
||||
it[personId] = konto.personId
|
||||
it[personName] = konto.personName
|
||||
it[saldoCent] = konto.saldoCent
|
||||
it[bemerkungen] = konto.bemerkungen
|
||||
}
|
||||
} else {
|
||||
TeilnehmerKontoTable.update({ TeilnehmerKontoTable.id eq konto.kontoId }) {
|
||||
it[personName] = konto.personName
|
||||
it[saldoCent] = konto.saldoCent
|
||||
it[bemerkungen] = konto.bemerkungen
|
||||
it[updatedAt] = CurrentTimestamp
|
||||
}
|
||||
}
|
||||
return findById(konto.kontoId)!!
|
||||
}
|
||||
|
||||
override fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long {
|
||||
TeilnehmerKontoTable.update({ TeilnehmerKontoTable.id eq kontoId }) {
|
||||
it[this.saldoCent] = saldoCent
|
||||
it[updatedAt] = CurrentTimestamp
|
||||
}
|
||||
return saldoCent
|
||||
}
|
||||
|
||||
private fun ResultRow.toModel() = TeilnehmerKonto(
|
||||
kontoId = this[TeilnehmerKontoTable.id],
|
||||
veranstaltungId = this[TeilnehmerKontoTable.veranstaltungId],
|
||||
personId = this[TeilnehmerKontoTable.personId],
|
||||
personName = this[TeilnehmerKontoTable.personName],
|
||||
saldoCent = this[TeilnehmerKontoTable.saldoCent],
|
||||
bemerkungen = this[TeilnehmerKontoTable.bemerkungen],
|
||||
updatedAt = this[TeilnehmerKontoTable.updatedAt]
|
||||
)
|
||||
}
|
||||
|
||||
@Repository
|
||||
class ExposedBuchungRepository : BuchungRepository {
|
||||
|
||||
override fun findByKonto(kontoId: Uuid): List<Buchung> {
|
||||
return BuchungTable
|
||||
.selectAll()
|
||||
.where { BuchungTable.kontoId eq kontoId }
|
||||
.map { it.toModel() }
|
||||
}
|
||||
|
||||
override fun findById(buchungId: Uuid): Buchung? {
|
||||
return BuchungTable
|
||||
.selectAll()
|
||||
.where { BuchungTable.id eq buchungId }
|
||||
.singleOrNull()
|
||||
?.toModel()
|
||||
}
|
||||
|
||||
override fun findByVeranstaltungAndZeitraum(
|
||||
veranstaltungId: Uuid,
|
||||
von: Instant,
|
||||
bis: Instant
|
||||
): List<Buchung> {
|
||||
// Da Buchungen über Konten verknüpft sind, müssen wir einen Join machen oder über die Konten der Veranstaltung filtern
|
||||
return Join(BuchungTable, TeilnehmerKontoTable, JoinType.INNER, BuchungTable.kontoId, TeilnehmerKontoTable.id)
|
||||
.selectAll()
|
||||
.where {
|
||||
(TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and
|
||||
(BuchungTable.gebuchtAm.between(von, bis))
|
||||
}
|
||||
.map { it.toModel() }
|
||||
}
|
||||
|
||||
override fun save(buchung: Buchung): Buchung {
|
||||
BuchungTable.insert {
|
||||
it[id] = buchung.buchungId
|
||||
it[kontoId] = buchung.kontoId
|
||||
it[betragCent] = buchung.betragCent
|
||||
it[typ] = buchung.typ.name
|
||||
it[verwendungszweck] = buchung.verwendungszweck
|
||||
it[gebuchtAm] = buchung.gebuchtAm
|
||||
it[storniertBuchungId] = buchung.storniertBuchungId
|
||||
}
|
||||
return buchung
|
||||
}
|
||||
|
||||
private fun ResultRow.toModel() = Buchung(
|
||||
buchungId = this[BuchungTable.id],
|
||||
kontoId = this[BuchungTable.kontoId],
|
||||
betragCent = this[BuchungTable.betragCent],
|
||||
typ = BuchungsTyp.valueOf(this[BuchungTable.typ]),
|
||||
verwendungszweck = this[BuchungTable.verwendungszweck],
|
||||
gebuchtAm = this[BuchungTable.gebuchtAm],
|
||||
storniertBuchungId = this[BuchungTable.storniertBuchungId]
|
||||
)
|
||||
}
|
||||
|
||||
@Repository
|
||||
class ExposedTagesabschlussRepository : TagesabschlussRepository {
|
||||
|
||||
override fun findByVeranstaltung(veranstaltungId: Uuid): List<Tagesabschluss> {
|
||||
return TagesabschlussTable
|
||||
.selectAll()
|
||||
.where { TagesabschlussTable.veranstaltungId eq veranstaltungId }
|
||||
.map { it.toModel() }
|
||||
}
|
||||
|
||||
override fun save(abschluss: Tagesabschluss): Tagesabschluss {
|
||||
TagesabschlussTable.insert {
|
||||
it[id] = abschluss.tagesabschlussId
|
||||
it[veranstaltungId] = abschluss.veranstaltungId
|
||||
it[abgeschlossenAm] = abschluss.abgeschlossenAm
|
||||
it[abgeschlossenVon] = abschluss.abgeschlossenVon
|
||||
it[summeBarCent] = abschluss.summeBarCent
|
||||
it[summeKarteCent] = abschluss.summeKarteCent
|
||||
it[summeGutschriftCent] = abschluss.summeGutschriftCent
|
||||
it[anzahlBuchungen] = abschluss.anzahlBuchungen
|
||||
it[bemerkungen] = abschluss.bemerkungen
|
||||
}
|
||||
return abschluss
|
||||
}
|
||||
|
||||
private fun ResultRow.toModel() = Tagesabschluss(
|
||||
tagesabschlussId = this[TagesabschlussTable.id],
|
||||
veranstaltungId = this[TagesabschlussTable.veranstaltungId],
|
||||
abgeschlossenAm = this[TagesabschlussTable.abgeschlossenAm],
|
||||
abgeschlossenVon = this[TagesabschlussTable.abgeschlossenVon],
|
||||
summeBarCent = this[TagesabschlussTable.summeBarCent],
|
||||
summeKarteCent = this[TagesabschlussTable.summeKarteCent],
|
||||
summeGutschriftCent = this[TagesabschlussTable.summeGutschriftCent],
|
||||
anzahlBuchungen = this[TagesabschlussTable.anzahlBuchungen],
|
||||
bemerkungen = this[TagesabschlussTable.bemerkungen]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
spring:
|
||||
application:
|
||||
name: billing-service
|
||||
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:pg-user}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:pg-password}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
|
||||
cloud:
|
||||
consul:
|
||||
host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
|
||||
port: ${SPRING_CLOUD_CONSUL_PORT:8500}
|
||||
discovery:
|
||||
enabled: true
|
||||
register: true
|
||||
prefer-ip-address: true
|
||||
health-check-path: /actuator/health
|
||||
health-check-interval: 10s
|
||||
# health-check-port: 8089
|
||||
instance-id: ${spring.application.name}:${server.port}:${random.uuid}
|
||||
service-name: ${spring.application.name}
|
||||
port: ${billing.http.port:8089}
|
||||
|
||||
server:
|
||||
port: 8089
|
||||
|
||||
billing:
|
||||
http:
|
||||
port: 8089 # Ktor API Port (Haupt-Einstiegspunkt für REST-Anfragen)
|
||||
address: 0.0.0.0 # Öffentlich erreichbar innerhalb des Netzwerks / Containers
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
probes:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
# at.mocode.billing: DEBUG
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Billing SCS API
|
||||
description: >
|
||||
API für den Billing-Bounded-Context (Kassa, Abrechnung, Teilnehmerkonten)
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: http://localhost:8089
|
||||
description: Lokaler Entwicklungs-Server
|
||||
paths:
|
||||
/api/billing/konten:
|
||||
get:
|
||||
summary: Teilnehmerkonto suchen
|
||||
description: Sucht ein Konto basierend auf Veranstaltungs-ID und Personen-ID. Erstellt das Konto, falls es nicht existiert.
|
||||
parameters:
|
||||
- name: veranstaltungId
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: personId
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Teilnehmerkonto
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KontoDto'
|
||||
'400':
|
||||
description: Ungültige UUID-Formate
|
||||
post:
|
||||
summary: Teilnehmerkonto erstellen oder abrufen
|
||||
description: Erstellt ein neues Teilnehmerkonto für eine Veranstaltung und eine Person.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateKontoRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Teilnehmerkonto (neu erstellt oder bestehend)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KontoDto'
|
||||
'400':
|
||||
description: Validierungsfehler
|
||||
/api/billing/konten/{kontoId}:
|
||||
get:
|
||||
summary: Teilnehmerkonto nach ID abrufen
|
||||
parameters:
|
||||
- name: kontoId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Details zum Teilnehmerkonto
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KontoDto'
|
||||
'404':
|
||||
description: Konto nicht gefunden
|
||||
'400':
|
||||
description: Ungültige Konto-ID
|
||||
/api/billing/konten/{kontoId}/buchungen:
|
||||
get:
|
||||
summary: Buchungshistorie abrufen
|
||||
description: Liefert alle Buchungen für ein bestimmtes Teilnehmerkonto.
|
||||
parameters:
|
||||
- name: kontoId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Liste von Buchungen
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BuchungDto'
|
||||
'400':
|
||||
description: Ungültige Konto-ID
|
||||
post:
|
||||
summary: Buchung hinzufügen
|
||||
description: Führt eine neue Buchung auf dem Teilnehmerkonto durch und aktualisiert den Saldo.
|
||||
parameters:
|
||||
- name: kontoId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BuchungRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Aktualisiertes Teilnehmerkonto nach der Buchung
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/KontoDto'
|
||||
'400':
|
||||
description: Validierungsfehler oder ungültige Konto-ID
|
||||
components:
|
||||
schemas:
|
||||
KontoDto:
|
||||
type: object
|
||||
properties:
|
||||
kontoId:
|
||||
type: string
|
||||
format: uuid
|
||||
veranstaltungId:
|
||||
type: string
|
||||
format: uuid
|
||||
personId:
|
||||
type: string
|
||||
format: uuid
|
||||
personName:
|
||||
type: string
|
||||
saldoCent:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Aktueller Saldo in Cent
|
||||
bemerkungen:
|
||||
type: string
|
||||
nullable: true
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Zeitpunkt der letzten Aktualisierung
|
||||
BuchungDto:
|
||||
type: object
|
||||
properties:
|
||||
buchungId:
|
||||
type: string
|
||||
format: uuid
|
||||
kontoId:
|
||||
type: string
|
||||
format: uuid
|
||||
betragCent:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Betrag in Cent (positiv für Gutschriften, negativ für Belastungen)
|
||||
typ:
|
||||
$ref: '#/components/schemas/BuchungsTyp'
|
||||
verwendungszweck:
|
||||
type: string
|
||||
gebuchtAm:
|
||||
type: string
|
||||
format: date-time
|
||||
CreateKontoRequest:
|
||||
type: object
|
||||
required:
|
||||
- veranstaltungId
|
||||
- personId
|
||||
- personName
|
||||
properties:
|
||||
veranstaltungId:
|
||||
type: string
|
||||
format: uuid
|
||||
personId:
|
||||
type: string
|
||||
format: uuid
|
||||
personName:
|
||||
type: string
|
||||
minLength: 1
|
||||
BuchungRequest:
|
||||
type: object
|
||||
required:
|
||||
- betragCent
|
||||
- typ
|
||||
- verwendungszweck
|
||||
properties:
|
||||
betragCent:
|
||||
type: integer
|
||||
format: int64
|
||||
typ:
|
||||
$ref: '#/components/schemas/BuchungsTyp'
|
||||
verwendungszweck:
|
||||
type: string
|
||||
minLength: 1
|
||||
BuchungsTyp:
|
||||
type: string
|
||||
enum:
|
||||
- NENNGEBUEHR
|
||||
- KOPPELGEBUEHR
|
||||
- NACHNENNGEBUEHR
|
||||
- STARTGEBUEHR
|
||||
- EINZAHLUNG
|
||||
- AUSZAHLUNG
|
||||
- SONSTIGES
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.service
|
||||
|
||||
import at.mocode.billing.domain.model.BuchungsTyp
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class TagesabschlussServiceTest {
|
||||
|
||||
@Autowired
|
||||
lateinit var kontoService: TeilnehmerKontoService
|
||||
|
||||
@Autowired
|
||||
lateinit var tagesabschlussService: TagesabschlussService
|
||||
|
||||
@Test
|
||||
fun `Tagesabschluss aggregiert Buchungen korrekt`() {
|
||||
val vId = Uuid.random()
|
||||
val k1 = kontoService.getOrCreateKonto(vId, Uuid.random(), "Reiter A")
|
||||
val k2 = kontoService.getOrCreateKonto(vId, Uuid.random(), "Reiter B")
|
||||
|
||||
val jetzt = Clock.System.now()
|
||||
val von = jetzt - 1.hours
|
||||
val bis = jetzt + 1.hours
|
||||
|
||||
// Buchungen erstellen
|
||||
kontoService.buche(k1.kontoId, 5000L, BuchungsTyp.ZAHLUNG_BAR, "Barzahlung 1")
|
||||
kontoService.buche(k2.kontoId, 3000L, BuchungsTyp.ZAHLUNG_BAR, "Barzahlung 2")
|
||||
kontoService.buche(k1.kontoId, 2500L, BuchungsTyp.ZAHLUNG_KARTE, "Kartenzahlung")
|
||||
kontoService.buche(k2.kontoId, 1000L, BuchungsTyp.GUTSCHRIFT, "Gutschrift")
|
||||
|
||||
// Gebühren (sollten nicht in den Zahlungs-Summen auftauchen)
|
||||
kontoService.buche(k1.kontoId, 1500L, BuchungsTyp.NENNGEBUEHR, "Gebühr")
|
||||
|
||||
// Abschluss erstellen
|
||||
val abschluss = tagesabschlussService.erstelleAbschluss(
|
||||
veranstaltungId = vId,
|
||||
von = von,
|
||||
bis = bis,
|
||||
abgeschlossenVon = "Admin"
|
||||
)
|
||||
|
||||
assertNotNull(abschluss)
|
||||
assertEquals(8000L, abschluss.summeBarCent)
|
||||
assertEquals(2500L, abschluss.summeKarteCent)
|
||||
assertEquals(1000L, abschluss.summeGutschriftCent)
|
||||
assertEquals(5, abschluss.anzahlBuchungen) // 2x Bar + 1x Karte + 1x Gutschrift + 1x Gebühr
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
@file:OptIn(ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.billing.service
|
||||
|
||||
import at.mocode.billing.domain.model.BuchungsTyp
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class TeilnehmerKontoServiceTest {
|
||||
|
||||
@Autowired
|
||||
lateinit var service: TeilnehmerKontoService
|
||||
|
||||
@Test
|
||||
fun `Konto erstellen und buchen`() {
|
||||
val veranstaltungId = Uuid.random()
|
||||
val personId = Uuid.random()
|
||||
val personName = "Max Mustermann"
|
||||
|
||||
// 1. Konto erstellen
|
||||
val konto = service.getOrCreateKonto(veranstaltungId, personId, personName)
|
||||
assertNotNull(konto)
|
||||
assertEquals(personName, konto.personName)
|
||||
assertEquals(0L, konto.saldoCent)
|
||||
|
||||
// 2. Buchung durchführen
|
||||
val updatedKonto = service.buche(
|
||||
kontoId = konto.kontoId,
|
||||
betragCent = 1500L,
|
||||
typ = BuchungsTyp.NENNGEBUEHR,
|
||||
zweck = "Nennung Bewerb 1"
|
||||
)
|
||||
|
||||
assertEquals(-1500L, updatedKonto.saldoCent)
|
||||
|
||||
// 3. Buchungshistorie prüfen
|
||||
val buchungen = service.getBuchungsHistorie(konto.kontoId)
|
||||
assertEquals(1, buchungen.size)
|
||||
assertEquals(-1500L, buchungen[0].betragCent)
|
||||
assertEquals("Nennung Bewerb 1", buchungen[0].verwendungszweck)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Mehrere Buchungen summieren sich korrekt`() {
|
||||
val vId = Uuid.random()
|
||||
val pId = Uuid.random()
|
||||
val konto = service.getOrCreateKonto(vId, pId, "Susi Sorglos")
|
||||
|
||||
service.buche(konto.kontoId, 2000L, BuchungsTyp.STARTGEBUEHR, "Startgeld")
|
||||
val finalKonto = service.buche(konto.kontoId, 500L, BuchungsTyp.STORNIERUNG, "Storno")
|
||||
|
||||
assertEquals(-1500L, finalKonto.saldoCent)
|
||||
|
||||
val historian = service.getBuchungsHistorie(konto.kontoId)
|
||||
assertEquals(2, historian.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Buchung stornieren`() {
|
||||
val veranstaltungId = Uuid.random()
|
||||
val personId = Uuid.random()
|
||||
val konto = service.getOrCreateKonto(veranstaltungId, personId, "Storno Test")
|
||||
|
||||
// 1. Ursprüngliche Buchung
|
||||
val gebuchtKonto = service.buche(
|
||||
kontoId = konto.kontoId,
|
||||
betragCent = 2500L,
|
||||
typ = BuchungsTyp.BOXENGEBUEHR,
|
||||
zweck = "Boxenmiete"
|
||||
)
|
||||
assertEquals(-2500L, gebuchtKonto.saldoCent)
|
||||
|
||||
val buchung = service.getBuchungsHistorie(konto.kontoId).first()
|
||||
|
||||
// 2. Stornieren
|
||||
val storniertKonto = service.storniereBuchung(buchung.buchungId, "Falsche Box")
|
||||
assertEquals(0L, storniertKonto.saldoCent)
|
||||
|
||||
// 3. Historie prüfen
|
||||
val buchungen = service.getBuchungsHistorie(konto.kontoId)
|
||||
assertEquals(2, buchungen.size)
|
||||
assertTrue(buchungen.any { it.typ == BuchungsTyp.STORNIERUNG })
|
||||
val storno = buchungen.find { it.typ == BuchungsTyp.STORNIERUNG }!!
|
||||
assertEquals(2500L, storno.betragCent)
|
||||
assertEquals(buchung.buchungId, storno.storniertBuchungId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
spring:
|
||||
application:
|
||||
name: billing-service-test
|
||||
cloud:
|
||||
consul:
|
||||
enabled: false
|
||||
discovery:
|
||||
enabled: false
|
||||
datasource:
|
||||
url: jdbc:h2:mem:billing_test;DB_CLOSE_DELAY=-1
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password: ""
|
||||
h2:
|
||||
console:
|
||||
enabled: true
|
||||
@@ -1,23 +1,30 @@
|
||||
# ===================================================================
|
||||
# Multi-stage Dockerfile for Meldestelle Entries Service
|
||||
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
|
||||
# Version: 2.6.0 - Reliable Monorepo Build
|
||||
# ===================================================================
|
||||
|
||||
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||
ARG GRADLE_VERSION
|
||||
ARG JAVA_VERSION
|
||||
ARG GRADLE_VERSION=9.4.1
|
||||
ARG JAVA_VERSION=25
|
||||
ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
ARG VERSION=1.0.0-SNAPSHOT
|
||||
|
||||
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION}-alpine AS builder
|
||||
# ===================================================================
|
||||
# Build Stage
|
||||
# ===================================================================
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
|
||||
|
||||
ARG VERSION
|
||||
ARG BUILD_DATE
|
||||
|
||||
LABEL stage=builder \
|
||||
service=entries-service \
|
||||
maintainer="Meldestelle Development Team" \
|
||||
version="${VERSION}" \
|
||||
build.date="${BUILD_DATE}"
|
||||
service="entries-service" \
|
||||
maintainer="Meldestelle Development Team"
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Gradle optimizations
|
||||
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
||||
-Dorg.gradle.daemon=false \
|
||||
-Dorg.gradle.parallel=true \
|
||||
@@ -25,33 +32,26 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
||||
-Dorg.gradle.jvmargs=-Xmx2g \
|
||||
-XX:+UseParallelGC \
|
||||
-XX:MaxMetaspaceSize=512m"
|
||||
ENV GRADLE_USER_HOME=/root/.gradle
|
||||
|
||||
ENV GRADLE_USER_HOME=/home/gradle/.gradle
|
||||
# 1. Copy full project structure for a reliable monorepo build
|
||||
# .dockerignore should be used to exclude unnecessary files (IDE, logs, etc.)
|
||||
COPY . .
|
||||
|
||||
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
|
||||
COPY gradle/ gradle/
|
||||
RUN chmod +x gradlew
|
||||
COPY platform/ platform/
|
||||
COPY frontend/ frontend/
|
||||
COPY core/ core/
|
||||
COPY backend/ backend/
|
||||
COPY docs/ docs/
|
||||
COPY entries-service/build.gradle.kts ./
|
||||
|
||||
# Copy entries modules
|
||||
COPY backend/services/entries/entries-api/ backend/services/entries/entries-api/
|
||||
COPY backend/services/entries/entries-service/ backend/services/entries/entries-service/
|
||||
|
||||
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
|
||||
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
|
||||
./gradlew :backend:services:entries:entries-service:dependencies --no-daemon --info
|
||||
|
||||
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
|
||||
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
|
||||
# 2. Build the service
|
||||
RUN --mount=type=cache,target=/root/.gradle/caches \
|
||||
--mount=type=cache,target=/root/.gradle/wrapper \
|
||||
./gradlew :backend:services:entries:entries-service:bootJar --no-daemon --info
|
||||
|
||||
# 3. Extract layers
|
||||
WORKDIR /builder
|
||||
RUN cp /workspace/backend/services/entries/entries-service/build/libs/*.jar app.jar && \
|
||||
java -Djarmode=layertools -jar app.jar extract
|
||||
|
||||
# ===================================================================
|
||||
# Runtime stage
|
||||
# Runtime Stage
|
||||
# ===================================================================
|
||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
|
||||
|
||||
@@ -59,10 +59,6 @@ ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
ARG JAVA_VERSION
|
||||
|
||||
ENV JAVA_VERSION=${JAVA_VERSION} \
|
||||
VERSION=${VERSION} \
|
||||
BUILD_DATE=${BUILD_DATE}
|
||||
|
||||
LABEL service="entries-service" \
|
||||
version="${VERSION}" \
|
||||
description="Microservice for Entries Management" \
|
||||
@@ -80,15 +76,17 @@ WORKDIR /app
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add --no-cache curl tzdata tini && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||
rm -rf /var/cache/apk/* && addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
||||
mkdir -p /app/logs /app/tmp /app/config && \
|
||||
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
||||
chmod -R 750 /app
|
||||
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} \
|
||||
/workspace/backend/services/entries/entries-service/build/libs/*.jar app.jar
|
||||
# Copy Spring Boot layers
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
|
||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
|
||||
|
||||
USER ${APP_USER}
|
||||
|
||||
@@ -115,17 +113,15 @@ ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||
-Dmanagement.endpoint.health.show-details=always \
|
||||
-Dmanagement.prometheus.metrics.export.enabled=true"
|
||||
|
||||
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS
|
||||
ENV SERVER_PORT=8083
|
||||
ENV LOGGING_LEVEL_ROOT=INFO
|
||||
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
|
||||
SERVER_PORT=8083 \
|
||||
LOGGING_LEVEL_ROOT=INFO
|
||||
|
||||
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
||||
echo 'Starting Entries Service with Java ${JAVA_VERSION}...'; \
|
||||
echo 'Service port: ${SERVER_PORT}'; \
|
||||
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
||||
echo 'DEBUG mode enabled'; \
|
||||
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar; \
|
||||
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
|
||||
else \
|
||||
echo 'Starting Entries Service in production mode'; \
|
||||
exec java ${JAVA_OPTS} -jar app.jar; \
|
||||
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
|
||||
fi"]
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
@file:OptIn(ExperimentalWasmDsl::class)
|
||||
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
@@ -7,38 +11,27 @@ group = "at.mocode"
|
||||
version = "1.0.0"
|
||||
|
||||
kotlin {
|
||||
// Toolchain is now handled centrally in the root build.gradle.kts
|
||||
|
||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
||||
|
||||
// JVM target for backend usage
|
||||
jvm()
|
||||
|
||||
// JS target for frontend usage (Compose/Browser)
|
||||
js {
|
||||
wasmJs {
|
||||
browser()
|
||||
// no need for binaries.executable() in a library
|
||||
}
|
||||
|
||||
// Optional Wasm target for browser clients
|
||||
if (enableWasm) {
|
||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
||||
wasmJs {
|
||||
browser()
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(projects.core.coreDomain)
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
commonTest {
|
||||
dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
|
||||
jvmTest.dependencies {
|
||||
implementation(projects.platform.platformTesting)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+29
-28
@@ -2,7 +2,7 @@
|
||||
|
||||
package at.mocode.entries.api
|
||||
|
||||
import at.mocode.core.domain.model.NennungsStatusE
|
||||
import at.mocode.core.domain.model.NennStatusE
|
||||
import at.mocode.core.domain.model.StartwunschE
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -13,28 +13,28 @@ import kotlin.uuid.Uuid
|
||||
*/
|
||||
@Serializable
|
||||
data class NennungDetailDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val nennungId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val abteilungId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val bewerbId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val turnierId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val reiterId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val pferdId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val zahlerId: Uuid? = null,
|
||||
val status: NennungsStatusE,
|
||||
val startwunsch: StartwunschE,
|
||||
val istNachnennung: Boolean,
|
||||
val nachnenngebuehrErlassen: Boolean,
|
||||
val isNachnenngebuehrFaellig: Boolean,
|
||||
val bemerkungen: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
val status: NennStatusE,
|
||||
val startwunsch: StartwunschE,
|
||||
val istNachnennung: Boolean,
|
||||
val nachnenngebuehrErlassen: Boolean,
|
||||
val isNachnenngebuehrFaellig: Boolean,
|
||||
val bemerkungen: String? = null,
|
||||
val createdAt: String,
|
||||
val updatedAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -42,21 +42,21 @@ data class NennungDetailDto(
|
||||
*/
|
||||
@Serializable
|
||||
data class NennungSummaryDto(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val nennungId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val turnierId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val bewerbId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val abteilungId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val reiterId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val pferdId: Uuid,
|
||||
val status: NennungsStatusE,
|
||||
val istNachnennung: Boolean,
|
||||
val createdAt: String
|
||||
val status: NennStatusE,
|
||||
val istNachnennung: Boolean,
|
||||
val createdAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -78,7 +78,8 @@ data class NennungEinreichenRequest(
|
||||
val zahlerId: Uuid? = null,
|
||||
val startwunsch: StartwunschE = StartwunschE.KEIN_WUNSCH,
|
||||
val istNachnennung: Boolean = false,
|
||||
val bemerkungen: String? = null
|
||||
val bemerkungen: String? = null,
|
||||
val email: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -86,8 +87,8 @@ data class NennungEinreichenRequest(
|
||||
*/
|
||||
@Serializable
|
||||
data class NennungStatusAendernRequest(
|
||||
val neuerStatus: NennungsStatusE,
|
||||
val bemerkungen: String? = null
|
||||
val neuerStatus: NennStatusE,
|
||||
val bemerkungen: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
@file:OptIn(ExperimentalWasmDsl::class)
|
||||
|
||||
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
wasmJs {
|
||||
browser()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain {
|
||||
kotlin.srcDir("src/main/kotlin")
|
||||
dependencies {
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(projects.backend.services.masterdata.masterdataDomain)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
all {
|
||||
languageSettings.optIn("kotlin.uuid.ExperimentalUuidApi")
|
||||
}
|
||||
commonTest {
|
||||
kotlin.srcDir("src/test/kotlin")
|
||||
dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(projects.platform.platformTesting)
|
||||
}
|
||||
|
||||
commonMain.dependencies {
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(projects.backend.services.masterdata.masterdataDomain)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
|
||||
jvmTest.dependencies {
|
||||
implementation(projects.platform.platformTesting)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+23
-10
@@ -3,6 +3,7 @@
|
||||
package at.mocode.entries.domain.model
|
||||
|
||||
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
|
||||
import at.mocode.core.domain.model.BesichtigungsTypE
|
||||
import at.mocode.core.domain.serialization.InstantSerializer
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -13,7 +14,7 @@ import kotlin.uuid.Uuid
|
||||
/**
|
||||
* Domain-Modell für eine Abteilung im registration-context.
|
||||
*
|
||||
* Eine Abteilung ist die kleinste startbare Einheit innerhalb eines [DomBewerb]s.
|
||||
* Eine Abteilung ist die kleinste startbare Einheit innerhalb eines [Bewerb]s.
|
||||
* Ein Bewerb kann in mehrere Abteilungen aufgeteilt sein (z.B. Abt. 1: ohne Lizenz,
|
||||
* Abt. 2: mit Lizenz R1). Die Aufteilung erfolgt gemäß ÖTO § 39 und den
|
||||
* spartenspezifischen Bestimmungen.
|
||||
@@ -32,7 +33,7 @@ import kotlin.uuid.Uuid
|
||||
* @property updatedAt Letzter Änderungszeitpunkt.
|
||||
*/
|
||||
@Serializable
|
||||
data class DomAbteilung(
|
||||
data class Abteilung(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val abteilungId: Uuid = Uuid.random(),
|
||||
|
||||
@@ -55,6 +56,9 @@ data class DomAbteilung(
|
||||
// Zeitplanung
|
||||
var startzeit: String? = null,
|
||||
|
||||
/** Besichtigungstyp für diese Abteilung (optional, wenn abweichend von Standard). */
|
||||
var besichtigungsTyp: BesichtigungsTypE? = null,
|
||||
|
||||
// Verwaltung
|
||||
var bemerkungen: String? = null,
|
||||
|
||||
@@ -81,22 +85,31 @@ data class DomAbteilung(
|
||||
* Validiert die Abteilung auf Überschreitung des maximalen Starter-Limits (§ 39 Abs. 2).
|
||||
* Gibt Warnungen zurück (kein harter Fehler – Override-Event möglich, ADR-0016).
|
||||
*/
|
||||
fun validateStarterLimit(): List<String> {
|
||||
val warnings = mutableListOf<String>()
|
||||
fun validateStarterLimit(): List<AbteilungsWarnung> {
|
||||
val warnings = mutableListOf<AbteilungsWarnung>()
|
||||
|
||||
// Maximale Abteilungsgröße nach Teilung: > 80 Starter → erneute Teilung verpflichtend (§ 39 Abs. 2)
|
||||
if (starterAnzahl > 80) {
|
||||
warnings.add(
|
||||
"WARN_ABTEILUNG_PFLICHT_TEILUNG_UEBERSCHRITTEN: ${getDisplayName()}, " +
|
||||
"Starter: $starterAnzahl > 80. Erneute Teilung verpflichtend (§ 39 Abs. 2). " +
|
||||
"Override möglich (TBA-Entscheidung)."
|
||||
AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_ABTEILUNG_ZU_GROSS,
|
||||
bewerbId = bewerbId,
|
||||
abteilungId = abteilungId,
|
||||
nachricht = "WARN_ABTEILUNG_ZU_GROSS: ${getDisplayName()}, Starter: $starterAnzahl > 80. Erneute Teilung verpflichtend.",
|
||||
oetoParagraph = "§ 39 Abs. 2"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (maxStarter > 0 && starterAnzahl > maxStarter) {
|
||||
warnings.add(
|
||||
"WARN_ABTEILUNG_MAX_STARTER_UEBERSCHRITTEN: ${getDisplayName()}, " +
|
||||
"Starter: $starterAnzahl > Limit $maxStarter. Override möglich (TBA-Entscheidung)."
|
||||
AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_ABTEILUNG_MAX_UEBERSCHRITTEN,
|
||||
bewerbId = bewerbId,
|
||||
abteilungId = abteilungId,
|
||||
nachricht = "WARN_ABTEILUNG_MAX_UEBERSCHRITTEN: ${getDisplayName()}, Starter: $starterAnzahl > Limit $maxStarter.",
|
||||
oetoParagraph = "Hausregel / Ausschreibung"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,5 +119,5 @@ data class DomAbteilung(
|
||||
/**
|
||||
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
|
||||
*/
|
||||
fun withUpdatedTimestamp(): DomAbteilung = this.copy(updatedAt = Clock.System.now())
|
||||
fun withUpdatedTimestamp(): Abteilung = this.copy(updatedAt = Clock.System.now())
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.entries.domain.model
|
||||
|
||||
import at.mocode.core.domain.serialization.InstantSerializer
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Value Object für eine Abteilungs-Warnung (ÖTO § 39).
|
||||
*
|
||||
* Eine Warnung wird ausgegeben, wenn Schwellenwerte überschritten werden oder
|
||||
* strukturelle Teilungen fehlen. Alle Warnungen sind overridebar (ADR-0007).
|
||||
*/
|
||||
@Serializable
|
||||
data class AbteilungsWarnung(
|
||||
val code: AbteilungsWarnungCodeE,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val bewerbId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val abteilungId: Uuid? = null,
|
||||
val nachricht: String,
|
||||
val oetoParagraph: String,
|
||||
val istOverridebar: Boolean = true,
|
||||
@Serializable(with = InstantSerializer::class)
|
||||
val timestamp: Instant = Clock.System.now()
|
||||
)
|
||||
|
||||
/**
|
||||
* Maschinenlesbare Codes für Abteilungs-Warnungen.
|
||||
*/
|
||||
enum class AbteilungsWarnungCodeE {
|
||||
/** Starterzahl > Pflicht-Schwellenwert (§ 39 Abs. 2) */
|
||||
WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN,
|
||||
/** Starterzahl > Kann-Schwellenwert, keine Teilung konfiguriert (§ 39 Abs. 2) */
|
||||
WARN_KANN_TEILUNG_EMPFOHLEN,
|
||||
/** Abteilung nach Teilung > 80 Starter (§ 39 Abs. 2) */
|
||||
WARN_ABTEILUNG_ZU_GROSS,
|
||||
/** Starter > konfiguriertes maxStarter-Limit */
|
||||
WARN_ABTEILUNG_MAX_UEBERSCHRITTEN,
|
||||
/** Vorgeschriebene Abteilungs-Struktur nicht vorhanden */
|
||||
WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||
/** Abteilungs-Struktur vorhanden, aber Teilnehmerkreis falsch/unvollständig */
|
||||
WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG,
|
||||
/** Mehrere Bewerbe zur gleichen Zeit am gleichen Platz */
|
||||
WARN_ZEITPLAN_PLATZ_KONFLIKT,
|
||||
/** Richter hat zeitgleiche Einsätze in verschiedenen Bewerben */
|
||||
WARN_ZEITPLAN_RICHTER_KONFLIKT
|
||||
}
|
||||
|
||||
/**
|
||||
* Event, das gespeichert wird, wenn ein TBA eine Warnung überschreibt.
|
||||
*/
|
||||
@Serializable
|
||||
data class AbteilungsWarnungOverrideEvent(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val overrideId: Uuid = Uuid.random(),
|
||||
val warnungCode: AbteilungsWarnungCodeE,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val bewerbId: Uuid,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val abteilungId: Uuid? = null,
|
||||
val begruendung: String,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val tbaUserId: Uuid,
|
||||
@Serializable(with = InstantSerializer::class)
|
||||
val timestamp: Instant = Clock.System.now()
|
||||
)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.entries.domain.model
|
||||
|
||||
import at.mocode.core.domain.model.BesichtigungsTypE
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Repräsentiert einen Zeitblock für die Parcoursbesichtigung.
|
||||
* Kann mit mehreren Abteilungen oder Bewerben verknüpft sein.
|
||||
*/
|
||||
@Serializable
|
||||
data class BesichtigungsBlock(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val besichtigungsBlockId: Uuid = Uuid.random(),
|
||||
|
||||
/** Typ der Besichtigung (zu Fuß / zu Pferd). */
|
||||
val typ: BesichtigungsTypE = BesichtigungsTypE.ZU_FUSS,
|
||||
|
||||
/** Geplante Dauer in Minuten. */
|
||||
val dauerMinuten: Int = 15,
|
||||
|
||||
/**
|
||||
* Liste der verknüpften Abteilungs-IDs.
|
||||
* Eine Besichtigung kann für mehrere Abteilungen gleichzeitig stattfinden.
|
||||
*/
|
||||
val abteilungIds: List<@Serializable(with = UuidSerializer::class) Uuid> = emptyList(),
|
||||
|
||||
/** Optionaler Puffer nach der Besichtigung bis zum ersten Start (Standard: 5 Min gemäß ÖTO). */
|
||||
val pufferMinuten: Int = 5
|
||||
)
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.entries.domain.model
|
||||
|
||||
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
|
||||
import at.mocode.core.domain.model.BeginnZeitTypE
|
||||
import at.mocode.core.domain.model.PruefungsTypE
|
||||
import at.mocode.core.domain.model.SparteE
|
||||
import at.mocode.core.domain.model.TurnierkategorieE
|
||||
import at.mocode.core.domain.serialization.InstantSerializer
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Domain-Modell für einen Bewerb im registration-context.
|
||||
*
|
||||
* Ein Bewerb ist eine einzelne Prüfung innerhalb eines Turniers (z.B. „Stilspringen 90 cm").
|
||||
* Er kann in mehrere [Abteilung]en aufgeteilt sein. Die Abteilungs-Warn-Logik basiert
|
||||
* auf den ÖTO-Schwellenwerten (§ 39 A-Teil + spartenspezifische Bestimmungen).
|
||||
*
|
||||
* Aggregate Root des `registration-context` für den Bewerbs-Workflow.
|
||||
*
|
||||
* @property bewerbId Eindeutige interne ID (UUID).
|
||||
* @property turnierId Referenz auf das übergeordnete Turnier (UUID).
|
||||
* @property bewerbNummer Laufende Nummer des Bewerbs innerhalb des Turniers (z.B. 1, 2, 3).
|
||||
* @property bezeichnung Offizielle Bezeichnung des Bewerbs (z.B. „Stilspringen 90 cm").
|
||||
* @property sparte Sportliche Sparte (Springen, Dressur, Vielseitigkeit, ...).
|
||||
* @property turnierkategorie Turnierkategorie (A*, A, B*, B, C, ...).
|
||||
* @property pruefungsTyp Typ der Prüfung – bestimmt den Abteilungs-Schwellenwert (§ 39).
|
||||
* @property hoeheCm Höhe in cm (relevant für Springen und Vielseitigkeit).
|
||||
* @property teilungsTyp Kriterium, nach dem der Bewerb in Abteilungen aufgeteilt wird.
|
||||
* @property maxStarterProAbteilung Maximale Starter pro Abteilung (0 = kein Limit gesetzt).
|
||||
* @property istMeisterschaft Ob es sich um einen Meisterschaftsbewerb handelt (Ausnahme von § 39 Abs. 4).
|
||||
* @property istNachnennungErlaubt Ob Nachnennungen für diesen Bewerb zugelassen sind.
|
||||
* @property beschreibung Optionale Beschreibung (z.B. "Pony Einsteiger Cup").
|
||||
* @property aufgabe Aufgaben-Bezeichnung gemäß ÖTO (z.B. "R1", "L1").
|
||||
* @property aufgabenNummer Aufgaben-Nummer (z.B. "R1/2024").
|
||||
* @property paraGrade Para-Equestrian Grade (z.B. "Grade I"), falls zutreffend.
|
||||
* @property austragungsplatzId Referenz auf den Austragungsplatz (UUID aus events-context).
|
||||
* @property richterEinsaetze Liste der Richter-Einsätze für diesen Bewerb.
|
||||
* @property geplantesDatum Geplantes Datum des Bewerbs.
|
||||
* @property beginnZeitTyp Typ des Beginnzeit-Eintrags (FIX oder ANSCHLIESSEND).
|
||||
* @property beginnZeit Geplante Beginnzeit (nur bei FIX).
|
||||
* @property reitdauerMinuten Geplante Reitdauer in Minuten.
|
||||
* @property umbauMinuten Geplante Umbauzeit in Minuten.
|
||||
* @property besichtigungMinuten Geplante Besichtigungszeit in Minuten.
|
||||
* @property stechenGeplant Ob ein Stechen geplant ist.
|
||||
* @property startgeldCent Startgeld in Cent (z.B. 1500 = 15,00 €).
|
||||
* @property geldpreisAusbezahlt Ob der Geldpreis bereits ausbezahlt wurde.
|
||||
* @property bemerkungen Interne Notizen.
|
||||
* @property createdAt Erstellungszeitpunkt.
|
||||
* @property updatedAt Letzter Änderungszeitpunkt.
|
||||
*/
|
||||
@Serializable
|
||||
data class Bewerb(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val bewerbId: Uuid = Uuid.random(),
|
||||
|
||||
// Zuordnung
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val turnierId: Uuid,
|
||||
|
||||
// Identifikation
|
||||
var bewerbNummer: Int,
|
||||
var bezeichnung: String,
|
||||
|
||||
// Fachliche Klassifikation
|
||||
var sparte: SparteE,
|
||||
var turnierkategorie: TurnierkategorieE,
|
||||
var pruefungsTyp: PruefungsTypE,
|
||||
var hoeheCm: Int? = null,
|
||||
|
||||
// Abteilungs-Konfiguration
|
||||
var teilungsTyp: AbteilungsTeilungsTypE = AbteilungsTeilungsTypE.KEINE,
|
||||
var maxStarterProAbteilung: Int = 0,
|
||||
|
||||
// Text & Details
|
||||
var beschreibung: String? = null,
|
||||
var aufgabe: String? = null,
|
||||
var aufgabenNummer: String? = null,
|
||||
var paraGrade: String? = null,
|
||||
|
||||
// Ort & Funktionäre
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
var austragungsplatzId: Uuid? = null,
|
||||
var richterEinsaetze: List<RichterEinsatz> = emptyList(),
|
||||
|
||||
// Zeitplan
|
||||
var geplantesDatum: LocalDate? = null,
|
||||
var beginnZeitTyp: BeginnZeitTypE? = null,
|
||||
var beginnZeit: LocalTime? = null,
|
||||
var reitdauerMinuten: Int? = null,
|
||||
var umbauMinuten: Int? = null,
|
||||
var besichtigungMinuten: Int? = null,
|
||||
|
||||
/** Konfiguration für Pausen während der Prüfung. */
|
||||
var pausenKonfiguration: PausenKonfiguration? = null,
|
||||
|
||||
var stechenGeplant: Boolean = false,
|
||||
|
||||
// Finanzen
|
||||
var startgeldCent: Long? = null,
|
||||
var geldpreisAusbezahlt: Boolean = false,
|
||||
|
||||
// Flags
|
||||
var istMeisterschaft: Boolean = false,
|
||||
var istNachnennungErlaubt: Boolean = true,
|
||||
|
||||
// Verwaltung
|
||||
var bemerkungen: String? = null,
|
||||
|
||||
// Audit
|
||||
@Serializable(with = InstantSerializer::class)
|
||||
val createdAt: Instant = Clock.System.now(),
|
||||
@Serializable(with = InstantSerializer::class)
|
||||
var updatedAt: Instant = Clock.System.now()
|
||||
) {
|
||||
/**
|
||||
* Gibt den Anzeigenamen mit Nummer zurück (z.B. „1 – Stilspringen 90 cm").
|
||||
*/
|
||||
fun getDisplayName(): String = "$bewerbNummer – $bezeichnung"
|
||||
|
||||
/**
|
||||
* Liefert den Pflicht-Teilungs-Schwellenwert gemäß ÖTO § 39 für diesen Prüfungstyp.
|
||||
* Gibt null zurück, wenn keine Pflicht-Teilung aufgrund der Starterzahl gilt
|
||||
* (strukturelle Teilungen werden separat über [pruefungsTyp] und [teilungsTyp] abgebildet).
|
||||
*
|
||||
* Meisterschaftsbewerbe sind von der Pflicht-Teilung ausgenommen (§ 39 Abs. 4).
|
||||
*/
|
||||
fun getPflichtTeilungsSchwellenwert(): Int? {
|
||||
if (istMeisterschaft) return null
|
||||
return when (pruefungsTyp) {
|
||||
PruefungsTypE.STIL_SPRINGEN,
|
||||
PruefungsTypE.SPRINGPFERDE,
|
||||
PruefungsTypE.DRESSURPFERDE -> 30
|
||||
|
||||
PruefungsTypE.VIELSEITIGKEIT -> 40
|
||||
PruefungsTypE.SPRINGEN_UEBRIG -> 80
|
||||
else -> null // Kann-Teilung oder strukturell – kein Starter-Schwellenwert
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Kann-Teilungs-Schwellenwert zurück (nur für Dressur: > 30 Starter, § 39 Abs. 2).
|
||||
* Gibt null zurück, wenn keine Kann-Teilung gilt.
|
||||
*/
|
||||
fun getKannTeilungsSchwellenwert(): Int? {
|
||||
if (istMeisterschaft) return null
|
||||
return when (pruefungsTyp) {
|
||||
PruefungsTypE.DRESSUR -> 30
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert den Bewerb auf Abteilungs-Schwellenwerte anhand der aktuellen Starterzahl.
|
||||
* Gibt Warnungen zurück (kein harter Fehler – Override-Event möglich, ADR-0016).
|
||||
*
|
||||
* @param aktuelleStarterAnzahl Aktuelle Anzahl der Nennungen für diesen Bewerb.
|
||||
*/
|
||||
fun validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl: Int): List<AbteilungsWarnung> {
|
||||
val warnings = mutableListOf<AbteilungsWarnung>()
|
||||
|
||||
val pflichtSchwellenwert = getPflichtTeilungsSchwellenwert()
|
||||
if (pflichtSchwellenwert != null && aktuelleStarterAnzahl > pflichtSchwellenwert) {
|
||||
warnings.add(
|
||||
AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN,
|
||||
bewerbId = bewerbId,
|
||||
nachricht = "WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN: Bewerb ${getDisplayName()}, Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > Schwellenwert $pflichtSchwellenwert. Empfehlung: Teilung nach ${teilungsTyp.name}.",
|
||||
oetoParagraph = "§ 39 Abs. 2"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val kannSchwellenwert = getKannTeilungsSchwellenwert()
|
||||
if (kannSchwellenwert != null && aktuelleStarterAnzahl > kannSchwellenwert &&
|
||||
teilungsTyp == AbteilungsTeilungsTypE.KEINE
|
||||
) {
|
||||
warnings.add(
|
||||
AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_KANN_TEILUNG_EMPFOHLEN,
|
||||
bewerbId = bewerbId,
|
||||
nachricht = "WARN_KANN_TEILUNG_EMPFOHLEN: Bewerb ${getDisplayName()}, Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > $kannSchwellenwert. Kann-Teilung empfohlen.",
|
||||
oetoParagraph = "§ 39 Abs. 2"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert die strukturelle Teilung des Bewerbs gemäß ÖTO.
|
||||
* Prüft, ob die vorgeschriebenen Abteilungen (z.B. nach Lizenz oder Pferdealter) vorhanden sind.
|
||||
*/
|
||||
fun validateStrukturellesTeilung(abteilungen: List<Abteilung>): List<AbteilungsWarnung> {
|
||||
val warnings = mutableListOf<AbteilungsWarnung>()
|
||||
|
||||
// 1. CSN Stilspringen bis 95 cm (§ 200 Abs. 5.3)
|
||||
if (sparte == SparteE.SPRINGEN && pruefungsTyp == PruefungsTypE.STIL_SPRINGEN && (hoeheCm ?: 0) <= 95) {
|
||||
val hatOhneLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("ohne Lizenz", ignoreCase = true) == true }
|
||||
val hatR1 = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
|
||||
|
||||
if (!hatOhneLizenz || !hatR1) {
|
||||
val fehlend = mutableListOf<String>()
|
||||
if (!hatOhneLizenz) fehlend.add("ohne Lizenz")
|
||||
if (!hatR1) fehlend.add("R1")
|
||||
|
||||
warnings.add(
|
||||
AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||
bewerbId = bewerbId,
|
||||
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Stilspringen bis 95 cm erfordert getrennte Abteilungen für: ${fehlend.joinToString(", ")}.",
|
||||
oetoParagraph = "ÖTO B-Teil § 200 Abs. 5.3"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Springpferdeprüfung 95-110 cm / Dressurpferdeprüfung Kl. A (§ 200 Abs. 6 / § 100 Abs. 5)
|
||||
val isSpringpferdeA = sparte == SparteE.SPRINGEN && pruefungsTyp == PruefungsTypE.SPRINGPFERDE && (hoeheCm ?: 0) in 95..110
|
||||
val isDressurpferdeA = sparte == SparteE.DRESSUR && pruefungsTyp == PruefungsTypE.DRESSURPFERDE && aufgabe?.contains("A", ignoreCase = true) == true
|
||||
|
||||
if (isSpringpferdeA || isDressurpferdeA) {
|
||||
val hat4Jaehrig = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("4-jährig", ignoreCase = true) == true }
|
||||
val hat56Jaehrig = abteilungen.any {
|
||||
it.teilnehmerkreisBeschreibung?.contains("5-jährig", ignoreCase = true) == true ||
|
||||
it.teilnehmerkreisBeschreibung?.contains("6-jährig", ignoreCase = true) == true ||
|
||||
it.teilnehmerkreisBeschreibung?.contains("5-6-jährig", ignoreCase = true) == true
|
||||
}
|
||||
|
||||
if (!hat4Jaehrig || !hat56Jaehrig) {
|
||||
val fehlend = mutableListOf<String>()
|
||||
if (!hat4Jaehrig) fehlend.add("4-jährige")
|
||||
if (!hat56Jaehrig) fehlend.add("5-6-jährige")
|
||||
|
||||
warnings.add(
|
||||
AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||
bewerbId = bewerbId,
|
||||
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Pferdeprüfung Kl. A erfordert Trennung nach Alter: ${fehlend.joinToString(", ")}.",
|
||||
oetoParagraph = if (isSpringpferdeA) "ÖTO B-Teil § 200 Abs. 6" else "ÖTO B-Teil § 100 Abs. 5"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. CSN-C-NEU (§ 231)
|
||||
if (turnierkategorie == TurnierkategorieE.C_NEU && sparte == SparteE.SPRINGEN) {
|
||||
if ((hoeheCm ?: 0) <= 95) {
|
||||
val hatOhneLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("ohne Lizenz", ignoreCase = true) == true }
|
||||
val hatMitLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("mit Lizenz", ignoreCase = true) == true }
|
||||
if (!hatOhneLizenz || !hatMitLizenz) {
|
||||
warnings.add(AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||
bewerbId = bewerbId,
|
||||
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: CSN-C-NEU bis 95 cm erfordert Abt. ohne Lizenz und Abt. mit Lizenz.",
|
||||
oetoParagraph = "ÖTO B-Teil § 231"
|
||||
))
|
||||
}
|
||||
} else if ((hoeheCm ?: 0) >= 100) {
|
||||
val hatR1 = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
|
||||
val hatR2Plus = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R2", ignoreCase = true) == true }
|
||||
if (!hatR1 || !hatR2Plus) {
|
||||
warnings.add(AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||
bewerbId = bewerbId,
|
||||
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: CSN-C-NEU ab 100 cm erfordert Abt. R1 und Abt. R2+.",
|
||||
oetoParagraph = "ÖTO B-Teil § 231"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Caprilli (§ 803 Abs. 2)
|
||||
if (pruefungsTyp == PruefungsTypE.CAPRILLI) {
|
||||
val hatLizenzfrei = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("lizenzfrei", ignoreCase = true) == true }
|
||||
val hatRD1Plus = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("RD1", ignoreCase = true) == true || it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
|
||||
|
||||
if (!hatLizenzfrei || !hatRD1Plus) {
|
||||
warnings.add(AbteilungsWarnung(
|
||||
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||
bewerbId = bewerbId,
|
||||
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Caprilli-Prüfung erfordert Abt. lizenzfrei und Abt. RD1+.",
|
||||
oetoParagraph = "ÖTO B-Teil § 803 Abs. 2"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
|
||||
*/
|
||||
fun withUpdatedTimestamp(): Bewerb = this.copy(updatedAt = Clock.System.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguration für automatische Pausen nach einer bestimmten Anzahl von Startern.
|
||||
*/
|
||||
@Serializable
|
||||
data class PausenKonfiguration(
|
||||
/** Pause alle X Starter (0 = keine automatischen Pausen). */
|
||||
val starterIntervall: Int = 0,
|
||||
|
||||
/** Dauer der Pause in Minuten. */
|
||||
val dauerMinuten: Int = 10,
|
||||
|
||||
/** Optionale Bezeichnung (z.B. "Platzpflege"). */
|
||||
val bezeichnung: String? = null
|
||||
)
|
||||
+2
-2
@@ -13,7 +13,7 @@ import kotlin.uuid.Uuid
|
||||
/**
|
||||
* Domain-Modell für eine Startliste im registration-context.
|
||||
*
|
||||
* Eine Startliste gehört zu einer [DomAbteilung] und enthält die geordnete Liste
|
||||
* Eine Startliste gehört zu einer [Abteilung] und enthält die geordnete Liste
|
||||
* der Starter (Nennungen) mit ihren Startnummern. Sie durchläuft einen definierten
|
||||
* Workflow: NICHT_ERSTELLT → ENTWURF → VEROEFFENTLICHT → GESPERRT → ARCHIVIERT.
|
||||
*
|
||||
@@ -128,7 +128,7 @@ data class DomStartliste(
|
||||
/**
|
||||
* Ein einzelner Eintrag in einer Startliste.
|
||||
*
|
||||
* Verbindet eine Startnummer mit einer Nennung ([DomNennung]).
|
||||
* Verbindet eine Startnummer mit einer Nennung ([Nennung]).
|
||||
*
|
||||
* @property startnummer Die zugewiesene Startnummer (Kopfnummer gemäß Ubiquitous Language).
|
||||
* @property nennungId Referenz auf die zugehörige Nennung (UUID).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user