feat(masterdata): introduce Reiter-Sparte persistence, services, and validations

- Added `ReiterSparteTable` to manage rider-discipline associations.
- Introduced services and tests for `LicenseMatrix`, `Altersklasse`, and `AbteilungsRegel` with domain logic and validations for ÖTO compliance.
- Enhanced `ExposedReiterRepository` to save and query `Reiter` disciplines efficiently.
- Implemented database migration script `V007__Cleanup_Initial_Tables_and_Add_Sparte.sql`.
- Updated `MasterdataDatabaseConfiguration` to include `ReiterSparteTable` in the schema initialization.
- Expanded test coverage with new cases for eligibility checks, age group determinations, and splitting regulations.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-03-30 14:47:11 +02:00
parent e8757c5c32
commit d8c9d11adb
17 changed files with 871 additions and 41 deletions

View File

@ -132,9 +132,9 @@
## Nächste konkrete Schritte (2Wochen SprintPlan)
1. [x] ADRs für ImporterEinbettung, RuleVersionierung, API-Schichten abschließen (🏗️)
2. Exposed-Tabellen vervollständigen und in `SchemaUtils.create`/Migrationen registrieren (👷)
3. UseCases: Altersklasse, LizenzMatrix, AbteilungsRegeln inkl. UnitTests (👷🧐)
4. ZNSImporter an Repositories anbinden, Idempotenz-Checks ergänzen, MiniZNS Testlauf (👷🧐)
2. [x] Exposed-Tabellen vervollständigen und in `SchemaUtils.create`/Migrationen registrieren (👷)
3. [x] UseCases: Altersklasse, LizenzMatrix, AbteilungsRegeln inkl. UnitTests (👷🧐)
4. [ ] ZNSImporter an Repositories anbinden, Idempotenz-Checks ergänzen, MiniZNS Testlauf (👷🧐) *
5. API v1 Endpunkte + OpenAPI, ContractTests (👷🧐)
6. Observability-Grundlagen (Metriken + Dashboards) (🐧)
7. Curator: Docs aktualisieren, Runbooks und Changelogs pflegen (🧹)

View File

@ -0,0 +1,33 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.domain.model
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domänenmodell für Gebühren gemäß ÖTO oder Veranstaltervorgabe.
*/
@Serializable
data class GebuehrDefinition(
@Serializable(with = UuidSerializer::class)
val gebuehrId: Uuid = Uuid.random(),
val bezeichnung: String,
val typ: String, // NENNUNG, STARTGELD, BOX, STALLGELD, SONSTIGES
val betrag: Double,
val waehrung: String = "EUR",
@Serializable(with = InstantSerializer::class)
val validFrom: Instant,
@Serializable(with = InstantSerializer::class)
val validTo: Instant? = null,
val istAktiv: Boolean = true,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)

View File

@ -0,0 +1,34 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.domain.model
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domänenmodell für die Lizenz-Matrix (Reiter-Lizenz vs. maximal erlaubte Turnierklasse).
*/
@Serializable
data class LicenseMatrixEntry(
@Serializable(with = UuidSerializer::class)
val licenseId: Uuid = Uuid.random(),
val sparte: SparteE,
val lizenzKlasse: LizenzKlasseE,
val maxTurnierklasseCode: String, // E, A, L, LM, M, S
@Serializable(with = InstantSerializer::class)
val validFrom: Instant,
@Serializable(with = InstantSerializer::class)
val validTo: Instant? = null,
val istAktiv: Boolean = true,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)

View File

@ -0,0 +1,32 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.domain.model
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domänenmodell für allgemeine Regelkonfigurationen.
*/
@Serializable
data class RegulationConfig(
@Serializable(with = UuidSerializer::class)
val configId: Uuid = Uuid.random(),
val key: String,
val value: String,
val beschreibung: String? = null,
@Serializable(with = InstantSerializer::class)
val validFrom: Instant,
@Serializable(with = InstantSerializer::class)
val validTo: Instant? = null,
val istAktiv: Boolean = true,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)

View File

@ -0,0 +1,34 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domänenmodell für Richtverfahren gemäß ÖTO.
*/
@Serializable
data class RichtverfahrenDefinition(
@Serializable(with = UuidSerializer::class)
val richtverfahrenId: Uuid = Uuid.random(),
val sparte: SparteE,
val code: String, // A1, A2, AM5, RV_A, RV_B
val bezeichnung: String,
val beschreibung: String? = null,
@Serializable(with = InstantSerializer::class)
val validFrom: Instant,
@Serializable(with = InstantSerializer::class)
val validTo: Instant? = null,
val istAktiv: Boolean = true,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)

