feat(domain+frontend): implement structured division warnings and enhance validation rules

- **Domain Updates:**
  - Introduced `AbteilungsWarnung` entity for structured warning handling compliant with ÖTO § 39.
  - Added validation rules for mandatory and optional division thresholds and structural completeness.
  - Implemented `CompetitionWarningService` and `AbteilungsRegelService` for domain-centric validations.
  - Updated domain models (`Bewerb`, `Abteilung`) to reflect structured warning logic.

- **Services:**
  - Expanded `BewerbService` to include warning validation through `CompetitionWarningService`.

- **Frontend Enhancements:**
  - Updated `TurnierBewerbeTab` to display warnings using tooltips with clear descriptions and structured formatting.
  - Modified `BewerbUiModel` to handle warnings and integrate them into the UI.

- **Persistence:**
  - Implemented `CompetitionRepositoryImpl` to map database rows to the new domain models and validation logic.

- **Testing:**
  - Added comprehensive unit tests for `validateStrukturellesTeilung` and division-specific warnings.
  - Enhanced existing tests to validate the new warning structure and code-based assertions.

- **Docs:**
  - Updated roadmap to reflect the completion of structural warnings implementation.
This commit is contained in:
Stefan Mogeritsch 2026-04-10 11:37:30 +02:00
parent 22c631ec43
commit e7d7e43ccf
16 changed files with 521 additions and 42 deletions

View File

@ -105,6 +105,23 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
---
## [1.0.6-SNAPSHOT] — 2026-04-10
### Hinzugefügt
- **Entries-Domain:** Strukturiertes Abteilungs-Warnungssystem gemäß ÖTO § 39 implementiert.
- Neues Value Object `AbteilungsWarnung` und Enum `AbteilungsWarnungCodeE` für präzise Fehlermeldungen und ÖTO-Referenzen.
- Erweiterung von `Bewerb` um die Methode `validateStrukturellesTeilung` zur Prüfung vorgeschriebener Abteilungsstrukturen (z.B. Lizenz-Trennung bei CSN-C-NEU, Stilspringen, Caprilli).
- Umstellung des `CompetitionWarningService` und `AbteilungsRegelService` auf das neue strukturierte Warnungsmodell.
- **Entries-Service:** Erweiterung der REST-API (`BewerbeController`) um die Auslieferung von Warnungen in den DTOs (`BewerbResponse`).
- **Frontend (Turnier-Feature):** Visuelle Integration der Abteilungs-Warnungen in der Bewerbe-Liste.
- Anzeige eines Warn-Icons (gelb) bei Regelverstößen.
- Tooltip-Funktionalität zur Anzeige der detaillierten Warnungstexte und ÖTO-Paragraphen.
- Erweiterung des `BewerbUiModel` und Repositories zur Unterstützung der Warnungs-Metadaten.
### Geändert
- **QA:** `AbteilungsRegelServiceTest` und `BewerbTest` auf das neue Warnungssystem aktualisiert und um Tests für strukturelle Teilungen (CSN Stilspringen, Caprilli) erweitert.
- **KMP:** Korrektur von veralteten `Instant`-Deprecations in Testklassen (`kotlin.time.Instant`).
## [1.0.5-SNAPSHOT] — 2026-04-06
### Geändert

View File

@ -81,22 +81,31 @@ data class Abteilung(
* 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>()
fun validateStarterLimit(): List<AbteilungsWarnung> {
val warnings = mutableListOf<AbteilungsWarnung>()
// 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)."
AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_ABTEILUNG_ZU_GROSS,
bewerbId = bewerbId,
abteilungId = abteilungId,
nachricht = "WARN_ABTEILUNG_ZU_GROSS: ${getDisplayName()}, Starter: $starterAnzahl > 80. Erneute Teilung verpflichtend.",
oetoParagraph = "§ 39 Abs. 2"
)
)
}
if (maxStarter > 0 && starterAnzahl > maxStarter) {
warnings.add(
"WARN_ABTEILUNG_MAX_STARTER_UEBERSCHRITTEN: ${getDisplayName()}, " +
"Starter: $starterAnzahl > Limit $maxStarter. Override möglich (TBA-Entscheidung)."
AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_ABTEILUNG_MAX_UEBERSCHRITTEN,
bewerbId = bewerbId,
abteilungId = abteilungId,
nachricht = "WARN_ABTEILUNG_MAX_UEBERSCHRITTEN: ${getDisplayName()}, Starter: $starterAnzahl > Limit $maxStarter.",
oetoParagraph = "Hausregel / Ausschreibung"
)
)
}

View File

