diff --git a/META-INF/MANIFEST.MF b/META-INF/MANIFEST.MF new file mode 100644 index 00000000..59499bce --- /dev/null +++ b/META-INF/MANIFEST.MF @@ -0,0 +1,2 @@ +Manifest-Version: 1.0 + diff --git a/META-INF/exposed-core.kotlin_module b/META-INF/exposed-core.kotlin_module new file mode 100644 index 00000000..cfc486a5 Binary files /dev/null and b/META-INF/exposed-core.kotlin_module differ diff --git a/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomVerein.kt b/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomVerein.kt new file mode 100644 index 00000000..32497bd4 --- /dev/null +++ b/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomVerein.kt @@ -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 { + val warnings = mutableListOf() + + 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()) +} diff --git a/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/repository/VereinRepository.kt b/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/repository/VereinRepository.kt new file mode 100644 index 00000000..73b3e95c --- /dev/null +++ b/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/repository/VereinRepository.kt @@ -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 + + /** + * Sucht alle Vereine eines Bundeslandes. + */ + suspend fun findByBundesland(bundesland: String, activeOnly: Boolean = true): List + + /** + * Sucht alle Vereine, die als Veranstalter markiert sind. + */ + suspend fun findVeranstalter(activeOnly: Boolean = true): List + + /** + * Gibt alle aktiven Vereine zurück (paginiert). + */ + suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List + + /** + * Gibt alle Vereine zurück (paginiert). + */ + suspend fun findAll(limit: Int = 100, offset: Int = 0): List + + /** + * 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 +} diff --git a/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ExposedVereinRepository.kt b/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ExposedVereinRepository.kt new file mode 100644 index 00000000..d723c78d --- /dev/null +++ b/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ExposedVereinRepository.kt @@ -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 = transaction { + VereinTable.selectAll().where { VereinTable.name like "%$searchTerm%" } + .limit(limit).map { rowToVerein(it) } + } + + override suspend fun findByBundesland(bundesland: String, activeOnly: Boolean): List = 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 = 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 = 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 = 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 + } +} diff --git a/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/VereinTable.kt b/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/VereinTable.kt new file mode 100644 index 00000000..9c6d64dc --- /dev/null +++ b/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/VereinTable.kt @@ -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) + } +} diff --git a/backend/services/clubs/clubs-service/src/main/resources/db/migration/V001__Create_Vereine_Table.sql b/backend/services/clubs/clubs-service/src/main/resources/db/migration/V001__Create_Vereine_Table.sql new file mode 100644 index 00000000..8be02b8f --- /dev/null +++ b/backend/services/clubs/clubs-service/src/main/resources/db/migration/V001__Create_Vereine_Table.sql @@ -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'; diff --git a/backend/services/entries/entries-api/build.gradle.kts b/backend/services/entries/entries-api/build.gradle.kts index b9f036d0..d87ec6f2 100644 --- a/backend/services/entries/entries-api/build.gradle.kts +++ b/backend/services/entries/entries-api/build.gradle.kts @@ -32,6 +32,7 @@ kotlin { commonMain { dependencies { implementation(libs.kotlinx.serialization.json) + implementation(projects.core.coreDomain) } } commonTest { diff --git a/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/NennungDtos.kt b/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/NennungDtos.kt new file mode 100644 index 00000000..4f6f2d17 --- /dev/null +++ b/backend/services/entries/entries-api/src/commonMain/kotlin/at/mocode/entries/api/NennungDtos.kt @@ -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 +) diff --git a/backend/services/entries/entries-domain/build.gradle.kts b/backend/services/entries/entries-domain/build.gradle.kts new file mode 100644 index 00000000..e6adb015 --- /dev/null +++ b/backend/services/entries/entries-domain/build.gradle.kts @@ -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) + } + } + } +} diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomAbteilung.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomAbteilung.kt new file mode 100644 index 00000000..e26c62a2 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomAbteilung.kt @@ -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 { + val warnings = mutableListOf() + + // 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()) +} diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomBewerb.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomBewerb.kt new file mode 100644 index 00000000..71fba7af --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomBewerb.kt @@ -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 { + val warnings = mutableListOf() + + 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()) +} diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennung.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennung.kt new file mode 100644 index 00000000..c4e97ae1 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennung.kt @@ -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()) +} diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennungsTransfer.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennungsTransfer.kt new file mode 100644 index 00000000..b8afa669 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennungsTransfer.kt @@ -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() +} diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomStartliste.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomStartliste.kt new file mode 100644 index 00000000..33b4347d --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomStartliste.kt @@ -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 = 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 { + val warnings = mutableListOf() + 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 { + val warnings = mutableListOf() + 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 +) diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/NennungRepository.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/NennungRepository.kt new file mode 100644 index 00000000..f71178e0 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/NennungRepository.kt @@ -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 + + /** + * Sucht alle Nennungen für eine bestimmte Abteilung. + */ + suspend fun findByAbteilungId(abteilungId: Uuid): List + + /** + * Sucht alle Nennungen für ein bestimmtes Turnier. + */ + suspend fun findByTurnierId(turnierId: Uuid): List + + /** + * Sucht alle Nennungen eines bestimmten Reiters. + */ + suspend fun findByReiterId(reiterId: Uuid): List + + /** + * Sucht alle Nennungen für ein bestimmtes Pferd. + */ + suspend fun findByPferdId(pferdId: Uuid): List + + /** + * Sucht alle Nennungen eines Reiters für ein bestimmtes Turnier. + */ + suspend fun findByReiterIdAndTurnierId(reiterId: Uuid, turnierId: Uuid): List + + /** + * Sucht alle Nennungen mit einem bestimmten Status. + */ + suspend fun findByStatus(status: NennungsStatusE): List + + /** + * Sucht alle Nachnennungen für einen Bewerb. + */ + suspend fun findNachnennungenByBewerbId(bewerbId: Uuid): List + + /** + * 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 +} diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/NennungsTransferRepository.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/NennungsTransferRepository.kt new file mode 100644 index 00000000..f09f9fe1 --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/repository/NennungsTransferRepository.kt @@ -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 + suspend fun save(transfer: DomNennungsTransfer): DomNennungsTransfer +} diff --git a/backend/services/entries/entries-service/build.gradle.kts b/backend/services/entries/entries-service/build.gradle.kts index af6a5362..93f31bf4 100644 --- a/backend/services/entries/entries-service/build.gradle.kts +++ b/backend/services/entries/entries-service/build.gradle.kts @@ -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) diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/NennungController.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/NennungController.kt new file mode 100644 index 00000000..8a5f1ef6 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/NennungController.kt @@ -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 = 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) +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesBeansConfiguration.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesBeansConfiguration.kt new file mode 100644 index 00000000..f9da0fba --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesBeansConfiguration.kt @@ -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() +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesDatabaseConfiguration.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesDatabaseConfiguration.kt new file mode 100644 index 00000000..5494c83e --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesDatabaseConfiguration.kt @@ -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 + } + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesExceptionHandler.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesExceptionHandler.kt new file mode 100644 index 00000000..6f0b41eb --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesExceptionHandler.kt @@ -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") + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungRepositoryImpl.kt new file mode 100644 index 00000000..62f2e304 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungRepositoryImpl.kt @@ -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 = transaction { + NennungTable.selectAll().where { NennungTable.bewerbId eq bewerbId.toJavaUuid() } + .map(::rowToNennung) + } + + override suspend fun findByAbteilungId(abteilungId: Uuid): List = transaction { + NennungTable.selectAll().where { NennungTable.abteilungId eq abteilungId.toJavaUuid() } + .map(::rowToNennung) + } + + override suspend fun findByTurnierId(turnierId: Uuid): List = transaction { + NennungTable.selectAll().where { NennungTable.turnierId eq turnierId.toJavaUuid() } + .map(::rowToNennung) + } + + override suspend fun findByReiterId(reiterId: Uuid): List = transaction { + NennungTable.selectAll().where { NennungTable.reiterId eq reiterId.toJavaUuid() } + .map(::rowToNennung) + } + + override suspend fun findByPferdId(pferdId: Uuid): List = transaction { + NennungTable.selectAll().where { NennungTable.pferdId eq pferdId.toJavaUuid() } + .map(::rowToNennung) + } + + override suspend fun findByReiterIdAndTurnierId(reiterId: Uuid, turnierId: Uuid): List = transaction { + NennungTable.selectAll().where { + (NennungTable.reiterId eq reiterId.toJavaUuid()) and + (NennungTable.turnierId eq turnierId.toJavaUuid()) + }.map(::rowToNennung) + } + + override suspend fun findByStatus(status: NennungsStatusE): List = transaction { + NennungTable.selectAll().where { NennungTable.status eq status.name } + .map(::rowToNennung) + } + + override suspend fun findNachnennungenByBewerbId(bewerbId: Uuid): List = 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() + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungTable.kt new file mode 100644 index 00000000..1f4879b0 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungTable.kt @@ -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) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferRepositoryImpl.kt new file mode 100644 index 00000000..d7203af0 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferRepositoryImpl.kt @@ -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 = 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) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferTable.kt new file mode 100644 index 00000000..1b2ebd45 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/NennungsTransferTable.kt @@ -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) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/usecase/NennungUseCases.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/usecase/NennungUseCases.kt new file mode 100644 index 00000000..ea945c7c --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/usecase/NennungUseCases.kt @@ -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 = + nennungRepository.findByTurnierId(turnierId).map { it.toSummaryDto() } + + suspend fun getNennungenByBewerb(bewerbId: Uuid): List = + nennungRepository.findByBewerbId(bewerbId).map { it.toSummaryDto() } + + suspend fun getNennungenByAbteilung(abteilungId: Uuid): List = + nennungRepository.findByAbteilungId(abteilungId).map { it.toSummaryDto() } + + suspend fun getNennungenByReiter(reiterId: Uuid): List = + 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() + ) +} diff --git a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomAusschreibung.kt b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomAusschreibung.kt new file mode 100644 index 00000000..7cc37ecf --- /dev/null +++ b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomAusschreibung.kt @@ -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 = 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 { + val warnings = mutableListOf() + 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()) +} diff --git a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomTurnier.kt b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomTurnier.kt new file mode 100644 index 00000000..864d7c43 --- /dev/null +++ b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomTurnier.kt @@ -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 { + val warnings = mutableListOf() + 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 { + val warnings = mutableListOf() + 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()) +} diff --git a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomVeranstaltung.kt b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomVeranstaltung.kt new file mode 100644 index 00000000..cdae1f23 --- /dev/null +++ b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomVeranstaltung.kt @@ -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 = 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 { + val warnings = mutableListOf() + 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 { + val warnings = mutableListOf() + 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()) +} diff --git a/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomFunktionaer.kt b/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomFunktionaer.kt new file mode 100644 index 00000000..e5548224 --- /dev/null +++ b/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomFunktionaer.kt @@ -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, G1–G3, 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 = emptySet(), + var richterQualifikation: RichterQualifikationE? = null, + var qualifiziertFuerSparten: Set = 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 { + val warnings = mutableListOf() + + 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()) +} diff --git a/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/repository/FunktionaerRepository.kt b/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/repository/FunktionaerRepository.kt new file mode 100644 index 00000000..7fe82228 --- /dev/null +++ b/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/repository/FunktionaerRepository.kt @@ -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 + + /** + * Sucht alle Funktionäre mit einer bestimmten Rolle. + */ + suspend fun findByRolle(rolle: FunktionaerRolleE, activeOnly: Boolean = true): List + + /** + * Sucht alle Richter mit einer bestimmten Qualifikation. + */ + suspend fun findByRichterQualifikation( + qualifikation: RichterQualifikationE, + activeOnly: Boolean = true + ): List + + /** + * Sucht alle Funktionäre, die für eine bestimmte Sparte qualifiziert sind. + */ + suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean = true): List + + /** + * Sucht alle Funktionäre eines bestimmten Vereins. + */ + suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean = true): List + + /** + * Gibt alle aktiven Funktionäre zurück (paginiert). + */ + suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List + + /** + * Gibt alle Funktionäre zurück (paginiert). + */ + suspend fun findAll(limit: Int = 100, offset: Int = 0): List + + /** + * 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 +} diff --git a/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ExposedFunktionaerRepository.kt b/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ExposedFunktionaerRepository.kt new file mode 100644 index 00000000..cffcb757 --- /dev/null +++ b/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ExposedFunktionaerRepository.kt @@ -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 = 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 = 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 = 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 = 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 = + 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 = 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 = 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>(row[FunktionaerTable.rollen]).toSet() + } catch (_: Exception) { + emptySet() + } + + val sparten = try { + Json.decodeFromString>(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 + } +} diff --git a/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/FunktionaerTable.kt b/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/FunktionaerTable.kt new file mode 100644 index 00000000..caa7e05d --- /dev/null +++ b/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/FunktionaerTable.kt @@ -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) + } +} diff --git a/backend/services/officials/officials-service/src/main/resources/db/migration/V001__Create_Funktionaere_Table.sql b/backend/services/officials/officials-service/src/main/resources/db/migration/V001__Create_Funktionaere_Table.sql new file mode 100644 index 00000000..dc99dbd3 --- /dev/null +++ b/backend/services/officials/officials-service/src/main/resources/db/migration/V001__Create_Funktionaere_Table.sql @@ -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'; diff --git a/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomReiter.kt b/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomReiter.kt new file mode 100644 index 00000000..b83a645d --- /dev/null +++ b/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomReiter.kt @@ -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 = 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 { + val warnings = mutableListOf() + + 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()) +} diff --git a/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/repository/ReiterRepository.kt b/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/repository/ReiterRepository.kt new file mode 100644 index 00000000..46a12ff6 --- /dev/null +++ b/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/repository/ReiterRepository.kt @@ -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 + + /** + * Sucht alle Reiter eines bestimmten Vereins. + */ + suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean = true): List + + /** + * Sucht alle Reiter mit einer bestimmten Lizenzklasse. + */ + suspend fun findByLizenzKlasse(lizenzKlasse: LizenzKlasseE, activeOnly: Boolean = true): List + + /** + * Sucht alle Reiter, die für eine bestimmte Sparte lizenziert sind. + */ + suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean = true): List + + /** + * Sucht alle Gastreiter. + */ + suspend fun findGastreiter(activeOnly: Boolean = true): List + + /** + * Gibt alle aktiven Reiter zurück (paginiert). + */ + suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List + + /** + * Gibt alle Reiter zurück (paginiert). + */ + suspend fun findAll(limit: Int = 100, offset: Int = 0): List + + /** + * 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 +} diff --git a/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ExposedReiterRepository.kt b/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ExposedReiterRepository.kt new file mode 100644 index 00000000..9b099888 --- /dev/null +++ b/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ExposedReiterRepository.kt @@ -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 = 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 = 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 = + 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 = 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 = 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 = 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 = 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>(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 + } +} diff --git a/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ReiterTable.kt b/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ReiterTable.kt new file mode 100644 index 00000000..61f3fac2 --- /dev/null +++ b/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ReiterTable.kt @@ -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) + } +} diff --git a/backend/services/persons/persons-service/src/main/resources/db/migration/V001__Create_Reiter_Table.sql b/backend/services/persons/persons-service/src/main/resources/db/migration/V001__Create_Reiter_Table.sql new file mode 100644 index 00000000..dc9b3a53 --- /dev/null +++ b/backend/services/persons/persons-service/src/main/resources/db/migration/V001__Create_Reiter_Table.sql @@ -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'; diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt index 5267bbae..f0306a1d 100644 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt @@ -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 95–110 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 +} diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index 73efa0d3..fca15840 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -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/` | diff --git a/docs/01_Architecture/adr/0014-bounded-context-mapping-de.md b/docs/01_Architecture/adr/0014-bounded-context-mapping-de.md new file mode 100644 index 00000000..b04b3c09 --- /dev/null +++ b/docs/01_Architecture/adr/0014-bounded-context-mapping-de.md @@ -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 diff --git a/docs/01_Architecture/adr/0015-context-map-de.md b/docs/01_Architecture/adr/0015-context-map-de.md new file mode 100644 index 00000000..9b29cf3b --- /dev/null +++ b/docs/01_Architecture/adr/0015-context-map-de.md @@ -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) diff --git a/docs/01_Architecture/adr/0016-api-design-acl-de.md b/docs/01_Architecture/adr/0016-api-design-acl-de.md new file mode 100644 index 00000000..4d280e42 --- /dev/null +++ b/docs/01_Architecture/adr/0016-api-design-acl-de.md @@ -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` | +| GET | `/pferde/{lebensnummer}` | Pferd per Lebensnummer laden | `PferdDto` | +| GET | `/pferde/search?name=&reiter=` | Pferde suchen (für Nennungs-Maske) | `List` | +| 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 +) + +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` | +| 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` | +| 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 +) + +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 // ["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` | +| 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 // 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() + 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 +} + +// 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) diff --git a/docs/01_Architecture/adr/README.md b/docs/01_Architecture/adr/README.md index f6ad6ff6..cd6e17ad 100644 --- a/docs/01_Architecture/adr/README.md +++ b/docs/01_Architecture/adr/README.md @@ -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. diff --git a/docs/03_Domain/01_Glossary/Ubiquitous_Language.md b/docs/03_Domain/01_Glossary/Ubiquitous_Language.md index 6157b4e4..38454742 100644 --- a/docs/03_Domain/01_Glossary/Ubiquitous_Language.md +++ b/docs/03_Domain/01_Glossary/Ubiquitous_Language.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 diff --git a/docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md b/docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md new file mode 100644 index 00000000..0ecefe49 --- /dev/null +++ b/docs/03_Domain/02_Reference/OETO_Regelwerk/Abteilungs-Trennungs-Schwellenwerte.md @@ -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 / 5–6-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 / 5–6-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 / 5–6) | +| CSN | Stil-/Idealzeit bis 95 cm | strukturell | MUSS | Lizenz (ohne / R1) | +| CSN | Springpferdeprüfung 95–110 cm | strukturell | MUSS | Pferdealter (4 / 5–6) | +| 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* diff --git a/docs/03_Domain/02_Reference/OETO_Regelwerk/Warn-Logik-Spezifikation-competition-context.md b/docs/03_Domain/02_Reference/OETO_Regelwerk/Warn-Logik-Spezifikation-competition-context.md new file mode 100644 index 00000000..d19d1b9d --- /dev/null +++ b/docs/03_Domain/02_Reference/OETO_Regelwerk/Warn-Logik-Spezifikation-competition-context.md @@ -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` | +| `WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG` | Strukturelle Pflicht | Abteilungs-Struktur vorhanden, aber Teilnehmerkreis falsch/unvollständig | `DomBewerb` + `List` | + +--- + +## 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`) + +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, 4–6-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 95–110 cm / Dressurpferdeprüfung Klasse A + +**Regel:** 4-jährige in eigener Abteilung, getrennt von 5–6-jährigen. + +| Prüfung | Bedingung für `WARN_STRUKTURELLE_TEILUNG_FEHLT` | +|-------------------------------|-------------------------------------------------------------------------| +| Abt. „4-jährige" vorhanden? | Keine Abteilung mit `teilnehmerkreisBeschreibung` ~ „4-jährig" | +| Abt. „5–6-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 / 5–6-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 + +// In DomBewerb – NEU zu implementieren: +fun validateStrukturellesTeilung(abteilungen: List): List + +// In DomAbteilung – bereits vorhanden, vollständig implementieren: +fun validateStarterLimit(): List +``` + +### 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)* diff --git a/docs/99_Journal/2026-03-24_Session_Log_ADR_Context_Map.md b/docs/99_Journal/2026-03-24_Session_Log_ADR_Context_Map.md new file mode 100644 index 00000000..0849f188 --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_ADR_Context_Map.md @@ -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 diff --git a/docs/99_Journal/2026-03-24_Session_Log_API_Design_ACL.md b/docs/99_Journal/2026-03-24_Session_Log_API_Design_ACL.md new file mode 100644 index 00000000..f40b8c99 --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_API_Design_ACL.md @@ -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) diff --git a/docs/99_Journal/2026-03-24_Session_Log_Actor_Context_Domain_Models.md b/docs/99_Journal/2026-03-24_Session_Log_Actor_Context_Domain_Models.md new file mode 100644 index 00000000..fbb4e15f --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_Actor_Context_Domain_Models.md @@ -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` | 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). diff --git a/docs/99_Journal/2026-03-24_Session_Log_DDD_Ubiquitous_Language.md b/docs/99_Journal/2026-03-24_Session_Log_DDD_Ubiquitous_Language.md index 826a1675..5e04058c 100644 --- a/docs/99_Journal/2026-03-24_Session_Log_DDD_Ubiquitous_Language.md +++ b/docs/99_Journal/2026-03-24_Session_Log_DDD_Ubiquitous_Language.md @@ -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` --- diff --git a/docs/99_Journal/2026-03-24_Session_Log_Desktop_Shell.md b/docs/99_Journal/2026-03-24_Session_Log_Desktop_Shell.md new file mode 100644 index 00000000..c9ba6801 --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_Desktop_Shell.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)* diff --git a/docs/99_Journal/2026-03-24_Session_Log_Event_Management_Context_Domain_Models.md b/docs/99_Journal/2026-03-24_Session_Log_Event_Management_Context_Domain_Models.md new file mode 100644 index 00000000..c9a5f6ba --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_Event_Management_Context_Domain_Models.md @@ -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` 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 | diff --git a/docs/99_Journal/2026-03-24_Session_Log_Nennung_REST_API.md b/docs/99_Journal/2026-03-24_Session_Log_Nennung_REST_API.md new file mode 100644 index 00000000..22f181eb --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_Nennung_REST_API.md @@ -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. diff --git a/docs/99_Journal/2026-03-24_Session_Log_Persistenz_Repository_Migrationen.md b/docs/99_Journal/2026-03-24_Session_Log_Persistenz_Repository_Migrationen.md new file mode 100644 index 00000000..545b89de --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_Persistenz_Repository_Migrationen.md @@ -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. diff --git a/docs/99_Journal/2026-03-24_Session_Log_Registration_Context_Domain_Models.md b/docs/99_Journal/2026-03-24_Session_Log_Registration_Context_Domain_Models.md new file mode 100644 index 00000000..ac1b94ba --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_Registration_Context_Domain_Models.md @@ -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). diff --git a/docs/99_Journal/2026-03-24_Session_Log_Warn-Logik-Spezifikation.md b/docs/99_Journal/2026-03-24_Session_Log_Warn-Logik-Spezifikation.md new file mode 100644 index 00000000..bcb12ca9 --- /dev/null +++ b/docs/99_Journal/2026-03-24_Session_Log_Warn-Logik-Spezifikation.md @@ -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 95–110 cm / Dressurpferdeprüfung Kl. A: Pferdealter 4 vs. 5–6 (§ 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)* diff --git a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt index 4ebe733b..7ca92e76 100644 --- a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt +++ b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt @@ -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 } } diff --git a/frontend/shells/meldestelle-desktop/build.gradle.kts b/frontend/shells/meldestelle-desktop/build.gradle.kts new file mode 100644 index 00000000..911329e4 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/build.gradle.kts @@ -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" + } + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt new file mode 100644 index 00000000..80a17d63 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt @@ -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() + val authTokenManager = koinInject() + 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)) + }, + ) + } + } + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/di/DesktopModule.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/di/DesktopModule.kt new file mode 100644 index 00000000..be653009 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/di/DesktopModule.kt @@ -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 { get() } + single { DesktopCurrentUserProvider(get()) } + single { DeepLinkHandler(get(), get()) } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt new file mode 100644 index 00000000..c80b1f6a --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt @@ -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() + val db = runBlocking { provider.createDatabase() } + loadKoinModules(module { single { 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() + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt new file mode 100644 index 00000000..8c510462 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt @@ -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.Login()) + val currentScreen: StateFlow = _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 + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/AktorScreens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/AktorScreens.kt new file mode 100644 index 00000000..84a0ef80 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/AktorScreens.kt @@ -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+).", + ) + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/DesktopMainLayout.kt new file mode 100644 index 00000000..736d5a9d --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/DesktopMainLayout.kt @@ -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)) }, + ) + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/PlaceholderContent.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/PlaceholderContent.kt new file mode 100644 index 00000000..bc5b1ced --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/PlaceholderContent.kt @@ -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, + ) + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/TurnierDetailScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/TurnierDetailScreen.kt new file mode 100644 index 00000000..24f4f4e6 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/TurnierDetailScreen.kt @@ -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 …") + } + } + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/TurnierNeuScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/TurnierNeuScreen.kt new file mode 100644 index 00000000..65157f86 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/TurnierNeuScreen.kt @@ -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 …") + } + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungDetailScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungDetailScreen.kt new file mode 100644 index 00000000..a23e29c1 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungDetailScreen.kt @@ -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.", + ) + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungNeuScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungNeuScreen.kt new file mode 100644 index 00000000..05792375 --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungNeuScreen.kt @@ -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 …") + } + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungenScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungenScreen.kt new file mode 100644 index 00000000..c6c47d1d --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/VeranstaltungenScreen.kt @@ -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.", + ) + } +} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt index 3957f343..bb36da86 100644 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt +++ b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt @@ -155,6 +155,8 @@ fun MainApp() { } } ) + + else -> {} } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 824077db..5e532fe5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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