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:
2026-04-11 21:00:14 +02:00
parent bc46054412
commit 3515d40fcb
11 changed files with 176 additions and 9 deletions
@@ -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()