View File

@ -0,0 +1,35 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domänenmodell für eine Turnierklasse gemäß ÖTO.
*/
@Serializable
data class TurnierklasseDefinition(
@Serializable(with = UuidSerializer::class)
val turnierklasseId: Uuid = Uuid.random(),
val sparte: SparteE,
val code: String, // E, A, L, LM, M, S
val bezeichnung: String,
val maxHoehe: Int? = null, // in cm (Springen)
val aufgabenNiveau: String? = null, // (Dressur)
@Serializable(with = InstantSerializer::class)
val validFrom: Instant,
@Serializable(with = InstantSerializer::class)
val validTo: Instant? = null,
val istAktiv: Boolean = true,
@Serializable(with = InstantSerializer::class)
val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)

View File

@ -0,0 +1,53 @@
package at.mocode.masterdata.domain.service
import at.mocode.masterdata.domain.model.DomPferd
import at.mocode.masterdata.domain.model.DomReiter
/**
* Service zur Prüfung von Abteilungs-Regeln gemäß ÖTO § 39.
*/
interface AbteilungsRegelService {
/**
* Prüft, ob eine strukturelle Teilung (unabhängig von der Starterzahl) erforderlich ist.
* Gemäß § 39 A-Teil:
* - Klassen A & L: Trennung nach R1/RD1 (Abt. 1) und höher (Abt. 2+).
* - CSN-C-NEU (bis 95cm): Abt. 1 (lizenzfrei), Abt. 2 (R1), Abt. 3 (R2+).
*
* @param reiter Der Reiter.
* @param pferd Das Pferd.
* @param turnierklasseCode Der Code der Turnierklasse (E, A, L, ...).
* @param sparte Die Sparte (DRESSUR, SPRINGEN).
* @param istCNeu Ob es sich um ein C-NEU Turnier handelt.
* @param hoehe Bei Springen: Die Hindernishöhe in cm.
* @return Die Abteilungsnummer (1, 2, 3), in die der Teilnehmer fällt.
*/
fun ermittleAbteilungStrukturell(
reiter: DomReiter,
pferd: DomPferd,
turnierklasseCode: String,
sparte: at.mocode.core.domain.model.SparteE,
istCNeu: Boolean = false,
hoehe: Int? = null
): Int
/**
* Prüft, ob eine kapazitive Teilung (aufgrund der Starterzahl) erforderlich ist.
* Gemäß § 39 A-Teil:
* - Standard-Springen: > 80 Starter.
* - Stil- & Springpferdeprüfungen: > 30 Starter.
* - Dressur: > 30 Starter (Empfehlung/Warnung).
*
* @param starterAnzahl Aktuelle Anzahl der Nennungen/Starter.
* @param turnierklasseCode Der Code der Turnierklasse.
* @param sparte Die Sparte.
* @param istStilOderJungpferdePruefung Ob es sich um eine Stil- oder Jungpferdeprüfung handelt.
* @return true, wenn eine Teilung MUSS oder SOLLTE (Warnung).
*/
fun istTeilungErforderlich(
starterAnzahl: Int,
turnierklasseCode: String,
sparte: at.mocode.core.domain.model.SparteE,
istStilOderJungpferdePruefung: Boolean = false
): Boolean
}

View File

