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