@ -0,0 +1,67 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.domain.model
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Value Object für eine Abteilungs-Warnung (ÖTO § 39).
*
* Eine Warnung wird ausgegeben, wenn Schwellenwerte überschritten werden oder
* strukturelle Teilungen fehlen. Alle Warnungen sind overridebar (ADR-0007).
*/
@Serializable
data class AbteilungsWarnung(
val code: AbteilungsWarnungCodeE,
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid,
@Serializable(with = UuidSerializer::class)
val abteilungId: Uuid? = null,
val nachricht: String,
val oetoParagraph: String,
val istOverridebar: Boolean = true,
@Serializable(with = InstantSerializer::class)
val timestamp: Instant = Clock.System.now()
)
/**
* Maschinenlesbare Codes für Abteilungs-Warnungen.
*/
enum class AbteilungsWarnungCodeE {
/** Starterzahl > Pflicht-Schwellenwert (§ 39 Abs. 2) */
WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN,
/** Starterzahl > Kann-Schwellenwert, keine Teilung konfiguriert (§ 39 Abs. 2) */
WARN_KANN_TEILUNG_EMPFOHLEN,
/** Abteilung nach Teilung > 80 Starter (§ 39 Abs. 2) */
WARN_ABTEILUNG_ZU_GROSS,
/** Starter > konfiguriertes maxStarter-Limit */
WARN_ABTEILUNG_MAX_UEBERSCHRITTEN,
/** Vorgeschriebene Abteilungs-Struktur nicht vorhanden */
WARN_STRUKTURELLE_TEILUNG_FEHLT,
/** Abteilungs-Struktur vorhanden, aber Teilnehmerkreis falsch/unvollständig */
WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG
}
/**
* Event, das gespeichert wird, wenn ein TBA eine Warnung überschreibt.
*/
@Serializable
data class AbteilungsWarnungOverrideEvent(
@Serializable(with = UuidSerializer::class)
val overrideId: Uuid = Uuid.random(),
val warnungCode: AbteilungsWarnungCodeE,
@Serializable(with = UuidSerializer::class)
val bewerbId: Uuid,
@Serializable(with = UuidSerializer::class)
val abteilungId: Uuid? = null,
val begruendung: String,
@Serializable(with = UuidSerializer::class)
val tbaUserId: Uuid,
@Serializable(with = InstantSerializer::class)
val timestamp: Instant = Clock.System.now()
)

View File