@ -0,0 +1,73 @@
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.DomPferd
import at.mocode.masterdata.domain.model.DomReiter
/**
* Standard-Implementierung des [AbteilungsRegelService] gemäß ÖTO § 39.
*/
class AbteilungsRegelServiceImpl : AbteilungsRegelService {
override fun ermittleAbteilungStrukturell(
reiter: DomReiter,
pferd: DomPferd,
turnierklasseCode: String,
sparte: SparteE,
istCNeu: Boolean,
hoehe: Int?
): Int {
// Gemäß § 39 A-Teil / 3.1 Strukturelle Teilung
// Fall 1: CSN-C-NEU (Spezialregeln)
if (istCNeu && sparte == SparteE.SPRINGEN) {
if (hoehe != null && hoehe <= 95) {
return when (reiter.lizenzKlasse) {
LizenzKlasseE.LIZENZFREI -> 1
LizenzKlasseE.R1, LizenzKlasseE.RD1 -> 2
else -> 3 // R2+
}
} else if (hoehe != null && hoehe >= 100) {
return when (reiter.lizenzKlasse) {
LizenzKlasseE.R1, LizenzKlasseE.RD1 -> 1
else -> 2 // R2+
}
}
}
// Fall 2: Klassen A & L (Standardregelung § 39 Abs. 1)
if (turnierklasseCode == "A" || turnierklasseCode == "L") {
return when (reiter.lizenzKlasse) {
LizenzKlasseE.R1, LizenzKlasseE.RD1 -> 1 // Abt. 1: R1
else -> 2 // Abt. 2+: R2 und höher
}
}
// Default: Keine strukturelle Teilung (Abt. 1)
return 1
}
override fun istTeilungErforderlich(
starterAnzahl: Int,
turnierklasseCode: String,
sparte: SparteE,
istStilOderJungpferdePruefung: Boolean
): Boolean {
// Gemäß § 39 A-Teil / 3.2 Kapazitive Teilung
if (sparte == SparteE.SPRINGEN) {
return if (istStilOderJungpferdePruefung) {
starterAnzahl > 30 // MUSS
} else {
starterAnzahl > 80 // MUSS
}
}
if (sparte == SparteE.DRESSUR) {
return starterAnzahl > 30 // KANN (System gibt Warnung)
}
return false
}
}

View File

@ -0,0 +1,44 @@
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.DomReiter
import at.mocode.masterdata.domain.model.LicenseMatrixEntry
import at.mocode.masterdata.domain.model.TurnierklasseDefinition
/**
* Service zur Prüfung der Teilnahmeberechtigung basierend auf der Lizenz-Matrix.
*/
interface LicenseMatrixService {
/**
* Prüft, ob ein Reiter mit seiner aktuellen Lizenz in einer bestimmten Turnierklasse startberechtigt ist.
*
* @param reiter Der Reiter, dessen Berechtigung geprüft werden soll.
* @param turnierklasse Die Turnierklasse (E, A, L, LM, M, S), in der gestartet werden soll.
* @param sparte Die Sparte des Bewerbs.
* @param matrix Die aktuelle Lizenz-Matrix (Regulation-as-Data).
* @param alleKlassen Alle verfügbaren Turnierklassen-Definitionen zur Code-Validierung.
* @return true, wenn der Reiter startberechtigt ist, sonst false.
*/
fun isEligible(
reiter: DomReiter,
turnierklasse: TurnierklasseDefinition,
sparte: SparteE,
matrix: List<LicenseMatrixEntry>,
alleKlassen: List<TurnierklasseDefinition>
): Boolean
/**
* Ermittelt die maximal erlaubte Turnierklasse für einen Reiter in einer Sparte.
*
* @param reiter Der Reiter.
* @param sparte Die Sparte.
* @param matrix Die aktuelle Lizenz-Matrix.
* @return Der Code der maximal erlaubten Turnierklasse oder null, wenn keine Regel gefunden wurde.
*/
fun getMaxTurnierklasse(
reiter: DomReiter,
sparte: SparteE,
matrix: List<LicenseMatrixEntry>
): String?
}

View File

@ -0,0 +1,48 @@
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.DomReiter
import at.mocode.masterdata.domain.model.LicenseMatrixEntry
import at.mocode.masterdata.domain.model.TurnierklasseDefinition
/**
* Standard-Implementierung des [LicenseMatrixService] gemäß ÖTO.
*/
class LicenseMatrixServiceImpl : LicenseMatrixService {
private val classHierarchy = listOf("E", "A", "L", "LM", "M", "S")
override fun isEligible(
reiter: DomReiter,
turnierklasse: TurnierklasseDefinition,
sparte: SparteE,
matrix: List<LicenseMatrixEntry>,
alleKlassen: List<TurnierklasseDefinition>
): Boolean {
// 1. Basis-Check: Hat der Reiter überhaupt eine Lizenz für diese Sparte?
if (!reiter.hasLizenzForSparte(sparte)) return false
// 2. Max Turnierklasse aus Matrix ermitteln
val maxClassCode = getMaxTurnierklasse(reiter, sparte, matrix) ?: return false
// 3. Hierarchie-Check (maxClassCode vs. turnierklasse.code)
val maxIndex = classHierarchy.indexOf(maxClassCode)
val targetIndex = classHierarchy.indexOf(turnierklasse.code)
if (maxIndex == -1 || targetIndex == -1) return false
return targetIndex <= maxIndex
}
override fun getMaxTurnierklasse(
reiter: DomReiter,
sparte: SparteE,
matrix: List<LicenseMatrixEntry>
): String? {
// Suche passenden Eintrag in der Matrix für (Sparte, Lizenzklasse)
val entry = matrix.find { it.sparte == sparte && it.lizenzKlasse == reiter.lizenzKlasse }
?: matrix.find { it.sparte == SparteE.DRESSUR && sparte == SparteE.DRESSUR && it.lizenzKlasse == reiter.lizenzKlasse } // Fallback/Spezial
return entry?.maxTurnierklasseCode
}
}

