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:
2026-03-30 14:47:11 +02:00
parent e8757c5c32
commit d8c9d11adb
17 changed files with 871 additions and 41 deletions
@@ -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)
}
@@ -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)
}
}