feat: integrate new desktop shell and extend backend & ADRs

- Added `meldestelle-desktop` module using JVM/Compose Desktop, registered in `settings.gradle.kts`.
- Integrated new screens and desktop navigation into core: `Veranstaltungen`, `TurnierDetail`, etc.
- Expanded backend with `ExposedFunktionaerRepository` in `officials-infrastructure`.
- Completed ADRs for bounded context mapping (`ADR-0014`) and context map (`ADR-0015`).
- Updated and extended project documentation with session logs and architecture decisions.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-24 18:22:15 +01:00
parent c624df8744
commit 354bd49de6
75 changed files with 7616 additions and 48 deletions
+2
View File
@@ -0,0 +1,2 @@
Manifest-Version: 1.0
Binary file not shown.
@@ -0,0 +1,121 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.clubs.domain.model
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain-Modell für einen Verein im actor-context.
*
* Repräsentiert einen OEPS-Mitgliedsverein, der als Veranstalter von Turnieren
* und als Heimverein von Reitern und Funktionären fungiert.
* Daten werden primär aus dem ZNS (VEREIN01.dat) importiert.
*
* Aggregate Root des `clubs`-Bounded Context.
*
* @property vereinId Eindeutige interne ID (UUID).
* @property vereinsNummer ÖPS-Vereinsnummer aus ZNS (VEREIN01.dat), 4-stellig. Primärschlüssel für ZNS-Datenaustausch.
* @property name Offizieller Vereinsname.
* @property kurzname Kurzbezeichnung des Vereins (optional).
* @property bundesland Bundesland, in dem der Verein ansässig ist.
* @property ort Ort / Stadt des Vereinssitzes.
* @property plz Postleitzahl.
* @property strasse Straße und Hausnummer.
* @property email Offizielle E-Mail-Adresse des Vereins.
* @property telefon Telefonnummer des Vereins.
* @property website Website-URL des Vereins.
* @property oepsRegionNummer Regionsnummer beim OEPS (Landesverband).
* @property istVeranstalter Ob der Verein als Veranstalter von Turnieren zugelassen ist.
* @property istAktiv Ob der Verein aktuell aktiv ist.
* @property bemerkungen Interne Notizen.
* @property datenQuelle Herkunft des Datensatzes (ZNS-Import oder manuell).
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomVerein(
@Serializable(with = UuidSerializer::class)
val vereinId: Uuid = Uuid.random(),
// Identifikation
val vereinsNummer: String,
// Stammdaten
var name: String,
var kurzname: String? = null,
// Adresse
var bundesland: String? = null,
var ort: String? = null,
var plz: String? = null,
var strasse: String? = null,
// Kontakt
var email: String? = null,
var telefon: String? = null,
var website: String? = null,
// OEPS-Verwaltung
var oepsRegionNummer: String? = null,
var istVeranstalter: Boolean = false,
// Status & Verwaltung
var istAktiv: Boolean = true,
var bemerkungen: String? = null,
var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Gibt den Anzeigenamen zurück Kurzname bevorzugt, sonst vollständiger Name.
*/
fun getDisplayName(): String = kurzname ?: name
/**
* Gibt den vollständigen Anzeigenamen mit Vereinsnummer zurück.
*/
fun getDisplayNameWithNummer(): String = "${getDisplayName()} ($vereinsNummer)"
/**
* Prüft, ob vollständige Adressdaten vorhanden sind.
*/
fun hasCompleteAddress(): Boolean =
!ort.isNullOrBlank() && !plz.isNullOrBlank() && !strasse.isNullOrBlank()
/**
* Validiert den Verein für den Einsatz als Veranstalter.
* Gibt Warnungen zurück (kein harter Fehler Override-Event möglich).
*/
fun validateFuerVeranstaltung(): List<String> {
val warnings = mutableListOf<String>()
if (!istAktiv) {
warnings.add("Verein ${getDisplayName()} ist nicht aktiv.")
}
if (!istVeranstalter) {
warnings.add("Verein ${getDisplayName()} ist nicht als Veranstalter zugelassen.")
}
if (!hasCompleteAddress()) {
warnings.add("Verein ${getDisplayName()} hat keine vollständige Adresse hinterlegt.")
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomVerein = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,72 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.clubs.domain.repository
import at.mocode.clubs.domain.model.DomVerein
import kotlin.uuid.Uuid
/**
* Repository-Interface für DomVerein (Verein) Domain-Operationen.
*
* Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit
* von konkreten Implementierungsdetails (Datenbank, etc.).
*/
interface VereinRepository {
/**
* Sucht einen Verein anhand seiner eindeutigen ID.
*/
suspend fun findById(id: Uuid): DomVerein?
/**
* Sucht einen Verein anhand seiner OEPS-Vereinsnummer.
*/
suspend fun findByVereinsNummer(vereinsNummer: String): DomVerein?
/**
* Sucht Vereine anhand des Namens (Teilübereinstimmung).
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<DomVerein>
/**
* Sucht alle Vereine eines Bundeslandes.
*/
suspend fun findByBundesland(bundesland: String, activeOnly: Boolean = true): List<DomVerein>
/**
* Sucht alle Vereine, die als Veranstalter markiert sind.
*/
suspend fun findVeranstalter(activeOnly: Boolean = true): List<DomVerein>
/**
* Gibt alle aktiven Vereine zurück (paginiert).
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<DomVerein>
/**
* Gibt alle Vereine zurück (paginiert).
*/
suspend fun findAll(limit: Int = 100, offset: Int = 0): List<DomVerein>
/**
* Speichert einen Verein (Insert oder Update).
*/
suspend fun save(verein: DomVerein): DomVerein
/**
* Löscht einen Verein anhand seiner ID.
*
* @return true wenn gelöscht, false wenn nicht gefunden
*/
suspend fun delete(id: Uuid): Boolean
/**
* Zählt alle aktiven Vereine.
*/
suspend fun countActive(): Long
/**
* Prüft ob ein Verein mit der gegebenen Vereinsnummer bereits existiert.
*/
suspend fun existsByVereinsNummer(vereinsNummer: String): Boolean
}
@@ -0,0 +1,142 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.clubs.infrastructure.persistence
import at.mocode.clubs.domain.model.DomVerein
import at.mocode.clubs.domain.repository.VereinRepository
import at.mocode.core.domain.model.DatenQuelleE
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.statements.UpdateBuilder
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.util.*
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Exposed-basierte Implementierung des VereinRepository.
*/
class ExposedVereinRepository : VereinRepository {
override suspend fun findById(id: Uuid): DomVerein? = transaction {
VereinTable.selectAll().where { VereinTable.id eq id.toJavaUuid() }
.map { rowToVerein(it) }
.singleOrNull()
}
override suspend fun findByVereinsNummer(vereinsNummer: String): DomVerein? = transaction {
VereinTable.selectAll().where { VereinTable.vereinsNummer eq vereinsNummer }
.map { rowToVerein(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<DomVerein> = transaction {
VereinTable.selectAll().where { VereinTable.name like "%$searchTerm%" }
.limit(limit).map { rowToVerein(it) }
}
override suspend fun findByBundesland(bundesland: String, activeOnly: Boolean): List<DomVerein> = transaction {
VereinTable.selectAll().where {
(VereinTable.bundesland eq bundesland).let {
if (activeOnly) it and (VereinTable.istAktiv eq true) else it
}
}.map { rowToVerein(it) }
}
override suspend fun findVeranstalter(activeOnly: Boolean): List<DomVerein> = transaction {
VereinTable.selectAll().where {
(VereinTable.istVeranstalter eq true).let {
if (activeOnly) it and (VereinTable.istAktiv eq true) else it
}
}.map { rowToVerein(it) }
}
override suspend fun findAllActive(limit: Int, offset: Int): List<DomVerein> = transaction {
VereinTable.selectAll().where { VereinTable.istAktiv eq true }
.limit(limit).offset(offset.toLong())
.map { rowToVerein(it) }
}
override suspend fun findAll(limit: Int, offset: Int): List<DomVerein> = transaction {
VereinTable.selectAll()
.limit(limit).offset(offset.toLong())
.map { rowToVerein(it) }
}
override suspend fun save(verein: DomVerein): DomVerein = transaction {
val now = Clock.System.now()
val updated = verein.copy(updatedAt = now)
val javaId = verein.vereinId.toJavaUuid()
val existing = VereinTable.selectAll().where { VereinTable.id eq javaId }.singleOrNull()
if (existing != null) {
VereinTable.update({ VereinTable.id eq javaId }) { vereinToStatement(it, updated) }
} else {
VereinTable.insert {
it[id] = javaId
vereinToStatement(it, updated)
}
}
updated
}
override suspend fun delete(id: Uuid): Boolean = transaction {
VereinTable.deleteWhere { VereinTable.id eq id.toJavaUuid() } > 0
}
override suspend fun countActive(): Long = transaction {
VereinTable.selectAll().where { VereinTable.istAktiv eq true }.count()
}
override suspend fun existsByVereinsNummer(vereinsNummer: String): Boolean = transaction {
VereinTable.selectAll().where { VereinTable.vereinsNummer eq vereinsNummer }.count() > 0
}
private fun rowToVerein(row: ResultRow): DomVerein = DomVerein(
vereinId = (row[VereinTable.id] as UUID).toKotlinUuid(),
vereinsNummer = row[VereinTable.vereinsNummer],
name = row[VereinTable.name],
kurzname = row[VereinTable.kurzname],
bundesland = row[VereinTable.bundesland],
ort = row[VereinTable.ort],
plz = row[VereinTable.plz],
strasse = row[VereinTable.strasse],
email = row[VereinTable.email],
telefon = row[VereinTable.telefon],
website = row[VereinTable.webseite],
oepsRegionNummer = row[VereinTable.oepsRegionsNummer],
istVeranstalter = row[VereinTable.istVeranstalter],
istAktiv = row[VereinTable.istAktiv],
bemerkungen = row[VereinTable.bemerkungen],
datenQuelle = runCatching { DatenQuelleE.valueOf(row[VereinTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS),
createdAt = row[VereinTable.createdAt],
updatedAt = row[VereinTable.updatedAt]
)
private fun vereinToStatement(stmt: UpdateBuilder<*>, v: DomVerein) {
stmt[VereinTable.vereinsNummer] = v.vereinsNummer
stmt[VereinTable.name] = v.name
stmt[VereinTable.kurzname] = v.kurzname
stmt[VereinTable.bundesland] = v.bundesland
stmt[VereinTable.ort] = v.ort
stmt[VereinTable.plz] = v.plz
stmt[VereinTable.strasse] = v.strasse
stmt[VereinTable.email] = v.email
stmt[VereinTable.telefon] = v.telefon
stmt[VereinTable.webseite] = v.website
stmt[VereinTable.oepsRegionsNummer] = v.oepsRegionNummer
stmt[VereinTable.istVeranstalter] = v.istVeranstalter
stmt[VereinTable.istAktiv] = v.istAktiv
stmt[VereinTable.bemerkungen] = v.bemerkungen
stmt[VereinTable.datenQuelle] = v.datenQuelle.name
stmt[VereinTable.createdAt] = v.createdAt
stmt[VereinTable.updatedAt] = v.updatedAt
}
}
@@ -0,0 +1,56 @@
package at.mocode.clubs.infrastructure.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed-Tabellendefinition für Vereine (DomVerein).
*
* Speichert alle Vereins-Daten inkl. Adresse, Kontakt und OEPS-Regionsnummer.
*/
object VereinTable : Table("vereine") {
val id = javaUUID("id").autoGenerate()
override val primaryKey = PrimaryKey(id)
// Identifikation
val vereinsNummer = varchar("vereins_nummer", 20).uniqueIndex()
// Vereinsdaten
val name = varchar("name", 200)
val kurzname = varchar("kurzname", 50).nullable()
// Adresse
val strasse = varchar("strasse", 200).nullable()
val plz = varchar("plz", 10).nullable()
val ort = varchar("ort", 100).nullable()
val bundesland = varchar("bundesland", 50).nullable()
val land = varchar("land", 50).nullable().default("AT")
// Kontakt
val email = varchar("email", 255).nullable()
val telefon = varchar("telefon", 50).nullable()
val webseite = varchar("webseite", 255).nullable()
// OEPS-Daten
val oepsRegionsNummer = varchar("oeps_regions_nummer", 10).nullable()
// Status & Verwaltung
val istAktiv = bool("ist_aktiv").default(true)
val istVeranstalter = bool("ist_veranstalter").default(false)
val bemerkungen = text("bemerkungen").nullable()
val datenQuelle = varchar("daten_quelle", 50)
// Audit-Felder
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
init {
index(false, name)
index(false, bundesland)
index(false, istAktiv)
index(false, istVeranstalter)
index(false, oepsRegionsNummer)
}
}
@@ -0,0 +1,95 @@
-- Migration V001: Create Vereine (Clubs) table
-- Speichert alle Vereins-Daten inkl. Adresse, Kontakt und OEPS-Regionsnummer.
CREATE TABLE IF NOT EXISTS vereine
(
id
UUID
PRIMARY
KEY
DEFAULT
gen_random_uuid
(
),
vereins_nummer VARCHAR
(
20
) NOT NULL,
name VARCHAR
(
200
) NOT NULL,
kurzname VARCHAR
(
50
),
strasse VARCHAR
(
200
),
plz VARCHAR
(
10
),
ort VARCHAR
(
100
),
bundesland VARCHAR
(
50
),
land VARCHAR
(
50
) DEFAULT 'AT',
email VARCHAR
(
255
),
telefon VARCHAR
(
50
),
webseite VARCHAR
(
255
),
oeps_regions_nummer VARCHAR
(
10
),
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
ist_veranstalter BOOLEAN NOT NULL DEFAULT false,
bemerkungen TEXT,
daten_quelle VARCHAR
(
50
) NOT NULL DEFAULT 'MANUELL',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Unique index für Vereinsnummer
CREATE UNIQUE INDEX IF NOT EXISTS uk_vereine_vereins_nummer ON vereine(vereins_nummer);
-- Performance-Indizes
CREATE INDEX IF NOT EXISTS idx_vereine_name ON vereine(name);
CREATE INDEX IF NOT EXISTS idx_vereine_bundesland ON vereine(bundesland);
CREATE INDEX IF NOT EXISTS idx_vereine_ist_aktiv ON vereine(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_vereine_ist_veranstalter ON vereine(ist_veranstalter);
CREATE INDEX IF NOT EXISTS idx_vereine_oeps_region ON vereine(oeps_regions_nummer);
-- Dokumentation
COMMENT
ON TABLE vereine IS 'Reitsportvereine gemäß OEPS-Vereinsregister';
COMMENT
ON COLUMN vereine.id IS 'Eindeutige interne ID (UUID)';
COMMENT
ON COLUMN vereine.vereins_nummer IS 'Offizielle OEPS-Vereinsnummer (eindeutig)';
COMMENT
ON COLUMN vereine.oeps_regions_nummer IS 'OEPS-Regionsnummer des Landesverbands';
COMMENT
ON COLUMN vereine.ist_veranstalter IS 'Gibt an ob der Verein als Veranstalter von Turnieren zugelassen ist';
COMMENT
ON COLUMN vereine.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI';
@@ -32,6 +32,7 @@ kotlin {
commonMain {
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(projects.core.coreDomain)
}
}
commonTest {
@@ -0,0 +1,134 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.api
import at.mocode.core.domain.model.NennungsStatusE
import at.mocode.core.domain.model.StartwunschE
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
/**
* Vollständige Nennungs-Details (für GET /{id}, POST, PUT, DELETE Responses).
*/
@Serializable
data class NennungDetailDto(
@Serializable(with = UuidSerializer::class)
val nennungId: Uuid,
@Serializable(with = UuidSerializer::class)
val abteilungId: Uuid,
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid,
@Serializable(with = UuidSerializer::class)
val turnierId: Uuid,
@Serializable(with = UuidSerializer::class)
val reiterId: Uuid,
@Serializable(with = UuidSerializer::class)
val pferdId: Uuid,
@Serializable(with = UuidSerializer::class)
val zahlerId: Uuid? = null,
val status: NennungsStatusE,
val startwunsch: StartwunschE,
val istNachnennung: Boolean,
val nachnenngebuehrErlassen: Boolean,
val isNachnenngebuehrFaellig: Boolean,
val bemerkungen: String? = null,
val createdAt: String,
val updatedAt: String
)
/**
* Kompakte Nennungs-Übersicht (für Listen-Endpunkte).
*/
@Serializable
data class NennungSummaryDto(
@Serializable(with = UuidSerializer::class)
val nennungId: Uuid,
@Serializable(with = UuidSerializer::class)
val turnierId: Uuid,
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid,
@Serializable(with = UuidSerializer::class)
val abteilungId: Uuid,
@Serializable(with = UuidSerializer::class)
val reiterId: Uuid,
@Serializable(with = UuidSerializer::class)
val pferdId: Uuid,
val status: NennungsStatusE,
val istNachnennung: Boolean,
val createdAt: String
)
/**
* Request zum Einreichen einer neuen Nennung (POST /).
*/
@Serializable
data class NennungEinreichenRequest(
@Serializable(with = UuidSerializer::class)
val abteilungId: Uuid,
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid,
@Serializable(with = UuidSerializer::class)
val turnierId: Uuid,
@Serializable(with = UuidSerializer::class)
val reiterId: Uuid,
@Serializable(with = UuidSerializer::class)
val pferdId: Uuid,
@Serializable(with = UuidSerializer::class)
val zahlerId: Uuid? = null,
val startwunsch: StartwunschE = StartwunschE.KEIN_WUNSCH,
val istNachnennung: Boolean = false,
val bemerkungen: String? = null
)
/**
* Request zum Ändern des Nennungs-Status (PUT /{id}/status).
*/
@Serializable
data class NennungStatusAendernRequest(
val neuerStatus: NennungsStatusE,
val bemerkungen: String? = null
)
/**
* Request für einen Nennungs-Transfer (POST /{id}/transfer).
*/
@Serializable
data class NennungTransferRequest(
@Serializable(with = UuidSerializer::class)
val neuerReiterId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
val neuesPferdId: Uuid? = null,
val istNachNennschluss: Boolean = false,
val nachnenngebuehrErlassen: Boolean = false,
@Serializable(with = UuidSerializer::class)
val autorisiertVon: Uuid,
val grund: String? = null
)
/**
* Response für einen abgeschlossenen Nennungs-Transfer.
*/
@Serializable
data class NennungsTransferDto(
@Serializable(with = UuidSerializer::class)
val transferId: Uuid,
@Serializable(with = UuidSerializer::class)
val ursprungsNennungId: Uuid,
@Serializable(with = UuidSerializer::class)
val neueNennungId: Uuid,
@Serializable(with = UuidSerializer::class)
val alterReiterId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
val neuerReiterId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
val altesPferdId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
val neuesPferdId: Uuid? = null,
val istNachNennschluss: Boolean,
val nachnenngebuehrErlassen: Boolean,
@Serializable(with = UuidSerializer::class)
val autorisiertVon: Uuid,
val grund: String? = null,
val createdAt: String
)
@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
sourceSets {
commonMain {
kotlin.srcDir("src/main/kotlin")
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
}
commonTest {
kotlin.srcDir("src/test/kotlin")
dependencies {
implementation(projects.platform.platformTesting)
}
}
}
}
@@ -0,0 +1,110 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.model
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain-Modell für eine Abteilung im registration-context.
*
* Eine Abteilung ist die kleinste startbare Einheit innerhalb eines [DomBewerb]s.
* Ein Bewerb kann in mehrere Abteilungen aufgeteilt sein (z.B. Abt. 1: ohne Lizenz,
* Abt. 2: mit Lizenz R1). Die Aufteilung erfolgt gemäß ÖTO § 39 und den
* spartenspezifischen Bestimmungen.
*
* @property abteilungId Eindeutige interne ID (UUID).
* @property bewerbId Referenz auf den übergeordneten Bewerb (UUID).
* @property abteilungsNummer Laufende Nummer der Abteilung innerhalb des Bewerbs (1, 2, 3, ...).
* @property bezeichnung Optionale Bezeichnung der Abteilung (z.B. „ohne Lizenz", „R1", „4-jährige").
* @property teilungsTyp Kriterium, nach dem diese Abteilung abgegrenzt ist (Lizenz, Pferdealter, ...).
* @property teilnehmerkreisBeschreibung Freitext-Beschreibung des Teilnehmerkreises (z.B. „Reiter ohne Lizenz").
* @property starterAnzahl Aktuelle Anzahl der zugeordneten Starter (Nennungen).
* @property maxStarter Maximale Starter in dieser Abteilung (0 = kein Limit).
* @property startzeit Geplante Startzeit als ISO-String (z.B. „09:00").
* @property bemerkungen Interne Notizen.
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomAbteilung(
@Serializable(with = UuidSerializer::class)
val abteilungId: Uuid = Uuid.random(),
// Zuordnung
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid,
// Identifikation
var abteilungsNummer: Int,
var bezeichnung: String? = null,
// Teilungs-Kriterium
var teilungsTyp: AbteilungsTeilungsTypE = AbteilungsTeilungsTypE.KEINE,
var teilnehmerkreisBeschreibung: String? = null,
// Starter
var starterAnzahl: Int = 0,
var maxStarter: Int = 0,
// Zeitplanung
var startzeit: String? = null,
// Verwaltung
var bemerkungen: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Gibt den Anzeigenamen zurück (z.B. „Abt. 1 ohne Lizenz").
*/
fun getDisplayName(): String =
bezeichnung?.let { "Abt. $abteilungsNummer $it" } ?: "Abt. $abteilungsNummer"
/**
* Prüft, ob die Abteilung noch freie Startplätze hat.
* Gibt true zurück, wenn kein Limit gesetzt ist (maxStarter == 0).
*/
fun hatFreiePlaetze(): Boolean =
maxStarter == 0 || starterAnzahl < maxStarter
/**
* Validiert die Abteilung auf Überschreitung des maximalen Starter-Limits (§ 39 Abs. 2).
* Gibt Warnungen zurück (kein harter Fehler Override-Event möglich, ADR-0016).
*/
fun validateStarterLimit(): List<String> {
val warnings = mutableListOf<String>()
// Maximale Abteilungsgröße nach Teilung: > 80 Starter → erneute Teilung verpflichtend (§ 39 Abs. 2)
if (starterAnzahl > 80) {
warnings.add(
"WARN_ABTEILUNG_PFLICHT_TEILUNG_UEBERSCHRITTEN: ${getDisplayName()}, " +
"Starter: $starterAnzahl > 80. Erneute Teilung verpflichtend (§ 39 Abs. 2). " +
"Override möglich (TBA-Entscheidung)."
)
}
if (maxStarter > 0 && starterAnzahl > maxStarter) {
warnings.add(
"WARN_ABTEILUNG_MAX_STARTER_UEBERSCHRITTEN: ${getDisplayName()}, " +
"Starter: $starterAnzahl > Limit $maxStarter. Override möglich (TBA-Entscheidung)."
)
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomAbteilung = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,150 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.model
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
import at.mocode.core.domain.model.PruefungsTypE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain-Modell für einen Bewerb im registration-context.
*
* Ein Bewerb ist eine einzelne Prüfung innerhalb eines Turniers (z.B. „Stilspringen 90 cm").
* Er kann in mehrere [DomAbteilung]en aufgeteilt sein. Die Abteilungs-Warn-Logik basiert
* auf den ÖTO-Schwellenwerten (§ 39 A-Teil + spartenspezifische Bestimmungen).
*
* Aggregate Root des `registration-context` für den Bewerbs-Workflow.
*
* @property bewerbId Eindeutige interne ID (UUID).
* @property turnierId Referenz auf das übergeordnete Turnier (UUID).
* @property bewerbNummer Laufende Nummer des Bewerbs innerhalb des Turniers (z.B. 1, 2, 3).
* @property bezeichnung Offizielle Bezeichnung des Bewerbs (z.B. „Stilspringen 90 cm").
* @property sparte Sportliche Sparte (Springen, Dressur, Vielseitigkeit, ...).
* @property turnierkategorie Turnierkategorie (A*, A, B*, B, C, ...).
* @property pruefungsTyp Typ der Prüfung bestimmt den Abteilungs-Schwellenwert (§ 39).
* @property hoeheCm Höhe in cm (relevant für Springen und Vielseitigkeit).
* @property teilungsTyp Kriterium, nach dem der Bewerb in Abteilungen aufgeteilt wird.
* @property maxStarterProAbteilung Maximale Starter pro Abteilung (0 = kein Limit gesetzt).
* @property istMeisterschaft Ob es sich um einen Meisterschaftsbewerb handelt (Ausnahme von § 39 Abs. 4).
* @property istNachnennungErlaubt Ob Nachnennungen für diesen Bewerb zugelassen sind.
* @property bemerkungen Interne Notizen.
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomBewerb(
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid = Uuid.random(),
// Zuordnung
@Serializable(with = UuidSerializer::class)
val turnierId: Uuid,
// Identifikation
var bewerbNummer: Int,
var bezeichnung: String,
// Fachliche Klassifikation
var sparte: SparteE,
var turnierkategorie: TurnierkategorieE,
var pruefungsTyp: PruefungsTypE,
var hoeheCm: Int? = null,
// Abteilungs-Konfiguration
var teilungsTyp: AbteilungsTeilungsTypE = AbteilungsTeilungsTypE.KEINE,
var maxStarterProAbteilung: Int = 0,
// Flags
var istMeisterschaft: Boolean = false,
var istNachnennungErlaubt: Boolean = true,
// Verwaltung
var bemerkungen: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Gibt den Anzeigenamen mit Nummer zurück (z.B. „1 Stilspringen 90 cm").
*/
fun getDisplayName(): String = "$bewerbNummer $bezeichnung"
/**
* Liefert den Pflicht-Teilungs-Schwellenwert gemäß ÖTO § 39 für diesen Prüfungstyp.
* Gibt null zurück, wenn keine Pflicht-Teilung aufgrund der Starterzahl gilt
* (strukturelle Teilungen werden separat über [pruefungsTyp] und [teilungsTyp] abgebildet).
*
* Meisterschaftsbewerbe sind von der Pflicht-Teilung ausgenommen (§ 39 Abs. 4).
*/
fun getPflichtTeilungsSchwellenwert(): Int? {
if (istMeisterschaft) return null
return when (pruefungsTyp) {
PruefungsTypE.STIL_SPRINGEN,
PruefungsTypE.SPRINGPFERDE,
PruefungsTypE.DRESSURPFERDE -> 30
PruefungsTypE.VIELSEITIGKEIT -> 40
PruefungsTypE.SPRINGEN_UEBRIG -> 80
else -> null // Kann-Teilung oder strukturell kein Starter-Schwellenwert
}
}
/**
* Gibt den Kann-Teilungs-Schwellenwert zurück (nur für Dressur: > 30 Starter, § 39 Abs. 2).
* Gibt null zurück, wenn keine Kann-Teilung gilt.
*/
fun getKannTeilungsSchwellenwert(): Int? {
if (istMeisterschaft) return null
return when (pruefungsTyp) {
PruefungsTypE.DRESSUR -> 30
else -> null
}
}
/**
* Validiert den Bewerb auf Abteilungs-Schwellenwerte anhand der aktuellen Starterzahl.
* Gibt Warnungen zurück (kein harter Fehler Override-Event möglich, ADR-0016).
*
* @param aktuelleStarterAnzahl Aktuelle Anzahl der Nennungen für diesen Bewerb.
*/
fun validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl: Int): List<String> {
val warnings = mutableListOf<String>()
val pflichtSchwellenwert = getPflichtTeilungsSchwellenwert()
if (pflichtSchwellenwert != null && aktuelleStarterAnzahl > pflichtSchwellenwert) {
warnings.add(
"WARN_ABTEILUNG_PFLICHT_TEILUNG_UEBERSCHRITTEN: Bewerb ${getDisplayName()}, " +
"Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > Schwellenwert $pflichtSchwellenwert. " +
"Empfehlung: Teilung nach ${teilungsTyp.name}. Override möglich (TBA-Entscheidung)."
)
}
val kannSchwellenwert = getKannTeilungsSchwellenwert()
if (kannSchwellenwert != null && aktuelleStarterAnzahl > kannSchwellenwert &&
teilungsTyp == AbteilungsTeilungsTypE.KEINE
) {
warnings.add(
"WARN_ABTEILUNG_KANN_TEILUNG_EMPFOHLEN: Bewerb ${getDisplayName()}, " +
"Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > $kannSchwellenwert. " +
"Kann-Teilung empfohlen (§ 39 Abs. 2)."
)
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomBewerb = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,99 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.model
import at.mocode.core.domain.model.NennungsStatusE
import at.mocode.core.domain.model.StartwunschE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain model representing a competition entry (Nennung) in the registration-context.
*
* A Nennung is the binding registration of a Reiter-Pferd-Paar (rider-horse pair)
* to a specific Abteilung (the smallest unit for entries and results per ÖTO).
*
* Key rules (ÖTO / registration-context):
* - The Abteilung (not the Bewerb) is the smallest unit for entries.
* - A Nennungs-Transfer is NOT a cancellation + re-entry, but a transfer operation.
* Already paid Nenngeld is kept as credit (Guthaben) on the payer's account.
* - After Nennschluss, a Nachnenngebühr applies (can be waived by the organizer).
* - The system issues WARNINGS only never hard errors. The TBA has the final say.
*
* @property nennungId Unique internal identifier (UUID).
* @property abteilungId Reference to the Abteilung (smallest entry unit).
* @property bewerbId Reference to the parent Bewerb (for display/reporting).
* @property turnierId Reference to the Turnier.
* @property reiterId Reference to the DomReiter (actor-context).
* @property pferdId Reference to the DomPferd (actor-context).
* @property zahlerId Reference to the payer (may differ from rider, e.g. club pays).
* @property status Current status of this entry.
* @property startwunsch Rider's preferred starting position (vorne/hinten).
* @property istNachnennung Whether this entry was submitted after Nennschluss.
* @property nachnenngebuehrErlassen Whether the organizer waived the late entry fee.
* @property bemerkungen Optional notes from the Meldestelle.
* @property createdAt Timestamp when this entry was created.
* @property updatedAt Timestamp when this entry was last updated.
*/
@Serializable
data class DomNennung(
@Serializable(with = UuidSerializer::class)
val nennungId: Uuid = Uuid.random(),
// Competition References
@Serializable(with = UuidSerializer::class)
val abteilungId: Uuid,
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid,
@Serializable(with = UuidSerializer::class)
val turnierId: Uuid,
// Actor References (actor-context)
@Serializable(with = UuidSerializer::class)
val reiterId: Uuid,
@Serializable(with = UuidSerializer::class)
val pferdId: Uuid,
// Billing Reference (billing-context)
@Serializable(with = UuidSerializer::class)
val zahlerId: Uuid? = null,
// Entry Details
val status: NennungsStatusE = NennungsStatusE.EINGEGANGEN,
val startwunsch: StartwunschE = StartwunschE.KEIN_WUNSCH,
// Late Entry (Nachnennung)
val istNachnennung: Boolean = false,
val nachnenngebuehrErlassen: Boolean = false,
// Notes
val bemerkungen: String? = null,
// Audit Fields
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Checks if this entry is still active (not withdrawn or cancelled).
*/
fun isAktiv(): Boolean = status !in listOf(
NennungsStatusE.ZURUECKGEZOGEN,
NennungsStatusE.NICHT_ANGETRETEN
)
/**
* Checks if a late entry fee (Nachnenngebühr) is due.
*/
fun isNachnenngebuehrFaellig(): Boolean = istNachnennung && !nachnenngebuehrErlassen
/**
* Creates a copy of this entry with an updated timestamp.
*/
fun withUpdatedTimestamp(): DomNennung = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,97 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.model
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain model representing a transfer operation on an existing entry (Nennungs-Transfer).
*
* A Nennungs-Transfer is the correct way to change the rider and/or horse on an existing
* Nennung. It is explicitly NOT a cancellation + re-entry. Already paid Nenngeld is
* retained as credit (Guthaben) on the payer's account in the billing-context.
*
* This model captures the full audit trail of the transfer: who was replaced by whom,
* who authorized it, and whether a Nachnenngebühr applies.
*
* Key rules (ÖTO / registration-context):
* - Transfer = atomic operation on the existing Nennung (status → TRANSFERIERT).
* - A new Nennung is created for the new Reiter-Pferd-Paar.
* - The original Nennung is closed with status TRANSFERIERT (not deleted).
* - Paid Nenngeld is credited to the payer's account, not refunded directly.
* - Every transfer is stored as an explicit event (Override-Event if after Nennschluss).
*
* @property transferId Unique internal identifier (UUID).
* @property ursprungsNennungId The original Nennung being transferred.
* @property neueNennungId The newly created Nennung after the transfer.
* @property alterReiterId Rider ID before the transfer (null if only horse changed).
* @property neuerReiterId Rider ID after the transfer (null if only horse changed).
* @property altesPferdId Horse ID before the transfer (null if only rider changed).
* @property neuesPferdId Horse ID after the transfer (null if only rider changed).
* @property istNachNennschluss Whether the transfer occurred after Nennschluss.
* @property nachnenngebuehrErlassen Whether the organizer waived the late transfer fee.
* @property autorisiertVon UUID of the Meldestelle user who authorized the transfer.
* @property grund Reason for the transfer (optional, for audit trail).
* @property createdAt Timestamp when this transfer was recorded.
*/
@Serializable
data class DomNennungsTransfer(
@Serializable(with = UuidSerializer::class)
val transferId: Uuid = Uuid.random(),
// Linked Entries
@Serializable(with = UuidSerializer::class)
val ursprungsNennungId: Uuid,
@Serializable(with = UuidSerializer::class)
val neueNennungId: Uuid,
// What changed Rider
@Serializable(with = UuidSerializer::class)
val alterReiterId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
val neuerReiterId: Uuid? = null,
// What changed Horse
@Serializable(with = UuidSerializer::class)
val altesPferdId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
val neuesPferdId: Uuid? = null,
// Timing & Fees
val istNachNennschluss: Boolean = false,
val nachnenngebuehrErlassen: Boolean = false,
// Authorization (Override-Event)
@Serializable(with = UuidSerializer::class)
val autorisiertVon: Uuid,
val grund: String? = null,
// Audit Fields
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now()
) {
/**
* Returns true if the rider was changed in this transfer.
*/
fun isReiterGetauscht(): Boolean = alterReiterId != null && neuerReiterId != null
/**
* Returns true if the horse was changed in this transfer.
*/
fun isPferdGetauscht(): Boolean = altesPferdId != null && neuesPferdId != null
/**
* Returns true if a Nachnenngebühr is due for this transfer.
*/
fun isNachnenngebuehrFaellig(): Boolean = istNachNennschluss && !nachnenngebuehrErlassen
/**
* Validates that the transfer changes at least one of rider or horse.
*/
fun isValid(): Boolean = isReiterGetauscht() || isPferdGetauscht()
}
@@ -0,0 +1,156 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.model
import at.mocode.core.domain.model.StartlistenStatusE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain-Modell für eine Startliste im registration-context.
*
* Eine Startliste gehört zu einer [DomAbteilung] und enthält die geordnete Liste
* der Starter (Nennungen) mit ihren Startnummern. Sie durchläuft einen definierten
* Workflow: NICHT_ERSTELLT → ENTWURF → VEROEFFENTLICHT → GESPERRT → ARCHIVIERT.
*
* @property startlisteId Eindeutige interne ID (UUID).
* @property abteilungId Referenz auf die zugehörige Abteilung (UUID).
* @property bewerbId Referenz auf den übergeordneten Bewerb (UUID) Denormalisierung für schnellen Zugriff.
* @property turnierId Referenz auf das übergeordnete Turnier (UUID) Denormalisierung für schnellen Zugriff.
* @property status Aktueller Status der Startliste im Workflow.
* @property eintraege Geordnete Liste der Startlisteneinträge (Startnummer → Nennung).
* @property veroeffentlichtAt Zeitpunkt der Veröffentlichung (null = noch nicht veröffentlicht).
* @property gesperrtAt Zeitpunkt der Sperrung (null = noch nicht gesperrt).
* @property bemerkungen Interne Notizen.
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomStartliste(
@Serializable(with = UuidSerializer::class)
val startlisteId: Uuid = Uuid.random(),
// Zuordnung
@Serializable(with = UuidSerializer::class)
val abteilungId: Uuid,
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid,
@Serializable(with = UuidSerializer::class)
val turnierId: Uuid,
// Status
var status: StartlistenStatusE = StartlistenStatusE.NICHT_ERSTELLT,
// Einträge
var eintraege: List<StartlistenEintrag> = emptyList(),
// Zeitstempel Workflow
@Serializable(with = KotlinInstantSerializer::class)
var veroeffentlichtAt: Instant? = null,
@Serializable(with = KotlinInstantSerializer::class)
var gesperrtAt: Instant? = null,
// Verwaltung
var bemerkungen: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Gibt die Anzahl der Starter in dieser Startliste zurück.
*/
fun getStarterAnzahl(): Int = eintraege.size
/**
* Gibt den Eintrag für eine bestimmte Startnummer zurück, oder null.
*/
fun getEintragByStartnummer(startnummer: Int): StartlistenEintrag? =
eintraege.find { it.startnummer == startnummer }
/**
* Prüft, ob die Startliste bearbeitbar ist (nur im Status ENTWURF).
*/
fun istBearbeitbar(): Boolean = status == StartlistenStatusE.ENTWURF
/**
* Prüft, ob die Startliste veröffentlicht oder gesperrt ist (für Reiter sichtbar).
*/
fun istSichtbar(): Boolean =
status == StartlistenStatusE.VEROEFFENTLICHT || status == StartlistenStatusE.GESPERRT
/**
* Führt den Workflow-Übergang ENTWURF → VEROEFFENTLICHT durch.
* Gibt Warnungen zurück, wenn der Übergang nicht möglich ist.
*/
fun veroeffentlichen(): List<String> {
val warnings = mutableListOf<String>()
if (status != StartlistenStatusE.ENTWURF) {
warnings.add(
"WARN_STARTLISTE_UNGÜLTIGER_STATUS_ÜBERGANG: Startliste kann nur aus Status " +
"ENTWURF veröffentlicht werden. Aktueller Status: $status."
)
return warnings
}
if (eintraege.isEmpty()) {
warnings.add("WARN_STARTLISTE_LEER: Startliste enthält keine Einträge.")
}
return warnings
}
/**
* Führt den Workflow-Übergang VEROEFFENTLICHT → GESPERRT durch.
* Gibt Warnungen zurück, wenn der Übergang nicht möglich ist.
*/
fun sperren(): List<String> {
val warnings = mutableListOf<String>()
if (status != StartlistenStatusE.VEROEFFENTLICHT) {
warnings.add(
"WARN_STARTLISTE_UNGÜLTIGER_STATUS_ÜBERGANG: Startliste kann nur aus Status " +
"VEROEFFENTLICHT gesperrt werden. Aktueller Status: $status."
)
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomStartliste = this.copy(updatedAt = Clock.System.now())
}
/**
* Ein einzelner Eintrag in einer Startliste.
*
* Verbindet eine Startnummer mit einer Nennung ([DomNennung]).
*
* @property startnummer Die zugewiesene Startnummer (Kopfnummer gemäß Ubiquitous Language).
* @property nennungId Referenz auf die zugehörige Nennung (UUID).
* @property reiterName Denormalisierter Reitername für schnelle Anzeige.
* @property pferdeName Denormalisierter Pferdename für schnelle Anzeige.
* @property startwunsch Optionaler Startwunsch (VORNE, HINTEN, KEIN_WUNSCH).
* @property istGestrichen Ob der Starter gestrichen wurde (Abmeldung nach Startlistenerstellung).
*/
@Serializable
data class StartlistenEintrag(
var startnummer: Int,
@Serializable(with = UuidSerializer::class)
val nennungId: Uuid,
// Denormalisierte Felder für schnelle Anzeige
var reiterName: String,
var pferdeName: String,
// Startwunsch
var startwunsch: String? = null,
// Status
var istGestrichen: Boolean = false
)
@@ -0,0 +1,88 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.repository
import at.mocode.core.domain.model.NennungsStatusE
import at.mocode.entries.domain.model.DomNennung
import kotlin.uuid.Uuid
/**
* Repository-Interface für DomNennung (Nennung) Domain-Operationen.
*
* Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit
* von konkreten Implementierungsdetails (Datenbank, etc.).
*/
interface NennungRepository {
/**
* Sucht eine Nennung anhand ihrer eindeutigen ID.
*/
suspend fun findById(id: Uuid): DomNennung?
/**
* Sucht alle Nennungen für einen bestimmten Bewerb.
*/
suspend fun findByBewerbId(bewerbId: Uuid): List<DomNennung>
/**
* Sucht alle Nennungen für eine bestimmte Abteilung.
*/
suspend fun findByAbteilungId(abteilungId: Uuid): List<DomNennung>
/**
* Sucht alle Nennungen für ein bestimmtes Turnier.
*/
suspend fun findByTurnierId(turnierId: Uuid): List<DomNennung>
/**
* Sucht alle Nennungen eines bestimmten Reiters.
*/
suspend fun findByReiterId(reiterId: Uuid): List<DomNennung>
/**
* Sucht alle Nennungen für ein bestimmtes Pferd.
*/
suspend fun findByPferdId(pferdId: Uuid): List<DomNennung>
/**
* Sucht alle Nennungen eines Reiters für ein bestimmtes Turnier.
*/
suspend fun findByReiterIdAndTurnierId(reiterId: Uuid, turnierId: Uuid): List<DomNennung>
/**
* Sucht alle Nennungen mit einem bestimmten Status.
*/
suspend fun findByStatus(status: NennungsStatusE): List<DomNennung>
/**
* Sucht alle Nachnennungen für einen Bewerb.
*/
suspend fun findNachnennungenByBewerbId(bewerbId: Uuid): List<DomNennung>
/**
* Speichert eine Nennung (Insert oder Update).
*/
suspend fun save(nennung: DomNennung): DomNennung
/**
* Löscht eine Nennung anhand ihrer ID.
*
* @return true wenn gelöscht, false wenn nicht gefunden
*/
suspend fun delete(id: Uuid): Boolean
/**
* Zählt alle Nennungen für einen Bewerb.
*/
suspend fun countByBewerbId(bewerbId: Uuid): Long
/**
* Zählt alle Nennungen für eine Abteilung.
*/
suspend fun countByAbteilungId(abteilungId: Uuid): Long
/**
* Zählt alle Nennungen für ein Turnier mit einem bestimmten Status.
*/
suspend fun countByTurnierIdAndStatus(turnierId: Uuid, status: NennungsStatusE): Long
}
@@ -0,0 +1,15 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.repository
import at.mocode.entries.domain.model.DomNennungsTransfer
import kotlin.uuid.Uuid
/**
* Repository-Interface für DomNennungsTransfer Domain-Operationen.
*/
interface NennungsTransferRepository {
suspend fun findById(id: Uuid): DomNennungsTransfer?
suspend fun findByUrsprungsNennungId(nennungId: Uuid): List<DomNennungsTransfer>
suspend fun save(transfer: DomNennungsTransfer): DomNennungsTransfer
}
@@ -14,6 +14,9 @@ dependencies {
implementation(platform(projects.platform.platformBom))
implementation(projects.platform.platformDependencies)
implementation(projects.backend.services.entries.entriesApi)
implementation(projects.backend.services.entries.entriesDomain)
implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain)
implementation(projects.backend.infrastructure.monitoring.monitoringClient)
implementation(projects.backend.infrastructure.security)
@@ -38,6 +41,10 @@ dependencies {
implementation(libs.spring.boot.starter.aop)
implementation(libs.springdoc.openapi.starter.webmvc.ui)
// Exposed ORM für Datenbankzugriff
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
testImplementation(projects.platform.platformTesting)
testImplementation(libs.bundles.testing.jvm)
@@ -0,0 +1,87 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service
import at.mocode.entries.api.*
import at.mocode.entries.service.usecase.NennungUseCases
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import kotlin.uuid.Uuid
/**
* REST-Controller für den Nennungs-Workflow (registration-context).
*
* Basis-URL: /api/v1/registrations/nennungen
*
* Endpunkte:
* GET / → Liste aller Nennungen (gefiltert)
* GET /{nennungsId} → Nennung-Detail
* POST / → Neue Nennung einreichen
* PUT /{nennungsId}/status → Status ändern
* DELETE /{nennungsId} → Nennung zurückziehen
* POST /{nennungsId}/transfer → Nennungs-Transfer
*/
@RestController
@RequestMapping("/api/v1/registrations/nennungen")
@CrossOrigin(
origins = ["http://localhost:8080", "http://localhost:8083", "http://localhost:4000"],
methods = [RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS],
allowedHeaders = ["*"],
allowCredentials = "true"
)
@Tag(name = "Nennungen", description = "Nennungs-Workflow (registration-context)")
class NennungController(
private val useCases: NennungUseCases
) {
@GetMapping
@Operation(summary = "Nennungen abrufen (gefiltert nach turnierId, bewerbId, abteilungId oder reiterId)")
suspend fun getNennungen(
@RequestParam(required = false) turnierId: String?,
@RequestParam(required = false) bewerbId: String?,
@RequestParam(required = false) abteilungId: String?,
@RequestParam(required = false) reiterId: String?
): List<NennungSummaryDto> = when {
turnierId != null -> useCases.getNennungenByTurnier(Uuid.parse(turnierId))
bewerbId != null -> useCases.getNennungenByBewerb(Uuid.parse(bewerbId))
abteilungId != null -> useCases.getNennungenByAbteilung(Uuid.parse(abteilungId))
reiterId != null -> useCases.getNennungenByReiter(Uuid.parse(reiterId))
else -> emptyList()
}
@GetMapping("/{nennungsId}")
@Operation(summary = "Nennung-Detail abrufen")
suspend fun getNennung(@PathVariable nennungsId: String): NennungDetailDto =
useCases.getNennungById(Uuid.parse(nennungsId))
?: throw NoSuchElementException("Nennung nicht gefunden: $nennungsId")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Neue Nennung einreichen")
suspend fun nennungEinreichen(
@RequestBody request: NennungEinreichenRequest
): NennungDetailDto = useCases.nennungEinreichen(request)
@PutMapping("/{nennungsId}/status")
@Operation(summary = "Nennungs-Status ändern (z.B. BESTAETIGT, ABGELEHNT)")
suspend fun statusAendern(
@PathVariable nennungsId: String,
@RequestBody request: NennungStatusAendernRequest
): NennungDetailDto = useCases.statusAendern(Uuid.parse(nennungsId), request)
@DeleteMapping("/{nennungsId}")
@Operation(summary = "Nennung zurückziehen (Status → ZURUECKGEZOGEN)")
suspend fun nennungZurueckziehen(
@PathVariable nennungsId: String
): NennungDetailDto = useCases.nennungZurueckziehen(Uuid.parse(nennungsId))
@PostMapping("/{nennungsId}/transfer")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Nennungs-Transfer durchführen (atomare Operation gemäß ÖTO)")
suspend fun nennungTransferieren(
@PathVariable nennungsId: String,
@RequestBody request: NennungTransferRequest
): NennungsTransferDto = useCases.nennungTransferieren(Uuid.parse(nennungsId), request)
}
@@ -0,0 +1,24 @@
package at.mocode.entries.service.config
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.domain.repository.NennungsTransferRepository
import at.mocode.entries.service.persistence.NennungRepositoryImpl
import at.mocode.entries.service.persistence.NennungsTransferRepositoryImpl
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Spring-Bean-Konfiguration für den Entries Service.
*
* Registriert die Repository-Implementierungen als Spring-Beans,
* damit sie in NennungUseCases per Constructor-Injection verfügbar sind.
*/
@Configuration
class EntriesBeansConfiguration {
@Bean
fun nennungRepository(): NennungRepository = NennungRepositoryImpl()
@Bean
fun nennungsTransferRepository(): NennungsTransferRepository = NennungsTransferRepositoryImpl()
}
@@ -0,0 +1,40 @@
package at.mocode.entries.service.config
import at.mocode.entries.service.persistence.NennungTable
import at.mocode.entries.service.persistence.NennungsTransferTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
/**
* Datenbank-Konfiguration für den Entries Service.
*
* Initialisiert das Exposed-Schema für Nennungen und Nennungs-Transfers.
* Die DB-Verbindung selbst wird durch den zentralen DataSource-Bean initialisiert.
*/
@Configuration
@Profile("!test")
class EntriesDatabaseConfiguration {
private val log = LoggerFactory.getLogger(EntriesDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initialisiere Datenbank-Schema für Entries Service...")
try {
transaction {
SchemaUtils.createMissingTablesAndColumns(
NennungTable,
NennungsTransferTable
)
log.info("Entries Datenbank-Schema erfolgreich initialisiert")
}
} catch (e: Exception) {
log.error("Fehler beim Initialisieren des Datenbank-Schemas", e)
throw e
}
}
}
@@ -0,0 +1,36 @@
package at.mocode.entries.service.config
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
/**
* Globaler Exception-Handler für den Entries Service.
*
* Mappt Domain-Exceptions auf HTTP-Statuscodes gemäß RFC 9457 (Problem Details).
*/
@RestControllerAdvice
class EntriesExceptionHandler {
private val log = LoggerFactory.getLogger(EntriesExceptionHandler::class.java)
@ExceptionHandler(NoSuchElementException::class)
fun handleNotFound(ex: NoSuchElementException): ProblemDetail {
log.warn("Ressource nicht gefunden: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Nicht gefunden")
}
@ExceptionHandler(IllegalArgumentException::class)
fun handleBadRequest(ex: IllegalArgumentException): ProblemDetail {
log.warn("Ungültige Anfrage: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.message ?: "Ungültige Anfrage")
}
@ExceptionHandler(IllegalStateException::class)
fun handleConflict(ex: IllegalStateException): ProblemDetail {
log.warn("Konflikt: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt")
}
}
@@ -0,0 +1,154 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.persistence
import at.mocode.core.domain.model.NennungsStatusE
import at.mocode.core.domain.model.StartwunschE
import at.mocode.entries.domain.model.DomNennung
import at.mocode.entries.domain.repository.NennungRepository
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Exposed-basierte Implementierung des NennungRepository.
*/
class NennungRepositoryImpl : NennungRepository {
private fun rowToNennung(row: ResultRow): DomNennung = DomNennung(
nennungId = row[NennungTable.id].toKotlinUuid(),
abteilungId = row[NennungTable.abteilungId].toKotlinUuid(),
bewerbId = row[NennungTable.bewerbId].toKotlinUuid(),
turnierId = row[NennungTable.turnierId].toKotlinUuid(),
reiterId = row[NennungTable.reiterId].toKotlinUuid(),
pferdId = row[NennungTable.pferdId].toKotlinUuid(),
zahlerId = row[NennungTable.zahlerId]?.toKotlinUuid(),
status = NennungsStatusE.valueOf(row[NennungTable.status]),
startwunsch = StartwunschE.valueOf(row[NennungTable.startwunsch]),
istNachnennung = row[NennungTable.istNachnennung],
nachnenngebuehrErlassen = row[NennungTable.nachnenngebuehrErlassen],
bemerkungen = row[NennungTable.bemerkungen],
createdAt = row[NennungTable.createdAt],
updatedAt = row[NennungTable.updatedAt]
)
override suspend fun findById(id: Uuid): DomNennung? = transaction {
NennungTable.selectAll().where { NennungTable.id eq id.toJavaUuid() }
.map(::rowToNennung)
.singleOrNull()
}
override suspend fun findByBewerbId(bewerbId: Uuid): List<DomNennung> = transaction {
NennungTable.selectAll().where { NennungTable.bewerbId eq bewerbId.toJavaUuid() }
.map(::rowToNennung)
}
override suspend fun findByAbteilungId(abteilungId: Uuid): List<DomNennung> = transaction {
NennungTable.selectAll().where { NennungTable.abteilungId eq abteilungId.toJavaUuid() }
.map(::rowToNennung)
}
override suspend fun findByTurnierId(turnierId: Uuid): List<DomNennung> = transaction {
NennungTable.selectAll().where { NennungTable.turnierId eq turnierId.toJavaUuid() }
.map(::rowToNennung)
}
override suspend fun findByReiterId(reiterId: Uuid): List<DomNennung> = transaction {
NennungTable.selectAll().where { NennungTable.reiterId eq reiterId.toJavaUuid() }
.map(::rowToNennung)
}
override suspend fun findByPferdId(pferdId: Uuid): List<DomNennung> = transaction {
NennungTable.selectAll().where { NennungTable.pferdId eq pferdId.toJavaUuid() }
.map(::rowToNennung)
}
override suspend fun findByReiterIdAndTurnierId(reiterId: Uuid, turnierId: Uuid): List<DomNennung> = transaction {
NennungTable.selectAll().where {
(NennungTable.reiterId eq reiterId.toJavaUuid()) and
(NennungTable.turnierId eq turnierId.toJavaUuid())
}.map(::rowToNennung)
}
override suspend fun findByStatus(status: NennungsStatusE): List<DomNennung> = transaction {
NennungTable.selectAll().where { NennungTable.status eq status.name }
.map(::rowToNennung)
}
override suspend fun findNachnennungenByBewerbId(bewerbId: Uuid): List<DomNennung> = transaction {
NennungTable.selectAll().where {
(NennungTable.bewerbId eq bewerbId.toJavaUuid()) and
(NennungTable.istNachnennung eq true)
}.map(::rowToNennung)
}
override suspend fun save(nennung: DomNennung): DomNennung = transaction {
val now = Clock.System.now()
val existing = NennungTable.selectAll()
.where { NennungTable.id eq nennung.nennungId.toJavaUuid() }
.singleOrNull()
if (existing == null) {
NennungTable.insert { stmt ->
stmt[id] = nennung.nennungId.toJavaUuid()
stmt[abteilungId] = nennung.abteilungId.toJavaUuid()
stmt[bewerbId] = nennung.bewerbId.toJavaUuid()
stmt[turnierId] = nennung.turnierId.toJavaUuid()
stmt[reiterId] = nennung.reiterId.toJavaUuid()
stmt[pferdId] = nennung.pferdId.toJavaUuid()
stmt[zahlerId] = nennung.zahlerId?.toJavaUuid()
stmt[status] = nennung.status.name
stmt[startwunsch] = nennung.startwunsch.name
stmt[istNachnennung] = nennung.istNachnennung
stmt[nachnenngebuehrErlassen] = nennung.nachnenngebuehrErlassen
stmt[bemerkungen] = nennung.bemerkungen
stmt[createdAt] = nennung.createdAt
stmt[updatedAt] = now
}
nennung.copy(updatedAt = now)
} else {
NennungTable.update({ NennungTable.id eq nennung.nennungId.toJavaUuid() }) { stmt ->
stmt[abteilungId] = nennung.abteilungId.toJavaUuid()
stmt[bewerbId] = nennung.bewerbId.toJavaUuid()
stmt[turnierId] = nennung.turnierId.toJavaUuid()
stmt[reiterId] = nennung.reiterId.toJavaUuid()
stmt[pferdId] = nennung.pferdId.toJavaUuid()
stmt[zahlerId] = nennung.zahlerId?.toJavaUuid()
stmt[status] = nennung.status.name
stmt[startwunsch] = nennung.startwunsch.name
stmt[istNachnennung] = nennung.istNachnennung
stmt[nachnenngebuehrErlassen] = nennung.nachnenngebuehrErlassen
stmt[bemerkungen] = nennung.bemerkungen
stmt[updatedAt] = now
}
nennung.copy(updatedAt = now)
}
}
override suspend fun delete(id: Uuid): Boolean = transaction {
NennungTable.deleteWhere { NennungTable.id eq id.toJavaUuid() } > 0
}
override suspend fun countByBewerbId(bewerbId: Uuid): Long = transaction {
NennungTable.selectAll().where { NennungTable.bewerbId eq bewerbId.toJavaUuid() }.count()
}
override suspend fun countByAbteilungId(abteilungId: Uuid): Long = transaction {
NennungTable.selectAll().where { NennungTable.abteilungId eq abteilungId.toJavaUuid() }.count()
}
override suspend fun countByTurnierIdAndStatus(turnierId: Uuid, status: NennungsStatusE): Long = transaction {
NennungTable.selectAll().where {
(NennungTable.turnierId eq turnierId.toJavaUuid()) and
(NennungTable.status eq status.name)
}.count()
}
}
@@ -0,0 +1,46 @@
package at.mocode.entries.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed-Tabellendefinition für Nennungen (registration-context).
*/
object NennungTable : Table("nennungen") {
val id = javaUUID("id").autoGenerate()
// Competition References
val abteilungId = javaUUID("abteilung_id")
val bewerbId = javaUUID("bewerb_id")
val turnierId = javaUUID("turnier_id")
// Actor References (actor-context)
val reiterId = javaUUID("reiter_id")
val pferdId = javaUUID("pferd_id")
// Billing Reference (billing-context)
val zahlerId = javaUUID("zahler_id").nullable()
// Entry Details
val status = varchar("status", 50)
val startwunsch = varchar("startwunsch", 50)
val istNachnennung = bool("ist_nachnennung").default(false)
val nachnenngebuehrErlassen = bool("nachnenngebuehr_erlassen").default(false)
val bemerkungen = text("bemerkungen").nullable()
// Audit Fields
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
init {
index(false, turnierId)
index(false, bewerbId)
index(false, abteilungId)
index(false, reiterId)
index(false, pferdId)
index(false, status)
}
}
@@ -0,0 +1,67 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.persistence
import at.mocode.entries.domain.model.DomNennungsTransfer
import at.mocode.entries.domain.repository.NennungsTransferRepository
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Exposed-basierte Implementierung des NennungsTransferRepository.
*/
class NennungsTransferRepositoryImpl : NennungsTransferRepository {
private fun rowToTransfer(row: ResultRow): DomNennungsTransfer = DomNennungsTransfer(
transferId = row[NennungsTransferTable.id].toKotlinUuid(),
ursprungsNennungId = row[NennungsTransferTable.ursprungsNennungId].toKotlinUuid(),
neueNennungId = row[NennungsTransferTable.neueNennungId].toKotlinUuid(),
alterReiterId = row[NennungsTransferTable.alterReiterId]?.toKotlinUuid(),
neuerReiterId = row[NennungsTransferTable.neuerReiterId]?.toKotlinUuid(),
altesPferdId = row[NennungsTransferTable.altesPferdId]?.toKotlinUuid(),
neuesPferdId = row[NennungsTransferTable.neuesPferdId]?.toKotlinUuid(),
istNachNennschluss = row[NennungsTransferTable.istNachNennschluss],
nachnenngebuehrErlassen = row[NennungsTransferTable.nachnenngebuehrErlassen],
autorisiertVon = row[NennungsTransferTable.autorisiertVon].toKotlinUuid(),
grund = row[NennungsTransferTable.grund],
createdAt = row[NennungsTransferTable.createdAt]
)
override suspend fun findById(id: Uuid): DomNennungsTransfer? = transaction {
NennungsTransferTable.selectAll().where { NennungsTransferTable.id eq id.toJavaUuid() }
.map(::rowToTransfer)
.singleOrNull()
}
override suspend fun findByUrsprungsNennungId(nennungId: Uuid): List<DomNennungsTransfer> = transaction {
NennungsTransferTable.selectAll()
.where { NennungsTransferTable.ursprungsNennungId eq nennungId.toJavaUuid() }
.map(::rowToTransfer)
}
override suspend fun save(transfer: DomNennungsTransfer): DomNennungsTransfer = transaction {
val now = Clock.System.now()
NennungsTransferTable.insert { stmt ->
stmt[id] = transfer.transferId.toJavaUuid()
stmt[ursprungsNennungId] = transfer.ursprungsNennungId.toJavaUuid()
stmt[neueNennungId] = transfer.neueNennungId.toJavaUuid()
stmt[alterReiterId] = transfer.alterReiterId?.toJavaUuid()
stmt[neuerReiterId] = transfer.neuerReiterId?.toJavaUuid()
stmt[altesPferdId] = transfer.altesPferdId?.toJavaUuid()
stmt[neuesPferdId] = transfer.neuesPferdId?.toJavaUuid()
stmt[istNachNennschluss] = transfer.istNachNennschluss
stmt[nachnenngebuehrErlassen] = transfer.nachnenngebuehrErlassen
stmt[autorisiertVon] = transfer.autorisiertVon.toJavaUuid()
stmt[grund] = transfer.grund
stmt[createdAt] = now
}
transfer.copy(createdAt = now)
}
}
@@ -0,0 +1,46 @@
package at.mocode.entries.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed-Tabellendefinition für Nennungs-Transfers (registration-context).
*
* Ein Transfer ist KEIN Storno + Neunennung, sondern eine atomare Operation
* auf der bestehenden Nennung. Die ursprüngliche Nennung erhält Status TRANSFERIERT.
*/
object NennungsTransferTable : Table("nennungs_transfers") {
val id = javaUUID("id").autoGenerate()
// Linked Entries
val ursprungsNennungId = javaUUID("ursprungs_nennung_id")
val neueNennungId = javaUUID("neue_nennung_id")
// What changed Rider
val alterReiterId = javaUUID("alter_reiter_id").nullable()
val neuerReiterId = javaUUID("neuer_reiter_id").nullable()
// What changed Horse
val altesPferdId = javaUUID("altes_pferd_id").nullable()
val neuesPferdId = javaUUID("neues_pferd_id").nullable()
// Timing & Fees
val istNachNennschluss = bool("ist_nach_nennschluss").default(false)
val nachnenngebuehrErlassen = bool("nachnenngebuehr_erlassen").default(false)
// Authorization (Override-Event)
val autorisiertVon = javaUUID("autorisiert_von")
val grund = text("grund").nullable()
// Audit
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
init {
index(false, ursprungsNennungId)
index(false, neueNennungId)
index(false, autorisiertVon)
}
}
@@ -0,0 +1,222 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.usecase
import at.mocode.core.domain.model.NennungsStatusE
import at.mocode.entries.api.*
import at.mocode.entries.domain.model.DomNennung
import at.mocode.entries.domain.model.DomNennungsTransfer
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.domain.repository.NennungsTransferRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import kotlin.uuid.Uuid
/**
* Use Cases für den Nennungs-Workflow (registration-context).
*
* Kapselt die fachliche Logik gemäß ÖTO-Regelwerk:
* - Warn-Logik statt harter Fehler (TBA hat das letzte Wort)
* - Transfer = atomare Operation, kein Storno + Neunennung
* - Nachnenngebühr kann vom Veranstalter erlassen werden
*/
@Service
class NennungUseCases(
private val nennungRepository: NennungRepository,
private val transferRepository: NennungsTransferRepository
) {
private val log = LoggerFactory.getLogger(NennungUseCases::class.java)
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
suspend fun getNennungById(id: Uuid): NennungDetailDto? =
nennungRepository.findById(id)?.toDetailDto()
suspend fun getNennungenByTurnier(turnierId: Uuid): List<NennungSummaryDto> =
nennungRepository.findByTurnierId(turnierId).map { it.toSummaryDto() }
suspend fun getNennungenByBewerb(bewerbId: Uuid): List<NennungSummaryDto> =
nennungRepository.findByBewerbId(bewerbId).map { it.toSummaryDto() }
suspend fun getNennungenByAbteilung(abteilungId: Uuid): List<NennungSummaryDto> =
nennungRepository.findByAbteilungId(abteilungId).map { it.toSummaryDto() }
suspend fun getNennungenByReiter(reiterId: Uuid): List<NennungSummaryDto> =
nennungRepository.findByReiterId(reiterId).map { it.toSummaryDto() }
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/**
* Reicht eine neue Nennung ein (POST).
* Gibt eine Warnung aus wenn Nachnennung, aber blockiert nicht.
*/
suspend fun nennungEinreichen(request: NennungEinreichenRequest): NennungDetailDto {
if (request.istNachnennung) {
log.warn(
"NACHNENNUNG eingereicht für Turnier={} Bewerb={} Reiter={}",
request.turnierId, request.bewerbId, request.reiterId
)
}
val nennung = DomNennung(
abteilungId = request.abteilungId,
bewerbId = request.bewerbId,
turnierId = request.turnierId,
reiterId = request.reiterId,
pferdId = request.pferdId,
zahlerId = request.zahlerId,
startwunsch = request.startwunsch,
istNachnennung = request.istNachnennung,
bemerkungen = request.bemerkungen
)
val saved = nennungRepository.save(nennung)
log.info("Nennung eingereicht: nennungId={} turnierId={}", saved.nennungId, saved.turnierId)
return saved.toDetailDto()
}
/**
* Ändert den Status einer Nennung (PUT).
*/
suspend fun statusAendern(nennungId: Uuid, request: NennungStatusAendernRequest): NennungDetailDto {
val nennung = nennungRepository.findById(nennungId)
?: throw NoSuchElementException("Nennung nicht gefunden: $nennungId")
val updated = nennung.copy(
status = request.neuerStatus,
bemerkungen = request.bemerkungen ?: nennung.bemerkungen
).withUpdatedTimestamp()
val saved = nennungRepository.save(updated)
log.info("Nennungs-Status geändert: nennungId={} status={}", nennungId, request.neuerStatus)
return saved.toDetailDto()
}
/**
* Zieht eine Nennung zurück (DELETE → Status ZURUECKGEZOGEN).
*/
suspend fun nennungZurueckziehen(nennungId: Uuid): NennungDetailDto {
val nennung = nennungRepository.findById(nennungId)
?: throw NoSuchElementException("Nennung nicht gefunden: $nennungId")
val updated = nennung.copy(status = NennungsStatusE.ZURUECKGEZOGEN).withUpdatedTimestamp()
val saved = nennungRepository.save(updated)
log.info("Nennung zurückgezogen: nennungId={}", nennungId)
return saved.toDetailDto()
}
/**
* Führt einen Nennungs-Transfer durch (POST /transfer).
*
* Atomare Operation gemäß ÖTO:
* 1. Ursprungs-Nennung → Status TRANSFERIERT
* 2. Neue Nennung für neues Reiter-Pferd-Paar anlegen
* 3. Transfer-Record als Audit-Trail speichern
*/
suspend fun nennungTransferieren(nennungId: Uuid, request: NennungTransferRequest): NennungsTransferDto {
val ursprung = nennungRepository.findById(nennungId)
?: throw NoSuchElementException("Nennung nicht gefunden: $nennungId")
if (request.neuerReiterId == null && request.neuesPferdId == null) {
throw IllegalArgumentException("Transfer erfordert mindestens eine Änderung (Reiter oder Pferd)")
}
if (request.istNachNennschluss) {
log.warn(
"Transfer NACH NENNSCHLUSS: nennungId={} autorisiertVon={}",
nennungId, request.autorisiertVon
)
}
// 1. Ursprungs-Nennung schließen
val geschlosseneNennung = ursprung.copy(status = NennungsStatusE.TRANSFERIERT).withUpdatedTimestamp()
nennungRepository.save(geschlosseneNennung)
// 2. Neue Nennung anlegen
val neueNennung = DomNennung(
abteilungId = ursprung.abteilungId,
bewerbId = ursprung.bewerbId,
turnierId = ursprung.turnierId,
reiterId = request.neuerReiterId ?: ursprung.reiterId,
pferdId = request.neuesPferdId ?: ursprung.pferdId,
zahlerId = ursprung.zahlerId,
startwunsch = ursprung.startwunsch,
istNachnennung = request.istNachNennschluss,
nachnenngebuehrErlassen = request.nachnenngebuehrErlassen,
bemerkungen = ursprung.bemerkungen
)
val gespeicherteNeueNennung = nennungRepository.save(neueNennung)
// 3. Transfer-Record speichern
val transfer = DomNennungsTransfer(
ursprungsNennungId = ursprung.nennungId,
neueNennungId = gespeicherteNeueNennung.nennungId,
alterReiterId = if (request.neuerReiterId != null) ursprung.reiterId else null,
neuerReiterId = request.neuerReiterId,
altesPferdId = if (request.neuesPferdId != null) ursprung.pferdId else null,
neuesPferdId = request.neuesPferdId,
istNachNennschluss = request.istNachNennschluss,
nachnenngebuehrErlassen = request.nachnenngebuehrErlassen,
autorisiertVon = request.autorisiertVon,
grund = request.grund
)
val gespeicherterTransfer = transferRepository.save(transfer)
log.info(
"Nennungs-Transfer abgeschlossen: ursprung={} neu={} transferId={}",
nennungId, gespeicherteNeueNennung.nennungId, gespeicherterTransfer.transferId
)
return gespeicherterTransfer.toDto()
}
// ---------------------------------------------------------------------------
// Mapping Helpers
// ---------------------------------------------------------------------------
private fun DomNennung.toDetailDto() = NennungDetailDto(
nennungId = nennungId,
abteilungId = abteilungId,
bewerbId = bewerbId,
turnierId = turnierId,
reiterId = reiterId,
pferdId = pferdId,
zahlerId = zahlerId,
status = status,
startwunsch = startwunsch,
istNachnennung = istNachnennung,
nachnenngebuehrErlassen = nachnenngebuehrErlassen,
isNachnenngebuehrFaellig = isNachnenngebuehrFaellig(),
bemerkungen = bemerkungen,
createdAt = createdAt.toString(),
updatedAt = updatedAt.toString()
)
private fun DomNennung.toSummaryDto() = NennungSummaryDto(
nennungId = nennungId,
turnierId = turnierId,
bewerbId = bewerbId,
abteilungId = abteilungId,
reiterId = reiterId,
pferdId = pferdId,
status = status,
istNachnennung = istNachnennung,
createdAt = createdAt.toString()
)
private fun DomNennungsTransfer.toDto() = NennungsTransferDto(
transferId = transferId,
ursprungsNennungId = ursprungsNennungId,
neueNennungId = neueNennungId,
alterReiterId = alterReiterId,
neuerReiterId = neuerReiterId,
altesPferdId = altesPferdId,
neuesPferdId = neuesPferdId,
istNachNennschluss = istNachNennschluss,
nachnenngebuehrErlassen = nachnenngebuehrErlassen,
autorisiertVon = autorisiertVon,
grund = grund,
createdAt = createdAt.toString()
)
}
@@ -0,0 +1,154 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.events.domain.model
import at.mocode.core.domain.model.AusschreibungsStatusE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Aggregate Root für die offizielle Ausschreibung einer Veranstaltung gemäß ÖTO.
*
* Die Ausschreibung ist das offizielle Dokument, das alle relevanten Informationen
* für Nennende enthält (Bewerbe, Gebühren, Nennschluss, Richter, etc.).
* Sie muss vom Verband genehmigt werden, bevor Nennungen möglich sind.
*
* @property ausschreibungsId Eindeutige interne ID (UUID).
* @property veranstaltungId Referenz auf die zugehörige Veranstaltung.
* @property titel Offizieller Titel der Ausschreibung.
* @property sparten Enthaltene Sparten.
* @property nennschluss Nennschluss-Datum (Pflichtfeld gemäß ÖTO).
* @property nachnennung Ob Nachnennungen möglich sind und bis wann.
* @property nachnennungBis Datum bis zu dem Nachnennungen möglich sind (optional).
* @property nenngebuehrBasisCent Basis-Nenngebühr in Cent (zur Vermeidung von Floating-Point-Fehlern).
* @property nachnenngebuehrCent Nachnenngebühr in Cent (Aufschlag auf Basis-Nenngebühr).
* @property sportfoerderbeitragCent Sportförderbeitrag in Cent (ÖTO-Pflichtabgabe).
* @property tierwohleuroAktiv Ob der Tierwohl-Euro erhoben wird.
* @property veranstaltungsortBeschreibung Detaillierte Beschreibung des Veranstaltungsorts.
* @property anfahrtsBeschreibung Anfahrtsbeschreibung (optional).
* @property stallplatzeVerfuegbar Anzahl verfügbarer Stallplätze (optional).
* @property stallplatzgebuehrCent Stallplatzgebühr pro Nacht in Cent (optional).
* @property kontaktEmail Kontakt-E-Mail für Rückfragen.
* @property kontaktTelefon Kontakt-Telefonnummer (optional).
* @property zusatzinformationen Freitext für weitere Informationen.
* @property status Aktueller Status im Genehmigungsworkflow.
* @property eingereichtAm Datum der Einreichung beim Verband (optional).
* @property genehmigungsNummer Offizielle Genehmigungsnummer (nach Genehmigung).
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomAusschreibung(
@Serializable(with = UuidSerializer::class)
val ausschreibungsId: Uuid = Uuid.random(),
// Zugehörigkeit
@Serializable(with = UuidSerializer::class)
var veranstaltungId: Uuid,
// Basis-Informationen
var titel: String,
var sparten: List<SparteE> = emptyList(),
// Termine
var nennschluss: LocalDate,
var nachnennung: Boolean = false,
var nachnennungBis: LocalDate? = null,
// Gebühren (in Cent, Integer zur Vermeidung von Floating-Point-Fehlern)
var nenngebuehrBasisCent: Int = 0,
var nachnenngebuehrCent: Int = 0,
var sportfoerderbeitragCent: Int = 0,
var tierwohleuroAktiv: Boolean = true,
// Veranstaltungsort
var veranstaltungsortBeschreibung: String? = null,
var anfahrtsBeschreibung: String? = null,
// Stallplätze
var stallplatzeVerfuegbar: Int? = null,
var stallplatzgebuehrCent: Int? = null,
// Kontakt
var kontaktEmail: String? = null,
var kontaktTelefon: String? = null,
// Freitext
var zusatzinformationen: String? = null,
// Workflow-Status
var status: AusschreibungsStatusE = AusschreibungsStatusE.ENTWURF,
var eingereichtAm: LocalDate? = null,
var genehmigungsNummer: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Validiert die Pflichtfelder für die Einreichung beim Verband.
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateFuerEinreichung(): List<String> {
val warnings = mutableListOf<String>()
if (titel.isBlank()) {
warnings.add("Ausschreibungstitel ist erforderlich.")
}
if (sparten.isEmpty()) {
warnings.add("Mindestens eine Sparte muss angegeben werden.")
}
if (kontaktEmail.isNullOrBlank()) {
warnings.add("Kontakt-E-Mail ist für die Einreichung erforderlich.")
}
if (nenngebuehrBasisCent < 0) {
warnings.add("Nenngebühr darf nicht negativ sein.")
}
if (nachnenngebuehrCent < 0) {
warnings.add("Nachnenngebühr darf nicht negativ sein.")
}
if (nachnennung && nachnennungBis == null) {
warnings.add("Nachnennungs-Datum muss angegeben werden, wenn Nachnennungen erlaubt sind.")
}
nachnennungBis?.let { nb ->
if (nb <= nennschluss) {
warnings.add("Nachnennungs-Datum muss nach dem Nennschluss liegen.")
}
}
stallplatzgebuehrCent?.let { sg ->
if (sg < 0) {
warnings.add("Stallplatzgebühr darf nicht negativ sein.")
}
}
return warnings
}
/**
* Gibt die Basis-Nenngebühr als formatierten Euro-String zurück (z.B. "12,50 €").
*/
fun getNenngebuehrAlsEuroString(): String {
val euro = nenngebuehrBasisCent / 100
val cent = nenngebuehrBasisCent % 100
return "$euro,${cent.toString().padStart(2, '0')}"
}
/**
* Gibt die Gesamtgebühr (Nenngebühr + Sportförderbeitrag + ggf. Tierwohl-Euro) in Cent zurück.
*/
fun getGesamtgebuehrCent(): Int {
val tierwohl = if (tierwohleuroAktiv) 100 else 0
return nenngebuehrBasisCent + sportfoerderbeitragCent + tierwohl
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomAusschreibung = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,111 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.events.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
import at.mocode.core.domain.model.TurnierStatusE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Aggregate Root für ein einzelnes Turnier innerhalb einer Veranstaltung gemäß ÖTO § 2 Abs. 1.
*
* Ein Turnier ist die konkrete Durchführungseinheit (z.B. "Springturnier CSN-C Samstag").
* Eine Veranstaltung kann mehrere Turniere enthalten (z.B. Dressur-Turnier + Spring-Turnier).
* Jedes Turnier hat eine eigene Kategorie (CSN-C, CDN, etc.) und Sparte.
*
* @property turnierId Eindeutige interne ID (UUID).
* @property veranstaltungId Referenz auf die übergeordnete Veranstaltung.
* @property name Offizieller Name des Turniers.
* @property sparte Sparte des Turniers (Springen, Dressur, Vielseitigkeit, etc.).
* @property kategorie Turnierkategorie gemäß ÖTO (CSN-C, CDN, CAN, etc.).
* @property datum Datum des Turniers (kann innerhalb der Veranstaltungsdauer liegen).
* @property richterObmannId ID des Richter-Obmanns (Referenz auf officials-context).
* @property parcoursbauerId ID des Parcoursbauers (Referenz auf officials-context, nur Springen).
* @property status Aktueller Status des Turniers.
* @property maxBewerbe Maximale Anzahl an Bewerben (optional, aus Ausschreibung).
* @property istMeisterschaft Ob dieses Turnier Meisterschafts-Charakter hat.
* @property bemerkungen Interne Bemerkungen.
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomTurnier(
@Serializable(with = UuidSerializer::class)
val turnierId: Uuid = Uuid.random(),
// Zugehörigkeit
@Serializable(with = UuidSerializer::class)
var veranstaltungId: Uuid,
// Basis-Informationen
var name: String,
var sparte: SparteE,
var kategorie: TurnierkategorieE,
var datum: LocalDate,
// Funktionäre
@Serializable(with = UuidSerializer::class)
var richterObmannId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
var parcoursbauerId: Uuid? = null,
// Workflow-Status
var status: TurnierStatusE = TurnierStatusE.GEPLANT,
// Konfiguration
var maxBewerbe: Int? = null,
var istMeisterschaft: Boolean = false,
// Administrativ
var bemerkungen: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Prüft ob das Turnier Pflicht-Funktionäre zugewiesen hat.
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateFunktionaerBesetzung(): List<String> {
val warnings = mutableListOf<String>()
if (richterObmannId == null) {
warnings.add("Kein Richter-Obmann zugewiesen. Pflichtfeld für Turnierdurchführung (ÖTO).")
}
if (sparte == SparteE.SPRINGEN && parcoursbauerId == null) {
warnings.add("Kein Parcoursbauer zugewiesen. Pflichtfeld für Springturniere (ÖTO).")
}
return warnings
}
/**
* Validiert die Pflichtfelder für die Turnier-Planung.
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateFuerPlanung(): List<String> {
val warnings = mutableListOf<String>()
if (name.isBlank()) {
warnings.add("Turniername ist erforderlich.")
}
maxBewerbe?.let { max ->
if (max <= 0) {
warnings.add("Maximale Bewerb-Anzahl muss positiv sein.")
}
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomTurnier = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,147 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.events.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.VeranstaltungsStatusE
import at.mocode.core.domain.model.VeranstaltungsTypE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Aggregate Root für eine pferdesportliche Veranstaltung gemäß ÖTO § 2 Abs. 1.
*
* Eine Veranstaltung ist der organisatorische Rahmen (z.B. "Frühjahrsturnier Wiener Neustadt").
* Sie kann ein oder mehrere Turniere enthalten. Die Unterscheidung Veranstaltung ≠ Turnier
* ist eine zentrale ADR-Entscheidung (ADR-0003).
*
* @property veranstaltungId Eindeutige interne ID (UUID).
* @property name Offizieller Name der Veranstaltung.
* @property veranstaltungsTyp Typ gemäß ÖTO (z.B. NATIONAL, INTERNATIONAL).
* @property sparten Liste der enthaltenen Sparten (Springen, Dressur, etc.).
* @property veranstalterVereinId ID des veranstaltenden Vereins (Referenz auf clubs-context).
* @property verantwortlicheFunktionaerId ID des verantwortlichen Funktionärs (Referenz auf officials-context).
* @property startDatum Erster Veranstaltungstag.
* @property endDatum Letzter Veranstaltungstag.
* @property ort Veranstaltungsort (Adresse / Reitanlage).
* @property nennschluss Nennschluss-Datum gemäß Ausschreibung.
* @property status Aktueller Status im Planungs-Workflow.
* @property ausschreibungsId Referenz auf die zugehörige Ausschreibung (optional bis Genehmigung).
* @property oepsGenehmigungsNummer Offizielle Genehmigungsnummer des Verbands (nach Genehmigung).
* @property bemerkungen Interne Bemerkungen.
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomVeranstaltung(
@Serializable(with = UuidSerializer::class)
val veranstaltungId: Uuid = Uuid.random(),
// Basis-Informationen
var name: String,
var veranstaltungsTyp: VeranstaltungsTypE,
var sparten: List<SparteE> = emptyList(),
// Organisation
@Serializable(with = UuidSerializer::class)
var veranstalterVereinId: Uuid,
@Serializable(with = UuidSerializer::class)
var verantwortlicheFunktionaerId: Uuid? = null,
// Termine
var startDatum: LocalDate,
var endDatum: LocalDate,
var ort: String,
var nennschluss: LocalDate? = null,
// Workflow-Status
var status: VeranstaltungsStatusE = VeranstaltungsStatusE.IN_PLANUNG,
// Verknüpfungen
@Serializable(with = UuidSerializer::class)
var ausschreibungsId: Uuid? = null,
var oepsGenehmigungsNummer: String? = null,
// Administrativ
var bemerkungen: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Gibt die Dauer der Veranstaltung in Tagen zurück.
*/
fun getDauerInTagen(): Int =
(endDatum.toEpochDays() - startDatum.toEpochDays()).toInt() + 1
/**
* Prüft ob die Veranstaltung mehrtägig ist.
*/
fun istMehrtaegig(): Boolean = startDatum != endDatum
/**
* Prüft ob Nennungen aktuell möglich sind (Status GENEHMIGT und Nennschluss nicht abgelaufen).
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateNennungsmoeglichkeit(): List<String> {
val warnings = mutableListOf<String>()
if (status != VeranstaltungsStatusE.GENEHMIGT) {
warnings.add(
"Veranstaltung ist nicht im Status GENEHMIGT (aktuell: $status). " +
"Nennungen sind erst nach Genehmigung möglich."
)
}
if (nennschluss == null) {
warnings.add("Kein Nennschluss definiert. Bitte Ausschreibung vervollständigen.")
}
if (veranstalterVereinId == null) {
warnings.add("Kein Veranstalter-Verein zugewiesen.")
}
return warnings
}
/**
* Validiert die Pflichtfelder für die Einreichung beim Verband.
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateFuerEinreichung(): List<String> {
val warnings = mutableListOf<String>()
if (name.isBlank()) {
warnings.add("Veranstaltungsname ist erforderlich.")
}
if (ort.isBlank()) {
warnings.add("Veranstaltungsort ist erforderlich.")
}
if (endDatum < startDatum) {
warnings.add("Enddatum darf nicht vor dem Startdatum liegen.")
}
if (sparten.isEmpty()) {
warnings.add("Mindestens eine Sparte muss angegeben werden.")
}
if (nennschluss == null) {
warnings.add("Nennschluss ist für die Einreichung erforderlich.")
}
nennschluss?.let { nl ->
if (nl >= startDatum) {
warnings.add("Nennschluss muss vor dem Veranstaltungsbeginn liegen (§ 2 ÖTO).")
}
}
if (ausschreibungsId == null) {
warnings.add("Keine Ausschreibung verknüpft. Einreichung ohne Ausschreibung nicht möglich.")
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomVeranstaltung = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,126 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.officials.domain.model
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.FunktionaerRolleE
import at.mocode.core.domain.model.RichterQualifikationE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain-Modell für einen Funktionär im actor-context.
*
* Repräsentiert eine Person mit einer definierten Rolle bei Turnieren (Richter, TBA,
* Parcoursbauer, etc.). Die Qualifikation wird gegen `RICHT01.DAT` aus dem ZNS geprüft.
*
* Aggregate Root des `officials`-Bounded Context.
*
* @property funktionaerId Eindeutige interne ID (UUID).
* @property richterNummer ÖPS-Funktionärsnummer aus ZNS (RICHT01.dat), 6-stellig.
* @property vorname Vorname der Person.
* @property nachname Nachname der Person.
* @property geburtsdatum Geburtsdatum (optional, für Altersklassen-Prüfung).
* @property rollen Menge der Rollen, die diese Person ausüben darf (TBA, Richter, ...).
* @property richterQualifikation Qualifikationsstufe als Richter (GA, G1G3, International).
* @property qualifiziertFuerSparten Sparten, für die eine Richter-Qualifikation vorliegt.
* @property email E-Mail-Adresse für Kommunikation.
* @property telefon Telefonnummer.
* @property vereinsNummer Vereinsnummer des Heimvereins (Referenz auf DomVerein).
* @property istAktiv Ob der Funktionär aktuell aktiv/einsatzbereit ist.
* @property bemerkungen Interne Notizen.
* @property datenQuelle Herkunft des Datensatzes (ZNS-Import oder manuell).
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomFunktionaer(
@Serializable(with = UuidSerializer::class)
val funktionaerId: Uuid = Uuid.random(),
// Identifikation
val richterNummer: String? = null,
// Persönliche Daten
var vorname: String,
var nachname: String,
var geburtsdatum: LocalDate? = null,
// Qualifikation & Rollen
var rollen: Set<FunktionaerRolleE> = emptySet(),
var richterQualifikation: RichterQualifikationE? = null,
var qualifiziertFuerSparten: Set<SparteE> = emptySet(),
// Kontakt
var email: String? = null,
var telefon: String? = null,
// Vereinszugehörigkeit
var vereinsNummer: String? = null,
// Status & Verwaltung
var istAktiv: Boolean = true,
var bemerkungen: String? = null,
var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Gibt den vollständigen Anzeigenamen zurück.
*/
fun getDisplayName(): String = "$vorname $nachname"
/**
* Gibt den Anzeigenamen mit Funktionärsnummer zurück (falls vorhanden).
*/
fun getDisplayNameWithNummer(): String =
richterNummer?.let { "${getDisplayName()} ($it)" } ?: getDisplayName()
/**
* Prüft, ob der Funktionär als Richter für eine bestimmte Sparte qualifiziert ist.
*/
fun istRichterFuerSparte(sparte: SparteE): Boolean =
rollen.contains(FunktionaerRolleE.RICHTER) && qualifiziertFuerSparten.contains(sparte)
/**
* Prüft, ob der Funktionär die Rolle TBA ausüben darf.
*/
fun istTba(): Boolean = rollen.contains(FunktionaerRolleE.TBA)
/**
* Validiert die Pflichtfelder für den Turniereinsatz.
* Gibt eine Liste von Warnungen zurück (kein harter Fehler Override-Event möglich).
*/
fun validateFuerTurniereinsatz(rolle: FunktionaerRolleE, sparte: SparteE? = null): List<String> {
val warnings = mutableListOf<String>()
if (!istAktiv) {
warnings.add("Funktionär ${getDisplayName()} ist nicht aktiv.")
}
if (!rollen.contains(rolle)) {
warnings.add("Funktionär ${getDisplayName()} hat keine Qualifikation für Rolle $rolle.")
}
if (rolle == FunktionaerRolleE.RICHTER && sparte != null && !istRichterFuerSparte(sparte)) {
warnings.add("Funktionär ${getDisplayName()} ist nicht als Richter für Sparte $sparte qualifiziert.")
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomFunktionaer = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,93 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.officials.domain.repository
import at.mocode.core.domain.model.FunktionaerRolleE
import at.mocode.core.domain.model.RichterQualifikationE
import at.mocode.core.domain.model.SparteE
import at.mocode.officials.domain.model.DomFunktionaer
import kotlin.uuid.Uuid
/**
* Repository-Interface für DomFunktionaer (Funktionär) Domain-Operationen.
*
* Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit
* von konkreten Implementierungsdetails (Datenbank, etc.).
*/
interface FunktionaerRepository {
/**
* Sucht einen Funktionär anhand seiner eindeutigen ID.
*/
suspend fun findById(id: Uuid): DomFunktionaer?
/**
* Sucht einen Funktionär anhand seiner Richternummer.
*/
suspend fun findByRichterNummer(richterNummer: String): DomFunktionaer?
/**
* Sucht Funktionäre anhand von Vor- und/oder Nachname (Teilübereinstimmung).
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<DomFunktionaer>
/**
* Sucht alle Funktionäre mit einer bestimmten Rolle.
*/
suspend fun findByRolle(rolle: FunktionaerRolleE, activeOnly: Boolean = true): List<DomFunktionaer>
/**
* Sucht alle Richter mit einer bestimmten Qualifikation.
*/
suspend fun findByRichterQualifikation(
qualifikation: RichterQualifikationE,
activeOnly: Boolean = true
): List<DomFunktionaer>
/**
* Sucht alle Funktionäre, die für eine bestimmte Sparte qualifiziert sind.
*/
suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean = true): List<DomFunktionaer>
/**
* Sucht alle Funktionäre eines bestimmten Vereins.
*/
suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean = true): List<DomFunktionaer>
/**
* Gibt alle aktiven Funktionäre zurück (paginiert).
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<DomFunktionaer>
/**
* Gibt alle Funktionäre zurück (paginiert).
*/
suspend fun findAll(limit: Int = 100, offset: Int = 0): List<DomFunktionaer>
/**
* Speichert einen Funktionär (Insert oder Update).
*/
suspend fun save(funktionaer: DomFunktionaer): DomFunktionaer
/**
* Löscht einen Funktionär anhand seiner ID.
*
* @return true wenn gelöscht, false wenn nicht gefunden
*/
suspend fun delete(id: Uuid): Boolean
/**
* Zählt alle aktiven Funktionäre.
*/
suspend fun countActive(): Long
/**
* Zählt alle Richter (Rolle = RICHTER) mit einer bestimmten Qualifikation.
*/
suspend fun countByRichterQualifikation(qualifikation: RichterQualifikationE, activeOnly: Boolean = true): Long
/**
* Prüft ob ein Funktionär mit der gegebenen Richternummer bereits existiert.
*/
suspend fun existsByRichterNummer(richterNummer: String): Boolean
}
@@ -0,0 +1,186 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.officials.infrastructure.persistence
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.FunktionaerRolleE
import at.mocode.core.domain.model.RichterQualifikationE
import at.mocode.core.domain.model.SparteE
import at.mocode.officials.domain.model.DomFunktionaer
import at.mocode.officials.domain.repository.FunktionaerRepository
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.core.statements.UpdateBuilder
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.util.*
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Exposed-basierte Implementierung des FunktionaerRepository.
*/
class ExposedFunktionaerRepository : FunktionaerRepository {
override suspend fun findById(id: Uuid): DomFunktionaer? = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.id eq id.toJavaUuid() }
.map { rowToFunktionaer(it) }
.singleOrNull()
}
override suspend fun findByRichterNummer(richterNummer: String): DomFunktionaer? = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.richterNummer eq richterNummer }
.map { rowToFunktionaer(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<DomFunktionaer> = transaction {
val pattern = "%$searchTerm%"
FunktionaerTable.selectAll().where {
(FunktionaerTable.nachname like pattern) or (FunktionaerTable.vorname like pattern)
}.limit(limit).map { rowToFunktionaer(it) }
}
override suspend fun findByRolle(rolle: FunktionaerRolleE, activeOnly: Boolean): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.rollen like "%${rolle.name}%").let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.map { rowToFunktionaer(it) }
}
override suspend fun findByRichterQualifikation(
qualifikation: RichterQualifikationE,
activeOnly: Boolean
): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.richterQualifikation eq qualifikation.name).let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.map { rowToFunktionaer(it) }
}
override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.qualifiziertFuerSparten like "%${sparte.name}%").let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.map { rowToFunktionaer(it) }
}
override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List<DomFunktionaer> =
transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.vereinsNummer eq vereinsNummer).let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.map { rowToFunktionaer(it) }
}
override suspend fun findAllActive(limit: Int, offset: Int): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.istAktiv eq true }
.limit(limit).offset(offset.toLong())
.map { rowToFunktionaer(it) }
}
override suspend fun findAll(limit: Int, offset: Int): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll()
.limit(limit).offset(offset.toLong())
.map { rowToFunktionaer(it) }
}
override suspend fun save(funktionaer: DomFunktionaer): DomFunktionaer = transaction {
val now = Clock.System.now()
val updated = funktionaer.copy(updatedAt = now)
val javaId = funktionaer.funktionaerId.toJavaUuid()
val existing = FunktionaerTable.selectAll().where { FunktionaerTable.id eq javaId }.singleOrNull()
if (existing != null) {
FunktionaerTable.update({ FunktionaerTable.id eq javaId }) { funktionaerToStatement(it, updated) }
} else {
FunktionaerTable.insert {
it[id] = javaId
funktionaerToStatement(it, updated)
}
}
updated
}
override suspend fun delete(id: Uuid): Boolean = transaction {
FunktionaerTable.deleteWhere { FunktionaerTable.id eq id.toJavaUuid() } > 0
}
override suspend fun countActive(): Long = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.istAktiv eq true }.count()
}
override suspend fun countByRichterQualifikation(qualifikation: RichterQualifikationE, activeOnly: Boolean): Long =
transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.richterQualifikation eq qualifikation.name).let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.count()
}
override suspend fun existsByRichterNummer(richterNummer: String): Boolean = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.richterNummer eq richterNummer }.count() > 0
}
private fun rowToFunktionaer(row: ResultRow): DomFunktionaer {
val rollen = try {
Json.decodeFromString<List<FunktionaerRolleE>>(row[FunktionaerTable.rollen]).toSet()
} catch (_: Exception) {
emptySet()
}
val sparten = try {
Json.decodeFromString<List<SparteE>>(row[FunktionaerTable.qualifiziertFuerSparten]).toSet()
} catch (_: Exception) {
emptySet()
}
return DomFunktionaer(
funktionaerId = (row[FunktionaerTable.id] as UUID).toKotlinUuid(),
richterNummer = row[FunktionaerTable.richterNummer],
vorname = row[FunktionaerTable.vorname],
nachname = row[FunktionaerTable.nachname],
geburtsdatum = row[FunktionaerTable.geburtsdatum],
rollen = rollen,
richterQualifikation = row[FunktionaerTable.richterQualifikation]?.let {
runCatching { RichterQualifikationE.valueOf(it) }.getOrNull()
},
qualifiziertFuerSparten = sparten,
email = row[FunktionaerTable.email],
telefon = row[FunktionaerTable.telefon],
vereinsNummer = row[FunktionaerTable.vereinsNummer],
istAktiv = row[FunktionaerTable.istAktiv],
bemerkungen = row[FunktionaerTable.bemerkungen],
datenQuelle = runCatching { DatenQuelleE.valueOf(row[FunktionaerTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS),
createdAt = row[FunktionaerTable.createdAt],
updatedAt = row[FunktionaerTable.updatedAt]
)
}
private fun funktionaerToStatement(stmt: UpdateBuilder<*>, f: DomFunktionaer) {
stmt[FunktionaerTable.richterNummer] = f.richterNummer
stmt[FunktionaerTable.vorname] = f.vorname
stmt[FunktionaerTable.nachname] = f.nachname
stmt[FunktionaerTable.geburtsdatum] = f.geburtsdatum
stmt[FunktionaerTable.rollen] = Json.encodeToString(f.rollen.toList())
stmt[FunktionaerTable.richterQualifikation] = f.richterQualifikation?.name
stmt[FunktionaerTable.qualifiziertFuerSparten] = Json.encodeToString(f.qualifiziertFuerSparten.toList())
stmt[FunktionaerTable.email] = f.email
stmt[FunktionaerTable.telefon] = f.telefon
stmt[FunktionaerTable.vereinsNummer] = f.vereinsNummer
stmt[FunktionaerTable.istAktiv] = f.istAktiv
stmt[FunktionaerTable.bemerkungen] = f.bemerkungen
stmt[FunktionaerTable.datenQuelle] = f.datenQuelle.name
stmt[FunktionaerTable.createdAt] = f.createdAt
stmt[FunktionaerTable.updatedAt] = f.updatedAt
}
}
@@ -0,0 +1,53 @@
package at.mocode.officials.infrastructure.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.date
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed-Tabellendefinition für Funktionäre (DomFunktionaer).
*
* Speichert alle Funktionärs-Daten inkl. Rollen (JSON), Richterqualifikation
* und Sparten-Qualifikationen (JSON) gemäß ÖTO-Anforderungen.
*/
object FunktionaerTable : Table("funktionaere") {
val id = javaUUID("id").autoGenerate()
override val primaryKey = PrimaryKey(id)
// Identifikation
val richterNummer = varchar("richter_nummer", 50).nullable()
// Persönliche Daten
val vorname = varchar("vorname", 100)
val nachname = varchar("nachname", 100)
val geburtsdatum = date("geburtsdatum").nullable()
// Rollen & Qualifikationen (als JSON-Arrays gespeichert)
val rollen = text("rollen") // JSON array of FunktionaerRolleE
val richterQualifikation = varchar("richter_qualifikation", 50).nullable()
val qualifiziertFuerSparten = text("qualifiziert_fuer_sparten") // JSON array of SparteE
// Kontaktdaten
val email = varchar("email", 255).nullable()
val telefon = varchar("telefon", 50).nullable()
val vereinsNummer = varchar("vereins_nummer", 20).nullable()
// Status & Verwaltung
val istAktiv = bool("ist_aktiv").default(true)
val bemerkungen = text("bemerkungen").nullable()
val datenQuelle = varchar("daten_quelle", 50)
// Audit-Felder
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
init {
index(false, nachname)
index(false, vorname)
index(false, vereinsNummer)
index(false, istAktiv)
index(false, richterQualifikation)
}
}
@@ -0,0 +1,82 @@
-- Migration V001: Create Funktionaere (Officials) table
-- Speichert alle Funktionärs-Daten inkl. Rollen (JSON), Richterqualifikation
-- und Sparten-Qualifikationen (JSON) gemäß ÖTO-Anforderungen.
CREATE TABLE IF NOT EXISTS funktionaere
(
id
UUID
PRIMARY
KEY
DEFAULT
gen_random_uuid
(
),
richter_nummer VARCHAR
(
50
),
vorname VARCHAR
(
100
) NOT NULL,
nachname VARCHAR
(
100
) NOT NULL,
geburtsdatum DATE,
rollen TEXT NOT NULL DEFAULT '[]',
richter_qualifikation VARCHAR
(
50
),
qualifiziert_fuer_sparten TEXT NOT NULL DEFAULT '[]',
email VARCHAR
(
255
),
telefon VARCHAR
(
50
),
vereins_nummer VARCHAR
(
20
),
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
bemerkungen TEXT,
daten_quelle VARCHAR
(
50
) NOT NULL DEFAULT 'MANUELL',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Unique index für Richternummer (wenn gesetzt)
CREATE UNIQUE INDEX IF NOT EXISTS uk_funktionaere_richter_nummer
ON funktionaere(richter_nummer)
WHERE richter_nummer IS NOT NULL;
-- Performance-Indizes
CREATE INDEX IF NOT EXISTS idx_funktionaere_nachname ON funktionaere(nachname);
CREATE INDEX IF NOT EXISTS idx_funktionaere_vorname ON funktionaere(vorname);
CREATE INDEX IF NOT EXISTS idx_funktionaere_vereins_nummer ON funktionaere(vereins_nummer);
CREATE INDEX IF NOT EXISTS idx_funktionaere_ist_aktiv ON funktionaere(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_funktionaere_richter_qual ON funktionaere(richter_qualifikation);
-- Dokumentation
COMMENT
ON TABLE funktionaere IS 'Funktionäre (Richter, Parcoursbauer, TBA, etc.) gemäß ÖTO-Regelwerk';
COMMENT
ON COLUMN funktionaere.id IS 'Eindeutige interne ID (UUID)';
COMMENT
ON COLUMN funktionaere.richter_nummer IS 'Offizielle OEPS-Richternummer (eindeutig, optional)';
COMMENT
ON COLUMN funktionaere.rollen IS 'JSON-Array der Funktionärs-Rollen (FunktionaerRolleE)';
COMMENT
ON COLUMN funktionaere.richter_qualifikation IS 'Richter-Qualifikationsstufe (RichterQualifikationE): GA, G1, G2, G3, INTERNATIONAL';
COMMENT
ON COLUMN funktionaere.qualifiziert_fuer_sparten IS 'JSON-Array der Sparten-Qualifikationen (SparteE)';
COMMENT
ON COLUMN funktionaere.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI';
@@ -0,0 +1,136 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.persons.domain.model
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.KotlinLocalDateSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain model representing a rider (Reiter) in the actor-context.
*
* A rider is a specialization of a person with additional equestrian-specific
* attributes such as license, start card, and competition eligibility.
* Data is primarily sourced from the OEPS ZNS (LIZENZ01.DAT).
*
* Key rules (ÖTO):
* - A rider requires an active Startkarte (annual fee paid) to compete nationally.
* - LizenzKlasse determines which competition classes the rider may enter.
* - Satznummer (6-digit) is the primary key for ZNS data exchange.
* - Kopfnummer is NOT a unique identifier it can change.
*
* @property reiterId Unique internal identifier (UUID).
* @property personId Reference to the base DomPerson record (UUID).
* @property satznummer 6-digit ZNS primary key for data exchange. Primary key for ZNS.
* @property lizenzNummer OEPS license number (from ZNS LIZENZ01.DAT).
* @property lizenzKlasse License class determining competition eligibility (e.g. R1, RD2).
* @property lizenzSparten Disciplines for which the license is valid.
* @property startkartAktiv Whether the annual start card fee has been paid.
* @property startkartSaison Season year for which the start card is valid (e.g. 2026).
* @property feiId FEI international rider ID (optional).
* @property nation Nation code (e.g. AUT).
* @property geburtsdatum Date of birth (for age class validation).
* @property vereinsNummer Club number (OEPS).
* @property vereinsName Club name.
* @property istGastreiter Whether the rider is a guest rider (foreign nationality, not in Austrian club).
* @property istAktiv Whether the rider is currently active in the system.
* @property datenQuelle Source of the data.
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class DomReiter(
@Serializable(with = UuidSerializer::class)
val reiterId: Uuid = Uuid.random(),
// Reference to base person
@Serializable(with = UuidSerializer::class)
val personId: Uuid,
// ZNS Identification
val satznummer: String,
val lizenzNummer: String? = null,
// License & Eligibility
val lizenzKlasse: LizenzKlasseE = LizenzKlasseE.LIZENZFREI,
val lizenzSparten: List<SparteE> = emptyList(),
// Start Card (Startkarte) annual fee proof
val startkartAktiv: Boolean = false,
val startkartSaison: Int? = null,
// International
val feiId: String? = null,
val nation: String? = null,
// Personal Data (denormalized from DomPerson for performance)
val nachname: String,
val vorname: String,
@Serializable(with = KotlinLocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
// Club Affiliation
val vereinsNummer: String? = null,
val vereinsName: String? = null,
// Status
val istGastreiter: Boolean = false,
val istAktiv: Boolean = true,
val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
// Audit Fields
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Returns the display name of the rider.
*/
fun getDisplayName(): String = "$vorname $nachname"
/**
* Checks if the rider is eligible to compete nationally.
* Requires an active start card (Startkarte).
*/
fun isStartberechtigt(): Boolean = istAktiv && startkartAktiv
/**
* Checks if the rider holds a license for the given discipline.
*/
fun hasLizenzForSparte(sparte: SparteE): Boolean =
lizenzKlasse == LizenzKlasseE.LIZENZFREI || lizenzSparten.contains(sparte)
/**
* Validates the rider for competition entry.
* Returns a list of warning messages (never hard errors TBA has final say).
*/
fun validateForNennung(sparte: SparteE): List<String> {
val warnings = mutableListOf<String>()
if (!istAktiv) {
warnings.add("Reiter ${getDisplayName()} ist nicht aktiv")
}
if (!startkartAktiv) {
warnings.add("Reiter ${getDisplayName()} hat keine aktive Startkarte für Saison $startkartSaison")
}
if (!hasLizenzForSparte(sparte)) {
warnings.add("Reiter ${getDisplayName()} hat keine Lizenz für Sparte $sparte (Lizenzklasse: $lizenzKlasse)")
}
return warnings
}
/**
* Creates a copy of this rider with an updated timestamp.
*/
fun withUpdatedTimestamp(): DomReiter = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,89 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.persons.domain.repository
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.persons.domain.model.DomReiter
import kotlin.uuid.Uuid
/**
* Repository-Interface für DomReiter (Reiter) Domain-Operationen.
*
* Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit
* von konkreten Implementierungsdetails (Datenbank, etc.).
*/
interface ReiterRepository {
/**
* Sucht einen Reiter anhand seiner eindeutigen ID.
*/
suspend fun findById(id: Uuid): DomReiter?
/**
* Sucht einen Reiter anhand seiner Satznummer (OEPS-Mitgliedsnummer).
*/
suspend fun findBySatznummer(satznummer: String): DomReiter?
/**
* Sucht einen Reiter anhand seiner FEI-ID.
*/
suspend fun findByFeiId(feiId: String): DomReiter?
/**
* Sucht Reiter anhand von Vor- und/oder Nachname (Teilübereinstimmung).
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<DomReiter>
/**
* Sucht alle Reiter eines bestimmten Vereins.
*/
suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean = true): List<DomReiter>
/**
* Sucht alle Reiter mit einer bestimmten Lizenzklasse.
*/
suspend fun findByLizenzKlasse(lizenzKlasse: LizenzKlasseE, activeOnly: Boolean = true): List<DomReiter>
/**
* Sucht alle Reiter, die für eine bestimmte Sparte lizenziert sind.
*/
suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean = true): List<DomReiter>
/**
* Sucht alle Gastreiter.
*/
suspend fun findGastreiter(activeOnly: Boolean = true): List<DomReiter>
/**
* Gibt alle aktiven Reiter zurück (paginiert).
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<DomReiter>
/**
* Gibt alle Reiter zurück (paginiert).
*/
suspend fun findAll(limit: Int = 100, offset: Int = 0): List<DomReiter>
/**
* Speichert einen Reiter (Insert oder Update).
*/
suspend fun save(reiter: DomReiter): DomReiter
/**
* Löscht einen Reiter anhand seiner ID.
*
* @return true wenn gelöscht, false wenn nicht gefunden
*/
suspend fun delete(id: Uuid): Boolean
/**
* Zählt alle aktiven Reiter.
*/
suspend fun countActive(): Long
/**
* Prüft ob ein Reiter mit der gegebenen Satznummer bereits existiert.
*/
suspend fun existsBySatznummer(satznummer: String): Boolean
}
@@ -0,0 +1,174 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.persons.infrastructure.persistence
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.persons.domain.model.DomReiter
import at.mocode.persons.domain.repository.ReiterRepository
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.core.statements.UpdateBuilder
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.util.*
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Exposed-basierte Implementierung des ReiterRepository.
*/
class ExposedReiterRepository : ReiterRepository {
override suspend fun findById(id: Uuid): DomReiter? = transaction {
ReiterTable.selectAll().where { ReiterTable.id eq id.toJavaUuid() }
.map { rowToReiter(it) }
.singleOrNull()
}
override suspend fun findBySatznummer(satznummer: String): DomReiter? = transaction {
ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer }
.map { rowToReiter(it) }
.singleOrNull()
}
override suspend fun findByFeiId(feiId: String): DomReiter? = transaction {
ReiterTable.selectAll().where { ReiterTable.feiId eq feiId }
.map { rowToReiter(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<DomReiter> = transaction {
val pattern = "%$searchTerm%"
ReiterTable.selectAll().where {
(ReiterTable.nachname like pattern) or (ReiterTable.vorname like pattern)
}.limit(limit).map { rowToReiter(it) }
}
override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List<DomReiter> = transaction {
ReiterTable.selectAll().where {
(ReiterTable.vereinsNummer eq vereinsNummer).let {
if (activeOnly) it and (ReiterTable.istAktiv eq true) else it
}
}.map { rowToReiter(it) }
}
override suspend fun findByLizenzKlasse(lizenzKlasse: LizenzKlasseE, activeOnly: Boolean): List<DomReiter> =
transaction {
ReiterTable.selectAll().where {
(ReiterTable.lizenzKlasse eq lizenzKlasse.name).let {
if (activeOnly) it and (ReiterTable.istAktiv eq true) else it
}
}.map { rowToReiter(it) }
}
override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List<DomReiter> = transaction {
ReiterTable.selectAll().where {
(ReiterTable.lizenziertFuerSparten like "%${sparte.name}%").let {
if (activeOnly) it and (ReiterTable.istAktiv eq true) else it
}
}.map { rowToReiter(it) }
}
override suspend fun findGastreiter(activeOnly: Boolean): List<DomReiter> = transaction {
ReiterTable.selectAll().where {
(ReiterTable.istGastreiter eq true).let {
if (activeOnly) it and (ReiterTable.istAktiv eq true) else it
}
}.map { rowToReiter(it) }
}
override suspend fun findAllActive(limit: Int, offset: Int): List<DomReiter> = transaction {
ReiterTable.selectAll().where { ReiterTable.istAktiv eq true }
.limit(limit).offset(offset.toLong())
.map { rowToReiter(it) }
}
override suspend fun findAll(limit: Int, offset: Int): List<DomReiter> = transaction {
ReiterTable.selectAll()
.limit(limit).offset(offset.toLong())
.map { rowToReiter(it) }
}
override suspend fun save(reiter: DomReiter): DomReiter = transaction {
val now = Clock.System.now()
val updated = reiter.copy(updatedAt = now)
val javaId = reiter.reiterId.toJavaUuid()
val existing = ReiterTable.selectAll().where { ReiterTable.id eq javaId }.singleOrNull()
if (existing != null) {
ReiterTable.update({ ReiterTable.id eq javaId }) { reiterToStatement(it, updated) }
} else {
ReiterTable.insert {
it[id] = javaId
reiterToStatement(it, updated)
}
}
updated
}
override suspend fun delete(id: Uuid): Boolean = transaction {
ReiterTable.deleteWhere { ReiterTable.id eq id.toJavaUuid() } > 0
}
override suspend fun countActive(): Long = transaction {
ReiterTable.selectAll().where { ReiterTable.istAktiv eq true }.count()
}
override suspend fun existsBySatznummer(satznummer: String): Boolean = transaction {
ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer }.count() > 0
}
private fun rowToReiter(row: ResultRow): DomReiter {
val sparten = try {
Json.decodeFromString<List<SparteE>>(row[ReiterTable.lizenziertFuerSparten])
} catch (_: Exception) {
emptyList()
}
return DomReiter(
reiterId = (row[ReiterTable.id] as UUID).toKotlinUuid(),
personId = (row[ReiterTable.id] as UUID).toKotlinUuid(), // same as reiterId for now
satznummer = row[ReiterTable.satznummer] ?: "",
feiId = row[ReiterTable.feiId],
nation = row[ReiterTable.nation],
vorname = row[ReiterTable.vorname],
nachname = row[ReiterTable.nachname],
geburtsdatum = row[ReiterTable.geburtsdatum],
vereinsNummer = row[ReiterTable.vereinsNummer],
vereinsName = row[ReiterTable.vereinsName],
lizenzKlasse = runCatching { LizenzKlasseE.valueOf(row[ReiterTable.lizenzKlasse] ?: "") }.getOrDefault(
LizenzKlasseE.LIZENZFREI
),
lizenzSparten = sparten,
istGastreiter = row[ReiterTable.istGastreiter],
istAktiv = row[ReiterTable.istAktiv],
datenQuelle = runCatching { DatenQuelleE.valueOf(row[ReiterTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS),
createdAt = row[ReiterTable.createdAt],
updatedAt = row[ReiterTable.updatedAt]
)
}
private fun reiterToStatement(stmt: UpdateBuilder<*>, r: DomReiter) {
stmt[ReiterTable.satznummer] = r.satznummer
stmt[ReiterTable.feiId] = r.feiId
stmt[ReiterTable.vorname] = r.vorname
stmt[ReiterTable.nachname] = r.nachname
stmt[ReiterTable.geburtsdatum] = r.geburtsdatum
stmt[ReiterTable.nation] = r.nation
stmt[ReiterTable.vereinsNummer] = r.vereinsNummer
stmt[ReiterTable.vereinsName] = r.vereinsName
stmt[ReiterTable.lizenzKlasse] = r.lizenzKlasse.name
stmt[ReiterTable.lizenziertFuerSparten] = Json.encodeToString(r.lizenzSparten)
stmt[ReiterTable.istGastreiter] = r.istGastreiter
stmt[ReiterTable.istAktiv] = r.istAktiv
stmt[ReiterTable.datenQuelle] = r.datenQuelle.name
stmt[ReiterTable.createdAt] = r.createdAt
stmt[ReiterTable.updatedAt] = r.updatedAt
}
}
@@ -0,0 +1,55 @@
package at.mocode.persons.infrastructure.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.date
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed-Tabellendefinition für Reiter (DomReiter).
*
* Speichert alle Reiter-Daten inkl. Lizenz, Sparten (JSON) und ZNS-Identifikation.
*/
object ReiterTable : Table("reiter") {
val id = javaUUID("id").autoGenerate()
override val primaryKey = PrimaryKey(id)
// Identifikation
val satznummer = varchar("satznummer", 20).nullable()
val feiId = varchar("fei_id", 20).nullable()
// Persönliche Daten
val vorname = varchar("vorname", 100)
val nachname = varchar("nachname", 100)
val geburtsdatum = date("geburtsdatum").nullable()
val nation = varchar("nation", 3).nullable().default("AUT")
// Vereinsdaten
val vereinsNummer = varchar("vereins_nummer", 20).nullable()
val vereinsName = varchar("vereins_name", 200).nullable()
// Lizenz & Qualifikation
val lizenzKlasse = varchar("lizenz_klasse", 50).nullable()
val lizenziertFuerSparten = text("lizenziert_fuer_sparten") // JSON array of SparteE
// Status & Verwaltung
val istAktiv = bool("ist_aktiv").default(true)
val istGastreiter = bool("ist_gastreiter").default(false)
val bemerkungen = text("bemerkungen").nullable()
val datenQuelle = varchar("daten_quelle", 50)
// Audit-Felder
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
init {
index(false, nachname)
index(false, vorname)
index(true, satznummer)
index(true, feiId)
index(false, vereinsNummer)
index(false, istAktiv)
index(false, lizenzKlasse)
}
}
@@ -0,0 +1,87 @@
-- Migration V001: Create Reiter (Riders) table
-- Speichert alle Reiter-Daten inkl. Lizenz, Sparten (JSON) und ZNS-Identifikation.
CREATE TABLE IF NOT EXISTS reiter
(
id
UUID
PRIMARY
KEY
DEFAULT
gen_random_uuid
(
),
satznummer VARCHAR
(
20
),
fei_id VARCHAR
(
20
),
vorname VARCHAR
(
100
) NOT NULL,
nachname VARCHAR
(
100
) NOT NULL,
geburtsdatum DATE,
nation VARCHAR
(
3
) DEFAULT 'AUT',
vereins_nummer VARCHAR
(
20
),
vereins_name VARCHAR
(
200
),
lizenz_klasse VARCHAR
(
50
),
lizenziert_fuer_sparten TEXT NOT NULL DEFAULT '[]',
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
ist_gastreiter BOOLEAN NOT NULL DEFAULT false,
bemerkungen TEXT,
daten_quelle VARCHAR
(
50
) NOT NULL DEFAULT 'IMPORT_ZNS',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Unique Indizes für ZNS-Identifikation
CREATE UNIQUE INDEX IF NOT EXISTS uk_reiter_satznummer ON reiter(satznummer) WHERE satznummer IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uk_reiter_fei_id ON reiter(fei_id) WHERE fei_id IS NOT NULL;
-- Performance-Indizes
CREATE INDEX IF NOT EXISTS idx_reiter_nachname ON reiter(nachname);
CREATE INDEX IF NOT EXISTS idx_reiter_vorname ON reiter(vorname);
CREATE INDEX IF NOT EXISTS idx_reiter_vereins_nummer ON reiter(vereins_nummer);
CREATE INDEX IF NOT EXISTS idx_reiter_ist_aktiv ON reiter(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_reiter_lizenz_klasse ON reiter(lizenz_klasse);
CREATE INDEX IF NOT EXISTS idx_reiter_ist_gastreiter ON reiter(ist_gastreiter);
-- Dokumentation
COMMENT
ON TABLE reiter IS 'Reiter/Teilnehmer gemäß OEPS-Mitgliederregister (ZNS)';
COMMENT
ON COLUMN reiter.id IS 'Eindeutige interne ID (UUID)';
COMMENT
ON COLUMN reiter.satznummer IS 'OEPS-Satznummer (Mitgliedsnummer, eindeutig)';
COMMENT
ON COLUMN reiter.fei_id IS 'FEI-ID für internationale Starts (eindeutig)';
COMMENT
ON COLUMN reiter.lizenz_klasse IS 'Lizenzklasse (LizenzKlasseE): LIZENZFREI, AMATEUR, PROFI, etc.';
COMMENT
ON COLUMN reiter.lizenziert_fuer_sparten IS 'JSON-Array der lizenzierten Sparten (SparteE)';
COMMENT
ON COLUMN reiter.ist_gastreiter IS 'Gastreiter ohne OEPS-Mitgliedschaft (z.B. ausländische Starter)';
COMMENT
ON COLUMN reiter.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI';
@@ -88,3 +88,353 @@ enum class BearbeitungsStatusE {
ABGEBROCHEN,
FEHLER
}
/**
* Sportliche Sparten gemäß ÖTO § 3 Abs. 2 und FEI-Kurzbezeichnungen.
* Nationale Turniere verwenden das Präfix "N" (z.B. CDN = Dressur national).
*/
@Serializable
enum class SparteE {
/** Dressur national (CDN) */
DRESSUR,
/** Springen national (CSN) */
SPRINGEN,
/** Vielseitigkeit national (CCN) */
VIELSEITIGKEIT,
/** Fahren national (CAN) */
FAHREN,
/** Voltigieren national (CVN) */
VOLTIGIEREN,
/** Distanzreiten national (CEN) */
DISTANZREITEN,
/** Westernreiten national (CWN) */
WESTERNREITEN,
/** Reining national (CWRN) */
REINING
}
/**
* Turnierkategorie gemäß ÖTO § 3 Abs. 4.
* Bestimmt das Niveau und die Teilnahmeberechtigung.
*/
@Serializable
enum class TurnierkategorieE {
C_NEU,
C,
B_STERN,
B,
A,
A_STERN
}
/**
* Typ einer pferdesportlichen Veranstaltung gemäß ÖTO § 2 Abs. 1.
*/
@Serializable
enum class VeranstaltungsTypE {
/** Turnier mit OEPS-Genehmigung und Turniernummer (§ 2 Abs. 2) */
TURNIER,
/** Reitertreffen für Vereinsmitglieder und geladene Gäste (§ 2 Abs. 3) */
REITERTREFFEN,
/** Sonderprüfung zur Erlangung von Abzeichen/Lizenzen (§ 2 Abs. 4) */
SONDERPRUEFUNG,
/** Pferde-Sport & Spiel (§ 2 Abs. 5) */
PS_UND_S,
/** Turnierartige Veranstaltung mit Sondergenehmigung (§ 2 Abs. 6) */
TURNIERARTIG
}
/**
* Lizenzklasse eines Reiters gemäß ÖTO Teilnahmeberechtigung.
*/
@Serializable
enum class LizenzKlasseE {
/** Lizenzfrei keine Lizenz erforderlich */
LIZENZFREI,
/** Reiter-Lizenz Klasse 1 */
R1,
/** Reiter-Lizenz Klasse 2 */
R2,
/** Reiter-Lizenz Klasse 3 */
R3,
/** Dressur-Reiter Klasse 1 */
RD1,
/** Dressur-Reiter Klasse 2 */
RD2,
/** Dressur-Reiter Klasse 3 */
RD3,
/** Jugend/Nachwuchs */
JN,
/** Junioren */
JG,
/** Young Rider */
YR
}
/**
* Status einer Nennung im registration-context.
*/
@Serializable
enum class NennungsStatusE {
/** Nennung eingegangen, noch nicht bestätigt */
EINGEGANGEN,
/** Nennung bestätigt und aktiv */
BESTAETIGT,
/** Nennung nach Nennschluss (Nachnennung) */
NACHNENNUNG,
/** Nennung wurde transferiert (Reiter/Pferd getauscht) */
TRANSFERIERT,
/** Nennung zurückgezogen */
ZURUECKGEZOGEN,
/** Reiter/Pferd gestartet */
GESTARTET,
/** Nicht angetreten */
NICHT_ANGETRETEN
}
/**
* Startwunsch eines Reiters bezüglich seiner Position in der Startliste.
*/
@Serializable
enum class StartwunschE {
VORNE,
HINTEN,
KEIN_WUNSCH
}
/**
* Rolle eines Funktionärs bei einer Veranstaltung gemäß ÖTO Funktionärs-Qualifikation.
* Bestimmt, welche Aufgaben eine Person bei einem Turnier übernehmen darf.
*/
@Serializable
enum class FunktionaerRolleE {
/** Turnierbeauftragter (TBA) hat bei Regelkonflikten das letzte Wort (ÖTO § 24/§ 25) */
TBA,
/** Richter / Kampfrichter */
RICHTER,
/** Parcoursbauer (Springen) */
PARCOURSBAUER,
/** Streckendesigner (Vielseitigkeit, Distanzreiten) */
STRECKENDESIGNER,
/** Tierarzt */
TIERARZT,
/** Steward */
STEWARD,
/** Starter */
STARTER,
/** Zeitnehmer */
ZEITNEHMER,
/** Protokollführer */
PROTOKOLLFUEHRER,
/** Sonstige Funktion */
SONSTIGE
}
/**
* Qualifikationsstufe eines Richters gemäß ÖTO/ZNS-Klassifikation (RICHT01.dat).
* Bestimmt, für welche Turnierkategorien und Sparten ein Richter zugelassen ist.
*/
@Serializable
enum class RichterQualifikationE {
/** Grundausbildung / Anfänger */
GA,
/** Gruppe 3 (niedrigste offizielle Stufe) */
G3,
/** Gruppe 2 */
G2,
/** Gruppe 1 (höchste nationale Stufe) */
G1,
/** Internationaler Richter (FEI-Lizenz) */
INTERNATIONAL,
/** Sonstige / unbekannte Qualifikation */
SONSTIGE
}
/**
* Typ einer Prüfung / eines Bewerbs gemäß ÖTO-Klassifikation.
* Relevant für die Abteilungs-Schwellenwerte (§ 39 A-Teil).
*/
@Serializable
enum class PruefungsTypE {
/** Stilspringprüfung (Schwellenwert: > 30 Starter → Pflicht-Teilung) */
STIL_SPRINGEN,
/** Springpferdeprüfung (Schwellenwert: > 30 Starter → Pflicht-Teilung) */
SPRINGPFERDE,
/** Dressurpferdeprüfung (Schwellenwert: > 30 Starter → Kann-Teilung) */
DRESSURPFERDE,
/** Vielseitigkeitsprüfung (Schwellenwert: > 40 Starter → Pflicht-Teilung) */
VIELSEITIGKEIT,
/** Übrige Springprüfung (Standard, Zweiphasen, Punkte, Risiko; Schwellenwert: > 80 Starter) */
SPRINGEN_UEBRIG,
/** Dressurprüfung (Schwellenwert: > 30 Starter → Kann-Teilung) */
DRESSUR,
/** Caprilli-Prüfung (strukturelle Pflicht-Teilung nach Lizenz, § 803) */
CAPRILLI,
/** Fahrprüfung / Fahrertreffen (CAN, § 850 ff.) */
FAHREN,
/** Voltigierprüfung (CVN, § 400 ff.) */
VOLTIGIEREN,
/** Sonstiger Prüfungstyp */
SONSTIGE
}
/**
* Typ der Abteilungs-Teilung gemäß ÖTO § 39.
* Bestimmt, nach welchem Kriterium ein Bewerb in Abteilungen aufgeteilt wird.
*/
@Serializable
enum class AbteilungsTeilungsTypE {
/** Keine Teilung (Bewerb läuft als eine Einheit) */
KEINE,
/** Teilung nach Lizenzstufe (Standard-Fallback gemäß § 39) */
NACH_LIZENZ,
/** Teilung nach Startplatznummer / Plätzen */
NACH_PLATZ,
/** Teilung nach Pferdealter (z.B. Springpferdeprüfung 95110 cm) */
NACH_PFERDEALTER,
/** Strukturelle Pflicht-Teilung (z.B. CSN-C-NEU, Caprilli) */
STRUKTURELL,
/** Teilung nach Ausschreibungs-Kriterium (Altersklasse, Geschlecht etc.) */
NACH_AUSSCHREIBUNG
}
/**
* Status einer Startliste gemäß Turnier-Workflow.
*/
@Serializable
enum class StartlistenStatusE {
/** Startliste noch nicht erstellt */
NICHT_ERSTELLT,
/** Startliste in Bearbeitung (Entwurf) */
ENTWURF,
/** Startliste veröffentlicht (für Reiter sichtbar) */
VEROEFFENTLICHT,
/** Startliste gesperrt (keine Änderungen mehr möglich, Turnier läuft) */
GESPERRT,
/** Startliste archiviert (Turnier abgeschlossen) */
ARCHIVIERT
}
/**
* Status einer Veranstaltung im Planungs- und Durchführungs-Workflow.
*/
@Serializable
enum class VeranstaltungsStatusE {
/** Veranstaltung in Planung (noch nicht genehmigt) */
IN_PLANUNG,
/** Ausschreibung eingereicht, wartet auf Genehmigung */
EINGEREICHT,
/** Veranstaltung genehmigt, Nennungen möglich */
GENEHMIGT,
/** Nennschluss abgelaufen, Startlisten in Erstellung */
NENNSCHLUSS_ABGELAUFEN,
/** Veranstaltung läuft aktiv */
AKTIV,
/** Veranstaltung abgeschlossen */
ABGESCHLOSSEN,
/** Veranstaltung abgesagt */
ABGESAGT
}
/**
* Status eines Turniers innerhalb einer Veranstaltung.
*/
@Serializable
enum class TurnierStatusE {
/** Turnier geplant, noch nicht gestartet */
GEPLANT,
/** Turnier läuft aktiv */
AKTIV,
/** Turnier abgeschlossen, Ergebnisse vorhanden */
ABGESCHLOSSEN,
/** Turnier abgesagt */
ABGESAGT
}
/**
* Status einer Ausschreibung gemäß ÖTO-Genehmigungsworkflow.
*/
@Serializable
enum class AusschreibungsStatusE {
/** Ausschreibung im Entwurf */
ENTWURF,
/** Ausschreibung eingereicht beim Verband */
EINGEREICHT,
/** Ausschreibung genehmigt */
GENEHMIGT,
/** Ausschreibung abgelehnt (Überarbeitung erforderlich) */
ABGELEHNT,
/** Ausschreibung veröffentlicht (für Reiter sichtbar) */
VEROEFFENTLICHT
}
+158 -31
View File
@@ -2,26 +2,54 @@
type: Roadmap
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-05
last_update: 2026-03-24
---
# MASTER ROADMAP: "Operation Self-Sovereignty"
# MASTER ROADMAP: Meldestelle-Biest
🏗️ **[Lead Architect]** | 24. März 2026
**Strategisches Ziel:**
Vollständige Migration auf Self-Hosted Infrastruktur (Gitea, Pangolin, Zora) und Konsolidierung der Dokumentation. Der Fokus liegt auf Datensouveränität, Offline-Fähigkeit und einer sauberen, aktuellen Wissensbasis.
Entwicklung einer ÖTO-konformen, offline-fähigen Turnier-Meldestelle als Compose Desktop App (KMP).
Vollständige Self-Hosted Infrastruktur (Gitea, Pangolin, Zora). Datensouveränität, Offline-First, saubere Wissensbasis.
**Aktueller technischer Stand (05.03.2026):**
**Aktueller technischer Stand (24.03.2026):**
* **Infrastruktur:** ✅ "Zora" (MS-R1, ARM64) ist live. Gitea & Registry laufen.
* **Networking:** ✅ Pangolin Tunnel ersetzt Cloudflare.
* **CI/CD:** ✅ Gitea Actions mit ARM64-Runner (VM 102) aktiv. Docker-Publish Pipeline grün.
* **Code-Basis:** ✅ Backend (Java 25), Frontend (Kotlin/JS) bauen sauber auf ARM64.
* **Dokumentation:** ✅ Konsolidiert und aufgeräumt.
* **Code-Basis:** ✅ Backend (Java 25 / Spring Boot / Kotlin), Frontend (KMP/Compose Desktop).
* **Domain-Design:** ✅ 6 Bounded Contexts (SCS-Architektur) definiert. Ubiquitous Language erstellt.
* **Domain-Modelle:** ✅ `DomReiter`, `DomNennung`, `DomNennungsTransfer`, `DomPferd`, `DomFunktionaer`, `DomVerein`,
`DomBewerb`, `DomAbteilung`, `DomStartliste`, `DomVeranstaltung`, `DomTurnier`, `DomAusschreibung` implementiert.
Enums ÖTO-konform.
* **Dokumentation:** ✅ Konsolidiert. ÖTO-Regelwerk-Referenzen (Abteilungs-Schwellenwerte) dokumentiert.
---
## 1. Arbeitsaufträge an die AGENTS (Phasenplan)
## Architektur-Übersicht: 6 Bounded Contexts (SCS)
### PHASE 1: Documentation Cleanup (ABGESCHLOSSEN)
Das System ist in **6 Self-Contained Systems (SCS)** aufgeteilt, die fachlich voneinander getrennt sind
und über definierte Schnittstellen kommunizieren.
| SCS | Kontext | Priorität | Status |
|----------------------------|---------------------------------------|-----------|----------------|
| `registration-context` | Nennungs-Workflow (Herzstück) | **P1** | 🟡 In Arbeit |
| `actor-context` | Reiter, Pferde, Funktionäre, ZNS | **P1** | 🟡 In Arbeit |
| `competition-context` | Bewerbe, Startlisten, Ergebnisse | **P2** | ⬜ Geplant |
| `event-management-context` | Veranstaltung, Turnier, Ausschreibung | **P2** | ⬜ Geplant |
| `billing-context` | Abrechnung, Kassa, Gebühren | **P3** | ⬜ Geplant |
| `identity-context` | Auth, Rollen (Keycloak) | **P3** | ⬜ Geplant |
| `series-context` | Cups, Serien, Meisterschaften | Phase 2+ | 🔵 Vorbereitet |
> **Hinweis `series-context`:** Ist Phase 2+, aber die Architektur ist von Anfang an vorbereitet.
> Cups/Serien/Meisterschaften benötigen eigene, konfigurierbare Reglements (kein Hard-Coding).
> Pluggable Berechnungsmodell und konfigurierbare Paar-Bindung (Reiter+Pferd vs. nur Reiter) erforderlich.
---
## 1. Abgeschlossene Phasen
### PHASE 1: Documentation Cleanup ✅ ABGESCHLOSSEN
*Ziel: Eine einzige, vertrauenswürdige Quelle der Wahrheit (SSOT) schaffen.*
#### 🧹 Agent: Curator
@@ -30,38 +58,137 @@ Vollständige Migration auf Self-Hosted Infrastruktur (Gitea, Pangolin, Zora) un
* [x] **Struktur:** `docs/` Ordner aufräumen (unnötige Root-Files in Unterordner).
* [x] **Index:** `README.md` im Root als Einstiegspunkt aktualisieren.
### PHASE 2: Infrastructure Hardening (ABGESCHLOSSEN)
### PHASE 2: Infrastructure Hardening ABGESCHLOSSEN
*Ziel: Stabilisierung der neuen Self-Hosted Umgebung.*
#### 🐧 Agent: DevOps Engineer
* [x] **Keycloak Fix:** Verbindungsprobleme innerhalb des Docker-Netzwerks (`meldestelle-host`) behoben (Alias `auth.mo-code.at`).
* [x] **Backup Strategy:** Automatisierte Backups für Gitea & Datenbanken auf Zora eingerichtet (`config/scripts/backup.sh`).
* [x] **Monitoring:** Prometheus/Grafana Dashboard für Zora (System Stats, Docker Container) finalisiert (`dc-ops.yaml`).
* [x] **Keycloak Fix:** Verbindungsprobleme innerhalb des Docker-Netzwerks behoben (Alias `auth.mo-code.at`).
* [x] **Backup Strategy:** Automatisierte Backups für Gitea & Datenbanken auf Zora (`config/scripts/backup.sh`).
* [x] **Monitoring:** Prometheus/Grafana Dashboard für Zora finalisiert (`dc-ops.yaml`).
* [x] **Deployment:** Git-basiertes Deployment-Skript (`config/scripts/deploy.sh`) erstellt.
### PHASE 3: Feature Development (ON HOLD)
*Ziel: Neuausrichtung der Fachlichkeit.*
### PHASE 3: Domain-Design & Ubiquitous Language ✅ ABGESCHLOSSEN
#### 🏗️ Agent: Lead Architect & Domain Expert
* [ ] **Domain Analysis:** Workshop mit dem Fachexperten zur Neudefinition der Anforderungen.
* [ ] **Feature Roadmap:** Erstellung eines neuen Plans basierend auf den Ergebnissen.
* [ ] **Entries Service:** Pausiert bis zur Klärung der Anforderungen.
*Ziel: Fachliche Grundlage für die Implementierung schaffen.*
#### 🏗️ Agent: Lead Architect
* [x] **DDD-Analyse:** 6 Bounded Contexts (SCS-Architektur) definiert und priorisiert.
* [x] **Terminologie:** `Veranstaltung``Turnier` gemäß ÖTO § 2 Abs. 1 festgelegt (ADR).
* [x] **Design-Baseline:** Vision_03 (Figma) als offizieller Design-Baseline festgelegt.
* [x] **Technologie:** Desktop-First-Strategie mit KMP/Compose Desktop beschlossen.
#### 📜 Agent: ÖTO/FEI Rulebook Expert
* [x] **Ubiquitous Language:** Offizielle Domänen-Terminologie mit ÖTO-Referenzen erstellt.
* [x] **Abteilungs-Schwellenwerte:** Alle Trennungs-Schwellenwerte (§ 39 + spartenspezifisch) dokumentiert.
`docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md`
#### 👷 Agent: Backend Developer
* [x] **Enums:** `SparteE`, `TurnierkategorieE`, `VeranstaltungsTypE`, `LizenzKlasseE`, `NennungsStatusE`,
`StartwunschE` ÖTO-konform.
* [x] **Domain-Modelle:** `DomReiter` (actor-context), `DomNennung`, `DomNennungsTransfer` (registration-context).
* [x] **Modul:** `entries-domain` als KMP-Modul aufgesetzt und in `settings.gradle.kts` registriert.
---
## 2. Definition of Done (für Phase 1 & 2)
1. [x] `docs/` Root enthält nur noch essentielle Einstiegspunkte (`README.md`, `MASTER_ROADMAP.md`).
2. [x] Alle veralteten Dokumente sind im `_archive` oder gelöscht.
3. [x] Die `Zora_System_Architektur.md` ist korrekt in `docs/07_Infrastructure/` eingeordnet.
4. [x] Ein neuer Entwickler findet sich sofort zurecht.
5. [x] Keycloak ist intern erreichbar.
6. [x] Backups laufen automatisch.
## 2. Aktuelle Phase
### PHASE 4: MVP-Implementierung 🟡 IN ARBEIT
*Ziel: Lauffähiger MVP für `registration-context` und `actor-context` (P1-Contexts).*
#### 🏗️ Agent: Lead Architect
* [x] **ADRs vervollständigen:** Bounded Context Mapping und Context Map dokumentieren.
`docs/01_Architecture/adr/0014-bounded-context-mapping-de.md`
`docs/01_Architecture/adr/0015-context-map-de.md`
* [x] **API-Design:** Schnittstellen zwischen den Contexts definieren (Anti-Corruption Layer).
`docs/01_Architecture/adr/0016-api-design-acl-de.md`
#### 👷 Agent: Backend Developer
* [x] **`actor-context`:** Domain-Modelle für `DomPferd`, `DomFunktionaer`, `DomVerein` implementiert.
* [x] **`registration-context`:** `DomBewerb`, `DomAbteilung`, `DomStartliste` implementiert.
* [x] **`event-management-context`:** `DomVeranstaltung`, `DomTurnier`, `DomAusschreibung` implementiert.
* [x] **Persistenz:** Repository-Interfaces und erste DB-Migrationen (Flyway/Liquibase).
* [x] **API:** REST-Endpunkte für Nennungs-Workflow (Kern-Use-Cases).
#### 🎨 Agent: Frontend Expert
* [x] **KMP/Compose Desktop:** Projektstruktur aufgesetzt (`frontend/shells/meldestelle-desktop`).
* [x] **Navigation:** Sidebar-Navigation gemäß Vision_03 implementiert (Veranstaltungen, Reiter, Pferde, Funktionäre,
Meisterschaften, Cups).
* [x] **Nennungs-Screen:** `TurnierDetailScreen` integriert `NennungsMaske` aus `nennung-feature` (Bewerbe-Tab ⭐).
#### 📜 Agent: ÖTO/FEI Rulebook Expert
* [ ] **Voltigieren (CVN):** Abteilungs-Trennungsregeln aus B-Teil § 400 ff. auswerten (offene Frage #3).
* [ ] **Fahren (CAN):** Starter-Schwellenwerte jenseits der Reitertreffen-Regel klären (offene Frage #4).
* [x] **Warn-Logik:** Spezifikation der `competition-context` Warn-Logik für Abteilungs-Schwellenwerte.
`docs/03_Domain/02_Reference/OETO_Regelwerk/Warn-Logik-Spezifikation-competition-context.md`
---
## 3. Wichtige Referenzen
* **Infrastruktur:** `docs/07_Infrastructure/Zora_System_Architektur.md`
* **Deployment Guide:** `docs/07_Infrastructure/Guides/Setup_Git_Deployment_Zora.md`
* **Backup Guide:** `docs/07_Infrastructure/Guides/Setup_Backup_Zora.md`
* **CI/CD:** `.gitea/workflows/docker-publish.yaml`
* **Projekt-Protokoll:** `docs/04_Agents/Playbooks/`
## 3. Geplante Phasen
### PHASE 5: P2-Contexts & Integration ⬜ GEPLANT
*Ziel: `competition-context` und `event-management-context` implementieren.*
* [ ] **`competition-context`:** Bewerbe, Startlisten, Ergebnisse, Abteilungs-Warn-Logik.
* [ ] **`event-management-context`:** Veranstaltungs- und Turnier-Verwaltung, Ausschreibungs-Generator.
* [ ] **ZNS-Integration:** Schnittstelle zum Zentralen Nennungs-System (A-Satz / B-Satz).
* [ ] **Offline-Sync:** Offline-First-Strategie für Desktop-App implementieren.
### PHASE 6: P3-Contexts & Billing ⬜ GEPLANT
*Ziel: `billing-context` und `identity-context` implementieren.*
* [ ] **`billing-context`:** Gebührenberechnung, Kassa, Abrechnung.
* [ ] **`identity-context`:** Rollen-Modell (TBA, Veranstalter, Richter etc.) mit Keycloak.
* [ ] **Reporting:** Startlisten- und Ergebnislisten-Druck (PDF).
### PHASE 7: Series-Context & Erweiterungen 🔵 PHASE 2+
*Ziel: Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.*
* [ ] **`series-context`:** Pluggable Berechnungsmodell, konfigurierbare Paar-Bindung.
* [ ] **Web-Portal:** Shared Module aus Desktop-App extrahieren → Web-Portal aufbauen.
* [ ] **Mobile:** KMP-Sharing auf Android/iOS ausweiten.
---
## 4. Wichtige Architektur-Entscheidungen (ADRs)
| # | Entscheidung | Status | Dokument |
|----|--------------------------------------------------------------|--------|------------------------------|
| 1 | Vision_03 = Design-Baseline | ✅ | Session Log 2026-03-24 |
| 2 | Desktop-First mit KMP/Compose Desktop | ✅ | ADR-0009 |
| 3 | `Veranstaltung``Turnier` (ÖTO § 2 Abs. 1) | ✅ | Ubiquitous Language |
| 4 | 6 Bounded Contexts als SCS-Architektur | ✅ | Session Log 2026-03-24 |
| 5 | `series-context` ist Phase 2+ (Architektur vorbereitet) | ✅ | Session Log 2026-03-24 |
| 6 | Cups/Serien benötigen konfigurierbare Reglements | ✅ | Session Log 2026-03-24 |
| 7 | Warn-Logik statt harter Fehler (Override-Event) | ✅ | Abteilungs-Schwellenwerte.md |
| 8 | 6 Bounded Contexts: Mapping & Aggregate Roots | ✅ | ADR-0014 |
| 9 | Context Map: Integration Patterns & ACL-Strategie | ✅ | ADR-0015 |
| 10 | API-Design & ACL: Ports, DTOs, REST-Endpunkte, Domain Events | ✅ | ADR-0016 |
---
## 5. Wichtige Referenzen
| Dokument | Pfad |
|---------------------------|----------------------------------------------------------------------------------------------|
| Ubiquitous Language | `docs/03_Domain/01_Glossary/Ubiquitous_Language.md` |
| Abteilungs-Schwellenwerte | `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md` |
| Warn-Logik-Spezifikation | `docs/03_Domain/02_Reference/OETO_Regelwerk/Warn-Logik-Spezifikation-competition-context.md` |
| Session Log (DDD) | `docs/99_Journal/2026-03-24_Session_Log_DDD_Ubiquitous_Language.md` |
| Infrastruktur | `docs/07_Infrastructure/Zora_System_Architektur.md` |
| Deployment Guide | `docs/07_Infrastructure/Guides/Setup_Git_Deployment_Zora.md` |
| Backup Guide | `docs/07_Infrastructure/Guides/Setup_Backup_Zora.md` |
| CI/CD | `.gitea/workflows/docker-publish.yaml` |
| Agent Playbooks | `docs/04_Agents/Playbooks/` |
| ADR-Verzeichnis | `docs/01_Architecture/adr/` |
@@ -0,0 +1,225 @@
---
type: ADR
id: ADR-0014
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-24
---
# ADR-0014: Bounded Context Mapping (SCS-Architektur)
## Status
Akzeptiert
## Kontext
Mit der Entscheidung für Domain-Driven Design (→ ADR-0002) und der modularen Architektur (→ ADR-0001) war es
notwendig, die fachlichen Grenzen des Systems explizit zu definieren. Die ursprünglichen Module (`masterdata`,
`members`, `horses`, `events`) spiegelten technische Kategorien wider, nicht die tatsächlichen Fachdomänen des
österreichischen Turniersports.
Folgende Probleme wurden identifiziert:
1. Fehlende Ausrichtung zwischen Code-Struktur und ÖTO-Regelwerk
2. Unklare Verantwortlichkeiten bei domänenübergreifenden Operationen (z.B. Nennungs-Transfer)
3. Keine explizite Trennung zwischen Kern-Domäne (Nennungs-Workflow) und unterstützenden Domänen
4. Fehlende Grundlage für eine skalierbare, offline-fähige Desktop-Architektur
## Entscheidung
Das System wird in **6 Bounded Contexts** aufgeteilt, die als **Self-Contained Systems (SCS)** implementiert werden.
Jeder Context ist fachlich eigenständig, besitzt seine eigene Ubiquitous Language und kommuniziert über definierte
Schnittstellen.
### Übersicht der 6 Bounded Contexts
| Context | Verantwortlichkeit | Priorität | Phase |
|----------------------------|------------------------------------------------------|-----------|---------|
| `registration-context` | Nennungs-Workflow (Herzstück des Systems) | **P1** | Phase 4 |
| `actor-context` | Reiter, Pferde, Funktionäre, Vereine, ZNS-Stammdaten | **P1** | Phase 4 |
| `competition-context` | Bewerbe, Startlisten, Ergebnisse, Abteilungs-Logik | **P2** | Phase 5 |
| `event-management-context` | Veranstaltung, Turnier, Ausschreibung, Genehmigungen | **P2** | Phase 5 |
| `billing-context` | Abrechnung, Kassa, Gebühren, Konten | **P3** | Phase 6 |
| `identity-context` | Authentifizierung, Rollen, Berechtigungen (Keycloak) | **P3** | Phase 6 |
> **Hinweis `series-context`:** Cups, Serien und Meisterschaften werden in Phase 2+ als eigenständiger Context
> implementiert. Die Architektur ist von Anfang an dafür vorbereitet (pluggable Berechnungsmodell,
> konfigurierbare Paar-Bindung). Kein Hard-Coding von Serien-Logik in anderen Contexts.
---
### Context-Beschreibungen
#### `registration-context` — Kern-Domäne (Core Domain)
**Verantwortlichkeit:** Der gesamte Lebenszyklus einer Nennung von der Erstanmeldung bis zur Stornierung.
**Aggregate Roots:**
- `DomNennung` Verbindliche Anmeldung eines Paares (Reiter & Pferd) zu einem Bewerb
- `DomNennungsTransfer` Transfer-Operation (kein Storno + Neu); Guthaben bleibt erhalten
- `DomAbteilung` Kleinste Einheit für Startlisten und Ergebnisse (mit Warn-Logik)
**Ubiquitous Language (Auswahl):**
- `Nennung`, `Nennschluss`, `Nachnenngebühr`, `Nennungs-Transfer`, `Override-Event`, `Startwunsch`
**Kern-Invarianten:**
- Eine Nennung ist immer einem Paar (Reiter + Pferd) zugeordnet
- Nennungs-Transfer ist eine atomare Operation kein Zwischenzustand ohne gültiges Paar
- Regelwerk-Verstöße erzeugen **Warnungen** (niemals harte Fehler) + `Override-Event`
---
#### `actor-context` — Unterstützende Domäne (Supporting Domain)
**Verantwortlichkeit:** Stammdaten aller Akteure und Synchronisation mit dem ZNS (Zentrales Nennungs-System).
**Aggregate Roots:**
- `DomReiter` Reiter mit Lizenz, Satznummer, Startkarte
- `DomPferd` Pferd mit Lebensnummer, Kopfnummer, Satznummer
- `DomFunktionär` Person mit Turnier-Rolle und Qualifikation
- `DomVerein` OEPS-Mitgliedsverein (Veranstalter)
**Ubiquitous Language (Auswahl):**
- `Satznummer`, `Lebensnummer`, `Kopfnummer`, `FEI-ID`, `Lizenz`, `Startkarte`, `Sperrliste`, `Gastreiter`
**Kern-Invarianten:**
- `Satznummer` ist der primäre Schlüssel für den ZNS-Datenaustausch
- `Lebensnummer` und `Kopfnummer` sind **nicht** als Datenbankschlüssel geeignet (ZNS-Inkonsistenzen)
- ZNS-Daten werden lokal gecacht (Offline-First); Synchronisation im Hintergrund
---
#### `competition-context` — Unterstützende Domäne (Supporting Domain)
**Verantwortlichkeit:** Strukturierung von Bewerben, Erstellung von Startlisten, Erfassung von Ergebnissen.
**Aggregate Roots:**
- `DomBewerb` Einzelne sportliche Prüfung mit Bewerbsnummer, Sparte, Klasse, Richtverfahren
- `DomAbteilung` Untereinheit eines Bewerbs mit eigenem Teilnehmerkreis und Platzierung
- `DomStartliste` Geordnete Liste der Starter einer Abteilung
- `DomErgebnis` Ergebnis eines Starts (Platzierung, Punkte, Zeit)
**Ubiquitous Language (Auswahl):**
- `Bewerb`, `Prüfung`, `Abteilung`, `Abteilungsnummer`, `Startliste`, `Richtverfahren`, `Klasse/Höhe`
**Kern-Invarianten:**
- Abteilungs-Schwellenwerte gemäß ÖTO § 39 lösen **Warnungen** aus (→ `Override-Event`)
- Vollständige Schwellenwert-Tabellen:
`docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md`
---
#### `event-management-context` — Unterstützende Domäne (Supporting Domain)
**Verantwortlichkeit:** Verwaltung von Veranstaltungen und Turnieren, Ausschreibungs-Generierung, Genehmigungsprozesse.
**Aggregate Roots:**
- `DomVeranstaltung` Interne Organisationseinheit des Veranstalters (selbst vergebene ID)
- `DomTurnier` Offizielles Turnier mit OEPS-vergebener Turniernummer
- `DomAusschreibung` Offizielles Dokument mit Pflichtfeldern gemäß ÖTO (A-Satz ZNS)
**Ubiquitous Language (Auswahl):**
- `Veranstaltung`, `Turnier`, `Turniernummer`, `Turnierkategorie`, `Ausschreibung`, `Kombination`, `TBA`
**Kern-Invarianten:**
- `Veranstaltung``Turnier` (→ ADR-0002, ÖTO § 2 Abs. 1): Eine Veranstaltung kann mehrere Turniere umfassen
- Turniernummern werden von der OEPS vergeben, nicht selbst generiert
- Kombinations-Turniere behalten je eigene Turniernummer
---
#### `billing-context` — Generische Domäne (Generic Domain)
**Verantwortlichkeit:** Gebührenberechnung, Kassenführung, Abrechnung mit Reitern und dem Verband.
**Aggregate Roots:**
- `DomKonto` Kontobasierte Abrechnung pro Zahler (Basis für „Hansi-Szenario")
- `DomGebühr` Einzelgebühr (Nenngeld, Nachnenngebühr, Sportförderbeitrag, Tierwohl-Euro)
- `DomAbrechnung` Zusammenfassung aller Gebühren einer Veranstaltung
**Ubiquitous Language (Auswahl):**
- `Konto`, `Nenngeld`, `Nachnenngebühr`, `Sportförderbeitrag`, `Tierwohl-Euro`, `Gebühren-Verzicht`
**Kern-Invarianten:**
- Sportförderbeitrag und Tierwohl-Euro fallen **pro Start** an (nicht pro Nennung)
- Gebühren-Verzicht wird als explizites Event gespeichert (Audit-Trail)
---
#### `identity-context` — Generische Domäne (Generic Domain)
**Verantwortlichkeit:** Authentifizierung, Rollen-Management, Berechtigungsprüfung (via Keycloak).
**Aggregate Roots:**
- `DomBenutzer` Systembenutzer mit Rollen (TBA, Veranstalter, Meldestelle, Richter)
- `DomRolle` Definierte Rolle mit Berechtigungen
**Ubiquitous Language (Auswahl):**
- `TBA`, `Veranstalter`, `Meldestelle`, `Richter`, `Rolle`, `Berechtigung`
**Kern-Invarianten:**
- Keycloak ist der einzige Identity Provider (→ ADR-0006)
- Rollen sind turnierbezogen (ein Benutzer kann bei Turnier A TBA und bei Turnier B Richter sein)
---
## Konsequenzen
### Positive
- **Fachliche Klarheit:** Jeder Context hat eine klar definierte Verantwortlichkeit und eigene Ubiquitous Language
- **Unabhängige Entwicklung:** P1-Contexts (`registration-context`, `actor-context`) können ohne P2/P3 entwickelt werden
- **Offline-First:** Jeder Context kann seinen eigenen lokalen Cache verwalten (SQLDelight)
- **ÖTO-Konformität:** Die Context-Grenzen spiegeln die Struktur des ÖTO-Regelwerks wider
- **Erweiterbarkeit:** `series-context` kann in Phase 2+ ohne Änderungen an bestehenden Contexts hinzugefügt werden
### Negative
- **Koordinationsaufwand:** Domänenübergreifende Use-Cases (z.B. Nennungs-Workflow) erfordern explizite Integration
- **Datenkonsistenz:** Eventual Consistency zwischen Contexts muss bewusst gehandhabt werden
- **Initialer Aufwand:** Vollständige Context-Implementierung erfordert mehr Vorabdesign
### Neutral
- Die Context-Grenzen können sich mit wachsendem Domänenwissen verschieben (Living Architecture)
## Betrachtete Alternativen
### Technische Modulaufteilung (abgelehnt)
Die ursprüngliche Aufteilung in `masterdata`, `members`, `horses`, `events` wurde verworfen, da sie technische
Kategorien statt fachliche Domänen widerspiegelt und keine klare Heimat für den Nennungs-Workflow bietet.
### Monolithische Domäne (abgelehnt)
Ein einzelner großer Domänen-Context würde die Komplexität des ÖTO-Regelwerks nicht beherrschbar machen und
die Offline-First-Strategie erschweren.
## Referenzen
- [ADR-0001: Modulare Architektur](0001-modular-architecture-de.md)
- [ADR-0002: Domain-Driven Design](0002-domain-driven-design-de.md)
- [ADR-0015: Context Map](0015-context-map-de.md)
- [Ubiquitous Language](../../03_Domain/01_Glossary/Ubiquitous_Language.md)
- [Abteilungs-Trennungs-Schwellenwerte](../../03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md)
- [MASTER_ROADMAP](../MASTER_ROADMAP.md)
- ÖTO 2026, § 2 Abs. 1, § 2 Abs. 7, § 2 Abs. 8, § 39
@@ -0,0 +1,275 @@
---
type: ADR
id: ADR-0015
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-24
---
# ADR-0015: Context Map & Integration Patterns
## Status
Akzeptiert
## Kontext
Nach der Definition der 6 Bounded Contexts (→ ADR-0014) müssen die **Beziehungen zwischen den Contexts** explizit
dokumentiert werden. Eine Context Map beschreibt:
- Welche Contexts miteinander kommunizieren
- In welche Richtung Abhängigkeiten fließen
- Welches Integration Pattern verwendet wird
- Wo Anti-Corruption Layers (ACL) notwendig sind
Ohne eine explizite Context Map entstehen implizite Abhängigkeiten, die die Unabhängigkeit der Contexts untergraben
und die Offline-First-Strategie gefährden.
## Entscheidung
### Context Map (ASCII-Diagramm)
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ EXTERNE SYSTEME │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ZNS (Zentrales Nennungs-System / OEPS) [Upstream / Big Ball] │ │
│ └──────────────────────────┬──────────────────────────────────────────┘ │
│ │ ACL (A-Satz / B-Satz Import) │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Keycloak (Identity Provider) [Upstream / Conformist] │ │
│ └──────────────────────────┬───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ JWT / OIDC
┌─────────────────────────────────────────────────────────────────────────────┐
│ INTERNE CONTEXTS │
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ identity-context │◄───────│ (alle Contexts) │ │
│ │ [Generic Domain] │ OHS │ prüfen Berechtigungen via Token │ │
│ └──────────────────────┘ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ACL ┌──────────────────────────────────────┐ │
│ │ actor-context │◄───────│ ZNS (extern) │ │
│ │ [Supporting Domain] │ └──────────────────────────────────────┘ │
│ │ │ │
│ │ DomReiter │ CL/SK ┌──────────────────────────────────────┐ │
│ │ DomPferd │───────►│ registration-context │ │
│ │ DomFunktionär │ │ [Core Domain] │ │
│ │ DomVerein │ │ │ │
│ └──────────────────────┘ │ DomNennung │ │
│ │ DomNennungsTransfer │ │
│ ┌──────────────────────┐ CL/SK │ DomAbteilung │ │
│ │ event-management- │───────►│ │ │
│ │ context │ └──────────────┬───────────────────────┘ │
│ │ [Supporting Domain] │ │ │
│ │ │ │ Domain Events │
│ │ DomVeranstaltung │ │ (NennungErstellt, │
│ │ DomTurnier │ │ NennungStorniert, │
│ │ DomAusschreibung │ │ NennungTransferiert) │
│ └──────────────────────┘ │ │
│ ▼ │
│ ┌──────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ billing-context │◄───────│ competition-context │ │
│ │ [Generic Domain] │ ACL │ [Supporting Domain] │ │
│ │ │ │ │ │
│ │ DomKonto │ │ DomBewerb │ │
│ │ DomGebühr │ │ DomAbteilung │ │
│ │ DomAbrechnung │ │ DomStartliste │ │
│ └──────────────────────┘ │ DomErgebnis │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ series-context [Phase 2+ — Architektur vorbereitet, nicht aktiv] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
**Legende:**
- `ACL` = Anti-Corruption Layer
- `CL/SK` = Customer/Supplier mit Shared Kernel (gemeinsame IDs)
- `OHS` = Open Host Service (standardisiertes Interface)
- `►` = Abhängigkeitsrichtung (Downstream → Upstream)
---
### Beziehungen im Detail
#### 1. ZNS (extern) → `actor-context`
**Pattern:** Upstream/Downstream mit **Anti-Corruption Layer (ACL)**
| Eigenschaft | Beschreibung |
|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| Richtung | ZNS ist Upstream (Datenquelle), `actor-context` ist Downstream |
| Protokoll | Datei-Import (A-Satz / B-Satz, proprietäres Format) |
| ACL-Aufgabe | Übersetzung von ZNS-Datenformaten in interne Domänenmodelle |
| Offline-Verhalten | ZNS-Daten werden lokal gecacht; Import läuft asynchron |
| Kritische Regel | `Satznummer` ist primärer Schlüssel; `Lebensnummer` und `Kopfnummer` sind **nicht** als DB-Schlüssel geeignet (ZNS-Inkonsistenzen bekannt) |
**ACL-Verantwortlichkeiten:**
- Normalisierung inkonsistenter Felder (z.B. Farbe `"Braun"` vs. `"Brauner"`)
- Generierung interner IDs für ausländische Pferde ohne UELN
- Validierung und Ablehnung korrupter ZNS-Datensätze mit Protokollierung
---
#### 2. `actor-context` → `registration-context`
**Pattern:** Customer/Supplier mit **Shared Kernel (gemeinsame IDs)**
| Eigenschaft | Beschreibung |
|-------------------|--------------------------------------------------------------------------------|
| Richtung | `actor-context` ist Upstream (Stammdaten-Lieferant) |
| Shared Kernel | `ReiterId` (Satznummer), `PferdId` (Satznummer) als gemeinsame Referenz-IDs |
| Kommunikation | Synchron: Lookup bei Nennungs-Erstellung; Asynchron: Sperrlisten-Updates |
| Offline-Verhalten | `registration-context` hält lokale Kopie der benötigten Akteur-Daten |
| Kritische Regel | `registration-context` darf Akteur-Daten **nicht** direkt mutieren (nur lesen) |
---
#### 3. `event-management-context` → `registration-context`
**Pattern:** Customer/Supplier mit **Shared Kernel (gemeinsame IDs)**
| Eigenschaft | Beschreibung |
|-----------------|---------------------------------------------------------------------|
| Richtung | `event-management-context` ist Upstream (Turnier-/Bewerbs-Struktur) |
| Shared Kernel | `TurnierId`, `BewerbId` als gemeinsame Referenz-IDs |
| Kommunikation | Synchron: Bewerbs-Lookup bei Nennungs-Erstellung |
| Kritische Regel | Nennungen können nur für existierende Bewerbe erstellt werden |
---
#### 4. `registration-context` → `competition-context`
**Pattern:** Upstream/Downstream via **Domain Events**
| Eigenschaft | Beschreibung |
|--------------------|-----------------------------------------------------------------------------|
| Richtung | `registration-context` ist Upstream (Ereignis-Quelle) |
| Events | `NennungErstelltEvent`, `NennungStorniertEvent`, `NennungTransferiertEvent` |
| Kommunikation | Asynchron (Event Bus / lokale Event Queue) |
| Aufgabe Downstream | `competition-context` baut Startlisten aus Nennungs-Events auf |
| Offline-Verhalten | Events werden lokal persistiert und bei Verbindung synchronisiert |
---
#### 5. `registration-context` → `billing-context`
**Pattern:** Upstream/Downstream via **Domain Events** mit ACL
| Eigenschaft | Beschreibung |
|-----------------|-----------------------------------------------------------------------------------------------------------|
| Richtung | `registration-context` ist Upstream (Gebühren-Auslöser) |
| Events | `NennungErstelltEvent` (löst Nenngeld aus), `NennungStorniertEvent` (Gutschrift), `GebührenVerzichtEvent` |
| ACL-Aufgabe | Übersetzung von Nennungs-Events in Gebühren-Buchungen |
| Kritische Regel | Sportförderbeitrag und Tierwohl-Euro fallen **pro Start** an (nicht pro Nennung) |
---
#### 6. `competition-context` → `billing-context`
**Pattern:** Upstream/Downstream via **Domain Events** mit ACL
| Eigenschaft | Beschreibung |
|-------------|------------------------------------------------------------------|
| Richtung | `competition-context` ist Upstream |
| Events | `StartErfolgreich` (löst Sportförderbeitrag + Tierwohl-Euro aus) |
| ACL-Aufgabe | Übersetzung von Start-Events in Gebühren-Buchungen |
---
#### 7. Keycloak → alle Contexts
**Pattern:** Upstream/Downstream, **Conformist** (alle Contexts passen sich Keycloak an)
| Eigenschaft | Beschreibung |
|-------------------|-----------------------------------------------------------------------------------|
| Richtung | Keycloak ist Upstream (Identity Provider) |
| Protokoll | OIDC / JWT-Token |
| Kommunikation | Synchron: Token-Validierung bei jedem Request |
| Offline-Verhalten | Token-Caching mit konfigurierbarer TTL; Offline-Modus mit eingeschränkten Rechten |
---
### Anti-Corruption Layer (ACL) — Implementierungsrichtlinien
Jeder ACL wird als **eigenständiges Modul** innerhalb des Downstream-Contexts implementiert:
```
actor-context/
└── infrastructure/
└── zns/
├── ZnsImportService.kt # Orchestrierung
├── ZnsAkteurMapper.kt # Übersetzung ZNS → Dom*
├── ZnsValidationFilter.kt # Ablehnung korrupter Daten
└── ZnsImportProtokoll.kt # Audit-Log aller Imports
```
**Prinzipien:**
1. Der ACL übersetzt **immer** in die interne Ubiquitous Language — niemals umgekehrt
2. Fehlerhafte externe Daten werden **protokolliert und übersprungen** (kein Systemabsturz)
3. Der ACL ist der einzige Ort, der das externe Datenformat kennt
---
### Offline-First Integration
Da die Anwendung als Desktop-App (Offline-First) betrieben wird, gelten folgende Regeln:
| Szenario | Verhalten |
|-----------------------------|------------------------------------------------------------|
| ZNS nicht erreichbar | Lokaler Cache wird verwendet; Import-Status wird angezeigt |
| Nennungs-Erstellung offline | Lokal gespeichert; Events werden bei Sync übertragen |
| Keycloak nicht erreichbar | Gecachter Token wird verwendet (TTL-basiert) |
| Konflikt bei Sync | Optimistic Locking (409) + manuelle Auflösung (→ ADR-0013) |
---
## Konsequenzen
### Positive
- **Explizite Abhängigkeiten:** Alle Context-Beziehungen sind dokumentiert und nachvollziehbar
- **Schutz der Kern-Domäne:** `registration-context` ist durch ACLs von externen Systemen isoliert
- **Offline-Fähigkeit:** Jede Integration ist auf Offline-Betrieb ausgelegt
- **Erweiterbarkeit:** `series-context` kann in Phase 2+ als reiner Downstream-Consumer hinzugefügt werden
### Negative
- **Komplexität:** ACLs und Event-Übersetzungen erhöhen den initialen Implementierungsaufwand
- **Eventual Consistency:** Zwischen `registration-context` und `competition-context` gibt es keine sofortige Konsistenz
### Neutral
- Die Context Map ist ein **lebendes Dokument** und wird mit jeder neuen Integration aktualisiert
## Betrachtete Alternativen
### Direkte Context-zu-Context-Aufrufe (abgelehnt)
Direkte synchrone Aufrufe zwischen Contexts würden enge Kopplung erzeugen und die Offline-Fähigkeit untergraben.
### Shared Database (abgelehnt)
Eine gemeinsame Datenbank für alle Contexts würde die Context-Grenzen aufweichen und die unabhängige Entwicklung
verhindern.
## Referenzen
- [ADR-0014: Bounded Context Mapping](0014-bounded-context-mapping-de.md)
- [ADR-0004: Event-Driven Communication](0004-event-driven-communication-de.md)
- [ADR-0006: Authentication & Authorization (Keycloak)](0006-authentication-authorization-keycloak-de.md)
- [ADR-0013: Tech Stack Stabilization](0013-tech-stack-stabilization-2026.md)
- [Ubiquitous Language](../../03_Domain/01_Glossary/Ubiquitous_Language.md)
- [MASTER_ROADMAP](../MASTER_ROADMAP.md)
- Vaughn Vernon: „Implementing Domain-Driven Design", Kapitel 3 (Context Maps)
- ÖTO 2026, ZNS-Schnittstellen-Spezifikation (A-Satz / B-Satz)
@@ -0,0 +1,561 @@
---
type: ADR
id: ADR-0016
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-24
---
# ADR-0016: API-Design & Anti-Corruption Layer (ACL)
## Status
Akzeptiert
## Kontext
Die 6 Bounded Contexts (ADR-0014) kommunizieren über definierte Schnittstellen (ADR-0015).
Dieses ADR konkretisiert:
1. **Welche Daten** über Kontextgrenzen fließen (DTOs / Query-Objekte)
2. **Wie** die ACL-Schicht technisch implementiert wird (Ports & Adapters)
3. **Welche REST-Endpunkte** die P1-Contexts nach außen exponieren
4. **Welche Domain Events** asynchron publiziert werden
Grundprinzip: **Kein Context kennt die internen Modelle eines anderen Context.**
Jeder Context übersetzt eingehende Daten in seine eigene Ubiquitous Language.
---
## Entscheidung
### 1. Architektur-Muster: Ports & Adapters (Hexagonal)
Jeder Context implementiert:
```
┌─────────────────────────────────────────────────────┐
│ [Context X] │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ Inbound │ │ Domain │ │ Outbound │ │
│ │ Port │───▶│ Model │───▶│ Port │ │
│ │(REST/UI) │ │ (Aggregate) │ │(ACL/Event)│ │
│ └──────────┘ └──────────────┘ └───────────┘ │
│ ▲ │ │
│ │DTO DTO │ │
└───────┼────────────────────────────────────┼─────────┘
│ │
[Client / [Anderer
Frontend] Context]
```
**Regel:** DTOs sind flache, serialisierbare Datenstrukturen ohne Domänen-Logik.
Domain-Objekte verlassen den Context **niemals**.
---
### 2. Schnittstellen-Katalog: P1-Contexts
#### 2.1 `actor-context` → Inbound REST API
**Base-URL:** `/api/v1/actors`
| Methode | Pfad | Beschreibung | Response-DTO |
|---------|--------------------------------|------------------------------------|--------------------------|
| GET | `/reiter/{satznummer}` | Reiter per Satznummer laden | `ReiterDto` |
| GET | `/reiter/search?name=&verein=` | Reiter suchen (für Nennungs-Maske) | `List<ReiterSummaryDto>` |
| GET | `/pferde/{lebensnummer}` | Pferd per Lebensnummer laden | `PferdDto` |
| GET | `/pferde/search?name=&reiter=` | Pferde suchen (für Nennungs-Maske) | `List<PferdSummaryDto>` |
| GET | `/funktionaere/{id}` | Funktionär laden | `FunktionaerDto` |
| GET | `/vereine/{vereinsnummer}` | Verein laden | `VereinDto` |
| POST | `/reiter` | Reiter anlegen (TBA-Workflow) | `ReiterDto` |
| PUT | `/reiter/{satznummer}` | Reiter aktualisieren | `ReiterDto` |
**DTOs (actor-context → outbound):**
```kotlin
// Vollständiges Reiter-Objekt (für Detail-Ansicht)
data class ReiterDto(
val satznummer: String, // OEPS-Satznummer (eindeutig)
val vorname: String,
val nachname: String,
val geburtsdatum: LocalDate,
val lizenzklasse: String, // "A", "B", "C", "AMATEUR" etc.
val vereinsnummer: String,
val vereinsname: String,
val startkarte: Boolean,
val znsId: String? // ZNS-Referenz (nullable, Offline-Fall)
)
// Kompaktes Objekt für Suchergebnisse / Dropdown
data class ReiterSummaryDto(
val satznummer: String,
val vollname: String, // "Nachname, Vorname"
val lizenzklasse: String,
val vereinsname: String
)
// Vollständiges Pferd-Objekt
data class PferdDto(
val lebensnummer: String, // FEI-Lebensnummer (eindeutig)
val name: String,
val kopfnummer: String?, // Turnier-Kopfnummer (optional)
val satznummer: String?, // OEPS-Satznummer des Besitzers
val rasse: String?,
val farbe: String?,
val geburtsjahr: Int?
)
data class PferdSummaryDto(
val lebensnummer: String,
val name: String,
val kopfnummer: String?
)
data class FunktionaerDto(
val id: String,
val vorname: String,
val nachname: String,
val rolle: String, // "RICHTER", "PARCOURSCHEF" etc.
val qualifikationen: List<String>
)
data class VereinDto(
val vereinsnummer: String,
val name: String,
val oepsNummer: String
)
```
---
#### 2.2 `event-management-context` → Inbound REST API
**Base-URL:** `/api/v1/events`
| Methode | Pfad | Beschreibung | Response-DTO |
|---------|-------------------------------------|-------------------------------------|---------------------------------|
| GET | `/veranstaltungen` | Alle Veranstaltungen (paginiert) | `Page<VeranstaltungSummaryDto>` |
| GET | `/veranstaltungen/{id}` | Veranstaltung mit Turnieren laden | `VeranstaltungDetailDto` |
| GET | `/turniere/{turniernummer}` | Turnier per OEPS-Nummer laden | `TurnierDto` |
| GET | `/turniere/{turniernummer}/bewerbe` | Bewerbe eines Turniers | `List<BewerbSummaryDto>` |
| POST | `/veranstaltungen` | Neue Veranstaltung anlegen | `VeranstaltungDto` |
| POST | `/veranstaltungen/{id}/turniere` | Turnier zu Veranstaltung hinzufügen | `TurnierDto` |
**DTOs (event-management-context → outbound):**
```kotlin
data class VeranstaltungSummaryDto(
val id: String,
val bezeichnung: String,
val datum: LocalDate,
val ort: String,
val veranstalterVereinsnummer: String,
val status: String // "GEPLANT", "AKTIV", "ABGESCHLOSSEN"
)
data class VeranstaltungDetailDto(
val id: String,
val bezeichnung: String,
val datum: LocalDate,
val ort: String,
val veranstalterVereinsnummer: String,
val status: String,
val turniere: List<TurnierSummaryDto>
)
data class TurnierDto(
val turniernummer: String, // OEPS-vergebene Nummer
val veranstaltungId: String, // Referenz auf interne Veranstaltung
val bezeichnung: String,
val datum: LocalDate,
val kategorie: String, // "LT", "RT", "BT", "ST" etc.
val sparten: List<String> // ["DRESSUR", "SPRINGEN"] etc.
)
data class TurnierSummaryDto(
val turniernummer: String,
val bezeichnung: String,
val datum: LocalDate,
val kategorie: String
)
// Wird vom registration-context konsumiert (ACL-Übersetzung)
data class BewerbSummaryDto(
val bewerbId: String,
val bewerbsnummer: String, // z.B. "1", "2A", "2B"
val bezeichnung: String,
val sparte: String,
val klasse: String,
val maxStarter: Int?
)
```
---
#### 2.3 `registration-context` → Inbound REST API
**Base-URL:** `/api/v1/registrations`
| Methode | Pfad | Beschreibung | Response-DTO |
|---------|-------------------------------------------------|-------------------------------------|---------------------------|
| GET | `/nennungen?turniernummer=&status=` | Nennungen filtern | `List<NennungSummaryDto>` |
| GET | `/nennungen/{nennungsId}` | Einzelne Nennung laden | `NennungDetailDto` |
| POST | `/nennungen` | Neue Nennung einreichen | `NennungDetailDto` |
| PUT | `/nennungen/{nennungsId}/status` | Status ändern (Storno, Bestätigung) | `NennungDetailDto` |
| POST | `/nennungen/{nennungsId}/transfer` | Nennung transferieren | `NennungsTransferDto` |
| GET | `/nennungen/{nennungsId}/transfer/{transferId}` | Transfer-Status abfragen | `NennungsTransferDto` |
**DTOs (registration-context):**
```kotlin
// Eingehend: Neue Nennung (Command)
data class NennungErstellenCommand(
val turniernummer: String,
val bewerbId: String,
val reiterSatznummer: String,
val pferdLebensnummer: String,
val startwunsch: String?, // "FRUEH", "SPAET", "EGAL"
val bemerkung: String?
)
// Ausgehend: Kompakte Nennung (für Listen)
data class NennungSummaryDto(
val nennungsId: String,
val turniernummer: String,
val bewerbBezeichnung: String,
val reiterName: String, // Denormalisiert für Performance
val pferdName: String, // Denormalisiert für Performance
val status: String, // "EINGEREICHT", "BESTAETIGT", "STORNIERT"
val eingereichtAm: LocalDateTime
)
// Ausgehend: Vollständige Nennung (für Detail-Ansicht)
data class NennungDetailDto(
val nennungsId: String,
val turniernummer: String,
val bewerbId: String,
val bewerbBezeichnung: String,
val reiterSatznummer: String,
val reiterName: String,
val pferdLebensnummer: String,
val pferdName: String,
val startwunsch: String?,
val status: String,
val bemerkung: String?,
val eingereichtAm: LocalDateTime,
val letzteAenderung: LocalDateTime
)
// Transfer-DTO
data class NennungsTransferDto(
val transferId: String,
val quellNennungsId: String,
val zielBewerbId: String,
val zielReiterSatznummer: String?, // null = gleicher Reiter
val zielPferdLebensnummer: String?, // null = gleiches Pferd
val status: String, // "BEANTRAGT", "GENEHMIGT", "ABGELEHNT"
val guthabenErhalten: Boolean
)
```
---
### 3. ACL-Implementierung: registration-context konsumiert actor-context
Der `registration-context` benötigt Reiter- und Pferd-Daten, darf aber **nicht** direkt auf
die Datenbank des `actor-context` zugreifen.
**ACL-Port (Interface im registration-context):**
```kotlin
// Port-Interface (Teil des registration-context Domain-Layers)
interface AktorReferenzPort {
fun ladeReiter(satznummer: String): ReiterReferenz?
fun ladePferd(lebensnummer: String): PferdReferenz?
fun validiereReiterPferdKombination(
reiterSatznummer: String,
pferdLebensnummer: String
): ValidiertesReiterPferdPaar?
}
// Interne Referenz-Objekte (registration-context eigene Sprache)
data class ReiterReferenz(
val satznummer: String,
val vollname: String,
val lizenzklasse: LizenzKlasseE, // Eigener Enum des registration-context
val vereinsnummer: String,
val istStartberechtigt: Boolean // Abgeleitetes Feld (Startkarte + Lizenz aktiv)
)
data class PferdReferenz(
val lebensnummer: String,
val name: String,
val kopfnummer: String?
)
data class ValidiertesReiterPferdPaar(
val reiter: ReiterReferenz,
val pferd: PferdReferenz,
val paarungGueltig: Boolean,
val warnungen: List<String> // z.B. "Pferd hat keine aktive Kopfnummer"
)
```
**ACL-Adapter (Infrastruktur-Layer, implementiert den Port):**
```kotlin
// Adapter übersetzt actor-context DTO → registration-context Referenz-Objekt
@Component
class AktorReferenzAdapter(
private val aktorClient: AktorContextClient // HTTP-Client oder direkte Bean
) : AktorReferenzPort {
override fun ladeReiter(satznummer: String): ReiterReferenz? {
val dto = aktorClient.getReiter(satznummer) ?: return null
// ACL-Übersetzung: ReiterDto → ReiterReferenz
return ReiterReferenz(
satznummer = dto.satznummer,
vollname = "${dto.nachname}, ${dto.vorname}",
lizenzklasse = LizenzKlasseE.valueOf(dto.lizenzklasse),
vereinsnummer = dto.vereinsnummer,
istStartberechtigt = dto.startkarte
)
}
override fun ladePferd(lebensnummer: String): PferdReferenz? {
val dto = aktorClient.getPferd(lebensnummer) ?: return null
return PferdReferenz(
lebensnummer = dto.lebensnummer,
name = dto.name,
kopfnummer = dto.kopfnummer
)
}
override fun validiereReiterPferdKombination(
reiterSatznummer: String,
pferdLebensnummer: String
): ValidiertesReiterPferdPaar? {
val reiter = ladeReiter(reiterSatznummer) ?: return null
val pferd = ladePferd(pferdLebensnummer) ?: return null
val warnungen = mutableListOf<String>()
if (pferd.kopfnummer == null) warnungen.add("Pferd hat keine aktive Kopfnummer")
if (!reiter.istStartberechtigt) warnungen.add("Reiter hat keine gültige Startkarte")
return ValidiertesReiterPferdPaar(reiter, pferd, warnungen.isEmpty(), warnungen)
}
}
```
---
### 4. ACL-Implementierung: registration-context konsumiert event-management-context
```kotlin
// Port-Interface
interface TurnierReferenzPort {
fun ladeTurnier(turniernummer: String): TurnierReferenz?
fun ladeBewerb(bewerbId: String): BewerbReferenz?
fun ladeBewerbeDesTurniers(turniernummer: String): List<BewerbReferenz>
}
// Interne Referenz-Objekte (registration-context Sprache)
data class TurnierReferenz(
val turniernummer: String,
val bezeichnung: String,
val datum: LocalDate,
val kategorie: TurnierkategorieE, // Eigener Enum
val istNennungMoeglich: Boolean // Abgeleitetes Feld (Datum + Status)
)
data class BewerbReferenz(
val bewerbId: String,
val bewerbsnummer: String,
val bezeichnung: String,
val sparte: SparteE, // Eigener Enum
val klasse: String,
val maxStarter: Int?
)
```
---
### 5. Domain Events (Asynchrone Kommunikation)
Folgende Events werden über den internen Event-Bus publiziert:
#### 5.1 `actor-context` publiziert
| Event | Payload (Schlüsselfelder) | Konsumenten |
|----------------------|--------------------------------------------|----------------------------------------------|
| `ReiterAktualisiert` | `satznummer`, `lizenzklasse`, `startkarte` | `registration-context` (Cache-Invalidierung) |
| `PferdAktualisiert` | `lebensnummer`, `kopfnummer` | `registration-context` (Cache-Invalidierung) |
| `ReiterGesperrt` | `satznummer`, `grund` | `registration-context` (Warn-Logik) |
#### 5.2 `registration-context` publiziert
| Event | Payload (Schlüsselfelder) | Konsumenten |
|-----------------------|------------------------------------------------------------------------------------|------------------------------------------|
| `NennungEingereicht` | `nennungsId`, `turniernummer`, `bewerbId`, `reiterSatznummer`, `pferdLebensnummer` | `billing-context`, `competition-context` |
| `NennungStorniert` | `nennungsId`, `turniernummer`, `grund` | `billing-context` |
| `NennungTransferiert` | `transferId`, `quellNennungsId`, `zielBewerbId` | `billing-context` |
#### 5.3 `event-management-context` publiziert
| Event | Payload (Schlüsselfelder) | Konsumenten |
|---------------------------|-------------------------------------|--------------------------------------------|
| `TurnierEroeffnet` | `turniernummer`, `datum`, `sparten` | `registration-context` (Nennungs-Freigabe) |
| `NennungsschlussErreicht` | `turniernummer`, `zeitpunkt` | `registration-context` (Sperr-Logik) |
| `TurnierAbgesagt` | `turniernummer`, `grund` | `registration-context`, `billing-context` |
**Event-Struktur (Basis):**
```kotlin
// Basis-Event (alle Domain Events erben davon)
abstract class DomainEvent(
val eventId: String = UUID.randomUUID().toString(),
val occurredAt: Instant = Instant.now(),
val contextSource: String // z.B. "actor-context"
)
// Beispiel: NennungEingereicht
data class NennungEingereichtEvent(
val nennungsId: String,
val turniernummer: String,
val bewerbId: String,
val reiterSatznummer: String,
val pferdLebensnummer: String,
val eingereichtVon: String // Benutzer-ID (identity-context Referenz)
) : DomainEvent(contextSource = "registration-context")
```
---
### 6. Offline-First: Lokale Referenz-Caches
Da die Desktop-App offline-fähig sein muss, cachen die ACL-Adapter Referenz-Daten lokal:
```
┌─────────────────────────────────────────────────────────┐
│ registration-context (Desktop) │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ AktorReferenzAdapter │ │
│ │ │ │
│ │ ladeReiter() ──▶ [Lokaler Cache (SQLite)] │ │
│ │ │ │ │
│ │ ▼ Cache-Miss │ │
│ │ [HTTP → actor-context] │ │
│ │ │ │ │
│ │ ▼ Offline │ │
│ │ [Fehler: ReiterNichtGefunden] │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
**Cache-Strategie:**
- **Reiter/Pferd-Daten:** Werden beim Turnier-Download vollständig gecacht (Bulk-Sync)
- **Cache-Invalidierung:** Via `ReiterAktualisiert`-Event (wenn online) oder manueller Sync
- **Offline-Fallback:** Gecachte Daten sind gültig; neue Reiter können nicht angelegt werden
---
### 7. ZNS-Schnittstelle (Externer Context)
Der `actor-context` implementiert eine ACL zum externen ZNS (Zentrales Nennungs-System):
```kotlin
// Port (actor-context → ZNS)
interface ZnsPort {
fun ladeReiterAusSatz(satznummer: String): ZnsReiterSatz?
fun ladePferdAusSatz(lebensnummer: String): ZnsPferdSatz?
fun synchronisiereStammdaten(turniernummer: String): ZnsSyncErgebnis
}
// ZNS A-Satz (Reiter-Stammdaten)
data class ZnsReiterSatz(
val satznummer: String,
val vorname: String,
val nachname: String,
val geburtsdatum: LocalDate,
val lizenzklasse: String, // ZNS-Kodierung → wird in LizenzKlasseE übersetzt
val vereinsNummer: String,
val startkarte: Boolean
)
// ZNS B-Satz (Pferd-Stammdaten)
data class ZnsPferdSatz(
val lebensnummer: String,
val name: String,
val satznummer: String?, // Besitzer-Satznummer
val rasse: String?,
val farbe: String?
)
```
---
## Konsequenzen
### Positiv
- **Klare Kontraktgrenzen:** Jeder Context hat explizite, versionierbare APIs
- **Unabhängige Deployments:** Contexts können unabhängig deployed werden
- **Testbarkeit:** ACL-Ports können einfach gemockt werden (Unit-Tests)
- **Offline-Fähigkeit:** Lokale Caches ermöglichen Offline-Betrieb ohne Architektur-Bruch
- **ZNS-Isolation:** Änderungen am ZNS-Format betreffen nur den `ZnsAdapter`
### Negativ / Risiken
- **Datenduplizierung:** Denormalisierte Felder in DTOs (z.B. `reiterName` in `NennungSummaryDto`)
- **Cache-Konsistenz:** Lokale Caches können veralten (akzeptiertes Risiko, Warn-Logik)
- **Initialer Aufwand:** ACL-Adapter müssen für jeden Context implementiert werden
### Neutral
- **DTO-Versionierung:** Bei Breaking Changes muss API-Version erhöht werden (`/api/v2/...`)
- **Event-Ordering:** Domain Events sind best-effort; kritische Operationen bleiben synchron
---
## Abgelehnte Alternativen
### Shared Domain Model
Alle Contexts teilen ein gemeinsames Domain-Modell (z.B. `DomReiter` überall).
**Abgelehnt:** Führt zu starker Kopplung; Änderungen an `DomReiter` brechen alle Contexts.
### GraphQL Federation
Einheitliches GraphQL-Schema über alle Contexts.
**Abgelehnt:** Zu komplex für MVP; REST + Domain Events reicht für die aktuelle Skalierung.
### Direkte Datenbank-Joins
`registration-context` liest direkt aus der `actor-context`-Datenbank.
**Abgelehnt:** Verletzt SCS-Prinzip; verhindert unabhängige Deployments und Skalierung.
---
## Implementierungs-Reihenfolge (P1-Priorität)
1. **`actor-context` REST API** (`/api/v1/actors`) Basis für alle anderen Contexts
2. **`event-management-context` REST API** (`/api/v1/events`) Turnier/Bewerb-Referenzen
3. **ACL-Adapter im `registration-context`** `AktorReferenzAdapter` + `TurnierReferenzAdapter`
4. **`registration-context` REST API** (`/api/v1/registrations`) Kern-Use-Cases
5. **Domain Events** `NennungEingereicht` als erstes Event (für `billing-context`)
6. **Offline-Cache** Bulk-Sync beim Turnier-Download
---
## Referenzen
- [ADR-0014: Bounded Context Mapping](0014-bounded-context-mapping-de.md)
- [ADR-0015: Context Map & Integration Patterns](0015-context-map-de.md)
- [ADR-0004: Event-Driven Communication](0004-event-driven-communication-de.md)
- [ADR-0001: Modular Architecture](0001-modular-architecture-de.md)
- [Ubiquitous Language](../../03_Domain/01_Glossary/Ubiquitous_Language.md)
- [MASTER_ROADMAP](../MASTER_ROADMAP.md)
- Vaughn Vernon: „Implementing Domain-Driven Design", Kapitel 13 (Integrating Bounded Contexts)
- ÖTO 2026, ZNS-Schnittstellen-Spezifikation (A-Satz / B-Satz)
+3
View File
@@ -14,5 +14,8 @@ Namensschema: ADR-XXX-title.md mit fortlaufender Nummerierung.
- ADR-003 Optimistic Locking (409) als Konfliktstrategie
- ADR-004 Freshness UI (Ampel)
- ADR-005 Core Domain & Feature Isolation
- ADR-0014 Bounded Context Mapping & Aggregate Roots
- ADR-0015 Context Map & Integration Patterns
- ADR-0016 API-Design & Anti-Corruption Layer (ACL)
Siehe Template: ADR-000-template.md.
@@ -46,11 +46,11 @@ Veranstalter (OEPS-Mitgliedsverein)
### A
| Begriff | Definition | ÖTO-Referenz |
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
| **Abteilung** | **Kleinste Einheit für Nennungen, Startlisten und Ergebnisse.** Untereinheit eines Bewerbs mit eigenem Teilnehmerkreis (Lizenz, Pferdealter etc.) und eigener Platzierung/Siegerehrung. Erhält eine fortlaufende **Abteilungsnummer** (1, 2, ...) innerhalb des Bewerbs. Referenz auf Startliste/Ergebnisliste: `BW: 9 Abt: 1` bzw. `9-1`. Die ÖTO definiert sparten- und klassenabhängige Schwellenwerte, ab wievielen Startern eine Abteilung **verpflichtend** getrennt werden muss. Bei Überschreitung gibt das System eine **WARNUNG** (kein harter Fehler) der TBA hat das letzte Wort (→ *Override-Event*). | ÖTO § 2 Abs. 7 |
| **Akteur** | Oberbegriff für alle Personen (Reiter, Richter, Funktionäre, Besitzer) und Organisationen (Vereine), die im System interagieren. | – |
| **Ausschreibung** | Das offizielle Dokument, das alle Bedingungen eines Turniers festlegt. Pflichtfelder gemäß ÖTO (A-Satz der ZNS-Schnittstelle). | ÖTO Ausschreibungs-Struktur |
| Begriff | Definition | ÖTO-Referenz |
|-------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
| **Abteilung** | **Kleinste Einheit für Nennungen, Startlisten und Ergebnisse.** Untereinheit eines Bewerbs mit eigenem Teilnehmerkreis (Lizenz, Pferdealter etc.) und eigener Platzierung/Siegerehrung. Erhält eine fortlaufende **Abteilungsnummer** (1, 2, ...) innerhalb des Bewerbs. Referenz auf Startliste/Ergebnisliste: `BW: 9 Abt: 1` bzw. `9-1`. Die ÖTO definiert sparten- und klassenabhängige Schwellenwerte, ab wievielen Startern eine Abteilung **verpflichtend** getrennt werden muss. Bei Überschreitung gibt das System eine **WARNUNG** (kein harter Fehler) der TBA hat das letzte Wort (→ *Override-Event*). Vollständige Schwellenwert-Tabellen: → [`Abteilungs-Trennungs-Schwellenwerte.md`](../02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md) | ÖTO § 2 Abs. 7, § 39 |
| **Akteur** | Oberbegriff für alle Personen (Reiter, Richter, Funktionäre, Besitzer) und Organisationen (Vereine), die im System interagieren. | – |
| **Ausschreibung** | Das offizielle Dokument, das alle Bedingungen eines Turniers festlegt. Pflichtfelder gemäß ÖTO (A-Satz der ZNS-Schnittstelle). | ÖTO Ausschreibungs-Struktur |
### B
@@ -0,0 +1,251 @@
---
type: Reference
status: ACTIVE
owner: ÖTO/FEI Rulebook Expert
last_update: 2026-03-24
sources:
- ÖTO 2026, A-Teil § 39 (Teilen von Bewerben)
- ÖTO 2026, B-Teil § 200 ff. (Springen / CSN)
- ÖTO 2026, B-Teil § 100 ff. (Dressur / CDN)
- ÖTO 2026, B-Teil § 300 ff. (Vielseitigkeit / CCN)
- ÖTO 2026, B-Teil § 803 (Caprilli)
- ÖTO 2026, B-Teil § 850 ff. (Reitertreffen / Fahren)
---
# Abteilungs-Trennungs-Schwellenwerte
📜 **[ÖTO/FEI Rulebook Expert]** | 24. März 2026
Dieses Dokument ist die **Single Source of Truth** für alle Regeln zur Pflicht- und Kann-Teilung
von Bewerben in Abteilungen. Es ergänzt die [Ubiquitous Language](../../01_Glossary/Ubiquitous_Language.md)
und dient als Grundlage für die Implementierung der Warn-Logik im `competition-context`.
> ⚠️ **System-Philosophie:** Das System gibt bei Überschreitung eines Schwellenwerts **niemals**
> einen harten Fehler, sondern immer nur eine **WARNUNG**. Der TBA hat das letzte Wort und kann
> per **Override-Event** bewusst abweichen (→ Ubiquitous Language: *Override-Event*).
---
## 1. Allgemeine Schwellenwerte (§ 39 A-Teil) Alle Sparten
Diese Regeln gelten für **alle Turniere der Kategorien A\*, A, B\*, B und C** und für alle Sparten,
sofern die spartenspezifischen Bestimmungen (Abschnitt 2) keine strengeren Regeln vorsehen.
### 1.1 Pflicht-Teilung (MUSS)
| Prüfungstyp | Schwellenwert (Starter) | Abteilungs-Kriterium (Standard) | ÖTO-Referenz |
|------------------------------------------------|-------------------------|---------------------------------|--------------|
| Stil- und Springpferdeprüfungen | **> 30** | Lizenzstufen (sonst: Plätze) | § 39 Abs. 2 |
| Vielseitigkeitsprüfungen | **> 40** | Lizenzstufen (sonst: Plätze) | § 39 Abs. 2 |
| Übrige Springprüfungen (Standard, Spezial ...) | **> 80** | Lizenzstufen (sonst: Plätze) | § 39 Abs. 2 |
| Jede Abteilung nach Teilung | **> 80** | Erneute Teilung verpflichtend | § 39 Abs. 2 |
> **Hinweis:** „Übrige Springprüfungen" umfasst alle Springprüfungen, die **nicht** als Stil- oder
> Springpferdeprüfung ausgeschrieben sind (Standardspringprüfung, Zweiphasen, Punkte, Risiko etc.).
### 1.2 Kann-Teilung (KANN / DARF)
| Prüfungstyp | Schwellenwert (Starter) | Abteilungs-Kriterium (Standard) | ÖTO-Referenz |
|------------------|-------------------------|---------------------------------|--------------|
| Dressurprüfungen | **> 30** | Lizenzstufen (sonst: Plätze) | § 39 Abs. 2 |
### 1.3 Teilungs-Kriterien (Priorität)
Sofern in der Ausschreibung kein anderes Kriterium festgelegt ist, gilt folgende Priorität:
1. **Ausschreibungs-Kriterium** (Altersklasse, Pferdealter, Geschlecht etc.) hat Vorrang
2. **Lizenzstufen** (Standard-Fallback)
3. **Plätze** (wenn Teilung nach Lizenzstufen nicht möglich)
### 1.4 Ausnahmen von der Pflicht-Teilung
| Ausnahme | ÖTO-Referenz |
|---------------------------------------------------------------------|--------------|
| Meisterschaftsbewerbe (Cups und Serien) | § 39 Abs. 4 |
| Bewerbe mit Geldpreisen > Doppeltes der Gebührenordnungs-Geldpreise | § 39 Abs. 2 |
### 1.5 Pflicht-Teilung nach Klasse (unabhängig von Starterzahl)
Diese Teilungen sind **strukturell verpflichtend** und gelten unabhängig von der Starterzahl:
| Prüfungstyp | Pflicht-Teilung | ÖTO-Referenz |
|------------------------------------------------------|-------------------------------------------------------|--------------|
| Dressur- und Springprüfungen Klassen **A und L** | Mindestens 2 Abteilungen; R1 in **eigener** Abteilung | § 39 Abs. 1 |
| Lizenzprüfungsaufgaben | Getrennt nach R2/RD2 und R3/RD3 | § 39 Abs. 1 |
| LM-Springen bei B- und C-Turnieren | Kann getrennt werden: R2/RS2 und R3/RS3/RS4 | § 39 Abs. 1 |
| Pferdeprüfungen (Dressur- und Springpferdeprüfungen) | Teilung nach **Pferdealter** (nicht nach Lizenzen) | § 39 Abs. 1 |
---
## 2. Spartenspezifische Schwellenwerte
### 2.1 Springen (CSN) Sparte B II
#### 2.1.1 Stil- und Idealzeitspringprüfungen bis 95 cm (alle CSN-Kategorien)
Pflicht-Teilung in **getrennte Abteilungen** nach Lizenzstatus unabhängig von der Starterzahl:
| Abteilung | Teilnehmerkreis |
|-----------|--------------------------|
| Abt. 1 | Reiter **ohne Lizenz** |
| Abt. 2 | Reiter mit Lizenz **R1** |
> **Referenz:** ÖTO B-Teil § 200 Abs. 5.3
#### 2.1.2 Springpferdeprüfungen Pflicht-Teilung nach Pferdealter
| Höhe | Erlaubtes Pferdealter | Pflicht-Teilung | ÖTO-Referenz |
|--------------|-----------------------|------------------------------------------|---------------------|
| 95 110 cm | 4 6 jährig | **Ja:** 4-jährige / 56-jährige getrennt | B-Teil § 200 Abs. 6 |
| 115 130 cm | 5 6 jährig | Nein (keine Teilung erforderlich) | B-Teil § 200 Abs. 6 |
| 135 cm | 6 7 jährig | Nein (keine Teilung erforderlich) | B-Teil § 200 Abs. 6 |
#### 2.1.3 CSN-C-NEU Zwingend 2 Abteilungen (strukturell, unabhängig von Starterzahl)
| Höhenbereich | Abt. 1 | Abt. 2 | ÖTO-Referenz |
|--------------|-----------------|------------------|--------------|
| bis 95 cm | **ohne Lizenz** | **mit Lizenz** | B-Teil § 231 |
| ab 100 cm | **R1** | **R2 und höher** | B-Teil § 231 |
---
### 2.2 Dressur (CDN) Sparte B I
#### 2.2.1 Allgemeine Schwellenwerte
Für Dressurprüfungen gilt die **Kann-Teilung** aus § 39 Abs. 2 (> 30 Starter).
Es gibt **keine Pflicht-Teilung** allein aufgrund der Starterzahl bei Dressur
(außer den strukturellen Pflichten aus § 39 Abs. 1 für Klassen A/L).
#### 2.2.2 Dressurpferdeprüfungen Pflicht-Teilung nach Pferdealter
| Klasse | Erlaubtes Pferdealter | Pflicht-Teilung | ÖTO-Referenz |
|--------|-----------------------|------------------------------------------|---------------------|
| A | 4 6 jährig | **Ja:** 4-jährige / 56-jährige getrennt | B-Teil § 100 Abs. 5 |
| L | 5 6 jährig | Nein (keine Teilung erforderlich) | B-Teil § 100 Abs. 5 |
| M | 6 7 jährig | Nein (keine Teilung erforderlich) | B-Teil § 100 Abs. 5 |
| S | 7 8 jährig | Nein (keine Teilung erforderlich) | B-Teil § 100 Abs. 5 |
---
### 2.3 Vielseitigkeit (CCN) Sparte B III
#### 2.3.1 Allgemeine Schwellenwerte
Für Vielseitigkeitsprüfungen gilt die **Pflicht-Teilung** aus § 39 Abs. 2 (> 40 Starter).
#### 2.3.2 Klasse Welcome und 80 cm Lizenz-Abteilung
| Klasse | Regel | ÖTO-Referenz |
|---------|---------------------------------------------------------------------------------------------|-----------------------|
| Welcome | Nur Reiter mit Reiterpass und **höchstens R1**. R2+ sind in **eigener Abteilung** zu werten | B-Teil § 301 Abs. 1.4 |
| 80 cm | R2+ startberechtigt, aber in **eigener Abteilung** zu werten, erhalten keine Ehrenpreise | B-Teil § 301 Abs. 1.4 |
#### 2.3.3 CCN-C-NEU Geländeritte/Geländepferdeprüfungen Zwingend nach Lizenz
| Höhenbereich | Abt. 1 | Abt. 2 | Abt. 3 | ÖTO-Referenz |
|--------------|-----------------|------------------|------------------|----------------------|
| bis 80 cm | **ohne Lizenz** | **R1-Reiter** | **R2 und höher** | B-Teil § 300 (C-NEU) |
| ab 90 cm | **ohne Lizenz** | **R1 und höher** | | B-Teil § 300 (C-NEU) |
---
### 2.4 Caprilli-Prüfungen (§ 803) Alle Sparten
Pflicht-Teilung in **mindestens 2 Abteilungen** unabhängig von der Starterzahl:
| Abteilung | Teilnehmerkreis |
|-----------|-----------------------|
| Abt. 1 | **lizenzfrei** |
| Abt. 2 | **RD1, R1 und höher** |
> **Referenz:** ÖTO B-Teil § 803 Abs. 2
---
### 2.5 Fahren (CAN) Reitertreffen
Bei Fahrertreffen (§ 850 ff.) gilt:
| Regel | ÖTO-Referenz |
|-------------------------------------------------------------------------------------|---------------------|
| Fahrer mit Lizenz **höher als F1** werden in einer **separaten Abteilung** gewertet | B-Teil § 850 Abs. 9 |
---
## 3. Zusammenfassung: Schwellenwert-Matrix
| Sparte | Prüfungstyp | Schwellenwert | Typ | Kriterium |
|--------|-------------------------------|---------------|------|-----------------------|
| Alle | Stil- / Springpferdeprüfungen | > 30 Starter | MUSS | Lizenz / Plätze |
| Alle | Vielseitigkeitsprüfungen | > 40 Starter | MUSS | Lizenz / Plätze |
| Alle | Übrige Springprüfungen | > 80 Starter | MUSS | Lizenz / Plätze |
| Alle | Jede Abteilung nach Teilung | > 80 Starter | MUSS | Erneute Teilung |
| CDN | Dressurprüfungen | > 30 Starter | KANN | Lizenz / Plätze |
| CDN | Dressurpferdeprüfung Klasse A | strukturell | MUSS | Pferdealter (4 / 56) |
| CSN | Stil-/Idealzeit bis 95 cm | strukturell | MUSS | Lizenz (ohne / R1) |
| CSN | Springpferdeprüfung 95110 cm | strukturell | MUSS | Pferdealter (4 / 56) |
| CSN | C-NEU bis 95 cm | strukturell | MUSS | Lizenz (ohne / mit) |
| CSN | C-NEU ab 100 cm | strukturell | MUSS | Lizenz (R1 / R2+) |
| CCN | C-NEU Gelände bis 80 cm | strukturell | MUSS | Lizenz (3 Abt.) |
| CCN | C-NEU Gelände ab 90 cm | strukturell | MUSS | Lizenz (2 Abt.) |
| CCN | Welcome / 80 cm | strukturell | MUSS | R2+ eigene Abt. |
| Alle | Caprilli (§ 803) | strukturell | MUSS | Lizenz (frei / RD1+) |
| CAN | Fahrertreffen | strukturell | MUSS | F1+ eigene Abt. |
---
## 4. Implementierungs-Hinweise für den `competition-context`
### 4.1 Warn-Logik (keine harten Fehler!)
Das System soll folgende **Warnungen** ausgeben (nie harte Fehler):
```
WARN_ABTEILUNG_PFLICHT_TEILUNG_UEBERSCHRITTEN
→ Bewerb [BW-Nr], Prüfungstyp [Typ], Starter: [N] > Schwellenwert [S]
→ Empfehlung: Teilung in Abteilungen nach [Kriterium]
→ Override möglich (TBA-Entscheidung erforderlich)
WARN_ABTEILUNG_STRUKTURELL_NICHT_GETRENNT
→ Bewerb [BW-Nr], Sparte [Sparte], Klasse/Höhe [X]
→ Strukturelle Pflicht-Teilung fehlt (z.B. CSN-C-NEU ohne Lizenz-Trennung)
→ Override möglich (TBA-Entscheidung erforderlich)
```
### 4.2 Konfigurierbare Parameter
Die Schwellenwerte sind **nicht hard-coded**, sondern als konfigurierbare Werte zu hinterlegen:
| Parameter | Standardwert | Quelle |
|----------------------------------------|--------------|-------------|
| `threshold.stil_springpferd.pflicht` | 30 | § 39 Abs. 2 |
| `threshold.vielseitigkeit.pflicht` | 40 | § 39 Abs. 2 |
| `threshold.springen_uebrig.pflicht` | 80 | § 39 Abs. 2 |
| `threshold.dressur.kann` | 30 | § 39 Abs. 2 |
| `threshold.abteilung.max_nach_teilung` | 80 | § 39 Abs. 2 |
### 4.3 Beziehung zu Domain-Modellen
| Domain-Modell | Relevanz |
|------------------|---------------------------------------------------------------|
| `DomAbteilung` | Trägt `abteilungsNummer`, `teilnehmerkreis`, `starterAnzahl` |
| `DomBewerb` | Kennt `prüfungsTyp`, `sparte`, `turnierkategorie`, `höhe` |
| `DomNennung` | Referenziert `DomAbteilung` als kleinste Einheit |
| `Override-Event` | Wird gespeichert, wenn TBA eine Warn-Überschreibung bestätigt |
---
## 5. Offene Fragen / Klärungsbedarf
| # | Frage | Status |
|---|-----------------------------------------------------------------------------------------------------------------------|----------|
| 1 | Gelten die Schwellenwerte aus § 39 auch für **Reitertreffen** (nicht nur Turniere)? | 🔍 Offen |
| 2 | Wie verhält sich die Pflicht-Teilung bei **kombinierten Turnieren** (CDN + CSN am selben Wochenende, § 4)? | 🔍 Offen |
| 3 | Gibt es für **Voltigieren (CVN)** eigene Abteilungs-Trennungsregeln? (B-Teil § 400 ff. nicht vollständig ausgewertet) | 🔍 Offen |
| 4 | Gibt es für **Fahren (CAN)** eigene Starter-Schwellenwerte jenseits der Reitertreffen-Regel? | 🔍 Offen |
---
*Erstellt: 2026-03-24 | Autor: ÖTO/FEI Rulebook Expert (Junie)*
*Basiert auf: ÖTO 2026 A-Teil § 39, B-Teil §§ 100, 200, 231, 300, 803, 850*
@@ -0,0 +1,401 @@
---
type: Specification
status: ACTIVE
owner: ÖTO/FEI Rulebook Expert
last_update: 2026-03-24
related:
- Abteilungs-Trennungs-Schwellenwerte.md
- docs/01_Architecture/adr/0016-api-design-acl-de.md
---
# Warn-Logik-Spezifikation: `competition-context` Abteilungs-Schwellenwerte
📜 **[ÖTO/FEI Rulebook Expert]** | 24. März 2026
Dieses Dokument ist die **verbindliche Implementierungs-Spezifikation** für die Warn-Logik
im `competition-context` bezüglich Abteilungs-Schwellenwerte. Es baut auf der
[Schwellenwert-Referenz](./Abteilungs-Trennungs-Schwellenwerte.md) auf und definiert
präzise, **wann**, **was** und **wie** gewarnt wird.
> ⚠️ **Grundprinzip (ADR-0007):** Das System gibt **niemals** harte Fehler bei
> Schwellenwert-Überschreitungen. Jede Warnung ist **overridebar** per Override-Event
> (TBA-Entscheidung). Warnungen werden gespeichert und sind auditierbar.
---
## 1. Warn-Typen: Übersicht
| Warn-Code | Typ | Auslöser | Betrifft |
|--------------------------------------------|-----------------------|--------------------------------------------------------------------------|------------------------------------|
| `WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN` | Starter-Schwellenwert | Starterzahl > Pflicht-Schwellenwert | `DomBewerb` |
| `WARN_KANN_TEILUNG_EMPFOHLEN` | Starter-Schwellenwert | Starterzahl > Kann-Schwellenwert, keine Teilung konfiguriert | `DomBewerb` |
| `WARN_ABTEILUNG_ZU_GROSS` | Abteilungs-Limit | Abteilung nach Teilung > 80 Starter | `DomAbteilung` |
| `WARN_ABTEILUNG_MAX_UEBERSCHRITTEN` | Konfigurations-Limit | Starter > konfiguriertes `maxStarter`-Limit | `DomAbteilung` |
| `WARN_STRUKTURELLE_TEILUNG_FEHLT` | Strukturelle Pflicht | Vorgeschriebene Abteilungs-Struktur nicht vorhanden | `DomBewerb` + `List<DomAbteilung>` |
| `WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG` | Strukturelle Pflicht | Abteilungs-Struktur vorhanden, aber Teilnehmerkreis falsch/unvollständig | `DomBewerb` + `List<DomAbteilung>` |
---
## 2. Warn-Typ 1: Starter-Schwellenwerte (`DomBewerb`)
### 2.1 Pflicht-Teilung überschritten
**Auslöser:** `DomBewerb.validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl)`
| Bedingung | Warn-Code | Schwellenwert |
|----------------------------------------------------------------------------------------------------------------------|---------------------------------------|---------------|
| `pruefungsTyp` ∈ {`STIL_SPRINGEN`, `SPRINGPFERDE`, `DRESSURPFERDE`} UND `starterAnzahl > 30` UND `!istMeisterschaft` | `WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN` | 30 |
| `pruefungsTyp == VIELSEITIGKEIT` UND `starterAnzahl > 40` UND `!istMeisterschaft` | `WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN` | 40 |
| `pruefungsTyp == SPRINGEN_UEBRIG` UND `starterAnzahl > 80` UND `!istMeisterschaft` | `WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN` | 80 |
**Warn-Nachricht (Format):**
```
WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN:
Bewerb: [bewerbNummer] [bezeichnung]
Prüfungstyp: [pruefungsTyp]
Starter: [N] > Schwellenwert [S]
Empfehlung: Teilung nach [teilungsTyp] (Standard: NACH_LIZENZ)
Referenz: ÖTO § 39 Abs. 2
Override möglich (TBA-Entscheidung erforderlich)
```
### 2.2 Kann-Teilung empfohlen
**Auslöser:** `DomBewerb.validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl)`
| Bedingung | Warn-Code | Schwellenwert |
|-------------------------------------------------------------------------------------------------------|-------------------------------|---------------|
| `pruefungsTyp == DRESSUR` UND `starterAnzahl > 30` UND `teilungsTyp == KEINE` UND `!istMeisterschaft` | `WARN_KANN_TEILUNG_EMPFOHLEN` | 30 |
**Warn-Nachricht (Format):**
```
WARN_KANN_TEILUNG_EMPFOHLEN:
Bewerb: [bewerbNummer] [bezeichnung]
Prüfungstyp: DRESSUR
Starter: [N] > 30
Empfehlung: Kann-Teilung nach NACH_LIZENZ möglich (§ 39 Abs. 2)
Override möglich (TBA-Entscheidung)
```
---
## 3. Warn-Typ 2: Abteilungs-Größe nach Teilung (`DomAbteilung`)
### 3.1 Abteilung nach Teilung zu groß
**Auslöser:** `DomAbteilung.validateStarterLimit()`
| Bedingung | Warn-Code |
|----------------------|---------------------------|
| `starterAnzahl > 80` | `WARN_ABTEILUNG_ZU_GROSS` |
**Warn-Nachricht (Format):**
```
WARN_ABTEILUNG_ZU_GROSS:
Abteilung: [abteilungsNummer] [bezeichnung]
Starter: [N] > 80
Erneute Teilung verpflichtend (§ 39 Abs. 2)
Override möglich (TBA-Entscheidung erforderlich)
```
### 3.2 Konfiguriertes Starter-Limit überschritten
**Auslöser:** `DomAbteilung.validateStarterLimit()`
| Bedingung | Warn-Code |
|---------------------------------------------------|-------------------------------------|
| `maxStarter > 0` UND `starterAnzahl > maxStarter` | `WARN_ABTEILUNG_MAX_UEBERSCHRITTEN` |
**Warn-Nachricht (Format):**
```
WARN_ABTEILUNG_MAX_UEBERSCHRITTEN:
Abteilung: [abteilungsNummer] [bezeichnung]
Starter: [N] > Limit [maxStarter]
Override möglich (TBA-Entscheidung erforderlich)
```
---
## 4. Warn-Typ 3: Strukturelle Pflicht-Teilungen (`DomBewerb` + `List<DomAbteilung>`)
Strukturelle Teilungen sind **unabhängig von der Starterzahl** verpflichtend.
Sie werden durch `DomBewerb.validateStrukturellesTeilung(abteilungen)` geprüft.
### 4.1 Entscheidungsbaum: Wann greift welche strukturelle Prüfung?
```
DomBewerb
├── sparte == SPRINGEN (CSN)
│ ├── pruefungsTyp == STIL_SPRINGEN UND hoeheCm <= 95
│ │ → Prüfung: LIZENZ_OHNE_VS_R1 (§ 200 Abs. 5.3)
│ ├── pruefungsTyp == SPRINGPFERDE UND hoeheCm IN [95..110]
│ │ → Prüfung: PFERDEALTER_4_VS_5_6 (§ 200 Abs. 6)
│ └── turnierkategorie == C_NEU
│ ├── hoeheCm <= 95
│ │ → Prüfung: C_NEU_OHNE_VS_MIT_LIZENZ (§ 231)
│ └── hoeheCm >= 100
│ → Prüfung: C_NEU_R1_VS_R2PLUS (§ 231)
├── sparte == VIELSEITIGKEIT (CCN)
│ ├── turnierkategorie == C_NEU
│ │ ├── hoeheCm <= 80
│ │ │ → Prüfung: CCN_C_NEU_3_ABT (§ 300 C-NEU)
│ │ └── hoeheCm >= 90
│ │ → Prüfung: CCN_C_NEU_2_ABT (§ 300 C-NEU)
│ └── pruefungsTyp == VIELSEITIGKEIT UND bezeichnung enthält "Welcome" ODER hoeheCm == 80
│ → Prüfung: CCN_WELCOME_80_R2PLUS_EIGENE_ABT (§ 301 Abs. 1.4)
├── sparte == DRESSUR (CDN)
│ └── pruefungsTyp == DRESSURPFERDE UND hoeheCm == null (Klasse A, 46-jährig)
│ → Prüfung: PFERDEALTER_4_VS_5_6 (§ 100 Abs. 5)
├── pruefungsTyp == CAPRILLI
│ → Prüfung: CAPRILLI_LIZENSFREI_VS_RD1PLUS (§ 803 Abs. 2)
└── sparte == FAHREN (CAN) UND pruefungsTyp == FAHREN
→ Prüfung: FAHREN_F1PLUS_EIGENE_ABT (§ 850 Abs. 9)
```
### 4.2 Strukturelle Prüfungen im Detail
#### LIZENZ_OHNE_VS_R1 CSN Stil-/Idealzeitspringen bis 95 cm
**Regel:** Mindestens 2 Abteilungen: Abt. ohne Lizenz + Abt. R1. Unabhängig von Starterzahl.
| Prüfung | Bedingung für `WARN_STRUKTURELLE_TEILUNG_FEHLT` |
|-------------------------------|-------------------------------------------------------------------|
| Abt. „ohne Lizenz" vorhanden? | Keine Abteilung mit `teilnehmerkreisBeschreibung` ~ „ohne Lizenz" |
| Abt. „R1" vorhanden? | Keine Abteilung mit `teilnehmerkreisBeschreibung` ~ „R1" |
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Sparte: CSN, Prüfungstyp: STIL_SPRINGEN, Höhe: ≤ 95 cm
Fehlende Abteilung(en): [ohne Lizenz / R1]
Referenz: ÖTO B-Teil § 200 Abs. 5.3
Override möglich (TBA-Entscheidung erforderlich)
```
#### PFERDEALTER_4_VS_5_6 Springpferdeprüfung 95110 cm / Dressurpferdeprüfung Klasse A
**Regel:** 4-jährige in eigener Abteilung, getrennt von 56-jährigen.
| Prüfung | Bedingung für `WARN_STRUKTURELLE_TEILUNG_FEHLT` |
|-------------------------------|-------------------------------------------------------------------------|
| Abt. „4-jährige" vorhanden? | Keine Abteilung mit `teilnehmerkreisBeschreibung` ~ „4-jährig" |
| Abt. „56-jährige" vorhanden? | Keine Abteilung mit `teilnehmerkreisBeschreibung` ~ „5" oder „6-jährig" |
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Sparte: [CSN/CDN], Prüfungstyp: [SPRINGPFERDE/DRESSURPFERDE], Höhe: [X] cm
Fehlende Abteilung(en): [4-jährige / 56-jährige]
Referenz: ÖTO B-Teil § 200 Abs. 6 / § 100 Abs. 5
Override möglich (TBA-Entscheidung erforderlich)
```
#### C_NEU_OHNE_VS_MIT_LIZENZ CSN-C-NEU bis 95 cm
**Regel:** Abt. 1 = ohne Lizenz, Abt. 2 = mit Lizenz.
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Sparte: CSN, Kategorie: C-NEU, Höhe: ≤ 95 cm
Fehlende Abteilung(en): [ohne Lizenz / mit Lizenz]
Referenz: ÖTO B-Teil § 231
Override möglich (TBA-Entscheidung erforderlich)
```
#### C_NEU_R1_VS_R2PLUS CSN-C-NEU ab 100 cm
**Regel:** Abt. 1 = R1, Abt. 2 = R2 und höher.
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Sparte: CSN, Kategorie: C-NEU, Höhe: ≥ 100 cm
Fehlende Abteilung(en): [R1 / R2+]
Referenz: ÖTO B-Teil § 231
Override möglich (TBA-Entscheidung erforderlich)
```
#### CCN_C_NEU_3_ABT CCN-C-NEU Gelände bis 80 cm
**Regel:** 3 Abteilungen: ohne Lizenz / R1 / R2+.
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Sparte: CCN, Kategorie: C-NEU, Höhe: ≤ 80 cm
Fehlende Abteilung(en): [ohne Lizenz / R1 / R2+]
Referenz: ÖTO B-Teil § 300 (C-NEU)
Override möglich (TBA-Entscheidung erforderlich)
```
#### CCN_C_NEU_2_ABT CCN-C-NEU Gelände ab 90 cm
**Regel:** 2 Abteilungen: ohne Lizenz / R1+.
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Sparte: CCN, Kategorie: C-NEU, Höhe: ≥ 90 cm
Fehlende Abteilung(en): [ohne Lizenz / R1+]
Referenz: ÖTO B-Teil § 300 (C-NEU)
Override möglich (TBA-Entscheidung erforderlich)
```
#### CCN_WELCOME_80_R2PLUS_EIGENE_ABT CCN Welcome / 80 cm
**Regel:** R2+ Reiter müssen in eigener Abteilung gewertet werden.
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Sparte: CCN, Klasse: Welcome / 80 cm
R2+-Reiter ohne eigene Abteilung
Referenz: ÖTO B-Teil § 301 Abs. 1.4
Override möglich (TBA-Entscheidung erforderlich)
```
#### CAPRILLI_LIZENSFREI_VS_RD1PLUS Caprilli (§ 803)
**Regel:** Mindestens 2 Abteilungen: lizenzfrei / RD1 und höher. Unabhängig von Starterzahl.
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Prüfungstyp: CAPRILLI
Fehlende Abteilung(en): [lizenzfrei / RD1+]
Referenz: ÖTO B-Teil § 803 Abs. 2
Override möglich (TBA-Entscheidung erforderlich)
```
#### FAHREN_F1PLUS_EIGENE_ABT Fahrertreffen (§ 850)
**Regel:** Fahrer mit Lizenz höher als F1 in eigener Abteilung.
```
WARN_STRUKTURELLE_TEILUNG_FEHLT:
Bewerb: [bewerbNummer] [bezeichnung]
Sparte: CAN, Prüfungstyp: FAHREN
F1+-Fahrer ohne eigene Abteilung
Referenz: ÖTO B-Teil § 850 Abs. 9
Override möglich (TBA-Entscheidung erforderlich)
```
---
## 5. Implementierungs-Vorgaben
### 5.1 Methoden-Signaturen (Kotlin)
```kotlin
// In DomBewerb bereits vorhanden, vollständig implementieren:
fun validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl: Int): List<AbteilungsWarnung>
// In DomBewerb NEU zu implementieren:
fun validateStrukturellesTeilung(abteilungen: List<DomAbteilung>): List<AbteilungsWarnung>
// In DomAbteilung bereits vorhanden, vollständig implementieren:
fun validateStarterLimit(): List<AbteilungsWarnung>
```
### 5.2 `AbteilungsWarnung` Value Object
Statt roher Strings soll ein typisiertes Value Object verwendet werden:
```kotlin
@Serializable
data class AbteilungsWarnung(
val code: AbteilungsWarnungCodeE, // Maschinenlesbarer Warn-Code
val bewerbId: Uuid, // Betroffener Bewerb
val abteilungId: Uuid? = null, // Betroffene Abteilung (wenn relevant)
val nachricht: String, // Menschenlesbare Beschreibung
val oetoParagraph: String, // z.B. "§ 39 Abs. 2"
val istOverridebar: Boolean = true, // Immer true (ADR-0007)
val timestamp: Instant = Clock.System.now()
)
enum class AbteilungsWarnungCodeE {
WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN,
WARN_KANN_TEILUNG_EMPFOHLEN,
WARN_ABTEILUNG_ZU_GROSS,
WARN_ABTEILUNG_MAX_UEBERSCHRITTEN,
WARN_STRUKTURELLE_TEILUNG_FEHLT,
WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG
}
```
### 5.3 Override-Event
Wenn der TBA eine Warnung bestätigt/überschreibt, wird ein `AbteilungsWarnungOverrideEvent` gespeichert:
```kotlin
@Serializable
data class AbteilungsWarnungOverrideEvent(
val overrideId: Uuid = Uuid.random(),
val warnungCode: AbteilungsWarnungCodeE,
val bewerbId: Uuid,
val abteilungId: Uuid? = null,
val begruendung: String, // Pflichtfeld TBA muss Begründung angeben
val tbaUserId: Uuid,
val timestamp: Instant = Clock.System.now()
)
```
### 5.4 Konfigurierbare Schwellenwerte
Die Schwellenwerte sind **nicht hard-coded**, sondern über `AbteilungsSchwellenwertConfig` konfigurierbar:
```kotlin
data class AbteilungsSchwellenwertConfig(
val stilSpringpferdPflicht: Int = 30, // § 39 Abs. 2
val vielseitigkeitPflicht: Int = 40, // § 39 Abs. 2
val springenUebrigPflicht: Int = 80, // § 39 Abs. 2
val dressurKann: Int = 30, // § 39 Abs. 2
val abteilungMaxNachTeilung: Int = 80 // § 39 Abs. 2
)
```
### 5.5 Aufruf-Zeitpunkte (Trigger)
| Ereignis | Aufgerufene Validierung |
|-------------------------------------------------------------|----------------------------------------------------------|
| Neue Nennung wird einem Bewerb zugeordnet | `DomBewerb.validateAbteilungsSchwellenwerte(neueAnzahl)` |
| Abteilung wird erstellt oder geändert | `DomAbteilung.validateStarterLimit()` |
| Bewerb wird gespeichert / Abteilungs-Konfiguration geändert | `DomBewerb.validateStrukturellesTeilung(abteilungen)` |
| Startliste wird aus ENTWURF → VEROEFFENTLICHT überführt | Alle drei Validierungen als Gesamt-Check |
---
## 6. Ausnahmen (nicht warnen)
| Bedingung | Begründung |
|--------------------------------------------------------|-----------------------------------------------------|
| `istMeisterschaft == true` | § 39 Abs. 4: Meisterschaftsbewerbe sind ausgenommen |
| Bewerb mit Geldpreisen > Doppeltes der Gebührenordnung | § 39 Abs. 2: Ausnahme von Pflicht-Teilung |
| `turnierkategorie` nicht in {A*, A, B*, B, C, C-NEU} | Schwellenwerte gelten nur für diese Kategorien |
---
## 7. Offene Fragen (Klärungsbedarf)
| # | Frage | Status |
|---|----------------------------------------------------------------------------------------------|----------|
| 1 | Gelten § 39-Schwellenwerte auch für **Reitertreffen** (nicht nur Turniere)? | 🔍 Offen |
| 2 | Pflicht-Teilung bei **kombinierten Turnieren** (CDN + CSN, § 4)? | 🔍 Offen |
| 3 | **Voltigieren (CVN):** Eigene Abteilungs-Trennungsregeln? (§ 400 ff. nicht ausgewertet) | 🔍 Offen |
| 4 | **Fahren (CAN):** Eigene Starter-Schwellenwerte jenseits der Reitertreffen-Regel? | 🔍 Offen |
| 5 | Wie wird „Bewerb mit Geldpreisen > Doppeltes der Gebührenordnung" im Datenmodell abgebildet? | 🔍 Offen |
---
*Erstellt: 2026-03-24 | Autor: ÖTO/FEI Rulebook Expert (Junie)*
*Basiert auf: ÖTO 2026 A-Teil § 39, B-Teil §§ 100, 200, 231, 300, 301, 803, 850*
*Implementierungs-Ziel: `competition-context` (PHASE 5)*
@@ -0,0 +1,75 @@
---
type: SessionLog
date: 2026-03-24
agents:
- Lead Architect
- Curator
status: ABGESCHLOSSEN
---
# Session Log: ADR-0014 & ADR-0015 — Bounded Context Mapping & Context Map
🏗️ **[Lead Architect]** | 🧹 **[Curator]** | 24. März 2026
---
## Ziel der Session
ADRs für Bounded Context Mapping und Context Map vervollständigen (PHASE 4, Task 1).
---
## Ergebnisse
### ADR-0014: Bounded Context Mapping (SCS-Architektur)
**Datei:** `docs/01_Architecture/adr/0014-bounded-context-mapping-de.md`
Dokumentiert die 6 Bounded Contexts als Self-Contained Systems:
| Context | Domänen-Typ | Priorität |
|----------------------------|-------------------|-----------|
| `registration-context` | Core Domain | P1 |
| `actor-context` | Supporting Domain | P1 |
| `competition-context` | Supporting Domain | P2 |
| `event-management-context` | Supporting Domain | P2 |
| `billing-context` | Generic Domain | P3 |
| `identity-context` | Generic Domain | P3 |
Für jeden Context dokumentiert: Aggregate Roots, Ubiquitous Language (Auswahl), Kern-Invarianten.
Begründung für Ablehnung der alten technischen Modulaufteilung (`masterdata`, `members`, `horses`, `events`).
---
### ADR-0015: Context Map & Integration Patterns
**Datei:** `docs/01_Architecture/adr/0015-context-map-de.md`
Dokumentiert 7 Context-Beziehungen mit ASCII-Diagramm und Detailtabellen:
| Beziehung | Pattern |
|-----------------------------------------------------|-----------------------------------|
| ZNS → `actor-context` | Upstream/Downstream + ACL |
| `actor-context``registration-context` | Customer/Supplier + Shared Kernel |
| `event-management-context``registration-context` | Customer/Supplier + Shared Kernel |
| `registration-context``competition-context` | Domain Events (asynchron) |
| `registration-context``billing-context` | Domain Events + ACL |
| `competition-context``billing-context` | Domain Events + ACL |
| Keycloak → alle Contexts | Conformist (OIDC/JWT) |
Enthält: ACL-Implementierungsrichtlinien, Offline-First-Verhalten pro Szenario.
---
## MASTER_ROADMAP Updates
- `[x]` **ADRs vervollständigen** (PHASE 4, Lead Architect) — abgeschlossen
- ADR-Tabelle: Einträge #8 (ADR-0014) und #9 (ADR-0015) hinzugefügt
---
## Offene Punkte (nächste Session)
- `[ ]` **API-Design:** Schnittstellen zwischen den Contexts definieren (Anti-Corruption Layer) — Lead Architect
- `[ ]` **`actor-context`:** `DomPferd`, `DomFunktionär`, `DomVerein` implementieren — Backend Developer
@@ -0,0 +1,92 @@
---
type: SessionLog
date: 2026-03-24
agent: Lead Architect
phase: PHASE 4
task: API-Design & Anti-Corruption Layer
status: ABGESCHLOSSEN
---
# 🧹 [Curator] Session Log API-Design & ACL
**Datum:** 24. März 2026
**Agent:** 🏗️ Lead Architect
**Phase:** PHASE 4 MVP-Implementierung
**Aufgabe:** API-Design: Schnittstellen zwischen den Contexts definieren (Anti-Corruption Layer)
---
## Ergebnisse
### Erstellt
- **`ADR-0016`** (`docs/01_Architecture/adr/0016-api-design-acl-de.md`)
- Architektur-Muster: Ports & Adapters (Hexagonal) für alle Contexts
- REST-API-Katalog für alle 3 P1-Contexts (`actor`, `event-management`, `registration`)
- Vollständige DTO-Definitionen (Inbound Commands + Outbound DTOs)
- ACL-Port-Interfaces: `AktorReferenzPort`, `TurnierReferenzPort`
- ACL-Adapter-Implementierung mit Übersetzungslogik (DTO → Referenz-Objekt)
- Domain Events-Katalog: 8 Events über 3 Contexts
- ZNS-Schnittstelle: `ZnsPort` mit A-Satz / B-Satz
- Offline-First Cache-Strategie für ACL-Adapter
- Implementierungs-Reihenfolge (P1-Priorität)
### Aktualisiert
- **`MASTER_ROADMAP.md`**: Task `[x]` abgehakt, ADR #10 in Tabelle eingetragen
- **`docs/01_Architecture/adr/README.md`**: ADR-0016 eingetragen
---
## Kern-Entscheidungen (ADR-0016)
| Bereich | Entscheidung |
|---------------|--------------------------------------------------|
| Muster | Ports & Adapters (Hexagonal Architecture) |
| Kommunikation | REST (synchron) + Domain Events (asynchron) |
| ACL-Regel | Domain-Objekte verlassen den Context **niemals** |
| DTOs | Flach, serialisierbar, ohne Domänen-Logik |
| Offline | Lokale SQLite-Caches in ACL-Adaptern |
| ZNS | Eigener `ZnsPort` im `actor-context` (isoliert) |
---
## REST-API Übersicht
| Context | Base-URL | Endpunkte |
|----------------------------|-------------------------|------------------------------------------|
| `actor-context` | `/api/v1/actors` | 8 (Reiter, Pferde, Funktionäre, Vereine) |
| `event-management-context` | `/api/v1/events` | 6 (Veranstaltungen, Turniere, Bewerbe) |
| `registration-context` | `/api/v1/registrations` | 6 (Nennungen, Transfer) |
---
## Domain Events Übersicht
| Context | Events |
|----------------------------|------------------------------------------------------------------|
| `actor-context` | `ReiterAktualisiert`, `PferdAktualisiert`, `ReiterGesperrt` |
| `registration-context` | `NennungEingereicht`, `NennungStorniert`, `NennungTransferiert` |
| `event-management-context` | `TurnierEroeffnet`, `NennungsschlussErreicht`, `TurnierAbgesagt` |
---
## Status PHASE 4: Lead Architect Tasks
- [x] ADRs vervollständigen (Bounded Context Mapping + Context Map) → ADR-0014, ADR-0015
- [x] API-Design & ACL definieren → ADR-0016
**Lead Architect Tasks PHASE 4: ✅ ABGESCHLOSSEN**
---
## Nächste Schritte (Backend Developer)
Gemäß Implementierungs-Reihenfolge aus ADR-0016:
1. `actor-context` REST API (`/api/v1/actors`) implementieren
2. `event-management-context` REST API (`/api/v1/events`) implementieren
3. ACL-Adapter im `registration-context` implementieren
4. `registration-context` REST API (`/api/v1/registrations`) implementieren
5. Domain Event `NennungEingereicht` als erstes Event
6. Offline-Cache (Bulk-Sync beim Turnier-Download)
@@ -0,0 +1,70 @@
---
type: Session Log
date: 2026-03-24
agent: Backend Developer
status: ABGESCHLOSSEN
roadmap_phase: PHASE 4 MVP-Implementierung
---
# Session Log: actor-context Domain-Modelle
👷 **[Backend Developer]** | 24. März 2026
## Ziel
Domain-Modelle für `DomPferd`, `DomFunktionär` und `DomVerein` im `actor-context` implementieren (PHASE 4,
MASTER_ROADMAP).
---
## Ergebnis
### ✅ DomPferd
- Bereits vollständig und ÖTO-konform im Modul `backend/services/horses/horses-domain` vorhanden.
- Keine Änderungen notwendig.
### ✅ DomFunktionaer (neu)
- **Datei:**
`backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomFunktionaer.kt`
- Aggregate Root des `officials`-Bounded Context.
- Felder: `richterNummer` (ZNS RICHT01.dat), `vorname`, `nachname`, `geburtsdatum`, `rollen`, `richterQualifikation`,
`qualifiziertFuerSparten`, `email`, `telefon`, `vereinsNummer`, `istAktiv`, `bemerkungen`, `datenQuelle`.
- Domain-Methoden: `getDisplayName()`, `istRichterFuerSparte()`, `istTba()`, `validateFuerTurniereinsatz()` (Warn-Logik,
kein harter Fehler).
### ✅ DomVerein (neu)
- **Datei:** `backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomVerein.kt`
- Aggregate Root des `clubs`-Bounded Context.
- Felder: `vereinsNummer` (ZNS VEREIN01.dat, 4-stellig), `name`, `kurzname`, `bundesland`, `ort`, `plz`, `strasse`,
`email`, `telefon`, `website`, `oepsRegionNummer`, `istVeranstalter`, `istAktiv`, `bemerkungen`, `datenQuelle`.
- Domain-Methoden: `getDisplayName()`, `getDisplayNameWithNummer()`, `hasCompleteAddress()`,
`validateFuerVeranstaltung()` (Warn-Logik).
### ✅ Neue Enums in core-domain
- **Datei:** `core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt`
- `FunktionaerRolleE`: TBA, RICHTER, PARCOURSBAUER, STRECKENDESIGNER, TIERARZT, STEWARD, STARTER, ZEITNEHMER,
PROTOKOLLFUEHRER, SONSTIGE
- `RichterQualifikationE`: GA, G3, G2, G1, INTERNATIONAL, SONSTIGE (gemäß ZNS RICHT01.dat)
---
## Design-Entscheidungen
| Entscheidung | Begründung |
|------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `DomFunktionaer` statt Erweiterung von `DomOfficial` | `DomOfficial` war ein minimaler ZNS-Import-Stub. `DomFunktionaer` ist das vollständige ÖTO-konforme Domain-Modell mit Rollen und Sparten-Qualifikation. |
| `DomVerein` statt Erweiterung von `DomClub` | Analog: `DomClub` war ein ZNS-Import-Stub. `DomVerein` enthält Adresse, Kontakt, Veranstalter-Flag und OEPS-Region. |
| Warn-Logik statt harter Fehler | Konsistent mit ADR-0016 und Override-Event-Prinzip: `validateFuerTurniereinsatz()` und `validateFuerVeranstaltung()` geben Warnungen zurück, kein Exception-Throwing. |
| `rollen: Set<FunktionaerRolleE>` | Eine Person kann mehrere Rollen haben (z.B. TBA + Richter). Set verhindert Duplikate. |
---
## Nächste Schritte (PHASE 4)
- [ ] `registration-context`: `DomBewerb`, `DomAbteilung`, `DomStartliste` implementieren.
- [ ] `event-management-context`: `DomVeranstaltung`, `DomTurnier`, `DomAusschreibung` implementieren.
- [ ] Persistenz: Repository-Interfaces und DB-Migrationen (Flyway/Liquibase).
@@ -67,10 +67,15 @@ die ÖTO-konforme Terminologie und die Erstellung der offiziellen Ubiquitous Lan
## Erstellte / Aktualisierte Dokumente
| Dokument | Aktion | Beschreibung |
|-----------------------------------------------------|----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `docs/03_Domain/01_Glossary/Ubiquitous_Language.md` | ✅ NEU ERSTELLT | Offizielle Domänen-Terminologie mit ÖTO-Referenzen, Bounded Context Zuordnung, Hierarchie-Diagramm, Reglement-Hinweis für Cups/Serien/Meisterschaften und MVP-Scope-Tabelle |
| `docs/03_Domain/01_Glossary/Ubiquitous_Language.md` | ✅ KORRIGIERT | Drei Korrekturen eingearbeitet (📜 ÖTO/FEI Rulebook Expert Review): **Abteilung** als kleinste Einheit für Nennungen/Startlisten/Ergebnisse mit Abteilungsnummer und Referenzformat `BW:9 Abt:1`; **Bewerb** korrigiert (nicht mehr „kleinste Einheit"); **Kopfnummer** als nicht eindeutige ID markiert; **Lebensnummer** mit Hinweis auf inkonsistente ZNS-Daten ergänzt |
| Dokument | Aktion | Beschreibung |
|----------------------------------------------------------------------|----------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `docs/03_Domain/01_Glossary/Ubiquitous_Language.md` | ✅ NEU ERSTELLT | Offizielle Domänen-Terminologie mit ÖTO-Referenzen, Bounded Context Zuordnung, Hierarchie-Diagramm, Reglement-Hinweis für Cups/Serien/Meisterschaften und MVP-Scope-Tabelle |
| `docs/03_Domain/01_Glossary/Ubiquitous_Language.md` | ✅ KORRIGIERT | Drei Korrekturen eingearbeitet (📜 ÖTO/FEI Rulebook Expert Review): **Abteilung** als kleinste Einheit für Nennungen/Startlisten/Ergebnisse mit Abteilungsnummer und Referenzformat `BW:9 Abt:1`; **Bewerb** korrigiert (nicht mehr „kleinste Einheit"); **Kopfnummer** als nicht eindeutige ID markiert; **Lebensnummer** mit Hinweis auf inkonsistente ZNS-Daten ergänzt |
| `core/core-domain/.../Enums.kt` | ✅ ERWEITERT | Neue Enums: `SparteE`, `TurnierkategorieE`, `VeranstaltungsTypE`, `LizenzKlasseE`, `NennungsStatusE`, `StartwunschE` alle ÖTO-konform mit KDoc-Kommentaren |
| `backend/services/persons/persons-domain/.../DomReiter.kt` | ✅ NEU ERSTELLT | Reiter-Domänenmodell (actor-context): Satznummer als ZNS-Primärschlüssel, LizenzKlasse, Startkarte, Sparten-Lizenz, Gastreiter-Flag, `validateForNennung()` gibt nur Warnungen (kein harter Fehler) |
| `backend/services/entries/entries-domain/.../DomNennung.kt` | ✅ NEU ERSTELLT | Nennungs-Domänenmodell (registration-context): Referenz auf Abteilung (kleinste Einheit), Reiter, Pferd, Zahler; Nachnennung-Flag, Gebühren-Verzicht, Status-Lifecycle |
| `backend/services/entries/entries-domain/.../DomNennungsTransfer.kt` | ✅ NEU ERSTELLT | Transfer-Domänenmodell: explizites Audit-Trail (alter/neuer Reiter, altes/neues Pferd), Override-Event-Referenz, `isValid()` prüft dass mindestens Reiter oder Pferd getauscht wurde |
| `backend/services/entries/entries-domain/build.gradle.kts` | ✅ NEU ERSTELLT | Neues KMP-Modul nach Muster `horses-domain`; in `settings.gradle.kts` registriert; `compileKotlinJvm` erfolgreich |
---
@@ -87,11 +92,12 @@ die ÖTO-konforme Terminologie und die Erstellung der offiziellen Ubiquitous Lan
## Nächste Schritte (Empfehlung)
- [ ] 👷 **[Backend Developer]**: Kotlin Domain-Modelle für `registration-context` und `actor-context` definieren
- [ ] 🏗️ **[Lead Architect]**: MASTER_ROADMAP mit den 6 Bounded Contexts aktualisieren
- [ ] 🎨 **[Frontend Expert]**: KMP/Compose Desktop Projektstruktur aufsetzen
- [ ] 📜 **[ÖTO/FEI Rulebook Expert]**: Abteilungs-Trennungs-Schwellenwerte (sparten- und klassenabhängig) recherchieren
und dokumentieren
- [x] 👷 **[Backend Developer]**: Kotlin Domain-Modelle für `registration-context` und `actor-context` definieren
- [x] 🏗️ **[Lead Architect]**: MASTER_ROADMAP mit den 6 Bounded Contexts aktualisieren
`docs/01_Architecture/MASTER_ROADMAP.md`
- [x] 🎨 **[Frontend Expert]**: KMP/Compose Desktop Projektstruktur aufsetzen
- [x] 📜 **[ÖTO/FEI Rulebook Expert]**: Abteilungs-Trennungs-Schwellenwerte (sparten- und klassenabhängig) recherchieren
und dokumentieren → `docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md`
---
@@ -0,0 +1,117 @@
---
date: 2026-03-24
type: Session Log
agents: Frontend Expert, Curator
status: COMPLETED
---
# Session Log KMP/Compose Desktop Shell aufsetzen
🎨 **[Frontend Expert]** & 🧹 **[Curator]** | 24. März 2026
---
## Zusammenfassung der Session
Aufsetzen der dedizierten Desktop-Shell `meldestelle-desktop` als eigenständiges
JVM/Compose-Desktop-Modul gemäß Desktop-First-Strategie (ADR-0009) und Vision_03-Design-Baseline.
---
## Durchgeführte Aktivitäten
### 1. Neues Modul: `frontend/shells/meldestelle-desktop`
- Eigenständige Desktop-Shell, getrennt vom Web-Portal (`meldestelle-portal`)
- Reines JVM-Modul (kein JS/WASM) Desktop-First gemäß MASTER_ROADMAP
- In `settings.gradle.kts` registriert
- `compileKotlinJvm` ✅ BUILD SUCCESSFUL
### 2. Projektstruktur
```
frontend/shells/meldestelle-desktop/
├── build.gradle.kts # JVM-only, compose.desktop, nativeDistributions
└── src/jvmMain/kotlin/at/mocode/desktop/
├── main.kt # application {} Entry-Point, Koin-Init, Window
├── DesktopApp.kt # Login-Gate + Haupt-Composable
├── di/
│ └── DesktopModule.kt # Koin: DesktopNavigationPort, CurrentUserProvider, DeepLinkHandler
├── navigation/
│ └── DesktopNavigationPort.kt # StateFlow-basierte Navigation
└── screens/
├── DesktopMainLayout.kt # Sidebar (220dp) + Content-Bereich
├── PlaceholderContent.kt # Wiederverwendbarer Platzhalter
├── VeranstaltungenScreen.kt # Übersicht + "Neue Veranstaltung"-Button
├── VeranstaltungNeuScreen.kt # Tabs: Übersicht | Stammdaten* | Organisation | Preisliste
├── VeranstaltungDetailScreen.kt # Übersicht-Tab + Turniere-Section
├── TurnierNeuScreen.kt # Tabs: Übersicht | Stammdaten | Organisation | Bewerbe⭐* | Preisliste
├── TurnierDetailScreen.kt # Bewerbe-Tab integriert NennungsMaske (nennung-feature)
└── AktorScreens.kt # Reiter, Pferde, Funktionäre, Meisterschaften, Cups
```
### 3. Navigation gemäß Vision_03
Sidebar-Navigation mit 6 Einträgen (links, 220dp, Material3 `surfaceVariant`):
| Eintrag | Route | Status |
|-----------------|--------------------|---------------------------------|
| Veranstaltungen | `/veranstaltungen` | ✅ Screen implementiert |
| Reiter | `/reiter` | ✅ Placeholder |
| Pferde | `/pferde` | ✅ Placeholder |
| Funktionäre | `/funktionaere` | ✅ Placeholder |
| Meisterschaften | `/meisterschaften` | ✅ Placeholder (Phase 2+) |
| Cups | `/cups` | ✅ Placeholder (Phase 2+) |
| Logout | — | ✅ Löscht Token, zurück zu Login |
### 4. Neue `AppScreen`-Einträge (core/navigation)
Folgende Screens wurden in `AppScreen.kt` ergänzt:
- `Veranstaltungen`, `VeranstaltungNeu`, `VeranstaltungDetail(id)`
- `TurnierNeu(veranstaltungId)`, `TurnierDetail(veranstaltungId, turnierId)`
- `Reiter`, `Pferde`, `Funktionaere`, `Meisterschaften`, `Cups`
### 5. Nennungs-Integration
- `TurnierDetailScreen` → Bewerbe-Tab (⭐ Standard-Tab) integriert `NennungsMaske` aus `nennung-feature`
- Callbacks für Startliste, Ergebnisse, Abrechnung als TODO vorbereitet
---
## Erstellte / Aktualisierte Dokumente
| Dokument | Aktion | Beschreibung |
|-----------------------------------------------------------------------------------|----------------|---------------------------------------------------------------------|
| `frontend/shells/meldestelle-desktop/build.gradle.kts` | ✅ NEU | JVM-only Shell, compose.desktop, nativeDistributions (Deb/Msi/Dmg) |
| `frontend/shells/meldestelle-desktop/src/.../main.kt` | ✅ NEU | application {} Entry-Point, Koin-Init, Window 1400×900 |
| `frontend/shells/meldestelle-desktop/src/.../DesktopApp.kt` | ✅ NEU | Login-Gate, delegiert an DesktopMainLayout |
| `frontend/shells/meldestelle-desktop/src/.../di/DesktopModule.kt` | ✅ NEU | Koin-Modul mit Navigation, CurrentUserProvider, DeepLinkHandler |
| `frontend/shells/meldestelle-desktop/src/.../navigation/DesktopNavigationPort.kt` | ✅ NEU | StateFlow-Navigation |
| `frontend/shells/meldestelle-desktop/src/.../screens/*.kt` | ✅ NEU | 8 Screen-Dateien (Layout, Placeholders, Veranstaltung/Turnier-Flow) |
| `frontend/core/navigation/.../AppScreen.kt` | ✅ ERWEITERT | 10 neue Desktop-Screens ergänzt |
| `settings.gradle.kts` | ✅ ERWEITERT | `:frontend:shells:meldestelle-desktop` registriert |
| `docs/01_Architecture/MASTER_ROADMAP.md` | ✅ AKTUALISIERT | Frontend-Expert-Tasks als abgeschlossen markiert |
---
## Wichtige Entscheidungen
1. **Eigenständiges Modul** statt Erweiterung des Portal-Shells → saubere Trennung Desktop/Web
2. **Login-Gate** in `DesktopApp.kt` → Desktop startet immer mit Login, kein Landing-Screen
3. **`PrimaryTabRow`** statt deprecated `TabRow` verwendet
4. **`NennungsMaske`** direkt im Bewerbe-Tab des `TurnierDetailScreen` integriert
---
## Nächste Schritte (Empfehlung)
- [ ] 👷 **[Backend Developer]**: `DomVeranstaltung`, `DomTurnier`, `DomBewerb`, `DomAbteilung` implementieren
- [ ] 🎨 **[Frontend Expert]**: `VeranstaltungenScreen` mit echten Daten aus Repository befüllen
- [ ] 🎨 **[Frontend Expert]**: Startlisten- und Ergebnisse-Screens implementieren (competition-context)
- [ ] 🎨 **[Frontend Expert]**: Formular-Felder für `VeranstaltungNeuScreen` und `TurnierNeuScreen` ausbauen
---
*Session-Dauer: 24. März 2026*
*Curator: Junie (KI-Agent)*
@@ -0,0 +1,78 @@
---
type: Session Log
date: 2026-03-24
agent: Backend Developer
context: event-management-context
status: ABGESCHLOSSEN
---
# Session Log: event-management-context Domain-Modelle
👷 **[Backend Developer]** | 24. März 2026
## Ziel
Implementierung der Domain-Modelle `DomVeranstaltung`, `DomTurnier` und `DomAusschreibung`
für den `event-management-context` gemäß ÖTO § 2 Abs. 1 und ADR-0003 (`Veranstaltung ≠ Turnier`).
---
## Implementierte Modelle
### `DomVeranstaltung` (`events-domain`)
- **Aggregate Root** für den organisatorischen Rahmen einer pferdesportlichen Veranstaltung.
- Felder: `veranstaltungId`, `name`, `veranstaltungsTyp`, `sparten`, `veranstalterVereinId`,
`verantwortlicheFunktionaerId`, `startDatum`, `endDatum`, `ort`, `nennschluss`, `status`,
`ausschreibungsId`, `oepsGenehmigungsNummer`, `bemerkungen`, Audit-Felder.
- Warn-Logik: `validateNennungsmoeglichkeit()` (Status GENEHMIGT + Nennschluss vorhanden),
`validateFuerEinreichung()` (Pflichtfelder, Datum-Konsistenz, Ausschreibung verknüpft).
### `DomTurnier` (`events-domain`)
- **Aggregate Root** für ein einzelnes Turnier innerhalb einer Veranstaltung.
- Felder: `turnierId`, `veranstaltungId` (FK), `name`, `sparte`, `kategorie`, `datum`,
`richterObmannId`, `parcoursbauerId`, `status`, `maxBewerbe`, `istMeisterschaft`, `bemerkungen`, Audit-Felder.
- Warn-Logik: `validateFunktionaerBesetzung()` (Richter-Obmann Pflicht; Parcoursbauer Pflicht bei Springen),
`validateFuerPlanung()` (Pflichtfelder, positive maxBewerbe).
### `DomAusschreibung` (`events-domain`)
- **Aggregate Root** für das offizielle Ausschreibungs-Dokument.
- Felder: `ausschreibungsId`, `veranstaltungId` (FK), `titel`, `sparten`, `nennschluss`,
`nachnennung`, `nachnennungBis`, Gebühren in Cent (Integer, kein Float), `tierwohleuroAktiv`,
Veranstaltungsort, Stallplätze, Kontakt, `status`, `eingereichtAm`, `genehmigungsNummer`, Audit-Felder.
- Warn-Logik: `validateFuerEinreichung()` (Pflichtfelder, Gebühren ≥ 0, Nachnennungs-Datum-Konsistenz).
- Hilfsmethoden: `getNenngebuehrAlsEuroString()`, `getGesamtgebuehrCent()`.
---
## Neue Enums in `Enums.kt` (`core-domain`)
| Enum | Werte |
|-------------------------|----------------------------------------------------------------------------------------------------------|
| `VeranstaltungsStatusE` | `IN_PLANUNG`, `EINGEREICHT`, `GENEHMIGT`, `NENNSCHLUSS_ABGELAUFEN`, `AKTIV`, `ABGESCHLOSSEN`, `ABGESAGT` |
| `TurnierStatusE` | `GEPLANT`, `AKTIV`, `ABGESCHLOSSEN`, `ABGESAGT` |
| `AusschreibungsStatusE` | `ENTWURF`, `EINGEREICHT`, `GENEHMIGT`, `ABGELEHNT`, `VEROEFFENTLICHT` |
---
## Design-Entscheidungen
- **Veranstaltung ≠ Turnier** (ADR-0003): `DomVeranstaltung` ist der Rahmen, `DomTurnier` die Durchführungseinheit.
- **Gebühren in Cent (Integer)**: Vermeidung von Floating-Point-Fehlern bei Geldbeträgen.
- **Warn-Logik statt Exceptions** (ADR-0007): Alle Validierungen geben `List<String>` zurück.
- **Bestehende `Veranstaltung.kt`** bleibt als Legacy-Scaffold erhalten; `DomVeranstaltung.kt` ist das neue ÖTO-konforme
Modell.
---
## Geänderte Dateien
| Datei | Aktion |
|------------------------------------------|----------------------|
| `events-domain/.../DomVeranstaltung.kt` | NEU |
| `events-domain/.../DomTurnier.kt` | NEU |
| `events-domain/.../DomAusschreibung.kt` | NEU |
| `core-domain/.../Enums.kt` | ERWEITERT (+3 Enums) |
| `docs/01_Architecture/MASTER_ROADMAP.md` | AKTUALISIERT |
@@ -0,0 +1,121 @@
---
date: 2026-03-24
agent: Backend Developer
phase: PHASE 4 MVP-Implementierung
status: ABGESCHLOSSEN
---
# Session Log: REST-Endpunkte Nennungs-Workflow
👷 **[Backend Developer]** | 24. März 2026
## Ziel
Implementierung der REST-Endpunkte für den Nennungs-Workflow (registration-context) gemäß MASTER_ROADMAP Phase 4.
---
## Erledigte Aufgaben
### 1. `entries-service` aktiviert
- `settings.gradle.kts`: `:backend:services:entries:entries-service` aus dem ON-HOLD-Kommentar befreit und aktiv
eingebunden.
### 2. `entries-api` Fachliche DTOs (`NennungDtos.kt`)
Neue Datei: `entries-api/src/commonMain/kotlin/at/mocode/entries/api/NennungDtos.kt`
| DTO | Zweck |
|-------------------------------|-------------------------------------|
| `NennungEinreichenRequest` | POST Neue Nennung einreichen |
| `NennungStatusAendernRequest` | PUT Status ändern |
| `NennungTransferRequest` | POST /transfer Transfer-Operation |
| `NennungSummaryDto` | GET Liste kompakte Ansicht |
| `NennungDetailDto` | GET Detail / POST Response |
| `NennungsTransferDto` | Transfer-Response mit Audit-Trail |
**Dependency ergänzt:** `projects.core.coreDomain` in `entries-api/build.gradle.kts` (für `NennungsStatusE`,
`StartwunschE`, `UuidSerializer`).
### 3. `entries-domain` `NennungsTransferRepository`
Neues Interface: `entries-domain/.../repository/NennungsTransferRepository.kt`
- `findById`, `findByUrsprungsNennungId`, `save`
### 4. `entries-service` Persistence-Schicht
| Datei | Inhalt |
|-------------------------------------|-----------------------------------------------------------------|
| `NennungTable.kt` | Exposed-Tabelle `nennungen` mit allen Feldern + Indizes |
| `NennungsTransferTable.kt` | Exposed-Tabelle `nennungs_transfers` mit Audit-Trail |
| `NennungRepositoryImpl.kt` | Vollständige Implementierung aller `NennungRepository`-Methoden |
| `NennungsTransferRepositoryImpl.kt` | Implementierung aller `NennungsTransferRepository`-Methoden |
### 5. `entries-service` Use Cases (`NennungUseCases.kt`)
`@Service`-Klasse mit folgenden Use Cases:
| Methode | Fachliche Logik |
|-------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|
| `getNennungById` | Query by ID |
| `getNennungenByTurnier/Bewerb/Abteilung/Reiter` | Gefilterte Listen |
| `nennungEinreichen` | Neue Nennung, Warn-Log bei Nachnennung |
| `statusAendern` | Status-Transition mit Audit-Timestamp |
| `nennungZurueckziehen` | Soft-Delete → Status `ZURUECKGEZOGEN` |
| `nennungTransferieren` | **Atomare Transfer-Operation** (ÖTO-konform): Ursprung → TRANSFERIERT, neue Nennung anlegen, Transfer-Record speichern |
**ÖTO-Konformität:** Warn-Logik statt harter Fehler. TBA hat das letzte Wort.
### 6. `entries-service` REST-Controller (`NennungController.kt`)
Basis-URL: `/api/v1/registrations/nennungen`
| Methode | Endpunkt | Beschreibung |
|----------|--------------------------|------------------------------------------------------------|
| `GET` | `/` | Liste (Filter: turnierId, bewerbId, abteilungId, reiterId) |
| `GET` | `/{nennungsId}` | Detail |
| `POST` | `/` | Neue Nennung einreichen (201) |
| `PUT` | `/{nennungsId}/status` | Status ändern |
| `DELETE` | `/{nennungsId}` | Zurückziehen |
| `POST` | `/{nennungsId}/transfer` | Transfer (201) |
### 7. `entries-service` Konfiguration
| Datei | Inhalt |
|-----------------------------------|---------------------------------------------------------------|
| `EntriesBeansConfiguration.kt` | Spring-Beans für Repository-Implementierungen |
| `EntriesDatabaseConfiguration.kt` | Exposed-Schema-Init (`NennungTable`, `NennungsTransferTable`) |
| `EntriesExceptionHandler.kt` | RFC 9457 Problem Details (404, 400, 409) |
### 8. `entries-service` Build-Dependencies ergänzt
```kotlin
implementation(projects.backend.services.entries.entriesDomain)
implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain)
implementation(libs.exposed.core)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
```
---
## Architektur-Entscheidungen
- **Warn-Logik:** Nachnennungen und Transfers nach Nennschluss werden geloggt (`log.warn`), aber nicht blockiert gemäß
ADR-7 (Warn-Logik statt harter Fehler).
- **Transfer = atomare Operation:** Keine Storno + Neunennung, sondern: Ursprung → TRANSFERIERT + neue Nennung +
Transfer-Record. Entspricht ÖTO-Regelwerk.
- **Soft-Delete:** Nennungen werden nie physisch gelöscht, sondern auf `ZURUECKGEZOGEN` gesetzt.
- **Repository-Pattern:** Interfaces in `entries-domain`, Implementierungen in `entries-service` (Dependency Inversion).
---
## Nächste Schritte
- **ÖTO/FEI Rulebook Expert:** Voltigieren (CVN) und Fahren (CAN) Abteilungs-Trennungsregeln auswerten (offene Fragen
#3, #4).
- **QA Specialist:** Integrationstests für den Nennungs-Workflow schreiben.
- **Backend Developer:** `competition-context` (Bewerbe, Startlisten, Ergebnisse) PHASE 5.
@@ -0,0 +1,107 @@
---
type: Session Log
date: 2026-03-24
agent: Backend Developer
topic: Persistenz Repository-Interfaces und erste DB-Migrationen
status: ABGESCHLOSSEN
---
# 👷 Session Log: Persistenz Repository-Interfaces & DB-Migrationen
**Datum:** 24. März 2026
**Agent:** 👷 Backend Developer
**Aufgabe:** Repository-Interfaces und erste DB-Migrationen (Flyway) für `actor-context`, `registration-context`.
---
## Erledigte Aufgaben
### 1. Repository-Interfaces (Domain-Layer)
Vier neue Repository-Interfaces als Ports (Hexagonale Architektur) in den `-domain`-Modulen:
| Interface | Modul | Aggregate Root |
|-------------------------|--------------------|------------------|
| `FunktionaerRepository` | `officials-domain` | `DomFunktionaer` |
| `VereinRepository` | `clubs-domain` | `DomVerein` |
| `ReiterRepository` | `persons-domain` | `DomReiter` |
| `NennungRepository` | `entries-domain` | `DomNennung` |
Alle Interfaces folgen dem gleichen Muster:
- `findById`, `findByXxx`, `findAll`, `findAllActive` (paginiert)
- `save` (Upsert-Semantik)
- `delete`, `countXxx`, `existsByXxx`
- `suspend fun` für Coroutine-Kompatibilität
### 2. Exposed-Tabellendefinitionen (Infrastructure-Layer)
Drei neue `Table`-Objekte (Exposed 1.0.0, `org.jetbrains.exposed.v1.core.Table` + `javaUUID`):
| Table | Modul | DB-Tabelle |
|--------------------|----------------------------|----------------|
| `FunktionaerTable` | `officials-infrastructure` | `funktionaere` |
| `VereinTable` | `clubs-infrastructure` | `vereine` |
| `ReiterTable` | `persons-infrastructure` | `reiter` |
Technische Details:
- UUID-PK via `javaUUID("id").autoGenerate()` (konsistent mit `HorseTable`)
- JSON-Spalten (`TEXT`) für `rollen`, `qualifiziert_fuer_sparten`, `lizenziert_fuer_sparten`
- `timestamp()` aus `org.jetbrains.exposed.v1.datetime` für Audit-Felder
- Partial Unique Indexes für nullable Felder (Richternummer, Satznummer, FEI-ID)
### 3. Repository-Implementierungen (Infrastructure-Layer)
Drei neue `Exposed*Repository`-Klassen:
| Implementierung | Modul |
|--------------------------------|----------------------------|
| `ExposedFunktionaerRepository` | `officials-infrastructure` |
| `ExposedVereinRepository` | `clubs-infrastructure` |
| `ExposedReiterRepository` | `persons-infrastructure` |
Technische Details:
- Imports: `org.jetbrains.exposed.v1.core.eq`, `.like`, `.and`, `.or` (Top-Level-Funktionen in Exposed 1.0.0)
- Pagination: `.limit(n).offset(m.toLong())` (Exposed 1.0.0 API)
- JSON-Serialisierung via `kotlinx.serialization.json.Json`
- `kotlin.time.Clock` (nicht `kotlinx.datetime.Clock`) für Timestamp-Kompatibilität mit Exposed
### 4. Flyway-SQL-Migrationen (Service-Layer)
Drei neue V001-Migrationen im Stil der bestehenden `masterdata-service`-Migrationen:
| Migration | Service | Tabelle |
|---------------------------------------|---------------------|----------------|
| `V001__Create_Funktionaere_Table.sql` | `officials-service` | `funktionaere` |
| `V001__Create_Vereine_Table.sql` | `clubs-service` | `vereine` |
| `V001__Create_Reiter_Table.sql` | `persons-service` | `reiter` |
Alle Migrationen enthalten:
- `CREATE TABLE IF NOT EXISTS` mit vollständiger Spaltendefinition
- Partial Unique Indexes für nullable Identifikationsfelder
- Performance-Indizes für häufige Suchabfragen
- `COMMENT ON TABLE/COLUMN` für Dokumentation
---
## Architektur-Entscheidungen
- **Hexagonale Architektur:** Repository-Interfaces im Domain-Layer, Implementierungen im Infrastructure-Layer keine
Abhängigkeit der Domain von Exposed.
- **JSON für Collections:** Rollen und Sparten als JSON-Arrays in TEXT-Spalten (kein Join-Table) pragmatisch für MVP,
erweiterbar.
- **Upsert-Semantik:** `save()` prüft Existenz via `selectAll().where { id eq ... }` und führt Insert oder Update durch.
- **Partial Unique Indexes:** Nullable Felder (Richternummer, Satznummer, FEI-ID) mit `WHERE field IS NOT NULL`
erlaubt mehrere NULL-Werte.
---
## Offene Punkte
- `entries-infrastructure`-Modul existiert noch nicht → `NennungRepository`-Implementierung ausstehend.
- Flyway-Konfiguration in `officials-service`, `clubs-service`, `persons-service` `application.yml` noch nicht
geprüft/ergänzt.
- Spring-DI-Konfiguration (Bean-Registrierung der Repository-Implementierungen) noch ausstehend.
@@ -0,0 +1,76 @@
---
type: Session Log
date: 2026-03-24
agent: Backend Developer
status: ABGESCHLOSSEN
roadmap_phase: PHASE 4 MVP-Implementierung
---
# Session Log: registration-context Domain-Modelle
👷 **[Backend Developer]** | 24. März 2026
## Ziel
Domain-Modelle `DomBewerb`, `DomAbteilung` und `DomStartliste` im `registration-context` implementieren (PHASE 4,
MASTER_ROADMAP).
---
## Ergebnis
### ✅ DomBewerb (neu)
- **Datei:** `backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomBewerb.kt`
- Aggregate Root für den Bewerbs-Workflow im `registration-context`.
- Felder: `turnierId`, `bewerbNummer`, `bezeichnung`, `sparte`, `turnierkategorie`, `pruefungsTyp`, `hoeheCm`,
`teilungsTyp`, `maxStarterProAbteilung`, `istMeisterschaft`, `istNachnennungErlaubt`.
- Domain-Methoden: `getDisplayName()`, `getPflichtTeilungsSchwellenwert()` (§ 39-konform),
`getKannTeilungsSchwellenwert()`, `validateAbteilungsSchwellenwerte()` (Warn-Logik).
### ✅ DomAbteilung (neu)
- **Datei:** `backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomAbteilung.kt`
- Kleinste startbare Einheit innerhalb eines Bewerbs.
- Felder: `bewerbId`, `abteilungsNummer`, `bezeichnung`, `teilungsTyp`, `teilnehmerkreisBeschreibung`, `starterAnzahl`,
`maxStarter`, `startzeit`.
- Domain-Methoden: `getDisplayName()`, `hatFreiePlaetze()`, `validateStarterLimit()` (Warn-Logik für > 80 Starter, § 39
Abs. 2).
### ✅ DomStartliste (neu)
- **Datei:** `backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomStartliste.kt`
- Enthält geordnete Liste von `StartlistenEintrag` (Startnummer → Nennung).
- Workflow: `NICHT_ERSTELLT → ENTWURF → VEROEFFENTLICHT → GESPERRT → ARCHIVIERT`.
- Domain-Methoden: `getStarterAnzahl()`, `getEintragByStartnummer()`, `istBearbeitbar()`, `istSichtbar()`,
`veroeffentlichen()`, `sperren()` (Warn-Logik für ungültige Status-Übergänge).
- `StartlistenEintrag`: Denormalisierte Felder `reiterName`, `pferdeName` für schnelle Anzeige; `istGestrichen`-Flag für
Abmeldungen nach Startlistenerstellung.
### ✅ Neue Enums in core-domain
- **Datei:** `core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt`
- `PruefungsTypE`: STIL_SPRINGEN, SPRINGPFERDE, DRESSURPFERDE, VIELSEITIGKEIT, SPRINGEN_UEBRIG, DRESSUR, CAPRILLI,
FAHREN, VOLTIGIEREN, SONSTIGE mit Schwellenwert-Kommentaren gemäß § 39.
- `AbteilungsTeilungsTypE`: KEINE, NACH_LIZENZ, NACH_PLATZ, NACH_PFERDEALTER, STRUKTURELL, NACH_AUSSCHREIBUNG.
- `StartlistenStatusE`: NICHT_ERSTELLT, ENTWURF, VEROEFFENTLICHT, GESPERRT, ARCHIVIERT.
---
## Design-Entscheidungen
| Entscheidung | Begründung |
|----------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `DomBewerb` im `entries-domain`-Modul | Bewerbe sind direkt mit Nennungen verknüpft; `entries-domain` ist das bestehende KMP-Modul für den `registration-context`. |
| Schwellenwerte als Domain-Methoden, nicht hard-coded | Konsistent mit Implementierungs-Hinweisen in `Abteilungs-Trennungs-Schwellenwerte.md` (§ 4.2). Konfigurierbare Parameter für spätere Persistenz vorbereitet. |
| `StartlistenEintrag` als Value Object in `DomStartliste` | Einträge haben keine eigene Identität außerhalb der Startliste; Denormalisierung von `reiterName`/`pferdeName` für performante Anzeige ohne Join. |
| Warn-Logik statt harter Fehler | Konsistent mit ADR-0016 und Override-Event-Prinzip: alle `validate*()`-Methoden geben Warnungen zurück, kein Exception-Throwing. |
| `istMeisterschaft`-Flag auf `DomBewerb` | Meisterschaftsbewerbe sind von der Pflicht-Teilung ausgenommen (§ 39 Abs. 4) Flag ermöglicht direkte Prüfung in `getPflichtTeilungsSchwellenwert()`. |
---
## Nächste Schritte (PHASE 4)
- [ ] `event-management-context`: `DomVeranstaltung`, `DomTurnier`, `DomAusschreibung` implementieren.
- [ ] Persistenz: Repository-Interfaces und DB-Migrationen (Flyway/Liquibase).
- [ ] API: REST-Endpunkte für Nennungs-Workflow (Kern-Use-Cases).
@@ -0,0 +1,82 @@
---
type: Session Log
date: 2026-03-24
agent: ÖTO/FEI Rulebook Expert
status: ABGESCHLOSSEN
---
# Session Log: Warn-Logik-Spezifikation `competition-context`
🧹 **[Curator]** | 24. März 2026
---
## Aufgabe
Spezifikation der `competition-context` Warn-Logik für Abteilungs-Schwellenwerte
(MASTER_ROADMAP Phase 4, ÖTO/FEI Rulebook Expert).
## Ergebnis
### Neues Dokument erstellt
**`docs/03_Domain/02_Reference/OETO_Regelwerk/Warn-Logik-Spezifikation-competition-context.md`**
Verbindliche Implementierungs-Spezifikation für die Warn-Logik im `competition-context`.
### Inhalt der Spezifikation
**6 Warn-Codes definiert:**
| Warn-Code | Typ |
|--------------------------------------------|----------------------------------------------|
| `WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN` | Starter-Schwellenwert überschritten |
| `WARN_KANN_TEILUNG_EMPFOHLEN` | Kann-Teilung empfohlen (Dressur) |
| `WARN_ABTEILUNG_ZU_GROSS` | Abteilung nach Teilung > 80 Starter |
| `WARN_ABTEILUNG_MAX_UEBERSCHRITTEN` | Konfiguriertes Starter-Limit überschritten |
| `WARN_STRUKTURELLE_TEILUNG_FEHLT` | Strukturelle Pflicht-Teilung nicht vorhanden |
| `WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG` | Strukturelle Teilung unvollständig |
**3 Warn-Typen mit vollständigem Entscheidungsbaum:**
1. Starter-Schwellenwerte (`DomBewerb.validateAbteilungsSchwellenwerte`)
2. Abteilungs-Größe nach Teilung (`DomAbteilung.validateStarterLimit`)
3. Strukturelle Pflicht-Teilungen (`DomBewerb.validateStrukturellesTeilung`) NEU
**Strukturelle Prüfungen abgedeckt:**
- CSN Stil-/Idealzeitspringen ≤ 95 cm: ohne Lizenz vs. R1 (§ 200 Abs. 5.3)
- Springpferdeprüfung 95110 cm / Dressurpferdeprüfung Kl. A: Pferdealter 4 vs. 56 (§ 200 Abs. 6 / § 100 Abs. 5)
- CSN-C-NEU ≤ 95 cm: ohne Lizenz vs. mit Lizenz (§ 231)
- CSN-C-NEU ≥ 100 cm: R1 vs. R2+ (§ 231)
- CCN-C-NEU Gelände ≤ 80 cm: 3 Abteilungen (§ 300)
- CCN-C-NEU Gelände ≥ 90 cm: 2 Abteilungen (§ 300)
- CCN Welcome / 80 cm: R2+ eigene Abteilung (§ 301 Abs. 1.4)
- Caprilli: lizenzfrei vs. RD1+ (§ 803 Abs. 2)
- Fahren/Fahrertreffen: F1+ eigene Abteilung (§ 850 Abs. 9)
**Implementierungs-Vorgaben:**
- Typisiertes Value Object `AbteilungsWarnung` (statt roher Strings)
- `AbteilungsWarnungCodeE` Enum
- `AbteilungsWarnungOverrideEvent` mit Pflicht-Begründungsfeld
- `AbteilungsSchwellenwertConfig` (konfigurierbare Schwellenwerte, nicht hard-coded)
- Aufruf-Zeitpunkte (Trigger) definiert
### Aktualisierte Dokumente
- `docs/01_Architecture/MASTER_ROADMAP.md` Task als `[x]` markiert, Referenz ergänzt, Referenz-Tabelle aktualisiert
## Offene Fragen (weiterhin offen)
| # | Frage |
|---|--------------------------------------------------------------------|
| 1 | Gelten § 39-Schwellenwerte auch für Reitertreffen? |
| 2 | Pflicht-Teilung bei kombinierten Turnieren (§ 4)? |
| 3 | Voltigieren (CVN): Eigene Abteilungs-Trennungsregeln? |
| 4 | Fahren (CAN): Eigene Starter-Schwellenwerte? |
| 5 | Abbildung „Geldpreise > Doppeltes Gebührenordnung" im Datenmodell? |
---
*Session: 2026-03-24 | Agent: ÖTO/FEI Rulebook Expert + Curator (Junie)*
@@ -14,6 +14,21 @@ sealed class AppScreen(val route: String) {
data object OrganizerProfile : AppScreen("/organizer/profile")
data object AuthCallback : AppScreen("/auth/callback")
data object Nennung : AppScreen("/nennung")
// --- Desktop-Navigation (Vision_03) ---
data object Veranstaltungen : AppScreen("/veranstaltungen")
data class VeranstaltungDetail(val id: Long) : AppScreen("/veranstaltung/$id")
data object VeranstaltungNeu : AppScreen("/veranstaltung/neu")
data class TurnierDetail(val veranstaltungId: Long, val turnierId: Long) :
AppScreen("/veranstaltung/$veranstaltungId/turnier/$turnierId")
data class TurnierNeu(val veranstaltungId: Long) : AppScreen("/veranstaltung/$veranstaltungId/turnier/neu")
data object Reiter : AppScreen("/reiter")
data object Pferde : AppScreen("/pferde")
data object Funktionaere : AppScreen("/funktionaere")
data object Meisterschaften : AppScreen("/meisterschaften")
data object Cups : AppScreen("/cups")
companion object {
fun fromRoute(route: String): AppScreen {
return when (route) {
@@ -27,6 +42,13 @@ sealed class AppScreen(val route: String) {
"/organizer/profile" -> OrganizerProfile
"/auth/callback" -> AuthCallback
"/nennung" -> Nennung
"/veranstaltungen" -> Veranstaltungen
"/veranstaltung/neu" -> VeranstaltungNeu
"/reiter" -> Reiter
"/pferde" -> Pferde
"/funktionaere" -> Funktionaere
"/meisterschaften" -> Meisterschaften
"/cups" -> Cups
else -> Landing // Default fallback
}
}
@@ -0,0 +1,79 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
/**
* Shell-Modul: Meldestelle Desktop App
* Reines JVM/Compose-Desktop-Modul Desktop-First gemäß MASTER_ROADMAP.
* Setzt alle Core- und Feature-Module zu einer lauffähigen Desktop-Anwendung zusammen.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
// Core-Module
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.sync)
implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth)
// Feature-Module
implementation(projects.frontend.features.nennungFeature)
implementation(projects.frontend.features.pingFeature)
// Compose Desktop
implementation(compose.desktop.currentOs)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(compose.uiTooling)
// DI (Koin)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Coroutines
implementation(libs.kotlinx.coroutines.swing)
// Bundles
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.compose.common)
}
jvmTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
compose.desktop {
application {
mainClass = "at.mocode.desktop.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Deb, TargetFormat.Msi, TargetFormat.Dmg)
packageName = "Meldestelle"
packageVersion = "1.0.0"
description = "ÖTO-konforme Turnier-Meldestelle Desktop App"
vendor = "mo-code.at"
linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
}
windows {
menuGroup = "Meldestelle"
upgradeUuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}
}
}
@@ -0,0 +1,70 @@
package at.mocode.desktop
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import at.mocode.desktop.navigation.DesktopNavigationPort
import at.mocode.desktop.screens.DesktopMainLayout
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.theme.AppTheme
import at.mocode.frontend.core.navigation.AppScreen
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
/**
* Haupt-Composable der Desktop-App.
* Steuert Login-Gate und delegiert an DesktopMainLayout (Sidebar + Content).
*/
@Composable
fun DesktopApp() {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
val nav = koinInject<DesktopNavigationPort>()
val authTokenManager = koinInject<AuthTokenManager>()
val currentScreen by nav.currentScreen.collectAsState()
val loginViewModel: LoginViewModel = koinViewModel()
val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login) {
LaunchedEffect(Unit) {
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen))
}
}
when (val screen = currentScreen) {
is AppScreen.Login -> LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = {
val returnTo = screen.returnTo ?: AppScreen.Veranstaltungen
nav.navigateToScreen(returnTo)
},
onBack = { /* Desktop hat keine Landing-Page */ },
)
else -> {
// Authentifiziert → Haupt-Layout mit Sidebar
DesktopMainLayout(
currentScreen = screen,
onNavigate = { nav.navigateToScreen(it) },
onLogout = {
authTokenManager.clearToken()
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen))
},
)
}
}
}
}
}
@@ -0,0 +1,35 @@
package at.mocode.desktop.di
import at.mocode.desktop.navigation.DesktopNavigationPort
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.domain.models.User
import at.mocode.frontend.core.navigation.CurrentUserProvider
import at.mocode.frontend.core.navigation.DeepLinkHandler
import at.mocode.frontend.core.navigation.NavigationPort
import org.koin.dsl.module
/**
* CurrentUserProvider-Implementierung für die Desktop-Shell.
* Liest den aktuellen Auth-State aus dem AuthTokenManager.
*/
class DesktopCurrentUserProvider(
private val authTokenManager: AuthTokenManager,
) : CurrentUserProvider {
override fun getCurrentUser(): User? {
val state = authTokenManager.authState.value
if (!state.isAuthenticated) return null
return User(
id = state.userId ?: state.username ?: "unknown",
username = state.username ?: state.userId ?: "unknown",
displayName = null,
roles = emptyList(),
)
}
}
val desktopModule = module {
single { DesktopNavigationPort() }
single<NavigationPort> { get<DesktopNavigationPort>() }
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) }
}
@@ -0,0 +1,56 @@
package at.mocode.desktop
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import at.mocode.desktop.di.desktopModule
import at.mocode.frontend.core.auth.di.authModule
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.nennung.feature.di.nennungFeatureModule
import at.mocode.ping.feature.di.pingFeatureModule
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.koin.dsl.module
fun main() = application {
try {
startKoin {
modules(
networkModule,
syncModule,
authModule,
localDbModule,
pingFeatureModule,
nennungFeatureModule,
desktopModule,
)
}
println("[DesktopApp] Koin initialisiert")
} catch (e: Exception) {
println("[DesktopApp] Koin-Warnung: ${e.message}")
}
try {
val provider = GlobalContext.get().get<DatabaseProvider>()
val db = runBlocking { provider.createDatabase() }
loadKoinModules(module { single<AppDatabase> { db } })
println("[DesktopApp] Lokale DB bereit")
} catch (e: Exception) {
println("[DesktopApp] DB-Warnung: ${e.message}")
}
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle",
state = WindowState(width = 1400.dp, height = 900.dp),
) {
DesktopApp()
}
}
@@ -0,0 +1,27 @@
package at.mocode.desktop.navigation
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.navigation.NavigationPort
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* NavigationPort-Implementierung für die Desktop-Shell.
* Hält den aktuellen Screen als StateFlow, den DesktopApp beobachtet.
*/
class DesktopNavigationPort : NavigationPort {
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Login())
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
override fun navigateTo(route: String) {
val screen = AppScreen.fromRoute(route)
println("[DesktopNav] navigateTo $route -> $screen")
_currentScreen.value = screen
}
fun navigateToScreen(screen: AppScreen) {
println("[DesktopNav] navigateToScreen -> $screen")
_currentScreen.value = screen
}
}
@@ -0,0 +1,72 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Placeholder-Screens für Akteur-Verwaltung (actor-context).
* Werden in Phase 4/5 mit echten Daten aus dem actor-context befüllt.
*/
@Composable
fun ReiterScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Reiter", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Reiter-Verwaltung",
subtitle = "Satznummer, Lizenzklasse, Sparten-Lizenz actor-context (Phase 4).",
)
}
}
@Composable
fun PferdeScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Pferde", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Pferde-Verwaltung",
subtitle = "Lebensnummer, ZNS-Daten, Passbesitzer actor-context (Phase 4).",
)
}
}
@Composable
fun FunktionaereScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Funktionäre", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Funktionäre-Verwaltung",
subtitle = "Richter, Parcourschef, Tierarzt actor-context (Phase 4).",
)
}
}
@Composable
fun MeisterschaftenScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Meisterschaften", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Meisterschaften",
subtitle = "Konfigurierbare Reglements, Punktesysteme series-context (Phase 2+).",
)
}
}
@Composable
fun CupsScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Cups", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Cups & Serien",
subtitle = "Pluggable Berechnungsmodell, Paar-Bindung series-context (Phase 2+).",
)
}
}
@@ -0,0 +1,230 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.nennung.feature.presentation.NennungViewModel
import org.koin.compose.viewmodel.koinViewModel
/**
* Haupt-Layout der Desktop-App gemäß Vision_03.
* Sidebar (links) + Content-Bereich (rechts).
*/
@Composable
fun DesktopMainLayout(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onLogout: () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
DesktopSidebar(
currentScreen = currentScreen,
onNavigate = onNavigate,
onLogout = onLogout,
)
HorizontalDivider(
modifier = Modifier.fillMaxHeight().width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
Box(modifier = Modifier.fillMaxSize()) {
DesktopContentArea(
currentScreen = currentScreen,
onNavigate = onNavigate,
)
}
}
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
private data class NavItem(
val label: String,
val icon: ImageVector,
val screen: AppScreen,
)
private val navItems = listOf(
NavItem("Veranstaltungen", Icons.Default.Event, AppScreen.Veranstaltungen),
NavItem("Reiter", Icons.Default.Person, AppScreen.Reiter),
NavItem("Pferde", Icons.Default.Star, AppScreen.Pferde),
NavItem("Funktionäre", Icons.Default.Badge, AppScreen.Funktionaere),
NavItem("Meisterschaften", Icons.Default.EmojiEvents, AppScreen.Meisterschaften),
NavItem("Cups", Icons.Default.WorkspacePremium, AppScreen.Cups),
)
@Composable
private fun DesktopSidebar(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onLogout: () -> Unit,
) {
Column(
modifier = Modifier
.width(220.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(vertical = 16.dp),
) {
// App-Titel
Text(
text = "Meldestelle",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(modifier = Modifier.height(8.dp))
// Navigations-Einträge
navItems.forEach { item ->
val isSelected = currentScreen::class == item.screen::class
SidebarNavItem(
item = item,
isSelected = isSelected,
onClick = { onNavigate(item.screen) },
)
}
Spacer(modifier = Modifier.weight(1f))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(modifier = Modifier.height(8.dp))
// Logout
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onLogout() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Logout",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Logout",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
}
@Composable
private fun SidebarNavItem(
item: NavItem,
isSelected: Boolean,
onClick: () -> Unit,
) {
val bgColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
val contentColor = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.background(bgColor, RoundedCornerShape(8.dp))
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = item.icon,
contentDescription = item.label,
tint = contentColor,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = item.label,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
),
color = contentColor,
)
}
}
// ---------------------------------------------------------------------------
// Content-Bereich: Screen-Routing
// ---------------------------------------------------------------------------
@Composable
private fun DesktopContentArea(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
) {
val nennungViewModel: NennungViewModel = koinViewModel()
when (currentScreen) {
is AppScreen.Veranstaltungen -> VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) },
)
is AppScreen.VeranstaltungNeu -> VeranstaltungNeuScreen(
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onSave = { onNavigate(AppScreen.Veranstaltungen) },
)
is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id,
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) },
onTurnierOeffnen = { turnierId -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, turnierId)) },
)
is AppScreen.TurnierNeu -> TurnierNeuScreen(
veranstaltungId = currentScreen.veranstaltungId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
onSave = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
)
is AppScreen.TurnierDetail -> TurnierDetailScreen(
veranstaltungId = currentScreen.veranstaltungId,
turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
nennungViewModel = nennungViewModel,
)
is AppScreen.Reiter -> ReiterScreen()
is AppScreen.Pferde -> PferdeScreen()
is AppScreen.Funktionaere -> FunktionaereScreen()
is AppScreen.Meisterschaften -> MeisterschaftenScreen()
is AppScreen.Cups -> CupsScreen()
// Fallback für alle anderen Screens (Dashboard, Ping etc.)
else -> VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) },
)
}
}
@@ -0,0 +1,47 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Construction
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Wiederverwendbarer Platzhalter für Screens, die noch nicht implementiert sind.
*/
@Composable
fun PlaceholderContent(
title: String,
subtitle: String = "Wird in einer späteren Phase implementiert.",
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.Construction,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.outlineVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
@@ -0,0 +1,84 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.nennung.feature.presentation.NennungViewModel
import at.mocode.nennung.feature.presentation.NennungsMaske
/**
* Detailansicht eines bestehenden Turniers (Vision_03: /veranstaltung/{id}/turnier/{tid}).
* Tabs: Übersicht | Stammdaten (A-Satz) | Organisation | Bewerbe | Preisliste
* Der Bewerbe-Tab integriert die NennungsMaske aus dem nennung-feature.
*/
@Composable
fun TurnierDetailScreen(
veranstaltungId: Long,
turnierId: Long,
onBack: () -> Unit,
nennungViewModel: NennungViewModel,
) {
var selectedTab by remember { mutableIntStateOf(3) } // Bewerbe ist Standard-Tab (⭐)
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Bewerbe ⭐", "Preisliste")
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Turnier #$turnierId",
style = MaterialTheme.typography.headlineSmall,
)
}
PrimaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Box(modifier = Modifier.fillMaxSize()) {
when (selectedTab) {
0 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Übersicht", "Turnier-Stammdaten und Status.")
}
1 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Stammdaten (A-Satz)", "OEPS-Turniernummer, Kategorie, Sparte …")
}
2 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Organisation", "Richter, Parcourschef, Tierarzt …")
}
3 -> {
// Nennungs-Workflow: NennungsMaske aus nennung-feature
NennungsMaske(
viewModel = nennungViewModel,
onStartlisteOeffnen = { /* TODO: Navigation zu Startliste */ },
onErgebnisseOeffnen = { /* TODO: Navigation zu Ergebnisse */ },
onAbrechnungOeffnen = { /* TODO: Navigation zu Abrechnung */ },
)
}
4 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
}
}
}
}
}
@@ -0,0 +1,64 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Formular zum Anlegen eines neuen Turniers (Vision_03: /veranstaltung/{id}/turnier/neu).
* Tabs: Übersicht | Stammdaten (A-Satz) | Organisation | Bewerbe | Preisliste
* TODO: Echte Formular-Felder und Persistenz (Phase 4/5).
*/
@Composable
fun TurnierNeuScreen(
veranstaltungId: Long,
onBack: () -> Unit,
onSave: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(3) } // Bewerbe ist Standard-Tab (⭐)
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Bewerbe ⭐", "Preisliste")
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Neues Turnier (Veranstaltung #$veranstaltungId)",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.alignByBaseline(),
)
}
Button(onClick = onSave) { Text("Speichern") }
}
PrimaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Box(modifier = Modifier.fillMaxSize().padding(24.dp)) {
when (selectedTab) {
0 -> PlaceholderContent("Übersicht", "Wird nach dem Speichern befüllt.")
1 -> PlaceholderContent("Stammdaten (A-Satz)", "OEPS-Turniernummer, Kategorie, Sparte …")
2 -> PlaceholderContent("Organisation", "Richter, Parcourschef, Tierarzt …")
3 -> PlaceholderContent("Bewerbe", "Bewerbe anlegen und Abteilungen konfigurieren …")
4 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
}
}
}
}
@@ -0,0 +1,66 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Detailansicht einer bestehenden Veranstaltung (Vision_03: /veranstaltung/{id}).
* Zeigt Übersicht-Tab mit Turniere-Section.
* TODO: Echte Daten laden (Phase 4/5).
*/
@Composable
fun VeranstaltungDetailScreen(
veranstaltungId: Long,
onBack: () -> Unit,
onTurnierNeu: () -> Unit,
onTurnierOeffnen: (Long) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Veranstaltung #$veranstaltungId",
style = MaterialTheme.typography.headlineSmall,
)
}
PrimaryTabRow(selectedTabIndex = 0) {
Tab(selected = true, onClick = {}, text = { Text("Veranstaltung Übersicht") })
}
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
// Turniere-Section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Turniere", style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = onTurnierNeu) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neues Turnier")
}
}
Spacer(Modifier.height(16.dp))
PlaceholderContent(
title = "Noch keine Turniere",
subtitle = "Lege ein neues Turnier für diese Veranstaltung an.",
)
}
}
}
@@ -0,0 +1,63 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Formular zum Anlegen einer neuen Veranstaltung (Vision_03: /veranstaltung/neu).
* Tabs: Veranstaltung-Übersicht | Stammdaten (A-Satz) | Organisation | Preisliste
* TODO: Echte Formular-Felder und Persistenz (Phase 4/5).
*/
@Composable
fun VeranstaltungNeuScreen(
onBack: () -> Unit,
onSave: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(1) } // Stammdaten ist Standard-Tab
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Preisliste")
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Neue Veranstaltung",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.alignByBaseline(),
)
}
Button(onClick = onSave) { Text("Speichern") }
}
PrimaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Box(modifier = Modifier.fillMaxSize().padding(24.dp)) {
when (selectedTab) {
0 -> PlaceholderContent("Veranstaltung Übersicht", "Wird nach dem Speichern befüllt.")
1 -> PlaceholderContent("Stammdaten (A-Satz)", "Felder: Bezeichnung, Datum, Ort, Veranstalter …")
2 -> PlaceholderContent("Organisation", "Felder: Richter, Parcourschef, Tierarzt …")
3 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
}
}
}
}
@@ -0,0 +1,50 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Veranstaltungs-Übersicht (Drawer-Einstieg gemäß Vision_03).
* Zeigt Liste aller Veranstaltungen + Button "Neue Veranstaltung".
* TODO: Echte Daten aus dem event-management-context laden (Phase 4/5).
*/
@Composable
fun VeranstaltungenScreen(
onVeranstaltungNeu: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Veranstaltungen",
style = MaterialTheme.typography.headlineMedium,
)
Button(onClick = onVeranstaltungNeu) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neue Veranstaltung")
}
}
Spacer(modifier = Modifier.height(24.dp))
// Platzhalter wird durch echte Daten ersetzt
PlaceholderContent(
title = "Noch keine Veranstaltungen",
subtitle = "Lege eine neue Veranstaltung an, um zu beginnen.",
)
}
}
@@ -155,6 +155,8 @@ fun MainApp() {
}
}
)
else -> {}
}
}
}
+3 -3
View File
@@ -69,9 +69,8 @@ include(":backend:infrastructure:security")
// === BACKEND - SERVICES ===
// --- ENTRIES (Nennungen) ---
include(":backend:services:entries:entries-api")
// entries-service: ON HOLD pausiert bis Domain-Workshop (siehe MASTER_ROADMAP Phase 3)
// Code liegt im Branch: feature/entries-service
// include(":backend:services:entries:entries-service")
include(":backend:services:entries:entries-domain")
include(":backend:services:entries:entries-service")
// --- CLUBS (Vereine) ---
include(":backend:services:clubs:clubs-domain")
@@ -131,6 +130,7 @@ include(":frontend:features:nennung-feature")
// --- SHELLS ---
include(":frontend:shells:meldestelle-portal")
include(":frontend:shells:meldestelle-desktop")
// ==========================================================================
// PLATFORM