feat(entries-domain): implement competition services, repository, and validations for ÖTO compliance

- Added `CompetitionRepository` with domain operations for Bewerb and Abteilung.
- Implemented `AbteilungsRegelService` for ÖTO § 39 rules and structural validations.
- Introduced `CompetitionWarningService` to handle threshold warnings for starters and structural requirements.
- Created test suites (`AbteilungsRegelServiceTest`, `DomBewerbTest`) to verify compliance and validations.
- Updated dependencies and build configuration for repository integration.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-30 16:27:28 +02:00
parent c5c1e96d25
commit 499673c9fb
6 changed files with 378 additions and 0 deletions

View File

@ -10,6 +10,7 @@ kotlin {
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
@ -17,6 +18,7 @@ kotlin {
commonTest {
kotlin.srcDir("src/test/kotlin")
dependencies {
implementation(kotlin("test"))
implementation(projects.platform.platformTesting)
}
}

View File

@ -0,0 +1,24 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.repository
import at.mocode.entries.domain.model.DomAbteilung
import at.mocode.entries.domain.model.DomBewerb
import kotlin.uuid.Uuid
/**
* Repository-Interface für DomBewerb und DomAbteilung Domain-Operationen.
*/
interface CompetitionRepository {
// Bewerbe
suspend fun findBewerbById(id: Uuid): DomBewerb?
suspend fun findBewerbeByTurnierId(turnierId: Uuid): List<DomBewerb>
suspend fun saveBewerb(bewerb: DomBewerb): DomBewerb
suspend fun deleteBewerb(id: Uuid): Boolean
// Abteilungen
suspend fun findAbteilungById(id: Uuid): DomAbteilung?
suspend fun findAbteilungenByBewerbId(bewerbId: Uuid): List<DomAbteilung>
suspend fun saveAbteilung(abteilung: DomAbteilung): DomAbteilung
suspend fun deleteAbteilung(id: Uuid): Boolean
}

View File

@ -0,0 +1,102 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.service
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.PruefungsTypE
import at.mocode.entries.domain.model.DomAbteilung
import at.mocode.entries.domain.model.DomBewerb
import at.mocode.masterdata.domain.model.DomReiter
/**
* Service für die Anwendung der Abteilungs-Regeln gemäß ÖTO § 39.
*
* Die Abteilung ist die kleinste operative Einheit. Dieser Service hilft dabei,
* Nennungen den richtigen Abteilungen zuzuordnen und Bewerbe auf strukturelle
* Vollständigkeit zu prüfen.
*/
class AbteilungsRegelService {
/**
* Bestimmt die passende Abteilung für einen Reiter in einem Bewerb.
*
* Regeln gemäß § 39:
* - Wenn [AbteilungsTeilungsTypE.NACH_LIZENZ]:
* - Reiter ohne Lizenz -> Abteilung mit Kennzeichnung "lizenzfrei" oder niedrigste Nummer.
* - Reiter mit Lizenz (R1, R2, ...) -> Abteilung mit entsprechender Kennzeichnung.
* - Wenn [AbteilungsTeilungsTypE.STRUKTURELL] (z.B. CSN-C-NEU):
* - Strikte Trennung nach Lizenz/Alter gemäß Sonderbestimmungen.
*
* @param bewerb Der betroffene Bewerb.
* @param abteilungen Liste der verfügbaren Abteilungen des Bewerbs.
* @param reiter Der Reiter, der genannt werden soll.
* @return Die passende [DomAbteilung] oder null, wenn keine Zuordnung eindeutig möglich ist.
*/
fun bestimmeAbteilung(
bewerb: DomBewerb,
abteilungen: List<DomAbteilung>,
reiter: DomReiter
): DomAbteilung? {
if (abteilungen.isEmpty()) return null
if (abteilungen.size == 1) return abteilungen.first()
return when (bewerb.teilungsTyp) {
AbteilungsTeilungsTypE.NACH_LIZENZ -> {
val istLizenzfrei = reiter.lizenzKlasse == LizenzKlasseE.LIZENZFREI
if (istLizenzfrei) {
// Suche Abteilung für Lizenzfreie (oft Abt. 1 oder explizit benannt)
abteilungen.find { it.bezeichnung?.contains("frei", ignoreCase = true) == true }
?: abteilungen.minByOrNull { it.abteilungsNummer }
} else {
// Suche Abteilung für Lizenzreiter (oft Abt. 2 oder explizit benannt)
abteilungen.find {
val bezeichnung = it.bezeichnung?.lowercase() ?: ""
bezeichnung.contains("lizenz") && !bezeichnung.contains("frei")
} ?: abteilungen.maxByOrNull { it.abteilungsNummer }
}
}
AbteilungsTeilungsTypE.STRUKTURELL -> {
// Bei strukturellen Teilungen (z.B. Caprilli oder CSN-C-NEU)
// Hier müsste eine detailliertere Prüfung der bewerb.pruefungsTyp erfolgen.
if (bewerb.pruefungsTyp == PruefungsTypE.CAPRILLI) {
val istLizenzfrei = reiter.lizenzKlasse == LizenzKlasseE.LIZENZFREI
if (istLizenzfrei) abteilungen.find { it.abteilungsNummer == 1 }
else abteilungen.find { it.abteilungsNummer == 2 }
} else {
abteilungen.firstOrNull()
}
}
else -> abteilungen.firstOrNull()
}
}
/**
* Prüft, ob ein Bewerb alle notwendigen Abteilungen gemäß ÖTO/Ausschreibung hat.
*
* Beispiel CSN-C-NEU: Ein Bewerb muss zwingend eine Abteilung für lizenzfreie Reiter haben.
*/
fun validateStrukturelleVollstaendigkeit(
bewerb: DomBewerb,
abteilungen: List<DomAbteilung>
): List<String> {
val warnings = mutableListOf<String>()
if (bewerb.teilungsTyp == AbteilungsTeilungsTypE.STRUKTURELL) {
if (abteilungen.size < 2) {
warnings.add("WARN_BEWERB_STRUKTURELLE_TEILUNG_FEHLT: Bewerb ${bewerb.getDisplayName()} erfordert mindestens zwei Abteilungen.")
}
}
// Pflicht-Teilung ab 80 Startern (§ 39 Abs. 2)
val gesamtStarter = abteilungen.sumOf { it.starterAnzahl }
val limit = bewerb.getPflichtTeilungsSchwellenwert() ?: 80
if (gesamtStarter > limit && abteilungen.size == 1) {
warnings.add("WARN_BEWERB_PFLICHT_TEILUNG_ERFORDERLICH: Bewerb ${bewerb.getDisplayName()} hat $gesamtStarter Starter. Teilung in mind. 2 Abteilungen verpflichtend.")
}
return warnings
}
}

View File

@ -0,0 +1,70 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.service
import at.mocode.entries.domain.repository.CompetitionRepository
import kotlin.uuid.Uuid
/**
* Service für das Warn-System im Competition-Context.
*
* Überwacht Schwellenwerte für Starterzahlen und strukturelle Vorgaben gemäß ÖTO.
*/
class CompetitionWarningService(
private val competitionRepository: CompetitionRepository,
private val abteilungsRegelService: AbteilungsRegelService
) {
/**
* Validiert ein gesamtes Turnier auf Abteilungs-Warnungen.
*
* @return Eine Map von Bewerb-ID zu einer Liste von Warnmeldungen.
*/
suspend fun validateTurnier(turnierId: Uuid): Map<Uuid, List<String>> {
val bewerbe = competitionRepository.findBewerbeByTurnierId(turnierId)
val result = mutableMapOf<Uuid, List<String>>()
for (bewerb in bewerbe) {
val abteilungen = competitionRepository.findAbteilungenByBewerbId(bewerb.bewerbId)
val warnings = mutableListOf<String>()
// 1. Bewerbs-Ebene Schwellenwerte (z.B. Dressur-Kann-Teilung)
val gesamtStarter = abteilungen.sumOf { it.starterAnzahl }
warnings.addAll(bewerb.validateAbteilungsSchwellenwerte(gesamtStarter))
// 2. Strukturelle Vollständigkeit (§ 39 / Sonderbestimmungen)
warnings.addAll(abteilungsRegelService.validateStrukturelleVollstaendigkeit(bewerb, abteilungen))
// 3. Abteilungs-Ebene Starter-Limits (z.B. > 80 Starter)
for (abt in abteilungen) {
warnings.addAll(abt.validateStarterLimit())
}
if (warnings.isNotEmpty()) {
result[bewerb.bewerbId] = warnings
}
}
return result
}
/**
* Validiert einen einzelnen Bewerb und gibt Warnungen zurück.
*/
suspend fun validateBewerb(bewerbId: Uuid): List<String> {
val bewerb = competitionRepository.findBewerbById(bewerbId) ?: return emptyList()
val abteilungen = competitionRepository.findAbteilungenByBewerbId(bewerbId)
val warnings = mutableListOf<String>()
val gesamtStarter = abteilungen.sumOf { it.starterAnzahl }
warnings.addAll(bewerb.validateAbteilungsSchwellenwerte(gesamtStarter))
warnings.addAll(abteilungsRegelService.validateStrukturelleVollstaendigkeit(bewerb, abteilungen))
for (abt in abteilungen) {
warnings.addAll(abt.validateStarterLimit())
}
return warnings
}
}

View File

@ -0,0 +1,80 @@
package at.mocode.entries.domain.model
import at.mocode.core.domain.model.PruefungsTypE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
class DomBewerbTest {
@Test
fun `getPflichtTeilungsSchwellenwert liefert korrekte Werte fuer alle PruefungsTypen`() {
val baseBewerb = DomBewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
bezeichnung = "Test",
sparte = SparteE.SPRINGEN,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = PruefungsTypE.SPRINGEN_UEBRIG
)
assertEquals(80, baseBewerb.getPflichtTeilungsSchwellenwert())
assertEquals(30, baseBewerb.copy(pruefungsTyp = PruefungsTypE.STIL_SPRINGEN).getPflichtTeilungsSchwellenwert())
assertEquals(30, baseBewerb.copy(pruefungsTyp = PruefungsTypE.SPRINGPFERDE).getPflichtTeilungsSchwellenwert())
assertEquals(30, baseBewerb.copy(pruefungsTyp = PruefungsTypE.DRESSURPFERDE).getPflichtTeilungsSchwellenwert())
assertEquals(40, baseBewerb.copy(pruefungsTyp = PruefungsTypE.VIELSEITIGKEIT).getPflichtTeilungsSchwellenwert())
assertEquals(null, baseBewerb.copy(pruefungsTyp = PruefungsTypE.DRESSUR).getPflichtTeilungsSchwellenwert())
}
@Test
fun `getPflichtTeilungsSchwellenwert liefert null fuer Meisterschaftsbewerbe`() {
val meisterschaft = DomBewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
bezeichnung = "Meisterschaft",
sparte = SparteE.SPRINGEN,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = PruefungsTypE.SPRINGEN_UEBRIG,
istMeisterschaft = true
)
assertEquals(null, meisterschaft.getPflichtTeilungsSchwellenwert())
}
@Test
fun `validateAbteilungsSchwellenwerte gibt Warnung bei Ueberschreitung des Pflicht-Schwellenwerts`() {
val bewerb = DomBewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
bezeichnung = "Springprüfung",
sparte = SparteE.SPRINGEN,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = PruefungsTypE.SPRINGEN_UEBRIG
)
val warnings = bewerb.validateAbteilungsSchwellenwerte(81)
assertEquals(1, warnings.size)
assertTrue(warnings[0].contains("WARN_ABTEILUNG_PFLICHT_TEILUNG_UEBERSCHRITTEN"))
}
@Test
fun `validateAbteilungsSchwellenwerte gibt Warnung bei Dressur-Kann-Teilung`() {
val bewerb = DomBewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
bezeichnung = "Dressurprüfung",
sparte = SparteE.DRESSUR,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = PruefungsTypE.DRESSUR
)
val warnings = bewerb.validateAbteilungsSchwellenwerte(31)
assertEquals(1, warnings.size)
assertTrue(warnings[0].contains("WARN_ABTEILUNG_KANN_TEILUNG_EMPFOHLEN"))
}
}