View File

@ -0,0 +1,110 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.DomPferd
import at.mocode.masterdata.domain.model.DomReiter
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.uuid.Uuid
class AbteilungsRegelServiceTest {
private val service = AbteilungsRegelServiceImpl()
private val standardPferd = DomPferd(pferdeName = "Testpferd", geschlecht = PferdeGeschlechtE.WALLACH)
private val dummyPersonId = Uuid.random()
@Test
fun `ermittleAbteilungStrukturell teilt Klassen A und L nach R1`() {
val r1Reiter = DomReiter(
personId = dummyPersonId,
satznummer = "1",
nachname = "R1",
vorname = "R1",
lizenzKlasse = LizenzKlasseE.R1
)
val r2Reiter = DomReiter(
personId = dummyPersonId,
satznummer = "2",
nachname = "R2",
vorname = "R2",
lizenzKlasse = LizenzKlasseE.R2
)
assertEquals(1, service.ermittleAbteilungStrukturell(r1Reiter, standardPferd, "A", SparteE.SPRINGEN))
assertEquals(2, service.ermittleAbteilungStrukturell(r2Reiter, standardPferd, "A", SparteE.SPRINGEN))
assertEquals(1, service.ermittleAbteilungStrukturell(r1Reiter, standardPferd, "L", SparteE.SPRINGEN))
assertEquals(2, service.ermittleAbteilungStrukturell(r2Reiter, standardPferd, "L", SparteE.SPRINGEN))
}
@Test
fun `ermittleAbteilungStrukturell berücksichtigt C-NEU Regeln`() {
val lfReiter = DomReiter(
personId = dummyPersonId,
satznummer = "0",
nachname = "LF",
vorname = "LF",
lizenzKlasse = LizenzKlasseE.LIZENZFREI
)
val r1Reiter = DomReiter(
personId = dummyPersonId,
satznummer = "1",
nachname = "R1",
vorname = "R1",
lizenzKlasse = LizenzKlasseE.R1
)
val r2Reiter = DomReiter(
personId = dummyPersonId,
satznummer = "2",
nachname = "R2",
vorname = "R2",
lizenzKlasse = LizenzKlasseE.R2
)
// Bis 95cm
assertEquals(
1,
service.ermittleAbteilungStrukturell(lfReiter, standardPferd, "E", SparteE.SPRINGEN, istCNeu = true, hoehe = 90)
)
assertEquals(
2,
service.ermittleAbteilungStrukturell(r1Reiter, standardPferd, "E", SparteE.SPRINGEN, istCNeu = true, hoehe = 90)
)
assertEquals(
3,
service.ermittleAbteilungStrukturell(r2Reiter, standardPferd, "E", SparteE.SPRINGEN, istCNeu = true, hoehe = 90)
)
// Ab 100cm
assertEquals(
1,
service.ermittleAbteilungStrukturell(r1Reiter, standardPferd, "A", SparteE.SPRINGEN, istCNeu = true, hoehe = 100)
)
assertEquals(
2,
service.ermittleAbteilungStrukturell(r2Reiter, standardPferd, "A", SparteE.SPRINGEN, istCNeu = true, hoehe = 100)
)
}
@Test
fun `istTeilungErforderlich prüft Starterzahlen`() {
// Springen Standard
assertFalse(service.istTeilungErforderlich(80, "A", SparteE.SPRINGEN))
assertTrue(service.istTeilungErforderlich(81, "A", SparteE.SPRINGEN))
// Springen Stil / Jungpferde
assertFalse(service.istTeilungErforderlich(30, "A", SparteE.SPRINGEN, istStilOderJungpferdePruefung = true))
assertTrue(service.istTeilungErforderlich(31, "A", SparteE.SPRINGEN, istStilOderJungpferdePruefung = true))
// Dressur
assertFalse(service.istTeilungErforderlich(30, "A", SparteE.DRESSUR))
assertTrue(service.istTeilungErforderlich(31, "A", SparteE.DRESSUR))
}
}

