feat(zns-importer): add ZNSImportService with tests and REST controller

- Created `ZnsImportService` to handle uploading, parsing, and persisting ZNS data from legacy `.zip` files.
- Introduced corresponding test cases in `ZnsImportServiceTest` for handling edge cases including imports and updates.
- Added REST controller `ZnsImportController` for initiating import jobs and retrieving their status.
- Defined `ZnsImportResult` data structure for reporting results of import operations.
- Established database configuration specific to ZNS importer for development profile.
- Updated utility libraries with `FixedWidthLineReader` for fixed-width string parsing.
- Refactored architecture by placing parser logic in `core:zns-parser` for reuse across backend and Compose Desktop app.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-25 14:43:01 +01:00
parent 4e8ed21ac0
commit 9d08cb0f72
21 changed files with 1653 additions and 22 deletions
@@ -0,0 +1,266 @@
@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 org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.core.statements.UpdateBuilder
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.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Exposed-basierte Implementierung des HorseRepository (v1-API).
*/
class HorseRepositoryImpl : HorseRepository {
override suspend fun findById(id: Uuid): DomPferd? = transaction {
HorseTable.selectAll().where { HorseTable.id eq id.toJavaUuid() }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = transaction {
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByChipNummer(chipNummer: String): DomPferd? = transaction {
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByPassNummer(passNummer: String): DomPferd? = transaction {
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = transaction {
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = transaction {
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }
.map { rowToDomPferd(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<DomPferd> = transaction {
val pattern = "%$searchTerm%"
HorseTable.selectAll().where { HorseTable.pferdeName like pattern }
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List<DomPferd> = transaction {
HorseTable.selectAll().where {
(HorseTable.besitzerId eq ownerId.toJavaUuid()).let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.map { rowToDomPferd(it) }
}
override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List<DomPferd> =
transaction {
HorseTable.selectAll().where {
(HorseTable.verantwortlichePersonId eq responsiblePersonId.toJavaUuid()).let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.map { rowToDomPferd(it) }
}
override suspend fun findByGeschlecht(
geschlecht: PferdeGeschlechtE,
activeOnly: Boolean,
limit: Int
): List<DomPferd> = transaction {
HorseTable.selectAll().where {
(HorseTable.geschlecht eq geschlecht).let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.limit(limit).map { rowToDomPferd(it) }
}
override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List<DomPferd> = transaction {
HorseTable.selectAll().where {
(HorseTable.rasse eq rasse).let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.limit(limit).map { rowToDomPferd(it) }
}
override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List<DomPferd> = transaction {
HorseTable.selectAll()
.map { rowToDomPferd(it) }
.filter { it.geburtsdatum?.year == birthYear && (!activeOnly || it.istAktiv) }
}
override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List<DomPferd> =
transaction {
HorseTable.selectAll()
.map { rowToDomPferd(it) }
.filter {
val year = it.geburtsdatum?.year
year != null && year in fromYear..toYear && (!activeOnly || it.istAktiv)
}
}
override suspend fun findAllActive(limit: Int): List<DomPferd> = transaction {
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findOepsRegistered(activeOnly: Boolean): List<DomPferd> = transaction {
HorseTable.selectAll().where {
HorseTable.oepsNummer.isNotNull().let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.map { rowToDomPferd(it) }
}
override suspend fun findFeiRegistered(activeOnly: Boolean): List<DomPferd> = transaction {
HorseTable.selectAll().where {
HorseTable.feiNummer.isNotNull().let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.map { rowToDomPferd(it) }
}
override suspend fun save(horse: DomPferd): DomPferd = transaction {
val existing = HorseTable.selectAll()
.where { HorseTable.id eq horse.pferdId.toJavaUuid() }
.singleOrNull()
if (existing == null) {
HorseTable.insert { applyToStatement(it, horse) }
} else {
HorseTable.update({ HorseTable.id eq horse.pferdId.toJavaUuid() }) {
applyToStatement(it, horse.copy(updatedAt = Clock.System.now()))
}
}
horse
}
override suspend fun delete(id: Uuid): Boolean = transaction {
HorseTable.deleteWhere { HorseTable.id eq id.toJavaUuid() } > 0
}
override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = transaction {
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }.count() > 0
}
override suspend fun existsByChipNummer(chipNummer: String): Boolean = transaction {
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }.count() > 0
}
override suspend fun existsByPassNummer(passNummer: String): Boolean = transaction {
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }.count() > 0
}
override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = transaction {
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }.count() > 0
}
override suspend fun existsByFeiNummer(feiNummer: String): Boolean = transaction {
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }.count() > 0
}
override suspend fun countActive(): Long = transaction {
HorseTable.selectAll().where { HorseTable.istAktiv eq true }.count()
}
override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = transaction {
HorseTable.selectAll().where {
(HorseTable.besitzerId eq ownerId.toJavaUuid()).let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.count()
}
override suspend fun countOepsRegistered(activeOnly: Boolean): Long = transaction {
HorseTable.selectAll().where {
HorseTable.oepsNummer.isNotNull().let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.count()
}
override suspend fun countFeiRegistered(activeOnly: Boolean): Long = transaction {
HorseTable.selectAll().where {
HorseTable.feiNummer.isNotNull().let {
if (activeOnly) it and (HorseTable.istAktiv eq true) else it
}
}.count()
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
private fun rowToDomPferd(row: ResultRow): DomPferd = DomPferd(
pferdId = row[HorseTable.id].value.toKotlinUuid(),
pferdeName = row[HorseTable.pferdeName],
geschlecht = row[HorseTable.geschlecht],
geburtsdatum = row[HorseTable.geburtsdatum],
rasse = row[HorseTable.rasse],
farbe = row[HorseTable.farbe],
besitzerId = row[HorseTable.besitzerId]?.toKotlinUuid(),
verantwortlichePersonId = row[HorseTable.verantwortlichePersonId]?.toKotlinUuid(),
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]
)
private fun applyToStatement(statement: UpdateBuilder<*>, horse: DomPferd) {
statement[HorseTable.id] = horse.pferdId.toJavaUuid()
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?.toJavaUuid()
statement[HorseTable.verantwortlichePersonId] = horse.verantwortlichePersonId?.toJavaUuid()
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
}
}
@@ -0,0 +1,47 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinSpring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
springBoot {
mainClass.set("at.mocode.zns.import.service.ZnsImportServiceApplicationKt")
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.backend.infrastructure.znsImporter)
implementation(projects.backend.services.clubs.clubsDomain)
implementation(projects.backend.services.clubs.clubsInfrastructure)
implementation(projects.backend.services.persons.personsDomain)
implementation(projects.backend.services.persons.personsInfrastructure)
implementation(projects.backend.services.horses.horsesDomain)
implementation(projects.backend.services.horses.horsesInfrastructure)
implementation(projects.backend.services.officials.officialsDomain)
implementation(projects.backend.services.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.migration.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,21 @@
package at.mocode.zns.import.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.ComponentScan
@SpringBootApplication
@ComponentScan(
basePackages = [
"at.mocode.zns.import.service",
"at.mocode.clubs.infrastructure",
"at.mocode.persons.infrastructure",
"at.mocode.horses.infrastructure",
"at.mocode.officials.infrastructure"
]
)
class ZnsImportServiceApplication
fun main(args: Array<String>) {
runApplication<ZnsImportServiceApplication>(*args)
}
@@ -0,0 +1,42 @@
package at.mocode.zns.import.service.api
import at.mocode.zns.import.service.job.ImportJob
import at.mocode.zns.import.service.job.ImportJobRegistry
import at.mocode.zns.import.service.job.ZnsImportOrchestrator
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
data class ImportStartResponse(val jobId: String)
@RestController
@RequestMapping("/api/v1/import/zns")
class ZnsImportController(
private val orchestrator: ZnsImportOrchestrator,
private val jobRegistry: ImportJobRegistry
) {
/**
* POST /api/v1/import/zns
* Nimmt eine .zip oder .dat Datei entgegen und startet den asynchronen Import.
* Rückgabe: 202 Accepted mit JobId.
*/
@PostMapping(consumes = ["multipart/form-data"])
fun starteImport(@RequestParam("file") file: MultipartFile): ResponseEntity<ImportStartResponse> {
val job = jobRegistry.erstelleJob()
orchestrator.starteImport(job.jobId, file.bytes)
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ImportStartResponse(job.jobId))
}
/**
* GET /api/v1/import/zns/{jobId}/status
* Gibt den aktuellen Fortschritt und Statusmeldungen zurück.
*/
@GetMapping("/{jobId}/status")
fun holeStatus(@PathVariable jobId: String): ResponseEntity<ImportJob> {
val job = jobRegistry.findeJob(jobId)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(job)
}
}
@@ -0,0 +1,28 @@
package at.mocode.zns.import.service.config
import at.mocode.clubs.domain.repository.VereinRepository
import at.mocode.clubs.infrastructure.persistence.ExposedVereinRepository
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.horses.infrastructure.persistence.HorseRepositoryImpl
import at.mocode.officials.domain.repository.FunktionaerRepository
import at.mocode.officials.infrastructure.persistence.ExposedFunktionaerRepository
import at.mocode.persons.domain.repository.ReiterRepository
import at.mocode.persons.infrastructure.persistence.ExposedReiterRepository
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class RepositoryConfiguration {
@Bean
fun vereinRepository(): VereinRepository = ExposedVereinRepository()
@Bean
fun reiterRepository(): ReiterRepository = ExposedReiterRepository()
@Bean
fun horseRepository(): HorseRepository = HorseRepositoryImpl()
@Bean
fun funktionaerRepository(): FunktionaerRepository = ExposedFunktionaerRepository()
}
@@ -0,0 +1,37 @@
package at.mocode.zns.import.service.config
import at.mocode.clubs.infrastructure.persistence.VereinTable
import at.mocode.horses.infrastructure.persistence.HorseTable
import at.mocode.officials.infrastructure.persistence.FunktionaerTable
import at.mocode.persons.infrastructure.persistence.ReiterTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils
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 ZnsImportDatabaseConfiguration(
@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(ZnsImportDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initialisiere Datenbank-Schema für ZNS-Import-Service...")
Database.connect(jdbcUrl, user = username, password = password)
transaction {
val statements = MigrationUtils.statementsRequiredForDatabaseMigration(
VereinTable, ReiterTable, HorseTable, FunktionaerTable
)
statements.forEach { exec(it) }
log.info("Datenbank-Schema erfolgreich initialisiert ({} Statements)", statements.size)
}
}
}
@@ -0,0 +1,39 @@
package at.mocode.zns.import.service.job
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
enum class ImportJobStatus { AUSSTEHEND, ENTPACKEN, LADE_VEREINE, LADE_REITER, LADE_PFERDE, LADE_RICHTER, ABGESCHLOSSEN, FEHLER }
data class ImportJob(
val jobId: String,
var status: ImportJobStatus = ImportJobStatus.AUSSTEHEND,
var fortschritt: Int = 0,
var meldungen: MutableList<String> = mutableListOf(),
var fehler: MutableList<String> = mutableListOf(),
var warnungen: MutableList<String> = mutableListOf()
)
@OptIn(ExperimentalUuidApi::class)
@Component
class ImportJobRegistry {
private val jobs = ConcurrentHashMap<String, ImportJob>()
fun erstelleJob(): ImportJob {
val job = ImportJob(jobId = Uuid.random().toString())
jobs[job.jobId] = job
return job
}
fun findeJob(jobId: String): ImportJob? = jobs[jobId]
fun aktualisiereStatus(jobId: String, status: ImportJobStatus, meldung: String? = null, fortschritt: Int? = null) {
jobs[jobId]?.let { job ->
job.status = status
meldung?.let { job.meldungen.add(it) }
fortschritt?.let { job.fortschritt = it }
}
}
}
@@ -0,0 +1,50 @@
package at.mocode.zns.import.service.job
import at.mocode.clubs.domain.repository.VereinRepository
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.officials.domain.repository.FunktionaerRepository
import at.mocode.persons.domain.repository.ReiterRepository
import at.mocode.zns.importer.ZnsImportService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.springframework.stereotype.Service
@Service
class ZnsImportOrchestrator(
private val vereinRepository: VereinRepository,
private val reiterRepository: ReiterRepository,
private val horseRepository: HorseRepository,
private val funktionaerRepository: FunktionaerRepository,
private val jobRegistry: ImportJobRegistry
) {
private val scope = CoroutineScope(Dispatchers.IO)
fun starteImport(jobId: String, zipBytes: ByteArray) {
scope.launch {
runCatching {
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.ENTPACKEN, "Entpacke ZIP-Datei...", 5)
val service = ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository)
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.LADE_VEREINE, "Lade Vereine...", 20)
val result = service.importiereZip(zipBytes.inputStream())
jobRegistry.aktualisiereStatus(
jobId, ImportJobStatus.ABGESCHLOSSEN,
"Import abgeschlossen: ${result.vereineImportiert} Vereine, " +
"${result.reiterImportiert} Reiter, ${result.pferdeImportiert} Pferde, " +
"${result.richterImportiert} Richter importiert.", 100
)
jobRegistry.findeJob(jobId)?.let { job ->
job.fehler.addAll(result.fehler)
job.warnungen.addAll(result.warnungen)
}
}.onFailure { ex ->
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.FEHLER, "Fehler: ${ex.message}")
jobRegistry.findeJob(jobId)?.fehler?.add(ex.message ?: "Unbekannter Fehler")
}
}
}
}
@@ -0,0 +1,34 @@
server:
port: ${ZNS_IMPORT_SERVER_PORT:8095}
spring:
application:
name: zns-import-service
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db}
username: ${SPRING_DATASOURCE_USERNAME:pg-user}
password: ${SPRING_DATASOURCE_PASSWORD:pg-password}
driver-class-name: org.postgresql.Driver
servlet:
multipart:
enabled: true
max-file-size: 50MB
max-request-size: 50MB
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
app:
service-name: ${spring.application.name}