View File

@ -0,0 +1,100 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.service
import at.mocode.core.domain.model.*
import at.mocode.entries.domain.model.DomAbteilung
import at.mocode.entries.domain.model.DomBewerb
import at.mocode.masterdata.domain.model.DomReiter
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.uuid.Uuid
class AbteilungsRegelServiceTest {
private val service = AbteilungsRegelService()
@Test
fun `bestimmeAbteilung waehlt die einzige Abteilung aus`() {
val bewerb = createBewerb()
val abteilung = createAbteilung(bewerb.bewerbId, 1)
val result = service.bestimmeAbteilung(bewerb, listOf(abteilung), createReiter())
assertEquals(abteilung.abteilungId, result?.abteilungId)
}
@Test
fun `bestimmeAbteilung waehlt bei Lizenz-Teilung die richtige Abteilung fuer lizenzfreie Reiter`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.NACH_LIZENZ)
val abt1 = createAbteilung(bewerb.bewerbId, 1, "lizenzfrei")
val abt2 = createAbteilung(bewerb.bewerbId, 2, "R1 und hoeher")
val reiter = createReiter(lizenzKlasse = LizenzKlasseE.LIZENZFREI)
val result = service.bestimmeAbteilung(bewerb, listOf(abt1, abt2), reiter)
assertEquals(abt1.abteilungId, result?.abteilungId)
}
@Test
fun `bestimmeAbteilung waehlt bei Lizenz-Teilung die richtige Abteilung fuer Lizenzreiter`() {
val bewerb = createBewerb(teilungsTyp = AbteilungsTeilungsTypE.NACH_LIZENZ)
val abt1 = createAbteilung(bewerb.bewerbId, 1, "lizenzfrei")
val abt2 = createAbteilung(bewerb.bewerbId, 2, "Lizenz")
val reiter = createReiter(lizenzKlasse = LizenzKlasseE.R1)
val result = service.bestimmeAbteilung(bewerb, listOf(abt1, abt2), reiter)
assertEquals(abt2.abteilungId, result?.abteilungId)
}
@Test
fun `validateStrukturelleVollstaendigkeit warnt bei fehlender Teilung trotz hoher Starterzahl`() {
val bewerb = createBewerb(pruefungsTyp = PruefungsTypE.SPRINGEN_UEBRIG)
val abt1 = createAbteilung(bewerb.bewerbId, 1, starterAnzahl = 81)
val warnings = service.validateStrukturelleVollstaendigkeit(bewerb, listOf(abt1))
assertEquals(1, warnings.size)
assertTrue(warnings[0].contains("WARN_BEWERB_PFLICHT_TEILUNG_ERFORDERLICH"))
}
private fun createBewerb(
pruefungsTyp: PruefungsTypE = PruefungsTypE.SPRINGEN_UEBRIG,
teilungsTyp: AbteilungsTeilungsTypE = AbteilungsTeilungsTypE.KEINE
) = DomBewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
bezeichnung = "Testbewerb",
sparte = SparteE.SPRINGEN,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = pruefungsTyp,
teilungsTyp = teilungsTyp
)
private fun createAbteilung(
bewerbId: Uuid,
nummer: Int,
bezeichnung: String? = null,
starterAnzahl: Int = 0
) = DomAbteilung(
bewerbId = bewerbId,
abteilungsNummer = nummer,
bezeichnung = bezeichnung,
starterAnzahl = starterAnzahl
)
private fun createReiter(lizenzKlasse: LizenzKlasseE = LizenzKlasseE.LIZENZFREI) = DomReiter(
personId = Uuid.random(),
satznummer = "123456",
nachname = "Mustermann",
vorname = "Max",
lizenzKlasse = lizenzKlasse
)
private fun assertTrue(condition: Boolean, message: String? = null) {
kotlin.test.assertTrue(condition, message)
}
}