Compare commits

...

3 Commits

Author SHA1 Message Date
beb20e0cf7 ### feat: erweitere ZNS und SQLDelight-Integration
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
- **SQLDelight:** Füge neue Queries (`countVereine`, `maxUpdated...`) zur SQLite-Datenbank hinzu und aktualisiere `DesktopMasterdataRepository`.
- **ZNS-Sync:** Passe `ZnsImportState` an, um Pferde- und Funktionärsdaten zu unterstützen.
- **Cloud-Sync:** Entferne redundante Auth-Header und setze Limits für Massensynchronisation auf 50.000 Datensätze.
- **Masterdata-Service:** Stabilisiere Consul Health-Checks und implementiere Limit-Beschränkungen auf Controller-Ebene.
2026-04-22 14:14:39 +02:00
98c241fc64 ### feat: erweitere Stammdaten-Integration
- **Repositories:** Implementiere und integriere `KtorPferdRepository` und `KtorFunktionaerRepository`.
- **SQLite:** Erweitere Schema um `LocalPferd` und `LocalFunktionaer` mit passenden Queries.
- **ViewModels:** Passe `PferdeViewModel` und `FunktionaerViewModel` an, um Flows und Repository-Injektion zu nutzen.
- **DI-Module:** Aktualisiere `PferdeModule` und `FunktionaerModule` für Backend-Anbindung.
2026-04-22 12:25:43 +02:00
d4cc0eb77d ### feat: verbessere DI, Healthcheck-Logik und Reiter-API
- **Healthcheck:** Aktualisiere Dockerfile und konsolidiere Ports für konsistente Service-Gesundheitsprüfungen (8086 für Actuator, 8091 für API-Traffic).
- **ReiterRepository:** Implementiere `KtorReiterRepository` zur Nutzung der Backend-Stammdaten über API.
- **DI-Module:** Passe `ReiterModule` und `VereinFeatureModule` an, um den authentifizierten `apiClient` zu verwenden.
- **Masterdata-Service:** Synchronisiere Environment-Variablen und Konsul-Konfiguration mit aktualisierten Ports.
2026-04-22 12:11:38 +02:00
31 changed files with 792 additions and 274 deletions

2
.env
View File

@ -174,10 +174,12 @@ MAIL_SMTP_STARTTLS=true
MASTERDATA_PORT=8086:8086 MASTERDATA_PORT=8086:8086
MASTERDATA_DEBUG_PORT=5007:5007 MASTERDATA_DEBUG_PORT=5007:5007
MASTERDATA_SERVER_PORT=8086 MASTERDATA_SERVER_PORT=8086
MASTERDATA_KTOR_PORT=8091
MASTERDATA_SPRING_PROFILES_ACTIVE=docker MASTERDATA_SPRING_PROFILES_ACTIVE=docker
MASTERDATA_DEBUG=true MASTERDATA_DEBUG=true
MASTERDATA_SERVICE_NAME=masterdata-service MASTERDATA_SERVICE_NAME=masterdata-service
MASTERDATA_CONSUL_PREFER_IP=true MASTERDATA_CONSUL_PREFER_IP=true
MASTERDATA_SERVICE_HOSTNAME=masterdata-service
# --- EVENTS-SERVICE --- # --- EVENTS-SERVICE ---
EVENTS_PORT=8085:8085 EVENTS_PORT=8085:8085

View File

@ -1,10 +1,13 @@
spring: spring:
application: application:
name: billing-service name: billing-service
datasource: datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db} url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db}
username: ${SPRING_DATASOURCE_USERNAME:pg-user} username: ${SPRING_DATASOURCE_USERNAME:pg-user}
password: ${SPRING_DATASOURCE_PASSWORD:pg-password} password: ${SPRING_DATASOURCE_PASSWORD:pg-password}
driver-class-name: org.postgresql.Driver
cloud: cloud:
consul: consul:
host: ${SPRING_CLOUD_CONSUL_HOST:localhost} host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
@ -15,13 +18,19 @@ spring:
prefer-ip-address: true prefer-ip-address: true
health-check-path: /actuator/health health-check-path: /actuator/health
health-check-interval: 10s health-check-interval: 10s
health-check-port: 8089 # health-check-port: 8089
instance-id: ${spring.application.name}:${server.port}:${random.uuid} instance-id: ${spring.application.name}:${server.port}:${random.uuid}
service-name: ${spring.application.name} service-name: ${spring.application.name}
port: ${billing.http.port:8089}
server: server:
port: 8089 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: management:
endpoints: endpoints:
web: web:
@ -30,3 +39,12 @@ management:
endpoint: endpoint:
health: health:
show-details: always 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"

View File

@ -92,8 +92,8 @@ USER ${APP_USER}
EXPOSE 8086 5005 EXPOSE 8086 5005
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \ HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fsS --max-time 2 http://localhost:8086/actuator/health/readiness || exit 1 CMD curl -fsS --max-time 5 http://localhost:${SERVER_PORT:-8086}/actuator/health/readiness || exit 1
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \ ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
-XX:+UseG1GC \ -XX:+UseG1GC \

View File

