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 ### Hinzugefügt
- **Core:** Modularisierte ZNS-Parser eingeführt (`ZnsVereinParser`, `ZnsReiterParser`, `ZnsPferdParser`, `ZnsFunktionaerParser`) zur Verbesserung der Wartbarkeit und Unterstützung von Einzelimporten. - **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:** 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: - **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)
@ -30,6 +34,10 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
### Behoben ### 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` ( - **Onboarding:** `remember``rememberSaveable` für `geraetName`, `sharedKey`, `znsStatus` in `OnboardingScreen.kt` (
Felder gingen bei Zurück-Navigation verloren) Felder gingen bei Zurück-Navigation verloren)
- **AbteilungsRegelService:** CSN-C-NEU Pflicht-Teilungslogik implementiert (≤95 cm: ohne/mit Lizenz; ≥100 cm: R1/R2+); - **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 package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.domain.model.SparteE import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.Reiter import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.LicenseMatrixEntry import at.mocode.masterdata.domain.model.LicenseMatrixEntry
@ -42,6 +43,14 @@ class LicenseMatrixServiceImpl : LicenseMatrixService {
// Suche passenden Eintrag in der Matrix für (Sparte, Lizenzklasse) // Suche passenden Eintrag in der Matrix für (Sparte, Lizenzklasse)
val entry = matrix.find { it.sparte == sparte && it.lizenzKlasse == reiter.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 ?: 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 return entry?.maxTurnierklasseCode
} }

View File

@ -95,6 +95,7 @@ class LicenseMatrixServiceTest {
satznummer = "1", satznummer = "1",
nachname = "R1", nachname = "R1",
vorname = "Reiter", vorname = "Reiter",
reiterLizenz = "R1",
lizenzKlasse = ReiterLizenzKlasseE.R1 lizenzKlasse = ReiterLizenzKlasseE.R1
) )
@ -114,6 +115,7 @@ class LicenseMatrixServiceTest {
satznummer = "2", satznummer = "2",
nachname = "RD1", nachname = "RD1",
vorname = "Reiter", vorname = "Reiter",
reiterLizenz = "RD1",
lizenzKlasse = ReiterLizenzKlasseE.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.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.*

View File

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

View File

@ -98,6 +98,7 @@ class RegulationSeedVerificationTest {
satznummer = "123456", satznummer = "123456",
nachname = "Müller", nachname = "Müller",
vorname = "Hans", vorname = "Hans",
reiterLizenz = "R1",
lizenzKlasse = ReiterLizenzKlasseE.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 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.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.ComponentScan
@SpringBootApplication @SpringBootApplication
@ -11,7 +17,18 @@ import org.springframework.context.annotation.ComponentScan
"at.mocode.masterdata.infrastructure" "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>) { fun main(args: Array<String>) {
runApplication<ZnsImportServiceApplication>(*args) runApplication<ZnsImportServiceApplication>(*args)

View File

@ -5,7 +5,7 @@ import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid 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( data class ImportJob(
val jobId: String, val jobId: String,

View File

@ -1,23 +1,21 @@
package at.mocode.zns.import.service.job 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.ZnsImportResult
import at.mocode.zns.importer.ZnsImportService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Service @Service
class ZnsImportOrchestrator( class ZnsImportOrchestrator(
private val vereinRepository: VereinRepository, private val service: ZnsImportService,
private val reiterRepository: ReiterRepository, private val jobRegistry: ImportJobRegistry,
private val horseRepository: HorseRepository, @Value("\${app.zns.archive-path}") private val archivePath: String
private val funktionaerRepository: FunktionaerRepository,
private val jobRegistry: ImportJobRegistry
) { ) {
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
@ -26,38 +24,15 @@ class ZnsImportOrchestrator(
runCatching { runCatching {
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.ENTPACKEN, "Entpacke ZIP-Datei...", 5) 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.VERARBEITUNG, "Verarbeite ZNS-Daten...", 20)
val result = service.importiereZip(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( jobRegistry.aktualisiereStatus(
jobId, ImportJobStatus.ABGESCHLOSSEN, jobId, ImportJobStatus.ABGESCHLOSSEN,
"Import abgeschlossen: ${result.vereineImportiert} Vereine, " + result.zusammenfassung(), 100
"${result.reiterImportiert} Reiter, ${result.pferdeImportiert} Pferde, " +
"${result.richterImportiert} Richter importiert.", 100
) )
jobRegistry.findeJob(jobId)?.let { job -> 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: app:
service-name: ${spring.application.name} 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`).