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:
parent
bc46054412
commit
3515d40fcb
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,8 +39,10 @@ class BewerbeZeitplanIntegrationTest {
|
|||
TurnierTable,
|
||||
BewerbTable,
|
||||
AbteilungTable,
|
||||
BewerbRichterEinsatzTable
|
||||
BewerbRichterEinsatzTable,
|
||||
AuditLogTable
|
||||
)
|
||||
AuditLogTable.deleteAll()
|
||||
BewerbRichterEinsatzTable.deleteAll()
|
||||
BewerbTable.deleteAll()
|
||||
AbteilungTable.deleteAll()
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user