@ -159,15 +159,18 @@ data class Bewerb(
*
* @param aktuelleStarterAnzahl Aktuelle Anzahl der Nennungen für diesen Bewerb.
*/
fun validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl: Int): List<String> {
val warnings = mutableListOf<String>()
fun validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl: Int): List<AbteilungsWarnung> {
val warnings = mutableListOf<AbteilungsWarnung>()
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)."
AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN,
bewerbId = bewerbId,
nachricht = "WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN: Bewerb ${getDisplayName()}, Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > Schwellenwert $pflichtSchwellenwert. Empfehlung: Teilung nach ${teilungsTyp.name}.",
oetoParagraph = "§ 39 Abs. 2"
)
)
}
@ -176,15 +179,119 @@ data class Bewerb(
teilungsTyp == AbteilungsTeilungsTypE.KEINE
) {
warnings.add(
"WARN_ABTEILUNG_KANN_TEILUNG_EMPFOHLEN: Bewerb ${getDisplayName()}, " +
"Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > $kannSchwellenwert. " +
"Kann-Teilung empfohlen (§ 39 Abs. 2)."
AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_KANN_TEILUNG_EMPFOHLEN,
bewerbId = bewerbId,
nachricht = "WARN_KANN_TEILUNG_EMPFOHLEN: Bewerb ${getDisplayName()}, Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > $kannSchwellenwert. Kann-Teilung empfohlen.",
oetoParagraph = "§ 39 Abs. 2"
)
)
}
return warnings
}
/**
* Validiert die strukturelle Teilung des Bewerbs gemäß ÖTO.
* Prüft, ob die vorgeschriebenen Abteilungen (z.B. nach Lizenz oder Pferdealter) vorhanden sind.
*/
fun validateStrukturellesTeilung(abteilungen: List<Abteilung>): List<AbteilungsWarnung> {
val warnings = mutableListOf<AbteilungsWarnung>()
// 1. CSN Stilspringen bis 95 cm (§ 200 Abs. 5.3)
if (sparte == SparteE.SPRINGEN && pruefungsTyp == PruefungsTypE.STIL_SPRINGEN && (hoeheCm ?: 0) <= 95) {
val hatOhneLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("ohne Lizenz", ignoreCase = true) == true }
val hatR1 = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
if (!hatOhneLizenz || !hatR1) {
val fehlend = mutableListOf<String>()
if (!hatOhneLizenz) fehlend.add("ohne Lizenz")
if (!hatR1) fehlend.add("R1")
warnings.add(
AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
bewerbId = bewerbId,
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Stilspringen bis 95 cm erfordert getrennte Abteilungen für: ${fehlend.joinToString(", ")}.",
oetoParagraph = "ÖTO B-Teil § 200 Abs. 5.3"
)
)
}
}
// 2. Springpferdeprüfung 95-110 cm / Dressurpferdeprüfung Kl. A (§ 200 Abs. 6 / § 100 Abs. 5)
val isSpringpferdeA = sparte == SparteE.SPRINGEN && pruefungsTyp == PruefungsTypE.SPRINGPFERDE && (hoeheCm ?: 0) in 95..110
val isDressurpferdeA = sparte == SparteE.DRESSUR && pruefungsTyp == PruefungsTypE.DRESSURPFERDE && aufgabe?.contains("A", ignoreCase = true) == true
if (isSpringpferdeA || isDressurpferdeA) {
val hat4Jaehrig = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("4-jährig", ignoreCase = true) == true }
val hat56Jaehrig = abteilungen.any {
it.teilnehmerkreisBeschreibung?.contains("5-jährig", ignoreCase = true) == true ||
it.teilnehmerkreisBeschreibung?.contains("6-jährig", ignoreCase = true) == true ||
it.teilnehmerkreisBeschreibung?.contains("5-6-jährig", ignoreCase = true) == true
}
if (!hat4Jaehrig || !hat56Jaehrig) {
val fehlend = mutableListOf<String>()
if (!hat4Jaehrig) fehlend.add("4-jährige")
if (!hat56Jaehrig) fehlend.add("5-6-jährige")
warnings.add(
AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
bewerbId = bewerbId,
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Pferdeprüfung Kl. A erfordert Trennung nach Alter: ${fehlend.joinToString(", ")}.",
oetoParagraph = if (isSpringpferdeA) "ÖTO B-Teil § 200 Abs. 6" else "ÖTO B-Teil § 100 Abs. 5"
)
)
}
}
// 3. CSN-C-NEU (§ 231)
if (turnierkategorie == TurnierkategorieE.C_NEU && sparte == SparteE.SPRINGEN) {
if ((hoeheCm ?: 0) <= 95) {
val hatOhneLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("ohne Lizenz", ignoreCase = true) == true }
val hatMitLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("mit Lizenz", ignoreCase = true) == true }
if (!hatOhneLizenz || !hatMitLizenz) {
warnings.add(AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
bewerbId = bewerbId,
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: CSN-C-NEU bis 95 cm erfordert Abt. ohne Lizenz und Abt. mit Lizenz.",
oetoParagraph = "ÖTO B-Teil § 231"
))
}
} else if ((hoeheCm ?: 0) >= 100) {
val hatR1 = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
val hatR2Plus = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R2", ignoreCase = true) == true }
if (!hatR1 || !hatR2Plus) {
warnings.add(AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
bewerbId = bewerbId,
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: CSN-C-NEU ab 100 cm erfordert Abt. R1 und Abt. R2+.",
oetoParagraph = "ÖTO B-Teil § 231"
))
}
}
}
// 4. Caprilli (§ 803 Abs. 2)
if (pruefungsTyp == PruefungsTypE.CAPRILLI) {
val hatLizenzfrei = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("lizenzfrei", ignoreCase = true) == true }
val hatRD1Plus = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("RD1", ignoreCase = true) == true || it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
if (!hatLizenzfrei || !hatRD1Plus) {
warnings.add(AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
bewerbId = bewerbId,
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Caprilli-Prüfung erfordert Abt. lizenzfrei und Abt. RD1+.",
oetoParagraph = "ÖTO B-Teil § 803 Abs. 2"
))
}
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/

View File

