Refactor license matrix and tokenizer logic: rename LicenseTable to LizenzTable, replace LicenseMatrixService with LizenzMatrixService, enhance tokenizer with normalized and fallback token handling, improve ZNS import for license extraction, and update related documentation.
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Waiting to run
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Waiting to run

This commit is contained in:
2026-04-06 23:51:56 +02:00
parent b7fa2d26a9
commit 7bf89c58d3
25 changed files with 612 additions and 138 deletions
@@ -4,6 +4,7 @@ import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.utils.parser.FixedWidthLineReader
import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.ReiterLizenz
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -50,17 +51,33 @@ object ZnsReiterParser {
val feiId = reader.getString(190, 8)
val sperrListe = reader.getString(198, 1)
val lizenzInfo = reader.getString(201, 10)
val lizenzKlasse = mapLizenz(reiterLizenzCode)
val lizenzen = mutableListOf<at.mocode.masterdata.domain.model.ReiterLizenz>()
// Lizenz-Token aus gesamter Zeile robust extrahieren und normalisieren
val parsedToken = ZnsReiterlicenseTokenizer.parseFromLine(line)
val lizenzen = mutableListOf<ReiterLizenz>()
// Aus festem Feld
if (reiterLizenzCode.isNotBlank()) {
lizenzen.add(at.mocode.masterdata.domain.model.ReiterLizenz(lizenzTyp = "REITERLIZENZ", kuerzel = reiterLizenzCode))
lizenzen.add(ReiterLizenz(lizenzTyp = "REITERLIZENZ", kuerzel = reiterLizenzCode))
}
// Aus erkanntem kombinierten Token weitere Einträge ergänzen
parsedToken?.normalizedKuerzel?.forEach { code ->
if (code.isNotBlank() && lizenzen.none { it.kuerzel.equals(code, ignoreCase = true) }) {
lizenzen.add(ReiterLizenz(lizenzTyp = "REITERLIZENZ", kuerzel = code))
}
}
if (startkarteCode.isNotBlank()) {
lizenzen.add(at.mocode.masterdata.domain.model.ReiterLizenz(lizenzTyp = "STARTKARTE", kuerzel = startkarteCode))
lizenzen.add(ReiterLizenz(lizenzTyp = "STARTKARTE", kuerzel = startkarteCode))
}
if (fahrLizenzCode.isNotBlank()) {
lizenzen.add(at.mocode.masterdata.domain.model.ReiterLizenz(lizenzTyp = "FAHRLIZENZ", kuerzel = fahrLizenzCode))
lizenzen.add(ReiterLizenz(lizenzTyp = "FAHRLIZENZ", kuerzel = fahrLizenzCode))
}
// lizenzKlasse befüllen: bevorzugt aus festem Feld, sonst aus Token ableiten (präferiere Dressur, sonst Springen)
val lizenzKlasse = when {
reiterLizenzCode.isNotBlank() -> mapLizenz(reiterLizenzCode)
parsedToken != null -> parsedToken.primaryKlasse
else -> ReiterLizenzKlasseE.LIZENZFREI
}
return Reiter(
@@ -71,7 +88,8 @@ object ZnsReiterParser {
bundeslandNummer = bundeslandNummer,
vereinsName = vereinsName.ifBlank { null },
nation = nation.ifBlank { null },
reiterLizenz = reiterLizenzCode.ifBlank { null },
reiterLizenz = (reiterLizenzCode.ifBlank { null }
?: parsedToken?.primaryKuerzel),
startkarte = startkarteCode.ifBlank { null },
fahrLizenz = fahrLizenzCode.ifBlank { null },
altersklasseJgJrU25 = altersklasseEnum,
@@ -0,0 +1,88 @@
package at.mocode.zns.parser
import at.mocode.core.domain.model.ReiterLizenzKlasseE
/**
* Erkennung und Normalisierung von Lizenz-Token in einer LIZENZ01.DAT-Zeile.
* Unterstützt Einzel- und Kombinationsformen (R{n}, RD{m}, S{n}, D{m}, R{n}S{k}, R{n}D{m}, RDS4).
*/
object ZnsReiterlicenseTokenizer {
private val tokenRegex = Regex(
pattern = "(RDS4|R[1-4]D[2-4]|R[1-4]S[2-4]|RD[1-4]|R[1-4]|S[1-4]|D[2-4])",
options = setOf(RegexOption.IGNORE_CASE)
)
data class Parsed(
val rawToken: String,
/** Normalisierte Kürzel-Liste, z.B. ["R3"], ["R2","RD3"] */
val normalizedKuerzel: List<String>,
/** Primäre Lizenzklasse für das vorhandene Domainfeld (Fallback): bevorzugt RD*, sonst R* */
val primaryKlasse: ReiterLizenzKlasseE,
/** Primäres Kürzel passend zur primaryKlasse */
val primaryKuerzel: String?
)
fun parseFromLine(line: String): Parsed? {
val matches = tokenRegex.findAll(line)
val last = matches.lastOrNull() ?: return null
val token = last.value.uppercase()
return normalize(token)
}
private fun normalize(token: String): Parsed? {
val upper = token.uppercase()
return when {
upper == "RDS4" -> Parsed(
rawToken = token,
normalizedKuerzel = listOf("R4", "RD3"),
primaryKlasse = ReiterLizenzKlasseE.RD3,
primaryKuerzel = "RD3"
)
upper.matches(Regex("R[1-4]D[2-4]")) -> {
val r = upper.substring(0, 2) // Rn
// upper is RnDm -> build RDm and ggf. kappen
val d = capRd4ToRd3("RD" + upper.substring(3, 4))
Parsed(token, listOf(r, d), mapPrimary(d, r), pickPrimaryKuerzel(d, r))
}
upper.matches(Regex("R[1-4]S[2-4]")) -> {
val r1 = upper.substring(0, 2)
val r2 = "R" + upper.substring(3, 4) // S{k} -> R{k}
val best = maxR(r1, r2)
Parsed(token, listOf(best), ReiterLizenzKlasseE.valueOf(best), best)
}
upper.matches(Regex("RD[1-4]")) -> {
val d = capRd4ToRd3(upper)
Parsed(token, listOf(d), mapPrimary(d, null), d)
}
upper.matches(Regex("R[1-4]")) -> {
Parsed(token, listOf(upper), ReiterLizenzKlasseE.valueOf(upper), upper)
}
upper.matches(Regex("S[1-4]")) -> {
val r = "R" + upper.substring(1, 2)
Parsed(token, listOf(r), ReiterLizenzKlasseE.valueOf(r), r)
}
upper.matches(Regex("D[2-4]")) -> {
val d = capRd4ToRd3("RD" + upper.substring(1, 2))
Parsed(token, listOf(d), mapPrimary(d, null), d)
}
else -> null
}
}
private fun capRd4ToRd3(code: String): String = if (code.equals("RD4", true)) "RD3" else code
private fun maxR(r1: String, r2: String): String {
val n1 = r1.removePrefix("R").toIntOrNull() ?: 0
val n2 = r2.removePrefix("R").toIntOrNull() ?: 0
val n = maxOf(n1, n2).coerceIn(1, 4)
return "R$n"
}
private fun mapPrimary(d: String?, r: String?): ReiterLizenzKlasseE = when {
d != null -> ReiterLizenzKlasseE.valueOf(d)
r != null -> ReiterLizenzKlasseE.valueOf(r)
else -> ReiterLizenzKlasseE.LIZENZFREI
}
private fun pickPrimaryKuerzel(d: String?, r: String?): String? = d ?: r
}
@@ -0,0 +1,47 @@
package at.mocode.zns.parser
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LicenseTokenizerTest {
@Test
fun `detects R2S3 and normalizes to R3`() {
val line = "... AUTR2S3 903801690699 18109450 2025W1990100310137032 R2S3 "
val parsed = ZnsReiterlicenseTokenizer.parseFromLine(line)
assertNotNull(parsed)
assertEquals(listOf("R3"), parsed.normalizedKuerzel)
assertEquals(ReiterLizenzKlasseE.R3, parsed.primaryKlasse)
assertEquals("R3", parsed.primaryKuerzel)
}
@Test
fun `detects R2D4 and caps to RD3`() {
val line = "... AUTR2D4 105500130676 6820868 2025W1987090510112093 R2D4 "
val parsed = ZnsReiterlicenseTokenizer.parseFromLine(line)
assertNotNull(parsed)
assertEquals(listOf("R2", "RD3"), parsed.normalizedKuerzel)
assertEquals(ReiterLizenzKlasseE.RD3, parsed.primaryKlasse)
assertEquals("RD3", parsed.primaryKuerzel)
}
@Test
fun `detects RD2`() {
val line = "... AUTRD2 900308190664 3462613 2021W19650928 "
val parsed = ZnsReiterlicenseTokenizer.parseFromLine(line)
assertNotNull(parsed)
assertEquals(listOf("RD2"), parsed.normalizedKuerzel)
assertEquals(ReiterLizenzKlasseE.RD2, parsed.primaryKlasse)
assertEquals("RD2", parsed.primaryKuerzel)
}
@Test
fun `returns null when no token present`() {
val line = "some random line without token"
val parsed = ZnsReiterlicenseTokenizer.parseFromLine(line)
assertNull(parsed)
}
}