From 9237882437203f3c0951f159d826028410100e62 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Mon, 6 Apr 2026 13:59:14 +0200 Subject: [PATCH] Integrate qualification master data (`QualifikationMasterTable`) with functionary models, update schema and repository logic, refactor `satzID` references, and harmonize database migration (`V010`). --- CHANGELOG.md | 2 ++ .../api/rest/FunktionaerController.kt | 22 ++++++++++++------- .../masterdata/domain/model/Funktionaer.kt | 5 +++-- .../FunktionaerExposedRepository.kt | 19 ++++++++++++---- .../funktionaer/FunktionaerTable.kt | 21 ++++++++++++++---- .../V010__Sync_Schema_with_Exposed.sql | 15 ++++++++++--- docs/01_Architecture/MASTER_ROADMAP.md | 1 + 7 files changed, 64 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ae5d71..69bf6529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,8 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). ## [1.0.1-SNAPSHOT] — 2026-04-05 ### 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). - **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. diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/FunktionaerController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/FunktionaerController.kt index 999b0bd8..70c653bb 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/FunktionaerController.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/FunktionaerController.kt @@ -19,9 +19,12 @@ import kotlin.uuid.Uuid */ class FunktionaerController(private val funktionaerRepository: FunktionaerRepository) { + /** + * DTO für die API-Response eines Funktionärs. + */ data class FunktionaerDto( val funktionaerId: String, - val satzID: String, + val satzId: String, val satzNummer: Int, val name: String? = null, val qualifikationen: List = emptyList(), @@ -31,9 +34,12 @@ class FunktionaerController(private val funktionaerRepository: FunktionaerReposi val updatedAt: Instant ) + /** + * Request-Body zum Anlegen eines neuen Funktionärs. + */ @Serializable data class FunktionaerCreateRequest( - val satzID: String, + val satzId: String, val satzNummer: Int, val name: String? = null, val qualifikationen: List = 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}") { - val satzID = call.parameters["satzID"] ?: return@get call.respond(HttpStatusCode.BadRequest) + get("/satz/{satzId}/{satzNummer}") { + val satzId = call.parameters["satzId"] ?: 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) } @@ -97,7 +103,7 @@ class FunktionaerController(private val funktionaerRepository: FunktionaerReposi post { val req = call.receive() val domFunktionaer = Funktionaer( - satzId = req.satzID, + satzId = req.satzId, satzNummer = req.satzNummer, name = req.name, qualifikationen = req.qualifikationen, @@ -141,7 +147,7 @@ class FunktionaerController(private val funktionaerRepository: FunktionaerReposi private fun Funktionaer.toDto() = FunktionaerDto( funktionaerId = funktionaerId.toString(), - satzID = satzId, + satzId = satzId, satzNummer = satzNummer, name = name, qualifikationen = qualifikationen, diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/Funktionaer.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/Funktionaer.kt index 60c78c38..9c1f8ca2 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/Funktionaer.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/Funktionaer.kt @@ -15,13 +15,14 @@ import kotlin.uuid.Uuid * * Repräsentiert eine Person mit einer definierten Rolle bei Turnieren (Richter, TBA, * 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 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 satzNummer Satznummer (6-stellig). 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 bemerkungen Interne Notizen. * @property datenQuelle Herkunft des Datensatzes (ZNS-Import oder manuell). diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/funktionaer/FunktionaerExposedRepository.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/funktionaer/FunktionaerExposedRepository.kt index df9b2831..de2a0277 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/funktionaer/FunktionaerExposedRepository.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/funktionaer/FunktionaerExposedRepository.kt @@ -5,14 +5,23 @@ import at.mocode.core.domain.model.DatenQuelleE import at.mocode.core.utils.database.DatabaseFactory import at.mocode.masterdata.domain.model.Funktionaer import at.mocode.masterdata.domain.repository.FunktionaerRepository -import at.mocode.masterdata.infrastructure.persistence.funktionaer.* -import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.and +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 kotlin.uuid.Uuid /** * 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 { private val log = LoggerFactory.getLogger(FunktionaerExposedRepository::class.java) @@ -21,7 +30,7 @@ class FunktionaerExposedRepository : FunktionaerRepository { return Funktionaer( funktionaerId = row[FunktionaerTable.id], personId = row[FunktionaerTable.personId], - satzId = row[FunktionaerTable.satzId] ?: "X", + satzId = row[FunktionaerTable.satzId], satzNummer = row[FunktionaerTable.satzNummer] ?: 0, name = row[FunktionaerTable.name], qualifikationen = qualifikationen, @@ -99,6 +108,8 @@ class FunktionaerExposedRepository : FunktionaerRepository { } // 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 } val typ = if (funktionaer.istRichter()) "RICHTER" else "PARCOURSBAUER" diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/funktionaer/FunktionaerTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/funktionaer/FunktionaerTable.kt index deb40185..d4d49c0c 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/funktionaer/FunktionaerTable.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/funktionaer/FunktionaerTable.kt @@ -10,15 +10,21 @@ import kotlin.uuid.ExperimentalUuidApi /** * Exposed-Tabellendefinition für die Funktionär-Entität. + * Speichert Basisdaten und Kontaktinformationen. */ object FunktionaerTable : Table("funktionaer") { val id = uuid("funktionaer_id") 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) + + /** 6-stellige Satznummer (eindeutig pro Typ) */ val satzNummer = integer("satz_nummer").nullable() + + /** Vollständiger Name (Nachname, Vorname) */ val name = varchar("name", 200).nullable() // === ZNS.zip RICHT01.DAT === ENDE === @@ -54,12 +60,19 @@ object FunktionaerTable : Table("funktionaer") { /** * 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") { 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" - val typ = varchar("typ", 20) // "RICHTER" oder "PARCOURSBAUER" + + /** Offizielles Kürzel (z.B. "SPF" für Springpferde) */ + 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) diff --git a/backend/services/masterdata/masterdata-service/src/main/resources/db/migration/V010__Sync_Schema_with_Exposed.sql b/backend/services/masterdata/masterdata-service/src/main/resources/db/migration/V010__Sync_Schema_with_Exposed.sql index a2ceaedd..a028d706 100644 --- a/backend/services/masterdata/masterdata-service/src/main/resources/db/migration/V010__Sync_Schema_with_Exposed.sql +++ b/backend/services/masterdata/masterdata-service/src/main/resources/db/migration/V010__Sync_Schema_with_Exposed.sql @@ -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 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 ( funktionaer_id UUID NOT NULL REFERENCES funktionaer(funktionaer_id), - qualifikation VARCHAR(20) NOT NULL, - PRIMARY KEY (funktionaer_id, qualifikation) + qualifikation_id UUID NOT NULL REFERENCES qualifikation_master(qualifikation_id), + PRIMARY KEY (funktionaer_id, qualifikation_id) ); -- Indizes (Exposed-Style) diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index d7ce893a..d925b195 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -106,6 +106,7 @@ und über definierte Schnittstellen kommunizieren. #### 🧐 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] **Documentation:** `CHANGELOG.md` aktualisiert und Port-Konfiguration in `application.yml` dokumentiert. → Note: `IdempotencyApiIntegrationTest` bleibt vorerst @Disabled, da das Hochfahren des Spring-Contexts in der