From 9d08cb0f727f6170d6c9466431447a01c10e2373 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Wed, 25 Mar 2026 14:43:01 +0100 Subject: [PATCH] feat(zns-importer): add ZNSImportService with tests and REST controller - Created `ZnsImportService` to handle uploading, parsing, and persisting ZNS data from legacy `.zip` files. - Introduced corresponding test cases in `ZnsImportServiceTest` for handling edge cases including imports and updates. - Added REST controller `ZnsImportController` for initiating import jobs and retrieving their status. - Defined `ZnsImportResult` data structure for reporting results of import operations. - Established database configuration specific to ZNS importer for development profile. - Updated utility libraries with `FixedWidthLineReader` for fixed-width string parsing. - Refactored architecture by placing parser logic in `core:zns-parser` for reuse across backend and Compose Desktop app. Signed-off-by: Stefan Mogeritsch --- .../zns-importer/build.gradle.kts | 16 ++ .../at/mocode/zns/importer/ZnsImportResult.kt | 38 +++ .../mocode/zns/importer/ZnsImportService.kt | 243 ++++++++++++++++ .../zns/importer/ZnsImportServiceTest.kt | 219 ++++++++++++++ .../persistence/HorseRepositoryImpl.kt | 266 ++++++++++++++++++ .../zns-import-service/build.gradle.kts | 47 ++++ .../service/ZnsImportServiceApplication.kt | 21 ++ .../import/service/api/ZnsImportController.kt | 42 +++ .../service/config/RepositoryConfiguration.kt | 28 ++ .../config/ZnsImportDatabaseConfiguration.kt | 37 +++ .../import/service/job/ImportJobRegistry.kt | 39 +++ .../service/job/ZnsImportOrchestrator.kt | 50 ++++ .../src/main/resources/application.yaml | 34 +++ .../core/utils/parser/FixedWidthParser.kt | 39 +++ core/zns-parser/build.gradle.kts | 30 ++ .../at/mocode/zns/parser/ZnsLegacyParsers.kt | 156 ++++++++++ .../mocode/zns/parser/ZnsLegacyParsersTest.kt | 80 ++++++ docs/01_Architecture/MASTER_ROADMAP.md | 8 +- docs/01_Architecture/Roadmap_ZNS_Importer.md | 39 +-- docs/temp/Chat-Verlauf-Gemini_2026-03-25.md | 236 ++++++++++++++++ settings.gradle.kts | 7 + 21 files changed, 1653 insertions(+), 22 deletions(-) create mode 100644 backend/infrastructure/zns-importer/build.gradle.kts create mode 100644 backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportResult.kt create mode 100644 backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt create mode 100644 backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt create mode 100644 backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt create mode 100644 backend/services/zns-import/zns-import-service/build.gradle.kts create mode 100644 backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/ZnsImportServiceApplication.kt create mode 100644 backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/api/ZnsImportController.kt create mode 100644 backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/RepositoryConfiguration.kt create mode 100644 backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/ZnsImportDatabaseConfiguration.kt create mode 100644 backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ImportJobRegistry.kt create mode 100644 backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ZnsImportOrchestrator.kt create mode 100644 backend/services/zns-import/zns-import-service/src/main/resources/application.yaml create mode 100644 core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/FixedWidthParser.kt create mode 100644 core/zns-parser/build.gradle.kts create mode 100644 core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt create mode 100644 core/zns-parser/src/commonTest/kotlin/at/mocode/zns/parser/ZnsLegacyParsersTest.kt create mode 100644 docs/temp/Chat-Verlauf-Gemini_2026-03-25.md diff --git a/backend/infrastructure/zns-importer/build.gradle.kts b/backend/infrastructure/zns-importer/build.gradle.kts new file mode 100644 index 00000000..a4f41db8 --- /dev/null +++ b/backend/infrastructure/zns-importer/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + alias(libs.plugins.kotlinJvm) +} + +dependencies { + implementation(platform(projects.platform.platformBom)) + implementation(projects.core.coreUtils) + implementation(projects.core.coreDomain) + implementation(projects.core.znsParser) + implementation(projects.backend.services.clubs.clubsDomain) + implementation(projects.backend.services.persons.personsDomain) + implementation(projects.backend.services.horses.horsesDomain) + implementation(projects.backend.services.officials.officialsDomain) + + testImplementation(projects.platform.platformTesting) +} diff --git a/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportResult.kt b/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportResult.kt new file mode 100644 index 00000000..01a5856e --- /dev/null +++ b/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportResult.kt @@ -0,0 +1,38 @@ +package at.mocode.zns.importer + +/** + * Ergebnis eines ZNS-Imports. + * + * Enthält Zähler für importierte, aktualisierte und übersprungene Datensätze + * sowie eine Liste von Fehlern/Warnungen, die während des Imports aufgetreten sind. + */ +data class ZnsImportResult( + val vereineImportiert: Int = 0, + val vereineAktualisiert: Int = 0, + val reiterImportiert: Int = 0, + val reiterAktualisiert: Int = 0, + val pferdeImportiert: Int = 0, + val pferdeAktualisiert: Int = 0, + val richterImportiert: Int = 0, + val richterAktualisiert: Int = 0, + val fehler: List = emptyList(), + val warnungen: List = emptyList() +) { + val gesamtImportiert: Int + get() = vereineImportiert + reiterImportiert + pferdeImportiert + richterImportiert + + val gesamtAktualisiert: Int + get() = vereineAktualisiert + reiterAktualisiert + pferdeAktualisiert + richterAktualisiert + + val hatFehler: Boolean + get() = fehler.isNotEmpty() + + fun zusammenfassung(): String = + "ZNS-Import abgeschlossen: " + + "$gesamtImportiert neu importiert, $gesamtAktualisiert aktualisiert. " + + "Vereine: ${vereineImportiert}N/${vereineAktualisiert}U, " + + "Reiter: ${reiterImportiert}N/${reiterAktualisiert}U, " + + "Pferde: ${pferdeImportiert}N/${pferdeAktualisiert}U, " + + "Richter: ${richterImportiert}N/${richterAktualisiert}U. " + + if (hatFehler) "${fehler.size} Fehler aufgetreten." else "Keine Fehler." +} 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 new file mode 100644 index 00000000..8a5d7abf --- /dev/null +++ b/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt @@ -0,0 +1,243 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.zns.importer + +import at.mocode.clubs.domain.repository.VereinRepository +import at.mocode.horses.domain.repository.HorseRepository +import at.mocode.officials.domain.repository.FunktionaerRepository +import at.mocode.persons.domain.repository.ReiterRepository +import at.mocode.zns.parser.ZnsLegacyParsers +import java.io.InputStream +import java.nio.charset.Charset +import java.util.zip.ZipInputStream + +/** + * Orchestrator für den ZNS-Stammdaten-Import. + * + * Nimmt eine ZNS-ZIP-Datei entgegen, entpackt sie in-memory, liest die + * enthaltenen .DAT-Dateien mit CP850-Encoding und persistiert die geparsten + * Domänenobjekte über die jeweiligen Repositories (Upsert-Logik). + * + * Die Verarbeitungsreihenfolge ist fix: + * 1. VEREIN01.DAT → DomVerein (via VereinRepository) + * 2. LIZENZ01.DAT → DomReiter (via ReiterRepository) + * 3. PFERDE01.DAT → DomPferd (via HorseRepository) + * 4. RICHT01.DAT → DomFunktionaer (via FunktionaerRepository) + * + * Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl + * im Backend (REST-Upload) als auch in der Compose Desktop App (Offline-Import) + * verwendet werden. + * + * @param vereinRepository Repository für Vereine. + * @param reiterRepository Repository für Reiter/Personen. + * @param horseRepository Repository für Pferde. + * @param funktionaerRepository Repository für Funktionäre/Richter. + */ +class ZnsImportService( + private val vereinRepository: VereinRepository, + private val reiterRepository: ReiterRepository, + private val horseRepository: HorseRepository, + private val funktionaerRepository: FunktionaerRepository +) { + + companion object { + private val CP850 = Charset.forName("Cp850") + + private const val FILE_VEREIN = "VEREIN01.DAT" + private const val FILE_LIZENZ = "LIZENZ01.DAT" + private const val FILE_PFERDE = "PFERDE01.DAT" + private const val FILE_RICHT = "RICHT01.DAT" + } + + /** + * Importiert eine ZNS-ZIP-Datei aus einem [InputStream]. + * + * @param zipInputStream Der InputStream der ZIP-Datei. + * @return [ZnsImportResult] mit Statistiken und eventuellen Fehlern. + */ + suspend fun importiereZip(zipInputStream: InputStream): ZnsImportResult { + val dateien = mutableMapOf>() + ZipInputStream(zipInputStream).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + val name = entry.name.uppercase().substringAfterLast("/") + if (name in setOf(FILE_VEREIN, FILE_LIZENZ, FILE_PFERDE, FILE_RICHT)) { + dateien[name] = zip.readBytes().toString(CP850).lines() + } + zip.closeEntry() + entry = zip.nextEntry + } + } + + val fehler = mutableListOf() + val warnungen = mutableListOf() + + val (vereineNeu, vereineUpd) = importiereVereine(dateien[FILE_VEREIN] ?: emptyList(), fehler) + val (reiterNeu, reiterUpd) = importiereReiter(dateien[FILE_LIZENZ] ?: emptyList(), fehler, warnungen) + val (pferdeNeu, pferdeUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler) + val (richterNeu, richterUpd) = importiereRichter(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen) + + return ZnsImportResult( + vereineImportiert = vereineNeu, + vereineAktualisiert = vereineUpd, + reiterImportiert = reiterNeu, + reiterAktualisiert = reiterUpd, + pferdeImportiert = pferdeNeu, + pferdeAktualisiert = pferdeUpd, + richterImportiert = richterNeu, + richterAktualisiert = richterUpd, + fehler = fehler, + warnungen = warnungen + ) + } + + // ------------------------------------------------------------------------- + // Private Hilfsmethoden + // ------------------------------------------------------------------------- + + private suspend fun importiereVereine( + zeilen: List, + fehler: MutableList + ): Pair { + var neu = 0 + var aktualisiert = 0 + zeilen.forEachIndexed { index, zeile -> + runCatching { + val verein = ZnsLegacyParsers.parseVerein(zeile) ?: return@forEachIndexed + val vorhanden = vereinRepository.findByVereinsNummer(verein.vereinsNummer) + if (vorhanden == null) { + vereinRepository.save(verein) + neu++ + } else { + vereinRepository.save( + vorhanden.copy( + name = verein.name, + kurzname = verein.kurzname, + bundesland = verein.bundesland, + ort = verein.ort, + plz = verein.plz, + strasse = verein.strasse, + oepsRegionNummer = verein.oepsRegionNummer, + istAktiv = verein.istAktiv, + datenQuelle = verein.datenQuelle + ).withUpdatedTimestamp() + ) + aktualisiert++ + } + }.onFailure { e -> + fehler.add("$FILE_VEREIN Zeile ${index + 1}: ${e.message}") + } + } + return Pair(neu, aktualisiert) + } + + private suspend fun importiereReiter( + zeilen: List, + fehler: MutableList, + warnungen: MutableList + ): Pair { + var neu = 0 + var aktualisiert = 0 + zeilen.forEachIndexed { index, zeile -> + runCatching { + val reiter = ZnsLegacyParsers.parseLizenz(zeile) ?: return@forEachIndexed + val vorhanden = reiterRepository.findBySatznummer(reiter.satznummer) + if (vorhanden == null) { + reiterRepository.save(reiter) + neu++ + } else { + reiterRepository.save( + vorhanden.copy( + vorname = reiter.vorname, + nachname = reiter.nachname, + vereinsName = reiter.vereinsName, + nation = reiter.nation, + lizenzKlasse = reiter.lizenzKlasse, + istAktiv = reiter.istAktiv, + datenQuelle = reiter.datenQuelle + ).withUpdatedTimestamp() + ) + aktualisiert++ + } + }.onFailure { e -> + fehler.add("$FILE_LIZENZ Zeile ${index + 1}: ${e.message}") + } + } + return Pair(neu, aktualisiert) + } + + private suspend fun importierePferde( + zeilen: List, + fehler: MutableList + ): Pair { + var neu = 0 + var aktualisiert = 0 + zeilen.forEachIndexed { index, zeile -> + runCatching { + val pferd = ZnsLegacyParsers.parsePferd(zeile) ?: return@forEachIndexed + val vorhanden = pferd.lebensnummer + ?.takeIf { it.isNotBlank() } + ?.let { horseRepository.findByLebensnummer(it) } + if (vorhanden == null) { + horseRepository.save(pferd) + neu++ + } else { + horseRepository.save( + vorhanden.copy( + pferdeName = pferd.pferdeName, + geschlecht = pferd.geschlecht, + geburtsdatum = pferd.geburtsdatum, + rasse = pferd.rasse, + lebensnummer = pferd.lebensnummer, + oepsNummer = pferd.oepsNummer, + istAktiv = pferd.istAktiv, + datenQuelle = pferd.datenQuelle + ).withUpdatedTimestamp() + ) + aktualisiert++ + } + }.onFailure { e -> + fehler.add("$FILE_PFERDE Zeile ${index + 1}: ${e.message}") + } + } + return Pair(neu, aktualisiert) + } + + private suspend fun importiereRichter( + zeilen: List, + fehler: MutableList, + warnungen: MutableList + ): Pair { + var neu = 0 + var aktualisiert = 0 + zeilen.forEachIndexed { index, zeile -> + runCatching { + val richter = ZnsLegacyParsers.parseRichter(zeile) ?: return@forEachIndexed + val richterNummer = richter.richterNummer ?: run { + warnungen.add("$FILE_RICHT Zeile ${index + 1}: Keine RichterNummer – übersprungen.") + return@forEachIndexed + } + val vorhanden = funktionaerRepository.findByRichterNummer(richterNummer) + if (vorhanden == null) { + funktionaerRepository.save(richter) + neu++ + } else { + funktionaerRepository.save( + vorhanden.copy( + vorname = richter.vorname, + nachname = richter.nachname, + vereinsNummer = richter.vereinsNummer, + richterNummer = richter.richterNummer, + istAktiv = richter.istAktiv, + datenQuelle = richter.datenQuelle + ).withUpdatedTimestamp() + ) + aktualisiert++ + } + }.onFailure { e -> + fehler.add("$FILE_RICHT Zeile ${index + 1}: ${e.message}") + } + } + return Pair(neu, aktualisiert) + } +} 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 new file mode 100644 index 00000000..315857d5 --- /dev/null +++ b/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt @@ -0,0 +1,219 @@ +package at.mocode.zns.importer + +import at.mocode.clubs.domain.model.DomVerein +import at.mocode.clubs.domain.repository.VereinRepository +import at.mocode.horses.domain.model.DomPferd +import at.mocode.horses.domain.repository.HorseRepository +import at.mocode.officials.domain.model.DomFunktionaer +import at.mocode.officials.domain.repository.FunktionaerRepository +import at.mocode.persons.domain.model.DomReiter +import at.mocode.persons.domain.repository.ReiterRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.charset.Charset +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@OptIn(kotlin.uuid.ExperimentalUuidApi::class) +class ZnsImportServiceTest { + + private val vereinRepository = mockk() + private val reiterRepository = mockk() + private val horseRepository = mockk() + private val funktionaerRepository = mockk() + + private lateinit var service: ZnsImportService + + private val cp850 = Charset.forName("Cp850") + + @BeforeEach + fun setUp() { + service = ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository) + } + + // ------------------------------------------------------------------------- + // Hilfsmethoden + // ------------------------------------------------------------------------- + + /** Erstellt eine in-memory ZIP-Datei mit den übergebenen Einträgen. */ + private fun buildZip(vararg entries: Pair): ByteArrayInputStream { + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zip -> + for ((name, content) in entries) { + zip.putNextEntry(ZipEntry(name)) + zip.write(content.toByteArray(cp850)) + zip.closeEntry() + } + } + return ByteArrayInputStream(baos.toByteArray()) + } + + /** Erzeugt eine gültige VEREIN01.DAT-Zeile (mind. 30 Zeichen). */ + private fun vereinZeile( + nummer: String = "0001", + name: String = "Testverein Wien" + ): String { + // Stelle 1-4: Vereinsnummer, Stelle 5-34: Name (30 Zeichen) + return nummer.padEnd(4) + name.padEnd(30) + } + + /** Erzeugt eine gültige LIZENZ01.DAT-Zeile (mind. 200 Zeichen). */ + private fun lizenzZeile( + satznummer: String = "123456", + nachname: String = "Mustermann", + vorname: String = "Max" + ): String { + // Stelle 1-6: Satznummer, 7-56: Nachname (50), 57-81: Vorname (25) + return satznummer.padEnd(6) + + nachname.padEnd(50) + + vorname.padEnd(25) + + " ".repeat(200) // Rest auffüllen + } + + /** Erzeugt eine gültige PFERDE01.DAT-Zeile (mind. 211 Zeichen). */ + private fun pferdeZeile( + name: String = "Blitz", + lebensnummer: String = "AT123456" + ): String { + // Stelle 1-4: Kopfnummer, 5-34: Name (30), 35-43: Lebensnummer (9), 44: Geschlecht, 45-48: Geburtsjahr, 202-211: Satznummer + val base = "0001" + + name.padEnd(30) + + lebensnummer.padEnd(9) + + "W" + // Geschlecht: Wallach + "2015" + // Geburtsjahr + " ".repeat(157) // Auffüllen bis Stelle 201 + return base + "SAT0000001".padEnd(10) // Satznummer ab Stelle 202 + } + + /** Erzeugt eine gültige RICHT01.DAT-Zeile (mind. 83 Zeichen). */ + private fun richterZeile( + satznummer: String = "R00001", + name: String = "Huber, Anna" + ): String { + // Stelle 1: Typ, 2-7: Satznummer (6), 8-82: Name (75) + return "R" + satznummer.padEnd(6) + name.padEnd(75) + } + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + @Test + fun `importiereZip - neue Vereine werden gespeichert`() = runTest { + val zip = buildZip("VEREIN01.DAT" to vereinZeile()) + + coEvery { vereinRepository.findByVereinsNummer(any()) } returns null + coEvery { vereinRepository.save(any()) } answers { firstArg() } + + val result = service.importiereZip(zip) + + assertThat(result.vereineImportiert).isEqualTo(1) + assertThat(result.vereineAktualisiert).isEqualTo(0) + assertThat(result.fehler).isEmpty() + coVerify(exactly = 1) { vereinRepository.save(any()) } + } + + @Test + fun `importiereZip - bestehende Vereine werden aktualisiert`() = runTest { + val zip = buildZip("VEREIN01.DAT" to vereinZeile(name = "Neuer Name")) + val vorhanden = DomVerein(vereinsNummer = "0001", name = "Alter Name") + + coEvery { vereinRepository.findByVereinsNummer("0001") } returns vorhanden + coEvery { vereinRepository.save(any()) } answers { firstArg() } + + val result = service.importiereZip(zip) + + assertThat(result.vereineImportiert).isEqualTo(0) + assertThat(result.vereineAktualisiert).isEqualTo(1) + coVerify(exactly = 1) { vereinRepository.save(match { it.name == "Neuer Name" }) } + } + + @Test + fun `importiereZip - neue Reiter werden gespeichert`() = runTest { + val zip = buildZip("LIZENZ01.DAT" to lizenzZeile()) + + coEvery { reiterRepository.findBySatznummer(any()) } returns null + coEvery { reiterRepository.save(any()) } answers { firstArg() } + + val result = service.importiereZip(zip) + + assertThat(result.reiterImportiert).isEqualTo(1) + assertThat(result.reiterAktualisiert).isEqualTo(0) + assertThat(result.fehler).isEmpty() + coVerify(exactly = 1) { reiterRepository.save(any()) } + } + + @Test + fun `importiereZip - neue Pferde werden gespeichert`() = runTest { + val zip = buildZip("PFERDE01.DAT" to pferdeZeile()) + + coEvery { horseRepository.findByLebensnummer(any()) } returns null + coEvery { horseRepository.save(any()) } answers { firstArg() } + + val result = service.importiereZip(zip) + + assertThat(result.pferdeImportiert).isEqualTo(1) + assertThat(result.pferdeAktualisiert).isEqualTo(0) + assertThat(result.fehler).isEmpty() + coVerify(exactly = 1) { horseRepository.save(any()) } + } + + @Test + fun `importiereZip - neue Richter werden gespeichert`() = runTest { + val zip = buildZip("RICHT01.DAT" to richterZeile()) + + coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null + coEvery { funktionaerRepository.save(any()) } answers { firstArg() } + + val result = service.importiereZip(zip) + + assertThat(result.richterImportiert).isEqualTo(1) + assertThat(result.richterAktualisiert).isEqualTo(0) + assertThat(result.fehler).isEmpty() + coVerify(exactly = 1) { funktionaerRepository.save(any()) } + } + + @Test + fun `importiereZip - vollstaendiger Import aller vier Dateien`() = runTest { + val zip = buildZip( + "VEREIN01.DAT" to vereinZeile(), + "LIZENZ01.DAT" to lizenzZeile(), + "PFERDE01.DAT" to pferdeZeile(), + "RICHT01.DAT" to richterZeile() + ) + + coEvery { vereinRepository.findByVereinsNummer(any()) } returns null + coEvery { vereinRepository.save(any()) } answers { firstArg() } + coEvery { reiterRepository.findBySatznummer(any()) } returns null + coEvery { reiterRepository.save(any()) } answers { firstArg() } + coEvery { horseRepository.findByLebensnummer(any()) } returns null + coEvery { horseRepository.save(any()) } answers { firstArg() } + coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null + coEvery { funktionaerRepository.save(any()) } answers { firstArg() } + + val result = service.importiereZip(zip) + + assertThat(result.gesamtImportiert).isEqualTo(4) + assertThat(result.gesamtAktualisiert).isEqualTo(0) + assertThat(result.fehler).isEmpty() + assertThat(result.hatFehler).isFalse() + } + + @Test + fun `importiereZip - leere ZIP ergibt Null-Ergebnis ohne Fehler`() = runTest { + val zip = buildZip() + + val result = service.importiereZip(zip) + + assertThat(result.gesamtImportiert).isEqualTo(0) + assertThat(result.gesamtAktualisiert).isEqualTo(0) + assertThat(result.fehler).isEmpty() + } +} diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt new file mode 100644 index 00000000..9b30d971 --- /dev/null +++ b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt @@ -0,0 +1,266 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.horses.infrastructure.persistence + +import at.mocode.core.domain.model.PferdeGeschlechtE +import at.mocode.horses.domain.model.DomPferd +import at.mocode.horses.domain.repository.HorseRepository +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.statements.UpdateBuilder +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.update +import kotlin.time.Clock +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +/** + * Exposed-basierte Implementierung des HorseRepository (v1-API). + */ +class HorseRepositoryImpl : HorseRepository { + + override suspend fun findById(id: Uuid): DomPferd? = transaction { + HorseTable.selectAll().where { HorseTable.id eq id.toJavaUuid() } + .map { rowToDomPferd(it) } + .singleOrNull() + } + + override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = transaction { + HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer } + .map { rowToDomPferd(it) } + .singleOrNull() + } + + override suspend fun findByChipNummer(chipNummer: String): DomPferd? = transaction { + HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer } + .map { rowToDomPferd(it) } + .singleOrNull() + } + + override suspend fun findByPassNummer(passNummer: String): DomPferd? = transaction { + HorseTable.selectAll().where { HorseTable.passNummer eq passNummer } + .map { rowToDomPferd(it) } + .singleOrNull() + } + + override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = transaction { + HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer } + .map { rowToDomPferd(it) } + .singleOrNull() + } + + override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = transaction { + HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer } + .map { rowToDomPferd(it) } + .singleOrNull() + } + + override suspend fun findByName(searchTerm: String, limit: Int): List = transaction { + val pattern = "%$searchTerm%" + HorseTable.selectAll().where { HorseTable.pferdeName like pattern } + .limit(limit) + .map { rowToDomPferd(it) } + } + + override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List = transaction { + HorseTable.selectAll().where { + (HorseTable.besitzerId eq ownerId.toJavaUuid()).let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.map { rowToDomPferd(it) } + } + + override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List = + transaction { + HorseTable.selectAll().where { + (HorseTable.verantwortlichePersonId eq responsiblePersonId.toJavaUuid()).let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.map { rowToDomPferd(it) } + } + + override suspend fun findByGeschlecht( + geschlecht: PferdeGeschlechtE, + activeOnly: Boolean, + limit: Int + ): List = transaction { + HorseTable.selectAll().where { + (HorseTable.geschlecht eq geschlecht).let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.limit(limit).map { rowToDomPferd(it) } + } + + override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List = transaction { + HorseTable.selectAll().where { + (HorseTable.rasse eq rasse).let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.limit(limit).map { rowToDomPferd(it) } + } + + override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List = transaction { + HorseTable.selectAll() + .map { rowToDomPferd(it) } + .filter { it.geburtsdatum?.year == birthYear && (!activeOnly || it.istAktiv) } + } + + override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List = + transaction { + HorseTable.selectAll() + .map { rowToDomPferd(it) } + .filter { + val year = it.geburtsdatum?.year + year != null && year in fromYear..toYear && (!activeOnly || it.istAktiv) + } + } + + override suspend fun findAllActive(limit: Int): List = transaction { + HorseTable.selectAll().where { HorseTable.istAktiv eq true } + .limit(limit) + .map { rowToDomPferd(it) } + } + + override suspend fun findOepsRegistered(activeOnly: Boolean): List = transaction { + HorseTable.selectAll().where { + HorseTable.oepsNummer.isNotNull().let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.map { rowToDomPferd(it) } + } + + override suspend fun findFeiRegistered(activeOnly: Boolean): List = transaction { + HorseTable.selectAll().where { + HorseTable.feiNummer.isNotNull().let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.map { rowToDomPferd(it) } + } + + override suspend fun save(horse: DomPferd): DomPferd = transaction { + val existing = HorseTable.selectAll() + .where { HorseTable.id eq horse.pferdId.toJavaUuid() } + .singleOrNull() + if (existing == null) { + HorseTable.insert { applyToStatement(it, horse) } + } else { + HorseTable.update({ HorseTable.id eq horse.pferdId.toJavaUuid() }) { + applyToStatement(it, horse.copy(updatedAt = Clock.System.now())) + } + } + horse + } + + override suspend fun delete(id: Uuid): Boolean = transaction { + HorseTable.deleteWhere { HorseTable.id eq id.toJavaUuid() } > 0 + } + + override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = transaction { + HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }.count() > 0 + } + + override suspend fun existsByChipNummer(chipNummer: String): Boolean = transaction { + HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }.count() > 0 + } + + override suspend fun existsByPassNummer(passNummer: String): Boolean = transaction { + HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }.count() > 0 + } + + override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = transaction { + HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }.count() > 0 + } + + override suspend fun existsByFeiNummer(feiNummer: String): Boolean = transaction { + HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }.count() > 0 + } + + override suspend fun countActive(): Long = transaction { + HorseTable.selectAll().where { HorseTable.istAktiv eq true }.count() + } + + override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = transaction { + HorseTable.selectAll().where { + (HorseTable.besitzerId eq ownerId.toJavaUuid()).let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.count() + } + + override suspend fun countOepsRegistered(activeOnly: Boolean): Long = transaction { + HorseTable.selectAll().where { + HorseTable.oepsNummer.isNotNull().let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.count() + } + + override suspend fun countFeiRegistered(activeOnly: Boolean): Long = transaction { + HorseTable.selectAll().where { + HorseTable.feiNummer.isNotNull().let { + if (activeOnly) it and (HorseTable.istAktiv eq true) else it + } + }.count() + } + + // ------------------------------------------------------------------------- + // Hilfsmethoden + // ------------------------------------------------------------------------- + + private fun rowToDomPferd(row: ResultRow): DomPferd = DomPferd( + pferdId = row[HorseTable.id].value.toKotlinUuid(), + pferdeName = row[HorseTable.pferdeName], + geschlecht = row[HorseTable.geschlecht], + geburtsdatum = row[HorseTable.geburtsdatum], + rasse = row[HorseTable.rasse], + farbe = row[HorseTable.farbe], + besitzerId = row[HorseTable.besitzerId]?.toKotlinUuid(), + verantwortlichePersonId = row[HorseTable.verantwortlichePersonId]?.toKotlinUuid(), + zuechterName = row[HorseTable.zuechterName], + zuchtbuchNummer = row[HorseTable.zuchtbuchNummer], + lebensnummer = row[HorseTable.lebensnummer], + chipNummer = row[HorseTable.chipNummer], + passNummer = row[HorseTable.passNummer], + oepsNummer = row[HorseTable.oepsNummer], + feiNummer = row[HorseTable.feiNummer], + vaterName = row[HorseTable.vaterName], + mutterName = row[HorseTable.mutterName], + mutterVaterName = row[HorseTable.mutterVaterName], + stockmass = row[HorseTable.stockmass], + istAktiv = row[HorseTable.istAktiv], + bemerkungen = row[HorseTable.bemerkungen], + datenQuelle = row[HorseTable.datenQuelle], + createdAt = row[HorseTable.createdAt], + updatedAt = row[HorseTable.updatedAt] + ) + + private fun applyToStatement(statement: UpdateBuilder<*>, horse: DomPferd) { + statement[HorseTable.id] = horse.pferdId.toJavaUuid() + statement[HorseTable.pferdeName] = horse.pferdeName + statement[HorseTable.geschlecht] = horse.geschlecht + statement[HorseTable.geburtsdatum] = horse.geburtsdatum + statement[HorseTable.rasse] = horse.rasse + statement[HorseTable.farbe] = horse.farbe + statement[HorseTable.besitzerId] = horse.besitzerId?.toJavaUuid() + statement[HorseTable.verantwortlichePersonId] = horse.verantwortlichePersonId?.toJavaUuid() + statement[HorseTable.zuechterName] = horse.zuechterName + statement[HorseTable.zuchtbuchNummer] = horse.zuchtbuchNummer + statement[HorseTable.lebensnummer] = horse.lebensnummer + statement[HorseTable.chipNummer] = horse.chipNummer + statement[HorseTable.passNummer] = horse.passNummer + statement[HorseTable.oepsNummer] = horse.oepsNummer + statement[HorseTable.feiNummer] = horse.feiNummer + statement[HorseTable.vaterName] = horse.vaterName + statement[HorseTable.mutterName] = horse.mutterName + statement[HorseTable.mutterVaterName] = horse.mutterVaterName + statement[HorseTable.stockmass] = horse.stockmass + statement[HorseTable.istAktiv] = horse.istAktiv + statement[HorseTable.bemerkungen] = horse.bemerkungen + statement[HorseTable.datenQuelle] = horse.datenQuelle + statement[HorseTable.createdAt] = horse.createdAt + statement[HorseTable.updatedAt] = horse.updatedAt + } +} diff --git a/backend/services/zns-import/zns-import-service/build.gradle.kts b/backend/services/zns-import/zns-import-service/build.gradle.kts new file mode 100644 index 00000000..070688ca --- /dev/null +++ b/backend/services/zns-import/zns-import-service/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSpring) + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.dependencyManagement) +} + +springBoot { + mainClass.set("at.mocode.zns.import.service.ZnsImportServiceApplicationKt") +} + +dependencies { + implementation(projects.platform.platformDependencies) + implementation(projects.core.coreDomain) + implementation(projects.core.coreUtils) + implementation(projects.backend.infrastructure.znsImporter) + implementation(projects.backend.services.clubs.clubsDomain) + implementation(projects.backend.services.clubs.clubsInfrastructure) + implementation(projects.backend.services.persons.personsDomain) + implementation(projects.backend.services.persons.personsInfrastructure) + implementation(projects.backend.services.horses.horsesDomain) + implementation(projects.backend.services.horses.horsesInfrastructure) + implementation(projects.backend.services.officials.officialsDomain) + implementation(projects.backend.services.officials.officialsInfrastructure) + + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.validation) + implementation(libs.spring.boot.starter.actuator) + + implementation(libs.exposed.core) + implementation(libs.exposed.dao) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.migration.jdbc) + implementation(libs.exposed.kotlin.datetime) + implementation(libs.hikari.cp) + + runtimeOnly(libs.postgresql.driver) + testRuntimeOnly(libs.h2.driver) + + testImplementation(projects.platform.platformTesting) + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.logback.classic) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/ZnsImportServiceApplication.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/ZnsImportServiceApplication.kt new file mode 100644 index 00000000..707e1a48 --- /dev/null +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/ZnsImportServiceApplication.kt @@ -0,0 +1,21 @@ +package at.mocode.zns.import.service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.context.annotation.ComponentScan + +@SpringBootApplication +@ComponentScan( + basePackages = [ + "at.mocode.zns.import.service", + "at.mocode.clubs.infrastructure", + "at.mocode.persons.infrastructure", + "at.mocode.horses.infrastructure", + "at.mocode.officials.infrastructure" + ] +) +class ZnsImportServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/api/ZnsImportController.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/api/ZnsImportController.kt new file mode 100644 index 00000000..026d8a99 --- /dev/null +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/api/ZnsImportController.kt @@ -0,0 +1,42 @@ +package at.mocode.zns.import.service.api + +import at.mocode.zns.import.service.job.ImportJob +import at.mocode.zns.import.service.job.ImportJobRegistry +import at.mocode.zns.import.service.job.ZnsImportOrchestrator +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile + +data class ImportStartResponse(val jobId: String) + +@RestController +@RequestMapping("/api/v1/import/zns") +class ZnsImportController( + private val orchestrator: ZnsImportOrchestrator, + private val jobRegistry: ImportJobRegistry +) { + + /** + * POST /api/v1/import/zns + * Nimmt eine .zip oder .dat Datei entgegen und startet den asynchronen Import. + * Rückgabe: 202 Accepted mit JobId. + */ + @PostMapping(consumes = ["multipart/form-data"]) + fun starteImport(@RequestParam("file") file: MultipartFile): ResponseEntity { + val job = jobRegistry.erstelleJob() + orchestrator.starteImport(job.jobId, file.bytes) + return ResponseEntity.status(HttpStatus.ACCEPTED).body(ImportStartResponse(job.jobId)) + } + + /** + * GET /api/v1/import/zns/{jobId}/status + * Gibt den aktuellen Fortschritt und Statusmeldungen zurück. + */ + @GetMapping("/{jobId}/status") + fun holeStatus(@PathVariable jobId: String): ResponseEntity { + val job = jobRegistry.findeJob(jobId) + ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(job) + } +} diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/RepositoryConfiguration.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/RepositoryConfiguration.kt new file mode 100644 index 00000000..726057ae --- /dev/null +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/RepositoryConfiguration.kt @@ -0,0 +1,28 @@ +package at.mocode.zns.import.service.config + +import at.mocode.clubs.domain.repository.VereinRepository +import at.mocode.clubs.infrastructure.persistence.ExposedVereinRepository +import at.mocode.horses.domain.repository.HorseRepository +import at.mocode.horses.infrastructure.persistence.HorseRepositoryImpl +import at.mocode.officials.domain.repository.FunktionaerRepository +import at.mocode.officials.infrastructure.persistence.ExposedFunktionaerRepository +import at.mocode.persons.domain.repository.ReiterRepository +import at.mocode.persons.infrastructure.persistence.ExposedReiterRepository +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class RepositoryConfiguration { + + @Bean + fun vereinRepository(): VereinRepository = ExposedVereinRepository() + + @Bean + fun reiterRepository(): ReiterRepository = ExposedReiterRepository() + + @Bean + fun horseRepository(): HorseRepository = HorseRepositoryImpl() + + @Bean + fun funktionaerRepository(): FunktionaerRepository = ExposedFunktionaerRepository() +} diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/ZnsImportDatabaseConfiguration.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/ZnsImportDatabaseConfiguration.kt new file mode 100644 index 00000000..5a072e9c --- /dev/null +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/ZnsImportDatabaseConfiguration.kt @@ -0,0 +1,37 @@ +package at.mocode.zns.import.service.config + +import at.mocode.clubs.infrastructure.persistence.VereinTable +import at.mocode.horses.infrastructure.persistence.HorseTable +import at.mocode.officials.infrastructure.persistence.FunktionaerTable +import at.mocode.persons.infrastructure.persistence.ReiterTable +import jakarta.annotation.PostConstruct +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile + +@Configuration +@Profile("dev") +class ZnsImportDatabaseConfiguration( + @Value("\${spring.datasource.url}") private val jdbcUrl: String, + @Value("\${spring.datasource.username}") private val username: String, + @Value("\${spring.datasource.password}") private val password: String +) { + private val log = LoggerFactory.getLogger(ZnsImportDatabaseConfiguration::class.java) + + @PostConstruct + fun initializeDatabase() { + log.info("Initialisiere Datenbank-Schema für ZNS-Import-Service...") + Database.connect(jdbcUrl, user = username, password = password) + transaction { + val statements = MigrationUtils.statementsRequiredForDatabaseMigration( + VereinTable, ReiterTable, HorseTable, FunktionaerTable + ) + statements.forEach { exec(it) } + log.info("Datenbank-Schema erfolgreich initialisiert ({} Statements)", statements.size) + } + } +} diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ImportJobRegistry.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ImportJobRegistry.kt new file mode 100644 index 00000000..0aad668a --- /dev/null +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ImportJobRegistry.kt @@ -0,0 +1,39 @@ +package at.mocode.zns.import.service.job + +import org.springframework.stereotype.Component +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 } + +data class ImportJob( + val jobId: String, + var status: ImportJobStatus = ImportJobStatus.AUSSTEHEND, + var fortschritt: Int = 0, + var meldungen: MutableList = mutableListOf(), + var fehler: MutableList = mutableListOf(), + var warnungen: MutableList = mutableListOf() +) + +@OptIn(ExperimentalUuidApi::class) +@Component +class ImportJobRegistry { + private val jobs = ConcurrentHashMap() + + fun erstelleJob(): ImportJob { + val job = ImportJob(jobId = Uuid.random().toString()) + jobs[job.jobId] = job + return job + } + + fun findeJob(jobId: String): ImportJob? = jobs[jobId] + + fun aktualisiereStatus(jobId: String, status: ImportJobStatus, meldung: String? = null, fortschritt: Int? = null) { + jobs[jobId]?.let { job -> + job.status = status + meldung?.let { job.meldungen.add(it) } + fortschritt?.let { job.fortschritt = it } + } + } +} diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ZnsImportOrchestrator.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ZnsImportOrchestrator.kt new file mode 100644 index 00000000..1f3b4da2 --- /dev/null +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ZnsImportOrchestrator.kt @@ -0,0 +1,50 @@ +package at.mocode.zns.import.service.job + +import at.mocode.clubs.domain.repository.VereinRepository +import at.mocode.horses.domain.repository.HorseRepository +import at.mocode.officials.domain.repository.FunktionaerRepository +import at.mocode.persons.domain.repository.ReiterRepository +import at.mocode.zns.importer.ZnsImportService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.springframework.stereotype.Service + +@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 scope = CoroutineScope(Dispatchers.IO) + + fun starteImport(jobId: String, zipBytes: ByteArray) { + scope.launch { + runCatching { + jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.ENTPACKEN, "Entpacke ZIP-Datei...", 5) + + val service = ZnsImportService(vereinRepository, reiterRepository, horseRepository, funktionaerRepository) + + jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.LADE_VEREINE, "Lade Vereine...", 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 + ) + + jobRegistry.findeJob(jobId)?.let { job -> + job.fehler.addAll(result.fehler) + job.warnungen.addAll(result.warnungen) + } + }.onFailure { ex -> + jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.FEHLER, "Fehler: ${ex.message}") + jobRegistry.findeJob(jobId)?.fehler?.add(ex.message ?: "Unbekannter Fehler") + } + } + } +} diff --git a/backend/services/zns-import/zns-import-service/src/main/resources/application.yaml b/backend/services/zns-import/zns-import-service/src/main/resources/application.yaml new file mode 100644 index 00000000..c726c1e7 --- /dev/null +++ b/backend/services/zns-import/zns-import-service/src/main/resources/application.yaml @@ -0,0 +1,34 @@ +server: + port: ${ZNS_IMPORT_SERVER_PORT:8095} + +spring: + application: + name: zns-import-service + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db} + username: ${SPRING_DATASOURCE_USERNAME:pg-user} + password: ${SPRING_DATASOURCE_PASSWORD:pg-password} + driver-class-name: org.postgresql.Driver + servlet: + multipart: + enabled: true + max-file-size: 50MB + max-request-size: 50MB + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +app: + service-name: ${spring.application.name} diff --git a/core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/FixedWidthParser.kt b/core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/FixedWidthParser.kt new file mode 100644 index 00000000..5aabf4a5 --- /dev/null +++ b/core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/FixedWidthParser.kt @@ -0,0 +1,39 @@ +package at.mocode.core.utils.parser + +/** + * A simple utility to parse fixed-width strings based on 1-based start positions and lengths. + * This is particularly useful for parsing legacy data formats like the OePS ZNS formats. + */ +class FixedWidthLineReader(private val line: String) { + + /** + * Extracts a string from the given 1-based start position with the given length. + * Trims leading and trailing whitespace. + * Returns an empty string if the position is out of bounds. + */ + fun getString(start1Based: Int, length: Int): String { + val start0Based = start1Based - 1 + if (start0Based >= line.length) return "" + + val end0Based = minOf(start0Based + length, line.length) + return line.substring(start0Based, end0Based).trim() + } + + /** + * Extracts a string and parses it as a Long. + * Returns null if the field is empty or cannot be parsed. + */ + fun getLongOrNull(start1Based: Int, length: Int): Long? { + val str = getString(start1Based, length) + return str.toLongOrNull() + } + + /** + * Extracts a string and parses it as an Int. + * Returns null if the field is empty or cannot be parsed. + */ + fun getIntOrNull(start1Based: Int, length: Int): Int? { + val str = getString(start1Based, length) + return str.toIntOrNull() + } +} diff --git a/core/zns-parser/build.gradle.kts b/core/zns-parser/build.gradle.kts new file mode 100644 index 00000000..929d69b1 --- /dev/null +++ b/core/zns-parser/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvm() + sourceSets { + commonMain { + dependencies { + implementation(projects.core.coreDomain) + implementation(projects.core.coreUtils) + + // Domänen-Modelle für das Parsing + implementation(projects.backend.services.clubs.clubsDomain) + implementation(projects.backend.services.persons.personsDomain) + implementation(projects.backend.services.horses.horsesDomain) + implementation(projects.backend.services.officials.officialsDomain) + + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) + } + } + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + } +} 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 new file mode 100644 index 00000000..89755e1e --- /dev/null +++ b/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt @@ -0,0 +1,156 @@ +package at.mocode.zns.parser + +import at.mocode.clubs.domain.model.DomVerein +import at.mocode.core.domain.model.DatenQuelleE +import at.mocode.core.domain.model.LizenzKlasseE +import at.mocode.core.domain.model.PferdeGeschlechtE +import at.mocode.core.utils.parser.FixedWidthLineReader +import at.mocode.horses.domain.model.DomPferd +import at.mocode.officials.domain.model.DomFunktionaer +import at.mocode.persons.domain.model.DomReiter +import kotlinx.datetime.LocalDate +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** + * Parsers for the legacy ZNS .dat files (Fixed-Width format, CP850 encoded). + * + * This module is independent of backend infrastructure so it can be used + * by both the backend API and the Compose Desktop app (Offline-First). + */ +@OptIn(ExperimentalUuidApi::class) +object ZnsLegacyParsers { + + /** + * Parses a line from VEREIN01.DAT. + */ + fun parseVerein(line: String): DomVerein? { + if (line.isBlank() || line.length < 5) return null + + val reader = FixedWidthLineReader(line) + + val vereinsNummer = reader.getString(1, 4) + val vereinsName = reader.getString(5, 50) + + if (vereinsNummer.isBlank() || vereinsName.isBlank()) return null + + return DomVerein( + vereinsNummer = vereinsNummer, + name = vereinsName, + datenQuelle = DatenQuelleE.IMPORT_ZNS + ) + } + + /** + * Parses a line from LIZENZ01.DAT. + */ + fun parseLizenz(line: String): DomReiter? { + if (line.isBlank() || line.length < 57) return null + + val reader = FixedWidthLineReader(line) + + val satznummer = reader.getString(1, 6) + if (satznummer.isBlank()) return null + + val nachname = reader.getString(7, 50) + val vorname = reader.getString(57, 25) + val vereinsName = reader.getString(84, 50) + val nation = reader.getString(134, 3) + + val lizenzString = reader.getString(137, 4) + val lizenz = mapLizenz(lizenzString) + + val sperrlisteFlag = reader.getString(200, 1) + val gesperrt = sperrlisteFlag == "S" + + return DomReiter( + personId = Uuid.random(), + satznummer = satznummer, + nachname = nachname, + vorname = vorname, + vereinsName = vereinsName.ifBlank { null }, + nation = nation.ifBlank { null }, + lizenzKlasse = lizenz, + istAktiv = !gesperrt, + datenQuelle = DatenQuelleE.IMPORT_ZNS + ) + } + + /** + * Parses a line from PFERDE01.DAT. + */ + fun parsePferd(line: String): DomPferd? { + if (line.isBlank() || line.length < 202) return null + + val reader = FixedWidthLineReader(line) + + val satznummer = reader.getString(202, 10) + if (satznummer.isBlank()) return null + + val name = reader.getString(5, 30) + val kopfnummer = reader.getString(1, 4) + val lebensnummer = reader.getString(35, 9) + + val geschlechtChar = reader.getString(44, 1) + val geschlecht = mapGeschlecht(geschlechtChar) + + val geburtsjahr = reader.getIntOrNull(45, 4) + val geburtsdatum = geburtsjahr?.let { LocalDate(it, 1, 1) } + + return DomPferd( + pferdeName = name, + geschlecht = geschlecht, + geburtsdatum = geburtsdatum, + lebensnummer = lebensnummer.ifBlank { null }, + datenQuelle = DatenQuelleE.IMPORT_ZNS + ) + } + + /** + * Parses a line from RICHT01.DAT. + */ + fun parseRichter(line: String): DomFunktionaer? { + if (line.isBlank() || line.length < 8) return null + + val reader = FixedWidthLineReader(line) + + val satznummer = reader.getString(2, 6) + if (satznummer.isBlank()) return null + + val fullName = reader.getString(8, 75) + val parts = fullName.split(",").map { it.trim() } + val nachname = parts.getOrNull(0) ?: fullName + val vorname = parts.getOrNull(1) ?: "" + + return DomFunktionaer( + richterNummer = satznummer, + nachname = nachname, + vorname = vorname, + datenQuelle = DatenQuelleE.IMPORT_ZNS + ) + } + + private fun mapLizenz(lizenz: String): LizenzKlasseE { + return when (lizenz.uppercase()) { + "R1" -> LizenzKlasseE.R1 + "R2" -> LizenzKlasseE.R2 + "R3" -> LizenzKlasseE.R3 + "RD1" -> LizenzKlasseE.RD1 + "RD2" -> LizenzKlasseE.RD2 + "RD3" -> LizenzKlasseE.RD3 + "JN" -> LizenzKlasseE.JN + "JG" -> LizenzKlasseE.JG + "YR" -> LizenzKlasseE.YR + else -> LizenzKlasseE.LIZENZFREI + } + } + + private fun mapGeschlecht(geschlecht: String): PferdeGeschlechtE { + return when (geschlecht.uppercase()) { + "W" -> PferdeGeschlechtE.WALLACH + "S" -> PferdeGeschlechtE.STUTE + "H" -> PferdeGeschlechtE.HENGST + else -> PferdeGeschlechtE.UNBEKANNT + } + } +} diff --git a/core/zns-parser/src/commonTest/kotlin/at/mocode/zns/parser/ZnsLegacyParsersTest.kt b/core/zns-parser/src/commonTest/kotlin/at/mocode/zns/parser/ZnsLegacyParsersTest.kt new file mode 100644 index 00000000..def667f7 --- /dev/null +++ b/core/zns-parser/src/commonTest/kotlin/at/mocode/zns/parser/ZnsLegacyParsersTest.kt @@ -0,0 +1,80 @@ +package at.mocode.zns.parser + +import at.mocode.core.domain.model.LizenzKlasseE +import at.mocode.core.domain.model.PferdeGeschlechtE +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class ZnsLegacyParsersTest { + + @Test + fun `parseVerein should extract VEREIN01 correctly`() { + val line = "1234Reitverein Test " + val result = ZnsLegacyParsers.parseVerein(line) + + assertNotNull(result) + assertEquals("1234", result.vereinsNummer) + assertEquals("Reitverein Test", result.name) + } + + @Test + fun `parseLizenz should extract LIZENZ01 correctly`() { + val sb = StringBuilder() + sb.append("123456") + sb.append("Mustermann ") + sb.append("Max ") + sb.append("01") + sb.append("Reitverein Wien ") + sb.append("AUT") + sb.append("R1 ") + + while (sb.length < 199) { + sb.append(" ") + } + sb.append("S") + + val result = ZnsLegacyParsers.parseLizenz(sb.toString()) + assertNotNull(result) + assertEquals("123456", result.satznummer) + assertEquals("Mustermann", result.nachname) + assertEquals("Max", result.vorname) + assertEquals("Reitverein Wien", result.vereinsName) + assertEquals(LizenzKlasseE.R1, result.lizenzKlasse) + assertEquals(false, result.istAktiv) + } + + @Test + fun `parsePferd should extract PFERDE01 correctly`() { + val sb = StringBuilder() + sb.append("A123") + sb.append("Black Beauty ") + sb.append("123456789") + sb.append("W") + sb.append("2010") + + while (sb.length < 201) { + sb.append(" ") + } + sb.append("0000000001") + + val result = ZnsLegacyParsers.parsePferd(sb.toString()) + assertNotNull(result) + assertEquals("Black Beauty", result.pferdeName) + assertEquals("123456789", result.lebensnummer) + assertEquals(PferdeGeschlechtE.WALLACH, result.geschlecht) + assertEquals(2010, result.geburtsdatum?.year) + } + + @Test + fun `parseRichter should extract RICHT01 correctly`() { + val line = + "X123456Richter, Peter GA " + val result = ZnsLegacyParsers.parseRichter(line) + + assertNotNull(result) + assertEquals("123456", result.richterNummer) + assertEquals("Richter", result.nachname) + assertEquals("Peter", result.vorname) + } +} diff --git a/docs/01_Architecture/MASTER_ROADMAP.md b/docs/01_Architecture/MASTER_ROADMAP.md index 39143362..610855c3 100644 --- a/docs/01_Architecture/MASTER_ROADMAP.md +++ b/docs/01_Architecture/MASTER_ROADMAP.md @@ -141,10 +141,12 @@ und über definierte Schnittstellen kommunizieren. #### 🧹 Agent: Curator & Lead Architect (ZNS-Importer) -* [ ] **ZNS-Importer (MVP):** Implementierung des ZNS-Importers für Stammdaten. +* [x] **ZNS-Importer (MVP) – Phase 1 & 2:** `core:zns-parser` (KMP), `ZnsLegacyParsers` (alle 4 Dateitypen, CP850), + `ZnsImportService` (Orchestrator, ZIP in-memory, Upsert), Unit-Tests grün. → Detaillierte Planung: `docs/01_Architecture/Roadmap_ZNS_Importer.md` - * [ ] Backend-Infrastruktur & CP850 Parser (Phase 1) - * [ ] Domain-Mapping & Upsert in DB (Phase 2) + * [x] Backend-Infrastruktur & CP850 Parser (Phase 1 – Parser/Modul) + * [x] Domain-Mapping & Upsert in DB (Phase 2) + * [ ] REST-API & Job-Management (Phase 1 – Controller/Job-Registry) * [ ] Frontend-Integration mit File-Picker & Status-Polling (Phase 3) --- diff --git a/docs/01_Architecture/Roadmap_ZNS_Importer.md b/docs/01_Architecture/Roadmap_ZNS_Importer.md index 02029dd4..3b80aa48 100644 --- a/docs/01_Architecture/Roadmap_ZNS_Importer.md +++ b/docs/01_Architecture/Roadmap_ZNS_Importer.md @@ -1,6 +1,6 @@ --- type: Roadmap -status: PLANNED +status: IN_PROGRESS owner: Curator last_update: 2026-03-25 --- @@ -37,30 +37,31 @@ gesteuert wird und die Daten persistent im Backend (`actor-context`) ablegt. ### Phase 1: Backend-Infrastruktur & Parsing (👷 Backend Developer) -* [ ] **API Design:** +* [x] **API Design:** * `POST /api/v1/import/zns` (Multipart/form-data, nimmt `.zip` oder `.dat` entgegen). * Rückgabe: `202 Accepted` mit einer `JobId` (UUID). * `GET /api/v1/import/zns/{jobId}/status` (Gibt aktuellen Fortschritt und Statusmeldungen zurück). -* [ ] **Job-Management:** - * Implementierung einer Thread-sicheren In-Memory-Registry oder einfachen DB-Tabelle für den Import-Status (z. B. " - ENTPACKEN", "LADE_VEREINE_40%", "ABGESCHLOSSEN"). -* [ ] **Unzip-Service:** - * Entpacken der übermittelten `ZNS.zip` in ein temporäres Verzeichnis (bzw. in den Speicher). -* [ ] **Legacy-Parser (CP850 Fixed-Width):** - * Schreiben von robusten Mapping-Funktionen, die die Strings bytegenau auslesen. - * Berücksichtigung der Spezifikationen aus `OETO-2026_Meldestelle_Pflichtenheft_V2.4_2021-07-28.md`. + * Implementiert in `backend:services:zns-import:zns-import-service` (`ZnsImportController`). ✅ +* [x] **Job-Management:** + * Thread-sichere In-Memory-Registry (`ImportJobRegistry`, `ConcurrentHashMap`) implementiert. + * Status-Enum: `AUSSTEHEND`, `ENTPACKEN`, `LADE_VEREINE`, `LADE_REITER`, `LADE_PFERDE`, `LADE_RICHTER`, + `ABGESCHLOSSEN`, `FEHLER`. ✅ +* [x] **Unzip-Service:** + * ZIP-Entpackung in-memory implementiert (`ZnsImportService`). +* [x] **Legacy-Parser (CP850 Fixed-Width):** + * `ZnsLegacyParsers` in `core:zns-parser` (KMP-Modul) implementiert. + * Alle 4 Dateitypen (VEREIN01, LIZENZ01, PFERDE01, RICHT01) bytegenau gemappt. 4 Unit-Tests grün. ### Phase 2: Domain-Mapping & Persistenz (👷 Backend Developer) -* [ ] **Mapper-Logik:** - * Konvertierung der geparsten Legacy-Daten in unsere Domain-Modelle (`DomVerein`, `DomReiter`, `DomPferd`, - `DomFunktionaer`). - * *Sonderfälle:* - * `PFERDE01.dat`: Ausländische Systemnummern ignorieren (keine echten Lebensnummern). - * `LIZENZ01.dat`: Sperrlisten-Flag (`S`) korrekt auf das `DomReiter`-Modell mappen. -* [ ] **Upsert-Strategie (DB):** - * Sicherstellen, dass bei mehrfachem Import keine Duplikate entstehen. - * Abgleich über eindeutige ZNS-IDs (Satznummern). `INSERT` bei neuen, `UPDATE` bei bestehenden Datensätzen. +* [x] **Mapper-Logik:** + * `DomVerein`, `DomReiter`, `DomPferd`, `DomFunktionaer` vollständig gemappt. + * *Sonderfälle umgesetzt:* + * `PFERDE01.dat`: Ausländische Systemnummern werden ignoriert. ✅ + * `LIZENZ01.dat`: Sperrlisten-Flag (`S`) korrekt auf `DomReiter` gemappt. ✅ +* [x] **Upsert-Strategie (DB):** + * `ZnsImportService` implementiert find + save Logik (Upsert). 7 Unit-Tests grün. + * Fehler pro Zeile werden gesammelt (kein Abbruch bei Einzelfehlern). `ZnsImportResult` mit Zählern & Fehlerlisten. ### Phase 3: Frontend-Integration (🎨 Frontend Expert) diff --git a/docs/temp/Chat-Verlauf-Gemini_2026-03-25.md b/docs/temp/Chat-Verlauf-Gemini_2026-03-25.md new file mode 100644 index 00000000..0240be98 --- /dev/null +++ b/docs/temp/Chat-Verlauf-Gemini_2026-03-25.md @@ -0,0 +1,236 @@ +Das ist der Chat-Verlauf mit Gemini +Datum: 25. März 2026 + +Dieser Verlauf begann mit den Dokumentationen + +- docs/01_Architecture/MASTER_ROADMAP.md +- docs/01_Architecture/Roadmap_ZNS_Importer.md + +--- + +👷 [Backend Developer] + +Großartig, packen wir es an! Da wir im actor-context mit Dateien arbeiten, die das Encoding CP850 (Codepage 850) nutzen +und feste Spaltenbreiten (Fixed-Width) haben, bauen wir uns als Erstes ein robustes Parser-Werkzeug. + +Da CP850 ein Single-Byte-Encoding ist, entspricht ein Byte exakt einem Zeichen (Char). Das macht das Parsing in Kotlin +relativ einfach: Wenn wir die Datei mit dem richtigen Charset als Text einlesen, können wir mit einfachen +substring-Operationen arbeiten. Da das Pflichtenheft 1-basierte Indizes verwendet (z.B. "Stelle 1"), bauen wir uns einen +Reader, der genau diese Logik abbildet, um das Mapping so fehlerfrei wie möglich abtippen zu können. + +Ich schlage vor, wir legen diesen Parser im core-utils Modul ab, da er eine reine Infrastruktur/Utility-Komponente ist. + +Ich erstelle nun die Basis für den Parser. + +--- + +👷 [Backend Developer] + +Ich habe die Basis für das Parsing der ZNS-Dateien gelegt. +Hier ist, was ich bisher gemacht habe: + +1. FixedWidthLineReader: Eine Utility-Klasse in core-utils, die uns exakt nach den 1-basierten Spaltenangaben aus dem + Pflichtenheft die Felder als String, Int oder Long ausschneidet. +2. ZnsLegacyParsers: Ein dedizierter Parser, der pro Legacy-Datei (VEREIN01.DAT, LIZENZ01.DAT, PFERDE01.DAT, + RICHT01.DAT) Zeile für Zeile in unsere modernen Domänenmodelle (DomVerein, DomReiter, DomPferd, DomFunktionaer) + übersetzt. Sonderfälle wie das "Sperrlisten"-Flag und Enums (Reiterlizenz, Geschlecht) werden dort direkt gemappt. +3. Abhängigkeiten: Die build.gradle.kts in core-utils wurde angepasst, sodass sie die Domänen der anderen SCS kennt, und + es wurden bereits Tests für alle Parser hinzugefügt, um sicherzustellen, dass das Substring-Mapping anhand der Längen + perfekt funktioniert. + +Nächster Schritt: ZnsImportService (Der Orchestrator) + +Als Nächstes brauchen wir im actor-context (bzw. einem übergreifenden Modul oder direkt in der Infrastruktur für den +Import) einen Service, der: + +1. Ein hochgeladenes .zip (oder byte-array) entgegennimmt und im Memory / Temp-Verzeichnis entpackt. +2. Die entpackten .dat-Dateien explizit mit Charset.forName("Cp850") einliest, um die Umlaute zu bewahren. +3. Die Zeilen an unseren ZnsLegacyParsers übergibt. +4. Die importierten Aggregate über die Repositories (Upsert-Logik) in der Datenbank persistiert – und zwar genau in der + festgelegten Reihenfolge (Vereine -> Reiter -> Pferde -> Richter). + +Wo wollen wir diesen zentralen ZnsImportService ablegen? Er berührt technisch mehrere Bounded Contexts (Vereine, +Reiter/Personen, Pferde, Funktionäre). + +Sollen wir dafür ein eigenes kleines Infrastruktur-Modul backend:infrastructure:zns-import erstellen oder es im +API-Gateway / einem dedizierten masterdata-Service bündeln? Was schlägt der Lead Architect vor? + +--- + +Frage: +Du willst in +core/core-utils/src/commonMain/kotlin/at/mocode/core/utils/parser/ZnsLegacyParsers.kt +=== Unresolved reference === +import at.mocode.clubs.domain.model.DomVerein +import at.mocode.persons.domain.model.DomReiter +import at.mocode.horses.domain.model.DomPferd +import at.mocode.officials.domain.model.DomFunktionaer +=== +importieren, aber diese sind "unresolved". + +Wir haben diese Domänen in: + +backend/services/clubs +backend/services/horses +backend/services/officials +backend/services/persons + +sind diese dort falsch platziert? Sollten diese nicht in "core/core-domain" sein? + +Analysiere dies und erkläre es mir bitte. + +--- + +🏗️ [Lead Architect] + +Adlerauge! Sehr gut aufgepasst. Du hast hier intuitiv ein klassisches Architektur-Problem aufgedeckt – eine Verletzung +der Dependency Rule (Schichtenarchitektur). + +Lass mich das genau analysieren und erklären: + +1. Sind die Domänen falsch platziert? + +NEIN, sie sind absolut richtig platziert! Gemäß unserem ADR-0014 (Bounded Context Mapping) haben wir uns für eine +Self-Contained-Systems (SCS) Architektur entschieden. Das bedeutet: Jeder Service (Clubs, Horses, Persons, Officials) +ist fachlich autark und hat seine eigenen, isolierten Geschäftsregeln. Wenn wir DomVerein, DomReiter und DomPferd in das +core-domain-Modul verschieben würden, würden wir wieder einen zentralen "Daten-Monolithen" erschaffen. Das core-domain +Modul ist ausschließlich als Shared Kernel gedacht (für Dinge, die wirklich von allen geteilt werden, wie unsere +ÖTO-Enums SparteE, Fehlercodes oder Basis-Klassen). + +2. Warum schlägt der Import fehl? + +Das Problem liegt nicht bei den Domänen, sondern beim ZnsLegacyParsers. Das Modul core-utils ist das absolute Fundament +unserer Anwendung. Es liegt auf der tiefsten Ebene und wird von fast allen anderen Modulen konsumiert. Wenn wir in +core-utils nun anfangen, auf backend/services/... zuzugreifen, erzeugen wir eine Rückwärts-Abhängigkeit. Das Fundament +versucht auf einmal, das Dach zu kennen. Im schlimmsten Fall führt das in Gradle zu einer Circular Dependency ( +Kreisabhängigkeit). + +Deswegen meckert die IDE und findet die Referenzen nicht – das Build-System verbietet (zurecht) diesen Zugriff von unten +nach oben. + +3. Wie ist die korrekte Architektur-Lösung? + +Wir müssen Infrastruktur-Code (Utilities) von Fach-Code (ZNS-Domänen-Mapping) trennen: + +• Der FixedWidthLineReader: Dieser bleibt in core-utils. Er ist ein rein technisches, dummes Werkzeug, das Strings +anhand von Spaltenlängen zerschneidet. Er kennt keine Pferde oder Vereine. +• Der ZnsLegacyParsers: Dieser muss aus core-utils gelöscht werden! Er gehört in eine Schicht, die weiter "oben" liegt +und die Domänen-Modelle kennen darf. + +Wohin mit dem Parser und dem Import-Service? + +Da der ZNS-Import übergreifend arbeitet und vier verschiedene Bounded Contexts orchestrieren muss (Vereine -> Reiter -> +Pferde -> Funktionäre), ist er ein klassischer Use-Case / Orchestrator. + +Wir haben zwei architektonisch saubere Optionen: + +Option A (Dezentral - im jeweiligen Context): Wir legen den Parser für DomVerein in +backend/services/clubs/clubs-infrastructure ab, den für DomPferd in horses-infrastructure etc. Jeder Context baut sich +seinen Legacy-Importer selbst. Ein übergeordneter Orchestrator ruft diese nur der Reihe nach auf. + +Option B (Zentrales Integrations-Modul): Wir erstellen (oder nutzen das bestehende masterdata-Verzeichnis als) ein +dediziertes Modul backend:infrastructure:zns-import (oder backend:services:zns-importer). Dieses Modul fungiert als +Anti-Corruption Layer (ACL). Es hat Abhängigkeiten zu clubs-domain, persons-domain, horses-domain und übersetzt die +externe Legacy-Welt zentral in unsere neuen Modelle und speichert sie über die jeweiligen Repositories. + +Mein Architekturbeschluss: Wir wählen Option B als dediziertes Modul, da der Import eine einmalige/regelmäßige +Massen-Infrastrukturaufgabe ist, die eng gekoppelt an eine externe Datei (.zip) ist. Wenn sich das ZNS-Format ändert, +müssen wir nur diesen einen Importer anpassen und nicht vier verschiedene Services anfassen. + +Nächste Schritte + +Soll der 👷 Backend Developer: + +1. Die fehlerhaften Domänen-Imports aus der core/core-utils/build.gradle.kts wieder entfernen? +2. Die Datei ZnsLegacyParsers.kt aus core-utils löschen? +3. Ein neues Modul backend/infrastructure/zns-importer anlegen, in dem wir den Orchestrator und die Parser sauber + bündeln? + +--- + +Ja ich bin ebenfalls deiner Meinung mit Option B (Zentrales Integrations-Modul) mit einem neuen Modul +backend/infrastructure/zns-importer und wenn ich unsere Konstruktion richtig verstanden habe, können wir diesen " +ZNS-Importer" dann auch in unsere Desktop-App integrieren, so dass wir auch echt offline mit der Desktop-App arbeiten +können. +Habe ich das richtig verstanden? + +--- + +🏗️ [Lead Architect] + +Exakt! Du hast den Nagel auf den Kopf getroffen. Das ist der absolute Königsvorteil unserer sauberen Trennung. + +Wenn wir den ZNS-Importer so bauen, dass die reine Parsing- und Mapping-Logik (die Übersetzung von CP850-Strings in +DomVerein, DomReiter etc.) keine Abhängigkeiten zu Spring Boot oder einer bestimmten Datenbank hat, können wir diese +Logik direkt in der Compose Desktop App ausführen! +Das ermöglicht folgenden genialen Offline-First Workflow: + +1. Der Turnierveranstalter ist im Zelt am Reitplatz (kein Internet). +2. Er lädt die ZNS.zip (die er vorher per USB-Stick oder Mail bekommen hat) in die Desktop-App. +3. Die App entpackt und parst die Daten lokal auf dem Rechner. +4. Die Daten werden in die lokale SQLite-Datenbank der Desktop-App (via SQLDelight) geschrieben. +5. Sobald wieder Internet da ist, synchronisiert die App die neuen Stammdaten im Hintergrund mit unserem zentralen " + Zora"-Backend. + +Kleine Architektur-Korrektur für diesen Offline-Traum: Da die Compose Desktop App (Frontend) den Parser nutzen soll, +darf der Parser selbst nicht im backend-Ordner liegen (das Frontend darf nicht vom Backend abhängen). + +Ich habe die Dateien soeben aufgeräumt (die fehlerhaften Imports aus core-utils entfernt). + +Um deinen Offline-Wunsch perfekt umzusetzen, schlage ich vor, wir bauen ein eigenes KMP-Modul namens core:zns-parser. +Dieses Modul kennt die Domänen (clubs-domain, persons-domain, etc.) und enthält nur die reine Übersetzungslogik ( +String -> Domain Object). + +• Das Backend nutzt dieses Modul, wenn jemand die Datei über die REST-API hochlädt. +• Das Frontend (Desktop) nutzt dieses Modul, wenn jemand die Datei offline über das UI importiert. + +Die eigentliche Speicher-Logik (das Upsert in die PostgreSQL via Repositories) bleibt im Backend (backend: +infrastructure:zns-importer), während das Frontend seine eigene Speicher-Logik für SQLite baut. + +Bist du mit diesem Split einverstanden? Dann würde der 👷 Backend Developer jetzt das Modul core:zns-parser aufsetzen und +die reparierten Parser dort ablegen! + +--- + +Perfekt, dass machen wir + +--- + +👷 [Backend Developer] + +Erledigt! Das war ein exzellenter architektonischer Eingriff. Ich habe die Struktur wie besprochen umgebaut: + +1. core:zns-parser (Neues KMP-Modul): + ◦ Beinhaltet jetzt exklusiv ZnsLegacyParsers und die dazugehörigen Tests. + ◦ Kennt unsere Domänen-Modelle (DomVerein, DomReiter, etc.) und das core-utils Modul (für den FixedWidthLineReader). + ◦ Keine Abhängigkeiten zu Spring Boot oder Datenbanken. Die Desktop-App kann dieses Modul problemlos für den + Offline-Import einbinden! +2. core-utils (Bereinigt): + ◦ Die unerlaubten Rückwärts-Abhängigkeiten zu den Domänen wurden entfernt. Es enthält nur noch das rein technische + Werkzeug (FixedWidthLineReader). +3. backend:infrastructure:zns-importer (Neues Backend-Modul): + ◦ In der settings.gradle.kts registriert. + ◦ Dieses Modul kennt den core:zns-parser, alle Domänen und wird später Spring Boot kennen. Hier bauen wir den + Orchestrator (Service), der die Daten nach dem Parsen in die Repositories schiebt. + +Wie geht es weiter? Wir sind jetzt genau an dem Punkt, an dem wir den ZnsImportService in backend:infrastructure: +zns-importer schreiben können. +Dieser Service wird: + +1. Ein InputStream oder ByteArray der .zip-Datei entgegennehmen. +2. Die Zip-Datei entpacken (In-Memory). +3. Die Dateien in der strikten Reihenfolge (VEREIN01.dat -> LIZENZ01.dat -> PFERDE01.dat -> RICHT01.dat) zeilenweise mit + CP850 einlesen. +4. Den ZnsLegacyParsers aufrufen. +5. Die resultierenden Domänen-Objekte an die jeweiligen (noch zu definierenden/mockenden) Repositories ( + VereinRepository, ReiterRepository, etc.) zum Speichern/Upsert übergeben. + +Soll ich mit der Implementierung dieses ZnsImportService starten? + +--- + +Bitte überprüfe noch einmal deine letzte Arbeit, ich glaube dir ist dabei ein Fehler unterlaufen. + +--- + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 5e532fe5..dc98acc4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,6 +66,9 @@ include(":backend:infrastructure:persistence") // --- SECURITY --- include(":backend:infrastructure:security") +// --- ZNS-IMPORTER --- +include(":backend:infrastructure:zns-importer") + // === BACKEND - SERVICES === // --- ENTRIES (Nennungen) --- include(":backend:services:entries:entries-api") @@ -99,11 +102,15 @@ include(":backend:services:persons:persons-service") // --- PING (Ping Service) --- include(":backend:services:ping:ping-service") +// --- ZNS-IMPORT (Asynchroner ZNS-Stammdaten-Import) --- +include(":backend:services:zns-import:zns-import-service") + // ========================================================================== // CORE // ========================================================================== include(":core:core-domain") include(":core:core-utils") +include(":core:zns-parser") // ========================================================================== // DOCUMENTATION