diff --git a/backend/services/entries/entries-domain/build.gradle.kts b/backend/services/entries/entries-domain/build.gradle.kts index e6adb015..40e0ee8a 100644 --- a/backend/services/entries/entries-domain/build.gradle.kts +++ b/backend/services/entries/entries-domain/build.gradle.kts @@ -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) } } diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/CompetitionRepository.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/CompetitionRepository.kt new file mode 100644 index 00000000..0ae35d00 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/CompetitionRepository.kt @@ -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 + 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 + suspend fun saveAbteilung(abteilung: DomAbteilung): DomAbteilung + suspend fun deleteAbteilung(id: Uuid): Boolean +} diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/AbteilungsRegelService.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/AbteilungsRegelService.kt new file mode 100644 index 00000000..2ce5f797 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/AbteilungsRegelService.kt @@ -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, + 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 + ): List { + val warnings = mutableListOf() + + 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 + } +} diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/CompetitionWarningService.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/CompetitionWarningService.kt new file mode 100644 index 00000000..a0f494b9 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/CompetitionWarningService.kt @@ -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> { + val bewerbe = competitionRepository.findBewerbeByTurnierId(turnierId) + val result = mutableMapOf>() + + for (bewerb in bewerbe) { + val abteilungen = competitionRepository.findAbteilungenByBewerbId(bewerb.bewerbId) + val warnings = mutableListOf() + + // 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 { + val bewerb = competitionRepository.findBewerbById(bewerbId) ?: return emptyList() + val abteilungen = competitionRepository.findAbteilungenByBewerbId(bewerbId) + + val warnings = mutableListOf() + 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 + } +} diff --git a/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/model/DomBewerbTest.kt b/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/model/DomBewerbTest.kt new file mode 100644 index 00000000..fc22a78e --- /dev/null +++ b/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/model/DomBewerbTest.kt @@ -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")) + } +} diff --git a/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/service/AbteilungsRegelServiceTest.kt b/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/service/AbteilungsRegelServiceTest.kt new file mode 100644 index 00000000..aecb73a0 --- /dev/null +++ b/backend/services/entries/entries-domain/src/test/kotlin/at/mocode/entries/domain/service/AbteilungsRegelServiceTest.kt @@ -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) + } +}