feat(db+domain): add turniernummer and einschraenkungen fields for tournament scope and constraints

- **Database Changes:** Introduced `turnier_nummer` (mandatory, 5 digits) and `einschraenkungen` (mandatory, ÖTO-specific constraints) columns in `turniere` table. Seeded `turnier_nummer` with `oeps_turniernummer` where applicable.
- **Domain Models:** Extended `Turnier` and `DomTurnier` with `turnierNummer` and `einschraenkungen` fields. Added `TeilnehmerKreisE` enum for mapping restriction types.
- **Services and Controllers:** Updated repository and service operations to handle the new fields. Controllers reflect the new request models for creation and updates.
- **Validation:** Enforced input validation for `turnierNummer` format and `einschraenkungen` values.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
Stefan Mogeritsch 2026-04-07 15:07:18 +02:00
parent 7bf89c58d3
commit 6b9177e818
9 changed files with 119 additions and 7 deletions

View File

@ -11,6 +11,8 @@ object TurnierTable : Table("turniere") {
val id = javaUUID("id").autoGenerate()
val veranstaltungId = javaUUID("veranstaltung_id")
val oepsTurniernummer = varchar("oeps_turniernummer", 50)
val turnierNummer = varchar("turnier_nummer", 5)
val einschraenkungen = text("einschraenkungen")
// V3 Felder
val status = varchar("status", 16).default("DRAFT")

View File

@ -2,12 +2,15 @@
package at.mocode.entries.service.turniere
import at.mocode.core.domain.model.TeilnehmerKreisE
import kotlin.uuid.Uuid
data class Turnier(
val id: Uuid,
val veranstaltungId: Uuid,
val oepsTurniernummer: String,
val turnierNummer: String,
val einschraenkungen: List<TeilnehmerKreisE>,
val status: String,
val publishedAt: String?,
)

View File

@ -2,6 +2,7 @@
package at.mocode.entries.service.turniere
import at.mocode.core.domain.model.TeilnehmerKreisE
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
@ -21,6 +22,11 @@ class TurnierRepositoryImpl : TurnierRepository {
id = row[TurnierTable.id].toKotlinUuid(),
veranstaltungId = row[TurnierTable.veranstaltungId].toKotlinUuid(),
oepsTurniernummer = row[TurnierTable.oepsTurniernummer],
turnierNummer = row[TurnierTable.turnierNummer],
einschraenkungen = row[TurnierTable.einschraenkungen]
.split(',')
.mapNotNull { key -> key.trim().takeIf { it.isNotBlank() } }
.map(TeilnehmerKreisE::valueOf),
status = row[TurnierTable.status],
publishedAt = row[TurnierTable.publishedAt]?.toString(),
)
@ -31,6 +37,8 @@ class TurnierRepositoryImpl : TurnierRepository {
stmt[TurnierTable.id] = t.id.toJavaUuid()
stmt[TurnierTable.veranstaltungId] = t.veranstaltungId.toJavaUuid()
stmt[TurnierTable.oepsTurniernummer] = t.oepsTurniernummer
stmt[TurnierTable.turnierNummer] = t.turnierNummer
stmt[TurnierTable.einschraenkungen] = t.einschraenkungen.joinToString(",") { it.name }
stmt[TurnierTable.status] = t.status
stmt[TurnierTable.publishedAt] = null
stmt[TurnierTable.createdAt] = now
@ -63,6 +71,8 @@ class TurnierRepositoryImpl : TurnierRepository {
val now = Clock.System.now()
TurnierTable.update({ TurnierTable.id eq t.id.toJavaUuid() }) { stmt ->
stmt[TurnierTable.oepsTurniernummer] = t.oepsTurniernummer
stmt[TurnierTable.turnierNummer] = t.turnierNummer
stmt[TurnierTable.einschraenkungen] = t.einschraenkungen.joinToString(",") { it.name }
stmt[TurnierTable.status] = t.status
stmt[TurnierTable.updatedAt] = now
}

View File

@ -2,6 +2,7 @@
package at.mocode.entries.service.turniere
import at.mocode.core.domain.model.TeilnehmerKreisE
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.errors.ValidationException
@ -12,11 +13,19 @@ class TurnierService(
private val nennungen: NennungRepository,
) {
suspend fun create(veranstaltungId: Uuid, oepsNr: String, status: String? = null): Turnier {
suspend fun create(
veranstaltungId: Uuid,
oepsNr: String,
turnierNummer: String,
einschraenkungen: List<TeilnehmerKreisE>,
status: String? = null,
): Turnier {
val t = Turnier(
id = Uuid.random(),
veranstaltungId = veranstaltungId,
oepsTurniernummer = oepsNr,
turnierNummer = turnierNummer,
einschraenkungen = einschraenkungen,
status = status ?: "DRAFT",
publishedAt = null,
)
@ -27,12 +36,24 @@ class TurnierService(
suspend fun list(status: String?, oepsNr: String?): List<Turnier> = repo.findAll(status, oepsNr)
suspend fun update(id: Uuid, oepsNr: String): Turnier {
suspend fun update(id: Uuid, oepsNr: String, turnierNummer: String, einschraenkungen: List<TeilnehmerKreisE>): Turnier {
val current = get(id)
if (current.status == "PUBLISHED" && current.oepsTurniernummer != oepsNr) {
if (
current.status == "PUBLISHED" && (
current.oepsTurniernummer != oepsNr ||
current.turnierNummer != turnierNummer ||
current.einschraenkungen != einschraenkungen
)
) {
throw LockedException("Turnier ist PUBLISHED strukturelle Felder nicht änderbar")
}
return repo.update(current.copy(oepsTurniernummer = oepsNr))
return repo.update(
current.copy(
oepsTurniernummer = oepsNr,
turnierNummer = turnierNummer,
einschraenkungen = einschraenkungen,
)
)
}
suspend fun delete(id: Uuid): Boolean {

View File

@ -2,6 +2,7 @@
package at.mocode.entries.service.turniere
import at.mocode.core.domain.model.TeilnehmerKreisE
import at.mocode.entries.service.persistence.VeranstaltungTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.jdbc.selectAll
@ -12,11 +13,15 @@ import kotlin.uuid.toKotlinUuid
data class CreateTurnierRequest(
val oepsTurniernummer: String,
val turnierNummer: String,
val einschraenkungen: List<TeilnehmerKreisE> = emptyList(),
val status: String? = null,
)
data class UpdateTurnierRequest(
val oepsTurniernummer: String,
val turnierNummer: String,
val einschraenkungen: List<TeilnehmerKreisE> = emptyList(),
)
data class UpdateTurnierStatusRequest(
@ -34,7 +39,13 @@ class TurniereController(
suspend fun create(@RequestBody body: CreateTurnierRequest): Turnier {
// Veranstaltung pro Tenant auflösen: wir nehmen die einzige vorhandene ID aus dem Schema
val veranstaltungId = resolveVeranstaltungId()
return service.create(veranstaltungId, body.oepsTurniernummer, body.status)
return service.create(
veranstaltungId = veranstaltungId,
oepsNr = body.oepsTurniernummer,
turnierNummer = body.turnierNummer,
einschraenkungen = body.einschraenkungen,
status = body.status,
)
}
@GetMapping("/turniere")
@ -48,7 +59,12 @@ class TurniereController(
@PutMapping("/turniere/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateTurnierRequest): Turnier =
service.update(Uuid.parse(id), body.oepsTurniernummer)
service.update(
id = Uuid.parse(id),
oepsNr = body.oepsTurniernummer,
turnierNummer = body.turnierNummer,
einschraenkungen = body.einschraenkungen,
)
@DeleteMapping("/turniere/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)

View File

@ -0,0 +1,23 @@
-- V4: Add turnier_nummer and einschraenkungen to turniere
-- Context: ÖTO § 3 Turniernummer + Teilnehmerkreis-Einschränkungen
ALTER TABLE IF EXISTS turniere
ADD COLUMN IF NOT EXISTS turnier_nummer VARCHAR(5);
ALTER TABLE IF EXISTS turniere
ADD COLUMN IF NOT EXISTS einschraenkungen TEXT;
UPDATE turniere
SET turnier_nummer = oeps_turniernummer
WHERE (turnier_nummer IS NULL OR turnier_nummer = '')
AND oeps_turniernummer ~ '^\d{5}$';
UPDATE turniere
SET einschraenkungen = ''
WHERE einschraenkungen IS NULL;
ALTER TABLE turniere
ALTER COLUMN turnier_nummer SET NOT NULL;
ALTER TABLE turniere
ALTER COLUMN einschraenkungen SET NOT NULL;

View File

@ -3,6 +3,7 @@
package at.mocode.events.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TeilnehmerKreisE
import at.mocode.core.domain.model.TurnierkategorieE
import at.mocode.core.domain.model.TurnierStatusE
import at.mocode.events.domain.validation.TurnierBewerbDescriptor
@ -48,9 +49,11 @@ data class DomTurnier(
// Basis-Informationen
var name: String,
val turnierNummer: String,
var sparte: SparteE,
var kategorie: TurnierkategorieE,
var datum: LocalDate,
var einschraenkungen: List<TeilnehmerKreisE> = emptyList(),
// Funktionäre
@Serializable(with = UuidSerializer::class)
@ -98,6 +101,9 @@ data class DomTurnier(
if (name.isBlank()) {
warnings.add("Turniername ist erforderlich.")
}
if (!Regex("^\\d{5}$").matches(turnierNummer)) {
warnings.add("Turniernummer muss exakt 5 Ziffern enthalten (z.B. 26128).")
}
maxBewerbe?.let { max ->
if (max <= 0) {
warnings.add("Maximale Bewerb-Anzahl muss positiv sein.")

View File

@ -441,6 +441,36 @@ enum class TurnierStatusE {
ABGESAGT
}
/**
* Einschränkungen des Teilnehmerkreises gemäß ÖTO § 3 (Zusatz-Buchstaben bei Turnieren).
*/
@Serializable
enum class TeilnehmerKreisE {
/** Jugend/Junioren/Junge Reiter (J) */
JUGEND_JUNIOREN_YR,
/** Ponys (P) */
PONYS,
/** Noriker (N) */
NORIKER,
/** Haflinger (H) */
HAFLINGER,
/** Ländliche Reiter (L) */
LAENDLICHE_REITER,
/** Vollblutaraber (A) */
VOLLBLUTARABER,
/** Kaltblut (K) */
KALTBLUT,
/** Damensattel (D) */
DAMENSATTEL
}
/**
* Status einer Ausschreibung gemäß ÖTO-Genehmigungsworkflow.
*/

View File

@ -155,7 +155,8 @@ Die ÖTO definiert sparten- und klassenabhängige Schwellenwerte, ab wievielen S
| **TBA** | Turnierbeauftragter. Hat bei Regelkonflikten immer das letzte Wort. Jede Überschreibung wird als → *Override-Event* gespeichert. | ÖTO § 24/§ 25 |
| **Tierwohl-Euro** | Gebühr, die **pro Start** anfällt (nicht pro Nennung!). | ÖTO Gebührenordnung |
| **Turnier** | In unserer Software: Eine pferdesportliche Veranstaltung mit einer offiziellen **Ausschreibung** und einer vom OEPS/LFV vergebenen, eindeutigen **Turniernummer**. Entspricht ÖTO § 2 Abs. 2. Ist eine Spezialisierung von → *Veranstaltung*. | ÖTO § 2 Abs. 2, § 5, § 24 |
| **Turniernummer** | Vom OEPS vergebene, eindeutige Kennung eines Turniers (z.B. `25123`). Ohne diese Nummer darf keine offizielle Ausschreibung veröffentlicht werden. | ZNS A-Satz |
| **Turniernummer** | Offizielle, vom OEPS vergebene **5-stellige** Kennung eines Turniers (z.B. `26128`). Sie ist eindeutig und Voraussetzung für die offizielle Ausschreibung. | ÖTO § 3, ZNS A-Satz |
| **Teilnehmerkreis-Einschränkung** | Optionale Einschränkung des zulässigen Teilnehmerkreises eines Turniers gemäß ÖTO-Gliederung (§ 3), dargestellt über Zusatz-Buchstaben (z.B. `-H`, `-P`, `-J`). | ÖTO § 3 |
| **Turnierkategorie** | Siehe → *Kategorie*. | ÖTO § 3 Abs. 4 |
| **Turnierkassa** | Kassa auf Ebene des einzelnen → *Turniers*. Hält Belege, Tagesabschlüsse und Barbestände pro Turnier. Wird in der → *VeranstaltungsKassa* konsolidiert. | Billing Context |
| **TeilnehmerKonto** | Konto eines Zahlers auf Ebene der → *Veranstaltung* (nicht nur eines Turniers). Aggregiert Saldo, Einzahlungen und Verrechnungen über alle → *Turniere* derselben Veranstaltung hinweg (MultiTurnierAggregation). Ermöglicht, dass eine einzelne Zahlung mehrere Rechnungen in verschiedenen Turnieren derselben Veranstaltung ausgleicht. | Billing Context |