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:
+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';
|
||||
Reference in New Issue
Block a user