feat(domain+frontend): implement structured division warnings and enhance validation rules

- **Domain Updates:**
  - Introduced `AbteilungsWarnung` entity for structured warning handling compliant with ÖTO § 39.
  - Added validation rules for mandatory and optional division thresholds and structural completeness.
  - Implemented `CompetitionWarningService` and `AbteilungsRegelService` for domain-centric validations.
  - Updated domain models (`Bewerb`, `Abteilung`) to reflect structured warning logic.

- **Services:**
  - Expanded `BewerbService` to include warning validation through `CompetitionWarningService`.

- **Frontend Enhancements:**
  - Updated `TurnierBewerbeTab` to display warnings using tooltips with clear descriptions and structured formatting.
  - Modified `BewerbUiModel` to handle warnings and integrate them into the UI.

- **Persistence:**
  - Implemented `CompetitionRepositoryImpl` to map database rows to the new domain models and validation logic.

- **Testing:**
  - Added comprehensive unit tests for `validateStrukturellesTeilung` and division-specific warnings.
  - Enhanced existing tests to validate the new warning structure and code-based assertions.

- **Docs:**
  - Updated roadmap to reflect the completion of structural warnings implementation.
This commit is contained in:
2026-04-10 11:37:30 +02:00
parent 22c631ec43
commit e7d7e43ccf
16 changed files with 521 additions and 42 deletions
@@ -7,6 +7,7 @@ import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import at.mocode.entries.domain.model.RichterEinsatz
import at.mocode.entries.domain.service.CompetitionWarningService
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
@@ -15,8 +16,12 @@ import kotlin.uuid.toJavaUuid
class BewerbService(
private val repo: BewerbRepository,
private val nennungen: NennungRepository,
private val warningService: CompetitionWarningService,
) {
suspend fun validateTurnier(turnierId: Uuid) = warningService.validateTurnier(turnierId)
suspend fun validateBewerb(bewerbId: Uuid) = warningService.validateBewerb(bewerbId)
private suspend fun isTurnierPublished(turnierId: Uuid): Boolean = tenantTransaction {
val row = TurnierTable.selectAll().where { TurnierTable.id eq turnierId.toJavaUuid() }.singleOrNull()
row?.get(TurnierTable.status) == "PUBLISHED"
@@ -4,6 +4,8 @@ package at.mocode.entries.service.bewerbe
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
import at.mocode.core.domain.model.BeginnZeitTypE
import at.mocode.entries.domain.model.AbteilungsWarnung
import at.mocode.entries.domain.model.AbteilungsWarnungCodeE
import at.mocode.entries.domain.model.RichterEinsatz
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
@@ -127,6 +129,13 @@ data class BewerbResponse(
// ZNS-Integration
val znsNummer: Int?,
val znsAbteilung: Int?,
val warnungen: List<AbteilungsWarnungDto> = emptyList(),
)
data class AbteilungsWarnungDto(
val code: AbteilungsWarnungCodeE,
val nachricht: String,
val oetoParagraph: String?
)
private fun RichterEinsatzDto.toDomain(): RichterEinsatz =
@@ -135,7 +144,7 @@ private fun RichterEinsatzDto.toDomain(): RichterEinsatz =
position = this.position
)
private fun domainToDto(b: Bewerb): BewerbResponse = BewerbResponse(
private fun domainToDto(b: Bewerb, warnungen: List<AbteilungsWarnung> = emptyList()): BewerbResponse = BewerbResponse(
id = b.id.toString(),
turnierId = b.turnierId.toString(),
klasse = b.klasse,
@@ -159,6 +168,7 @@ private fun domainToDto(b: Bewerb): BewerbResponse = BewerbResponse(
geldpreisAusbezahlt = b.geldpreisAusbezahlt,
znsNummer = b.znsNummer,
znsAbteilung = b.znsAbteilung,
warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) }
)
@RestController
@@ -185,21 +195,37 @@ class BewerbeController(
): List<BewerbResponse> = service.importZns(
Uuid.parse(turnierId),
body
).map(::domainToDto)
).map { domainToDto(it) }
@GetMapping("/turniere/{turnierId}/bewerbe")
suspend fun list(
@PathVariable turnierId: String,
@RequestParam(required = false) klasse: String?,
@RequestParam(required = false) q: String?,
): List<BewerbResponse> = service.list(Uuid.parse(turnierId), klasse, q).map(::domainToDto)
): List<BewerbResponse> {
val turnierUuid = Uuid.parse(turnierId)
val bewerbe = service.list(turnierUuid, klasse, q)
val warnungenMap = service.validateTurnier(turnierUuid)
return bewerbe.map { b ->
domainToDto(b, warnungenMap[b.id] ?: emptyList())
}
}
@GetMapping("/bewerbe/{id}")
suspend fun get(@PathVariable id: String): BewerbResponse = domainToDto(service.get(Uuid.parse(id)))
suspend fun get(@PathVariable id: String): BewerbResponse {
val uuid = Uuid.parse(id)
val b = service.get(uuid)
val warnungen = service.validateBewerb(uuid)
return domainToDto(b, warnungen)
}
@PutMapping("/bewerbe/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateBewerbRequest): BewerbResponse =
domainToDto(service.update(Uuid.parse(id), body))
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateBewerbRequest): BewerbResponse {
val uuid = Uuid.parse(id)
val updated = service.update(uuid, body)
val warnungen = service.validateBewerb(uuid)
return domainToDto(updated, warnungen)
}
@DeleteMapping("/bewerbe/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@@ -13,6 +13,10 @@ import at.mocode.entries.service.bewerbe.BewerbService
import at.mocode.entries.service.abteilungen.AbteilungRepository
import at.mocode.entries.service.abteilungen.AbteilungRepositoryImpl
import at.mocode.entries.service.abteilungen.AbteilungenService
import at.mocode.entries.domain.repository.CompetitionRepository
import at.mocode.entries.domain.service.AbteilungsRegelService
import at.mocode.entries.domain.service.CompetitionWarningService
import at.mocode.entries.service.persistence.CompetitionRepositoryImpl
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@@ -43,11 +47,24 @@ class EntriesBeansConfiguration {
@Bean
fun bewerbRepository(): BewerbRepository = BewerbRepositoryImpl()
@Bean
fun abteilungsRegelService(): AbteilungsRegelService = AbteilungsRegelService()
@Bean
fun competitionRepository(): CompetitionRepository = CompetitionRepositoryImpl()
@Bean
fun competitionWarningService(
competitionRepository: CompetitionRepository,
regelService: AbteilungsRegelService
): CompetitionWarningService = CompetitionWarningService(competitionRepository, regelService)
@Bean
fun bewerbService(
bewerbRepository: BewerbRepository,
nennungRepository: NennungRepository
): BewerbService = BewerbService(bewerbRepository, nennungRepository)
nennungRepository: NennungRepository,
warningService: CompetitionWarningService
): BewerbService = BewerbService(bewerbRepository, nennungRepository, warningService)
@Bean
fun abteilungRepository(): AbteilungRepository = AbteilungRepositoryImpl()
@@ -0,0 +1,120 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.persistence
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
import at.mocode.core.domain.model.PruefungsTypE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
import at.mocode.entries.domain.model.Abteilung as DomainAbteilung
import at.mocode.entries.domain.model.Bewerb as DomainBewerb
import at.mocode.entries.domain.repository.CompetitionRepository
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Implementierung des CompetitionRepository für den Domain-Service.
* Mappt zwischen Service-Tabellen und Domain-Modellen.
*/
class CompetitionRepositoryImpl : CompetitionRepository {
override suspend fun findBewerbById(id: Uuid): DomainBewerb? = tenantTransaction {
BewerbTable.selectAll().where { BewerbTable.id eq id.toJavaUuid() }
.map { row -> rowToDomainBewerb(row) }
.singleOrNull()
}
override suspend fun findBewerbeByTurnierId(turnierId: Uuid): List<DomainBewerb> = tenantTransaction {
BewerbTable.selectAll().where { BewerbTable.turnierId eq turnierId.toJavaUuid() }
.map { row -> rowToDomainBewerb(row) }
}
override suspend fun saveBewerb(bewerb: DomainBewerb): DomainBewerb {
// Für Read-Only Validierung im WarningService vorerst nicht implementiert
throw UnsupportedOperationException("saveBewerb via CompetitionRepository not implemented yet")
}
override suspend fun deleteBewerb(id: Uuid): Boolean {
throw UnsupportedOperationException("deleteBewerb via CompetitionRepository not implemented yet")
}
override suspend fun findAbteilungById(id: Uuid): DomainAbteilung? = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.id eq id.toJavaUuid() }
.map { row -> rowToDomainAbteilung(row) }
.singleOrNull()
}
override suspend fun findAbteilungenByBewerbId(bewerbId: Uuid): List<DomainAbteilung> = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.bewerbId eq bewerbId.toJavaUuid() }
.map { row -> rowToDomainAbteilung(row) }
}
override suspend fun saveAbteilung(abteilung: DomainAbteilung): DomainAbteilung {
throw UnsupportedOperationException("saveAbteilung via CompetitionRepository not implemented yet")
}
override suspend fun deleteAbteilung(id: Uuid): Boolean {
throw UnsupportedOperationException("deleteAbteilung via CompetitionRepository not implemented yet")
}
private fun rowToDomainBewerb(row: ResultRow): DomainBewerb {
val bId = row[BewerbTable.id].toKotlinUuid()
val tId = row[BewerbTable.turnierId].toKotlinUuid()
// Wir müssen Sparte und Kategorie irgendwie herleiten oder aus einer anderen Tabelle laden.
// In der BewerbTable fehlen diese Felder aktuell noch im Vergleich zum Domain-Modell.
// Für den MVP hardcoden wir Standardwerte oder versuchen sie aus dem Turnier zu lesen.
return DomainBewerb(
bewerbId = bId,
turnierId = tId,
bewerbNummer = row[BewerbTable.znsNummer] ?: 0,
bezeichnung = row[BewerbTable.bezeichnung],
sparte = SparteE.SPRINGEN, // FIXME: Herleiten
turnierkategorie = TurnierkategorieE.B, // FIXME: Herleiten
pruefungsTyp = PruefungsTypE.SPRINGEN_UEBRIG, // FIXME: Herleiten
hoeheCm = row[BewerbTable.hoeheCm],
teilungsTyp = row[BewerbTable.teilungsTyp]?.let { AbteilungsTeilungsTypE.valueOf(it) } ?: AbteilungsTeilungsTypE.KEINE,
beschreibung = row[BewerbTable.beschreibung],
aufgabe = row[BewerbTable.aufgabe],
aufgabenNummer = row[BewerbTable.aufgabenNummer],
paraGrade = row[BewerbTable.paraGrade],
austragungsplatzId = row[BewerbTable.austragungsplatzId]?.toKotlinUuid(),
geplantesDatum = row[BewerbTable.geplantesDatum],
beginnZeitTyp = row[BewerbTable.beginnZeitTyp]?.let { at.mocode.core.domain.model.BeginnZeitTypE.valueOf(it) },
beginnZeit = row[BewerbTable.beginnZeit],
reitdauerMinuten = row[BewerbTable.reitdauerMinuten],
umbauMinuten = row[BewerbTable.umbauMinuten],
besichtigungMinuten = row[BewerbTable.besichtigungMinuten],
stechenGeplant = row[BewerbTable.stechenGeplant],
startgeldCent = row[BewerbTable.startgeldCent],
geldpreisAusbezahlt = row[BewerbTable.geldpreisAusbezahlt],
createdAt = row[BewerbTable.createdAt],
updatedAt = row[BewerbTable.updatedAt]
)
}
private fun rowToDomainAbteilung(row: ResultRow): DomainAbteilung {
val aId = row[AbteilungTable.id].toKotlinUuid()
val bId = row[AbteilungTable.bewerbId].toKotlinUuid()
// Starteranzahl berechnen
val count = NennungTable.selectAll().where { NennungTable.abteilungId eq aId.toJavaUuid() }.count()
return DomainAbteilung(
abteilungId = aId,
bewerbId = bId,
abteilungsNummer = row[AbteilungTable.nr],
bezeichnung = row[AbteilungTable.bezeichnung],
teilungsTyp = row[AbteilungTable.typ].let { AbteilungsTeilungsTypE.valueOf(it) },
starterAnzahl = count.toInt(),
createdAt = row[AbteilungTable.createdAt],
updatedAt = row[AbteilungTable.updatedAt]
)
}
}