Add audit logging for Zeitplan updates, implement conflict validation for overlapping schedules and judge assignments, and enhance frontend with detailed warning visualizations in Zeitplan tab.

This commit is contained in:
Stefan Mogeritsch 2026-04-11 21:00:14 +02:00
parent bc46054412
commit 3515d40fcb
11 changed files with 176 additions and 9 deletions

View File

@ -44,7 +44,11 @@ enum class AbteilungsWarnungCodeE {
/** Vorgeschriebene Abteilungs-Struktur nicht vorhanden */
WARN_STRUKTURELLE_TEILUNG_FEHLT,
/** Abteilungs-Struktur vorhanden, aber Teilnehmerkreis falsch/unvollständig */
WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG
WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG,
/** Mehrere Bewerbe zur gleichen Zeit am gleichen Platz */
WARN_ZEITPLAN_PLATZ_KONFLIKT,
/** Richter hat zeitgleiche Einsätze in verschiedenen Bewerben */
WARN_ZEITPLAN_RICHTER_KONFLIKT
}
/**

View File

@ -3,7 +3,11 @@
package at.mocode.entries.domain.service
import at.mocode.entries.domain.model.AbteilungsWarnung
import at.mocode.entries.domain.model.AbteilungsWarnungCodeE
import at.mocode.entries.domain.model.Bewerb
import at.mocode.entries.domain.repository.CompetitionRepository
import kotlinx.datetime.toJavaLocalTime
import java.time.LocalTime
import kotlin.uuid.Uuid
/**
@ -46,9 +50,80 @@ class CompetitionWarningService(
}
}
// 4. Zeitplan-Konflikte (Turnier-weit)
result.putAll(validateZeitplanKonflikte(bewerbe))
return result
}
private fun validateZeitplanKonflikte(bewerbe: List<Bewerb>): Map<Uuid, List<AbteilungsWarnung>> {
val conflicts = mutableMapOf<Uuid, List<AbteilungsWarnung>>()
// Nur Bewerbe mit Zeitangabe prüfen
val zeitplanBewerbe = bewerbe.filter { it.geplantesDatum != null && it.beginnZeit != null }
for (b1 in zeitplanBewerbe) {
val warnings = mutableListOf<AbteilungsWarnung>()
val start1 = b1.beginnZeit!!
val ende1 = berechneEnde(b1)
for (b2 in zeitplanBewerbe) {
if (b1.bewerbId == b2.bewerbId) continue
if (b1.geplantesDatum != b2.geplantesDatum) continue
val start2 = b2.beginnZeit!!
val ende2 = berechneEnde(b2)
// Überlappungs-Check
if (isOverlapping(start1, ende1, start2, ende2)) {
// Platz-Konflikt
if (b1.austragungsplatzId != null && b1.austragungsplatzId == b2.austragungsplatzId) {
warnings.add(AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_ZEITPLAN_PLATZ_KONFLIKT,
bewerbId = b1.bewerbId,
nachricht = "Zeitliche Überschneidung auf dem gleichen Platz mit Bewerb ${b2.bewerbNummer}",
oetoParagraph = "Allgemeine Zeitplanung"
))
}
// Richter-Konflikt
val gemeinsameRichter = b1.richterEinsaetze.map { it.funktionaerId }
.intersect(b2.richterEinsaetze.map { it.funktionaerId }.toSet())
if (gemeinsameRichter.isNotEmpty()) {
warnings.add(AbteilungsWarnung(
code = AbteilungsWarnungCodeE.WARN_ZEITPLAN_RICHTER_KONFLIKT,
bewerbId = b1.bewerbId,
nachricht = "Richter-Doppelbelegung mit Bewerb ${b2.bewerbNummer}",
oetoParagraph = "Richter-Einteilung"
))
}
}
}
if (warnings.isNotEmpty()) {
conflicts[b1.bewerbId] = (conflicts[b1.bewerbId] ?: emptyList()) + warnings
}
}
return conflicts
}
private fun berechneEnde(b: Bewerb): kotlinx.datetime.LocalTime {
val start = b.beginnZeit!!
val dauer = b.reitdauerMinuten ?: 0
val umbau = b.umbauMinuten ?: 0
val besichtigung = b.besichtigungMinuten ?: 0
val gesamtMinuten = start.hour * 60 + start.minute + dauer + umbau + besichtigung
val endHour = (gesamtMinuten / 60) % 24
val endMin = gesamtMinuten % 60
return kotlinx.datetime.LocalTime(endHour, endMin)
}
private fun isOverlapping(s1: kotlinx.datetime.LocalTime, e1: kotlinx.datetime.LocalTime,
s2: kotlinx.datetime.LocalTime, e2: kotlinx.datetime.LocalTime): Boolean {
return s1 < e2 && s2 < e1
}
/**
* Validiert einen einzelnen Bewerb und gibt Warnungen zurück.
*/

