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:
parent
c5c1e96d25
commit
499673c9fb
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user