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,16 @@
plugins {
alias(libs.plugins.kotlinJvm)
}
dependencies {
implementation(platform(projects.platform.platformBom))
implementation(projects.core.coreUtils)
implementation(projects.core.coreDomain)
implementation(projects.core.znsParser)
implementation(projects.backend.services.clubs.clubsDomain)
implementation(projects.backend.services.persons.personsDomain)
implementation(projects.backend.services.horses.horsesDomain)
implementation(projects.backend.services.officials.officialsDomain)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,38 @@
package at.mocode.zns.importer
/**
* Ergebnis eines ZNS-Imports.
*
* Enthält Zähler für importierte, aktualisierte und übersprungene Datensätze
* sowie eine Liste von Fehlern/Warnungen, die während des Imports aufgetreten sind.
*/
data class ZnsImportResult(
val vereineImportiert: Int = 0,
val vereineAktualisiert: Int = 0,
val reiterImportiert: Int = 0,
val reiterAktualisiert: Int = 0,
val pferdeImportiert: Int = 0,
val pferdeAktualisiert: Int = 0,
val richterImportiert: Int = 0,
val richterAktualisiert: Int = 0,
val fehler: List<String> = emptyList(),
val warnungen: List<String> = emptyList()
) {
val gesamtImportiert: Int
get() = vereineImportiert + reiterImportiert + pferdeImportiert + richterImportiert
val gesamtAktualisiert: Int
get() = vereineAktualisiert + reiterAktualisiert + pferdeAktualisiert + richterAktualisiert
val hatFehler: Boolean
get() = fehler.isNotEmpty()
fun zusammenfassung(): String =
"ZNS-Import abgeschlossen: " +
"$gesamtImportiert neu importiert, $gesamtAktualisiert aktualisiert. " +
"Vereine: ${vereineImportiert}N/${vereineAktualisiert}U, " +
"Reiter: ${reiterImportiert}N/${reiterAktualisiert}U, " +
"Pferde: ${pferdeImportiert}N/${pferdeAktualisiert}U, " +
"Richter: ${richterImportiert}N/${richterAktualisiert}U. " +
if (hatFehler) "${fehler.size} Fehler aufgetreten." else "Keine Fehler."
}
@@ -0,0 +1,243 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.zns.importer
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.parser.ZnsLegacyParsers
import java.io.InputStream
import java.nio.charset.Charset
import java.util.zip.ZipInputStream
/**
* Orchestrator für den ZNS-Stammdaten-Import.
*
* Nimmt eine ZNS-ZIP-Datei entgegen, entpackt sie in-memory, liest die
* enthaltenen .DAT-Dateien mit CP850-Encoding und persistiert die geparsten
* Domänenobjekte über die jeweiligen Repositories (Upsert-Logik).
*
* Die Verarbeitungsreihenfolge ist fix:
* 1. VEREIN01.DAT → DomVerein (via VereinRepository)
* 2. LIZENZ01.DAT → DomReiter (via ReiterRepository)
* 3. PFERDE01.DAT → DomPferd (via HorseRepository)
* 4. RICHT01.DAT → DomFunktionaer (via FunktionaerRepository)
*
* Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl
* im Backend (REST-Upload) als auch in der Compose Desktop App (Offline-Import)
* verwendet werden.
*
* @param vereinRepository Repository für Vereine.
* @param reiterRepository Repository für Reiter/Personen.
* @param horseRepository Repository für Pferde.
* @param funktionaerRepository Repository für Funktionäre/Richter.
*/
class ZnsImportService(
private val vereinRepository: VereinRepository,
private val reiterRepository: ReiterRepository,
private val horseRepository: HorseRepository,
private val funktionaerRepository: FunktionaerRepository
) {
companion object {
private val CP850 = Charset.forName("Cp850")
private const val FILE_VEREIN = "VEREIN01.DAT"
private const val FILE_LIZENZ = "LIZENZ01.DAT"
private const val FILE_PFERDE = "PFERDE01.DAT"
private const val FILE_RICHT = "RICHT01.DAT"
}
/**
* Importiert eine ZNS-ZIP-Datei aus einem [InputStream].
*
* @param zipInputStream Der InputStream der ZIP-Datei.
* @return [ZnsImportResult] mit Statistiken und eventuellen Fehlern.
*/
suspend fun importiereZip(zipInputStream: InputStream): ZnsImportResult {
val dateien = mutableMapOf<String, List<String>>()
ZipInputStream(zipInputStream).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val name = entry.name.uppercase().substringAfterLast("/")
if (name in setOf(FILE_VEREIN, FILE_LIZENZ, FILE_PFERDE, FILE_RICHT)) {
dateien[name] = zip.readBytes().toString(CP850).lines()
}
zip.closeEntry()
entry = zip.nextEntry
}
}
val fehler = mutableListOf<String>()
val warnungen = mutableListOf<String>()
val (vereineNeu, vereineUpd) = importiereVereine(dateien[FILE_VEREIN] ?: emptyList(), fehler)
val (reiterNeu, reiterUpd) = importiereReiter(dateien[FILE_LIZENZ] ?: emptyList(), fehler, warnungen)
val (pferdeNeu, pferdeUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler)
val (richterNeu, richterUpd) = importiereRichter(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen)
return ZnsImportResult(
vereineImportiert = vereineNeu,
vereineAktualisiert = vereineUpd,
reiterImportiert = reiterNeu,
reiterAktualisiert = reiterUpd,
pferdeImportiert = pferdeNeu,
pferdeAktualisiert = pferdeUpd,
richterImportiert = richterNeu,
richterAktualisiert = richterUpd,
fehler = fehler,
warnungen = warnungen
)
}
// -------------------------------------------------------------------------
// Private Hilfsmethoden
// -------------------------------------------------------------------------
private suspend fun importiereVereine(
zeilen: List<String>,
fehler: MutableList<String>
): Pair<Int, Int> {
var neu = 0
var aktualisiert = 0
zeilen.forEachIndexed { index, zeile ->
runCatching {
val verein = ZnsLegacyParsers.parseVerein(zeile) ?: return@forEachIndexed
val vorhanden = vereinRepository.findByVereinsNummer(verein.vereinsNummer)
if (vorhanden == null) {
vereinRepository.save(verein)
neu++
} else {
vereinRepository.save(
vorhanden.copy(
name = verein.name,
kurzname = verein.kurzname,
bundesland = verein.bundesland,
ort = verein.ort,
plz = verein.plz,
strasse = verein.strasse,
oepsRegionNummer = verein.oepsRegionNummer,
istAktiv = verein.istAktiv,
datenQuelle = verein.datenQuelle
).withUpdatedTimestamp()
)
aktualisiert++
}
}.onFailure { e ->
fehler.add("$FILE_VEREIN Zeile ${index + 1}: ${e.message}")
}
}
return Pair(neu, aktualisiert)
}
private suspend fun importiereReiter(
zeilen: List<String>,
fehler: MutableList<String>,
warnungen: MutableList<String>
): Pair<Int, Int> {
var neu = 0
var aktualisiert = 0
zeilen.forEachIndexed { index, zeile ->
runCatching {
val reiter = ZnsLegacyParsers.parseLizenz(zeile) ?: return@forEachIndexed
val vorhanden = reiterRepository.findBySatznummer(reiter.satznummer)
if (vorhanden == null) {
reiterRepository.save(reiter)
neu++
} else {
reiterRepository.save(
vorhanden.copy(
vorname = reiter.vorname,
nachname = reiter.nachname,
vereinsName = reiter.vereinsName,
nation = reiter.nation,
lizenzKlasse = reiter.lizenzKlasse,
istAktiv = reiter.istAktiv,
datenQuelle = reiter.datenQuelle
).withUpdatedTimestamp()
)
aktualisiert++
}
}.onFailure { e ->
fehler.add("$FILE_LIZENZ Zeile ${index + 1}: ${e.message}")
}
}
return Pair(neu, aktualisiert)
}
private suspend fun importierePferde(
zeilen: List<String>,
fehler: MutableList<String>
): Pair<Int, Int> {
var neu = 0
var aktualisiert = 0
zeilen.forEachIndexed { index, zeile ->
runCatching {
val pferd = ZnsLegacyParsers.parsePferd(zeile) ?: return@forEachIndexed
val vorhanden = pferd.lebensnummer
?.takeIf { it.isNotBlank() }
?.let { horseRepository.findByLebensnummer(it) }
if (vorhanden == null) {
horseRepository.save(pferd)
neu++
} else {
horseRepository.save(
vorhanden.copy(
pferdeName = pferd.pferdeName,
geschlecht = pferd.geschlecht,
geburtsdatum = pferd.geburtsdatum,
rasse = pferd.rasse,
lebensnummer = pferd.lebensnummer,
oepsNummer = pferd.oepsNummer,
istAktiv = pferd.istAktiv,
datenQuelle = pferd.datenQuelle
).withUpdatedTimestamp()
)
aktualisiert++
}
}.onFailure { e ->
fehler.add("$FILE_PFERDE Zeile ${index + 1}: ${e.message}")
}
}
return Pair(neu, aktualisiert)
}
private suspend fun importiereRichter(
zeilen: List<String>,
fehler: MutableList<String>,
warnungen: MutableList<String>
): Pair<Int, Int> {
var neu = 0
var aktualisiert = 0
zeilen.forEachIndexed { index, zeile ->
runCatching {
val richter = ZnsLegacyParsers.parseRichter(zeile) ?: return@forEachIndexed
val richterNummer = richter.richterNummer ?: run {
warnungen.add("$FILE_RICHT Zeile ${index + 1}: Keine RichterNummer übersprungen.")
return@forEachIndexed
}
val vorhanden = funktionaerRepository.findByRichterNummer(richterNummer)
if (vorhanden == null) {
funktionaerRepository.save(richter)
neu++
} else {
funktionaerRepository.save(
vorhanden.copy(
vorname = richter.vorname,
nachname = richter.nachname,
vereinsNummer = richter.vereinsNummer,
richterNummer = richter.richterNummer,
istAktiv = richter.istAktiv,
datenQuelle = richter.datenQuelle
).withUpdatedTimestamp()
)
aktualisiert++
}
}.onFailure { e ->
fehler.add("$FILE_RICHT Zeile ${index + 1}: ${e.message}")
}
}
return Pair(neu, aktualisiert)
}
}
@@ -0,0 +1,219 @@
package at.mocode.zns.importer
import at.mocode.clubs.domain.model.DomVerein
import at.mocode.clubs.domain.repository.VereinRepository
import at.mocode.horses.domain.model.DomPferd
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.officials.domain.model.DomFunktionaer
import at.mocode.officials.domain.repository.FunktionaerRepository
import at.mocode.persons.domain.model.DomReiter
import at.mocode.persons.domain.repository.ReiterRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.nio.charset.Charset
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@OptIn(kotlin.uuid.ExperimentalUuidApi::class)
class ZnsImportServiceTest {
private val vereinRepository = mockk<VereinRepository>()
private val reiterRepository = mockk<ReiterRepository>()
private val horseRepository = mockk<HorseRepository>()
private val funktionaerRepository = mockk<FunktionaerRepository>()
private lateinit var service: ZnsImportService
private val cp850 = Charset.forName("Cp850")
@BeforeEach
fun setUp() {
service = ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository)
}
// -------------------------------------------------------------------------
// Hilfsmethoden
// -------------------------------------------------------------------------
/** Erstellt eine in-memory ZIP-Datei mit den übergebenen Einträgen. */
private fun buildZip(vararg entries: Pair<String, String>): ByteArrayInputStream {
val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zip ->
for ((name, content) in entries) {
zip.putNextEntry(ZipEntry(name))
zip.write(content.toByteArray(cp850))
zip.closeEntry()
}
}
return ByteArrayInputStream(baos.toByteArray())
}
/** Erzeugt eine gültige VEREIN01.DAT-Zeile (mind. 30 Zeichen). */
private fun vereinZeile(
nummer: String = "0001",
name: String = "Testverein Wien"
): String {
// Stelle 1-4: Vereinsnummer, Stelle 5-34: Name (30 Zeichen)
return nummer.padEnd(4) + name.padEnd(30)
}
/** Erzeugt eine gültige LIZENZ01.DAT-Zeile (mind. 200 Zeichen). */
private fun lizenzZeile(
satznummer: String = "123456",
nachname: String = "Mustermann",
vorname: String = "Max"
): String {
// Stelle 1-6: Satznummer, 7-56: Nachname (50), 57-81: Vorname (25)
return satznummer.padEnd(6) +
nachname.padEnd(50) +
vorname.padEnd(25) +
" ".repeat(200) // Rest auffüllen
}
/** Erzeugt eine gültige PFERDE01.DAT-Zeile (mind. 211 Zeichen). */
private fun pferdeZeile(
name: String = "Blitz",
lebensnummer: String = "AT123456"
): String {
// Stelle 1-4: Kopfnummer, 5-34: Name (30), 35-43: Lebensnummer (9), 44: Geschlecht, 45-48: Geburtsjahr, 202-211: Satznummer
val base = "0001" +
name.padEnd(30) +
lebensnummer.padEnd(9) +
"W" + // Geschlecht: Wallach
"2015" + // Geburtsjahr
" ".repeat(157) // Auffüllen bis Stelle 201
return base + "SAT0000001".padEnd(10) // Satznummer ab Stelle 202
}
/** Erzeugt eine gültige RICHT01.DAT-Zeile (mind. 83 Zeichen). */
private fun richterZeile(
satznummer: String = "R00001",
name: String = "Huber, Anna"
): String {
// Stelle 1: Typ, 2-7: Satznummer (6), 8-82: Name (75)
return "R" + satznummer.padEnd(6) + name.padEnd(75)
}
// -------------------------------------------------------------------------
// Tests
// -------------------------------------------------------------------------
@Test
fun `importiereZip - neue Vereine werden gespeichert`() = runTest {
val zip = buildZip("VEREIN01.DAT" to vereinZeile())
coEvery { vereinRepository.findByVereinsNummer(any()) } returns null
coEvery { vereinRepository.save(any()) } answers { firstArg() }
val result = service.importiereZip(zip)
assertThat(result.vereineImportiert).isEqualTo(1)
assertThat(result.vereineAktualisiert).isEqualTo(0)
assertThat(result.fehler).isEmpty()
coVerify(exactly = 1) { vereinRepository.save(any<DomVerein>()) }
}
@Test
fun `importiereZip - bestehende Vereine werden aktualisiert`() = runTest {
val zip = buildZip("VEREIN01.DAT" to vereinZeile(name = "Neuer Name"))
val vorhanden = DomVerein(vereinsNummer = "0001", name = "Alter Name")
coEvery { vereinRepository.findByVereinsNummer("0001") } returns vorhanden
coEvery { vereinRepository.save(any()) } answers { firstArg() }
val result = service.importiereZip(zip)
assertThat(result.vereineImportiert).isEqualTo(0)
assertThat(result.vereineAktualisiert).isEqualTo(1)
coVerify(exactly = 1) { vereinRepository.save(match { it.name == "Neuer Name" }) }
}
@Test
fun `importiereZip - neue Reiter werden gespeichert`() = runTest {
val zip = buildZip("LIZENZ01.DAT" to lizenzZeile())
coEvery { reiterRepository.findBySatznummer(any()) } returns null
coEvery { reiterRepository.save(any()) } answers { firstArg() }
val result = service.importiereZip(zip)
assertThat(result.reiterImportiert).isEqualTo(1)
assertThat(result.reiterAktualisiert).isEqualTo(0)
assertThat(result.fehler).isEmpty()
coVerify(exactly = 1) { reiterRepository.save(any<DomReiter>()) }
}
@Test
fun `importiereZip - neue Pferde werden gespeichert`() = runTest {
val zip = buildZip("PFERDE01.DAT" to pferdeZeile())
coEvery { horseRepository.findByLebensnummer(any()) } returns null
coEvery { horseRepository.save(any()) } answers { firstArg() }
val result = service.importiereZip(zip)
assertThat(result.pferdeImportiert).isEqualTo(1)
assertThat(result.pferdeAktualisiert).isEqualTo(0)
assertThat(result.fehler).isEmpty()
coVerify(exactly = 1) { horseRepository.save(any<DomPferd>()) }
}
@Test
fun `importiereZip - neue Richter werden gespeichert`() = runTest {
val zip = buildZip("RICHT01.DAT" to richterZeile())
coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null
coEvery { funktionaerRepository.save(any()) } answers { firstArg() }
val result = service.importiereZip(zip)
assertThat(result.richterImportiert).isEqualTo(1)
assertThat(result.richterAktualisiert).isEqualTo(0)
assertThat(result.fehler).isEmpty()
coVerify(exactly = 1) { funktionaerRepository.save(any<DomFunktionaer>()) }
}
@Test
fun `importiereZip - vollstaendiger Import aller vier Dateien`() = runTest {
val zip = buildZip(
"VEREIN01.DAT" to vereinZeile(),
"LIZENZ01.DAT" to lizenzZeile(),
"PFERDE01.DAT" to pferdeZeile(),
"RICHT01.DAT" to richterZeile()
)
coEvery { vereinRepository.findByVereinsNummer(any()) } returns null
coEvery { vereinRepository.save(any()) } answers { firstArg() }
coEvery { reiterRepository.findBySatznummer(any()) } returns null
coEvery { reiterRepository.save(any()) } answers { firstArg() }
coEvery { horseRepository.findByLebensnummer(any()) } returns null
coEvery { horseRepository.save(any()) } answers { firstArg() }
coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null
coEvery { funktionaerRepository.save(any()) } answers { firstArg() }
val result = service.importiereZip(zip)
assertThat(result.gesamtImportiert).isEqualTo(4)
assertThat(result.gesamtAktualisiert).isEqualTo(0)
assertThat(result.fehler).isEmpty()
assertThat(result.hatFehler).isFalse()
}
@Test
fun `importiereZip - leere ZIP ergibt Null-Ergebnis ohne Fehler`() = runTest {
val zip = buildZip()
val result = service.importiereZip(zip)
assertThat(result.gesamtImportiert).isEqualTo(0)
assertThat(result.gesamtAktualisiert).isEqualTo(0)
assertThat(result.fehler).isEmpty()
}
}