View File

@ -0,0 +1,113 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.AltersklasseDefinition
import at.mocode.masterdata.domain.model.DomReiter
import kotlinx.datetime.LocalDate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.uuid.Uuid
class AltersklasseRechnerTest {
private val rechner = AltersklasseRechnerImpl()
@Test
fun `berechneOetoAlter berechnet korrektes Alter am 31_12_`() {
val geb = LocalDate(2010, 5, 15)
assertEquals(16, rechner.berechneOetoAlter(geb, 2026))
val gebSilvester = LocalDate(2010, 12, 31)
assertEquals(16, rechner.berechneOetoAlter(gebSilvester, 2026))
val gebNeujahr = LocalDate(2011, 1, 1)
assertEquals(15, rechner.berechneOetoAlter(gebNeujahr, 2026))
}
@Test
fun `ermittleAltersklassen findet passende Definitionen`() {
val reiter = DomReiter(
personId = Uuid.random(),
satznummer = "123456",
nachname = "Mustermann",
vorname = "Max",
geburtsdatum = LocalDate(2010, 1, 1) // 16 Jahre in 2026
)
val nun = Clock.System.now()
val definitionen = listOf(
AltersklasseDefinition(
altersklasseCode = "JG",
bezeichnung = "Jugend",
minAlter = 8,
maxAlter = 15,
createdAt = nun,
updatedAt = nun
),
AltersklasseDefinition(
altersklasseCode = "JN",
bezeichnung = "Junioren",
minAlter = 16,
maxAlter = 18,
createdAt = nun,
updatedAt = nun
),
AltersklasseDefinition(
altersklasseCode = "AK",
bezeichnung = "Allg. Klasse",
minAlter = 19,
createdAt = nun,
updatedAt = nun
)
)
val ergebnis = rechner.ermittleAltersklassen(reiter, 2026, SparteE.SPRINGEN, definitionen)
assertEquals(1, ergebnis.size)
assertEquals("JN", ergebnis[0].altersklasseCode)
}
@Test
fun `ermittleAltersklassen berücksichtigt SpartenFilter`() {
val reiter = DomReiter(
personId = Uuid.random(),
satznummer = "123456",
nachname = "Mustermann",
vorname = "Max",
geburtsdatum = LocalDate(2013, 1, 1) // 13 Jahre in 2026
)
val nun = Clock.System.now()
val definitionen = listOf(
AltersklasseDefinition(
altersklasseCode = "CH_D",
bezeichnung = "Children Dressur",
minAlter = 12,
maxAlter = 14,
sparteFilter = SparteE.DRESSUR,
createdAt = nun,
updatedAt = nun
),
AltersklasseDefinition(
altersklasseCode = "JG",
bezeichnung = "Jugend",
minAlter = 8,
maxAlter = 15,
createdAt = nun,
updatedAt = nun
)
)
val ergebnisDressur = rechner.ermittleAltersklassen(reiter, 2026, SparteE.DRESSUR, definitionen)
assertEquals(2, ergebnisDressur.size)
assertTrue(ergebnisDressur.any { it.altersklasseCode == "CH_D" })
val ergebnisSpringen = rechner.ermittleAltersklassen(reiter, 2026, SparteE.SPRINGEN, definitionen)
assertEquals(1, ergebnisSpringen.size)
assertEquals("JG", ergebnisSpringen[0].altersklasseCode)
}
}

View File

