diff --git a/CHANGELOG.md b/CHANGELOG.md index 110f2b97..d9e18649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). ## [1.0.2-SNAPSHOT] — 2026-04-06 ### Geändert +- **ZNS-Import:** `ZnsImportService` stabilisiert (ZipInputStream-Management korrigiert), um sequentielle Imports in Tests zu ermöglichen. +- **Test-Vollständigkeit:** `ZnsImportServiceTest` korrigiert (Mocking für Reiter-Suche ergänzt, Testdaten für Funktionäre an Int-Parser angepasst). Alle 9 Tests nun grün. - **Data Modeling:** Redundante Kontakt- und Adressdaten aus `FunktionaerTable` entfernt; stattdessen Verknüpfung zu `ReiterTable` via `reiter_id` hinzugefügt. (Bereinigung der Felder erfolgte in `V010`). - **Import:** ZNS-Importer verknüpft nun Funktionäre automatisch mit vorhandenen Reitern anhand des Namens (Nachname, Vorname). - **Infrastructure:** `findByName` in `ReiterRepository` implementiert für effiziente Suche während des Imports. diff --git a/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt b/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt index 89205932..3b5cb4de 100644 --- a/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt +++ b/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt @@ -57,7 +57,8 @@ class ZnsImportService( */ fun extrahiereDateien(zipInputStream: InputStream): Map> { val dateien = mutableMapOf>() - ZipInputStream(zipInputStream).use { zip -> + val zip = ZipInputStream(zipInputStream) + try { var entry = zip.nextEntry while (entry != null) { val fileName = entry.name.uppercase().substringAfterLast("/") @@ -73,8 +74,14 @@ class ZnsImportService( val lines = content.split(Regex("\\r?\\n|\\r")).filter { it.isNotBlank() } dateien[fileName] = lines } + zip.closeEntry() entry = zip.nextEntry } + } finally { + // Wir schließen den ZipInputStream NICHT mit use, + // um den zugrunde liegenden zipInputStream nicht vorzeitig zu schließen. + // Falls der Aufrufer den Stream schließen will, soll er das tun. + // Aber wir müssen sicherstellen, dass wir alle Entries gelesen haben. } return dateien } @@ -261,7 +268,6 @@ class ZnsImportService( runCatching { val funktionaerRaw = ZnsFunktionaerParser.parse(zeile) ?: return@forEachIndexed - // Versuch, den Reiter anhand des Namens (Nachname, Vorname) zu finden val nameParts = funktionaerRaw.name?.split(",")?.map { it.trim() } val reiterId = if (nameParts != null && nameParts.size >= 2) { val nachname = nameParts[0] diff --git a/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt b/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt index c57a59bf..cdcb17bb 100644 --- a/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt +++ b/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt @@ -103,7 +103,8 @@ class ZnsImportServiceTest { qualifikationen: String = "GA" ): String { // Stelle 1: Typ (X=Richter, Y=Parcoursbauer), 2-7: Satznummer (6), 8-82: Name (75), 83-112: Quali (30) - return typ + satznummer.padEnd(6) + name.padEnd(75) + qualifikationen.padEnd(30) + // WICHTIG: satznummer muss genau 6 Stellen lang sein, ohne abschließende Leerzeichen für den Int-Parser + return typ + satznummer.padStart(6, '0') + name.padEnd(75) + qualifikationen.padEnd(30) } // ------------------------------------------------------------------------- @@ -197,6 +198,7 @@ class ZnsImportServiceTest { coEvery { funktionaerRepository.findBySatz(any(), any()) } returns null coEvery { funktionaerRepository.save(any()) } answers { firstArg() } + coEvery { reiterRepository.findByName(any(), any()) } returns emptyList() val result = service.importiereZip(zip) @@ -234,6 +236,7 @@ class ZnsImportServiceTest { coEvery { vereinRepository.findByVereinsNummer(any()) } returns null coEvery { vereinRepository.save(any()) } answers { firstArg() } coEvery { reiterRepository.findBySatznummer(any()) } returns null + coEvery { reiterRepository.findByName(any(), any()) } returns emptyList() coEvery { reiterRepository.save(any()) } answers { firstArg() } coEvery { horseRepository.findBySatznummer(any()) } returns null coEvery { horseRepository.findByLebensnummer(any()) } returns null diff --git a/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt b/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt deleted file mode 100644 index c616b85a..00000000 --- a/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt +++ /dev/null @@ -1,208 +0,0 @@ -package at.mocode.zns.parser - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.model.ReiterLizenzKlasseE -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.core.utils.parser.FixedWidthLineReader -import at.mocode.masterdata.domain.model.Funktionaer -import at.mocode.masterdata.domain.model.Pferd -import at.mocode.masterdata.domain.model.Reiter -import at.mocode.masterdata.domain.model.Verein -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -/** - * Parsers for the legacy ZNS .dat files (Fixed-Width format, CP850 encoded). - * - * @deprecated Use specialized parsers instead (ZnsVereinParser, ZnsReiterParser, etc.) - */ -@Deprecated("Use specialized parsers instead") -@OptIn(ExperimentalUuidApi::class) -object ZnsLegacyParsers { - - /** - * Parses a line from VEREIN01.DAT. - */ - fun parseVerein(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 - ) - } - - /** - * Parses a line from LIZENZ01.DAT. - */ - fun parseLizenz(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 mitgliedsNummer = reader.getIntOrNull(147, 8) - 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 - ) - } - - /** - * Parses a line from PFERDE01.DAT. - */ - fun parsePferd(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 - ) - } - - /** - * Parses a line from RICHT01.DAT (Richter oder Parcoursbauer). - */ - fun parseFunktionaer(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) - val name = reader.getString(8, 75).trim() - // Qualifikation is much later, probably at 83? - // Wait, name is 75 chars, so 8 + 75 = 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 - ) - } - - 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 - } - } - - private fun mapGeschlecht(geschlecht: String): PferdeGeschlechtE { - return when (geschlecht.uppercase()) { - "W" -> PferdeGeschlechtE.WALLACH - "S" -> PferdeGeschlechtE.STUTE - "H" -> PferdeGeschlechtE.HENGST - else -> PferdeGeschlechtE.UNBEKANNT - } - } -} diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index 84cba9eb..e0f12d90 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -106,8 +106,8 @@ und über definierte Schnittstellen kommunizieren. #### 🧐 Agent: QA Specialist -* [x] **Actor Context Stabilization:** Funktionär-Datenmodell (Richter/Parcoursbauer) auf professionelle Master-Daten-Referenzierung umgestellt und mit Reiter-Daten verknüpft (Import-Idempotenz via `satzNummer`). Redundante Kontakt-/Adressdaten entfernt. -* [x] **ZNS-Import Optimization:** Automatische Verknüpfung von Funktionären mit Reitern (Reihenfolge: VEREIN -> LIZENZ -> PFERDE -> RICHT). +* [x] **Actor Context Stabilization:** Funktionär-Datenmodell (Richter/Parcoursbauer) auf professionelle Master-Daten-Referenzierung umgestellt und mit Reiter-Daten verknüpft (Import-Idempotenz via `satzNummer`). Redundante Kontakt-/Adressdaten entfernt. Alle ZNS-Import-Tests (9/9) stabilisiert und verwaiste Parser-Reste entfernt. +* [x] **ZNS-Import Optimization:** Automatische Verknüpfung von Funktionären mit Reitern (Reihenfolge: VEREIN -> LIZENZ -> PFERDE -> RICHT). `ZnsImportService` für sequentielle Imports in Tests gehärtet. * [x] **Service Stability:** Port-Konflikt des `masterdata-service` (Spring Management Port 8081 vs. Gateway) durch Umzug auf Port 8086 und explizite Bind-Adressen (Spring: 127.0.0.1, Ktor: 0.0.0.0) dauerhaft gelöst. * [x] **Documentation:** `CHANGELOG.md` aktualisiert und Port-Konfiguration in `application.yml` dokumentiert. → Note: `IdempotencyApiIntegrationTest` bleibt vorerst @Disabled, da das Hochfahren des Spring-Contexts in der