feat: integrate new desktop shell and extend backend & ADRs

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

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-24 18:22:15 +01:00
parent c624df8744
commit 354bd49de6
75 changed files with 7616 additions and 48 deletions
@@ -0,0 +1,154 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.events.domain.model
import at.mocode.core.domain.model.AusschreibungsStatusE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Aggregate Root für die offizielle Ausschreibung einer Veranstaltung gemäß ÖTO.
*
* Die Ausschreibung ist das offizielle Dokument, das alle relevanten Informationen
* für Nennende enthält (Bewerbe, Gebühren, Nennschluss, Richter, etc.).
* Sie muss vom Verband genehmigt werden, bevor Nennungen möglich sind.
*
* @property ausschreibungsId Eindeutige interne ID (UUID).
* @property veranstaltungId Referenz auf die zugehörige Veranstaltung.
* @property titel Offizieller Titel der Ausschreibung.
* @property sparten Enthaltene Sparten.
* @property nennschluss Nennschluss-Datum (Pflichtfeld gemäß ÖTO).
* @property nachnennung Ob Nachnennungen möglich sind und bis wann.
* @property nachnennungBis Datum bis zu dem Nachnennungen möglich sind (optional).
* @property nenngebuehrBasisCent Basis-Nenngebühr in Cent (zur Vermeidung von Floating-Point-Fehlern).
* @property nachnenngebuehrCent Nachnenngebühr in Cent (Aufschlag auf Basis-Nenngebühr).
* @property sportfoerderbeitragCent Sportförderbeitrag in Cent (ÖTO-Pflichtabgabe).
* @property tierwohleuroAktiv Ob der Tierwohl-Euro erhoben wird.
* @property veranstaltungsortBeschreibung Detaillierte Beschreibung des Veranstaltungsorts.
* @property anfahrtsBeschreibung Anfahrtsbeschreibung (optional).
* @property stallplatzeVerfuegbar Anzahl verfügbarer Stallplätze (optional).
* @property stallplatzgebuehrCent Stallplatzgebühr pro Nacht in Cent (optional).
* @property kontaktEmail Kontakt-E-Mail für Rückfragen.
* @property kontaktTelefon Kontakt-Telefonnummer (optional).
* @property zusatzinformationen Freitext für weitere Informationen.
* @property status Aktueller Status im Genehmigungsworkflow.
* @property eingereichtAm Datum der Einreichung beim Verband (optional).
* @property genehmigungsNummer Offizielle Genehmigungsnummer (nach Genehmigung).
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomAusschreibung(
@Serializable(with = UuidSerializer::class)
val ausschreibungsId: Uuid = Uuid.random(),
// Zugehörigkeit
@Serializable(with = UuidSerializer::class)
var veranstaltungId: Uuid,
// Basis-Informationen
var titel: String,
var sparten: List<SparteE> = emptyList(),
// Termine
var nennschluss: LocalDate,
var nachnennung: Boolean = false,
var nachnennungBis: LocalDate? = null,
// Gebühren (in Cent, Integer zur Vermeidung von Floating-Point-Fehlern)
var nenngebuehrBasisCent: Int = 0,
var nachnenngebuehrCent: Int = 0,
var sportfoerderbeitragCent: Int = 0,
var tierwohleuroAktiv: Boolean = true,
// Veranstaltungsort
var veranstaltungsortBeschreibung: String? = null,
var anfahrtsBeschreibung: String? = null,
// Stallplätze
var stallplatzeVerfuegbar: Int? = null,
var stallplatzgebuehrCent: Int? = null,
// Kontakt
var kontaktEmail: String? = null,
var kontaktTelefon: String? = null,
// Freitext
var zusatzinformationen: String? = null,
// Workflow-Status
var status: AusschreibungsStatusE = AusschreibungsStatusE.ENTWURF,
var eingereichtAm: LocalDate? = null,
var genehmigungsNummer: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Validiert die Pflichtfelder für die Einreichung beim Verband.
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateFuerEinreichung(): List<String> {
val warnings = mutableListOf<String>()
if (titel.isBlank()) {
warnings.add("Ausschreibungstitel ist erforderlich.")
}
if (sparten.isEmpty()) {
warnings.add("Mindestens eine Sparte muss angegeben werden.")
}
if (kontaktEmail.isNullOrBlank()) {
warnings.add("Kontakt-E-Mail ist für die Einreichung erforderlich.")
}
if (nenngebuehrBasisCent < 0) {
warnings.add("Nenngebühr darf nicht negativ sein.")
}
if (nachnenngebuehrCent < 0) {
warnings.add("Nachnenngebühr darf nicht negativ sein.")
}
if (nachnennung && nachnennungBis == null) {
warnings.add("Nachnennungs-Datum muss angegeben werden, wenn Nachnennungen erlaubt sind.")
}
nachnennungBis?.let { nb ->
if (nb <= nennschluss) {
warnings.add("Nachnennungs-Datum muss nach dem Nennschluss liegen.")
}
}
stallplatzgebuehrCent?.let { sg ->
if (sg < 0) {
warnings.add("Stallplatzgebühr darf nicht negativ sein.")
}
}
return warnings
}
/**
* Gibt die Basis-Nenngebühr als formatierten Euro-String zurück (z.B. "12,50 €").
*/
fun getNenngebuehrAlsEuroString(): String {
val euro = nenngebuehrBasisCent / 100
val cent = nenngebuehrBasisCent % 100
return "$euro,${cent.toString().padStart(2, '0')}"
}
/**
* Gibt die Gesamtgebühr (Nenngebühr + Sportförderbeitrag + ggf. Tierwohl-Euro) in Cent zurück.
*/
fun getGesamtgebuehrCent(): Int {
val tierwohl = if (tierwohleuroAktiv) 100 else 0
return nenngebuehrBasisCent + sportfoerderbeitragCent + tierwohl
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomAusschreibung = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,111 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.events.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.TurnierkategorieE
import at.mocode.core.domain.model.TurnierStatusE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Aggregate Root für ein einzelnes Turnier innerhalb einer Veranstaltung gemäß ÖTO § 2 Abs. 1.
*
* Ein Turnier ist die konkrete Durchführungseinheit (z.B. "Springturnier CSN-C Samstag").
* Eine Veranstaltung kann mehrere Turniere enthalten (z.B. Dressur-Turnier + Spring-Turnier).
* Jedes Turnier hat eine eigene Kategorie (CSN-C, CDN, etc.) und Sparte.
*
* @property turnierId Eindeutige interne ID (UUID).
* @property veranstaltungId Referenz auf die übergeordnete Veranstaltung.
* @property name Offizieller Name des Turniers.
* @property sparte Sparte des Turniers (Springen, Dressur, Vielseitigkeit, etc.).
* @property kategorie Turnierkategorie gemäß ÖTO (CSN-C, CDN, CAN, etc.).
* @property datum Datum des Turniers (kann innerhalb der Veranstaltungsdauer liegen).
* @property richterObmannId ID des Richter-Obmanns (Referenz auf officials-context).
* @property parcoursbauerId ID des Parcoursbauers (Referenz auf officials-context, nur Springen).
* @property status Aktueller Status des Turniers.
* @property maxBewerbe Maximale Anzahl an Bewerben (optional, aus Ausschreibung).
* @property istMeisterschaft Ob dieses Turnier Meisterschafts-Charakter hat.
* @property bemerkungen Interne Bemerkungen.
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomTurnier(
@Serializable(with = UuidSerializer::class)
val turnierId: Uuid = Uuid.random(),
// Zugehörigkeit
@Serializable(with = UuidSerializer::class)
var veranstaltungId: Uuid,
// Basis-Informationen
var name: String,
var sparte: SparteE,
var kategorie: TurnierkategorieE,
var datum: LocalDate,
// Funktionäre
@Serializable(with = UuidSerializer::class)
var richterObmannId: Uuid? = null,
@Serializable(with = UuidSerializer::class)
var parcoursbauerId: Uuid? = null,
// Workflow-Status
var status: TurnierStatusE = TurnierStatusE.GEPLANT,
// Konfiguration
var maxBewerbe: Int? = null,
var istMeisterschaft: Boolean = false,
// Administrativ
var bemerkungen: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Prüft ob das Turnier Pflicht-Funktionäre zugewiesen hat.
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateFunktionaerBesetzung(): List<String> {
val warnings = mutableListOf<String>()
if (richterObmannId == null) {
warnings.add("Kein Richter-Obmann zugewiesen. Pflichtfeld für Turnierdurchführung (ÖTO).")
}
if (sparte == SparteE.SPRINGEN && parcoursbauerId == null) {
warnings.add("Kein Parcoursbauer zugewiesen. Pflichtfeld für Springturniere (ÖTO).")
}
return warnings
}
/**
* Validiert die Pflichtfelder für die Turnier-Planung.
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateFuerPlanung(): List<String> {
val warnings = mutableListOf<String>()
if (name.isBlank()) {
warnings.add("Turniername ist erforderlich.")
}
maxBewerbe?.let { max ->
if (max <= 0) {
warnings.add("Maximale Bewerb-Anzahl muss positiv sein.")
}
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomTurnier = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,147 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.events.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.model.VeranstaltungsStatusE
import at.mocode.core.domain.model.VeranstaltungsTypE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Aggregate Root für eine pferdesportliche Veranstaltung gemäß ÖTO § 2 Abs. 1.
*
* Eine Veranstaltung ist der organisatorische Rahmen (z.B. "Frühjahrsturnier Wiener Neustadt").
* Sie kann ein oder mehrere Turniere enthalten. Die Unterscheidung Veranstaltung ≠ Turnier
* ist eine zentrale ADR-Entscheidung (ADR-0003).
*
* @property veranstaltungId Eindeutige interne ID (UUID).
* @property name Offizieller Name der Veranstaltung.
* @property veranstaltungsTyp Typ gemäß ÖTO (z.B. NATIONAL, INTERNATIONAL).
* @property sparten Liste der enthaltenen Sparten (Springen, Dressur, etc.).
* @property veranstalterVereinId ID des veranstaltenden Vereins (Referenz auf clubs-context).
* @property verantwortlicheFunktionaerId ID des verantwortlichen Funktionärs (Referenz auf officials-context).
* @property startDatum Erster Veranstaltungstag.
* @property endDatum Letzter Veranstaltungstag.
* @property ort Veranstaltungsort (Adresse / Reitanlage).
* @property nennschluss Nennschluss-Datum gemäß Ausschreibung.
* @property status Aktueller Status im Planungs-Workflow.
* @property ausschreibungsId Referenz auf die zugehörige Ausschreibung (optional bis Genehmigung).
* @property oepsGenehmigungsNummer Offizielle Genehmigungsnummer des Verbands (nach Genehmigung).
* @property bemerkungen Interne Bemerkungen.
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomVeranstaltung(
@Serializable(with = UuidSerializer::class)
val veranstaltungId: Uuid = Uuid.random(),
// Basis-Informationen
var name: String,
var veranstaltungsTyp: VeranstaltungsTypE,
var sparten: List<SparteE> = emptyList(),
// Organisation
@Serializable(with = UuidSerializer::class)
var veranstalterVereinId: Uuid,
@Serializable(with = UuidSerializer::class)
var verantwortlicheFunktionaerId: Uuid? = null,
// Termine
var startDatum: LocalDate,
var endDatum: LocalDate,
var ort: String,
var nennschluss: LocalDate? = null,
// Workflow-Status
var status: VeranstaltungsStatusE = VeranstaltungsStatusE.IN_PLANUNG,
// Verknüpfungen
@Serializable(with = UuidSerializer::class)
var ausschreibungsId: Uuid? = null,
var oepsGenehmigungsNummer: String? = null,
// Administrativ
var bemerkungen: String? = null,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Gibt die Dauer der Veranstaltung in Tagen zurück.
*/
fun getDauerInTagen(): Int =
(endDatum.toEpochDays() - startDatum.toEpochDays()).toInt() + 1
/**
* Prüft ob die Veranstaltung mehrtägig ist.
*/
fun istMehrtaegig(): Boolean = startDatum != endDatum
/**
* Prüft ob Nennungen aktuell möglich sind (Status GENEHMIGT und Nennschluss nicht abgelaufen).
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateNennungsmoeglichkeit(): List<String> {
val warnings = mutableListOf<String>()
if (status != VeranstaltungsStatusE.GENEHMIGT) {
warnings.add(
"Veranstaltung ist nicht im Status GENEHMIGT (aktuell: $status). " +
"Nennungen sind erst nach Genehmigung möglich."
)
}
if (nennschluss == null) {
warnings.add("Kein Nennschluss definiert. Bitte Ausschreibung vervollständigen.")
}
if (veranstalterVereinId == null) {
warnings.add("Kein Veranstalter-Verein zugewiesen.")
}
return warnings
}
/**
* Validiert die Pflichtfelder für die Einreichung beim Verband.
* Gibt Warnungen zurück keine harten Fehler (Warn-Logik statt Exception, ADR-0007).
*/
fun validateFuerEinreichung(): List<String> {
val warnings = mutableListOf<String>()
if (name.isBlank()) {
warnings.add("Veranstaltungsname ist erforderlich.")
}
if (ort.isBlank()) {
warnings.add("Veranstaltungsort ist erforderlich.")
}
if (endDatum < startDatum) {
warnings.add("Enddatum darf nicht vor dem Startdatum liegen.")
}
if (sparten.isEmpty()) {
warnings.add("Mindestens eine Sparte muss angegeben werden.")
}
if (nennschluss == null) {
warnings.add("Nennschluss ist für die Einreichung erforderlich.")
}
nennschluss?.let { nl ->
if (nl >= startDatum) {
warnings.add("Nennschluss muss vor dem Veranstaltungsbeginn liegen (§ 2 ÖTO).")
}
}
if (ausschreibungsId == null) {
warnings.add("Keine Ausschreibung verknüpft. Einreichung ohne Ausschreibung nicht möglich.")
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomVeranstaltung = this.copy(updatedAt = Clock.System.now())
}