feat(horses-service): remove legacy configuration and repository classes
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 7m12s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 6m42s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Successful in 6m28s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 1m51s

- Deleted outdated `ApplicationConfiguration` and `HorseRepositoryImpl` classes.
- Migrated to streamlined modular Gradle configuration in `build.gradle.kts`.
- Updated domain models and dependencies to use multiplatform and serialization plugins.
- Added modular setup for `horses` namespace in `settings.gradle.kts`.
- Reorganized database configuration with minimal JDBC bindings for development.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-23 13:47:04 +01:00
parent 7b35831d8c
commit c53daa926a
44 changed files with 75781 additions and 530 deletions
@@ -0,0 +1,26 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
sourceSets {
commonMain {
kotlin.srcDir("src/main/kotlin")
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
}
commonTest {
kotlin.srcDir("src/test/kotlin")
dependencies {
implementation(projects.platform.platformTesting)
}
}
}
}
@@ -0,0 +1,38 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.clubs.domain.model
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain model representing a club (Verein) in the registry system.
*
* @property clubId Unique internal identifier (UUID).
* @property vereinsNummer ÖPS club number (from ZNS VEREIN01.dat).
* @property name Club name.
* @property datenQuelle Source of the data.
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class DomClub(
@Serializable(with = UuidSerializer::class)
val clubId: Uuid = Uuid.random(),
val vereinsNummer: String,
val name: String,
val istAktiv: Boolean = true,
val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
)
@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.kotlinSerialization)
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.clubs.clubsDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.spring.boot.starter.web)
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
runtimeOnly(libs.postgresql.driver)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,14 @@
package at.mocode.clubs.infrastructure.persistence
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Tabelle für importierte Vereine aus dem ZNS-Datenbestand (VEREIN01.dat).
* Wird ausschließlich als Dev-Seed-Daten verwendet.
*/
object ZnsClubTable : UUIDTable("zns_clubs") {
val vereinsNummer = varchar("vereins_nummer", 4).uniqueIndex()
val name = varchar("name", 50)
val createdAt = timestamp("created_at")
}
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
springBoot {
mainClass.set("at.mocode.clubs.service.ClubsServiceApplicationKt")
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.clubs.clubsDomain)
implementation(projects.clubs.clubsInfrastructure)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)
testRuntimeOnly(libs.h2.driver)
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.logback.classic)
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,18 @@
package at.mocode.clubs.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
@SpringBootApplication
@ComponentScan(
basePackages = [
"at.mocode.clubs.service",
"at.mocode.clubs.infrastructure"
]
)
class ClubsServiceApplication
fun main(args: Array<String>) {
runApplication<ClubsServiceApplication>(*args)
}
@@ -0,0 +1,31 @@
package at.mocode.clubs.service.config
import at.mocode.clubs.infrastructure.persistence.ZnsClubTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
@Configuration
@Profile("dev")
class ClubsDatabaseConfiguration(
@Value("\${spring.datasource.url}") private val jdbcUrl: String,
@Value("\${spring.datasource.username}") private val username: String,
@Value("\${spring.datasource.password}") private val password: String
) {
private val log = LoggerFactory.getLogger(ClubsDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initialisiere Datenbank-Schema für Clubs-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsClubTable)
log.info("Datenbank-Schema erfolgreich initialisiert")
}
}
}
@@ -0,0 +1,80 @@
package at.mocode.clubs.service.dev
import at.mocode.clubs.infrastructure.persistence.ZnsClubTable
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.batchInsert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.CommandLineRunner
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import java.io.File
import kotlin.time.Clock
/**
* Dev-only Seeder: Importiert VEREIN01.dat (ZNS) → zns_clubs.
* Aktivierung: Umgebungsvariable ZNS_DATA_DIR setzen + Profil "dev".
*/
@Component
@Profile("dev")
class ZnsClubSeeder(
@Value("\${zns.data.dir:#{null}}") private val znsDataDir: String?
) : CommandLineRunner {
private val log = LoggerFactory.getLogger(ZnsClubSeeder::class.java)
override fun run(vararg args: String) {
if (znsDataDir == null) {
log.info("ZNS_DATA_DIR nicht gesetzt ZnsClubSeeder wird übersprungen.")
return
}
val dir = File(znsDataDir)
if (!dir.exists() || !dir.isDirectory) {
log.warn("ZNS_DATA_DIR '{}' existiert nicht oder ist kein Verzeichnis.", znsDataDir)
return
}
log.info("Starte ZNS-Vereine-Import aus: {}", dir.absolutePath)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsClubTable)
}
seedClubs(dir)
log.info("ZNS-Vereine-Import abgeschlossen.")
}
// -------------------------------------------------------------------------
// VEREIN01.dat → zns_clubs
// Format:
// [0-3] VereinsNr (4 Zeichen)
// [4-53] Name (50 Zeichen)
// -------------------------------------------------------------------------
private fun seedClubs(dir: File) {
val file = File(dir, "VEREIN01.dat")
if (!file.exists()) {
log.warn("VEREIN01.dat nicht gefunden Vereine werden übersprungen.")
return
}
data class ClubRow(val nr: String, val name: String)
val rows = file.readLines(Charsets.ISO_8859_1)
.filter { it.length >= 4 }
.map { line ->
ClubRow(
nr = line.substring(0, 4).trim(),
name = line.substring(4).trim().take(50)
)
}
.filter { it.nr.isNotBlank() }
val now = Clock.System.now()
transaction {
ZnsClubTable.batchInsert(rows, ignore = true) { row ->
this[ZnsClubTable.vereinsNummer] = row.nr
this[ZnsClubTable.name] = row.name
this[ZnsClubTable.createdAt] = now
}
}
log.info("Vereine importiert: {} Datensätze", rows.size)
}
}
@@ -1,5 +1,5 @@
plugins {
kotlin("jvm")
alias(libs.plugins.kotlinJvm)
}
dependencies {
@@ -1,9 +1,26 @@
plugins {
kotlin("jvm")
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
testImplementation(projects.platform.platformTesting)
kotlin {
jvm()
sourceSets {
commonMain {
kotlin.srcDir("src/main/kotlin")
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
}
commonTest {
kotlin.srcDir("src/test/kotlin")
dependencies {
implementation(projects.platform.platformTesting)
}
}
}
}
@@ -1,16 +1,16 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.domain.model
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlin.uuid.Uuid
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.todayIn
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain model representing a horse in the registry system.
@@ -129,7 +129,7 @@ data class DomPferd(
*/
fun getAge(): Int? {
return geburtsdatum?.let { birthDate ->
val today = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault())
val today = kotlin.time.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault())
var age = today.year - birthDate.year
// Check if birthday has occurred this year
@@ -167,6 +167,6 @@ data class DomPferd(
* Creates a copy of this horse with updated timestamp.
*/
fun withUpdatedTimestamp(): DomPferd {
return this.copy(updatedAt = Clock.System.now())
return this.copy(updatedAt = kotlin.time.Clock.System.now())
}
}
@@ -1,26 +1,26 @@
plugins {
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
// Das JPA-Plugin wird jetzt ebenfalls zentral verwaltet.
alias(libs.plugins.kotlin.jpa)
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.kotlinSerialization)
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.horses.horsesDomain)
implementation(projects.horses.horsesApplication)
// horses-common: ON HOLD
// implementation(projects.horses.horsesCommon)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.infrastructure.cache.cacheApi)
implementation(projects.infrastructure.eventStore.eventStoreApi)
implementation(projects.infrastructure.messaging.messagingClient)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring
implementation(libs.spring.boot.starter.web)
// Spring Data JPA
implementation(libs.spring.boot.starter.data.jpa)
// Datenbank via Exposed
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
// Datenbank-Treiber
runtimeOnly(libs.postgresql.driver)
@@ -1,336 +0,0 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.infrastructure.persistence
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.utils.database.DatabaseFactory
import kotlin.uuid.Uuid
import kotlinx.datetime.Clock
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.statements.UpdateBuilder
import org.springframework.stereotype.Repository
/**
* PostgreSQL implementation of the HorseRepository using Exposed ORM.
*
* This implementation provides database operations for horse entities,
* mapping between the domain model (DomPferd) and the database table (HorseTable).
*/
@Repository
class HorseRepositoryImpl : HorseRepository {
override suspend fun findById(id: Uuid): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.id eq id }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByChipNummer(chipNummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByPassNummer(passNummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.pferdeName like "%$searchTerm%" }
.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.verantwortlichePersonId eq responsiblePersonId }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun findByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.geschlecht eq geschlecht }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.rasse eq rasse }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where {
HorseTable.geburtsdatum.isNotNull() and
(CustomFunction(
"EXTRACT",
IntegerColumnType(),
stringLiteral("YEAR FROM "),
HorseTable.geburtsdatum
) eq birthYear)
}
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where {
HorseTable.geburtsdatum.isNotNull() and
(CustomFunction(
"EXTRACT",
IntegerColumnType(),
stringLiteral("YEAR FROM "),
HorseTable.geburtsdatum
) greaterEq fromYear) and
(CustomFunction(
"EXTRACT",
IntegerColumnType(),
stringLiteral("YEAR FROM "),
HorseTable.geburtsdatum
) lessEq toYear)
}
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.geburtsdatum, SortOrder.DESC)
.map { rowToDomPferd(it) }
}
override suspend fun findAllActive(limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findOepsRegistered(activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.oepsNummer.isNotNull() }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun findFeiRegistered(activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.feiNummer.isNotNull() }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
.map { rowToDomPferd(it) }
}
override suspend fun save(horse: DomPferd): DomPferd = DatabaseFactory.dbQuery {
val now = Clock.System.now()
val existingHorse = findById(horse.pferdId)
if (existingHorse != null) {
// Update existing horse
val updatedHorse = horse.copy(updatedAt = now)
HorseTable.update({ HorseTable.id eq horse.pferdId }) {
domPferdToStatement(it, updatedHorse)
}
updatedHorse
} else {
// Insert a new horse
HorseTable.insert {
it[id] = horse.pferdId
domPferdToStatement(it, horse.copy(updatedAt = now))
}
horse.copy(updatedAt = now)
}
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
val deletedRows = HorseTable.deleteWhere { HorseTable.id eq id }
deletedRows > 0
}
override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }
.count() > 0
}
override suspend fun existsByChipNummer(chipNummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }
.count() > 0
}
override suspend fun existsByPassNummer(passNummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }
.count() > 0
}
override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }
.count() > 0
}
override suspend fun existsByFeiNummer(feiNummer: String): Boolean = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }
.count() > 0
}
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
.count()
}
override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId }
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.count()
}
override suspend fun countOepsRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where {
HorseTable.oepsNummer.isNotNull() and (HorseTable.oepsNummer neq "")
}
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.count()
}
override suspend fun countFeiRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = HorseTable.selectAll().where {
HorseTable.feiNummer.isNotNull() and (HorseTable.feiNummer neq "")
}
if (activeOnly) {
query.andWhere { HorseTable.istAktiv eq true }
} else {
query
}.count()
}
/**
* Maps a database row to a DomPferd domain object.
*/
private fun rowToDomPferd(row: ResultRow): DomPferd {
return DomPferd(
pferdId = row[HorseTable.id].value,
pferdeName = row[HorseTable.pferdeName],
geschlecht = row[HorseTable.geschlecht],
geburtsdatum = row[HorseTable.geburtsdatum],
rasse = row[HorseTable.rasse],
farbe = row[HorseTable.farbe],
besitzerId = row[HorseTable.besitzerId],
verantwortlichePersonId = row[HorseTable.verantwortlichePersonId],
zuechterName = row[HorseTable.zuechterName],
zuchtbuchNummer = row[HorseTable.zuchtbuchNummer],
lebensnummer = row[HorseTable.lebensnummer],
chipNummer = row[HorseTable.chipNummer],
passNummer = row[HorseTable.passNummer],
oepsNummer = row[HorseTable.oepsNummer],
feiNummer = row[HorseTable.feiNummer],
vaterName = row[HorseTable.vaterName],
mutterName = row[HorseTable.mutterName],
mutterVaterName = row[HorseTable.mutterVaterName],
stockmass = row[HorseTable.stockmass],
istAktiv = row[HorseTable.istAktiv],
bemerkungen = row[HorseTable.bemerkungen],
datenQuelle = row[HorseTable.datenQuelle],
createdAt = row[HorseTable.createdAt],
updatedAt = row[HorseTable.updatedAt]
)
}
/**
* Maps a DomPferd domain object to database statement values.
*/
private fun domPferdToStatement(statement: UpdateBuilder<*>, horse: DomPferd) {
statement[HorseTable.pferdeName] = horse.pferdeName
statement[HorseTable.geschlecht] = horse.geschlecht
statement[HorseTable.geburtsdatum] = horse.geburtsdatum
statement[HorseTable.rasse] = horse.rasse
statement[HorseTable.farbe] = horse.farbe
statement[HorseTable.besitzerId] = horse.besitzerId
statement[HorseTable.verantwortlichePersonId] = horse.verantwortlichePersonId
statement[HorseTable.zuechterName] = horse.zuechterName
statement[HorseTable.zuchtbuchNummer] = horse.zuchtbuchNummer
statement[HorseTable.lebensnummer] = horse.lebensnummer
statement[HorseTable.chipNummer] = horse.chipNummer
statement[HorseTable.passNummer] = horse.passNummer
statement[HorseTable.oepsNummer] = horse.oepsNummer
statement[HorseTable.feiNummer] = horse.feiNummer
statement[HorseTable.vaterName] = horse.vaterName
statement[HorseTable.mutterName] = horse.mutterName
statement[HorseTable.mutterVaterName] = horse.mutterVaterName
statement[HorseTable.stockmass] = horse.stockmass
statement[HorseTable.istAktiv] = horse.istAktiv
statement[HorseTable.bemerkungen] = horse.bemerkungen
statement[HorseTable.datenQuelle] = horse.datenQuelle
statement[HorseTable.createdAt] = horse.createdAt
statement[HorseTable.updatedAt] = horse.updatedAt
}
}
@@ -1,11 +1,11 @@
package at.mocode.horses.infrastructure.persistence
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.PferdeGeschlechtE
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
import org.jetbrains.exposed.v1.core.kotlin.datetime.date
import org.jetbrains.exposed.v1.core.kotlin.datetime.timestamp
import org.jetbrains.exposed.v1.core.javaUUID
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.date
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Database table definition for horses in the horse-registry context.
@@ -13,7 +13,7 @@ import org.jetbrains.exposed.v1.core.javaUUID
* This table stores all horse information including identification,
* ownership, breeding data, and administrative information.
*/
object HorseTable : UUIDTable("horses") {
object HorseTable : UUIDTable("zns_horses") {
// Basic Information
val pferdeName = varchar("pferde_name", 255)
val geschlecht = enumerationByName<PferdeGeschlechtE>("geschlecht", 20)
@@ -1,12 +1,10 @@
plugins {
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
// Der springBoot-Block konfiguriert die Anwendung, wenn sie als JAR-Datei ausgeführt wird.
springBoot {
mainClass.set("at.mocode.horses.service.HorsesServiceApplicationKt")
}
@@ -17,16 +15,9 @@ dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.horses.horsesDomain)
implementation(projects.horses.horsesApplication)
// horses-common: ON HOLD veraltete API-Referenzen
// implementation(projects.horses.horsesCommon)
implementation(projects.horses.horsesInfrastructure)
implementation(projects.horses.horsesApi)
// Infrastruktur-Clients
implementation(projects.infrastructure.cache.redisCache)
implementation(projects.infrastructure.messaging.messagingClient)
implementation(projects.infrastructure.monitoring.monitoringClient)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring Boot Starters
implementation(libs.spring.boot.starter.web)
@@ -42,11 +33,10 @@ dependencies {
runtimeOnly(libs.postgresql.driver)
testRuntimeOnly(libs.h2.driver)
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.logback.classic) // SLF4J provider for tests
testImplementation(libs.logback.classic)
}
tasks.test {
@@ -1,60 +0,0 @@
package at.mocode.horses.service.config
import at.mocode.horses.application.usecase.CreateHorseUseCase
import at.mocode.horses.application.usecase.TransactionalCreateHorseUseCase
import at.mocode.horses.application.usecase.UpdateHorseUseCase
import at.mocode.horses.application.usecase.DeleteHorseUseCase
import at.mocode.horses.application.usecase.GetHorseUseCase
import at.mocode.horses.domain.repository.HorseRepository
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Application configuration for the Horses Service.
*
* This configuration wires the use cases as Spring beans.
*/
@Configuration
class ApplicationConfiguration {
/**
* Creates the CreateHorseUseCase as a Spring bean.
*/
@Bean
fun createHorseUseCase(horseRepository: HorseRepository): CreateHorseUseCase {
return CreateHorseUseCase(horseRepository)
}
/**
* Creates the TransactionalCreateHorseUseCase as a Spring bean.
* This version ensures all database operations run within a single transaction.
*/
@Bean
fun transactionalCreateHorseUseCase(horseRepository: HorseRepository): TransactionalCreateHorseUseCase {
return TransactionalCreateHorseUseCase(horseRepository)
}
/**
* Creates the UpdateHorseUseCase as a Spring bean.
*/
@Bean
fun updateHorseUseCase(horseRepository: HorseRepository): UpdateHorseUseCase {
return UpdateHorseUseCase(horseRepository)
}
/**
* Creates the DeleteHorseUseCase as a Spring bean.
*/
@Bean
fun deleteHorseUseCase(horseRepository: HorseRepository): DeleteHorseUseCase {
return DeleteHorseUseCase(horseRepository)
}
/**
* Creates the GetHorseUseCase as a Spring bean.
*/
@Bean
fun getHorseUseCase(horseRepository: HorseRepository): GetHorseUseCase {
return GetHorseUseCase(horseRepository)
}
}
@@ -1,106 +1,37 @@
package at.mocode.horses.service.config
import at.mocode.core.utils.database.DatabaseConfig
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.horses.infrastructure.persistence.HorseTable
import org.springframework.boot.context.properties.ConfigurationProperties
import at.mocode.horses.infrastructure.persistence.ZnsClubTable
import at.mocode.horses.infrastructure.persistence.ZnsPersonTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
/**
* Database configuration for the Horses Service.
*
* This configuration ensures that Database.connect() is called properly
* before any Exposed operations are performed.
* Minimale Datenbank-Konfiguration für den Horses-Service (Dev-Profil).
* Verbindet sich direkt via JDBC und legt die Tabellen an.
*/
@Configuration
@Profile("!test")
class HorsesDatabaseConfiguration {
@Profile("dev")
class HorsesDatabaseConfiguration(
@Value("\${spring.datasource.url}") private val jdbcUrl: String,
@Value("\${spring.datasource.username}") private val username: String,
@Value("\${spring.datasource.password}") private val password: String
) {
private val log = LoggerFactory.getLogger(HorsesDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initializing database schema for Horses Service...")
try {
// Database connection is already initialized by the gateway
// Only initialize the schema for this service
transaction {
SchemaUtils.createMissingTablesAndColumns(HorseTable)
log.info("Horse database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize database schema", e)
throw e
}
}
@PreDestroy
fun closeDatabase() {
log.info("Closing database connection for Horses Service...")
try {
DatabaseFactory.close()
log.info("Database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing database connection", e)
}
}
}
/**
* Test-specific database configuration.
*/
@Configuration
@Profile("test")
class HorsesTestDatabaseConfiguration {
private val log = LoggerFactory.getLogger(HorsesTestDatabaseConfiguration::class.java)
@PostConstruct
fun initializeTestDatabase() {
log.info("Initializing test database connection for Horses Service...")
try {
// Use H2 in-memory database for tests
val testConfig = DatabaseConfig(
jdbcUrl = "jdbc:h2:mem:horses_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
username = "sa",
password = "",
driverClassName = "org.h2.Driver",
maxPoolSize = 5,
minPoolSize = 1,
autoMigrate = true
)
DatabaseFactory.init(testConfig)
log.info("Test database connection initialized successfully")
// Initialize database schema for tests
transaction {
SchemaUtils.createMissingTablesAndColumns(HorseTable)
log.info("Test horse database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize test database connection", e)
throw e
}
}
@PreDestroy
fun closeTestDatabase() {
log.info("Closing test database connection for Horses Service...")
try {
DatabaseFactory.close()
log.info("Test database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing test database connection", e)
log.info("Initialisiere Datenbank-Schema für Horses-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
SchemaUtils.createMissingTablesAndColumns(HorseTable, ZnsPersonTable, ZnsClubTable)
log.info("Datenbank-Schema erfolgreich initialisiert")
}
}
}
@@ -0,0 +1,26 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
sourceSets {
commonMain {
kotlin.srcDir("src/main/kotlin")
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
}
commonTest {
kotlin.srcDir("src/test/kotlin")
dependencies {
implementation(projects.platform.platformTesting)
}
}
}
}
@@ -0,0 +1,40 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.officials.domain.model
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain model representing an official (Richter) in the registry system.
*
* @property officialId Unique internal identifier (UUID).
* @property richterNummer ÖPS official number (from ZNS RICHT01.dat).
* @property name Full name of the official.
* @property qualifikation Qualification/class code (e.g. "GA", "G3").
* @property datenQuelle Source of the data.
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class DomOfficial(
@Serializable(with = UuidSerializer::class)
val officialId: Uuid = Uuid.random(),
val richterNummer: String,
val name: String,
val qualifikation: String? = null,
val istAktiv: Boolean = true,
val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
)
@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.kotlinSerialization)
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.officials.officialsDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.spring.boot.starter.web)
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
runtimeOnly(libs.postgresql.driver)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,15 @@
package at.mocode.officials.infrastructure.persistence
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Tabelle für importierte Richter aus dem ZNS-Datenbestand (RICHT01.dat).
* Wird ausschließlich als Dev-Seed-Daten verwendet.
*/
object ZnsOfficialTable : UUIDTable("zns_officials") {
val richterNummer = varchar("richter_nummer", 6).uniqueIndex()
val name = varchar("name", 80)
val qualifikation = varchar("qualifikation", 5).nullable()
val createdAt = timestamp("created_at")
}
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
springBoot {
mainClass.set("at.mocode.officials.service.OfficialsServiceApplicationKt")
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.officials.officialsDomain)
implementation(projects.officials.officialsInfrastructure)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)
testRuntimeOnly(libs.h2.driver)
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.logback.classic)
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,18 @@
package at.mocode.officials.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
@SpringBootApplication
@ComponentScan(
basePackages = [
"at.mocode.officials.service",
"at.mocode.officials.infrastructure"
]
)
class OfficialsServiceApplication
fun main(args: Array<String>) {
runApplication<OfficialsServiceApplication>(*args)
}
@@ -0,0 +1,31 @@
package at.mocode.officials.service.config
import at.mocode.officials.infrastructure.persistence.ZnsOfficialTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
@Configuration
@Profile("dev")
class OfficialsDatabaseConfiguration(
@Value("\${spring.datasource.url}") private val jdbcUrl: String,
@Value("\${spring.datasource.username}") private val username: String,
@Value("\${spring.datasource.password}") private val password: String
) {
private val log = LoggerFactory.getLogger(OfficialsDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initialisiere Datenbank-Schema für Officials-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsOfficialTable)
log.info("Datenbank-Schema erfolgreich initialisiert")
}
}
}
@@ -0,0 +1,90 @@
package at.mocode.officials.service.dev
import at.mocode.officials.infrastructure.persistence.ZnsOfficialTable
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.batchInsert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.CommandLineRunner
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import java.io.File
import kotlin.time.Clock
/**
* Dev-only Seeder: Importiert RICHT01.dat (ZNS) → zns_officials.
* Aktivierung: Umgebungsvariable ZNS_DATA_DIR setzen + Profil "dev".
*/
@Component
@Profile("dev")
class ZnsOfficialSeeder(
@Value("\${zns.data.dir:#{null}}") private val znsDataDir: String?
) : CommandLineRunner {
private val log = LoggerFactory.getLogger(ZnsOfficialSeeder::class.java)
override fun run(vararg args: String) {
if (znsDataDir == null) {
log.info("ZNS_DATA_DIR nicht gesetzt ZnsOfficialSeeder wird übersprungen.")
return
}
val dir = File(znsDataDir)
if (!dir.exists() || !dir.isDirectory) {
log.warn("ZNS_DATA_DIR '{}' existiert nicht oder ist kein Verzeichnis.", znsDataDir)
return
}
log.info("Starte ZNS-Richter-Import aus: {}", dir.absolutePath)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsOfficialTable)
}
seedOfficials(dir)
log.info("ZNS-Richter-Import abgeschlossen.")
}
// -------------------------------------------------------------------------
// RICHT01.dat → zns_officials
// Format (~109 Zeichen):
// [0] 'Y' (Kennzeichen)
// [1-7] RichterNr (6 Zeichen)
// [7-87] Name (80 Zeichen)
// [87-89] Qualifikation (z.B. "GA", "G3")
// -------------------------------------------------------------------------
private fun seedOfficials(dir: File) {
val file = File(dir, "RICHT01.dat")
if (!file.exists()) {
log.warn("RICHT01.dat nicht gefunden Richter werden übersprungen.")
return
}
data class OfficialRow(
val richterNr: String,
val name: String,
val qualifikation: String?
)
val rows = file.readLines(Charsets.ISO_8859_1)
.filter { it.length >= 7 && it.startsWith("Y") }
.mapNotNull { line ->
val richterNr = line.substring(1, 7).trim()
if (richterNr.isBlank()) return@mapNotNull null
val name = line.safeSubstring(7, 87).trim()
val qualifikation = line.safeSubstring(87, 92).trim().takeIf { it.isNotBlank() }
OfficialRow(richterNr, name, qualifikation)
}
val now = Clock.System.now()
transaction {
ZnsOfficialTable.batchInsert(rows, ignore = true) { row ->
this[ZnsOfficialTable.richterNummer] = row.richterNr
this[ZnsOfficialTable.name] = row.name
this[ZnsOfficialTable.qualifikation] = row.qualifikation
this[ZnsOfficialTable.createdAt] = now
}
}
log.info("Richter importiert: {} Datensätze", rows.size)
}
private fun String.safeSubstring(start: Int, end: Int): String =
if (this.length >= end) this.substring(start, end) else if (this.length > start) this.substring(start) else ""
}
@@ -0,0 +1,26 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
sourceSets {
commonMain {
kotlin.srcDir("src/main/kotlin")
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
}
}
commonTest {
kotlin.srcDir("src/test/kotlin")
dependencies {
implementation(projects.platform.platformTesting)
}
}
}
}
@@ -0,0 +1,58 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.persons.domain.model
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain model representing a person (rider/member) in the registry system.
*
* @property personId Unique internal identifier (UUID).
* @property lizenzNummer ÖPS license number (from ZNS LIZENZ01.dat).
* @property nachname Last name.
* @property vorname First name.
* @property geschlecht Gender code (M/W).
* @property geburtsdatum Date of birth.
* @property nation Nation code (e.g. AUT).
* @property lizenzKlasse License class (e.g. R1, R2, RD2).
* @property mitgliedsNummer Membership number.
* @property vereinsNummer Club number.
* @property vereinsName Club name.
* @property datenQuelle Source of the data.
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class DomPerson(
@Serializable(with = UuidSerializer::class)
val personId: Uuid = Uuid.random(),
val lizenzNummer: String,
val nachname: String,
val vorname: String,
val geschlecht: String? = null,
val geburtsdatum: LocalDate? = null,
val nation: String? = null,
val lizenzKlasse: String? = null,
val mitgliedsNummer: String? = null,
val vereinsNummer: String? = null,
val vereinsName: String? = null,
val istAktiv: Boolean = true,
val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
fun getDisplayName(): String = "$vorname $nachname"
}
@@ -0,0 +1,23 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.kotlinSerialization)
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.persons.personsDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(libs.spring.boot.starter.web)
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
runtimeOnly(libs.postgresql.driver)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,23 @@
package at.mocode.persons.infrastructure.persistence
import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable
import org.jetbrains.exposed.v1.datetime.date
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Tabelle für importierte Personen/Reiter aus dem ZNS-Datenbestand (LIZENZ01.dat).
* Wird ausschließlich als Dev-Seed-Daten verwendet.
*/
object ZnsPersonTable : UUIDTable("zns_persons") {
val lizenzNummer = varchar("lizenz_nummer", 10).uniqueIndex()
val nachname = varchar("nachname", 50)
val vorname = varchar("vorname", 25)
val vereinsNummer = varchar("vereins_nummer", 4).nullable()
val vereinsName = varchar("vereins_name", 50).nullable()
val nation = varchar("nation", 3).nullable()
val lizenzKlasse = varchar("lizenz_klasse", 6).nullable()
val mitgliedsNummer = varchar("mitglieds_nummer", 13).nullable()
val geschlecht = varchar("geschlecht", 1).nullable()
val geburtsdatum = date("geburtsdatum").nullable()
val createdAt = timestamp("created_at")
}
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
springBoot {
mainClass.set("at.mocode.persons.service.PersonsServiceApplicationKt")
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.persons.personsDomain)
implementation(projects.persons.personsInfrastructure)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)
testRuntimeOnly(libs.h2.driver)
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.logback.classic)
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,18 @@
package at.mocode.persons.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
@SpringBootApplication
@ComponentScan(
basePackages = [
"at.mocode.persons.service",
"at.mocode.persons.infrastructure"
]
)
class PersonsServiceApplication
fun main(args: Array<String>) {
runApplication<PersonsServiceApplication>(*args)
}
@@ -0,0 +1,31 @@
package at.mocode.persons.service.config
import at.mocode.persons.infrastructure.persistence.ZnsPersonTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
@Configuration
@Profile("dev")
class PersonsDatabaseConfiguration(
@Value("\${spring.datasource.url}") private val jdbcUrl: String,
@Value("\${spring.datasource.username}") private val username: String,
@Value("\${spring.datasource.password}") private val password: String
) {
private val log = LoggerFactory.getLogger(PersonsDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initialisiere Datenbank-Schema für Persons-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsPersonTable)
log.info("Datenbank-Schema erfolgreich initialisiert")
}
}
}
@@ -0,0 +1,136 @@
package at.mocode.persons.service.dev
import at.mocode.persons.infrastructure.persistence.ZnsPersonTable
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.batchInsert
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.CommandLineRunner
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import java.io.File
import kotlin.time.Clock
/**
* Dev-only Seeder: Importiert LIZENZ01.dat (ZNS) → zns_persons.
* Aktivierung: Umgebungsvariable ZNS_DATA_DIR setzen + Profil "dev".
*/
@Component
@Profile("dev")
class ZnsPersonSeeder(
@Value("\${zns.data.dir:#{null}}") private val znsDataDir: String?
) : CommandLineRunner {
private val log = LoggerFactory.getLogger(ZnsPersonSeeder::class.java)
override fun run(vararg args: String) {
if (znsDataDir == null) {
log.info("ZNS_DATA_DIR nicht gesetzt ZnsPersonSeeder wird übersprungen.")
return
}
val dir = File(znsDataDir)
if (!dir.exists() || !dir.isDirectory) {
log.warn("ZNS_DATA_DIR '{}' existiert nicht oder ist kein Verzeichnis.", znsDataDir)
return
}
log.info("Starte ZNS-Personen-Import aus: {}", dir.absolutePath)
transaction {
SchemaUtils.createMissingTablesAndColumns(ZnsPersonTable)
}
seedPersons(dir)
log.info("ZNS-Personen-Import abgeschlossen.")
}
// -------------------------------------------------------------------------
// LIZENZ01.dat → zns_persons
// Format (220 Zeichen):
// [0-5] LizenzNr
// [6-55] Nachname
// [56-80] Vorname
// [81-83] VereinsNr
// [84-133] VereinsName
// [134-136] Nation
// [137-143] LizenzKlasse (z.B. "R1", "R2", "RD2")
// [144-156] MitgliedsNr
// [157-165] Telefon (nicht gespeichert)
// [166-169] Jahr
// [170] Geschlecht (M/W)
// [171-178] Geburtsdatum (YYYYMMDD)
// -------------------------------------------------------------------------
private fun seedPersons(dir: File) {
val file = File(dir, "LIZENZ01.dat")
if (!file.exists()) {
log.warn("LIZENZ01.dat nicht gefunden Personen werden übersprungen.")
return
}
data class PersonRow(
val lizenzNr: String,
val nachname: String,
val vorname: String,
val vereinsNr: String?,
val vereinsName: String?,
val nation: String?,
val lizenzKlasse: String?,
val mitgliedsNr: String?,
val geschlecht: String?,
val geburtsdatum: java.time.LocalDate?
)
val rows = file.readLines(Charsets.ISO_8859_1)
.filter { it.length >= 6 }
.mapNotNull { line ->
val lizenzNr = line.substring(0, 6).trim()
if (lizenzNr.isBlank()) return@mapNotNull null
val nachname = line.safeSubstring(6, 56).trim()
val vorname = line.safeSubstring(56, 81).trim()
val vereinsNr = line.safeSubstring(81, 84).trim().takeIf { it.isNotBlank() }
val vereinsName = line.safeSubstring(84, 134).trim().takeIf { it.isNotBlank() }
val nation = line.safeSubstring(134, 137).trim().takeIf { it.isNotBlank() }
val lizenzKlasse = line.safeSubstring(137, 144).trim().takeIf { it.isNotBlank() }
val mitgliedsNr = line.safeSubstring(144, 157).trim().takeIf { it.isNotBlank() }
val geschlecht = line.safeSubstring(170, 171).trim().takeIf { it.isNotBlank() }
val gebDatStr = line.safeSubstring(171, 179).trim()
val gebDat = parseDate(gebDatStr)
PersonRow(
lizenzNr, nachname, vorname, vereinsNr, vereinsName,
nation, lizenzKlasse, mitgliedsNr, geschlecht, gebDat
)
}
val now = Clock.System.now()
transaction {
ZnsPersonTable.batchInsert(rows, ignore = true) { row ->
this[ZnsPersonTable.lizenzNummer] = row.lizenzNr
this[ZnsPersonTable.nachname] = row.nachname
this[ZnsPersonTable.vorname] = row.vorname
this[ZnsPersonTable.vereinsNummer] = row.vereinsNr
this[ZnsPersonTable.vereinsName] = row.vereinsName
this[ZnsPersonTable.nation] = row.nation
this[ZnsPersonTable.lizenzKlasse] = row.lizenzKlasse
this[ZnsPersonTable.mitgliedsNummer] = row.mitgliedsNr
this[ZnsPersonTable.geschlecht] = row.geschlecht
this[ZnsPersonTable.geburtsdatum] = row.geburtsdatum?.let {
kotlinx.datetime.LocalDate(it.year, it.monthValue, it.dayOfMonth)
}
this[ZnsPersonTable.createdAt] = now
}
}
log.info("Personen importiert: {} Datensätze", rows.size)
}
private fun String.safeSubstring(start: Int, end: Int): String =
if (this.length >= end) this.substring(start, end) else if (this.length > start) this.substring(start) else ""
private fun parseDate(s: String): java.time.LocalDate? {
if (s.length < 8 || s.all { it == '0' }) return null
return try {
java.time.LocalDate.of(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
} catch (_: Exception) {
null
}
}
}