feat(entries+time-scheduling): add support for automatic breaks and inspection type configurations

- **Domain Enhancements:**
  - Introduced `PausenKonfiguration` and `BesichtigungsBlock` entities to handle automatic breaks and inspection scheduling.
  - Added `BesichtigungsTypE` enum for inspection types (`ZU_FUSS`, `ZU_PFERD`).
  - Updated `Bewerb` and `Abteilung` models to include pause and inspection type fields.

- **Service Updates:**
  - Enhanced `StartlistenService` to calculate start times, accounting for breaks and inspection buffers.
  - Extended `BewerbService` to support patchable time scheduling via new `updateZeitplan` API.

- **Persistence Changes:**
  - Updated tables (`BewerbTable`, `AbteilungTable`) to persist break configurations and inspection types.
  - Implemented repository mappings to include these new fields.

- **Testing:**
  - Introduced `BewerbeZeitplanIntegrationTest` to validate new scheduling behaviors, including automatic pauses and inspection handling.

- **Documentation:**
  - Added rulebook and conceptual documents for inspection and scheduling logic in `docs/01_Architecture/`.
This commit is contained in:
2026-04-11 12:21:37 +02:00
parent 97ed8ad20a
commit 0aa1a1b9b7
19 changed files with 423 additions and 20 deletions
@@ -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,
@@ -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
)
@@ -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
)
@@ -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
@@ -10,6 +10,7 @@ data class Abteilung(
val nr: Int,
val bezeichnung: String,
val typ: String,
val besichtigungsTyp: String? = null,
)
interface AbteilungRepository {
@@ -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()
@@ -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,
@@ -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
@@ -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")
@@ -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<AbteilungsWarnungDto> = 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<AbteilungsWarnung> = 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)
}
}
@@ -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")
@@ -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()
@@ -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)
}
}