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.
This commit is contained in:
+6
-1
@@ -14,7 +14,8 @@ class GatewayConfig(
|
|||||||
@Value("\${masterdata.service.url:http://localhost:8086}") private val masterdataServiceUrl: String,
|
@Value("\${masterdata.service.url:http://localhost:8086}") private val masterdataServiceUrl: String,
|
||||||
@Value("\${events.service.url:http://localhost:8085}") private val eventsServiceUrl: 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("\${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
|
@Bean
|
||||||
@@ -47,6 +48,10 @@ class GatewayConfig(
|
|||||||
path("/api/v1/results/**")
|
path("/api/v1/results/**")
|
||||||
uri(resultsServiceUrl)
|
uri(resultsServiceUrl)
|
||||||
}
|
}
|
||||||
|
route(id = "series-service") {
|
||||||
|
path("/api/v1/series/**")
|
||||||
|
uri(seriesServiceUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -1,9 +1,10 @@
|
|||||||
package at.mocode.results.service
|
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.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.bind.annotation.RestController
|
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
class ResultsServiceApplication
|
class ResultsServiceApplication
|
||||||
@@ -13,7 +14,27 @@ fun main(args: Array<String>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
class ResultsController {
|
@RequestMapping("/api/v1/results")
|
||||||
|
class ResultsController(
|
||||||
|
private val service: ResultsService
|
||||||
|
) {
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
fun health(): String = "Results Service is running"
|
fun health(): String = "Results Service is running"
|
||||||
|
|
||||||
|
@GetMapping("/bewerb/{bewerbId}")
|
||||||
|
fun getForBewerb(@PathVariable bewerbId: String): List<Ergebnis> = 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<Ergebnis> = service.calculatePlatzierung(bewerbId)
|
||||||
|
|
||||||
|
@GetMapping("/bewerb/{bewerbId}/pdf")
|
||||||
|
fun exportPdf(@PathVariable bewerbId: String): ByteArray = service.generatePdf(bewerbId)
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -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<Ergebnis> = repository.findByBewerbId(bewerbId)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun saveErgebnis(ergebnis: Ergebnis): Ergebnis = repository.save(ergebnis)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun calculatePlatzierung(bewerbId: String): List<Ergebnis> {
|
||||||
|
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<Ergebnis> { 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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
|
||||||
|
}
|
||||||
+10
@@ -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<Ergebnis, String> {
|
||||||
|
fun findByBewerbId(bewerbId: String): List<Ergebnis>
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+41
@@ -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<String>) {
|
||||||
|
runApplication<SeriesServiceApplication>(*args)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/series")
|
||||||
|
class SeriesController(
|
||||||
|
private val service: SeriesService
|
||||||
|
) {
|
||||||
|
@GetMapping("/")
|
||||||
|
fun health(): String = "Series Service is running"
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun getAll(): List<Serie> = 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -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<Serie> = 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<Pair<String, String>, 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)
|
||||||
|
}
|
||||||
+57
@@ -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<String> = 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
|
||||||
|
)
|
||||||
+14
@@ -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<Serie, String>
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface JpaSeriePunktRepository : JpaRepository<SeriePunkt, String> {
|
||||||
|
fun findBySerieId(serieId: String): List<SeriePunkt>
|
||||||
|
}
|
||||||
@@ -466,6 +466,56 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z
|
- ./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:
|
networks:
|
||||||
meldestelle-network:
|
meldestelle-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -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).
|
||||||
+5
@@ -36,4 +36,9 @@ object ApiRoutes {
|
|||||||
const val ROOT = "/api/v1/results"
|
const val ROOT = "/api/v1/results"
|
||||||
fun bewerb(bewerbId: String) = "$ROOT/bewerb/$bewerbId"
|
fun bewerb(bewerbId: String) = "$ROOT/bewerb/$bewerbId"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object Series {
|
||||||
|
const val ROOT = "/api/v1/series"
|
||||||
|
fun stand(serieId: String) = "$ROOT/$serieId/stand"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
@@ -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<String> = 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<List<Serie>>
|
||||||
|
suspend fun getById(id: String): Result<Serie>
|
||||||
|
suspend fun save(serie: Serie): Result<Serie>
|
||||||
|
suspend fun getStand(serieId: String): Result<Map<String, Double>> // Einfache Map Reiter+Pferd ID zu Punkten
|
||||||
|
}
|
||||||
+59
@@ -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<Serie> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val selectedSerieStand: Map<String, Double> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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<List<Serie>> = runCatching {
|
||||||
|
client.get(ApiRoutes.Series.ROOT).body()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getById(id: String): Result<Serie> = runCatching {
|
||||||
|
client.get("${ApiRoutes.Series.ROOT}/$id").body()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(serie: Serie): Result<Serie> = 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<Map<String, Double>> = runCatching {
|
||||||
|
client.get(ApiRoutes.Series.stand(serieId)).body()
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -19,6 +19,7 @@ val turnierFeatureModule = module {
|
|||||||
single<at.mocode.turnier.feature.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
|
single<at.mocode.turnier.feature.domain.NennungRepository> { DefaultNennungRepository(client = get(qualifier = named("apiClient"))) }
|
||||||
single<at.mocode.turnier.feature.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
|
single<at.mocode.turnier.feature.domain.MasterdataRepository> { DefaultMasterdataRepository(client = get(qualifier = named("apiClient"))) }
|
||||||
single<at.mocode.turnier.feature.domain.ErgebnisRepository> { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) }
|
single<at.mocode.turnier.feature.domain.ErgebnisRepository> { DefaultErgebnisRepository(client = get(qualifier = named("apiClient"))) }
|
||||||
|
single<at.mocode.turnier.feature.domain.SeriesRepository> { DefaultSeriesRepository(client = get(qualifier = named("apiClient"))) }
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
factory { TurnierViewModel(repo = get()) }
|
factory { TurnierViewModel(repo = get()) }
|
||||||
@@ -46,4 +47,5 @@ val turnierFeatureModule = module {
|
|||||||
turnierId = turnierId
|
turnierId = turnierId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
factory { SeriesViewModel(repository = get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-20
@@ -1,27 +1,31 @@
|
|||||||
package at.mocode.turnier.feature.presentation
|
package at.mocode.turnier.feature.presentation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
private val SeriesBlue = Color(0xFF1E3A8A)
|
private val SeriesBlue = Color(0xFF1E3A8A)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SERIES-Screen gemäß Vision_03 & Phase 10.
|
* SERIES-Screen gemäß Vision_03 & Phase 10.
|
||||||
*
|
|
||||||
* Zeigt Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesScreen(
|
fun SeriesScreen(
|
||||||
title: String,
|
title: String,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit,
|
||||||
|
viewModel: SeriesViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Toolbar
|
// Toolbar
|
||||||
Row(
|
Row(
|
||||||
@@ -34,7 +38,7 @@ fun SeriesScreen(
|
|||||||
Text("Konfiguration & Auswertung (Phase 10)", fontSize = 13.sp, color = Color.Gray)
|
Text("Konfiguration & Auswertung (Phase 10)", fontSize = 13.sp, color = Color.Gray)
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { /* Neu anlegen Dialog */ },
|
onClick = { viewModel.createSerie("Neuer Cup") },
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = SeriesBlue)
|
colors = ButtonDefaults.buttonColors(containerColor = SeriesBlue)
|
||||||
) {
|
) {
|
||||||
Text("Neue Serie anlegen")
|
Text("Neue Serie anlegen")
|
||||||
@@ -43,23 +47,61 @@ fun SeriesScreen(
|
|||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
// Leere Liste (Placeholder)
|
if (state.series.isEmpty()) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
EmptyState(title, onBack)
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
} else {
|
||||||
Text("Keine $title konfiguriert", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
SeriesList(state, onSelect = { viewModel.selectSerie(it) })
|
||||||
Spacer(Modifier.height(8.dp))
|
}
|
||||||
Text(
|
}
|
||||||
"Verknüpfe Bewerbe zu einer Serie, um Punktestände automatisch zu berechnen.",
|
}
|
||||||
fontSize = 13.sp,
|
|
||||||
color = Color.Gray,
|
@Composable
|
||||||
modifier = Modifier.padding(horizontal = 32.dp),
|
private fun SeriesList(state: SeriesState, onSelect: (String) -> Unit) {
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
Row(Modifier.fillMaxSize()) {
|
||||||
)
|
LazyColumn(Modifier.weight(0.4f).padding(16.dp)) {
|
||||||
Spacer(Modifier.height(24.dp))
|
items(state.series) { serie ->
|
||||||
OutlinedButton(onClick = onBack) {
|
Card(
|
||||||
Text("Zurück zur Verwaltung")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-1
@@ -12,7 +12,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import at.mocode.turnier.feature.domain.Bewerb
|
|
||||||
import at.mocode.turnier.feature.domain.Ergebnis
|
import at.mocode.turnier.feature.domain.Ergebnis
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user