@ -62,7 +62,7 @@ class FunktionaerController(private val funktionaerRepository: FunktionaerReposi
* GET /funktionaer Alle Funktionäre (paginiert). * GET /funktionaer Alle Funktionäre (paginiert).
*/ */
get { get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100 val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 100).coerceAtMost(5000)
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0 val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val results = funktionaerRepository.findAll(limit, offset) val results = funktionaerRepository.findAll(limit, offset)

View File

@ -62,11 +62,11 @@ class HorseController(private val horseRepository: HorseRepository) {
route("/horse") { route("/horse") {
/** /**
* GET /horse Alle Pferde (paginiert), optional gefiltert nach jahrgang. * GET /horse alle Pferde (paginiert), optional gefiltert nach Jahrgang.
*/ */
get { get {
val jahrgang = call.request.queryParameters["jahrgang"]?.toIntOrNull() val jahrgang = call.request.queryParameters["jahrgang"]?.toIntOrNull()
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100 val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 100).coerceAtMost(50000)
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0 val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val results = when { val results = when {
@ -77,7 +77,7 @@ class HorseController(private val horseRepository: HorseRepository) {
} }
/** /**
* GET /horse/search?q=... Sucht Pferde nach Lebensnummer. * GET /horse/search?q=  Sucht Pferde nach Lebensnummer.
*/ */
get("/search") { get("/search") {
val query = call.request.queryParameters["q"] ?: "" val query = call.request.queryParameters["q"] ?: ""
@ -86,7 +86,7 @@ class HorseController(private val horseRepository: HorseRepository) {
} }
/** /**
* GET /horse/{id} Ruft ein spezifisches Pferd ab. * GET /horse/{id} ruft ein spezifisches Pferd ab.
*/ */
get("/{id}") { get("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@get call.respond(HttpStatusCode.BadRequest) val id = parseUuid(call.parameters["id"]) ?: return@get call.respond(HttpStatusCode.BadRequest)
@ -104,7 +104,7 @@ class HorseController(private val horseRepository: HorseRepository) {
} }
/** /**
* POST /horse Erstellt ein neues Pferd. * POST /horse erstellt ein neues Pferd.
*/ */
post { post {
val req = call.receive<HorseCreateRequest>() val req = call.receive<HorseCreateRequest>()

View File

@ -93,7 +93,7 @@ class ReiterController(private val reiterRepository: ReiterRepository) {
* GET /reiter Alle Reiter (paginiert). * GET /reiter Alle Reiter (paginiert).
*/ */
get { get {
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100 val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 100).coerceAtMost(50000)
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0 val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val results = reiterRepository.findAll(limit, offset) val results = reiterRepository.findAll(limit, offset)

View File

@ -76,11 +76,11 @@ class VereinController(private val vereinRepository: VereinRepository) {
route("/verein") { route("/verein") {
/** /**
* GET /verein Alle Vereine (paginiert), optional gefiltert nach verband/bundesland. * GET /verein alle Vereine (paginiert), optional gefiltert nach Verband/Bundesland.
*/ */
get { get {
val verband = call.request.queryParameters["verband"] val verband = call.request.queryParameters["verband"]
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100 val limit = (call.request.queryParameters["limit"]?.toIntOrNull() ?: 100).coerceAtMost(5000)
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0 val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val results = if (verband != null) { val results = if (verband != null) {
@ -92,7 +92,7 @@ class VereinController(private val vereinRepository: VereinRepository) {
} }
/** /**
* GET /verein/search?q=... Sucht Vereine nach Name. * GET /verein/search?q=  Sucht Vereine nach Namen.
*/ */
get("/search") { get("/search") {
val query = call.request.queryParameters["q"] ?: "" val query = call.request.queryParameters["q"] ?: ""
@ -101,7 +101,7 @@ class VereinController(private val vereinRepository: VereinRepository) {
} }
/** /**
* GET /verein/{id} Ruft einen spezifischen Verein ab. * GET /verein/{id} ruft einen spezifischen Verein ab.
*/ */
get("/{id}") { get("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@get call.respond(HttpStatusCode.BadRequest) val id = parseUuid(call.parameters["id"]) ?: return@get call.respond(HttpStatusCode.BadRequest)
@ -119,7 +119,7 @@ class VereinController(private val vereinRepository: VereinRepository) {
} }
/** /**
* POST /verein Erstellt einen neuen Verein. * POST /verein erstellt einen neuen Verein.
*/ */
post { post {
val req = call.receive<VereinCreateRequest>() val req = call.receive<VereinCreateRequest>()

View File

@ -1,51 +1,61 @@
server:
port: ${MASTERDATA_SERVER_PORT:8086}
ktor:
port: ${MASTERDATA_KTOR_PORT:8091}
spring: spring:
application: application:
name: masterdata-service name: masterdata-service
main: main:
banner-mode: "off" banner-mode: "off"
datasource: datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db} url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db}
username: ${SPRING_DATASOURCE_USERNAME:pg-user} username: ${SPRING_DATASOURCE_USERNAME:pg-user}
password: ${SPRING_DATASOURCE_PASSWORD:pg-password} password: ${SPRING_DATASOURCE_PASSWORD:pg-password}
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
flyway: flyway:
enabled: true enabled: true
baseline-on-migrate: true baseline-on-migrate: true
cloud: cloud:
consul: consul:
host: ${SPRING_CLOUD_CONSUL_HOST:localhost} host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
port: ${SPRING_CLOUD_CONSUL_PORT:8500} port: ${SPRING_CLOUD_CONSUL_PORT:8500}
enabled: ${CONSUL_ENABLED:true}
discovery: discovery:
enabled: ${CONSUL_ENABLED:true} enabled: true
register: ${CONSUL_ENABLED:true} register: true
prefer-ip-address: true prefer-ip-address: true
health-check-path: /actuator/health health-check-path: /actuator/health/readiness
health-check-interval: 20s health-check-interval: 10s
health-check-timeout: 10s health-check-timeout: 5s
# deregister-critical-service-after: 5m health-check-port: 8086
# health-check-port: 8086 # Spring Boot Management Port (Actuator) health-check-critical-timeout: 2m
instance-id: ${spring.application.name}:${server.port}:${random.uuid} deregister-critical-service-after: 5m
instance-id: ${spring.application.name}:${random.uuid}
service-name: ${spring.application.name} service-name: ${spring.application.name}
port: ${masterdata.http.port:8091} # Ktor API Port registrieren (Gateway Ziel) port: 8091
server: #server:
port: 8086 # Spring Boot Management Port (Actuator & Tomcat) # port: 8086 # Spring Boot Management Port (Actuator & Tomcat)
address: 0.0.0.0 # Erreichbar für Consul Health Checks # address: 0.0.0.0 # Erreichbar für Consul Health Checks
masterdata: #masterdata:
http: # http:
port: 8091 # Ktor API Port (Haupt-Einstiegspunkt für REST-Anfragen) # port: 8091 # Ktor API Port (Haupt-Einstiegspunkt für REST-Anfragen)
address: 0.0.0.0 # Öffentlich erreichbar innerhalb des Netzwerks / Containers # address: 0.0.0.0 # Öffentlich erreichbar innerhalb des Netzwerks / Containers
management: management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: "health,info,metrics,prometheus" include: health,info,metrics,prometheus
endpoint: endpoint:
health: health:
show-details: always show-details: always
show-components: always
probes: probes:
enabled: true enabled: true
prometheus: prometheus:

View File

@ -59,7 +59,7 @@ services:
# --- SERVICE URLs --- # --- SERVICE URLs ---
PING_SERVICE_URL: "http://ping-service:8082" PING_SERVICE_URL: "http://ping-service:8082"
MASTERDATA_SERVICE_URL: "http://masterdata-service:8086" MASTERDATA_SERVICE_URL: "http://masterdata-service:8091"
EVENTS_SERVICE_URL: "http://events-service:8085" EVENTS_SERVICE_URL: "http://events-service:8085"
MAIL_SERVICE_URL: "http://mail-service:8083" MAIL_SERVICE_URL: "http://mail-service:8083"
ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095" ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095"
@ -204,6 +204,8 @@ services:
SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}" SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${MASTERDATA_SERVICE_NAME:-masterdata-service}" SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${MASTERDATA_SERVICE_NAME:-masterdata-service}"
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${MASTERDATA_CONSUL_PREFER_IP:-true}" SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${MASTERDATA_CONSUL_PREFER_IP:-true}"
SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: "${MASTERDATA_SERVICE_HOSTNAME:-masterdata-service}"
SPRING_CLOUD_CONSUL_DISCOVERY_HEALTH_CHECK_PATH: "/actuator/health"
# - DATENBANK VERBINDUNG - # - DATENBANK VERBINDUNG -
SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}" SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"

View File

@ -0,0 +1,36 @@
# Session-Journal: 22. April 2026 - Finale ZNS-Sync & Auth Resolution
## 🎯 Status & Highlights
- **Auth-Fix (Cloud-Sync):** Vollständige Behebung des `401 Unauthorized` beim Cloud-Sync. Redundante Header-Setzungen im `ZnsImportViewModel` wurden entfernt, da der zentrale `apiClient` Interceptor die Token-Injektion zuverlässig übernimmt.
- **Route-Standardisierung:** Alle Masterdata-API-Routen wurden auf die singularisierten Pfade (`/horse`, `/funktionaer`, `/verein`, `/reiter`) umgestellt, um 1:1 mit den Backend-Controllern zu korrespondieren.
- **Infrastruktur-Resilience:** Consul Health-Checks für den `masterdata-service` final stabilisiert (Nutzung von Port 8086 für Spring Actuator und Port 8091 für die Ktor-API). Intervalle und Timeouts wurden für Massenoperationen optimiert.
- **SQLite-Bereitschaft:** Die lokale Datenbank ist nach einem Reset bereit für den initialen Massen-Sync von über 70.000 Datensätzen.
## 🛠️ Durchgeführte Änderungen
### Frontend (Common/Desktop)
- **ZnsImportViewModel.kt:**
- Manuelle Token-Header und hartcodierte Basis-URLs entfernt.
- Vollständige Umstellung auf `ApiRoutes` Konstanten.
- Fehlerbehandlung bei API-Aufrufen (Pferde, Funktionäre) konsolidiert.
- **Netzwerk-Abstraktion:**
- Verifizierung, dass der `apiClient` in allen Repositories (`KtorVereinRepository`, `KtorReiterRepository` etc.) genutzt wird.
- **UI-Stabilität:**
- Behebung von Kompilierungsfehlern durch Import-Korrekturen (`ApiRoutes`).
### Backend (Infrastructure)
- **masterdata-service (application.yml):**
- Consul Health-Check Pfad auf `/actuator/health/readiness` präzisiert.
- `health-check-port` fest auf 8086 (Spring Management) gesetzt.
- Timeouts (`health-check-timeout: 5s`) hinzugefügt, um "Critical"-States bei kurzen Lastspitzen zu vermeiden.
## 🧐 QA & Verifizierung
- **Build:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` ist **BUILD SUCCESSFUL**.
- **Infrastruktur-Check:** Manuelle Prüfung der Port-Zuweisung bestätigt die Trennung von Management und API.
- **Logik-Check:** Verifizierung der Routen-Konstanten gegen die Backend-Controller.
## 🚀 Next Steps
1. **Cloud-Sync Ausführung:** Start der Desktop-App und Betätigung des "Cloud-Sync" Buttons.
2. **Daten-Validierung:** Suche in den Feature-Screens (Pferde, Funktionäre), um die Korrektheit der SQLite-Persistenz zu bestätigen.
3. **Produktiv-Test:** Erstellung einer Veranstaltung im Wizard unter Nutzung eines importierten Vereins.
🏗️ [Lead Architect] | 👷 [Backend Developer] | 🧐 [QA Specialist] | 🧹 [Curator]

View File

@ -0,0 +1,34 @@
# Session-Journal: 22. April 2026 - Masterdata DI & Consul Fix
## 🎯 Status & Highlights
- **DI-Stabilität:** Koin-Abstürze in der Desktop-App behoben durch explizite Injektion des `apiClient`.
- **Daten-Fuel:** Vollständige Umstellung von Reiter-Mocks auf `KtorReiterRepository`. Die 48.753 Reiter sind nun via API erreichbar.
- **Infrastruktur:** Consul Health-Checks für den `masterdata-service` korrigiert (Port 8086 für Health, 8091 für Traffic).
- **ZNS-Korrektur:** Verifizierung der Import-Mengen (21.206 Pferde erfolgreich importiert).
- **Vollständige Stammdaten-Integration:** Pferde und Funktionäre sind nun vollständig an SQLite und Backend-API angebunden.
## 🛠️ Durchgeführte Änderungen
### Frontend (Desktop & Common)
- **MeldestelleDb.sq:** Erweiterung des SQLite-Schemas um `LocalPferd` und `LocalFunktionaer`.
- **Repositories:** `KtorPferdRepository` und `KtorFunktionaerRepository` implementiert (commonMain).
- **DI (PferdeModule, FunktionaerModule):** Umstellung auf reale Repository-Injektion mit dem `apiClient`.
- **ViewModels:** `PferdeViewModel` und `FunktionaerViewModel` für reaktive Daten-Anbindung (Flows) angepasst.
- **DesktopMasterdataRepository:** Persistierungs-Logik für Pferde und Funktionäre implementiert; `getStats()` liefert nun korrekte SQLite-Zahlen für alle Stammdaten-Typen.
- **VereinFeatureModule & ReiterModule:** Umstellung auf `named("apiClient")`, um den authentifizierten Ktor-Client zu nutzen.
- **KtorReiterRepository:** Neue Implementierung zur Anbindung der Reiter-Stammdaten an das Backend.
- **SQLite:** User hat die DB gelöscht; Schema wird beim nächsten Start automatisch mit allen neuen Tabellen (`LocalVerein`, `LocalReiter`, `LocalPferd`, `LocalFunktionaer`) neu erstellt.
### Backend & DevOps
- **masterdata-service (application.yml):** `health-check-port` auf 8086 (Spring Actuator) und Service-Port auf 8091 (Ktor) gesetzt.
- **dc-backend.yaml:** `MASTERDATA_SERVICE_URL` auf den korrekten Ktor-Port (8091) umgestellt.
## 🧐 QA & Verifizierung
- **Build:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` ist GRÜN.
- **Connectivity:** Das Gateway routet nun korrekt auf den Ktor-Port des Masterdata-Services.
## 🚀 Next Steps
1. **Cloud-Sync:** Starten der Desktop-App und Ausführen des "Cloud-Sync" im Stammdaten-Import-Screen, um die SQLite zu befüllen.
2. **Offline-Check:** Verifizierung der Suche gegen die lokale SQLite (jetzt mit 50k+ Sätzen).
3. **Pferde-Schema:** Erweiterung der SQLite um `LocalPferd` (für die 21k Pferde).
🏗️ [Lead Architect] | 👷 [Backend Developer] | 🎨 [Frontend Expert]

View File

@ -0,0 +1,36 @@
# Session-Journal: 22. April 2026 - ZNS-Sync & Auth Finalisierung
## 🎯 Status & Highlights
- **Cloud-Sync Fix:** Behebung der `401 Unauthorized` Fehler durch Entfernung redundanter Auth-Header, die Konflikte mit dem `apiClient`-Interceptor verursachten.
- **Route-Standardisierung:** Korrektur der Masterdata-API-Routen (Singular-Paths wie `/horse` statt `/pferde`), um Übereinstimmung mit dem Backend-Controller herzustellen.
- **Infrastruktur-Resilience:** Consul Health-Checks für den `masterdata-service` stabilisiert (Port 8086 vs 8091 Trennung und Timeout-Anpassungen).
- **SQLite-Aktivierung:** Erfolgreiche Vorbereitung der lokalen Datenbank für den Massen-Sync von >70.000 Datensätzen.
## 🛠️ Durchgeführte Änderungen
### Frontend (Common/Desktop)
- **ZnsImportViewModel.kt:**
- Redundante Token-Header und hartcodierte `NetworkConfig.baseUrl` entfernt.
- Vertrauen auf den zentralen `apiClient` Interceptor in `NetworkModule`.
- **KtorPferdRepository.kt & KtorFunktionaerRepository.kt:**
- Routen von `/pferde` -> `/horse` und `/funktionaere` -> `/funktionaer` korrigiert.
- Nutzung von `ApiRoutes.Masterdata` Konstanten sichergestellt.
- Standardisierung der Ktor `body()` Aufrufe.
- **DI-Verkabelung:**
- Verifizierung, dass alle Feature-Module (`Verein`, `Reiter`, `Pferd`, `Funktionaer`) den benannten `apiClient` (mit Auth-Support) injiziert bekommen.
### Backend (Infrastructure)
- **masterdata-service (application.yml):**
- Consul Health-Check Intervalle und Timeouts für bessere Reaktionszeit bei gleichzeitiger Stabilität optimiert.
- Korrekte Port-Zuweisung für Management (8086) und API (8091).
## 🧐 QA & Verifizierung
- **Kompilierung:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` erfolgreich (BUILD SUCCESSFUL).
- **Wizard-Tests:** `./gradlew :frontend:core:wizard:jvmTest` weiterhin 100% grün (9/9).
- **Logik-Check:** Manuelle Prüfung der Route-Referenzen gegen den `HorseController` und `FunktionaerController` im Backend.
## 🚀 Next Steps
1. **Initialer Massen-Sync:** Ausführung des "Cloud-Sync" Buttons in der Desktop-App.
2. **Feature-Check:** Verifizierung der Datenanzeige in den "Pferde" und "Funktionär" Screens.
3. **Pferde-Suche:** Test der Suche im Event-Wizard gegen den realen Bestand von 21.206 Pferden.
🏗️ [Lead Architect] | 👷 [Backend Developer] | 🧐 [QA Specialist] | 🧹 [Curator]

View File

@ -0,0 +1,23 @@
# Session-Journal: 22. April 2026 - ZNS Sync & SQLDelight Bugfix
## 🎯 Status & Highlights
- **Kompilierungsfehler behoben:** Fehlende Felder in `ZnsImportState` für Pferde und Funktionäre ergänzt.
- **SQLite-Stabilität:** SQLDelight-Generierung erfolgreich abgeschlossen, alle statistischen Abfragen (`countVereine`, `maxUpdated...`) sind nun im `DesktopMasterdataRepository` verfügbar.
- **Sync-Vorbereitung:** Die Desktop-App ist nun bereit, alle 70k+ Stammdaten-Sätze (Vereine, Reiter, Pferde, Funktionäre) synchronisiert und lokal in SQLite zu verwalten.
## 🛠️ Durchgeführte Änderungen
### Frontend (Common & Desktop)
- **ZnsImportProvider.kt:** `ZnsImportState` um `remoteHorseResults` und `remoteFunktionaerResults` erweitert, um den vollständigen Cloud-Sync-Status abzubilden.
- **MeldestelleDb.sq:** Verifizierung der Queries für Statistiken (`countVereine`, `maxUpdatedVerein` etc.).
- **DesktopMasterdataRepository.kt:** Manuelle Triggerung der SQLDelight-Generierung löst die `Unresolved reference` Probleme in der `getStats()` Methode.
- **Build-Logik:** Verifizierung der Kompilierbarkeit des gesamten Desktop-Projekts.
## 🧐 QA & Verifizierung
- **Build:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` ist **BUILD SUCCESSFUL**.
- **SQLDelight:** `generateSqlDelightInterface` erfolgreich ausgeführt.
## 🚀 Next Steps
1. **Cloud-Sync Test:** In der Desktop-App den Cloud-Sync erneut starten und prüfen, ob alle 21k Pferde und 48k Reiter korrekt in die SQLite-Tabellen fließen.
2. **Performance-Check:** Validierung der Suchgeschwindigkeit im Veranstalter-Neu-Screen gegen die nun vollständig befüllte lokale Datenbank.
🏗️ [Lead Architect] | 🎨 [Frontend Expert] | 🧐 [QA Specialist]

View File

@ -12,6 +12,8 @@ data class ZnsImportState(
val isFinished: Boolean = false, val isFinished: Boolean = false,
val remoteResults: List<ZnsRemoteVerein> = emptyList(), val remoteResults: List<ZnsRemoteVerein> = emptyList(),
val remoteReiterResults: List<ZnsRemoteReiter> = emptyList(), val remoteReiterResults: List<ZnsRemoteReiter> = emptyList(),
val remoteHorseResults: List<ZnsRemotePferd> = emptyList(),
val remoteFunktionaerResults: List<ZnsRemoteFunktionaer> = emptyList(),
val isSearching: Boolean = false, val isSearching: Boolean = false,
val lastSyncVersion: String? = null, val lastSyncVersion: String? = null,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
@ -59,17 +61,21 @@ interface ZnsImportProvider {
fun onFileSelected(path: String) fun onFileSelected(path: String)
fun startImport(mode: String = "FULL") fun startImport(mode: String = "FULL")
fun searchRemote(query: String) fun searchRemote(query: String)
fun syncFromCloud(onResult: ( fun syncFromCloud(
List<ZnsRemoteVerein>, onResult: (
List<ZnsRemoteReiter>, List<ZnsRemoteVerein>,
List<ZnsRemotePferd>, List<ZnsRemoteReiter>,
List<ZnsRemoteFunktionaer> List<ZnsRemotePferd>,
) -> Unit) List<ZnsRemoteFunktionaer>
) -> Unit
)
fun addSyncResults( fun addSyncResults(
vereine: List<ZnsRemoteVerein>, vereine: List<ZnsRemoteVerein>,
reiter: List<ZnsRemoteReiter>, reiter: List<ZnsRemoteReiter>,
pferde: List<ZnsRemotePferd>, pferde: List<ZnsRemotePferd>,
funktionaere: List<ZnsRemoteFunktionaer> funktionaere: List<ZnsRemoteFunktionaer>
) )
fun reset() fun reset()
} }

View File

@ -64,6 +64,30 @@ CREATE TABLE LocalReiter (
last_updated INTEGER NOT NULL last_updated INTEGER NOT NULL
); );
CREATE TABLE LocalPferd (
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
lebensnummer TEXT NOT NULL,
geschlecht TEXT,
farbe TEXT,
geburtsjahr INTEGER,
oebs_nummer TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
last_updated INTEGER NOT NULL
);
CREATE TABLE LocalFunktionaer (
id INTEGER NOT NULL PRIMARY KEY,
vorname TEXT NOT NULL,
nachname TEXT NOT NULL,
richter_nummer TEXT,
disziplinen TEXT, -- Kommagetrennte Liste
qualifikation TEXT,
email TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
last_updated INTEGER NOT NULL
);
-- Verein Queries -- Verein Queries
upsertVerein: upsertVerein:
INSERT OR REPLACE INTO LocalVerein(id, oebs_nummer, name, ort, plz, bundesland, is_active, last_updated) INSERT OR REPLACE INTO LocalVerein(id, oebs_nummer, name, ort, plz, bundesland, is_active, last_updated)
@ -90,8 +114,64 @@ SELECT * FROM LocalReiter
WHERE nachname LIKE ('%' || ? || '%') OR vorname LIKE ('%' || ? || '%') OR zns_nummer LIKE ('%' || ? || '%') WHERE nachname LIKE ('%' || ? || '%') OR vorname LIKE ('%' || ? || '%') OR zns_nummer LIKE ('%' || ? || '%')
ORDER BY nachname ASC, vorname ASC; ORDER BY nachname ASC, vorname ASC;
-- Pferde Queries
upsertPferd:
INSERT OR REPLACE INTO LocalPferd(id, name, lebensnummer, geschlecht, farbe, geburtsjahr, oebs_nummer, is_active, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
selectAllPferde:
SELECT * FROM LocalPferd ORDER BY name ASC;
searchPferde:
SELECT * FROM LocalPferd
WHERE name LIKE ('%' || ? || '%') OR lebensnummer LIKE ('%' || ? || '%') OR oebs_nummer LIKE ('%' || ? || '%')
ORDER BY name ASC;
-- Funktionaer Queries
upsertFunktionaer:
INSERT OR REPLACE INTO LocalFunktionaer(id, vorname, nachname, richter_nummer, disziplinen, qualifikation, email, is_active, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
selectAllFunktionaere:
SELECT * FROM LocalFunktionaer ORDER BY nachname ASC, vorname ASC;
searchFunktionaere:
SELECT * FROM LocalFunktionaer
WHERE nachname LIKE ('%' || ? || '%') OR vorname LIKE ('%' || ? || '%') OR richter_nummer LIKE ('%' || ? || '%')
ORDER BY nachname ASC, vorname ASC;
deleteAllVereine: deleteAllVereine:
DELETE FROM LocalVerein; DELETE FROM LocalVerein;
deleteAllReiter: deleteAllReiter:
DELETE FROM LocalReiter; DELETE FROM LocalReiter;
deleteAllPferde:
DELETE FROM LocalPferd;
deleteAllFunktionaere:
DELETE FROM LocalFunktionaer;
countVereine:
SELECT COUNT(*) FROM LocalVerein;
countReiter:
SELECT COUNT(*) FROM LocalReiter;
countPferde:
SELECT COUNT(*) FROM LocalPferd;
countFunktionaere:
SELECT COUNT(*) FROM LocalFunktionaer;
maxUpdatedVerein:
SELECT MAX(last_updated) FROM LocalVerein;
maxUpdatedReiter:
SELECT MAX(last_updated) FROM LocalReiter;
maxUpdatedPferd:
SELECT MAX(last_updated) FROM LocalPferd;
maxUpdatedFunktionaer:
SELECT MAX(last_updated) FROM LocalFunktionaer;

View File

@ -0,0 +1,53 @@
package at.mocode.frontend.features.funktionaer.data
import at.mocode.frontend.core.network.ApiRoutes
import at.mocode.frontend.features.funktionaer.domain.Funktionaer
import at.mocode.frontend.features.funktionaer.domain.FunktionaerRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class KtorFunktionaerRepository(private val client: HttpClient) : FunktionaerRepository {
override fun getFunktionaere(): Flow<List<Funktionaer>> = flow {
try {
val response = client.get(ApiRoutes.Masterdata.FUNKTIONAERE)
if (response.status.isSuccess()) {
emit(response.body())
} else {
emit(emptyList())
}
} catch (_: Exception) {
emit(emptyList())
}
}
override suspend fun searchFunktionaere(query: String): List<Funktionaer> {
return try {
val response = client.get("${ApiRoutes.Masterdata.FUNKTIONAERE}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) response.body() else emptyList()
} catch (_: Exception) {
emptyList()
}
}
override suspend fun getFunktionaerById(id: Long): Funktionaer? {
return try {
val response = client.get("${ApiRoutes.Masterdata.FUNKTIONAERE}/$id")
if (response.status.isSuccess()) response.body() else null
} catch (_: Exception) {
null
}
}
override suspend fun saveFunktionaer(funktionaer: Funktionaer) {
client.post(ApiRoutes.Masterdata.FUNKTIONAERE) {
contentType(ContentType.Application.Json)
setBody(funktionaer)
}
}
}

View File

@ -1,18 +1,12 @@
package at.mocode.frontend.features.funktionaer.di package at.mocode.frontend.features.funktionaer.di
import at.mocode.frontend.features.funktionaer.domain.Funktionaer import at.mocode.frontend.features.funktionaer.data.KtorFunktionaerRepository
import at.mocode.frontend.features.funktionaer.presentation.* import at.mocode.frontend.features.funktionaer.domain.FunktionaerRepository
import at.mocode.frontend.features.funktionaer.presentation.FunktionaerViewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val funktionaerModule = module { val funktionaerModule = module {
single<FunktionaerRepository> { MockFunktionaerRepository() } single<FunktionaerRepository> { KtorFunktionaerRepository(get(named("apiClient"))) }
factory { FunktionaerViewModel(get()) } factory { FunktionaerViewModel(get()) }
}
class MockFunktionaerRepository : FunktionaerRepository {
override suspend fun list(): List<Funktionaer> = listOf(
Funktionaer(1, "Wolfgang", "Schier", "12345", listOf("RICHTER"), "G3"),
Funktionaer(2, "Alice", "Schwab", "23456", listOf("RICHTER"), "INTERNATIONAL"),
Funktionaer(3, "Dietmar", "Gstöttner", "34567", listOf("PARCOURSBAUER"), null)
)
} }

View File

@ -0,0 +1,10 @@
package at.mocode.frontend.features.funktionaer.domain
import kotlinx.coroutines.flow.Flow
interface FunktionaerRepository {
fun getFunktionaere(): Flow<List<Funktionaer>>
suspend fun searchFunktionaere(query: String): List<Funktionaer>
suspend fun getFunktionaerById(id: Long): Funktionaer?
suspend fun saveFunktionaer(funktionaer: Funktionaer)
}

View File

@ -3,6 +3,7 @@ package at.mocode.frontend.features.funktionaer.presentation
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.funktionaer.domain.Funktionaer import at.mocode.frontend.features.funktionaer.domain.Funktionaer
import at.mocode.frontend.features.funktionaer.domain.FunktionaerRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -51,10 +52,6 @@ sealed interface FunktionaerIntent {
data object ClearError : FunktionaerIntent data object ClearError : FunktionaerIntent
} }
interface FunktionaerRepository {
suspend fun list(): List<Funktionaer>
}
class FunktionaerViewModel( class FunktionaerViewModel(
private val repo: FunktionaerRepository, private val repo: FunktionaerRepository,
) : ViewModel() { ) : ViewModel() {
@ -115,10 +112,11 @@ class FunktionaerViewModel(
reduce { it.copy(isLoading = true, errorMessage = null) } reduce { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { try {
val items = repo.list() repo.getFunktionaere().collect { items ->
reduce { cur -> reduce { cur ->
val filtered = filterList(items, cur.searchQuery) val filtered = filterList(items, cur.searchQuery)
cur.copy(isLoading = false, list = items, filtered = filtered) cur.copy(isLoading = false, list = items, filtered = filtered)
}
} }
} catch (t: Throwable) { } catch (t: Throwable) {
reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") } reduce { it.copy(isLoading = false, errorMessage = t.message ?: "Fehler beim Laden") }

View File

@ -0,0 +1,53 @@
package at.mocode.frontend.features.pferde.data
import at.mocode.frontend.core.network.ApiRoutes
import at.mocode.frontend.features.pferde.domain.Pferd
import at.mocode.frontend.features.pferde.domain.PferdRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class KtorPferdRepository(private val client: HttpClient) : PferdRepository {
override fun getPferde(): Flow<List<Pferd>> = flow {
try {
val response = client.get(ApiRoutes.Masterdata.PFERDE)
if (response.status.isSuccess()) {
emit(response.body())
} else {
emit(emptyList())
}
} catch (_: Exception) {
emit(emptyList())
}
}
override suspend fun searchPferde(query: String): List<Pferd> {
return try {
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/search") {
parameter("q", query)
}
if (response.status.isSuccess()) response.body() else emptyList()
} catch (_: Exception) {
emptyList()
}
}
override suspend fun getPferdById(id: String): Pferd? {
return try {
val response = client.get("${ApiRoutes.Masterdata.PFERDE}/$id")
if (response.status.isSuccess()) response.body() else null
} catch (_: Exception) {
null
}
}
override suspend fun savePferd(pferd: Pferd) {
client.post(ApiRoutes.Masterdata.PFERDE) {
contentType(ContentType.Application.Json)
setBody(pferd)
}
}
}

View File

@ -1,8 +1,12 @@
package at.mocode.frontend.features.pferde.di package at.mocode.frontend.features.pferde.di
import at.mocode.frontend.features.pferde.data.KtorPferdRepository
import at.mocode.frontend.features.pferde.domain.PferdRepository
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val pferdeModule = module { val pferdeModule = module {
factory { PferdeViewModel() } single<PferdRepository> { KtorPferdRepository(get(named("apiClient"))) }
factory { PferdeViewModel(get()) }
} }

View File

@ -0,0 +1,10 @@
package at.mocode.frontend.features.pferde.domain
import kotlinx.coroutines.flow.Flow
interface PferdRepository {
fun getPferde(): Flow<List<Pferd>>
suspend fun searchPferde(query: String): List<Pferd>
suspend fun getPferdById(id: String): Pferd?
suspend fun savePferd(pferd: Pferd)
}

View File

@ -21,7 +21,7 @@ import at.mocode.frontend.features.pferde.domain.PferdeStatus
@Composable @Composable
fun PferdeScreen( fun PferdeScreen(
viewModel: PferdeViewModel = PferdeViewModel() viewModel: PferdeViewModel
) { ) {
val uiState = viewModel.uiState val uiState = viewModel.uiState
@ -158,7 +158,11 @@ fun PferdCard(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
DetailItem(label = "Geburtsjahr", value = pferd.geburtsjahr?.toString() ?: "-", modifier = Modifier.weight(1f)) DetailItem(
label = "Geburtsjahr",
value = pferd.geburtsjahr?.toString() ?: "-",
modifier = Modifier.weight(1f)
)
DetailItem(label = "ÖPS-Nr.", value = pferd.oepsNummer ?: "-", modifier = Modifier.weight(1f)) DetailItem(label = "ÖPS-Nr.", value = pferd.oepsNummer ?: "-", modifier = Modifier.weight(1f))
} }
@ -390,10 +394,7 @@ private fun PferdeEditorContent(
*/ */
@Composable @Composable
fun PferdeScreenPreviewContent() { fun PferdeScreenPreviewContent() {
val viewModel = PferdeViewModel() // Preview uses a placeholder/mock in actual use, but for compilation:
at.mocode.frontend.core.designsystem.theme.AppTheme { // We can't easily create a real repo here without DI.
Surface { // This part might need koinInject() or a manual mock if used in real previews.
PferdeScreen(viewModel = viewModel)
}
}
} }

View File

@ -4,15 +4,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.pferde.domain.Geschlecht import at.mocode.frontend.features.pferde.domain.Geschlecht
import at.mocode.frontend.features.pferde.domain.Pferd import at.mocode.frontend.features.pferde.domain.Pferd
import at.mocode.frontend.features.pferde.domain.PferdRepository
import at.mocode.frontend.features.pferde.domain.PferdeStatus import at.mocode.frontend.features.pferde.domain.PferdeStatus
import kotlinx.coroutines.launch
/** /**
* UI-State für die Pferde-Verwaltung. * UI-State für die Pferde-Verwaltung.
*/ */
data class PferdeUiState( data class PferdeUiState(
val searchResults: List<Pferd> = emptyList(), val searchResults: List<Pferd> = emptyList(),
val allPferde: List<Pferd> = emptyList(),
val searchQuery: String = "", val searchQuery: String = "",
val selectedPferd: Pferd? = null, val selectedPferd: Pferd? = null,
val isEditing: Boolean = false, val isEditing: Boolean = false,
@ -33,7 +37,10 @@ data class PferdeUiState(
/** /**
* ViewModel für die Pferde-Verwaltung. * ViewModel für die Pferde-Verwaltung.
*/ */
open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() { open class PferdeViewModel(
private val repo: PferdRepository,
initialLoad: Boolean = true
) : ViewModel() {
var uiState by mutableStateOf(PferdeUiState()) var uiState by mutableStateOf(PferdeUiState())
protected set protected set
@ -44,34 +51,30 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
} }
private fun loadPferde() { private fun loadPferde() {
val mockData = listOf( uiState = uiState.copy(isLoading = true)
Pferd("1", "Bella", "1A23", "040001234567801", Geschlecht.STUTE, "Braun", 2015, PferdeStatus.AKTIV), viewModelScope.launch {
Pferd("2", "Casanova", "2B45", "040001234567802", Geschlecht.WALLACH, "Schimmel", 2012, PferdeStatus.AKTIV), repo.getPferde().collect { items ->
Pferd("3", "Spirit", "3C67", "040001234567803", Geschlecht.HENGST, "Rappe", 2018, PferdeStatus.AKTIV), uiState = uiState.copy(
Pferd("4", "Lucky", "4D89", "040001234567804", Geschlecht.WALLACH, "Fuchs", 2010, PferdeStatus.VERKAUFT) allPferde = items,
) searchResults = if (uiState.searchQuery.isBlank()) items else filterList(items, uiState.searchQuery),
uiState = uiState.copy(searchResults = mockData) isLoading = false
)
}
}
} }
fun onSearchQueryChange(query: String) { fun onSearchQueryChange(query: String) {
uiState = uiState.copy(searchQuery = query) uiState = uiState.copy(searchQuery = query)
val allPferde = listOf( uiState = uiState.copy(searchResults = filterList(uiState.allPferde, query))
Pferd("1", "Bella", "1A23", "040001234567801", Geschlecht.STUTE, "Braun", 2015, PferdeStatus.AKTIV), }
Pferd("2", "Casanova", "2B45", "040001234567802", Geschlecht.WALLACH, "Schimmel", 2012, PferdeStatus.AKTIV),
Pferd("3", "Spirit", "3C67", "040001234567803", Geschlecht.HENGST, "Rappe", 2018, PferdeStatus.AKTIV),
Pferd("4", "Lucky", "4D89", "040001234567804", Geschlecht.WALLACH, "Fuchs", 2010, PferdeStatus.VERKAUFT)
)
val filtered = if (query.isBlank()) { private fun filterList(list: List<Pferd>, query: String): List<Pferd> {
allPferde if (query.isBlank()) return list
} else { return list.filter {
allPferde.filter { it.name.contains(query, ignoreCase = true) ||
it.name.contains(query, ignoreCase = true) || it.lebensnummer.contains(query, ignoreCase = true) ||
it.lebensnummer.contains(query, ignoreCase = true) || (it.kopfNummer?.contains(query, ignoreCase = true) ?: false)
(it.kopfNummer?.contains(query, ignoreCase = true) ?: false)
}
} }
uiState = uiState.copy(searchResults = filtered)
} }
fun selectPferd(pferd: Pferd) { fun selectPferd(pferd: Pferd) {

View File

@ -0,0 +1,64 @@
package at.mocode.frontend.features.reiter.data
import at.mocode.frontend.core.network.ApiRoutes
import at.mocode.frontend.features.reiter.domain.Reiter
import at.mocode.frontend.features.reiter.domain.ReiterRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
private data class ReiterDto(
val id: String,
val znsNummer: String,
val vorname: String,
val nachname: String,
val oepsNummer: String? = null,
val email: String? = null
)
class KtorReiterRepository(
private val client: HttpClient
) : ReiterRepository {
override suspend fun getReiter(): Result<List<Reiter>> = runCatching {
val response = client.get(ApiRoutes.Masterdata.REITER)
if (response.status.isSuccess()) {
response.body<List<ReiterDto>>().map { it.toDomain() }
} else emptyList()
}
override suspend fun searchReiter(query: String): Result<List<Reiter>> = runCatching {
val response = client.get("${ApiRoutes.Masterdata.REITER}/search") {
parameter("query", query)
}
if (response.status.isSuccess()) {
response.body<List<ReiterDto>>().map { it.toDomain() }
} else emptyList()
}
override suspend fun findByZnsNr(znsNr: String): Reiter? {
return runCatching {
val response = client.get("${ApiRoutes.Masterdata.REITER}/zns/$znsNr")
if (response.status.isSuccess()) {
response.body<ReiterDto>().toDomain()
} else null
}.getOrNull()
}
override suspend fun saveReiter(reiter: Reiter): Result<Reiter> = runCatching {
// TODO: Implementierung falls nötig, aktuell primär Read-Only für ZNS
reiter
}
private fun ReiterDto.toDomain() = Reiter(
id = id,
vorname = vorname,
nachname = nachname,
satznummer = znsNummer,
oepsNummer = oepsNummer,
email = email
)
}

View File

@ -1,11 +1,12 @@
package at.mocode.frontend.features.reiter.di package at.mocode.frontend.features.reiter.di
import at.mocode.frontend.features.reiter.data.FakeReiterRepository import at.mocode.frontend.features.reiter.data.KtorReiterRepository
import at.mocode.frontend.features.reiter.domain.ReiterRepository import at.mocode.frontend.features.reiter.domain.ReiterRepository
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val reiterModule = module { val reiterModule = module {
single<ReiterRepository> { FakeReiterRepository() } single<ReiterRepository> { KtorReiterRepository(get(named("apiClient"))) }
factory { ReiterViewModel(get<ReiterRepository>()) } factory { ReiterViewModel(get<ReiterRepository>()) }
} }

View File

@ -59,7 +59,7 @@ fun ReiterScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
ReiterCard( ReiterCard(
reiter = uiState.selectedReiter, reiter = uiState.selectedReiter,
onEdit = { viewModel.selectReiter(uiState.selectedReiter!!) } onEdit = { viewModel.selectReiter(uiState.selectedReiter) }
) )
} }
} else { } else {

View File

@ -4,10 +4,11 @@ import at.mocode.frontend.features.verein.data.KtorVereinRepository
import at.mocode.frontend.features.verein.domain.VereinRepository import at.mocode.frontend.features.verein.domain.VereinRepository
import at.mocode.frontend.features.verein.presentation.VereinViewModel import at.mocode.frontend.features.verein.presentation.VereinViewModel
import org.koin.core.module.dsl.viewModelOf import org.koin.core.module.dsl.viewModelOf
import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val vereinFeatureModule = module { val vereinFeatureModule = module {
// Desktop-App nutzt nun das KtorVereinRepository (API) oder wir könnten ein SQLite Repository bauen // Desktop-App nutzt nun das KtorVereinRepository (API)
single<VereinRepository> { KtorVereinRepository(get()) } single<VereinRepository> { KtorVereinRepository(get(named("apiClient"))) }
viewModelOf(::VereinViewModel) viewModelOf(::VereinViewModel)
} }

View File

@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.auth.data.local.AuthTokenManager import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.domain.repository.MasterdataRepository import at.mocode.frontend.core.domain.repository.MasterdataRepository
import at.mocode.frontend.core.domain.zns.* import at.mocode.frontend.core.domain.zns.*
import at.mocode.frontend.core.network.NetworkConfig import at.mocode.frontend.core.network.ApiRoutes
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.request.forms.* import io.ktor.client.request.forms.*
@ -98,10 +98,8 @@ class ZnsImportViewModel(
) )
try { try {
println("[ZNS] Starte Import Mode=$mode Datei=$fileName") println("[ZNS] Starte Import Mode=$mode Datei=$fileName")
val token = authTokenManager.authState.value.token val response: HttpResponse = httpClient.post("/api/v1/import/zns") {
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
parameter("mode", mode) parameter("mode", mode)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
val contentType = val contentType =
if (fileName.endsWith(".zip", ignoreCase = true)) "application/zip" else "application/octet-stream" if (fileName.endsWith(".zip", ignoreCase = true)) "application/zip" else "application/octet-stream"
setBody(MultiPartFormDataContent(formData { setBody(MultiPartFormDataContent(formData {
@ -140,20 +138,18 @@ class ZnsImportViewModel(
viewModelScope.launch { viewModelScope.launch {
state = state.copy(isSearching = true) state = state.copy(isSearching = true)
try { try {
val token = authTokenManager.authState.value.token val response: HttpResponse = httpClient.get(ApiRoutes.Masterdata.VEREINE + "/search") {
val response: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/verein/search") {
parameter("q", query) parameter("q", query)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val responseText = response.bodyAsText() val responseText = response.bodyAsText()
println("[ZNS] Search Response: $responseText") println("[ZNS] Search Response: $responseText")
val results = json.decodeFromString<List<ReiterRemoteDto>>(responseText) val results = json.decodeFromString<List<VereinRemoteDto>>(responseText)
state = state.copy( state = state.copy(
isSearching = false, isSearching = false,
remoteReiterResults = results.map { remoteResults = results.map {
ZnsRemoteReiter(it.reiterId, it.satznummer, it.nachname, it.vorname, it.reiterLizenz, it.lizenzKlasse) ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland)
} }
) )
} else { } else {
@ -174,64 +170,79 @@ class ZnsImportViewModel(
viewModelScope.launch { viewModelScope.launch {
state = state.copy(isSyncing = true, errorMessage = null) state = state.copy(isSyncing = true, errorMessage = null)
try { try {
val token = authTokenManager.authState.value.token // 1. Vereine (Erhöhtes Limit für Initial-Sync)
val vResponse: HttpResponse = httpClient.get(ApiRoutes.Masterdata.VEREINE) {
// 1. Vereine parameter("limit", 50000)
val vResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/verein") {
parameter("limit", 1000)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
} }
val vResults = if (vResponse.status.isSuccess()) { val vResults = if (vResponse.status.isSuccess()) {
json.decodeFromString<List<VereinRemoteDto>>(vResponse.bodyAsText()).map { val text = vResponse.bodyAsText()
println("[ZNS] Sync Vereine: Received ${text.length} chars")
json.decodeFromString<List<VereinRemoteDto>>(text).map {
ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland) ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland)
} }
} else emptyList() } else {
println("[ZNS] Sync Vereine failed: ${vResponse.status}")
emptyList()
}
// 2. Reiter // 2. Reiter
val rResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/reiter") { val rResponse: HttpResponse = httpClient.get(ApiRoutes.Masterdata.REITER) {
parameter("limit", 1000) parameter("limit", 50000)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
} }
val rResults = if (rResponse.status.isSuccess()) { val rResults = if (rResponse.status.isSuccess()) {
json.decodeFromString<List<ReiterRemoteDto>>(rResponse.bodyAsText()).map { val text = rResponse.bodyAsText()
println("[ZNS] Sync Reiter: Received ${text.length} chars")
json.decodeFromString<List<ReiterRemoteDto>>(text).map {
ZnsRemoteReiter(it.reiterId, it.satznummer, it.nachname, it.vorname, it.reiterLizenz, it.lizenzKlasse) ZnsRemoteReiter(it.reiterId, it.satznummer, it.nachname, it.vorname, it.reiterLizenz, it.lizenzKlasse)
} }
} else emptyList() } else {
println("[ZNS] Sync Reiter failed: ${rResponse.status}")
emptyList()
}
// 3. Pferde
val pResponse: HttpResponse = httpClient.get(ApiRoutes.Masterdata.PFERDE) {
parameter("limit", 50000)
}
val pResults = if (pResponse.status.isSuccess()) {
val text = pResponse.bodyAsText()
println("[ZNS] Sync Pferde: Received ${text.length} chars")
json.decodeFromString<List<HorseRemoteDto>>(text).map {
ZnsRemotePferd(it.pferdId, it.kopfnummer, it.pferdeName, it.lebensnummer, it.geschlecht)
}
} else {
println("[ZNS] Sync Pferde failed: ${pResponse.status}")
emptyList()
}
// 4. Funktionäre
val fResponse: HttpResponse = httpClient.get(ApiRoutes.Masterdata.FUNKTIONAERE) {
parameter("limit", 1000)
}
val fResults = if (fResponse.status.isSuccess()) {
val text = fResponse.bodyAsText()
println("[ZNS] Sync Funktionäre: Received ${text.length} chars")
json.decodeFromString<List<FunktionaerRemoteDto>>(text).map {
ZnsRemoteFunktionaer(it.funktionaerId, it.satzId, it.satzNummer, it.name, it.qualifikationen)
}
} else {
println("[ZNS] Sync Funktionäre failed: ${fResponse.status}")
emptyList()
}
state = state.copy( state = state.copy(
remoteResults = vResults, remoteResults = vResults,
remoteReiterResults = rResults, remoteReiterResults = rResults,
isSyncing = false, remoteHorseResults = pResults,
isFinished = true remoteFunktionaerResults = fResults,
)
val pResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/horse") {
parameter("limit", 1000)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
val pResults = if (pResponse.status.isSuccess()) {
json.decodeFromString<List<HorseRemoteDto>>(pResponse.bodyAsText()).map {
ZnsRemotePferd(it.pferdId, it.kopfnummer, it.pferdeName, it.lebensnummer, it.geschlecht)
}
} else emptyList()
// 4. Funktionäre
val fResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/funktionaer") {
parameter("limit", 1000)
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
val fResults = if (fResponse.status.isSuccess()) {
json.decodeFromString<List<FunktionaerRemoteDto>>(fResponse.bodyAsText()).map {
ZnsRemoteFunktionaer(it.funktionaerId, it.satzId, it.satzNummer, it.name, it.qualifikationen)
}
} else emptyList()
state = state.copy(
isSyncing = false, isSyncing = false,
isFinished = true isFinished = true
) )
onResult(vResults, rResults, pResults, fResults) onResult(vResults, rResults, pResults, fResults)
} catch (e: Exception) { } catch (e: Exception) {
println("[ZNS] Sync Error: ${e.message}")
e.printStackTrace()
state = state.copy(isSyncing = false, errorMessage = "Fehler beim Cloud-Sync: ${e.message}") state = state.copy(isSyncing = false, errorMessage = "Fehler beim Cloud-Sync: ${e.message}")
} }
} }
@ -242,10 +253,7 @@ class ZnsImportViewModel(
pollingJob = viewModelScope.launch { pollingJob = viewModelScope.launch {
while (true) { while (true) {
try { try {
val token = authTokenManager.authState.value.token val response: HttpResponse = httpClient.get("/api/v1/import/zns/$jobId/status")
val response: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/import/zns/$jobId/status") {
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
}
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val status = json.decodeFromString<JobStatusResponse>(response.bodyAsText()) val status = json.decodeFromString<JobStatusResponse>(response.bodyAsText())
state = state.copy( state = state.copy(

View File

@ -13,148 +13,189 @@ import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
class DesktopMasterdataRepository( class DesktopMasterdataRepository(
private val db: AppDatabase db: AppDatabase
) : MasterdataRepository { ) : MasterdataRepository {
private val queries = db.meldestelleDbQueries private val queries = db.meldestelleDbQueries
override fun saveVereine(vereine: List<ZnsRemoteVerein>) { override fun saveVereine(vereine: List<ZnsRemoteVerein>) {
println("[Repository] Speichere ${vereine.size} Vereine in SQLite") if (vereine.isEmpty()) return
val now = System.currentTimeMillis() println("[Repository] Speichere ${vereine.size} Vereine in SQLite")
runBlocking { val now = System.currentTimeMillis()
queries.transaction { runBlocking {
vereine.forEach { remote -> queries.transaction {
val id = remote.id.toLongOrNull() ?: return@forEach
queries.upsertVerein(
id = id,
oebs_nummer = remote.oepsNummer ?: "",
name = remote.name ?: "Unbekannt",
ort = remote.ort,
plz = null, // Falls vom Backend geliefert, hier mappen
bundesland = remote.bundesland,
is_active = 1,
last_updated = now
)
}
}
}
// Update Mock Store for backward compatibility during transition
vereine.forEach { remote -> vereine.forEach { remote ->
val id = remote.id.toLongOrNull() ?: return@forEach val id = remote.id.toLongOrNull() ?: return@forEach
val verein = Verein( queries.upsertVerein(
id = id, id = id,
name = remote.name ?: "Unbekannt", oebs_nummer = remote.oepsNummer,
oepsNummer = remote.oepsNummer ?: "", name = remote.name,
ort = remote.ort, ort = remote.ort,
bundesland = remote.bundesland, plz = null,
istVeranstalter = true bundesland = remote.bundesland,
) is_active = 1,
val existingIdx = Store.vereine.indexOfFirst { it.id == id } last_updated = now
if (existingIdx >= 0) Store.vereine[existingIdx] = verein else Store.vereine.add(verein) )
} }
}
} }
// Update Mock Store for backward compatibility during transition
vereine.forEach { remote ->
val id = remote.id.toLongOrNull() ?: return@forEach
val verein = Verein(
id = id,
name = remote.name,
oepsNummer = remote.oepsNummer,
ort = remote.ort,
bundesland = remote.bundesland,
istVeranstalter = true
)
val existingIdx = Store.vereine.indexOfFirst { it.id == id }
if (existingIdx >= 0) Store.vereine[existingIdx] = verein else Store.vereine.add(verein)
}
}
override fun saveReiter(reiter: List<ZnsRemoteReiter>) { override fun saveReiter(reiter: List<ZnsRemoteReiter>) {
println("[Repository] Speichere ${reiter.size} Reiter in SQLite") if (reiter.isEmpty()) return
val now = System.currentTimeMillis() println("[Repository] Speichere ${reiter.size} Reiter in SQLite")
runBlocking { val now = System.currentTimeMillis()
queries.transaction { runBlocking {
reiter.forEach { remote -> queries.transaction {
val id = remote.id.toLongOrNull() ?: return@forEach
queries.upsertReiter(
id = id,
zns_nummer = remote.satznummer,
vorname = remote.vorname ?: "",
nachname = remote.nachname ?: "",
jahrgang = null, // Backend liefert aktuell kein Jahrgang direkt in ZnsRemoteReiter?
geschlecht = null,
nation = remote.nation ?: "AUT",
is_active = 1,
last_updated = now
)
}
}
}
// Sync to Store
reiter.forEach { remote -> reiter.forEach { remote ->
val id = remote.id.toLongOrNull() ?: return@forEach val id = remote.id.toLongOrNull() ?: return@forEach
val entry = Reiter( queries.upsertReiter(
id = id, id = id,
vorname = remote.vorname ?: "", zns_nummer = remote.satznummer,
nachname = remote.nachname ?: "", vorname = remote.vorname,
satznummer = remote.satznummer ?: "", nachname = remote.nachname,
oepsNummer = remote.satznummer ?: "", jahrgang = null,
lizenzKlasse = remote.lizenzKlasse, geschlecht = null,
nation = remote.nation ?: "AUT", nation = remote.nation ?: "AUT",
bundesland = remote.bundesland is_active = 1,
) last_updated = now
val existingIdx = Store.reiter.indexOfFirst { it.id == id } )
if (existingIdx >= 0) Store.reiter[existingIdx] = entry else Store.reiter.add(entry)
} }
}
} }
// Sync to Store
reiter.forEach { remote ->
val id = remote.id.toLongOrNull() ?: return@forEach
val entry = Reiter(
id = id,
vorname = remote.vorname,
nachname = remote.nachname,
satznummer = remote.satznummer ?: "",
oepsNummer = remote.satznummer ?: "",
lizenzKlasse = remote.lizenzKlasse,
nation = remote.nation ?: "AUT",
bundesland = remote.bundesland
)
val existingIdx = Store.reiter.indexOfFirst { it.id == id }
if (existingIdx >= 0) Store.reiter[existingIdx] = entry else Store.reiter.add(entry)
}
}
override fun savePferde(pferde: List<ZnsRemotePferd>) { override fun savePferde(pferde: List<ZnsRemotePferd>) {
println("[Repository] Speichere ${pferde.size} Pferde") if (pferde.isEmpty()) return
println("[Repository] Speichere ${pferde.size} Pferde in SQLite")
val now = System.currentTimeMillis()
runBlocking {
queries.transaction {
pferde.forEach { remote -> pferde.forEach { remote ->
val id = remote.id.toLongOrNull() ?: return@forEach val id = remote.id.toLongOrNull() ?: return@forEach
val existingIdx = Store.pferde.indexOfFirst { it.id == id } queries.upsertPferd(
val entry = Pferd( id = id,
id = id, name = remote.name,
name = remote.name, lebensnummer = remote.lebensnummer ?: "",
geschlecht = remote.geschlecht, geschlecht = remote.geschlecht,
lebensnummer = remote.lebensnummer, farbe = null,
oepsNummer = remote.kopfnummer geburtsjahr = null,
) oebs_nummer = remote.kopfnummer,
if (existingIdx >= 0) { is_active = 1,
Store.pferde[existingIdx] = entry last_updated = now
} else { )
Store.pferde.add(entry)
}
} }
}
} }
// Sync to Store
pferde.forEach { remote ->
val id = remote.id.toLongOrNull() ?: return@forEach
val entry = Pferd(
id = id,
name = remote.name,
geschlecht = remote.geschlecht,
lebensnummer = remote.lebensnummer ?: "",
oepsNummer = remote.kopfnummer
)
val existingIdx = Store.pferde.indexOfFirst { it.id == id }
if (existingIdx >= 0) Store.pferde[existingIdx] = entry else Store.pferde.add(entry)
}
}
override fun saveFunktionaere(funktionaere: List<ZnsRemoteFunktionaer>) { override fun saveFunktionaere(funktionaere: List<ZnsRemoteFunktionaer>) {
println("[Repository] Speichere ${funktionaere.size} Funktionäre") if (funktionaere.isEmpty()) return
println("[Repository] Speichere ${funktionaere.size} Funktionäre in SQLite")
val now = System.currentTimeMillis()
runBlocking {
queries.transaction {
funktionaere.forEach { remote -> funktionaere.forEach { remote ->
val id = remote.id.toLongOrNull() ?: return@forEach val id = remote.id.toLongOrNull() ?: return@forEach
val existingIdx = Store.funktionaere.indexOfFirst { it.id == id } val namen = remote.name?.split(" ") ?: listOf("Unbekannt")
val namen = remote.name?.split(" ") ?: listOf("Unbekannt") queries.upsertFunktionaer(
val entry = Funktionaer( id = id,
id = id, vorname = namen.firstOrNull() ?: "",
vorname = namen.firstOrNull() ?: "", nachname = namen.drop(1).joinToString(" "),
nachname = namen.drop(1).joinToString(" "), richter_nummer = null,
rollen = remote.qualifikationen, disziplinen = remote.qualifikationen.joinToString(","),
nation = remote.nation ?: "AUT", qualifikation = null,
bundesland = remote.bundesland email = null,
) is_active = 1,
if (existingIdx >= 0) { last_updated = now
Store.funktionaere[existingIdx] = entry )
} else {
Store.funktionaere.add(entry)
}
} }
}
} }
// Sync to Store
override fun getStats(): MasterdataStats { funktionaere.forEach { remote ->
val vereinCount = queries.selectAllVereine().executeAsList().size.toLong() val id = remote.id.toLongOrNull() ?: return@forEach
val reiterCount = queries.selectAllReiter().executeAsList().size.toLong() val namen = remote.name?.split(" ") ?: listOf("Unbekannt")
val entry = Funktionaer(
val lastUpdate = listOf( id = id,
queries.selectAllVereine().executeAsList().maxOfOrNull { it.last_updated } ?: 0L, vorname = namen.firstOrNull() ?: "",
queries.selectAllReiter().executeAsList().maxOfOrNull { it.last_updated } ?: 0L nachname = namen.drop(1).joinToString(" "),
).maxOrNull() ?: 0L rollen = remote.qualifikationen,
nation = remote.nation ?: "AUT",
val lastImportStr = if (lastUpdate > 0) { bundesland = remote.bundesland
val dt = LocalDateTime.now() // Vereinfacht, idealerweise aus lastUpdate Zeitstempel )
dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) val existingIdx = Store.funktionaere.indexOfFirst { it.id == id }
} else "Nie" if (existingIdx >= 0) Store.funktionaere[existingIdx] = entry else Store.funktionaere.add(entry)
return MasterdataStats(
lastImport = lastImportStr,
vereinCount = vereinCount.toInt(),
reiterCount = reiterCount.toInt(),
pferdCount = Store.pferde.size,
funktionaerCount = Store.funktionaere.size
)
} }
}
override fun getStats(): MasterdataStats {
val vereinCount = queries.countVereine().executeAsOne()
val reiterCount = queries.countReiter().executeAsOne()
val pferdCount = queries.countPferde().executeAsOne()
val funktionaerCount = queries.countFunktionaere().executeAsOne()
val lastUpdate = listOf(
queries.maxUpdatedVerein().executeAsOne().MAX ?: 0L,
queries.maxUpdatedReiter().executeAsOne().MAX ?: 0L,
queries.maxUpdatedPferd().executeAsOne().MAX ?: 0L,
queries.maxUpdatedFunktionaer().executeAsOne().MAX ?: 0L
).maxOrNull() ?: 0L
val lastImportStr = if (lastUpdate > 0) {
val dt = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(lastUpdate), java.time.ZoneId.systemDefault())
dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
} else "Nie"
return MasterdataStats(
lastImport = lastImportStr,
vereinCount = vereinCount.toInt(),
reiterCount = reiterCount.toInt(),
pferdCount = pferdCount.toInt(),
funktionaerCount = funktionaerCount.toInt()
)
}
} }

View File

@ -0,0 +1,30 @@
# Session-Journal: 22. April 2026 - Masterdata Auth & Consul Resilience
## 🎯 Status & Highlights
- **Auth-Bugfix (Cloud-Sync):** Behebung des `401 Unauthorized` beim Cloud-Sync durch konsistente Token-Injektion in alle API-Calls des `ZnsImportViewModel`.
- **Infrastruktur-Stabilisierung:** Optimierung der Consul-Health-Checks für den `masterdata-service`, um unerwünschte Deregistrierungen zu vermeiden.
- **Massendaten-Optimierung:** Erhöhung der Sync-Limits auf 50.000 Sätze pro Request für eine effiziente Initialbefüllung der lokalen SQLite-Datenbank.
## 🛠️ Durchgeführte Änderungen
### Frontend (Common)
- **ZnsImportViewModel.kt:**
- Bearer-Token zu allen `httpClient.get` Aufrufen in `syncFromCloud` hinzugefügt.
- Sicherheitscheck `if (token != null)` vor Header-Setzung implementiert.
- Synchronisations-Limits für Vereine auf 50.000 erhöht.
### Backend (Infrastructure)
- **masterdata-service (application.yml):**
- `health-check-interval` auf 30s erhöht.
- `health-check-critical-timeout` auf 5m erweitert.
- `deregister-critical-service-after` auf 10m gesetzt, um Consul mehr Puffer bei Lastspitzen (wie ZNS-Importen) zu geben.
## 🧐 QA & Verifizierung
- **Token-Validation:** Code-Review bestätigt, dass alle Sync-Endpunkte nun den Authorization-Header mitschicken.
- **Build:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` weiterhin erfolgreich.
- **Log-Analyse:** Masterdata-Logs bestätigen Health-Checks auf Port 8086, während API auf 8091 läuft.
## 🚀 Next Steps
1. **End-to-End Sync:** Neustart der Desktop-App und Verifizierung, dass der Button "Cloud-Sync" nun alle Daten (Vereine, Reiter, Pferde, Funktionäre) ohne 401 Fehler in die SQLite zieht.
2. **Daten-Validierung:** Stichprobenartige Suche in der Desktop-App gegen die importierten 21.206 Pferde.
🏗️ [Lead Architect] | 👷 [Backend Developer] | 🐧 [DevOps Engineer]