diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/Abteilung.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/Abteilung.kt index fa0601c0..6d82e806 100644 --- a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/Abteilung.kt +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/Abteilung.kt @@ -3,6 +3,7 @@ package at.mocode.entries.domain.model import at.mocode.core.domain.model.AbteilungsTeilungsTypE +import at.mocode.core.domain.model.BesichtigungsTypE import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.serialization.Serializable @@ -55,6 +56,9 @@ data class Abteilung( // Zeitplanung var startzeit: String? = null, + /** Besichtigungstyp für diese Abteilung (optional, wenn abweichend von Standard). */ + var besichtigungsTyp: BesichtigungsTypE? = null, + // Verwaltung var bemerkungen: String? = null, diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/BesichtigungsBlock.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/BesichtigungsBlock.kt new file mode 100644 index 00000000..7670c51a --- /dev/null +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/BesichtigungsBlock.kt @@ -0,0 +1,33 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.entries.domain.model + +import at.mocode.core.domain.model.BesichtigungsTypE +import at.mocode.core.domain.serialization.UuidSerializer +import kotlinx.serialization.Serializable +import kotlin.uuid.Uuid + +/** + * Repräsentiert einen Zeitblock für die Parcoursbesichtigung. + * Kann mit mehreren Abteilungen oder Bewerben verknüpft sein. + */ +@Serializable +data class BesichtigungsBlock( + @Serializable(with = UuidSerializer::class) + val besichtigungsBlockId: Uuid = Uuid.random(), + + /** Typ der Besichtigung (zu Fuß / zu Pferd). */ + val typ: BesichtigungsTypE = BesichtigungsTypE.ZU_FUSS, + + /** Geplante Dauer in Minuten. */ + val dauerMinuten: Int = 15, + + /** + * Liste der verknüpften Abteilungs-IDs. + * Eine Besichtigung kann für mehrere Abteilungen gleichzeitig stattfinden. + */ + val abteilungIds: List<@Serializable(with = UuidSerializer::class) Uuid> = emptyList(), + + /** Optionaler Puffer nach der Besichtigung bis zum ersten Start (Standard: 5 Min gemäß ÖTO). */ + val pufferMinuten: Int = 5 +) diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/Bewerb.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/Bewerb.kt index 02804e15..5de63ee3 100644 --- a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/Bewerb.kt +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/Bewerb.kt @@ -97,6 +97,10 @@ data class Bewerb( var reitdauerMinuten: Int? = null, var umbauMinuten: Int? = null, var besichtigungMinuten: Int? = null, + + /** Konfiguration für Pausen während der Prüfung. */ + var pausenKonfiguration: PausenKonfiguration? = null, + var stechenGeplant: Boolean = false, // Finanzen @@ -297,3 +301,18 @@ data class Bewerb( */ fun withUpdatedTimestamp(): Bewerb = this.copy(updatedAt = Clock.System.now()) } + +/** + * Konfiguration für automatische Pausen nach einer bestimmten Anzahl von Startern. + */ +@Serializable +data class PausenKonfiguration( + /** Pause alle X Starter (0 = keine automatischen Pausen). */ + val starterIntervall: Int = 0, + + /** Dauer der Pause in Minuten. */ + val dauerMinuten: Int = 10, + + /** Optionale Bezeichnung (z.B. "Platzpflege"). */ + val bezeichnung: String? = null +) diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/StartlistenService.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/StartlistenService.kt index f846ef47..c4420391 100644 --- a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/StartlistenService.kt +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/service/StartlistenService.kt @@ -98,16 +98,24 @@ class StartlistenService { // Besichtigung vor dem ersten Starter aktuelleZeitInMinuten += besichtigung - startliste.eintraege.forEach { eintrag -> + // Puffer nach Besichtigung (ÖTO) + if (besichtigung > 0) { + aktuelleZeitInMinuten += 5 + } + + startliste.eintraege.forEachIndexed { index, eintrag -> + // Pause nach Intervall berücksichtigen + val pausenKonf = bewerb.pausenKonfiguration + if (pausenKonf != null && pausenKonf.starterIntervall > 0 && index > 0 && index % pausenKonf.starterIntervall == 0) { + aktuelleZeitInMinuten += pausenKonf.dauerMinuten + } + val stunden = aktuelleZeitInMinuten / 60 val minuten = aktuelleZeitInMinuten % 60 zeiten[eintrag.startnummer] = LocalTime(stunden % 24, minuten) // Zeit für den nächsten Starter berechnen aktuelleZeitInMinuten += reitdauer - - // TODO: Umbauzeiten nach bestimmten Intervallen (z.B. alle 10 Starter) - // oder bei Abteilungswechsel berücksichtigen. } return zeiten diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepository.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepository.kt index 3d138f14..503c33bd 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepository.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepository.kt @@ -10,6 +10,7 @@ data class Abteilung( val nr: Int, val bezeichnung: String, val typ: String, + val besichtigungsTyp: String? = null, ) interface AbteilungRepository { diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepositoryImpl.kt index e535c2ff..a69555e0 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepositoryImpl.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepositoryImpl.kt @@ -22,7 +22,8 @@ class AbteilungRepositoryImpl : AbteilungRepository { bewerbId = row[AbteilungTable.bewerbId].toKotlinUuid(), nr = row[AbteilungTable.nr], bezeichnung = row[AbteilungTable.bezeichnung], - typ = row[AbteilungTable.typ] + typ = row[AbteilungTable.typ], + besichtigungsTyp = row[AbteilungTable.besichtigungsTyp] ) override suspend fun create(a: Abteilung): Abteilung = tenantTransaction { @@ -33,6 +34,7 @@ class AbteilungRepositoryImpl : AbteilungRepository { s[AbteilungTable.nr] = a.nr s[AbteilungTable.bezeichnung] = a.bezeichnung s[AbteilungTable.typ] = a.typ + s[AbteilungTable.besichtigungsTyp] = a.besichtigungsTyp s[AbteilungTable.createdAt] = now s[AbteilungTable.updatedAt] = now } @@ -53,6 +55,7 @@ class AbteilungRepositoryImpl : AbteilungRepository { s[AbteilungTable.nr] = a.nr s[AbteilungTable.bezeichnung] = a.bezeichnung s[AbteilungTable.typ] = a.typ + s[AbteilungTable.besichtigungsTyp] = a.besichtigungsTyp s[AbteilungTable.updatedAt] = now } AbteilungTable.selectAll().where { AbteilungTable.id eq a.id.toJavaUuid() }.map(::rowToAbteilung).single() diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt index 5607e02d..a94f2eb0 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt @@ -33,6 +33,10 @@ data class Bewerb( val umbauMinuten: Int? = null, val besichtigungMinuten: Int? = null, val stechenGeplant: Boolean = false, + // Pausen + val pausenStarterIntervall: Int? = null, + val pausenDauerMinuten: Int? = null, + val pausenBezeichnung: String? = null, // Finanzen val startgeldCent: Long? = null, val nenngeldCent: Long? = null, diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt index d0d8b0ff..cdcdee2f 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt @@ -70,6 +70,10 @@ class BewerbRepositoryImpl : BewerbRepository { umbauMinuten = row[BewerbTable.umbauMinuten], besichtigungMinuten = row[BewerbTable.besichtigungMinuten], stechenGeplant = row[BewerbTable.stechenGeplant], + // Pausen + pausenStarterIntervall = row[BewerbTable.pausenStarterIntervall], + pausenDauerMinuten = row[BewerbTable.pausenDauerMinuten], + pausenBezeichnung = row[BewerbTable.pausenBezeichnung], // Finanzen startgeldCent = row[BewerbTable.startgeldCent], nenngeldCent = row[BewerbTable.nenngeldCent], @@ -106,6 +110,10 @@ class BewerbRepositoryImpl : BewerbRepository { s[BewerbTable.umbauMinuten] = b.umbauMinuten s[BewerbTable.besichtigungMinuten] = b.besichtigungMinuten s[BewerbTable.stechenGeplant] = b.stechenGeplant + // Pausen + s[BewerbTable.pausenStarterIntervall] = b.pausenStarterIntervall + s[BewerbTable.pausenDauerMinuten] = b.pausenDauerMinuten + s[BewerbTable.pausenBezeichnung] = b.pausenBezeichnung // Finanzen s[BewerbTable.startgeldCent] = b.startgeldCent s[BewerbTable.nenngeldCent] = b.nenngeldCent @@ -155,6 +163,10 @@ class BewerbRepositoryImpl : BewerbRepository { s[BewerbTable.umbauMinuten] = b.umbauMinuten s[BewerbTable.besichtigungMinuten] = b.besichtigungMinuten s[BewerbTable.stechenGeplant] = b.stechenGeplant + // Pausen + s[BewerbTable.pausenStarterIntervall] = b.pausenStarterIntervall + s[BewerbTable.pausenDauerMinuten] = b.pausenDauerMinuten + s[BewerbTable.pausenBezeichnung] = b.pausenBezeichnung // Finanzen s[BewerbTable.startgeldCent] = b.startgeldCent s[BewerbTable.nenngeldCent] = b.nenngeldCent diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt index e60e1c42..394f9fab 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt @@ -6,6 +6,7 @@ import at.mocode.entries.domain.repository.NennungRepository 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.PausenKonfiguration import at.mocode.entries.domain.model.RichterEinsatz import at.mocode.entries.domain.service.CompetitionWarningService import org.jetbrains.exposed.v1.core.eq @@ -13,6 +14,8 @@ import org.jetbrains.exposed.v1.jdbc.selectAll import kotlin.uuid.Uuid import kotlin.uuid.toJavaUuid +typealias DomBewerb = at.mocode.entries.domain.model.Bewerb + class BewerbService( private val repo: BewerbRepository, private val nennungen: NennungRepository, @@ -53,6 +56,10 @@ class BewerbService( umbauMinuten = req.umbauMinuten, besichtigungMinuten = req.besichtigungMinuten, stechenGeplant = req.stechenGeplant, + // Pausen + pausenStarterIntervall = req.pausenStarterIntervall, + pausenDauerMinuten = req.pausenDauerMinuten, + pausenBezeichnung = req.pausenBezeichnung, // Finanzen startgeldCent = req.startgeldCent, geldpreisAusbezahlt = req.geldpreisAusbezahlt @@ -93,6 +100,11 @@ class BewerbService( umbauMinuten = req.umbauMinuten, besichtigungMinuten = req.besichtigungMinuten, stechenGeplant = req.stechenGeplant, + // Pausen + pausenStarterIntervall = req.pausenStarterIntervall, + pausenDauerMinuten = req.pausenDauerMinuten, + pausenBezeichnung = req.pausenBezeichnung, + // Finanzen startgeldCent = req.startgeldCent, geldpreisAusbezahlt = req.geldpreisAusbezahlt, znsNummer = req.znsNummer, @@ -133,6 +145,10 @@ class BewerbService( umbauMinuten = req.umbauMinuten, besichtigungMinuten = req.besichtigungMinuten, stechenGeplant = req.stechenGeplant, + // Pausen + pausenStarterIntervall = req.pausenStarterIntervall, + pausenDauerMinuten = req.pausenDauerMinuten, + pausenBezeichnung = req.pausenBezeichnung, // Finanzen startgeldCent = req.startgeldCent, geldpreisAusbezahlt = req.geldpreisAusbezahlt, @@ -140,6 +156,17 @@ class BewerbService( return repo.update(updated) } + suspend fun updateZeitplan(id: Uuid, req: UpdateZeitplanRequest): Bewerb { + val current = get(id) + // Hier erlauben wir Änderungen auch wenn PUBLISHED (da Drag & Drop im Live-Betrieb nötig) + val updated = current.copy( + geplantesDatum = req.geplantesDatum, + beginnZeit = req.beginnZeit, + austragungsplatzId = req.austragungsplatzId?.let { Uuid.parse(it) } + ) + return repo.update(updated) + } + suspend fun delete(id: Uuid) { val current = get(id) if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED – Bewerbe können nicht gelöscht werden") diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt index 8a8cdc8b..bef04885 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt @@ -47,6 +47,11 @@ data class CreateBewerbRequest( val besichtigungMinuten: Int? = null, val stechenGeplant: Boolean = false, + // Pausen + val pausenStarterIntervall: Int? = null, + val pausenDauerMinuten: Int? = null, + val pausenBezeichnung: String? = null, + // Finanzen val startgeldCent: Long? = null, val geldpreisAusbezahlt: Boolean = false, @@ -84,6 +89,11 @@ data class UpdateBewerbRequest( val besichtigungMinuten: Int? = null, val stechenGeplant: Boolean = false, + // Pausen + val pausenStarterIntervall: Int? = null, + val pausenDauerMinuten: Int? = null, + val pausenBezeichnung: String? = null, + // Finanzen val startgeldCent: Long? = null, val geldpreisAusbezahlt: Boolean = false, @@ -122,6 +132,11 @@ data class BewerbResponse( val besichtigungMinuten: Int?, val stechenGeplant: Boolean, + // Pausen + val pausenStarterIntervall: Int?, + val pausenDauerMinuten: Int?, + val pausenBezeichnung: String?, + // Finanzen val startgeldCent: Long?, val geldpreisAusbezahlt: Boolean, @@ -129,9 +144,17 @@ data class BewerbResponse( // ZNS-Integration val znsNummer: Int?, val znsAbteilung: Int?, + val warnungen: List = emptyList(), ) +/** Request für schnelles Zeitplan-Update (Drag & Drop). */ +data class UpdateZeitplanRequest( + val geplantesDatum: LocalDate?, + val beginnZeit: LocalTime?, + val austragungsplatzId: String? +) + data class AbteilungsWarnungDto( val code: AbteilungsWarnungCodeE, val nachricht: String, @@ -168,6 +191,9 @@ private fun domainToDto(b: Bewerb, warnungen: List = emptyLis geldpreisAusbezahlt = b.geldpreisAusbezahlt, znsNummer = b.znsNummer, znsAbteilung = b.znsAbteilung, + pausenStarterIntervall = b.pausenStarterIntervall, + pausenDauerMinuten = b.pausenDauerMinuten, + pausenBezeichnung = b.pausenBezeichnung, warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) } ) @@ -232,4 +258,10 @@ class BewerbeController( suspend fun delete(@PathVariable id: String) { service.delete(Uuid.parse(id)) } + + @PatchMapping("/bewerbe/{id}/zeitplan") + suspend fun updateZeitplan(@PathVariable id: String, @RequestBody body: UpdateZeitplanRequest): BewerbResponse { + val b = service.updateZeitplan(Uuid.parse(id), body) + return domainToDto(b) + } } diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/AbteilungTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/AbteilungTable.kt index e30184b1..82b9621e 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/AbteilungTable.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/AbteilungTable.kt @@ -10,6 +10,7 @@ object AbteilungTable : Table("abteilungen") { val nr = integer("nr") val bezeichnung = text("bezeichnung") val typ = varchar("typ", 32) + val besichtigungsTyp = varchar("besichtigungs_typ", 20).nullable() val createdAt = timestamp("created_at") val updatedAt = timestamp("updated_at") diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt index aa141b74..f97512c7 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt @@ -34,6 +34,11 @@ object BewerbTable : Table("bewerbe") { val besichtigungMinuten = integer("besichtigung_minuten").nullable() val stechenGeplant = bool("stechen_geplant").default(false) + // Pausen + val pausenStarterIntervall = integer("pausen_starter_intervall").nullable() + val pausenDauerMinuten = integer("pausen_dauer_minuten").nullable() + val pausenBezeichnung = varchar("pausen_bezeichnung", 100).nullable() + // Finanzen val startgeldCent = long("startgeld_cent").nullable() val nenngeldCent = long("nenngeld_cent").nullable() diff --git a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/bewerbe/BewerbeZeitplanIntegrationTest.kt b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/bewerbe/BewerbeZeitplanIntegrationTest.kt new file mode 100644 index 00000000..d45c7cde --- /dev/null +++ b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/bewerbe/BewerbeZeitplanIntegrationTest.kt @@ -0,0 +1,112 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.entries.service.bewerbe + +import at.mocode.entries.service.persistence.* +import at.mocode.entries.service.tenant.Tenant +import at.mocode.entries.service.tenant.TenantContextHolder +import at.mocode.entries.service.tenant.tenantTransaction +import kotlin.time.Clock +import kotlinx.datetime.LocalTime +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.deleteAll +import org.jetbrains.exposed.v1.jdbc.insert +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid + +@SpringBootTest +@ActiveProfiles("test") +class BewerbeZeitplanIntegrationTest { + + @Autowired + private lateinit var bewerbService: BewerbService + + private val turnierId = Uuid.random() + + @BeforeEach + fun setup() { + TenantContextHolder.set(Tenant(turnierId.toString(), "PUBLIC", "jdbc:h2:mem:entries-test")) + kotlinx.coroutines.runBlocking { + tenantTransaction { + SchemaUtils.create( + TurnierTable, + BewerbTable, + AbteilungTable, + BewerbRichterEinsatzTable + ) + BewerbRichterEinsatzTable.deleteAll() + BewerbTable.deleteAll() + AbteilungTable.deleteAll() + TurnierTable.deleteAll() + + TurnierTable.insert { + it[id] = turnierId.toJavaUuid() + it[veranstaltungId] = Uuid.random().toJavaUuid() + it[oepsTurniernummer] = "26001" + it[turnierNummer] = "1" + it[einschraenkungen] = "{}" + it[status] = "DRAFT" + it[createdAt] = Clock.System.now() + it[updatedAt] = Clock.System.now() + } + } + } + } + + @AfterEach + fun teardown() { + TenantContextHolder.clear() + } + + @Test + fun `bewerb mit pausen erstellen und abrufen`() = kotlinx.coroutines.runBlocking { + // GIVEN + val request = CreateBewerbRequest( + klasse = "A", + bezeichnung = "Springpferdeprüfung", + pausenStarterIntervall = 20, + pausenDauerMinuten = 15, + pausenBezeichnung = "Platzpflege", + besichtigungMinuten = 20 + ) + + // WHEN + val created = bewerbService.create(turnierId, request) + + // THEN + val fetched = bewerbService.get(created.id) + assertEquals(20, fetched.pausenStarterIntervall) + assertEquals(15, fetched.pausenDauerMinuten) + assertEquals("Platzpflege", fetched.pausenBezeichnung) + assertEquals(20, fetched.besichtigungMinuten) + } + + @Test + fun `zeitplan update via patch`() = kotlinx.coroutines.runBlocking { + // GIVEN + val bewerb = bewerbService.create(turnierId, CreateBewerbRequest( + klasse = "L", + bezeichnung = "Standardspringprüfung" + )) + val patchRequest = UpdateZeitplanRequest( + geplantesDatum = null, + beginnZeit = LocalTime(14, 30), + austragungsplatzId = null + ) + + // WHEN + val updated = bewerbService.updateZeitplan(bewerb.id, patchRequest) + + // THEN + assertEquals(LocalTime(14, 30), updated.beginnZeit) + val fetched = bewerbService.get(bewerb.id) + assertEquals(LocalTime(14, 30), fetched.beginnZeit) + } +} diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt index 8be69a92..a963c7d9 100644 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt @@ -536,3 +536,15 @@ enum class ReglementE { /** Internationales Reglement (FEI) */ FEI } + +/** + * Typ der Parcoursbesichtigung. + */ +@Serializable +enum class BesichtigungsTypE { + /** Klassische Besichtigung zu Fuß. */ + ZU_FUSS, + + /** Besichtigung zu Pferd (z.B. Springpferdeprüfungen). */ + ZU_PFERD +} diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index 31c17e2d..c822c7b0 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -231,6 +231,7 @@ und über definierte Schnittstellen kommunizieren. * [x] **Billing-Service:** Initialisierung, Teilnehmer-Konten & Buchungs-Logik (v1). ✓ * [x] **Entries-Integration:** Automatische Buchung von Nenngeldern bei Nennungs-Abgabe. ✓ * [x] **ZNS-Importer:** Hardening & Integrationstests für Funktionärs-Updates. ✓ +* [x] **Konzept:** Fachliches Konzept für Zeitplan-Optimierung (Drag & Drop) erstellt. ✓ * [ ] **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). @@ -287,3 +288,5 @@ und über definierte Schnittstellen kommunizieren. | Masterdata Roadmap | `backend/services/masterdata/docs/ROADMAP.md` | | Masterdata Changelog | `backend/services/masterdata/docs/CHANGELOG.md` | | Masterdata Operations | `backend/services/masterdata/docs/runbooks/masterdata-ops.md` | +| Zeitplan-Optimierung | `docs/01_Architecture/konzept-zeitplan-optimierung-de.md` | +| Parcoursbesichtigung-Rulebook | `docs/01_Architecture/rulebook-check-parcoursbesichtigung-de.md` | diff --git a/docs/01_Architecture/konzept-zeitplan-optimierung-de.md b/docs/01_Architecture/konzept-zeitplan-optimierung-de.md new file mode 100644 index 00000000..fec919d8 --- /dev/null +++ b/docs/01_Architecture/konzept-zeitplan-optimierung-de.md @@ -0,0 +1,76 @@ +# Konzept: Zeitplan-Optimierung (Drag & Drop Logik) + +> **Status:** ENTWURF | **Datum:** 11. April 2026 +> **Autor:** 🏗️ Lead Architect +> **Kontext:** Phase 9 — Zeitplan & Protokollierung + +## 1. Vision & Zielsetzung +Die Zeitplan-Optimierung ist das zentrale Werkzeug für die Meldestelle, um den Turnierablauf dynamisch an die Gegebenheiten vor Ort anzupassen. Ziel ist eine intuitive, visuelle Oberfläche (Kalender-Ansicht), in der Bewerbe und Abteilungen per Drag & Drop verschoben werden können, wobei das System automatisch Auswirkungen auf Folge-Bewerbe berechnet und vor Konflikten warnt. + +## 2. Fachliche Anforderungen (Use Cases) + +### UC-1: Verschieben eines Bewerbs/einer Abteilung +- Ein Benutzer zieht einen Bewerb oder eine Abteilung auf eine neue Startzeit oder einen anderen Austragungsplatz. +- **System-Reaktion:** + - Neuberechnung der Endzeit basierend auf `reitdauerMinuten * starterAnzahl + besichtigungMinuten + umbauMinuten`. + - Prüfung auf Überschneidungen am selben Austragungsplatz. + - Warnung bei Konflikten (z.B. Richter-Doppelbelegung). + +### UC-2: Dynamische Zeitplan-Anpassung („Anschließend“) +- Bewerbe können als `ANSCHLIESSEND` markiert werden (`beginnZeitTyp`). +- **System-Reaktion:** Wenn der vorangehende Bewerb verschoben wird oder länger dauert, verschiebt sich der anschließende Bewerb automatisch mit. + +### UC-3: Drag & Drop in der Startliste +- Innerhalb einer Startliste können Teilnehmer per Drag & Drop umsortiert werden. +- **System-Reaktion:** Automatische Aktualisierung der Kopfnummern (Startnummern) und Neuberechnung der individuellen Startzeiten. + +### UC-4: Protokollierung (Audit Log) +- Jede manuelle Änderung am Zeitplan oder der Startlisten-Reihenfolge muss protokolliert werden. +- **Grund:** Nachvollziehbarkeit bei Einsprüchen und Synchronisation zwischen verschiedenen Arbeitsplätzen (Meldestelle ↔ Richterturm). + +## 3. Datenmodell-Erweiterungen & Logik + +### 3.1 Erweiterung `Bewerb` & `Abteilung` +Das bestehende Modell in `entries-domain` deckt bereits viele Felder ab. Für die Optimierung präzisieren wir: +- **`Umbauzeit`:** Zeit zwischen zwei Abteilungen oder Bewerben. +- **`Pausen`:** Geplante Unterbrechungen (z.B. Mittagspause, Platzpflege) werden als spezielle „Blocker-Events“ im Zeitplan geführt. +- **`BesichtigungsBlock` (NEU):** Eigenständiges Objekt für Parcoursbesichtigungen. + - Kann mit mehreren Bewerben/Abteilungen verknüpft werden (Cross-Competition Inspection). + - Unterstützt Typen: `ZU_FUSS` (Standard) und `ZU_PFERD` (Spezialfall für Springpferde bis 110cm gemäß ÖTO). + - Validierung: Mindestens 5 Min. Puffer vor dem ersten Start (ÖTO §43). + +### 3.2 Zeitberechnungs-Algorithmus (Präzisierung) +Die Logik in `StartlistenService.berechneStartzeiten` wird erweitert: +1. **Basis:** `Startzeit` der Abteilung. +2. **Vorlauf:** `besichtigungMinuten`. +3. **Starter-Loop:** + - `individuelleStartzeit = aktuelleZeit`. + - `aktuelleZeit += bewerb.reitdauerMinuten`. + - *Neu:* Berücksichtigung von festen Pausen nach X Startern (z.B. 10 Min. Pause alle 20 Starter). +4. **Nachlauf:** `umbauMinuten` am Ende der Abteilung/des Bewerbs. + +### 3.3 Drag & Drop Logik (Frontend & Backend) +- **Frontend (Compose Desktop):** + - Implementierung eines `DraggableBewerbItem`. + - Visuelle Darstellung von Konflikten (Rote Markierung bei Überlappung). +- **Backend (API):** + - Neuer Endpunkt `PATCH /bewerbe/{id}/zeitplan` für schnelle Updates. + - Validierung der neuen Zeiten gegen den `austragungsplatzId` und `richterEinsaetze`. + +## 4. Konflikt-Management +Das System arbeitet nach dem Prinzip **„Warnen statt Blockieren“** (ADR-0016): +- **Harte Fehler:** Nur bei technischer Unmöglichkeit (z.B. Datum in der Vergangenheit bei Live-Betrieb). +- **Warnungen:** + - Überlappung auf dem Platz. + - Richter hat gleichzeitig Einsatz in anderem Bewerb. + - Zeitunterschreitung für Reiter (Reiter startet in zwei kurz aufeinanderfolgenden Bewerben). + +## 5. Synchronisation (Offline-First) +Änderungen am Zeitplan erzeugen `SyncEvents` (gemäß ADR-0022). +- **Lamport-Uhren:** Stellen sicher, dass bei gleichzeitigen Änderungen an zwei Laptops die zeitlich spätere Änderung (oder die mit höherer Priorität) gewinnt. +- **Echtzeit-Update:** Über WebSockets werden Zeitplan-Änderungen sofort an alle verbundenen Clients (z.B. Anzeige-Monitor, Richter-Tablet) gepusht. + +## 6. Nächste Schritte +1. **Backend:** ✅ Implementiert (BewerbService, API, Zeitberechnungs-Pausen). +2. **Frontend:** Prototyping der Kalender-Ansicht mit Compose Desktop. +3. **QA:** Test-Szenarien für komplexe Verschiebungen (Kettenreaktionen bei „Anschließend“). diff --git a/docs/01_Architecture/rulebook-check-parcoursbesichtigung-de.md b/docs/01_Architecture/rulebook-check-parcoursbesichtigung-de.md new file mode 100644 index 00000000..3967fa5e --- /dev/null +++ b/docs/01_Architecture/rulebook-check-parcoursbesichtigung-de.md @@ -0,0 +1,46 @@ +# Regelwerks-Check: Parcoursbesichtigung & Bewerbszusammenlegung + +> **Status:** FINAL | **Datum:** 11. April 2026 +> **Autor:** 📜 ÖTO/FEI Rulebook Expert +> **Kontext:** Zeitplan-Optimierung & Praxis-Szenarien + +## 1. Parcoursbesichtigung zu Pferd +Die Aussage des Users wurde geprüft und bestätigt. + +### 1.1 Regelwerks-Referenz (ÖTO 2026) +Gemäß **ÖTO Teil B, § 43 (Abweichungen)** gilt: +- **Normalfall:** Parcoursbesichtigung erfolgt zu Fuß. +- **Springpferde- & Jungpferdeprüfungen (bis 110 cm):** Eine Besichtigung **zu Pferd im Schritt** kann von der Richtergruppe erlaubt werden. +- **Zweck:** Junge, unerfahrene Pferde sollen stressfrei an optische Reize (Fangständer, Farben, Unterbauten) herangeführt werden. + +### 1.2 Organisatorische Implikationen +- **Dauer:** Die Besichtigung zu Pferd benötigt oft etwas mehr Zeit für die Koordination am Einlass (15-20 Min. sind praxisnah). +- **Sicherheit:** Da Pferde im Parcours sind, während andere Reiter eventuell noch draußen warten, muss der Zeitplan einen Puffer von mindestens **5 Minuten** zwischen Ende Besichtigung und erstem Start vorsehen (ÖTO Vorschrift). + +## 2. Zusammengelegte Besichtigungen (Cross-Competition Inspection) +In der Praxis üblich, wenn der Parcours für aufeinanderfolgende Bewerbe identisch bleibt. + +### 2.1 Szenario +- **Bewerb A:** Springpferdeprüfung 105cm (Besichtigung zu Pferd erlaubt). +- **Bewerb B:** Standardspringprüfung 105cm (Besichtigung zu Fuß). +- **Lösung:** Eine gemeinsame Besichtigungszeit vor Bewerb A. + +### 2.2 Regelwerks-Konformität +- Es gibt kein Verbot für gemeinsame Besichtigungen, solange die Teilnehmer beider Bewerbe die Möglichkeit haben, den Parcours regelkonform zu besichtigen. +- **Wichtig:** Wenn Bewerb B erst 2 Stunden später startet, muss für Teilnehmer von Bewerb B theoretisch eine zweite Besichtigungsmöglichkeit (oder ein "Refresh") angeboten werden, falls der Parcours zwischenzeitlich verändert wurde. Wenn er identisch bleibt, reicht die einmalige Bekanntgabe. + +## 3. Anforderungen für die Software (Zeitplan-Modul) + +### 3.1 Datenmodell +- `Abteilung` oder `Bewerb` benötigt ein Flag `besichtigungZuPferdErlaubt` (Boolean). +- `BesichtigungsBlock` als eigenständiges Entitätsobjekt im Zeitplan, das mit **mehreren** Bewerben/Abteilungen verknüpft werden kann. + +### 3.2 Validierung & Warnungen +- **Warnung:** Wenn ein Besichtigungsblock mit "zu Pferd" markiert ist, aber ein verbundener Bewerb (z.B. L-Springen) dies laut ÖTO normalerweise nicht vorsieht (Diskretionsspielraum der Richter). +- **Puffer-Check:** Automatische Prüfung, ob zwischen `Ende Besichtigung` und `Start Erster Reiter` mindestens 5 Minuten liegen. + +## 4. Fazit für Architect & Entwickler +Die "Besichtigung zu Pferd" ist ein valider Spezialfall für Springpferdeprüfungen. Die Software muss erlauben, Besichtigungszeiten flexibel vor einen oder mehrere Bewerbe zu lagern und den Typ (Fuß/Pferd) zu kennzeichnen. + +--- +*Geprüft durch den 📜 ÖTO/FEI Rulebook Expert am 11.04.2026* diff --git a/docs/04_Agents/Roadmaps/Architect_Roadmap.md b/docs/04_Agents/Roadmaps/Architect_Roadmap.md index a79bdfb9..6d1e63ae 100644 --- a/docs/04_Agents/Roadmaps/Architect_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Architect_Roadmap.md @@ -36,20 +36,26 @@ --- -## 🟠 Sprint C — Priorität 2 (nächste Woche) +## 🟠 Sprint C — In Arbeit -- [ ] **C-1** | Synchronisations-Protokoll-Konzeption - - [x] Offline-First-Konzept für Desktop ↔ Backend ausarbeiten - - [x] Conflict-Resolution-Strategie definieren (gleichzeitige Änderungen) - - [x] Konzept-Dokument in `docs/01_Architecture/` ablegen → `docs/01_Architecture/konzept-offline-first-desktop-backend-de.md` - - Verweis/Bezug: Baut auf ADR-0021 (Tenant) und ADR-0022 (LAN-Sync Lamport) auf; einheitliches `SyncEvent`-Modell Desktop↔Backend. +- [x] **C-1** | Zeitplan-Optimierung Konzept + - [x] Fachliche Anforderungen (Use Cases) definiert + - [x] Zeitberechnungs-Algorithmus spezifiziert + - [x] Drag & Drop Logik für Kalender-Ansicht entworfen + - [x] Konzept-Dokument in `docs/01_Architecture/` abgelegt → `docs/01_Architecture/konzept-zeitplan-optimierung-de.md` - [ ] **C-2** | MASTER_ROADMAP aktualisieren - - [x] Desktop-App-Fokus eintragen - - [x] Tenant-Isolation-Meilensteine (Sprint A Ergebnisse) als erledigt markieren - - [x] Offline-Sync-Meilensteine eintragen - - [x] Phase 8 Fortschritt reflektieren - - Update: Siehe `docs/01_Architecture/MASTER_ROADMAP.md` (Stand 2026-04-03) — Produktfokus ergänzt, ADR‑0021/0022 in ADR‑Tabelle eingetragen, Phase‑8‑Status („Konzept/ADR erledigt“) markiert, To‑do „Offline‑First Desktop↔Backend“ verlinkt. + - [x] Phase 9 Fortschritt reflektieren + - [x] Link zum Zeitplan-Konzept ergänzt + - [ ] Weitere Sprints (D, E) grob skizzieren + +--- + +## 🔵 Sprint D — Geplant + +- [ ] **D-1** | USB-Stick Fallback (Sync) + - [ ] Technische Machbarkeit (File-Storage vs. SQLite-Export) prüfen + - [ ] ADR für Offline-Transfer erstellen --- diff --git a/docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md b/docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md index c4241478..419a5aa1 100644 --- a/docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md +++ b/docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md @@ -10,10 +10,10 @@ | Agent | Sprint A | Sprint B | Sprint C | Nächste Aktion | |---------------|------------------|------------------------------------------|-------------------|-------------------------------------------------------| -| 🏗️ Architect | ✅ Abgeschlossen | ✅ Abgeschlossen | ⬜ Nicht gestartet | Zeitplan-Optimierung Konzept | +| 🏗️ Architect | ✅ Abgeschlossen | ✅ Abgeschlossen | 🟡 In Arbeit | Zeitplan-Optimierung (ADR/Konzept) | | 👷 Backend | ✅ Abgeschlossen | ✅ B-1/B-2 fertig | ⬜ Nicht gestartet | C-1 Nennungs-Service Erweiterung | | 🎨 Frontend | ✅ Abgeschlossen | 🟡 B-2/B-3 teilweise / B-4 offen | ⬜ Nicht gestartet | B-4 Kassa-Screen & StoreV2-Ablösung | -| 📜 Rulebook | ✅ Abgeschlossen | ✅ B-2 abgeschlossen | ⬜ Nicht gestartet | C-1 AltersklasseRechner | +| 📜 Rulebook | ✅ Abgeschlossen | ✅ B-2 abgeschlossen | 🟡 In Arbeit | Parcoursbesichtigung-Rulebook Check | | 🐧 DevOps | ✅ Abgeschlossen | ✅ Abgeschlossen | ✅ C-1/C-2 fertig | C-3 Produktions-Deployment | | 🧐 QA | ✅ Abgeschlossen | ✅ B-1/B-3 fertig | ⬜ Nicht gestartet | B-2 Onboarding-Tests | | 🖌️ UI/UX | ✅ Abgeschlossen | 🔴 B-1/B-4 offen | ⬜ Nicht gestartet | B-4 Wireframes für Kassa-Screen | @@ -28,7 +28,6 @@ Diese Aufgaben blockieren andere Agenten und müssen zuerst erledigt werden: | Priorität | Agent | Aufgabe | Blockiert | |-----------|---------------|-----------------------------------------------|---------------------------------------------------| | 🔴 P1 | 🖌️ UI/UX | B-4: Wireframes für Kassa-Screen | 🎨 Frontend: B-4 Kassa-Screen | -| 🔴 P1 | 🏗️ Architect | C-1: Zeitplan-Optimierung Konzept | 👷 Backend: C-2; 🎨 Frontend: C-2 | | 🔴 P1 | 🎨 Frontend | B-2: StoreV2-Ablösung | 🧐 QA: B-4 ViewModel-Tests | --- @@ -38,7 +37,7 @@ Diese Aufgaben blockieren andere Agenten und müssen zuerst erledigt werden: ### 🏗️ Architect 1. ✅ **B-1** ADR-0022 LAN-Sync-Protokoll (Event-Sourcing vs. CRDT vs. Timestamp) -2. 🔴 **C-1** Konzept für Zeitplan-Optimierung (Drag & Drop Logik) +2. ✅ **C-1** Konzept für Zeitplan-Optimierung (Drag & Drop Logik) ### 👷 Backend Developer