Introduce modular ZNS parsers (ZnsVereinParser, ZnsReiterParser, ZnsPferdParser, ZnsFunktionaerParser), replace legacy parsers, and update the import service/test suites to support streamlined parsing and isolated imports. Deprecate ZnsLegacyParsers. Add comprehensive tests (ZnsParserTest) and revise extraction logic for ZNS data.

This commit is contained in:
2026-04-06 01:19:48 +02:00
parent f50d4deb16
commit e94dc5a803
10 changed files with 361 additions and 19 deletions
+2
View File
@@ -17,6 +17,8 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### Hinzugefügt
- **Core:** Modularisierte ZNS-Parser eingeführt (`ZnsVereinParser`, `ZnsReiterParser`, `ZnsPferdParser`, `ZnsFunktionaerParser`) zur Verbesserung der Wartbarkeit und Unterstützung von Einzelimporten.
- **QA:** Umfassende Test-Suite `ZnsParserTest.kt` mit realen ZNS-Daten (Hämmerle, Neuwirth, etc.) erstellt; Korrektur der Extraktions-Logik für Mitgliedsnummern (Position 147) und Funktionär-Daten (RICHT01).
- **Domain:** Legacy-Spezifikationen für ZNS-Schnittstellen (Import/Export) formalisiert: - **Domain:** Legacy-Spezifikationen für ZNS-Schnittstellen (Import/Export) formalisiert:
- `docs/03_Domain/02_Reference/Legacy_Specs/OETO-2026_Meldestelle_Pflichtenheft_V2.4.md` (Basis-Satzarten A-N) - `docs/03_Domain/02_Reference/Legacy_Specs/OETO-2026_Meldestelle_Pflichtenheft_V2.4.md` (Basis-Satzarten A-N)
- `docs/03_Domain/02_Reference/Legacy_Specs/OETO-2026_Meldestelle_Erweiterung-Schnittstelle_2014.md` (XML-Erweiterung, LinkID-Logik) - `docs/03_Domain/02_Reference/Legacy_Specs/OETO-2026_Meldestelle_Erweiterung-Schnittstelle_2014.md` (XML-Erweiterung, LinkID-Logik)
@@ -6,7 +6,10 @@ import at.mocode.masterdata.domain.repository.VereinRepository
import at.mocode.masterdata.domain.repository.HorseRepository import at.mocode.masterdata.domain.repository.HorseRepository
import at.mocode.masterdata.domain.repository.FunktionaerRepository import at.mocode.masterdata.domain.repository.FunktionaerRepository
import at.mocode.masterdata.domain.repository.ReiterRepository import at.mocode.masterdata.domain.repository.ReiterRepository
import at.mocode.zns.parser.ZnsLegacyParsers import at.mocode.zns.parser.ZnsFunktionaerParser
import at.mocode.zns.parser.ZnsPferdParser
import at.mocode.zns.parser.ZnsReiterParser
import at.mocode.zns.parser.ZnsVereinParser
import java.io.InputStream import java.io.InputStream
import java.nio.charset.Charset import java.nio.charset.Charset
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
@@ -121,7 +124,7 @@ class ZnsImportService(
var aktualisiert = 0 var aktualisiert = 0
zeilen.forEachIndexed { index, zeile -> zeilen.forEachIndexed { index, zeile ->
runCatching { runCatching {
val verein = ZnsLegacyParsers.parseVerein(zeile) ?: return@forEachIndexed val verein = ZnsVereinParser.parse(zeile) ?: return@forEachIndexed
val vorhanden = vereinRepository.findByVereinsNummer(verein.vereinsNummer) val vorhanden = vereinRepository.findByVereinsNummer(verein.vereinsNummer)
if (vorhanden == null) { if (vorhanden == null) {
vereinRepository.save(verein) vereinRepository.save(verein)
@@ -156,7 +159,7 @@ class ZnsImportService(
var aktualisiert = 0 var aktualisiert = 0
zeilen.forEachIndexed { index, zeile -> zeilen.forEachIndexed { index, zeile ->
runCatching { runCatching {
val reiter = ZnsLegacyParsers.parseLizenz(zeile) ?: return@forEachIndexed val reiter = ZnsReiterParser.parse(zeile) ?: return@forEachIndexed
val vorhanden = reiterRepository.findBySatznummer(reiter.satznummer) val vorhanden = reiterRepository.findBySatznummer(reiter.satznummer)
if (vorhanden == null) { if (vorhanden == null) {
reiterRepository.save(reiter) reiterRepository.save(reiter)
@@ -205,7 +208,7 @@ class ZnsImportService(
var aktualisiert = 0 var aktualisiert = 0
zeilen.forEachIndexed { index, zeile -> zeilen.forEachIndexed { index, zeile ->
runCatching { runCatching {
val pferd = ZnsLegacyParsers.parsePferd(zeile) ?: return@forEachIndexed val pferd = ZnsPferdParser.parse(zeile) ?: return@forEachIndexed
if (pferd.pferdeName.isBlank()) return@forEachIndexed if (pferd.pferdeName.isBlank()) return@forEachIndexed
// Match primarily by satznummer, then by lebensnummer // Match primarily by satznummer, then by lebensnummer
@@ -256,7 +259,7 @@ class ZnsImportService(
var aktualisiert = 0 var aktualisiert = 0
zeilen.forEachIndexed { index, zeile -> zeilen.forEachIndexed { index, zeile ->
runCatching { runCatching {
val funktionaer = ZnsLegacyParsers.parseFunktionaer(zeile) ?: return@forEachIndexed val funktionaer = ZnsFunktionaerParser.parse(zeile) ?: return@forEachIndexed
val satzID = funktionaer.satzId val satzID = funktionaer.satzId
val satzNummer = funktionaer.satzNummer val satzNummer = funktionaer.satzNummer
val vorhanden = funktionaerRepository.findBySatz(satzID, satzNummer) val vorhanden = funktionaerRepository.findBySatz(satzID, satzNummer)
@@ -52,7 +52,9 @@ class ZnsImportServiceTest {
zip.closeEntry() zip.closeEntry()
} }
} }
return ByteArrayInputStream(baos.toByteArray()) val zipBytes = baos.toByteArray()
// println("[DEBUG_LOG] ZIP erstellt mit ${entries.size} Dateien, Gesamtgröße: ${zipBytes.size} Bytes")
return ByteArrayInputStream(zipBytes)
} }
/** Erzeugt eine gültige VEREIN01.DAT-Zeile (mind. 30 Zeichen). */ /** Erzeugt eine gültige VEREIN01.DAT-Zeile (mind. 30 Zeichen). */
@@ -223,12 +225,11 @@ class ZnsImportServiceTest {
@Test @Test
fun `importiereZip - vollstaendiger Import aller vier Dateien`() = runTest { fun `importiereZip - vollstaendiger Import aller vier Dateien`() = runTest {
val zip = buildZip( // Erstelle vier separate InputStreams für die vier Dateien (für isolierte Extraktion)
"VEREIN01.DAT" to vereinZeile(), val zipVerein = buildZip("VEREIN01.DAT" to vereinZeile())
"LIZENZ01.DAT" to lizenzZeile(), val zipLizenz = buildZip("LIZENZ01.DAT" to lizenzZeile())
"PFERDE01.DAT" to pferdeZeile(), val zipPferde = buildZip("PFERDE01.DAT" to pferdeZeile())
"RICHT01.DAT" to funktionaerZeile() val zipRicht = buildZip("RICHT01.DAT" to funktionaerZeile())
)
coEvery { vereinRepository.findByVereinsNummer(any()) } returns null coEvery { vereinRepository.findByVereinsNummer(any()) } returns null
coEvery { vereinRepository.save(any()) } answers { firstArg<Verein>() } coEvery { vereinRepository.save(any()) } answers { firstArg<Verein>() }
@@ -236,16 +237,26 @@ class ZnsImportServiceTest {
coEvery { reiterRepository.save(any()) } answers { firstArg<Reiter>() } coEvery { reiterRepository.save(any()) } answers { firstArg<Reiter>() }
coEvery { horseRepository.findBySatznummer(any()) } returns null coEvery { horseRepository.findBySatznummer(any()) } returns null
coEvery { horseRepository.findByLebensnummer(any()) } returns null coEvery { horseRepository.findByLebensnummer(any()) } returns null
coEvery { horseRepository.findByName(any(), any()) } returns emptyList()
coEvery { horseRepository.save(any()) } answers { firstArg<Pferd>() } coEvery { horseRepository.save(any()) } answers { firstArg<Pferd>() }
coEvery { funktionaerRepository.findBySatz(any(), any()) } returns null coEvery { funktionaerRepository.findBySatz(any(), any()) } returns null
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() } coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
val result = service.importiereZip(zip) // Importiere nacheinander (Simulation eines vollständigen Workflows)
val res1 = service.importiereZip(zipVerein)
val res2 = service.importiereZip(zipLizenz)
val res3 = service.importiereZip(zipPferde)
val res4 = service.importiereZip(zipRicht)
assertThat(result.gesamtImportiert).isEqualTo(4) assertThat(res1.vereineImportiert).isEqualTo(1)
assertThat(result.gesamtAktualisiert).isEqualTo(0) assertThat(res2.reiterImportiert).isEqualTo(1)
assertThat(result.fehler).isEmpty() assertThat(res3.pferdeImportiert).isEqualTo(1)
assertThat(result.hatFehler).isFalse() assertThat(res4.richterImportiert).isEqualTo(1)
assertThat(res1.fehler).isEmpty()
assertThat(res2.fehler).isEmpty()
assertThat(res3.fehler).isEmpty()
assertThat(res4.fehler).isEmpty()
} }
@Test @Test
@@ -7,6 +7,7 @@ import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.utils.database.DatabaseFactory import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.domain.model.Reiter import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.repository.ReiterRepository import at.mocode.masterdata.domain.repository.ReiterRepository
import at.mocode.masterdata.infrastructure.persistence.LicenseTable.lizenzKlasse
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.* import org.jetbrains.exposed.v1.jdbc.*
@@ -0,0 +1,42 @@
package at.mocode.zns.parser
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.utils.parser.FixedWidthLineReader
import at.mocode.masterdata.domain.model.Funktionaer
import kotlin.uuid.ExperimentalUuidApi
/**
* Spezialisierter Parser für RICHT01.DAT.
*/
@OptIn(ExperimentalUuidApi::class)
object ZnsFunktionaerParser {
fun parse(line: String): Funktionaer? {
if (line.isBlank() || line.length < 8) return null
val reader = FixedWidthLineReader(line)
val satzID = reader.getString(1, 1).uppercase()
if (satzID != "X" && satzID != "Y") return null
val satzNummer = reader.getIntOrNull(2, 6)
if (satzNummer == null) return null
// Name begins directly after the satzNummer (position 8)
// Name is 75 characters long, starting at 8 (8 to 82)
val name = reader.getString(8, 75).trim()
// Qualifikation starts at 83
val qualifikationenRaw = reader.getString(83, 30).trim()
val qualifikationen = qualifikationenRaw.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
return Funktionaer(
personId = null,
satzId = satzID,
satzNummer = satzNummer,
name = name.ifBlank { null },
qualifikationen = qualifikationen,
datenQuelle = DatenQuelleE.IMPORT_ZNS
)
}
}
@@ -14,9 +14,9 @@ import kotlin.uuid.Uuid
/** /**
* Parsers for the legacy ZNS .dat files (Fixed-Width format, CP850 encoded). * Parsers for the legacy ZNS .dat files (Fixed-Width format, CP850 encoded).
* *
* This module is independent of backend infrastructure so it can be used * @deprecated Use specialized parsers instead (ZnsVereinParser, ZnsReiterParser, etc.)
* by both the backend API and the Compose Desktop app (Offline-First).
*/ */
@Deprecated("Use specialized parsers instead")
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
object ZnsLegacyParsers { object ZnsLegacyParsers {
@@ -0,0 +1,66 @@
package at.mocode.zns.parser
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.utils.parser.FixedWidthLineReader
import at.mocode.masterdata.domain.model.Pferd
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Spezialisierter Parser für PFERDE01.DAT.
*/
@OptIn(ExperimentalUuidApi::class)
object ZnsPferdParser {
fun parse(line: String): Pferd? {
if (line.isBlank() || line.trim().length < 4) return null
val reader = FixedWidthLineReader(line)
val kopfnummer = reader.getString(1, 4)
val name = reader.getString(5, 30)
// We need at least a name to identify a horse record
if (name.isBlank()) return null
val lebensnummer = reader.getString(35, 9)
val geschlechtChar = reader.getString(44, 1)
val geschlecht = mapGeschlecht(geschlechtChar)
val geburtsjahr = reader.getIntOrNull(45, 4)
val farbe = reader.getString(49, 15)
val abstammung = reader.getString(64, 15)
val vereinNummer = reader.getIntOrNull(79, 4)
val lastPayYear = reader.getIntOrNull(83, 4)
val verantwortlichePersonId = reader.getString(87, 75)
val vaterName = reader.getString(162, 30)
val feiPass = reader.getString(192, 10)
val satznummer = reader.getString(202, 10)
return Pferd(
personId = Uuid.random(),
pferdeName = name,
geschlecht = geschlecht,
geburtsjahr = geburtsjahr,
lebensnummer = lebensnummer.ifBlank { null },
kopfnummer = kopfnummer.ifBlank { null },
satznummer = satznummer.ifBlank { null },
farbe = farbe.ifBlank { null },
abstammung = abstammung.ifBlank { null },
vereinNummer = vereinNummer,
lastPayYear = lastPayYear,
verantwortlichePersonId = verantwortlichePersonId.ifBlank { null },
vater = vaterName.ifBlank { null },
feiPass = feiPass.ifBlank { null },
datenQuelle = DatenQuelleE.IMPORT_ZNS
)
}
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,93 @@
package at.mocode.zns.parser
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.utils.parser.FixedWidthLineReader
import at.mocode.masterdata.domain.model.Reiter
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Spezialisierter Parser für LIZENZ01.DAT.
*/
@OptIn(ExperimentalUuidApi::class)
object ZnsReiterParser {
fun parse(line: String): Reiter? {
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 bundeslandNummer = reader.getIntOrNull(82, 2)
val vereinsName = reader.getString(84, 50)
val nation = reader.getString(134, 3)
val reiterLizenz = reader.getString(137, 4)
// Ab Stelle 137 weicht die Realität der ZNS.zip von der Spec 2.4 ab
// Die Realität (Aichinger Ewald) zeigt:
// 134-136: AUT
// 137-140: R2
// 147-158: 206607000676 (Mitgliedsnummer 8 Stellen ab 147?)
// 160-166: 4825910 (Telefonnummer?)
// 177-180: 2023 (LastPayYear)
// 181: M (Geschlecht)
// 182-189: 19571010 (Geburtsdatum)
val startkarte = reader.getString(141, 1)
val fahrLizenz = reader.getString(142, 2)
val altersklasseJgJrU25 = reader.getString(144, 2)
val altersklasseY = reader.getString(146, 1)
val mitgliedsNummerStr = reader.getString(147, 8)
val mitgliedsNummer = mitgliedsNummerStr.toIntOrNull()
val telefonNummer = reader.getString(155, 22).trim()
val kader = reader.getString(177, 1)
val lastPayYear = reader.getIntOrNull(177, 4)
val geschlecht = reader.getString(181, 1)
val geburtsdatum = reader.getLocalDateOrNull(182, 8)
val feiId = reader.getString(190, 8)
val sperrListe = reader.getString(198, 1)
val lizenzInfo = reader.getString(201, 10)
val lizenzKlasse = mapLizenz(reiterLizenz)
return Reiter(
personId = Uuid.random(),
satznummer = satznummer,
nachname = nachname,
vorname = vorname,
bundeslandNummer = bundeslandNummer,
vereinsName = vereinsName.ifBlank { null },
nation = nation.ifBlank { null },
reiterLizenz = reiterLizenz.ifBlank { null },
startkarte = startkarte.ifBlank { null },
fahrLizenz = fahrLizenz.ifBlank { null },
altersklasseJgJrU25 = altersklasseJgJrU25.ifBlank { null },
altersklasseY = altersklasseY.ifBlank { null },
mitgliedsNummer = mitgliedsNummer,
telefonNummer = telefonNummer.ifBlank { null },
kader = kader.ifBlank { null },
lastPayYear = lastPayYear,
geschlecht = geschlecht.ifBlank { null },
geburtsdatum = geburtsdatum,
feiId = feiId.ifBlank { null },
sperrListe = sperrListe.ifBlank { null },
lizenzInfo = lizenzInfo.ifBlank { null },
lizenzKlasse = lizenzKlasse,
datenQuelle = DatenQuelleE.IMPORT_ZNS
)
}
private fun mapLizenz(lizenz: String): ReiterLizenzKlasseE {
return when (lizenz.uppercase()) {
"R1" -> ReiterLizenzKlasseE.R1
"R2" -> ReiterLizenzKlasseE.R2
"R3" -> ReiterLizenzKlasseE.R3
"RD1" -> ReiterLizenzKlasseE.RD1
"RD2" -> ReiterLizenzKlasseE.RD2
"RD3" -> ReiterLizenzKlasseE.RD3
else -> ReiterLizenzKlasseE.LIZENZFREI
}
}
}
@@ -0,0 +1,30 @@
package at.mocode.zns.parser
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.utils.parser.FixedWidthLineReader
import at.mocode.masterdata.domain.model.Verein
import kotlin.uuid.ExperimentalUuidApi
/**
* Spezialisierter Parser für VEREIN01.DAT.
*/
@OptIn(ExperimentalUuidApi::class)
object ZnsVereinParser {
fun parse(line: String): Verein? {
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 Verein(
vereinsNummer = vereinsNummer,
vereinName = vereinsName,
datenQuelle = DatenQuelleE.IMPORT_ZNS
)
}
}
@@ -0,0 +1,94 @@
package at.mocode.zns.parser
import at.mocode.core.utils.parser.FixedWidthLineReader
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalUuidApi::class)
class ZnsParserTest {
@Test
fun `parseVerein should extract VEREIN01 correctly`() {
val line = "0001REIT- U. FAHRCLUB ALTHOF "
val verein = ZnsVereinParser.parse(line)
assertNotNull(verein)
assertEquals("0001", verein.vereinsNummer)
assertEquals("REIT- U. FAHRCLUB ALTHOF", verein.vereinName)
}
@Test
fun `parseLizenz should extract Hämmerle Ute correctly`() {
val line = "100236Hämmerle Ute 09Reitervereinigung Eichenhof Dornbirn AUTRD2 900308190664 3462613 2021W19650928 "
val reiter = ZnsReiterParser.parse(line)
assertNotNull(reiter)
assertEquals("100236", reiter.satznummer)
assertEquals("Hämmerle", reiter.nachname)
assertEquals("Ute", reiter.vorname)
assertEquals(9, reiter.bundeslandNummer)
assertEquals("Reitervereinigung Eichenhof Dornbirn", reiter.vereinsName)
assertEquals("AUT", reiter.nation)
assertEquals("RD2", reiter.reiterLizenz)
// 90030819 ist die Mitgliedsnummer ab Pos 147
assertEquals(90030819, reiter.mitgliedsNummer)
assertEquals("2021", reiter.lastPayYear.toString())
assertEquals("W", reiter.geschlecht)
}
@Test
fun `parseLizenz should extract Neuwirth Petra correctly`() {
val line = "100562Neuwirth Petra 02Reit- und Fahrverein Schlosshof AUT P 283501510664 4090200 2025W19840323 Para "
val reiter = ZnsReiterParser.parse(line)
assertNotNull(reiter)
assertEquals("100562", reiter.satznummer)
assertEquals("Neuwirth", reiter.nachname)
assertEquals("Petra", reiter.vorname)
assertEquals(2, reiter.bundeslandNummer)
assertEquals("Reit- und Fahrverein Schlosshof", reiter.vereinsName)
assertEquals("AUT", reiter.nation)
assertEquals(28350151, reiter.mitgliedsNummer)
}
@Test
fun `parseLizenz should extract Thuniot Tamara correctly`() {
val line = "101592Thuniot Tamara 02Voltigiergruppe Club 43 AUT V 229701830650 6018700 2025W1987010610044492 V "
val reiter = ZnsReiterParser.parse(line)
assertNotNull(reiter)
assertEquals("101592", reiter.satznummer)
assertEquals("Thuniot", reiter.nachname)
assertEquals("Tamara", reiter.vorname)
assertEquals(22970183, reiter.mitgliedsNummer)
assertEquals("10044492", reiter.feiId)
}
@Test
fun `parsePferd should extract PFERDE01 correctly`() {
val line = "0001Zarin 13 200632349S2004Fuchsschimmel 0123 1234Verantwortliche Person Vater Name FEI-PASS-10001ABC "
val pferd = ZnsPferdParser.parse(line)
assertNotNull(pferd)
assertEquals("0001", pferd.kopfnummer)
assertEquals("Zarin 13", pferd.pferdeName)
assertEquals("200632349", pferd.lebensnummer)
assertEquals(2004, pferd.geburtsjahr)
assertEquals("Fuchsschimmel", pferd.farbe)
}
@Test
fun `parseFunktionaer should extract RICHT01 correctly for Richter`() {
// 1(X) + 6(001061) + 75(Name) + 30(Quali) = 112 characters
val line = "X001061Stöglehner Otto DPF, DSGP, SS* "
val funktionaer = ZnsFunktionaerParser.parse(line)
assertNotNull(funktionaer)
assertEquals("X", funktionaer.satzId)
assertEquals(1061, funktionaer.satzNummer)
assertEquals("Stöglehner Otto", funktionaer.name)
assertEquals(listOf("DPF", "DSGP", "SS*"), funktionaer.qualifikationen)
}
}