@ -0,0 +1,128 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.DomReiter
import at.mocode.masterdata.domain.model.LicenseMatrixEntry
import at.mocode.masterdata.domain.model.TurnierklasseDefinition
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.uuid.Uuid
class LicenseMatrixServiceTest {
private val service = LicenseMatrixServiceImpl()
private val nun = Clock.System.now()
private val matrix = listOf(
LicenseMatrixEntry(
sparte = SparteE.SPRINGEN,
lizenzKlasse = LizenzKlasseE.R1,
maxTurnierklasseCode = "L",
validFrom = nun,
createdAt = nun,
updatedAt = nun
),
LicenseMatrixEntry(
sparte = SparteE.SPRINGEN,
lizenzKlasse = LizenzKlasseE.R2,
maxTurnierklasseCode = "M",
validFrom = nun,
createdAt = nun,
updatedAt = nun
),
LicenseMatrixEntry(
sparte = SparteE.DRESSUR,
lizenzKlasse = LizenzKlasseE.RD1,
maxTurnierklasseCode = "L",
validFrom = nun,
createdAt = nun,
updatedAt = nun
)
)
private val turnierklassen = listOf(
TurnierklasseDefinition(
sparte = SparteE.SPRINGEN,
code = "E",
bezeichnung = "E",
validFrom = nun,
createdAt = nun,
updatedAt = nun
),
TurnierklasseDefinition(
sparte = SparteE.SPRINGEN,
code = "A",
bezeichnung = "A",
validFrom = nun,
createdAt = nun,
updatedAt = nun
),
TurnierklasseDefinition(
sparte = SparteE.SPRINGEN,
code = "L",
bezeichnung = "L",
validFrom = nun,
createdAt = nun,
updatedAt = nun
),
TurnierklasseDefinition(
sparte = SparteE.SPRINGEN,
code = "LM",
bezeichnung = "LM",
validFrom = nun,
createdAt = nun,
updatedAt = nun
),
TurnierklasseDefinition(
sparte = SparteE.SPRINGEN,
code = "M",
bezeichnung = "M",
validFrom = nun,
createdAt = nun,
updatedAt = nun
)
)
@Test
fun `isEligible erlaubt Starts bis zum Limit`() {
val r1Reiter = DomReiter(
personId = Uuid.random(),
satznummer = "1",
nachname = "R1",
vorname = "Reiter",
lizenzKlasse = LizenzKlasseE.R1,
lizenzSparten = listOf(SparteE.SPRINGEN),
startkartAktiv = true
)
val klasseA = turnierklassen.find { it.code == "A" }!!
val klasseL = turnierklassen.find { it.code == "L" }!!
val klasseM = turnierklassen.find { it.code == "M" }!!
assertTrue(service.isEligible(r1Reiter, klasseA, SparteE.SPRINGEN, matrix, turnierklassen))
assertTrue(service.isEligible(r1Reiter, klasseL, SparteE.SPRINGEN, matrix, turnierklassen))
assertFalse(service.isEligible(r1Reiter, klasseM, SparteE.SPRINGEN, matrix, turnierklassen))
}
@Test
fun `isEligible verweigert Start ohne passende Spartenlizenz`() {
val rd1Reiter = DomReiter(
personId = Uuid.random(),
satznummer = "2",
nachname = "RD1",
vorname = "Reiter",
lizenzKlasse = LizenzKlasseE.RD1,
lizenzSparten = listOf(SparteE.DRESSUR), // Nur Dressur
startkartAktiv = true
)
val klasseA = turnierklassen.find { it.code == "A" }!!
assertFalse(service.isEligible(rd1Reiter, klasseA, SparteE.SPRINGEN, matrix, turnierklassen))
}
}

View File

