Integrate qualification master data (QualifikationMasterTable) with functionary models, update schema and repository logic, refactor satzID references, and harmonize database migration (V010).

This commit is contained in:
2026-04-06 13:59:14 +02:00
parent c35869f8ee
commit 9237882437
7 changed files with 64 additions and 21 deletions
+2
View File
@@ -59,6 +59,8 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
## [1.0.1-SNAPSHOT] — 2026-04-05 ## [1.0.1-SNAPSHOT] — 2026-04-05
### Geändert ### Geändert
- **Masterdata:** Funktionär-Datenmodell und API bereinigt und vollständig dokumentiert. Konsistente Verwendung von `satzId` (statt `satzID`) in allen Schichten (Domain, Infrastructure, API).
- **Refactoring:** `DomVerein` zu `Verein`, `DomReiter` zu `Reiter`, `DomPferd` zu `Pferd` und `DomFunktionaer` zu `Funktionaer` umbenannt (Domain, Infrastructure, API, Core). - **Refactoring:** `DomVerein` zu `Verein`, `DomReiter` zu `Reiter`, `DomPferd` zu `Pferd` und `DomFunktionaer` zu `Funktionaer` umbenannt (Domain, Infrastructure, API, Core).
- **Domain:** `personId` ist nun optional (`nullable`) bei `Verein`, `Reiter`, `Pferd` und `Funktionaer`, um ZNS-Initialimporte zu unterstützen. - **Domain:** `personId` ist nun optional (`nullable`) bei `Verein`, `Reiter`, `Pferd` und `Funktionaer`, um ZNS-Initialimporte zu unterstützen.
- **Infrastructure:** `VereinTable`, `ReiterTable`, `HorseTable` und `FunktionaerTable` synchronisiert; `personId` ist nun optional. - **Infrastructure:** `VereinTable`, `ReiterTable`, `HorseTable` und `FunktionaerTable` synchronisiert; `personId` ist nun optional.
@@ -19,9 +19,12 @@ import kotlin.uuid.Uuid
*/ */
class FunktionaerController(private val funktionaerRepository: FunktionaerRepository) { class FunktionaerController(private val funktionaerRepository: FunktionaerRepository) {
/**
* DTO für die API-Response eines Funktionärs.
*/
data class FunktionaerDto( data class FunktionaerDto(
val funktionaerId: String, val funktionaerId: String,
val satzID: String, val satzId: String,
val satzNummer: Int, val satzNummer: Int,
val name: String? = null, val name: String? = null,
val qualifikationen: List<String> = emptyList(), val qualifikationen: List<String> = emptyList(),
@@ -31,9 +34,12 @@ class FunktionaerController(private val funktionaerRepository: FunktionaerReposi
val updatedAt: Instant val updatedAt: Instant
) )
/**
* Request-Body zum Anlegen eines neuen Funktionärs.
*/
@Serializable @Serializable
data class FunktionaerCreateRequest( data class FunktionaerCreateRequest(
val satzID: String, val satzId: String,
val satzNummer: Int, val satzNummer: Int,
val name: String? = null, val name: String? = null,
val qualifikationen: List<String> = emptyList(), val qualifikationen: List<String> = emptyList(),
@@ -82,12 +88,12 @@ class FunktionaerController(private val funktionaerRepository: FunktionaerReposi
} }
/** /**
* GET /funktionaer/satz/{satzID}/{satzNummer} — Sucht einen Funktionär nach Satz-ID und Nummer. * GET /funktionaer/satz/{satzId}/{satzNummer} — Sucht einen Funktionär nach Satz-ID und Nummer.
*/ */
get("/satz/{satzID}/{satzNummer}") { get("/satz/{satzId}/{satzNummer}") {
val satzID = call.parameters["satzID"] ?: return@get call.respond(HttpStatusCode.BadRequest) val satzId = call.parameters["satzId"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val satzNummer = call.parameters["satzNummer"]?.toIntOrNull() ?: return@get call.respond(HttpStatusCode.BadRequest) val satzNummer = call.parameters["satzNummer"]?.toIntOrNull() ?: return@get call.respond(HttpStatusCode.BadRequest)
val funktionaer = funktionaerRepository.findBySatz(satzID, satzNummer) val funktionaer = funktionaerRepository.findBySatz(satzId, satzNummer)
if (funktionaer != null) call.respond(funktionaer.toDto()) else call.respond(HttpStatusCode.NotFound) if (funktionaer != null) call.respond(funktionaer.toDto()) else call.respond(HttpStatusCode.NotFound)
} }
@@ -97,7 +103,7 @@ class FunktionaerController(private val funktionaerRepository: FunktionaerReposi
post { post {
val req = call.receive<FunktionaerCreateRequest>() val req = call.receive<FunktionaerCreateRequest>()
val domFunktionaer = Funktionaer( val domFunktionaer = Funktionaer(
satzId = req.satzID, satzId = req.satzId,
satzNummer = req.satzNummer, satzNummer = req.satzNummer,
name = req.name, name = req.name,
qualifikationen = req.qualifikationen, qualifikationen = req.qualifikationen,
@@ -141,7 +147,7 @@ class FunktionaerController(private val funktionaerRepository: FunktionaerReposi
private fun Funktionaer.toDto() = FunktionaerDto( private fun Funktionaer.toDto() = FunktionaerDto(
funktionaerId = funktionaerId.toString(), funktionaerId = funktionaerId.toString(),
satzID = satzId, satzId = satzId,
satzNummer = satzNummer, satzNummer = satzNummer,
name = name, name = name,
qualifikationen = qualifikationen, qualifikationen = qualifikationen,
@@ -15,13 +15,14 @@ import kotlin.uuid.Uuid
* *
* Repräsentiert eine Person mit einer definierten Rolle bei Turnieren (Richter, TBA, * Repräsentiert eine Person mit einer definierten Rolle bei Turnieren (Richter, TBA,
* Parcoursbauer etc.). Die Qualifikation wird gegen `RICHT01.DAT` oder `PARCO01.DAT` * Parcoursbauer etc.). Die Qualifikation wird gegen `RICHT01.DAT` oder `PARCO01.DAT`
* aus dem ZNS geprüft. * aus dem ZNS geprüft und gegen die `QualifikationMasterTable` validiert.
* *
* @property funktionaerId Eindeutige interne ID (UUID). * @property funktionaerId Eindeutige interne ID (UUID).
* @property personId Optionale Verknüpfung zu einer Basis-Person (actor-context).
* @property satzId Typ des Satzes (X = Richter, Y = Parcoursbauer). Aus ZNS (RICHT01.DAT / PARCO01.DAT). * @property satzId Typ des Satzes (X = Richter, Y = Parcoursbauer). Aus ZNS (RICHT01.DAT / PARCO01.DAT).
* @property satzNummer Satznummer (6-stellig). Aus ZNS (RICHT01.DAT / PARCO01.DAT). * @property satzNummer Satznummer (6-stellig). Aus ZNS (RICHT01.DAT / PARCO01.DAT).
* @property name Vollständiger Name (Nachname, Vorname). Aus ZNS (RICHT01.DAT / PARCO01.DAT). * @property name Vollständiger Name (Nachname, Vorname). Aus ZNS (RICHT01.DAT / PARCO01.DAT).
* @property qualifikation Qualifikationen (getrennt durch `,`). Aus ZNS (RICHT01.DAT / PARCO01.DAT). * @property qualifikationen Liste der Qualifikations-Kürzel (z.B. "D", "S", "P1"). Wird in Join-Tabelle persistiert.
* @property istAktiv Ob der Funktionär aktuell aktiv/einsatzbereit ist. * @property istAktiv Ob der Funktionär aktuell aktiv/einsatzbereit ist.
* @property bemerkungen Interne Notizen. * @property bemerkungen Interne Notizen.
* @property datenQuelle Herkunft des Datensatzes (ZNS-Import oder manuell). * @property datenQuelle Herkunft des Datensatzes (ZNS-Import oder manuell).
@@ -5,14 +5,23 @@ import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.utils.database.DatabaseFactory import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.domain.model.Funktionaer import at.mocode.masterdata.domain.model.Funktionaer
import at.mocode.masterdata.domain.repository.FunktionaerRepository import at.mocode.masterdata.domain.repository.FunktionaerRepository
import at.mocode.masterdata.infrastructure.persistence.funktionaer.* import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.* import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
/** /**
* Exposed-basierte Implementierung des Funktionaer-Repositorys. * Exposed-basierte Implementierung des Funktionaer-Repositorys.
*
* Verwaltet die Persistenz von Funktionären und deren Qualifikationen.
* Die Qualifikationen werden beim Speichern gegen die [QualifikationMasterTable]
* aufgelöst, um Datenintegrität bezüglich offizieller ÖTO-Kürzel sicherzustellen.
*/ */
class FunktionaerExposedRepository : FunktionaerRepository { class FunktionaerExposedRepository : FunktionaerRepository {
private val log = LoggerFactory.getLogger(FunktionaerExposedRepository::class.java) private val log = LoggerFactory.getLogger(FunktionaerExposedRepository::class.java)
@@ -21,7 +30,7 @@ class FunktionaerExposedRepository : FunktionaerRepository {
return Funktionaer( return Funktionaer(
funktionaerId = row[FunktionaerTable.id], funktionaerId = row[FunktionaerTable.id],
personId = row[FunktionaerTable.personId], personId = row[FunktionaerTable.personId],
satzId = row[FunktionaerTable.satzId] ?: "X", satzId = row[FunktionaerTable.satzId],
satzNummer = row[FunktionaerTable.satzNummer] ?: 0, satzNummer = row[FunktionaerTable.satzNummer] ?: 0,
name = row[FunktionaerTable.name], name = row[FunktionaerTable.name],
qualifikationen = qualifikationen, qualifikationen = qualifikationen,
@@ -99,6 +108,8 @@ class FunktionaerExposedRepository : FunktionaerRepository {
} }
// Qualifikationen synchronisieren (über Master-Daten Auflösung) // Qualifikationen synchronisieren (über Master-Daten Auflösung)
// Wir löschen bestehende Zuordnungen und bauen sie basierend auf den Master-Daten neu auf.
// Unbekannte Kürzel werden nicht persistiert, sondern geloggt.
FunktionaerQualifikationTable.deleteWhere { funktionaerId eq funktionaer.funktionaerId } FunktionaerQualifikationTable.deleteWhere { funktionaerId eq funktionaer.funktionaerId }
val typ = if (funktionaer.istRichter()) "RICHTER" else "PARCOURSBAUER" val typ = if (funktionaer.istRichter()) "RICHTER" else "PARCOURSBAUER"
@@ -10,15 +10,21 @@ import kotlin.uuid.ExperimentalUuidApi
/** /**
* Exposed-Tabellendefinition für die Funktionär-Entität. * Exposed-Tabellendefinition für die Funktionär-Entität.
* Speichert Basisdaten und Kontaktinformationen.
*/ */
object FunktionaerTable : Table("funktionaer") { object FunktionaerTable : Table("funktionaer") {
val id = uuid("funktionaer_id") val id = uuid("funktionaer_id")
val personId = uuid("person_id").nullable() val personId = uuid("person_id").nullable()
// === ZNS.zip RICHT01.DAT === ANFANG === // === ZNS.zip RICHT01.DAT (Zentrales Nennungssystem) === ANFANG ===
/** Typ des Satzes: "X" = Richter, "Y" = Parcoursbauer */
val satzId = varchar("satz_id", 1) val satzId = varchar("satz_id", 1)
/** 6-stellige Satznummer (eindeutig pro Typ) */
val satzNummer = integer("satz_nummer").nullable() val satzNummer = integer("satz_nummer").nullable()
/** Vollständiger Name (Nachname, Vorname) */
val name = varchar("name", 200).nullable() val name = varchar("name", 200).nullable()
// === ZNS.zip RICHT01.DAT === ENDE === // === ZNS.zip RICHT01.DAT === ENDE ===
@@ -54,12 +60,19 @@ object FunktionaerTable : Table("funktionaer") {
/** /**
* Exposed-Tabellendefinition für die Qualifikation-Master-Daten. * Exposed-Tabellendefinition für die Qualifikation-Master-Daten.
* Enthält offizielle ÖTO/FEI Kürzel (z.B. "D", "S", "P1").
*/ */
object QualifikationMasterTable : Table("qualifikation_master") { object QualifikationMasterTable : Table("qualifikation_master") {
val id = uuid("qualifikation_id") val id = uuid("qualifikation_id")
val code = varchar("code", 10) // z.B. "D", "S", "SPF", "P1"
val bezeichnung = varchar("bezeichnung", 100) // z.B. "Dressur", "Springpferde" /** Offizielles Kürzel (z.B. "SPF" für Springpferde) */
val typ = varchar("typ", 20) // "RICHTER" oder "PARCOURSBAUER" val code = varchar("code", 10)
/** Fachlich ausgeschriebene Bezeichnung */
val bezeichnung = varchar("bezeichnung", 100)
/** Bereich der Qualifikation: "RICHTER" oder "PARCOURSBAUER" */
val typ = varchar("typ", 20)
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
@@ -74,11 +74,20 @@ ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS plz VARCHAR(10);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS ort VARCHAR(100); ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS ort VARCHAR(100);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS bundesland VARCHAR(100); ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS bundesland VARCHAR(100);
-- 5. Qualifikations-Tabelle für Funktionäre -- 5. Qualifikations-Master-Tabelle und Join-Tabelle für Funktionäre
CREATE TABLE IF NOT EXISTS qualifikation_master (
qualifikation_id UUID NOT NULL,
code VARCHAR(10) NOT NULL,
bezeichnung VARCHAR(100) NOT NULL,
typ VARCHAR(20) NOT NULL,
PRIMARY KEY (qualifikation_id),
CONSTRAINT idx_qualifikation_code_typ UNIQUE (code, typ)
);
CREATE TABLE IF NOT EXISTS funktionaer_qualifikation ( CREATE TABLE IF NOT EXISTS funktionaer_qualifikation (
funktionaer_id UUID NOT NULL REFERENCES funktionaer(funktionaer_id), funktionaer_id UUID NOT NULL REFERENCES funktionaer(funktionaer_id),
qualifikation VARCHAR(20) NOT NULL, qualifikation_id UUID NOT NULL REFERENCES qualifikation_master(qualifikation_id),
PRIMARY KEY (funktionaer_id, qualifikation) PRIMARY KEY (funktionaer_id, qualifikation_id)
); );
-- Indizes (Exposed-Style) -- Indizes (Exposed-Style)
+1
View File
@@ -106,6 +106,7 @@ und über definierte Schnittstellen kommunizieren.
#### 🧐 Agent: QA Specialist #### 🧐 Agent: QA Specialist
* [x] **Actor Context Stabilization:** Funktionär-Datenmodell (Richter/Parcoursbauer) auf professionelle Master-Daten-Referenzierung umgestellt. Qualifikations-Kürzel (ÖTO/FEI) werden nun zentral validiert und über einen Seeder befüllt. SQL-Schema (V010) harmonisiert.
* [x] **Service Stability:** Port-Konflikt des `masterdata-service` (Spring Management Port 8081 vs. Gateway) durch Umzug auf Port 8086 und explizite Bind-Adressen (Spring: 127.0.0.1, Ktor: 0.0.0.0) dauerhaft gelöst. * [x] **Service Stability:** Port-Konflikt des `masterdata-service` (Spring Management Port 8081 vs. Gateway) durch Umzug auf Port 8086 und explizite Bind-Adressen (Spring: 127.0.0.1, Ktor: 0.0.0.0) dauerhaft gelöst.
* [x] **Documentation:** `CHANGELOG.md` aktualisiert und Port-Konfiguration in `application.yml` dokumentiert. * [x] **Documentation:** `CHANGELOG.md` aktualisiert und Port-Konfiguration in `application.yml` dokumentiert.
→ Note: `IdempotencyApiIntegrationTest` bleibt vorerst @Disabled, da das Hochfahren des Spring-Contexts in der → Note: `IdempotencyApiIntegrationTest` bleibt vorerst @Disabled, da das Hochfahren des Spring-Contexts in der