Remove deprecated ZnsLegacyParsersTest.kt, synchronize database schema with Exposed domain models (migration V010), add license-related fields to Reiter, integrate updated LicenseMatrixService fallback logic, improve ZnsImportService with file archiving, and add ZNS testing runbook.

This commit is contained in:
Stefan Mogeritsch 2026-04-06 01:45:49 +02:00
parent e94dc5a803
commit aa9e2da3a3
13 changed files with 260 additions and 283 deletions

View File

@ -18,7 +18,11 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt
- **Core:** Modularisierte ZNS-Parser eingeführt (`ZnsVereinParser`, `ZnsReiterParser`, `ZnsPferdParser`, `ZnsFunktionaerParser`) zur Verbesserung der Wartbarkeit und Unterstützung von Einzelimporten.
- **Infrastructure:** Datenbank-Migration `V010` hinzugefügt, um das Schema final mit den `Exposed`-Modellen zu synchronisieren.
- **Infrastructure:** Datei-Archivierung für hochgeladene ZNS-ZIP-Dateien im `ZnsImportOrchestrator` implementiert.
- **Infrastructure:** `ZnsImportService` vollständig auf die neuen spezialisierten Parser umgestellt und als Spring-Bean im Backend registriert.
- **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).
- **QA:** Neue Betriebsanleitung für ZNS-Importer Tests erstellt: `docs/07_Infrastructure/runbooks/ZNS_Importer_Test_Manual.md`.
- **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_Erweiterung-Schnittstelle_2014.md` (XML-Erweiterung, LinkID-Logik)
@ -30,6 +34,10 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
### Behoben
- **Core:** Veraltete `ZnsLegacyParsersTest.kt` entfernt; alle Tests sind nun in `ZnsParserTest.kt` konsolidiert.
- **Domain:** Fehlschlagenden `LicenseMatrixServiceTest` behoben; fehlende `reiterLizenz`-Daten in Test-Reitern ergänzt und Fallback-Logik in `LicenseMatrixServiceImpl` für spartenübergreifende Lizenzen (z.B. Springlizenz für Dressur-Basis) stabilisiert.
- **Infrastructure:** Fehlschlagenden `RegulationSeedVerificationTest` behoben; Testdaten an das neue Modell (`reiterLizenz` Feld) angepasst.
- **Infrastructure:** Kompilierfehler 'Unresolved reference lizenzKlasse' in `ReiterExposedRepository` behoben; fehlendes Feld `lizenzKlasse` zu `ReiterTable` und Datenbank-Migration `V010` hinzugefügt.
- **Onboarding:** `remember``rememberSaveable` für `geraetName`, `sharedKey`, `znsStatus` in `OnboardingScreen.kt` (
Felder gingen bei Zurück-Navigation verloren)
- **AbteilungsRegelService:** CSN-C-NEU Pflicht-Teilungslogik implementiert (≤95 cm: ohne/mit Lizenz; ≥100 cm: R1/R2+);

View File

@ -1,5 +1,6 @@
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.LicenseMatrixEntry
@ -42,6 +43,14 @@ class LicenseMatrixServiceImpl : LicenseMatrixService {
// Suche passenden Eintrag in der Matrix für (Sparte, Lizenzklasse)
val entry = matrix.find { it.sparte == sparte && it.lizenzKlasse == reiter.lizenzKlasse }
?: matrix.find { it.sparte == SparteE.DRESSUR && sparte == SparteE.DRESSUR && it.lizenzKlasse == reiter.lizenzKlasse } // Fallback/Spezial
?: if (reiter.lizenzKlasse == ReiterLizenzKlasseE.R1 ||
reiter.lizenzKlasse == ReiterLizenzKlasseE.R2 ||
reiter.lizenzKlasse == ReiterLizenzKlasseE.R3 ||
reiter.lizenzKlasse == ReiterLizenzKlasseE.R4) {
// Fallback für Dressur, wenn man eine Springlizenz hat (R1 gilt oft auch als RD1 etc. in manchen Kontexten,
// aber hier schauen wir primär ob die Matrix einen generischen Eintrag hat)
matrix.find { it.sparte == sparte && it.lizenzKlasse == ReiterLizenzKlasseE.LIZENZFREI }
} else null
return entry?.maxTurnierklasseCode
}

View File

@ -95,6 +95,7 @@ class LicenseMatrixServiceTest {
satznummer = "1",
nachname = "R1",
vorname = "Reiter",
reiterLizenz = "R1",
lizenzKlasse = ReiterLizenzKlasseE.R1
)
@ -114,6 +115,7 @@ class LicenseMatrixServiceTest {
satznummer = "2",
nachname = "RD1",
vorname = "Reiter",
reiterLizenz = "RD1",
lizenzKlasse = ReiterLizenzKlasseE.RD1
)

View File

@ -7,7 +7,6 @@ import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.domain.model.Reiter
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.eq
import org.jetbrains.exposed.v1.jdbc.*

View File

@ -37,6 +37,7 @@ object ReiterTable : Table("reiter") {
val feiId = varchar("fei_id", 20).nullable()
val sperrListe = varchar("sperr_liste", 50).nullable()
val lizenzInfo = varchar("lizenz_info", 100).nullable()
val lizenzKlasse = varchar("lizenz_klasse", 20).default("LIZENZFREI")
// === ZNS.zip LITENZ01.DAT === ENDE ===

View File

@ -98,6 +98,7 @@ class RegulationSeedVerificationTest {
satznummer = "123456",
nachname = "Müller",
vorname = "Hans",
reiterLizenz = "R1",
lizenzKlasse = ReiterLizenzKlasseE.R1
)

View File

@ -0,0 +1,87 @@
-- V010: Synchronize Database Schema with Exposed Domain Models
-- Harmonisiert die Spaltennamen und Typen mit den aktuellen Kotlin-Definitionen.
-- 1. Tabelle VEREIN anpassen
ALTER TABLE verein RENAME COLUMN name TO verein_name;
ALTER TABLE verein DROP COLUMN IF EXISTS kurzname;
ALTER TABLE verein DROP COLUMN IF EXISTS oeps_region_nummer;
ALTER TABLE verein ADD COLUMN IF NOT EXISTS person_id UUID;
ALTER TABLE verein ADD COLUMN IF NOT EXISTS image_url VARCHAR(255);
ALTER TABLE verein ADD COLUMN IF NOT EXISTS hausnummer VARCHAR(10);
-- 2. Tabelle REITER anpassen
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS bundesland_nummer INTEGER;
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS reiter_lizenz VARCHAR(20);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS startkarte VARCHAR(20);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS fahr_lizenz VARCHAR(20);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS altersklasse_jg_jr_u25 VARCHAR(10);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS altersklasse_y VARCHAR(10);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS mitglieds_nummer INTEGER;
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS telefon_nummer VARCHAR(50);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS kader VARCHAR(50);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS last_pay_year INTEGER;
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS geschlecht VARCHAR(10);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS sperr_liste VARCHAR(50);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS lizenz_info VARCHAR(100);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS lizenz_klasse VARCHAR(20) DEFAULT 'LIZENZFREI';
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS image_url VARCHAR(255);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS website VARCHAR(255);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS strasse VARCHAR(200);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS hausnummer VARCHAR(10);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS plz VARCHAR(10);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS ort VARCHAR(100);
ALTER TABLE reiter ADD COLUMN IF NOT EXISTS bundesland VARCHAR(100);
-- 3. Tabelle HORSE anpassen (in Exposed "HorseTable" aber in SQL "horse")
ALTER TABLE horse ADD COLUMN IF NOT EXISTS kopfnummer VARCHAR(4);
ALTER TABLE horse ADD COLUMN IF NOT EXISTS geburtsjahr INTEGER;
ALTER TABLE horse ADD COLUMN IF NOT EXISTS abstammung VARCHAR(100);
ALTER TABLE horse ADD COLUMN IF NOT EXISTS verein_nummer INTEGER;
ALTER TABLE horse ADD COLUMN IF NOT EXISTS last_pay_year INTEGER;
ALTER TABLE horse ADD COLUMN IF NOT EXISTS vater VARCHAR(200);
ALTER TABLE horse ADD COLUMN IF NOT EXISTS fei_pass VARCHAR(50);
ALTER TABLE horse ADD COLUMN IF NOT EXISTS satznummer VARCHAR(10);
-- Aufräumen von Feldern die nicht im Exposed Model sind (aus V006)
ALTER TABLE horse DROP COLUMN IF EXISTS geburtsdatum;
ALTER TABLE horse DROP COLUMN IF EXISTS rasse;
ALTER TABLE horse DROP COLUMN IF EXISTS besitzer_id;
ALTER TABLE horse DROP COLUMN IF EXISTS zuechter_name;
ALTER TABLE horse DROP COLUMN IF EXISTS zuchtbuch_nummer;
ALTER TABLE horse DROP COLUMN IF EXISTS chip_nummer;
ALTER TABLE horse DROP COLUMN IF EXISTS pass_nummer;
ALTER TABLE horse DROP COLUMN IF EXISTS oeps_nummer;
ALTER TABLE horse DROP COLUMN IF EXISTS fei_nummer;
ALTER TABLE horse DROP COLUMN IF EXISTS vater_name;
ALTER TABLE horse DROP COLUMN IF EXISTS mutter_name;
ALTER TABLE horse DROP COLUMN IF EXISTS mutter_vater_name;
ALTER TABLE horse DROP COLUMN IF EXISTS stockmass;
-- 4. Tabelle FUNKTIONAER anpassen
ALTER TABLE funktionaer DROP COLUMN IF EXISTS richter_nummer;
ALTER TABLE funktionaer DROP COLUMN IF EXISTS vorname;
ALTER TABLE funktionaer DROP COLUMN IF EXISTS nachname;
ALTER TABLE funktionaer DROP COLUMN IF EXISTS geburtsdatum;
ALTER TABLE funktionaer DROP COLUMN IF EXISTS vereins_nummer;
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS person_id UUID;
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS satz_id VARCHAR(1);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS satz_nummer INTEGER;
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS name VARCHAR(200);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS image_url VARCHAR(255);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS website VARCHAR(255);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS strasse VARCHAR(200);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS hausnummer VARCHAR(10);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS plz VARCHAR(10);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS ort VARCHAR(100);
ALTER TABLE funktionaer ADD COLUMN IF NOT EXISTS bundesland VARCHAR(100);
-- 5. Qualifikations-Tabelle für Funktionäre
CREATE TABLE IF NOT EXISTS funktionaer_qualifikation (
funktionaer_id UUID NOT NULL REFERENCES funktionaer(funktionaer_id),
qualifikation VARCHAR(20) NOT NULL,
PRIMARY KEY (funktionaer_id, qualifikation)
);
-- Indizes (Exposed-Style)
CREATE UNIQUE INDEX IF NOT EXISTS idx_horse_satznummer ON horse (satznummer);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reiter_satznummer ON reiter (satznummer);
CREATE UNIQUE INDEX IF NOT EXISTS idx_funktionaer_satz ON funktionaer (satz_id, satz_nummer);

View File

@ -1,7 +1,13 @@
package at.mocode.zns.import.service
import at.mocode.masterdata.domain.repository.FunktionaerRepository
import at.mocode.masterdata.domain.repository.HorseRepository
import at.mocode.masterdata.domain.repository.ReiterRepository
import at.mocode.masterdata.domain.repository.VereinRepository
import at.mocode.zns.importer.ZnsImportService
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
@SpringBootApplication
@ -11,7 +17,18 @@ import org.springframework.context.annotation.ComponentScan
"at.mocode.masterdata.infrastructure"
]
)
class ZnsImportServiceApplication
class ZnsImportServiceApplication {
@Bean
fun znsImportService(
vereinRepository: VereinRepository,
reiterRepository: ReiterRepository,
horseRepository: HorseRepository,
funktionaerRepository: FunktionaerRepository
): ZnsImportService {
return ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository)
}
}
fun main(args: Array<String>) {
runApplication<ZnsImportServiceApplication>(*args)

View File

@ -5,7 +5,7 @@ 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 }
enum class ImportJobStatus { AUSSTEHEND, ENTPACKEN, VERARBEITUNG, ABGESCHLOSSEN, FEHLER }
data class ImportJob(
val jobId: String,

View File

@ -1,23 +1,21 @@
package at.mocode.zns.import.service.job
import at.mocode.masterdata.domain.repository.VereinRepository
import at.mocode.masterdata.domain.repository.HorseRepository
import at.mocode.masterdata.domain.repository.FunktionaerRepository
import at.mocode.masterdata.domain.repository.ReiterRepository
import at.mocode.zns.importer.ZnsImportService
import at.mocode.zns.importer.ZnsImportResult
import at.mocode.zns.importer.ZnsImportService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@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 service: ZnsImportService,
private val jobRegistry: ImportJobRegistry,
@Value("\${app.zns.archive-path}") private val archivePath: String
) {
private val scope = CoroutineScope(Dispatchers.IO)
@ -26,38 +24,15 @@ class ZnsImportOrchestrator(
runCatching {
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.ENTPACKEN, "Entpacke ZIP-Datei...", 5)
val service = ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository)
// Archivierung
archiviereZip(zipBytes)
val dateien = service.extrahiereDateien(zipBytes.inputStream())
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.LADE_VEREINE, "Lade Vereine...", 20)
val vereineResult = service.importiereVereine(dateien["VEREIN01.DAT"] ?: emptyList(), mutableListOf())
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.LADE_REITER, "Lade Reiter...", 40)
val reiterResult = service.importiereReiter(dateien["LIZENZ01.DAT"] ?: emptyList(), mutableListOf(), mutableListOf())
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.LADE_PFERDE, "Lade Pferde...", 60)
val pferdeResult = service.importierePferde(dateien["PFERDE01.DAT"] ?: emptyList(), mutableListOf())
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.LADE_RICHTER, "Lade Funktionäre...", 80)
val richterResult = service.importiereFunktionaere(dateien["RICHT01.DAT"] ?: emptyList(), mutableListOf(), mutableListOf())
val result = ZnsImportResult(
vereineImportiert = vereineResult.first,
vereineAktualisiert = vereineResult.second,
reiterImportiert = reiterResult.first,
reiterAktualisiert = reiterResult.second,
pferdeImportiert = pferdeResult.first,
pferdeAktualisiert = pferdeResult.second,
richterImportiert = richterResult.first,
richterAktualisiert = richterResult.second
)
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.VERARBEITUNG, "Verarbeite ZNS-Daten...", 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
result.zusammenfassung(), 100
)
jobRegistry.findeJob(jobId)?.let { job ->
@ -70,4 +45,18 @@ class ZnsImportOrchestrator(
}
}
}
private fun archiviereZip(bytes: ByteArray) {
try {
val dir = File(archivePath)
if (!dir.exists()) dir.mkdirs()
val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
val archiveFile = File(dir, "zns_import_$timestamp.zip")
archiveFile.writeBytes(bytes)
} catch (e: Exception) {
// Archivierung schlägt fehl -> Loggen aber Import nicht abbrechen
println("[WARN] Archivierung der ZNS-Datei fehlgeschlagen: ${e.message}")
}
}
}

View File

@ -42,3 +42,5 @@ management:
app:
service-name: ${spring.application.name}
zns:
archive-path: ${ZNS_ARCHIVE_PATH:/data/zns/archive}

View File

@ -1,242 +0,0 @@
package at.mocode.zns.parser
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.vereinName)
}
@Test
fun `parseLizenz should extract LIZENZ01 correctly`() {
val sb = StringBuilder()
sb.append("123456") // 1-6
sb.append("Mustermann ") // 7-56
sb.append("Max ") // 57-81
sb.append("01") // 82-83
sb.append("Reitverein Wien ") // 84-133
sb.append("AUT") // 134-136
sb.append("R1 ") // 137-140
sb.append(" ") // 141-146 (leer)
sb.append("00000001") // 147-154 (mitgliedsNummer)
sb.append("0676 12345678 ") // 155-176 (telefonNummer length 22)
sb.append("2026") // 177-180 (lastPayYear)
sb.append("M") // 181 (geschlecht)
sb.append("19800101") // 182-189 (geburtsdatum)
sb.append("1000000001") // 190-199 (feiId length 10)
sb.append("S") // 200 (sperrListe)
sb.append("INFO1 ") // 201-210 (lizenzInfo)
val result = ZnsLegacyParsers.parseLizenz(sb.toString())
assertNotNull(result)
assertEquals("123456", result.satznummer)
assertEquals("Mustermann", result.nachname)
assertEquals("Max", result.vorname)
assertEquals(1, result.bundeslandNummer)
assertEquals("Reitverein Wien", result.vereinsName)
assertEquals("AUT", result.nation)
assertEquals("R1", result.reiterLizenz)
assertEquals(2026, result.lastPayYear)
assertEquals("M", result.geschlecht)
assertEquals("1980-01-01", result.geburtsdatum.toString())
}
@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("A123", result.kopfnummer)
assertEquals("0000000001", result.satznummer)
assertEquals("Black Beauty", result.pferdeName)
assertEquals("123456789", result.lebensnummer)
assertEquals(PferdeGeschlechtE.WALLACH, result.geschlecht)
assertEquals(2010, result.geburtsjahr)
}
@Test
fun `parseFunktionaer should extract RICHT01 correctly for Richter`() {
// Real example from RICHT01.dat
val line = "X010128Zitterbart Rainer PI-A"
val result = ZnsLegacyParsers.parseFunktionaer(line)
assertNotNull(result)
assertEquals("X", result.satzId)
assertEquals(10128, result.satzNummer)
assertEquals("Zitterbart Rainer", result.name)
assertEquals(listOf("PI-A"), result.qualifikationen)
}
@Test
fun `parseFunktionaer should extract RICHT01 correctly with more examples`() {
// X139552Mc Mullen Elizabeth DIOR
val line1 = "X139552Mc Mullen Elizabeth DIOR"
val result1 = ZnsLegacyParsers.parseFunktionaer(line1)
assertNotNull(result1)
assertEquals("X", result1.satzId)
assertEquals(139552, result1.satzNummer)
assertEquals("Mc Mullen Elizabeth", result1.name)
assertEquals(listOf("DIOR"), result1.qualifikationen)
// X014346Schubert Renate DM,DPF,GAR-SP,SPF,SS*
val line2 = "X014346Schubert Renate DM,DPF,GAR-SP,SPF,SS*"
val result2 = ZnsLegacyParsers.parseFunktionaer(line2)
assertNotNull(result2)
assertEquals(14346, result2.satzNummer)
assertEquals("Schubert Renate", result2.name)
assertEquals(listOf("DM", "DPF", "GAR-SP", "SPF", "SS*"), result2.qualifikationen)
// Y002211Salusek Andreas Christian P3,PL2
val line3 = "Y002211Salusek Andreas Christian P3,PL2"
val result3 = ZnsLegacyParsers.parseFunktionaer(line3)
assertNotNull(result3)
assertEquals("Y", result3.satzId)
assertEquals(2211, result3.satzNummer)
assertEquals("Salusek Andreas Christian", result3.name)
assertEquals(listOf("P3", "PL2"), result3.qualifikationen)
// X001061Kager Franz DPF,DSGP,GAR-SP,GAR-VS,SPF
val line4 = "X001061Kager Franz DPF,DSGP,GAR-SP,GAR-VS,SPF"
val result4 = ZnsLegacyParsers.parseFunktionaer(line4)
assertNotNull(result4)
assertEquals("X", result4.satzId)
assertEquals(1061, result4.satzNummer)
assertEquals("Kager Franz", result4.name)
assertEquals(listOf("DPF", "DSGP", "GAR-SP", "GAR-VS", "SPF"), result4.qualifikationen)
// X001112Keiblinger Brigitta DPF,DSGP,SPF,SS,VS,VSILEV1"
val line5 = "X001112Keiblinger Brigitta DPF,DSGP,SPF,SS,VS,VSILEV1"
val result5 = ZnsLegacyParsers.parseFunktionaer(line5)
assertNotNull(result5)
assertEquals("X", result5.satzId)
assertEquals(1112, result5.satzNummer)
assertEquals("Keiblinger Brigitta", result5.name)
assertEquals(listOf("DPF", "DSGP", "SPF", "SS", "VS", "VSILEV1"), result5.qualifikationen)
}
@Test
fun `parseFunktionaer should return null for invalid lines`() {
assertEquals(null, ZnsLegacyParsers.parseFunktionaer(""))
assertEquals(null, ZnsLegacyParsers.parseFunktionaer("Z123456Test"))
assertEquals(null, ZnsLegacyParsers.parseFunktionaer("XABCDEFTest"))
}
@Test
fun `parsePferd should extract real PFERDE01 correctly`() {
// Real example from PFERDE01.dat (line length approx 211 characters)
val line = "9D56Viola B 000000017S2005Brauner Tschech. WB 10952024Tanja Kuntner 535 Latinus 5637401268"
val result = ZnsLegacyParsers.parsePferd(line)
assertNotNull(result)
assertEquals("9D56", result.kopfnummer)
assertEquals("Viola B", result.pferdeName)
assertEquals("000000017", result.lebensnummer)
assertEquals(PferdeGeschlechtE.STUTE, result.geschlecht)
assertEquals(2005, result.geburtsjahr)
assertEquals("Brauner", result.farbe)
assertEquals("Tschech. WB", result.abstammung)
assertEquals(1095, result.vereinNummer)
assertEquals(2024, result.lastPayYear)
assertEquals("Tanja Kuntner", result.verantwortlichePersonId)
assertEquals("535 Latinus", result.vater)
assertEquals("5637401268", result.satznummer)
}
@Test
fun `parsePferd should extract shortened PFERDE01 correctly`() {
// A line that ends after the name
val line = "1234Fuchur"
val result = ZnsLegacyParsers.parsePferd(line)
assertNotNull(result)
assertEquals("1234", result.kopfnummer)
assertEquals("Fuchur", result.pferdeName)
assertEquals(null, result.satznummer)
assertEquals(null, result.lebensnummer)
assertEquals(PferdeGeschlechtE.UNBEKANNT, result.geschlecht)
}
@Test
fun `parseLizenz should extract real LIZENZ01 correctly for Ebner Sarah`() {
// Real example from user:
// "100365Ebner Sarah 09Hubertus Voltigier Reit- und Fahrverein AUTR2S3 903801690699 18109450 2025W1990100310137032 R2S3 "
val line = "100365Ebner Sarah 09Hubertus Voltigier Reit- und Fahrverein AUTR2S3 903801690699 18109450 2025W1990100310137032 R2S3 "
val result = ZnsLegacyParsers.parseLizenz(line)
assertNotNull(result)
assertEquals("100365", result.satznummer)
assertEquals("Ebner", result.nachname)
assertEquals("Sarah", result.vorname)
assertEquals(9, result.bundeslandNummer)
assertEquals("Hubertus Voltigier Reit- und Fahrverein", result.vereinsName)
assertEquals("AUT", result.nation)
assertEquals("R2S3", result.reiterLizenz)
assertEquals(90380169, result.mitgliedsNummer)
assertEquals("0699 18109450", result.telefonNummer)
assertEquals(2025, result.lastPayYear)
assertEquals("W", result.geschlecht)
assertEquals("1990-10-03", result.geburtsdatum.toString())
assertEquals("10137032", result.feiId)
assertEquals("R2S3", result.lizenzInfo)
}
@Test
fun `parseLizenz should extract real LIZENZ01 correctly`() {
// Real example from LIZENZ01.dat (second line of file)
val sb = StringBuilder()
sb.append("000010") // 1-6
sb.append("Aichinger ") // 7-56
sb.append("Ewald ") // 57-81
sb.append("02") // 82-83
sb.append("Reitverein Geiger-Amstetten ") // 84-133
sb.append("AUT") // 134-136
sb.append("R2 ") // 137-140
sb.append(" ") // 141-146 (leer)
sb.append("20660700") // 147-154 (mitgliedsNummer)
sb.append("0676 4825910 ") // 155-176 (telefon)
sb.append("2023") // 177-180 (lastPayYear)
sb.append("M") // 181 (geschlecht)
sb.append("19571010") // 182-189 (geburtsdatum)
sb.append(" ") // 190-199 (feiId length 10)
sb.append(" ") // 200 (sperrliste)
sb.append(" ") // 201-210 (lizenzinfo)
val result = ZnsLegacyParsers.parseLizenz(sb.toString())
assertNotNull(result)
assertEquals("000010", result.satznummer)
assertEquals("Aichinger", result.nachname)
assertEquals("Ewald", result.vorname)
assertEquals(2, result.bundeslandNummer)
assertEquals("Reitverein Geiger-Amstetten", result.vereinsName)
assertEquals("AUT", result.nation)
assertEquals("R2", result.reiterLizenz)
assertEquals(20660700, result.mitgliedsNummer)
assertEquals("0676 4825910", result.telefonNummer)
assertEquals(2023, result.lastPayYear)
assertEquals("M", result.geschlecht)
assertEquals("1957-10-10", result.geburtsdatum.toString())
}
}

View File

@ -0,0 +1,104 @@
# 🐎 ZNS-Importer Test-Anleitung (Runbook)
Diese Anleitung beschreibt den Prozess, um den ZNS-Importer in einer lokalen Entwicklungsumgebung zu starten und mit Postman vollständig zu testen.
## 1. Infrastruktur starten (Docker)
Bevor der Service gestartet werden kann, müssen die Basis-Dienste (Datenbank, Discovery, Auth) laufen.
### 1.1 Docker Container starten
Öffne ein Terminal im Projekt-Root (`/mocode/Meldestelle`) und führe folgenden Befehl aus:
```bash
docker compose up -d postgres consul keycloak valkey zipkin
```
### 1.2 Status prüfen
Stelle sicher, dass alle Container "healthy" sind:
* **PostgreSQL:** `localhost:5432`
* **Consul UI:** [http://localhost:8500](http://localhost:8500) (Hier muss der Status aller Dienste grün sein)
---
## 2. Backend Services starten
Der ZNS-Importer benötigt den `masterdata-service` (für die Datenbank-Tabellen) und den `zns-import-service`.
### 2.1 Masterdata Service (DB-Migrationen)
Starte den Masterdata-Service, damit die Tabellen (Verein, Reiter, Pferd, Funktionär) angelegt werden:
```bash
./gradlew :backend:services:masterdata:masterdata-service:bootRun
```
*Warte bis im Log erscheint: `Started MasterdataServiceApplication`*
### 2.2 ZNS-Import Service
Starte den Import-Service in einem neuen Terminal:
```bash
./gradlew :backend:services:zns-import:zns-import-service:bootRun
```
*Warte bis im Log erscheint: `Started ZnsImportServiceApplication`*
---
## 3. Postman Test-Ablauf
### 3.1 Health-Check (Optionaler Smoke-Test)
Prüfe ob der Service erreichbar ist:
* **Methode:** `GET`
* **URL:** `http://localhost:8095/actuator/health`
* **Erwartetes Ergebnis:** `{"status":"UP"}`
### 3.2 ZNS-ZIP Upload (Import starten)
Dieser Schritt lädt die ZNS-Daten (ZIP-Datei mit .DAT Files) hoch und startet den asynchronen Prozess.
* **Methode:** `POST`
* **URL:** `http://localhost:8095/api/v1/import/zns`
* **Body:** `form-data`
* Key: `file`
* Type: `File`
* Value: Wähle deine `ZNS_EXPORT.zip` aus.
* **Erwartete Antwort (202 Accepted):**
```json
{
"jobId": "7d3a...-..."
}
```
*(Kopiere die `jobId` für den nächsten Schritt!)*
### 3.3 Status Polling
Da der Import im Hintergrund läuft, musst du den Status abfragen:
* **Methode:** `GET`
* **URL:** `http://localhost:8095/api/v1/import/zns/{jobId}/status`
* **Erwartete Antwort (währenddessen):** `status: "VERARBEITUNG"`
* **Erwartete Antwort (Erfolg):**
```json
{
"jobId": "...",
"status": "ABGESCHLOSSEN",
"fortschritt": 100,
"meldungen": ["Import erfolgreich: 4 Dateien verarbeitet (150 Reiter, 200 Pferde, ...)"]
}
```
---
## 4. Erfolgskontrolle (Nach dem Import)
### 4.1 Datenbank prüfen (pgAdmin / SQL)
Prüfe in der Tabelle `reiter` oder `horse`, ob Daten vorhanden sind:
```sql
SELECT count(*) FROM reiter;
SELECT * FROM horse LIMIT 10;
```
### 4.2 Archiv-Ordner
Prüfe ob die Datei erfolgreich archiviert wurde:
* Pfad (laut `application.yaml`): `/data/zns/archive` (oder der konfigurierte Pfad)
* Die Datei sollte `zns_import_YYYYMMDD_HHMMSS.zip` heißen.
## 5. Troubleshooting
* **404 Not Found:** Prüfe ob der Service auf Port 8095 läuft.
* **500 Internal Server Error:** Prüfe die Konsolenausgabe des `zns-import-service` auf Stacktraces.
* **Import bleibt bei 0% hängen:** Prüfe ob die ZIP-Datei die richtigen Dateinamen enthält (z.B. `VEREIN01.DAT`, `LIZENZ01.DAT`).