@ -20,7 +20,7 @@ import kotlin.uuid.Uuid
*/
class ExposedReiterRepository : ReiterRepository {
private fun rowToDomReiter(row: ResultRow): DomReiter {
private fun rowToDomReiter(row: ResultRow, sparten: List<SparteE> = emptyList()): DomReiter {
return DomReiter(
reiterId = row[ReiterTable.id],
personId = row[ReiterTable.personId],
@ -30,6 +30,7 @@ class ExposedReiterRepository : ReiterRepository {
geburtsdatum = row[ReiterTable.geburtsdatum],
lizenzNummer = row[ReiterTable.lizenzNummer],
lizenzKlasse = LizenzKlasseE.valueOf(row[ReiterTable.lizenzKlasse]),
lizenzSparten = sparten,
startkartAktiv = row[ReiterTable.startkartAktiv],
startkartSaison = row[ReiterTable.startkartSaison],
feiId = row[ReiterTable.feiId],
@ -44,21 +45,32 @@ class ExposedReiterRepository : ReiterRepository {
)
}
private fun getSpartenForReiter(reiterId: Uuid): List<SparteE> {
return ReiterSparteTable.selectAll().where { ReiterSparteTable.reiterId eq reiterId }
.map { SparteE.valueOf(it[ReiterSparteTable.sparte]) }
}
override suspend fun findById(id: Uuid): DomReiter? = DatabaseFactory.dbQuery {
ReiterTable.selectAll().where { ReiterTable.id eq id }
.map(::rowToDomReiter)
.map { rowToDomReiter(it, getSpartenForReiter(id)) }
.singleOrNull()
}
override suspend fun findBySatznummer(satznummer: String): DomReiter? = DatabaseFactory.dbQuery {
ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer }
.map(::rowToDomReiter)
.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
.singleOrNull()
}
override suspend fun findByFeiId(feiId: String): DomReiter? = DatabaseFactory.dbQuery {
ReiterTable.selectAll().where { ReiterTable.feiId eq feiId }
.map(::rowToDomReiter)
.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
.singleOrNull()
}
@ -66,7 +78,10 @@ class ExposedReiterRepository : ReiterRepository {
val pattern = "%$searchTerm%"
ReiterTable.selectAll().where { (ReiterTable.nachname like pattern) or (ReiterTable.vorname like pattern) }
.limit(limit)
.map(::rowToDomReiter)
.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
}
override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List<DomReiter> =
@ -75,7 +90,10 @@ class ExposedReiterRepository : ReiterRepository {
if (activeOnly) {
query.andWhere { ReiterTable.istAktiv eq true }
}
query.map(::rowToDomReiter)
query.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
}
override suspend fun findByLizenzKlasse(lizenzKlasse: LizenzKlasseE, activeOnly: Boolean): List<DomReiter> =
@ -84,14 +102,22 @@ class ExposedReiterRepository : ReiterRepository {
if (activeOnly) {
query.andWhere { ReiterTable.istAktiv eq true }
}
query.map(::rowToDomReiter)
query.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
}
override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List<DomReiter> = DatabaseFactory.dbQuery {
// Da wir in ReiterTable keinen sparteFilter haben, müssen wir ggf. über eine andere Tabelle gehen
// oder die Logik anpassen. Fürs erste geben wir eine leere Liste zurück oder suchen nach Name in Lizenz?
// TODO: Implementierung prüfen, falls Sparten-Lizenzierung in eigener Tabelle liegt.
emptyList()
val query = (ReiterTable innerJoin ReiterSparteTable)
.selectAll().where { ReiterSparteTable.sparte eq sparte.name }
if (activeOnly) {
query.andWhere { ReiterTable.istAktiv eq true }
}
query.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
}
override suspend fun findGastreiter(activeOnly: Boolean): List<DomReiter> = DatabaseFactory.dbQuery {
@ -99,19 +125,28 @@ class ExposedReiterRepository : ReiterRepository {
if (activeOnly) {
query.andWhere { ReiterTable.istAktiv eq true }
}
query.map(::rowToDomReiter)
query.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
}
override suspend fun findAllActive(limit: Int, offset: Int): List<DomReiter> = DatabaseFactory.dbQuery {
ReiterTable.selectAll().where { ReiterTable.istAktiv eq true }
.limit(limit).offset(offset.toLong())
.map(::rowToDomReiter)
.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
}
override suspend fun findAll(limit: Int, offset: Int): List<DomReiter> = DatabaseFactory.dbQuery {
ReiterTable.selectAll()
.limit(limit).offset(offset.toLong())
.map(::rowToDomReiter)
.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
}
override suspend fun save(reiter: DomReiter): DomReiter = DatabaseFactory.dbQuery {
@ -136,7 +171,6 @@ class ExposedReiterRepository : ReiterRepository {
it[datenQuelle] = reiter.datenQuelle.name
it[updatedAt] = reiter.updatedAt
}
reiter
} else {
ReiterTable.insert {
it[id] = reiter.reiterId
@ -159,8 +193,19 @@ class ExposedReiterRepository : ReiterRepository {
it[createdAt] = reiter.createdAt
it[updatedAt] = reiter.updatedAt
}
reiter
}
// Sparten aktualisieren
ReiterSparteTable.deleteWhere { ReiterSparteTable.reiterId eq reiter.reiterId }
reiter.lizenzSparten.forEach { sparte ->
ReiterSparteTable.insert {
it[ReiterSparteTable.id] = Uuid.random()
it[ReiterSparteTable.reiterId] = reiter.reiterId
it[ReiterSparteTable.sparte] = sparte.name
}
}
reiter
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
@ -177,30 +222,15 @@ class ExposedReiterRepository : ReiterRepository {
override suspend fun upsertBySatznummer(reiter: DomReiter): DomReiter = DatabaseFactory.dbQuery {
val existing = ReiterTable.selectAll().where { ReiterTable.satznummer eq reiter.satznummer }
.map(::rowToDomReiter)
.map { row ->
val id = row[ReiterTable.id]
rowToDomReiter(row, getSpartenForReiter(id))
}
.singleOrNull()
if (existing != null) {
val toUpdate = reiter.copy(reiterId = existing.reiterId)
ReiterTable.update({ ReiterTable.id eq existing.reiterId }) {
it[personId] = toUpdate.personId
it[nachname] = toUpdate.nachname
it[vorname] = toUpdate.vorname
it[geburtsdatum] = toUpdate.geburtsdatum
it[lizenzNummer] = toUpdate.lizenzNummer
it[lizenzKlasse] = toUpdate.lizenzKlasse.name
it[startkartAktiv] = toUpdate.startkartAktiv
it[startkartSaison] = toUpdate.startkartSaison
it[feiId] = toUpdate.feiId
it[nation] = toUpdate.nation
it[vereinsNummer] = toUpdate.vereinsNummer
it[vereinsName] = toUpdate.vereinsName
it[istGastreiter] = toUpdate.istGastreiter
it[istAktiv] = toUpdate.istAktiv
it[datenQuelle] = toUpdate.datenQuelle.name
it[updatedAt] = toUpdate.updatedAt
}
toUpdate
save(toUpdate)
} else {
save(reiter)
}

View File

@ -0,0 +1,26 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.infrastructure.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed-Tabellendefinition für die Spartenberechtigung eines Reiters.
* Verknüpft einen Reiter mit den Sparten (DRESSUR, SPRINGEN), für die er lizenziert ist.
*/
object ReiterSparteTable : Table("reiter_sparte") {
val id = uuid("id")
val reiterId = uuid("reiter_id").references(ReiterTable.id)
val sparte = varchar("sparte", 20) // DRESSUR, SPRINGEN
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp)
override val primaryKey = PrimaryKey(id)
init {
uniqueIndex("ux_reiter_sparte", reiterId, sparte)
}
}

View File

@ -42,7 +42,8 @@ class MasterdataDatabaseConfiguration {
LicenseTable,
RichtverfahrenTable,
GebuehrTable,
RegulationConfigTable
RegulationConfigTable,
ReiterSparteTable
)
log.info("Masterdata database schema initialized successfully")
}
@ -87,7 +88,8 @@ class MasterdataTestDatabaseConfiguration {
LicenseTable,
RichtverfahrenTable,
GebuehrTable,
RegulationConfigTable
RegulationConfigTable,
ReiterSparteTable
)
log.info("Test masterdata database schema initialized successfully")
}

View File

@ -0,0 +1,35 @@
-- V007: Cleanup Initial Tables and Add ReiterSparte Table
-- Harmonisierung: Löschen der veralteten dom_person / dom_verein Tabellen aus V1
-- Hinzufügen der Zwischentabelle für Reiter-Sparten
-- Löschen der alten Tabellen (Daten wurden bereits in V006 in die neuen Tabellen migriert bzw. werden neu importiert)
-- Vorsicht: Da dies ein Greenfield-Projekt ist und der Fokus auf V26 liegt, ist ein sauberer Schnitt hier erlaubt.
DROP TABLE IF EXISTS dom_person CASCADE;
DROP TABLE IF EXISTS dom_verein CASCADE;
-- Erstellung der Reiter-Sparten Tabelle
CREATE TABLE IF NOT EXISTS reiter_sparte
(
id
UUID
PRIMARY
KEY,
reiter_id
UUID
NOT
NULL
REFERENCES
reiter
(
reiter_id
) ON DELETE CASCADE,
sparte VARCHAR
(
20
) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP
WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX ux_reiter_sparte ON reiter_sparte (reiter_id, sparte);