View File

@ -6,7 +6,9 @@ import at.mocode.entries.domain.model.RichterEinsatz
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.domain.service.CompetitionWarningService
import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.persistence.AuditLogTable
import at.mocode.entries.service.persistence.TurnierTable
import org.jetbrains.exposed.v1.jdbc.insert
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
@ -162,6 +164,18 @@ class BewerbService(
beginnZeit = req.beginnZeit,
austragungsplatzId = req.austragungsplatzId?.let { Uuid.parse(it) }
)
tenantTransaction {
// Audit-Log schreiben
AuditLogTable.insert {
it[entityType] = "BEWERB"
it[entityId] = id.toJavaUuid()
it[action] = "UPDATE_ZEITPLAN"
it[timestamp] = kotlin.time.Clock.System.now()
it[changesJson] = "{\"old\": {\"datum\": \"${current.geplantesDatum}\", \"zeit\": \"${current.beginnZeit}\", \"platz\": \"${current.austragungsplatzId}\"}, \"new\": {\"datum\": \"${updated.geplantesDatum}\", \"zeit\": \"${updated.beginnZeit}\", \"platz\": \"${updated.austragungsplatzId}\"}}"
}
}
return repo.update(updated)
}

View File

@ -261,7 +261,9 @@ class BewerbeController(
@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)
val uuid = Uuid.parse(id)
val b = service.updateZeitplan(uuid, body)
val warnungen = service.validateBewerb(uuid)
return domainToDto(b, warnungen)
}
}

View File

@ -0,0 +1,23 @@
package at.mocode.entries.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
import kotlin.time.Instant
object AuditLogTable : Table("audit_log") {
val id = javaUUID("id").autoGenerate()
val entityType = varchar("entity_type", 50)
val entityId = javaUUID("entity_id")
val action = varchar("action", 50)
val userId = javaUUID("user_id").nullable()
val timestamp = timestamp("timestamp")
val changesJson = text("changes_json").nullable()
override val primaryKey = PrimaryKey(id)
init {
index(false, entityId)
index(false, timestamp)
}
}

View File

@ -39,8 +39,10 @@ class BewerbeZeitplanIntegrationTest {
TurnierTable,
BewerbTable,
AbteilungTable,
BewerbRichterEinsatzTable
BewerbRichterEinsatzTable,
AuditLogTable
)
AuditLogTable.deleteAll()
BewerbRichterEinsatzTable.deleteAll()
BewerbTable.deleteAll()
AbteilungTable.deleteAll()

View File

