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,39 @@
package at.mocode.core.utils.parser
/**
* A simple utility to parse fixed-width strings based on 1-based start positions and lengths.
* This is particularly useful for parsing legacy data formats like the OePS ZNS formats.
*/
class FixedWidthLineReader(private val line: String) {
/**
* Extracts a string from the given 1-based start position with the given length.
* Trims leading and trailing whitespace.
* Returns an empty string if the position is out of bounds.
*/
fun getString(start1Based: Int, length: Int): String {
val start0Based = start1Based - 1
if (start0Based >= line.length) return ""
val end0Based = minOf(start0Based + length, line.length)
return line.substring(start0Based, end0Based).trim()
}
/**
* Extracts a string and parses it as a Long.
* Returns null if the field is empty or cannot be parsed.
*/
fun getLongOrNull(start1Based: Int, length: Int): Long? {
val str = getString(start1Based, length)
return str.toLongOrNull()
}
/**
* Extracts a string and parses it as an Int.
* Returns null if the field is empty or cannot be parsed.
*/
fun getIntOrNull(start1Based: Int, length: Int): Int? {
val str = getString(start1Based, length)
return str.toIntOrNull()
}
}
+30
View File
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
sourceSets {
commonMain {
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// Domänen-Modelle für das Parsing
implementation(projects.backend.services.clubs.clubsDomain)
implementation(projects.backend.services.persons.personsDomain)
implementation(projects.backend.services.horses.horsesDomain)
implementation(projects.backend.services.officials.officialsDomain)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
}
}
}
}
@@ -0,0 +1,156 @@
package at.mocode.zns.parser
import at.mocode.clubs.domain.model.DomVerein
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.utils.parser.FixedWidthLineReader
import at.mocode.horses.domain.model.DomPferd
import at.mocode.officials.domain.model.DomFunktionaer
import at.mocode.persons.domain.model.DomReiter
import kotlinx.datetime.LocalDate
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Parsers for the legacy ZNS .dat files (Fixed-Width format, CP850 encoded).
*
* This module is independent of backend infrastructure so it can be used
* by both the backend API and the Compose Desktop app (Offline-First).
*/
@OptIn(ExperimentalUuidApi::class)
object ZnsLegacyParsers {
/**
* Parses a line from VEREIN01.DAT.
*/
fun parseVerein(line: String): DomVerein? {
if (line.isBlank() || line.length < 5) return null
val reader = FixedWidthLineReader(line)
val vereinsNummer = reader.getString(1, 4)
val vereinsName = reader.getString(5, 50)
if (vereinsNummer.isBlank() || vereinsName.isBlank()) return null
return DomVerein(
vereinsNummer = vereinsNummer,
name = vereinsName,
datenQuelle = DatenQuelleE.IMPORT_ZNS
)
}
/**
* Parses a line from LIZENZ01.DAT.
*/
fun parseLizenz(line: String): DomReiter? {
if (line.isBlank() || line.length < 57) return null
val reader = FixedWidthLineReader(line)
val satznummer = reader.getString(1, 6)
if (satznummer.isBlank()) return null
val nachname = reader.getString(7, 50)
val vorname = reader.getString(57, 25)
val vereinsName = reader.getString(84, 50)
val nation = reader.getString(134, 3)
val lizenzString = reader.getString(137, 4)
val lizenz = mapLizenz(lizenzString)
val sperrlisteFlag = reader.getString(200, 1)
val gesperrt = sperrlisteFlag == "S"
return DomReiter(
personId = Uuid.random(),
satznummer = satznummer,
nachname = nachname,
vorname = vorname,
vereinsName = vereinsName.ifBlank { null },
nation = nation.ifBlank { null },
lizenzKlasse = lizenz,
istAktiv = !gesperrt,
datenQuelle = DatenQuelleE.IMPORT_ZNS
)
}
/**
* Parses a line from PFERDE01.DAT.
*/
fun parsePferd(line: String): DomPferd? {
if (line.isBlank() || line.length < 202) return null
val reader = FixedWidthLineReader(line)
val satznummer = reader.getString(202, 10)
if (satznummer.isBlank()) return null
val name = reader.getString(5, 30)
val kopfnummer = reader.getString(1, 4)
val lebensnummer = reader.getString(35, 9)
val geschlechtChar = reader.getString(44, 1)
val geschlecht = mapGeschlecht(geschlechtChar)
val geburtsjahr = reader.getIntOrNull(45, 4)
val geburtsdatum = geburtsjahr?.let { LocalDate(it, 1, 1) }
return DomPferd(
pferdeName = name,
geschlecht = geschlecht,
geburtsdatum = geburtsdatum,
lebensnummer = lebensnummer.ifBlank { null },
datenQuelle = DatenQuelleE.IMPORT_ZNS
)
}
/**
* Parses a line from RICHT01.DAT.
*/
fun parseRichter(line: String): DomFunktionaer? {
if (line.isBlank() || line.length < 8) return null
val reader = FixedWidthLineReader(line)
val satznummer = reader.getString(2, 6)
if (satznummer.isBlank()) return null
val fullName = reader.getString(8, 75)
val parts = fullName.split(",").map { it.trim() }
val nachname = parts.getOrNull(0) ?: fullName
val vorname = parts.getOrNull(1) ?: ""
return DomFunktionaer(
richterNummer = satznummer,
nachname = nachname,
vorname = vorname,
datenQuelle = DatenQuelleE.IMPORT_ZNS
)
}
private fun mapLizenz(lizenz: String): LizenzKlasseE {
return when (lizenz.uppercase()) {
"R1" -> LizenzKlasseE.R1
"R2" -> LizenzKlasseE.R2
"R3" -> LizenzKlasseE.R3
"RD1" -> LizenzKlasseE.RD1
"RD2" -> LizenzKlasseE.RD2
"RD3" -> LizenzKlasseE.RD3
"JN" -> LizenzKlasseE.JN
"JG" -> LizenzKlasseE.JG
"YR" -> LizenzKlasseE.YR
else -> LizenzKlasseE.LIZENZFREI
}
}
private fun mapGeschlecht(geschlecht: String): PferdeGeschlechtE {
return when (geschlecht.uppercase()) {
"W" -> PferdeGeschlechtE.WALLACH
"S" -> PferdeGeschlechtE.STUTE
"H" -> PferdeGeschlechtE.HENGST
else -> PferdeGeschlechtE.UNBEKANNT
}
}
}
@@ -0,0 +1,80 @@
package at.mocode.zns.parser
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.PferdeGeschlechtE
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class ZnsLegacyParsersTest {
@Test
fun `parseVerein should extract VEREIN01 correctly`() {
val line = "1234Reitverein Test "
val result = ZnsLegacyParsers.parseVerein(line)
assertNotNull(result)
assertEquals("1234", result.vereinsNummer)
assertEquals("Reitverein Test", result.name)
}
@Test
fun `parseLizenz should extract LIZENZ01 correctly`() {
val sb = StringBuilder()
sb.append("123456")
sb.append("Mustermann ")
sb.append("Max ")
sb.append("01")
sb.append("Reitverein Wien ")
sb.append("AUT")
sb.append("R1 ")
while (sb.length < 199) {
sb.append(" ")
}
sb.append("S")
val result = ZnsLegacyParsers.parseLizenz(sb.toString())
assertNotNull(result)
assertEquals("123456", result.satznummer)
assertEquals("Mustermann", result.nachname)
assertEquals("Max", result.vorname)
assertEquals("Reitverein Wien", result.vereinsName)
assertEquals(LizenzKlasseE.R1, result.lizenzKlasse)
assertEquals(false, result.istAktiv)
}
@Test
fun `parsePferd should extract PFERDE01 correctly`() {
val sb = StringBuilder()
sb.append("A123")
sb.append("Black Beauty ")
sb.append("123456789")
sb.append("W")
sb.append("2010")
while (sb.length < 201) {
sb.append(" ")
}
sb.append("0000000001")
val result = ZnsLegacyParsers.parsePferd(sb.toString())
assertNotNull(result)
assertEquals("Black Beauty", result.pferdeName)
assertEquals("123456789", result.lebensnummer)
assertEquals(PferdeGeschlechtE.WALLACH, result.geschlecht)
assertEquals(2010, result.geburtsdatum?.year)
}
@Test
fun `parseRichter should extract RICHT01 correctly`() {
val line =
"X123456Richter, Peter GA "
val result = ZnsLegacyParsers.parseRichter(line)
assertNotNull(result)
assertEquals("123456", result.richterNummer)
assertEquals("Richter", result.nachname)
assertEquals("Peter", result.vorname)
}
}