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:
@@ -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()
|
||||
}
|
||||
+21
@@ -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)
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
+28
@@ -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()
|
||||
}
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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}
|
||||
Reference in New Issue
Block a user