@ -0,0 +1,29 @@
# 🧹 Curator Log - 2026-04-11 (Spätschicht)
## 📅 Session Info
- **Datum:** 2026-04-11
- **Agenten:** 🏗️ Lead Architect, 👷 Backend Developer, 🎨 Frontend Expert, 🧹 Curator, 📜 Rulebook Expert
- **Fokus:** Zeitplan-Konfliktprüfung & Audit-Log
## 🏗️ Architektur-Entscheidungen
- **Audit-Log (UC-4):** Einführung einer zentralen `audit_log` Tabelle im `entries-service`. Zeitplan-Änderungen werden nun mit Vorher-Nachher-Vergleich (JSON) und Zeitstempel protokolliert.
- **Konfliktprüfung:** Erweiterung des `CompetitionWarningService` im Domain-Layer um Turnier-weite Prüfungen.
- **Datenfluss:** Warnungen werden nun dynamisch bei jeder Zeitplan-Änderung vom Backend neu berechnet und im Frontend-DTO mitgeliefert.
## 👷 Backend/Integration
- **Audit-Log:** Implementierung in `BewerbService.updateZeitplan`. Protokollierung erfolgt transaktional via `tenantTransaction`.
- **Warn-Logik:** Neue Regeln für Platz-Überlappung und Richter-Doppelbelegung (UC-3) implementiert.
- **Typen:** Umstellung auf `kotlin.time` für Konsistenz mit dem restlichen System (Behebung von Deprecation-Issues).
## 🎨 Frontend (Details)
- **UI-Anpassung:** `TurnierZeitplanTab.kt` zeigt nun spezifische Fehlermeldungen (z.B. "Richter-Doppelbelegung mit Bewerb 5") direkt am Bewerbs-Block an.
- **Mapping:** Mapper und DTOs wurden um das Feld `warnungen` erweitert, um die detaillierten Informationen vom Backend zu visualisieren.
## 🧹 Curator Status & Cleanup
- ✅ Audit-Log Tabelle und Repository-Integration abgeschlossen.
- ✅ Zeitplan-Konfliktregeln (Platz & Richter) im Domain-Service aktiv.
- ✅ Frontend-Visualisierung der spezifischen Warnungen implementiert.
- 📂 Nächster Schritt: Implementierung der automatischen Startnummern-Generierung basierend auf der Zeitplan-Reihenfolge (Phase 11).
---
*Erstellt durch den Curator.*

View File

@ -1,9 +1,11 @@
package at.mocode.turnier.feature.data.mapper
import at.mocode.turnier.feature.data.remote.dto.AbteilungDto
import at.mocode.turnier.feature.data.remote.dto.AbteilungsWarnungDto
import at.mocode.turnier.feature.data.remote.dto.BewerbDto
import at.mocode.turnier.feature.data.remote.dto.TurnierDto
import at.mocode.turnier.feature.domain.Abteilung
import at.mocode.turnier.feature.domain.AbteilungsWarnung
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.Turnier
@ -25,6 +27,7 @@ fun BewerbDto.toDomain(): Bewerb = Bewerb(
umbauMinuten = umbauMinuten,
besichtigungMinuten = besichtigungMinuten,
austragungsplatzId = austragungsplatzId,
warnungen = warnungen.map { AbteilungsWarnung(it.code, it.nachricht, it.oetoParagraph) }
)
fun Bewerb.toDto(): BewerbDto = BewerbDto(
@ -42,6 +45,7 @@ fun Bewerb.toDto(): BewerbDto = BewerbDto(
umbauMinuten = umbauMinuten,
besichtigungMinuten = besichtigungMinuten,
austragungsplatzId = austragungsplatzId,
warnungen = warnungen.map { AbteilungsWarnungDto(it.code, it.nachricht, it.oetoParagraph) }
)
fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name)

View File

@ -24,6 +24,14 @@ data class BewerbDto(
val umbauMinuten: Int? = null,
val besichtigungMinuten: Int? = null,
val austragungsplatzId: String? = null,
val warnungen: List<AbteilungsWarnungDto> = emptyList(),
)
@Serializable
data class AbteilungsWarnungDto(
val code: String,
val nachricht: String,
val oetoParagraph: String? = null,
)
@Serializable

View File

@ -1,6 +1,5 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@ -11,7 +10,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf

View File

@ -61,7 +61,8 @@ fun ZeitplanTabContent(
"SPRINGEN" -> Color(0xFF059669)
else -> ZeitplanBlue
},
hasConflict = bewerb.warnungen.isNotEmpty()
hasConflict = bewerb.warnungen.isNotEmpty(),
conflictMessage = bewerb.warnungen.joinToString("\n") { it.nachricht }
)
}
@ -246,7 +247,13 @@ private fun DraggableBewerbBox(
) {
Text("⚠️", fontSize = 12.sp)
Spacer(Modifier.width(4.dp))
Text("Konflikt", fontSize = 10.sp, color = Color.Red, fontWeight = FontWeight.Bold)
Text(
text = item.conflictMessage.ifEmpty { "Konflikt" },
fontSize = 10.sp,
color = Color.Red,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
@ -260,7 +267,8 @@ data class ZeitplanItemUi(
val startMinutes: Int, // Minuten seit 00:00
val durationMinutes: Int,
val color: Color = ZeitplanBlue,
val hasConflict: Boolean = false
val hasConflict: Boolean = false,
val conflictMessage: String = ""
) {
val timeString: String
get() {