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:
parent
22c631ec43
commit
e7d7e43ccf
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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+"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.*
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user