@ -7,6 +7,8 @@ import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.domain.model.PruefungsTypE
import at.mocode.core.domain.model.SparteE
import at.mocode.entries.domain.model.Abteilung
import at.mocode.entries.domain.model.AbteilungsWarnung
import at.mocode.entries.domain.model.AbteilungsWarnungCodeE
import at.mocode.entries.domain.model.Bewerb
import at.mocode.masterdata.domain.model.Reiter
@ -117,12 +119,22 @@ class AbteilungsRegelService {
fun validateStrukturelleVollstaendigkeit(
bewerb: Bewerb,
abteilungen: List<Abteilung>
): List<String> {
val warnings = mutableListOf<String>()
): List<AbteilungsWarnung> {
val warnings = mutableListOf<AbteilungsWarnung>()
// Rufe die neue domänen-zentrierte Validierung in Bewerb auf
warnings.addAll(bewerb.validateStrukturellesTeilung(abteilungen))
if (bewerb.teilungsTyp == AbteilungsTeilungsTypE.STRUKTURELL) {
if (abteilungen.size < 2) {
warnings.add("WARN_BEWERB_STRUKTURELLE_TEILUNG_FEHLT: Bewerb ${bewerb.getDisplayName()} erfordert mindestens zwei Abteilungen.")
warnings.add(
AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
bewerbId = bewerb.bewerbId,
nachricht = "WARN_BEWERB_STRUKTURELLE_TEILUNG_FEHLT: Bewerb ${bewerb.getDisplayName()} erfordert mindestens zwei Abteilungen.",
oetoParagraph = "§ 39"
)
)
}
}
@ -130,7 +142,14 @@ class AbteilungsRegelService {
val gesamtStarter = abteilungen.sumOf { it.starterAnzahl }
val limit = bewerb.getPflichtTeilungsSchwellenwert() ?: 80
if (gesamtStarter > limit && abteilungen.size == 1) {
warnings.add("WARN_BEWERB_PFLICHT_TEILUNG_ERFORDERLICH: Bewerb ${bewerb.getDisplayName()} hat $gesamtStarter Starter. Teilung in mind. 2 Abteilungen verpflichtend.")
warnings.add(
AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN,
bewerbId = bewerb.bewerbId,
nachricht = "WARN_BEWERB_PFLICHT_TEILUNG_ERFORDERLICH: Bewerb ${bewerb.getDisplayName()} hat $gesamtStarter Starter. Teilung in mind. 2 Abteilungen verpflichtend.",
oetoParagraph = "§ 39 Abs. 2"
)
)
}
return warnings

View File

@ -2,6 +2,7 @@
package at.mocode.entries.domain.service
import at.mocode.entries.domain.model.AbteilungsWarnung
import at.mocode.entries.domain.repository.CompetitionRepository
import kotlin.uuid.Uuid
@ -20,13 +21,13 @@ class CompetitionWarningService(
*
* @return Eine Map von Bewerb-ID zu einer Liste von Warnmeldungen.
*/
suspend fun validateTurnier(turnierId: Uuid): Map<Uuid, List<String>> {
suspend fun validateTurnier(turnierId: Uuid): Map<Uuid, List<AbteilungsWarnung>> {
val bewerbe = competitionRepository.findBewerbeByTurnierId(turnierId)
val result = mutableMapOf<Uuid, List<String>>()
val result = mutableMapOf<Uuid, List<AbteilungsWarnung>>()
for (bewerb in bewerbe) {
val abteilungen = competitionRepository.findAbteilungenByBewerbId(bewerb.bewerbId)
val warnings = mutableListOf<String>()
val warnings = mutableListOf<AbteilungsWarnung>()
// 1. Bewerbs-Ebene Schwellenwerte (z. B. Dressur-Kann-Teilung)
val gesamtStarter = abteilungen.sumOf { it.starterAnzahl }
@ -51,11 +52,11 @@ class CompetitionWarningService(
/**
* Validiert einen einzelnen Bewerb und gibt Warnungen zurück.
*/
suspend fun validateBewerb(bewerbId: Uuid): List<String> {
suspend fun validateBewerb(bewerbId: Uuid): List<AbteilungsWarnung> {
val bewerb = competitionRepository.findBewerbById(bewerbId) ?: return emptyList()
val abteilungen = competitionRepository.findAbteilungenByBewerbId(bewerbId)
val warnings = mutableListOf<String>()
val warnings = mutableListOf<AbteilungsWarnung>()
val gesamtStarter = abteilungen.sumOf { it.starterAnzahl }
warnings.addAll(bewerb.validateAbteilungsSchwellenwerte(gesamtStarter))

View File

@ -59,7 +59,7 @@ class BewerbTest {
val warnings = bewerb.validateAbteilungsSchwellenwerte(81)
assertEquals(1, warnings.size)
assertTrue(warnings[0].contains("WARN_ABTEILUNG_PFLICHT_TEILUNG_UEBERSCHRITTEN"))
assertEquals(AbteilungsWarnungCodeE.WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN, warnings[0].code)
}
@Test
@ -75,6 +75,49 @@ class BewerbTest {
val warnings = bewerb.validateAbteilungsSchwellenwerte(31)
assertEquals(1, warnings.size)
assertTrue(warnings[0].contains("WARN_ABTEILUNG_KANN_TEILUNG_EMPFOHLEN"))
assertEquals(AbteilungsWarnungCodeE.WARN_KANN_TEILUNG_EMPFOHLEN, warnings[0].code)
}
@Test
fun `validateStrukturellesTeilung erkennt fehlende Abteilungen bei CSN Stilspringen`() {
val bewerb = Bewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
bezeichnung = "Stilspringen 95cm",
sparte = SparteE.SPRINGEN,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = PruefungsTypE.STIL_SPRINGEN,
hoeheCm = 95
)
val abteilungen = listOf(
Abteilung(bewerbId = bewerb.bewerbId, abteilungsNummer = 1, teilnehmerkreisBeschreibung = "Reiter ohne Lizenz")
)
val warnings = bewerb.validateStrukturellesTeilung(abteilungen)
assertEquals(1, warnings.size)
assertEquals(AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT, warnings[0].code)
assertTrue(warnings[0].nachricht.contains("R1"))
}
@Test
fun `validateStrukturellesTeilung erkennt fehlende Abteilungen bei Caprilli`() {
val bewerb = Bewerb(
turnierId = Uuid.random(),
bewerbNummer = 1,
bezeichnung = "Caprilli",
sparte = SparteE.SPRINGEN,
turnierkategorie = TurnierkategorieE.B,
pruefungsTyp = PruefungsTypE.CAPRILLI
)
val abteilungen = listOf(
Abteilung(bewerbId = bewerb.bewerbId, abteilungsNummer = 1, teilnehmerkreisBeschreibung = "lizenzfrei")
)
val warnings = bewerb.validateStrukturellesTeilung(abteilungen)
assertEquals(1, warnings.size)
assertEquals(AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT, warnings[0].code)
assertTrue(warnings[0].nachricht.contains("RD1+"))
}
}

View File

@ -4,6 +4,7 @@ package at.mocode.entries.domain.service
import at.mocode.core.domain.model.*
import at.mocode.entries.domain.model.Abteilung
import at.mocode.entries.domain.model.AbteilungsWarnungCodeE
import at.mocode.entries.domain.model.Bewerb
import at.mocode.masterdata.domain.model.Reiter
import kotlin.test.Test
@ -58,7 +59,7 @@ class AbteilungsRegelServiceTest {
val warnings = service.validateStrukturelleVollstaendigkeit(bewerb, listOf(abt1))
assertEquals(1, warnings.size)
assertTrue(warnings[0].contains("WARN_BEWERB_PFLICHT_TEILUNG_ERFORDERLICH"))
assertEquals(AbteilungsWarnungCodeE.WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN, warnings[0].code)
}
// ─── B-3: CSN-C-NEU ≤ 95 cm ────────────────────────────────────────────────

View File

@ -7,6 +7,7 @@ import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import at.mocode.entries.domain.model.RichterEinsatz
import at.mocode.entries.domain.service.CompetitionWarningService
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
@ -15,8 +16,12 @@ import kotlin.uuid.toJavaUuid
class BewerbService(
private val repo: BewerbRepository,
private val nennungen: NennungRepository,
private val warningService: CompetitionWarningService,
) {
suspend fun validateTurnier(turnierId: Uuid) = warningService.validateTurnier(turnierId)
suspend fun validateBewerb(bewerbId: Uuid) = warningService.validateBewerb(bewerbId)
private suspend fun isTurnierPublished(turnierId: Uuid): Boolean = tenantTransaction {
val row = TurnierTable.selectAll().where { TurnierTable.id eq turnierId.toJavaUuid() }.singleOrNull()
row?.get(TurnierTable.status) == "PUBLISHED"

View File

@ -4,6 +4,8 @@ package at.mocode.entries.service.bewerbe
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
import at.mocode.core.domain.model.BeginnZeitTypE
import at.mocode.entries.domain.model.AbteilungsWarnung
import at.mocode.entries.domain.model.AbteilungsWarnungCodeE
import at.mocode.entries.domain.model.RichterEinsatz
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
@ -127,6 +129,13 @@ data class BewerbResponse(
// ZNS-Integration
val znsNummer: Int?,
val znsAbteilung: Int?,
val warnungen: List<AbteilungsWarnungDto> = emptyList(),
)
data class AbteilungsWarnungDto(
val code: AbteilungsWarnungCodeE,
val nachricht: String,
val oetoParagraph: String?
)
private fun RichterEinsatzDto.toDomain(): RichterEinsatz =
@ -135,7 +144,7 @@ private fun RichterEinsatzDto.toDomain(): RichterEinsatz =
position = this.position
)
private fun domainToDto(b: Bewerb): BewerbResponse = BewerbResponse(
private fun domainToDto(b: Bewerb, warnungen: List<AbteilungsWarnung> = emptyList()): BewerbResponse = BewerbResponse(
id = b.id.toString(),
turnierId = b.turnierId.toString(),
klasse = b.klasse,
@ -159,6 +168,7 @@ private fun domainToDto(b: Bewerb): BewerbResponse = BewerbResponse(
geldpreisAusbezahlt = b.geldpreisAusbezahlt,
znsNummer = b.znsNummer,
znsAbteilung = b.znsAbteilung,
warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) }
)
@RestController
@ -185,21 +195,37 @@ class BewerbeController(
): List<BewerbResponse> = service.importZns(
Uuid.parse(turnierId),
body
).map(::domainToDto)
).map { domainToDto(it) }
@GetMapping("/turniere/{turnierId}/bewerbe")
suspend fun list(
@PathVariable turnierId: String,
@RequestParam(required = false) klasse: String?,
@RequestParam(required = false) q: String?,
): List<BewerbResponse> = service.list(Uuid.parse(turnierId), klasse, q).map(::domainToDto)
): List<BewerbResponse> {
val turnierUuid = Uuid.parse(turnierId)
val bewerbe = service.list(turnierUuid, klasse, q)
val warnungenMap = service.validateTurnier(turnierUuid)
return bewerbe.map { b ->
domainToDto(b, warnungenMap[b.id] ?: emptyList())
}
}
@GetMapping("/bewerbe/{id}")
suspend fun get(@PathVariable id: String): BewerbResponse = domainToDto(service.get(Uuid.parse(id)))
suspend fun get(@PathVariable id: String): BewerbResponse {
val uuid = Uuid.parse(id)
val b = service.get(uuid)
val warnungen = service.validateBewerb(uuid)
return domainToDto(b, warnungen)
}
@PutMapping("/bewerbe/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateBewerbRequest): BewerbResponse =
domainToDto(service.update(Uuid.parse(id), body))
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateBewerbRequest): BewerbResponse {
val uuid = Uuid.parse(id)
val updated = service.update(uuid, body)
val warnungen = service.validateBewerb(uuid)
return domainToDto(updated, warnungen)
}
@DeleteMapping("/bewerbe/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)

View File

@ -13,6 +13,10 @@ import at.mocode.entries.service.bewerbe.BewerbService
import at.mocode.entries.service.abteilungen.AbteilungRepository
import at.mocode.entries.service.abteilungen.AbteilungRepositoryImpl
import at.mocode.entries.service.abteilungen.AbteilungenService
import at.mocode.entries.domain.repository.CompetitionRepository
import at.mocode.entries.domain.service.AbteilungsRegelService
import at.mocode.entries.domain.service.CompetitionWarningService
import at.mocode.entries.service.persistence.CompetitionRepositoryImpl
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@ -43,11 +47,24 @@ class EntriesBeansConfiguration {
@Bean
fun bewerbRepository(): BewerbRepository = BewerbRepositoryImpl()
@Bean
fun abteilungsRegelService(): AbteilungsRegelService = AbteilungsRegelService()
@Bean
fun competitionRepository(): CompetitionRepository = CompetitionRepositoryImpl()
@Bean
fun competitionWarningService(
competitionRepository: CompetitionRepository,
regelService: AbteilungsRegelService
): CompetitionWarningService = CompetitionWarningService(competitionRepository, regelService)
@Bean
fun bewerbService(
bewerbRepository: BewerbRepository,
nennungRepository: NennungRepository
): BewerbService = BewerbService(bewerbRepository, nennungRepository)
nennungRepository: NennungRepository,
warningService: CompetitionWarningService
): BewerbService = BewerbService(bewerbRepository, nennungRepository, warningService)
@Bean
fun abteilungRepository(): AbteilungRepository = AbteilungRepositoryImpl()

View File

@ -0,0 +1,120 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.persistence
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.entries.domain.model.Abteilung as DomainAbteilung
import at.mocode.entries.domain.model.Bewerb as DomainBewerb
import at.mocode.entries.domain.repository.CompetitionRepository
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Implementierung des CompetitionRepository für den Domain-Service.
* Mappt zwischen Service-Tabellen und Domain-Modellen.
*/
class CompetitionRepositoryImpl : CompetitionRepository {
override suspend fun findBewerbById(id: Uuid): DomainBewerb? = tenantTransaction {
BewerbTable.selectAll().where { BewerbTable.id eq id.toJavaUuid() }
.map { row -> rowToDomainBewerb(row) }
.singleOrNull()
}
override suspend fun findBewerbeByTurnierId(turnierId: Uuid): List<DomainBewerb> = tenantTransaction {
BewerbTable.selectAll().where { BewerbTable.turnierId eq turnierId.toJavaUuid() }
.map { row -> rowToDomainBewerb(row) }
}
override suspend fun saveBewerb(bewerb: DomainBewerb): DomainBewerb {
// Für Read-Only Validierung im WarningService vorerst nicht implementiert
throw UnsupportedOperationException("saveBewerb via CompetitionRepository not implemented yet")
}
override suspend fun deleteBewerb(id: Uuid): Boolean {
throw UnsupportedOperationException("deleteBewerb via CompetitionRepository not implemented yet")
}
override suspend fun findAbteilungById(id: Uuid): DomainAbteilung? = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.id eq id.toJavaUuid() }
.map { row -> rowToDomainAbteilung(row) }
.singleOrNull()
}
override suspend fun findAbteilungenByBewerbId(bewerbId: Uuid): List<DomainAbteilung> = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.bewerbId eq bewerbId.toJavaUuid() }
.map { row -> rowToDomainAbteilung(row) }
}
override suspend fun saveAbteilung(abteilung: DomainAbteilung): DomainAbteilung {
throw UnsupportedOperationException("saveAbteilung via CompetitionRepository not implemented yet")
}
override suspend fun deleteAbteilung(id: Uuid): Boolean {
throw UnsupportedOperationException("deleteAbteilung via CompetitionRepository not implemented yet")
}
private fun rowToDomainBewerb(row: ResultRow): DomainBewerb {
val bId = row[BewerbTable.id].toKotlinUuid()
val tId = row[BewerbTable.turnierId].toKotlinUuid()
// Wir müssen Sparte und Kategorie irgendwie herleiten oder aus einer anderen Tabelle laden.
// In der BewerbTable fehlen diese Felder aktuell noch im Vergleich zum Domain-Modell.
// Für den MVP hardcoden wir Standardwerte oder versuchen sie aus dem Turnier zu lesen.
return DomainBewerb(
bewerbId = bId,
turnierId = tId,
bewerbNummer = row[BewerbTable.znsNummer] ?: 0,
bezeichnung = row[BewerbTable.bezeichnung],
sparte = SparteE.SPRINGEN, // FIXME: Herleiten
turnierkategorie = TurnierkategorieE.B, // FIXME: Herleiten
pruefungsTyp = PruefungsTypE.SPRINGEN_UEBRIG, // FIXME: Herleiten
hoeheCm = row[BewerbTable.hoeheCm],
teilungsTyp = row[BewerbTable.teilungsTyp]?.let { AbteilungsTeilungsTypE.valueOf(it) } ?: AbteilungsTeilungsTypE.KEINE,
beschreibung = row[BewerbTable.beschreibung],
aufgabe = row[BewerbTable.aufgabe],
aufgabenNummer = row[BewerbTable.aufgabenNummer],
paraGrade = row[BewerbTable.paraGrade],
austragungsplatzId = row[BewerbTable.austragungsplatzId]?.toKotlinUuid(),
geplantesDatum = row[BewerbTable.geplantesDatum],
beginnZeitTyp = row[BewerbTable.beginnZeitTyp]?.let { at.mocode.core.domain.model.BeginnZeitTypE.valueOf(it) },
beginnZeit = row[BewerbTable.beginnZeit],
reitdauerMinuten = row[BewerbTable.reitdauerMinuten],
umbauMinuten = row[BewerbTable.umbauMinuten],
besichtigungMinuten = row[BewerbTable.besichtigungMinuten],
stechenGeplant = row[BewerbTable.stechenGeplant],
startgeldCent = row[BewerbTable.startgeldCent],
geldpreisAusbezahlt = row[BewerbTable.geldpreisAusbezahlt],
createdAt = row[BewerbTable.createdAt],
updatedAt = row[BewerbTable.updatedAt]
)
}
private fun rowToDomainAbteilung(row: ResultRow): DomainAbteilung {
val aId = row[AbteilungTable.id].toKotlinUuid()
val bId = row[AbteilungTable.bewerbId].toKotlinUuid()
// Starteranzahl berechnen
val count = NennungTable.selectAll().where { NennungTable.abteilungId eq aId.toJavaUuid() }.count()
return DomainAbteilung(
abteilungId = aId,
bewerbId = bId,
abteilungsNummer = row[AbteilungTable.nr],
bezeichnung = row[AbteilungTable.bezeichnung],
teilungsTyp = row[AbteilungTable.typ].let { AbteilungsTeilungsTypE.valueOf(it) },
starterAnzahl = count.toInt(),
createdAt = row[AbteilungTable.createdAt],
updatedAt = row[AbteilungTable.updatedAt]
)
}
}

View File

@ -2,7 +2,7 @@
type: Roadmap
status: ACTIVE
owner: Lead Architect
last_update: 2026-04-03
last_update: 2026-04-10
---
# MASTER ROADMAP: Meldestelle-Biest
@ -212,7 +212,7 @@ und über definierte Schnittstellen kommunizieren.
* [x] **Konzept:** LAN-Discovery (mDNS) und Echtzeit-Sync (WebSockets) entworfen.
* [x] **ADR:** ADR-0020 (Lokale Netzwerk-Kommunikation) erstellt.
### PHASE 8: Bewerbe-Management & Startlisten 🔵 IN ARBEIT
### PHASE 8: Bewerbe-Management & Startlisten ✅ ABGESCHLOSSEN
*Ziel: Fachliche Tiefe in den Turnieren (Import, Generierung, Zeitberechnung).*
@ -222,8 +222,17 @@ und über definierte Schnittstellen kommunizieren.
* [x] **Discovery:** Implementierung des mDNS-Service (JmDNS) für die Geräte-Suche. ✓
* [x] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync (Ktor WebSockets, SyncManager). ✓
* [x] **Offline-First Desktop↔Backend:** Umsetzung gemäß Konzept „Offline-First Synchronisation (Desktop ↔ Backend)“ → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md`. ✓
* [x] **Regelwerks-Validierung:** Implementierung des strukturierten Abteilungs-Warnungssystems gemäß ÖTO § 39 inkl. UI-Integration. ✓
### PHASE 9: Series-Context & Erweiterungen 🔵 PHASE 2+
### PHASE 9: Zeitplan-Optimierung & Protokollierung 🔵 IN ARBEIT
*Ziel: Dynamische Zeitplan-Anpassungen, Protokollierung von Änderungen und Export-Funktionen.*
* [ ] **Zeitplan:** Dynamische Verschiebung von Bewerben (Drag & Drop im Kalender).
* [ ] **Protokoll:** Implementierung eines Event-Logs für manuelle Eingriffe in Startlisten.
* [ ] **Export:** Startlisten-Export für ZNS (XML-B-Satz).
### PHASE 10: Series-Context & Erweiterungen 🔵 PHASE 2+
*Ziel: Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.*

View File

@ -9,6 +9,13 @@ data class Bewerb(
val sparte: String,
val klasse: String,
val nennungen: Int,
val warnungen: List<AbteilungsWarnung> = emptyList(),
)
data class AbteilungsWarnung(
val code: String,
val nachricht: String,
val oetoParagraph: String?
)
interface BewerbRepository {

View File

@ -114,7 +114,7 @@ class BewerbViewModel(
scope.launch {
manager.getConnectedPeers().collect { peers ->
reduce { it.copy(discoveredNodes = peers.map { p ->
at.mocode.frontend.core.network.discovery.DiscoveredService("P2P", p, 0)
DiscoveredService("P2P", p, 0)
}) }
}
}

View File

@ -11,7 +11,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -293,6 +295,7 @@ private fun RowScope.TableHeaderCell(text: String, width: androidx.compose.ui.un
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick: () -> Unit) {
Row(
@ -315,6 +318,30 @@ private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick:
Text(bewerb.beginn, fontSize = 12.sp, modifier = Modifier.width(55.dp))
Text(bewerb.ende, fontSize = 12.sp, modifier = Modifier.width(55.dp))
Text(bewerb.name, fontSize = 12.sp, modifier = Modifier.weight(1f), maxLines = 2)
if (bewerb.warnungen.isNotEmpty()) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Column {
bewerb.warnungen.forEach { warnung ->
Text("${warnung.oetoParagraph ?: ""}: ${warnung.nachricht}", fontSize = 11.sp)
}
}
}
},
state = rememberTooltipState(),
) {
Icon(
Icons.Default.Warning,
contentDescription = "Warnungen vorhanden",
modifier = Modifier.padding(horizontal = 4.dp).size(16.dp),
tint = Color(0xFFFACC15) // Yellow-400
)
}
} else {
Spacer(Modifier.width(24.dp))
}
Text("${bewerb.zns}", fontSize = 12.sp, modifier = Modifier.width(45.dp))
Text("${bewerb.nennungen}", fontSize = 12.sp, modifier = Modifier.width(75.dp))
}
@ -484,7 +511,8 @@ private fun BewerbListItem.toUiModel() = BewerbUiModel(
typ = "",
zeile1 = "",
zns = 1,
nennungen = nennungen
nennungen = nennungen,
warnungen = warnungen
)
@Composable
@ -839,6 +867,7 @@ data class BewerbUiModel(
val zeile1: String,
val zns: Int,
val nennungen: Int,
val warnungen: List<at.mocode.turnier.feature.domain.AbteilungsWarnung> = emptyList(),
)
private fun sampleBewerbe() = listOf(
@ -853,7 +882,8 @@ private fun sampleBewerbe() = listOf(
"Dressur",
"Pony Einsteiger Cup OO",
0,
0
0,
emptyList()
),
BewerbUiModel(
"28.05.2023",
@ -866,7 +896,8 @@ private fun sampleBewerbe() = listOf(
"Dressur",
"Pony Einsteiger Cup OO",
0,
0
0,
emptyList()
),
BewerbUiModel(
"28.05.2023",