From 6e99bc97fd3ed4ee01849329c997444ccc79f099 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sun, 12 Apr 2026 16:58:22 +0200 Subject: [PATCH] Integrate `series-service` microservice with API gateway routing, implement Series domain and point aggregation logic, and update frontend with SeriesViewModel, SeriesScreen, and dynamic state handling. --- .../gateway/config/GatewayConfig.kt | 7 +- .../service/ResultsServiceApplication.kt | 27 +++- .../service/application/ResultsService.kt | 43 +++++++ .../mocode/results/service/domain/Ergebnis.kt | 37 ++++++ .../persistence/JpaErgebnisRepository.kt | 10 ++ .../services/series/series-service/Dockerfile | 121 ++++++++++++++++++ .../series/series-service/build.gradle.kts | 42 ++++++ .../service/SeriesServiceApplication.kt | 41 ++++++ .../service/application/SeriesService.kt | 36 ++++++ .../at/mocode/series/service/domain/Serie.kt | 57 +++++++++ .../persistence/JpaSeriesRepository.kt | 14 ++ dc-backend.yaml | 50 ++++++++ ...26-04-12_Series_Integration_Curator_Log.md | 32 +++++ .../mocode/frontend/core/network/ApiRoutes.kt | 5 + .../feature/domain/SeriesRepository.kt | 30 +++++ .../feature/presentation/SeriesViewModel.kt | 59 +++++++++ .../data/remote/DefaultSeriesRepository.kt | 40 ++++++ .../feature/di/TurnierFeatureModule.kt | 2 + .../feature/presentation/SeriesScreen.kt | 82 +++++++++--- .../presentation/TurnierErgebnislistenTab.kt | 1 - 20 files changed, 711 insertions(+), 25 deletions(-) create mode 100644 backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/application/ResultsService.kt create mode 100644 backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/domain/Ergebnis.kt create mode 100644 backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/persistence/JpaErgebnisRepository.kt create mode 100644 backend/services/series/series-service/Dockerfile create mode 100644 backend/services/series/series-service/build.gradle.kts create mode 100644 backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/SeriesServiceApplication.kt create mode 100644 backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/application/SeriesService.kt create mode 100644 backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/domain/Serie.kt create mode 100644 backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/persistence/JpaSeriesRepository.kt create mode 100644 docs/04_Agents/Logs/2026-04-12_Series_Integration_Curator_Log.md create mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/SeriesRepository.kt create mode 100644 frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/SeriesViewModel.kt create mode 100644 frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultSeriesRepository.kt diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt index 58fd5961..b8b3d580 100644 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt @@ -14,7 +14,8 @@ class GatewayConfig( @Value("\${masterdata.service.url:http://localhost:8086}") private val masterdataServiceUrl: String, @Value("\${events.service.url:http://localhost:8085}") private val eventsServiceUrl: String, @Value("\${zns.import.service.url:http://localhost:8095}") private val znsImportServiceUrl: String, - @Value("\${results.service.url:http://localhost:8088}") private val resultsServiceUrl: String + @Value("\${results.service.url:http://localhost:8088}") private val resultsServiceUrl: String, + @Value("\${series.service.url:http://localhost:8089}") private val seriesServiceUrl: String ) { @Bean @@ -47,6 +48,10 @@ class GatewayConfig( path("/api/v1/results/**") uri(resultsServiceUrl) } + route(id = "series-service") { + path("/api/v1/series/**") + uri(seriesServiceUrl) + } } } } diff --git a/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/ResultsServiceApplication.kt b/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/ResultsServiceApplication.kt index cf09ae6e..1048dc24 100644 --- a/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/ResultsServiceApplication.kt +++ b/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/ResultsServiceApplication.kt @@ -1,9 +1,10 @@ package at.mocode.results.service +import at.mocode.results.service.application.ResultsService +import at.mocode.results.service.domain.Ergebnis import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @SpringBootApplication class ResultsServiceApplication @@ -13,7 +14,27 @@ fun main(args: Array) { } @RestController -class ResultsController { +@RequestMapping("/api/v1/results") +class ResultsController( + private val service: ResultsService +) { @GetMapping("/") fun health(): String = "Results Service is running" + + @GetMapping("/bewerb/{bewerbId}") + fun getForBewerb(@PathVariable bewerbId: String): List = service.getByBewerbId(bewerbId) + + @PostMapping + fun save(@RequestBody ergebnis: Ergebnis): Ergebnis = service.saveErgebnis(ergebnis) + + @PutMapping("/{id}") + fun update(@PathVariable id: String, @RequestBody ergebnis: Ergebnis): Ergebnis { + return service.saveErgebnis(ergebnis.copy(id = id)) + } + + @PostMapping("/bewerb/{bewerbId}/calculate") + fun calculate(@PathVariable bewerbId: String): List = service.calculatePlatzierung(bewerbId) + + @GetMapping("/bewerb/{bewerbId}/pdf") + fun exportPdf(@PathVariable bewerbId: String): ByteArray = service.generatePdf(bewerbId) } diff --git a/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/application/ResultsService.kt b/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/application/ResultsService.kt new file mode 100644 index 00000000..b93a08fa --- /dev/null +++ b/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/application/ResultsService.kt @@ -0,0 +1,43 @@ +package at.mocode.results.service.application + +import at.mocode.results.service.domain.Ergebnis +import at.mocode.results.service.domain.ErgebnisStatus +import at.mocode.results.service.persistence.JpaErgebnisRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ResultsService( + private val repository: JpaErgebnisRepository +) { + + fun getByBewerbId(bewerbId: String): List = repository.findByBewerbId(bewerbId) + + @Transactional + fun saveErgebnis(ergebnis: Ergebnis): Ergebnis = repository.save(ergebnis) + + @Transactional + fun calculatePlatzierung(bewerbId: String): List { + val results = repository.findByBewerbId(bewerbId) + .filter { it.status == ErgebnisStatus.OK } + + // Einfache Platzierungs-Logik: Höchste Wertnote zuerst (Standard Dressur/Stil) + // Bei gleicher Wertnote: Zeit als Tie-Breaker (Placeholder) + val sortedResults = results.sortedWith( + compareByDescending { it.wertnote ?: 0.0 } + .thenBy { it.zeit ?: 0.0 } + .thenByDescending { it.fehler ?: 0.0 } + ) + + val updatedResults = sortedResults.mapIndexed { index, ergebnis -> + ergebnis.copy(platzierung = index + 1) + } + + return repository.saveAll(updatedResults) + } + + fun generatePdf(bewerbId: String): ByteArray { + // Placeholder für PDF-Generierung + return "PDF Content for Bewerb $bewerbId".toByteArray() + } +} diff --git a/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/domain/Ergebnis.kt b/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/domain/Ergebnis.kt new file mode 100644 index 00000000..095443e9 --- /dev/null +++ b/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/domain/Ergebnis.kt @@ -0,0 +1,37 @@ +package at.mocode.results.service.domain + +import jakarta.persistence.* +import java.util.* + +@Entity +@Table(name = "ergebnisse") +data class Ergebnis( + @Id + val id: String = UUID.randomUUID().toString(), + + @Column(nullable = false) + val nennungId: String, + + @Column(nullable = false) + val bewerbId: String, + + @Column + val wertnote: Double? = null, + + @Column + val zeit: Double? = null, + + @Column + val fehler: Double? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val status: ErgebnisStatus = ErgebnisStatus.OK, + + @Column + val platzierung: Int? = null +) + +enum class ErgebnisStatus { + OK, AUSGESCHIEDEN, VERZICHTET, DISQUALIFIZIERT, NICHT_GESTARTET +} diff --git a/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/persistence/JpaErgebnisRepository.kt b/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/persistence/JpaErgebnisRepository.kt new file mode 100644 index 00000000..12e7747f --- /dev/null +++ b/backend/services/results/results-service/src/main/kotlin/at/mocode/results/service/persistence/JpaErgebnisRepository.kt @@ -0,0 +1,10 @@ +package at.mocode.results.service.persistence + +import at.mocode.results.service.domain.Ergebnis +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface JpaErgebnisRepository : JpaRepository { + fun findByBewerbId(bewerbId: String): List +} diff --git a/backend/services/series/series-service/Dockerfile b/backend/services/series/series-service/Dockerfile new file mode 100644 index 00000000..341a4065 --- /dev/null +++ b/backend/services/series/series-service/Dockerfile @@ -0,0 +1,121 @@ +ARG GRADLE_VERSION +ARG JAVA_VERSION +ARG BUILD_DATE +ARG VERSION + +FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION}-alpine AS builder + +LABEL stage=builder \ + service=series-service \ + maintainer="Meldestelle Development Team" \ + version="${VERSION}" \ + build.date="${BUILD_DATE}" + +WORKDIR /workspace + +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=/home/gradle/.gradle + +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 build.gradle.kts ./ + +# Copy series modules +COPY backend/services/results/series-service/ backend/services/results/series-service/ + +RUN --mount=type=cache,target=/home/gradle/.gradle/caches \ + --mount=type=cache,target=/home/gradle/.gradle/wrapper \ + ./gradlew :backend:services:results:series-service:dependencies --no-daemon --info + +RUN --mount=type=cache,target=/home/gradle/.gradle/caches \ + --mount=type=cache,target=/home/gradle/.gradle/wrapper \ + ./gradlew :backend:services:results:series-service:bootJar --no-daemon --info + +FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime + +ARG BUILD_DATE +ARG VERSION +ARG JAVA_VERSION + +ENV JAVA_VERSION=${JAVA_VERSION} \ + VERSION=${VERSION} \ + BUILD_DATE=${BUILD_DATE} + +LABEL service="series-service" \ + version="${VERSION}" \ + description="Microservice for Series Management" \ + maintainer="Meldestelle Development Team" \ + java.version="${JAVA_VERSION}" \ + build.date="${BUILD_DATE}" + +ARG APP_USER=appuser +ARG APP_GROUP=appgroup +ARG APP_UID=1009 +ARG APP_GID=1009 + +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 --from=builder --chown=${APP_USER}:${APP_GROUP} \ + /workspace/backend/services/results/series-service/build/libs/*.jar app.jar + +USER ${APP_USER} + +EXPOSE 8089 5011 + +HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \ + CMD curl -fsS --max-time 2 http://localhost:8089/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 +ENV SERVER_PORT=8089 +ENV LOGGING_LEVEL_ROOT=INFO + +ENTRYPOINT ["tini", "--", "sh", "-c", "\ + echo 'Starting Results 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=*:5011 -jar app.jar; \ + else \ + exec java ${JAVA_OPTS} -jar app.jar; \ + fi"] diff --git a/backend/services/series/series-service/build.gradle.kts b/backend/services/series/series-service/build.gradle.kts new file mode 100644 index 00000000..bc8f9fb5 --- /dev/null +++ b/backend/services/series/series-service/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSpring) + alias(libs.plugins.kotlinJpa) + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.dependencyManagement) +} + +springBoot { + mainClass.set("at.mocode.series.service.SeriesServiceApplicationKt") +} + +dependencies { + implementation(platform(projects.platform.platformBom)) + implementation(projects.platform.platformDependencies) + implementation(projects.backend.infrastructure.monitoring.monitoringClient) + + implementation(libs.bundles.spring.boot.service.complete) + implementation(libs.postgresql.driver) + implementation(libs.spring.boot.starter.web) + + // KORREKTUR: Jackson Bundle aufgelöst + implementation(libs.jackson.module.kotlin) + implementation(libs.jackson.datatype.jsr310) + + implementation(libs.kotlin.reflect) + implementation(libs.spring.cloud.starter.consul.discovery) + implementation(libs.caffeine) + implementation(libs.spring.web) + + // KORREKTUR: Resilience Bundle aufgelöst + implementation(libs.resilience4j.spring.boot3) + implementation(libs.resilience4j.reactor) + implementation(libs.spring.boot.starter.aop) + + implementation(libs.springdoc.openapi.starter.webmvc.ui) + + testImplementation(projects.platform.platformTesting) + testImplementation(libs.bundles.testing.jvm) + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.spring.boot.starter.web) +} diff --git a/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/SeriesServiceApplication.kt b/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/SeriesServiceApplication.kt new file mode 100644 index 00000000..eda00ea7 --- /dev/null +++ b/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/SeriesServiceApplication.kt @@ -0,0 +1,41 @@ +package at.mocode.series.service + +import at.mocode.series.service.application.SeriesService +import at.mocode.series.service.domain.Serie +import at.mocode.series.service.domain.SeriePunkt +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.web.bind.annotation.* + +@SpringBootApplication +class SeriesServiceApplication + +fun main(args: Array) { + runApplication(*args) +} + +@RestController +@RequestMapping("/api/v1/series") +class SeriesController( + private val service: SeriesService +) { + @GetMapping("/") + fun health(): String = "Series Service is running" + + @GetMapping + fun getAll(): List = service.getAllSeries() + + @GetMapping("/{id}") + fun getById(@PathVariable id: String): Serie? = service.getSeriesById(id) + + @PostMapping + fun save(@RequestBody serie: Serie): Serie = service.saveSerie(serie) + + @GetMapping("/{id}/stand") + fun getStand(@PathVariable id: String) = service.getStand(id) + + @PostMapping("/{id}/punkte") + fun addPunkt(@PathVariable id: String, @RequestBody punkt: SeriePunkt): SeriePunkt { + return service.addPunkt(punkt.copy(serieId = id)) + } +} diff --git a/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/application/SeriesService.kt b/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/application/SeriesService.kt new file mode 100644 index 00000000..d3624d82 --- /dev/null +++ b/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/application/SeriesService.kt @@ -0,0 +1,36 @@ +package at.mocode.series.service.application + +import at.mocode.series.service.domain.Serie +import at.mocode.series.service.domain.SeriePunkt +import at.mocode.series.service.persistence.JpaSeriePunktRepository +import at.mocode.series.service.persistence.JpaSerieRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class SeriesService( + private val serieRepository: JpaSerieRepository, + private val punkteRepository: JpaSeriePunktRepository +) { + + fun getAllSeries(): List = serieRepository.findAll() + + fun getSeriesById(id: String): Serie? = serieRepository.findById(id).orElse(null) + + @Transactional + fun saveSerie(serie: Serie): Serie = serieRepository.save(serie) + + fun getStand(serieId: String): Map, Double> { + val punkte = punkteRepository.findBySerieId(serieId) + + // Aggregation pro Paar (Reiter, Pferd) + return punkte.groupBy { it.reiterId to it.pferdId } + .mapValues { (_, v) -> v.sumOf { it.punkte } } + .toList() + .sortedByDescending { it.second } + .toMap() + } + + @Transactional + fun addPunkt(punkt: SeriePunkt): SeriePunkt = punkteRepository.save(punkt) +} diff --git a/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/domain/Serie.kt b/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/domain/Serie.kt new file mode 100644 index 00000000..a04b6e3f --- /dev/null +++ b/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/domain/Serie.kt @@ -0,0 +1,57 @@ +package at.mocode.series.service.domain + +import jakarta.persistence.* +import java.util.* + +@Entity +@Table(name = "serien") +class Serie( + @Id + val id: String = UUID.randomUUID().toString(), + + @Column(nullable = false) + val name: String, + + @Column + val beschreibung: String? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val reglementTyp: ReglementTyp = ReglementTyp.STREICHER_NORMAL, + + @ElementCollection + @CollectionTable(name = "serie_bewerbe", joinColumns = [JoinColumn(name = "serie_id")]) + @Column(name = "bewerb_id") + val bewerbIds: Set = mutableSetOf() +) + +enum class ReglementTyp { + STREICHER_NORMAL, // z.B. 4 von 6 Wertungen zählen + ALLES_ZAEHLT, // Alle Bewerbe zählen + MEISTERSCHAFT // Spezielle Gewichtung (z.B. Finale doppelt) +} + +@Entity +@Table(name = "serie_punkte") +class SeriePunkt( + @Id + val id: String = UUID.randomUUID().toString(), + + @Column(nullable = false) + val serieId: String, + + @Column(nullable = false) + val reiterId: String, + + @Column(nullable = false) + val pferdId: String, + + @Column(nullable = false) + val bewerbId: String, + + @Column(nullable = false) + val punkte: Double, + + @Column(nullable = false) + val platzierung: Int +) diff --git a/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/persistence/JpaSeriesRepository.kt b/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/persistence/JpaSeriesRepository.kt new file mode 100644 index 00000000..eade2228 --- /dev/null +++ b/backend/services/series/series-service/src/main/kotlin/at/mocode/series/service/persistence/JpaSeriesRepository.kt @@ -0,0 +1,14 @@ +package at.mocode.series.service.persistence + +import at.mocode.series.service.domain.Serie +import at.mocode.series.service.domain.SeriePunkt +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface JpaSerieRepository : JpaRepository + +@Repository +interface JpaSeriePunktRepository : JpaRepository { + fun findBySerieId(serieId: String): List +} diff --git a/dc-backend.yaml b/dc-backend.yaml index a8b9cbf5..856da1c5 100644 --- a/dc-backend.yaml +++ b/dc-backend.yaml @@ -466,6 +466,56 @@ services: volumes: - ./config/app/base-application.yaml:/workspace/config/application.yml:Z + # --- MICROSERVICE: Series Service --- + series-service: + image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/series-service:${DOCKER_TAG:-latest}" + build: + context: . + dockerfile: backend/services/series/series-service/Dockerfile + args: + GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.3.1}" + JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}" + VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}" + BUILD_DATE: "${DOCKER_BUILD_DATE}" + labels: + - "org.opencontainers.image.created=${DOCKER_BUILD_DATE}" + container_name: "${PROJECT_NAME:-meldestelle}-series-service" + restart: unless-stopped + ports: + - "${SERIES_PORT:-8089:8089}" + - "${SERIES_DEBUG_PORT:-5011:5011}" + environment: + SPRING_PROFILES_ACTIVE: "${SERIES_SPRING_PROFILES_ACTIVE:-docker}" + DEBUG: "${SERIES_DEBUG:-true}" + SERVER_PORT: "${SERIES_SERVER_PORT:-8089}" + + # --- KEYCLOAK --- + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://localhost:8180/realms/meldestelle}" + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}" + + # --- DATASOURCE --- + SPRING_DATASOURCE_URL: "${DATASOURCE_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}" + SPRING_DATASOURCE_USERNAME: "${DATASOURCE_USERNAME:-meldestelle}" + SPRING_DATASOURCE_PASSWORD: "${DATASOURCE_PASSWORD:-meldestelle}" + SPRING_DATASOURCE_DRIVER_CLASS_NAME: "org.postgresql.Driver" + + # --- CONSUL --- + SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}" + SPRING_CLOUD_CONSUL_PORT: "${CONSUL_PORT:-8500}" + + depends_on: + postgres: + condition: service_healthy + consul: + condition: service_started + networks: + meldestelle-network: + aliases: + - "series-service" + profiles: [ "backend", "all" ] + volumes: + - ./config/app/base-application.yaml:/workspace/config/application.yml:Z + networks: meldestelle-network: driver: bridge diff --git a/docs/04_Agents/Logs/2026-04-12_Series_Integration_Curator_Log.md b/docs/04_Agents/Logs/2026-04-12_Series_Integration_Curator_Log.md new file mode 100644 index 00000000..bc7e01f0 --- /dev/null +++ b/docs/04_Agents/Logs/2026-04-12_Series_Integration_Curator_Log.md @@ -0,0 +1,32 @@ +# 🧹 [Curator] Log - 2026-04-12 (Phase 10 & 11: Backend & Series Integration) + +## Status +- **Phase 10 (Series-Context):** 🏗️ In Progress (Backend & Frontend-Skeleton ready) +- **Phase 11 (Ergebniserfassung):** ✅ Completed (Backend & Frontend integrated) + +## Heute erledigt +- **Results-Service (Backend):** + - Vollständige Implementierung der Business-Logik: + - `Ergebnis` JPA Entity & Repository. + - `calculatePlatzierung` mit Sortier-Logik (Wertnote -> Zeit -> Fehler). + - `exportPdf` Placeholder-Endpunkt. + - REST-Controller für alle CRUD und Business-Operationen. +- **Series-Service (Backend):** + - Initialisierung eines neuen Microservices: + - `Serie` und `SeriePunkt` JPA Entities. + - Aggregations-Logik für Cup-Zwischenstände pro Reiter/Pferd-Paar. + - Docker-Integration (`dc-backend.yaml`) und API-Gateway Routing. +- **Frontend Integration (Series):** + - `SeriesRepository` und `DefaultSeriesRepository` (Ktor) implementiert. + - `SeriesViewModel` mit `androidx.lifecycle` State-Management erstellt. + - `SeriesScreen` funktionalisiert: Anzeige von Serien-Listen und dynamische Abfrage von Zwischenständen. + - Koin-DI-Konfiguration im `turnier-feature` vervollständigt. + +## Verifikation +- Kompilierung des `turnier-feature` erfolgreich (`BUILD SUCCESSFUL`). +- Gateway-Routing für `/api/v1/results` und `/api/v1/series` verifiziert. +- Datenmodell für Serien-Punktebildung entspricht den ÖTO-Anforderungen (Paar-Bindung). + +## Nächste Schritte +- Implementierung der automatischen Punkte-Gutschrift im `series-service`, wenn ein Ergebnis im `results-service` finalisiert wird. +- Ausbau der PDF-Generierung für Ergebnislisten (Phase 11.2). diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt index 40fb145c..4f70864d 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/ApiRoutes.kt @@ -36,4 +36,9 @@ object ApiRoutes { const val ROOT = "/api/v1/results" fun bewerb(bewerbId: String) = "$ROOT/bewerb/$bewerbId" } + + object Series { + const val ROOT = "/api/v1/series" + fun stand(serieId: String) = "$ROOT/$serieId/stand" + } } diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/SeriesRepository.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/SeriesRepository.kt new file mode 100644 index 00000000..cb1adc5e --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/domain/SeriesRepository.kt @@ -0,0 +1,30 @@ +package at.mocode.turnier.feature.domain + +import kotlinx.serialization.Serializable + +@Serializable +data class Serie( + val id: String? = null, + val name: String, + val beschreibung: String? = null, + val reglementTyp: String = "STREICHER_NORMAL", + val bewerbIds: Set = emptySet() +) + +@Serializable +data class SeriePunkt( + val id: String? = null, + val serieId: String, + val reiterId: String, + val pferdId: String, + val bewerbId: String, + val punkte: Double, + val platzierung: Int +) + +interface SeriesRepository { + suspend fun getAll(): Result> + suspend fun getById(id: String): Result + suspend fun save(serie: Serie): Result + suspend fun getStand(serieId: String): Result> // Einfache Map Reiter+Pferd ID zu Punkten +} diff --git a/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/SeriesViewModel.kt b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/SeriesViewModel.kt new file mode 100644 index 00000000..84f24b16 --- /dev/null +++ b/frontend/features/turnier-feature/src/commonMain/kotlin/at/mocode/turnier/feature/presentation/SeriesViewModel.kt @@ -0,0 +1,59 @@ +package at.mocode.turnier.feature.presentation + +import at.mocode.turnier.feature.domain.Serie +import at.mocode.turnier.feature.domain.SeriesRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope + +data class SeriesState( + val series: List = emptyList(), + val isLoading: Boolean = false, + val selectedSerieStand: Map = emptyMap(), + val error: String? = null +) + +class SeriesViewModel( + private val repository: SeriesRepository +) : ViewModel() { + + private val _state = MutableStateFlow(SeriesState()) + val state = _state.asStateFlow() + + init { + loadSeries() + } + + fun loadSeries() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true) + repository.getAll() + .onSuccess { series -> + _state.value = _state.value.copy(series = series, isLoading = false) + } + .onFailure { + _state.value = _state.value.copy(error = it.message, isLoading = false) + } + } + } + + fun selectSerie(id: String) { + viewModelScope.launch { + repository.getStand(id) + .onSuccess { stand -> + _state.value = _state.value.copy(selectedSerieStand = stand) + } + } + } + + fun createSerie(name: String) { + viewModelScope.launch { + repository.save(Serie(name = name)) + .onSuccess { + loadSeries() + } + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultSeriesRepository.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultSeriesRepository.kt new file mode 100644 index 00000000..7cb1bd34 --- /dev/null +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/data/remote/DefaultSeriesRepository.kt @@ -0,0 +1,40 @@ +package at.mocode.turnier.feature.data.remote + +import at.mocode.frontend.core.network.ApiRoutes +import at.mocode.turnier.feature.domain.Serie +import at.mocode.turnier.feature.domain.SeriesRepository +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class DefaultSeriesRepository( + private val client: HttpClient +) : SeriesRepository { + + override suspend fun getAll(): Result> = runCatching { + client.get(ApiRoutes.Series.ROOT).body() + } + + override suspend fun getById(id: String): Result = runCatching { + client.get("${ApiRoutes.Series.ROOT}/$id").body() + } + + override suspend fun save(serie: Serie): Result = runCatching { + if (serie.id == null) { + client.post(ApiRoutes.Series.ROOT) { + contentType(ContentType.Application.Json) + setBody(serie) + }.body() + } else { + client.put("${ApiRoutes.Series.ROOT}/${serie.id}") { + contentType(ContentType.Application.Json) + setBody(serie) + }.body() + } + } + + override suspend fun getStand(serieId: String): Result> = runCatching { + client.get(ApiRoutes.Series.stand(serieId)).body() + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt index 9257f88d..51fb8ea1 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/di/TurnierFeatureModule.kt @@ -19,6 +19,7 @@ val turnierFeatureModule = module { single { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) } single { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) } single { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) } + single { DefaultSeriesRepository(client = get(qualifier = named("apiClient"))) } // ViewModels factory { TurnierViewModel(repo = get()) } @@ -46,4 +47,5 @@ val turnierFeatureModule = module { turnierId = turnierId ) } + factory { SeriesViewModel(repository = get()) } } diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesScreen.kt index 6323b292..d9a30b4a 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesScreen.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/SeriesScreen.kt @@ -1,27 +1,31 @@ package at.mocode.turnier.feature.presentation import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.compose.viewmodel.koinViewModel private val SeriesBlue = Color(0xFF1E3A8A) /** * SERIES-Screen gemäß Vision_03 & Phase 10. - * - * Zeigt Cups, Serien und Meisterschaften mit konfigurierbaren Reglements. */ @Composable fun SeriesScreen( title: String, - onBack: () -> Unit + onBack: () -> Unit, + viewModel: SeriesViewModel = koinViewModel() ) { + val state by viewModel.state.collectAsState() + Column(modifier = Modifier.fillMaxSize()) { // Toolbar Row( @@ -34,7 +38,7 @@ fun SeriesScreen( Text("Konfiguration & Auswertung (Phase 10)", fontSize = 13.sp, color = Color.Gray) } Button( - onClick = { /* Neu anlegen Dialog */ }, + onClick = { viewModel.createSerie("Neuer Cup") }, colors = ButtonDefaults.buttonColors(containerColor = SeriesBlue) ) { Text("Neue Serie anlegen") @@ -43,23 +47,61 @@ fun SeriesScreen( HorizontalDivider() - // Leere Liste (Placeholder) - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium) - Spacer(Modifier.height(8.dp)) - Text( - "Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.", - fontSize = 13.sp, - color = Color.Gray, - modifier = Modifier.padding(horizontal = 32.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(Modifier.height(24.dp)) - OutlinedButton(onClick = onBack) { - Text("Zurück zur Verwaltung") + if (state.series.isEmpty()) { + EmptyState(title, onBack) + } else { + SeriesList(state, onSelect = { viewModel.selectSerie(it) }) + } + } +} + +@Composable +private fun SeriesList(state: SeriesState, onSelect: (String) -> Unit) { + Row(Modifier.fillMaxSize()) { + LazyColumn(Modifier.weight(0.4f).padding(16.dp)) { + items(state.series) { serie -> + Card( + onClick = { serie.id?.let { onSelect(it) } }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) { + Column(Modifier.padding(12.dp)) { + Text(serie.name, fontWeight = FontWeight.Bold) + Text(serie.reglementTyp, fontSize = 12.sp) + } + } + } + } + VerticalDivider() + Column(Modifier.weight(0.6f).padding(16.dp)) { + Text("Zwischenstand", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + state.selectedSerieStand.forEach { (paar, punkte) -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(paar) + Text("$punkte Pkt", fontWeight = FontWeight.Bold) } } } } } + +@Composable +private fun EmptyState(title: String, onBack: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(8.dp)) + Text( + "Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.", + fontSize = 13.sp, + color = Color.Gray, + modifier = Modifier.padding(horizontal = 32.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + Spacer(Modifier.height(24.dp)) + OutlinedButton(onClick = onBack) { + Text("Zurück zur Verwaltung") + } + } + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt index d41f0ba5..445f05d1 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierErgebnislistenTab.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import at.mocode.turnier.feature.domain.Bewerb import at.mocode.turnier.feature.domain.Ergebnis import org.koin.compose.koinInject