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
@@ -1,7 +1,7 @@
# 📜 Reiter-Lizenzen & Startberechtigungen (OEPS)
Diese Dokumentation beschreibt die verschiedenen Lizenzstufen des **OEPS (Österreichischer Pferdesportverband)** und die
daraus resultierenden Startberechtigungen für die Sparten **Dressur (CDN)** und **Springen (CSN)** gemäß ÖTO 2026.
daraus resultierenden Startberechtigungen für die Sparten **Dressur (CDN)**, **Springen (CSN)**, **Vielseitigkeit (CCN/CCI)** und **Fahren (CAN/CAI)** gemäß ÖTO 2026.
## 1. Lizenztypen & Klassen
@@ -11,10 +11,10 @@ Prüfungen antreten darf.
| Code | Bezeichnung | Beschreibung | ZNS-Mapping |
|:--------|:-----------------|:-------------------------------------------------------|:-------------|
| **LZF** | Lizenzfrei | Nur Startkarte oder Reiterpass vorhanden. | `LIZENZFREI` |
| **R1** | Reiter-Lizenz 1 | Einstiegslizenz für Springen, Dressur, Vielseitigkeit. | `R1` |
| **R1** | Reiter-Lizenz 1 | Einstiegslizenz für Springen. | `R1` |
| **R2** | Reiter-Lizenz 2 | Fortgeschrittene (Springen bis LM/130cm). | `R2` |
| **R3** | Reiter-Lizenz 3 | Schwere Klasse (Springen bis S/145cm). | `R3` |
| **R4** | Reiter-Lizenz 4 | Höchste nationale Stufe (alle Klassen). | `R4` |
| **R4** | Reiter-Lizenz 4 | Höchste nationale Stufe Springen (bis 160cm). | `R4` |
| **RD1** | Dressur-Lizenz 1 | Speziallizenz nur für Dressur (Kl. A, L). | `RD1` |
| **RD2** | Dressur-Lizenz 2 | Speziallizenz nur für Dressur (Kl. LM, M). | `RD2` |
| **RD3** | Dressur-Lizenz 3 | Speziallizenz nur für Dressur (Kl. S). | `RD3` |
@@ -23,6 +23,8 @@ Prüfungen antreten darf.
## 2. Startberechtigungen nach Sparten
> Referenz: ÖTO 2026, ATeil § 15 „Reiterlizenzen“. Eine komprimierte Matrix mit Klassen/Höhen und zulässigen Lizenzen findet sich in der ProjektReferenz: `docs/03_Domain/02_Reference/OETO_Regelwerk/OETO-2026_A-Teil-Allgemeiner-Teil_16-12-2025.md` (Kapitel „Reiterlizenzen, § 15 ÖTO“).
### 2.1 Springen (CSN)
Die Startberechtigung richtet sich nach der Hindernishöhe der jeweiligen Klasse (§ 200 B-Teil).
@@ -44,11 +46,81 @@ Die Startberechtigung richtet sich nach dem Aufgabenniveau (§ 100 B-Teil).
| Klasse | Niveau | Erforderliche Lizenz | Besonderheiten |
|:---------------|:--------------------|:------------------------|:-----------------------------------------|
| **lizenzfrei** | - | **LZF** (Reiterpass) | Inkl. First Ridden, Dressurreiterbewerbe |
| **A** | Leicht | **R1 / RD1** oder höher | Grundausbildung |
| **L** | Mittelleicht | **R1 / RD1** oder höher | - |
| **LM** | Leicht-Mittelschwer | **R2 / RD2** oder höher | Kandare wahlweise |
| **M** | Mittelschwer | **R2 / RD2** oder höher | Kandarenpflicht |
| **S** | Schwer | **R3 / RD3** oder höher | St. Georg bis Grand Prix |
| **A** | Leicht | **RD1** oder höher | Grundausbildung |
| **L** | Mittelleicht | **RD1** oder höher | - |
| **LM** | Leicht-Mittelschwer | **RD2** oder höher | Kandare wahlweise |
| **M** | Mittelschwer | **RD2** oder höher | Kandarenpflicht |
| **S** | Schwer | **RD3** oder höher | St. Georg bis Grand Prix |
Hinweis RD4: Das ÖTO kennt in der Tabelle Kombinationsfälle bis `RD4`. Im System ist derzeit kein `RD4`Enum modelliert; faktisch wird `RD4` wie `RD3` behandelt (Kappung auf Dressur S). Eine spätere Erweiterung um `RD4` ist vorbereitet, siehe CHANGELOG.
---
### 2.3 Vielseitigkeit (CCN/CCI)
Vielseitigkeit kombiniert Dressur, Gelände und Springen. Für die Startberechtigung gilt das Prinzip der kumulativen Mindestanforderung: Die jeweils geforderte Dressur- und Spring-Lizenz muss erfüllt sein (Details je Klasse und Ausschreibung, § 300 BTeil).
| Klasse (typ.) | Richtwert | Erforderliche Lizenzen (kumulativ) | Besonderheiten |
|:--------------|:---------------------|:-----------------------------------|:--------------|
| **E/Einsteiger** | national, Einstiegsniveau | **LZF** (Startkarte) ODER `RD1`+`R1` (wenn gefordert) | gem. Ausschreibung |
| **A** | Einsteiger/Light | `RD1` UND `R1` | — |
| **L** | Mittelleicht | `RD1` UND `R1` | — |
| **M** | Mittelschwer | `RD2` UND `R2` | — |
| **S** | Schwer | `RD3` UND `R3` | höhere Anforderungen international |
Hinweis: In der Praxis orientiert sich die Spring-Anforderung an der in der Ausschreibung angegebenen Hindernishöhe der Springprüfung; die Dressur-Anforderung am ausgeschriebenen Dressur-Niveau der Teilprüfung. Wo nationale Sonderregelungen (Ponys, Haflinger/Noriker) bestehen, gelten diese zusätzlich.
---
### 2.4 Fahren (CAN/CAI)
Für Fahren existieren im ÖTO/FEIKontext eigene Fahrlizenzen (z. B. F1F3). Im aktuellen Systemmodell sind diese noch nicht als `ReiterLizenzKlasseE` hinterlegt. Bis zur Einführung entsprechender Enums/Regeln gilt:
| Klasse (typ.) | Richtwert | Systemzustand | Teilnahmevoraussetzung (heute) |
|:--------------|:-----------------|:----------------------------------------------|:-------------------------------|
| **A/L/M/S** | national/intern. | Keine `F*`Lizenztypen im Enum vorhanden | **Startkarte** (LZF) plus Zulassung laut Ausschreibung/Verbandsvorgaben |
Geplante Erweiterung: Ergänzung der EnumWerte `F1`..`F3` und einer FahrMatrix analog Dressur/Springen (separates Ticket). Die ZNSIntegration bleibt davon unberührt, bis ZNS eigene FahrKodierungen liefert.
---
### 2.5 Übersichtstabelle gemäß § 15 (kompakt)
Zur schnellen Orientierung ist die folgende, auf die SystemEnums abgebildete Kurzfassung der §15Tabelle enthalten. „B,C“ bedeutet „Turnierkategorien B, C, CNEU zulässig“, „LP“ steht für „Lizenzprüfung erforderlich“.
| Klasse/Höhe | V 80 | 60100 | V 90 | A | 105110 | V 100 | L | 115120 | V 105 | LM | 125130 | M | 135 | V110115 | S | V120 | 140145 | 150160 |
|:-----------:|:----:|:------:|:----:|:---:|:-------:|:-----:|:---:|:-------:|:-----:|:---:|:-------:|:---:|:---:|:---------:|:---:|:----:|:-------:|:-------:|
| Sparte | V | S | V | D | S | V | D | S | V | D | S | D | S | V | D | V | S | S |
| R1 | X | X | X | B,C | B,C | X | B,C | B,C | | | | | | | | | | |
| R2 | X | X | X | X | X | X | X | X | X | X | X | LP | | | | | | |
| R3 | X | X | X | X | X | X | X | X | X | X | X | X | X | X | | X | X | |
| RD1 | | | | B,C | | | B,C | | | | | | | | | | | |
| RD2 | | | | X | | | X | | | X | | LP | | | | | | |
| RD3 | | | | X | | | X | | | X | | X | | | X | | | |
| R4 | X | X | X | X | X | X | X | X | X | X | X | X | X | X | | X | X | X |
Abbildung im System:
- Zeilen mit KombiLizenzen (z. B. `R1D2`, `RDS4`) werden als gleichzeitiger Besitz getrennter Lizenzen interpretiert (zwei Einträge in `Reiter.lizenzen`).
- `RD4` ist aktuell nicht als Enum verfügbar; bis zur Erweiterung entspricht das Verhalten `RD3`.
---
### 2.6 Kombinationsreihen (§ 15)
Die folgenden Zeilen aus der ÖTOTabelle (§ 15) bilden Kombinationsfälle ab, die sich aus gleichzeitig erfüllten
Voraussetzungen ergeben. Sie sind systemisch als Parallelbesitz mehrerer Lizenzen zu verstehen und dienen der
schnellen Orientierung. Spaltenbezeichnungen entsprechen Abschnitt 2.5.
| Klasse/Höhe | V 80 | 60100 | V 90 | A | 105110 | V 100 | L | 115120 | V 105 | LM | 125130 | M | 135 | V110115 | S | V120 | 140145 | 150160 |
|:-----------:|:----:|:------:|:----:|:---:|:-------:|:-----:|:---:|:-------:|:-----:|:---:|:-------:|:---:|:---:|:---------:|:---:|:----:|:-------:|:-------:|
| R1S2 | X | X | X | B,C | X | X | B,C | X | | | X | | | | | | | |
| R1S3 | X | X | X | B,C | X | X | B,C | X | | | X | | X | | | | X | |
| R1S4 | X | X | X | B,C | X | X | B,C | X | | | X | | X | | | | X | X |
| R2S3 | X | X | X | X | X | X | X | X | X | X | X | LP | X | | | | X | |
| R2S4 | X | X | X | X | X | X | X | X | X | X | X | LP | X | | | | X | X |
| R3S4 | X | X | X | X | X | X | X | X | X | X | X | X | X | X | | X | X | X |
Legende siehe Abschnitt 2.5 (Bedeutung von „B,C“/„LP“ sowie Spartenkürzel).
---
@@ -58,30 +130,58 @@ Die Startberechtigung richtet sich nach dem Aufgabenniveau (§ 100 B-Teil).
Für Rasse-spezifische Bewerbe gelten oft abweichende (niedrigere) Lizenz-Anforderungen für höhere Klassen.
Hinweis: Abweichungen sind disziplinspezifisch zu verstehen (Dressur → `RD*`, Springen → `R*`).
* **Dressur (Haflinger):**
* Klasse L/LM: R(D)1 ausreichend.
* Klasse M: R(D)3 erforderlich.
* Klasse S: R(D)4 erforderlich.
* Klasse L/LM: **RD1** ausreichend.
* Klasse M: **RD3** erforderlich.
* Klasse S: nationale Sonderregelungen, idR **RD3**; höhere Abweichungen gemäß Ausschreibung.
* **Springen (Haflinger):**
* 95-120cm (bis Klasse M): R1 ausreichend.
* 125-135cm (Klasse S): R2 ausreichend.
* 95120cm (bis Klasse M): **R1** ausreichend.
* 125135cm (Klasse S): **R2** ausreichend.
### 3.2 Pony
* In Pony-Bewerben (bis Kl. L) ist die **Startkarte Allgemein** (Voraussetzung Reiterpass) ausreichend.
* Ab Klasse LM ist eine entsprechende Lizenz (R1/RD1) erforderlich.
* Ab Klasse LM ist eine entsprechende Lizenz erforderlich: **RD1** (Dressur) bzw. **R1** (Springen).
---
## 4. ZNS-Integration (LIZENZ01.dat)
## 4. Validierungslogik & ZNS-Integration
### 4.1 Validierungslogik (Systemverhalten)
Die Eligibility wird in zwei strikt getrennten Schritten geprüft:
1) Spartenlizenz prüfen
- Für Springen sind ausschließlich `R1..R4` gültig (keine Dressur-Lizenz ausreichend).
- Für Dressur sind ausschließlich `RD1..RD3` gültig (keine Spring-Lizenz ausreichend).
- Für Vielseitigkeit müssen die Anforderungen beider Sparten erfüllt sein: Dressur-Teilprüfung (RD*) UND Spring-Teilprüfung (R*), jeweils gemäß ausgeschriebener Klasse.
- Für Fahren gibt es aktuell keine `F*`Lizenzen im System; bis zur Erweiterung wird Fahren als lizenzfrei (LZF) behandelt und organisatorisch über die Ausschreibung begrenzt.
2) Maximal erlaubte Turnierklasse ermitteln
- Innerhalb der geprüften Sparte bestimmt die höchste bezahlte Lizenz die Obergrenze der Klasse/Höhe.
- Zur internen Kappung wird ggf. ein disziplinübergreifendes Mapping verwendet (nur nach bestandener Spartenprüfung):
- `R1↔RD1`, `R2↔RD2`, `R3/R4↔RD3`.
Konsequenz: Ein Reiter mit nur `RD2` darf Dressur bis LM/M reiten, aber im Springen nur lizenzfreie Bewerbe (E0). Umgekehrt
ermöglicht eine `R2` Springen bis 130cm, aber keine Dressur-Klassen. In der Vielseitigkeit wären für eine MKlasse beide Nachweise (`RD2` UND `R2`) erforderlich.
### 4.2 ZNS-Integration (LIZENZ01.dat)
Das System mappt die Felder aus der ZNS-Datei automatisch auf die interne `LizenzKlasseE`.
* **Feld `Reiterlizenz` (Pos 137):** Enthält die Hauptlizenz (z.B. `R1`).
* **Feld `Lizenz-Details` (Pos 201):** Enthält die Liste aller bezahlten Lizenzen (z.B. `RD1,F1`).
* *Logik:* Ein Reiter mit `RD2` darf Dressur LM/M reiten, aber Springen nur lizenzfrei (E0), sofern keine `R1` (oder
höher) vorhanden ist.
* **Feld `Reiterlizenz` (Pos 137):** Enthält die primäre Lizenzangabe (z. B. `R1` oder `RD1`).
* **Feld `Lizenz-Details` (Pos 201):** Enthält die Liste aller bezahlten Lizenzen (z. B. `RD1`). Werte für Fahren (`F*`) werden aktuell ignoriert, solange keine SystemEnums existieren.
* Das System aggregiert alle Vorkommen und bestimmt je Sparte die höchste vorhandene Lizenz.
---
> 📜 **Rulebook Expert Hinweis:** Die Startberechtigung muss bei jeder Nennung gegen die aktuelle Lizenz des Reiters (
> Stichtag Nennschluss) geprüft werden. Eine Höherreihung während eines Turniers ist gemäß § 17 Abs. 6 ausgeschlossen.
> 📜 **Rulebook Expert Hinweis:** Die Startberechtigung muss bei jeder Nennung gegen die aktuelle Lizenz des Reiters
> (Stichtag Nennschluss) geprüft werden. Eine Höherreihung während eines Turniers ist gemäß § 17 Abs. 6 ausgeschlossen.
---
Siehe ergänzend:
* `TURNIER_KLASSEN.md` für Klassendefinitionen und Höhen/Niveaus je Sparte.
* `GEBUEHRENORDNUNG.md` für Start-/Nenngelder und Mindest-Geldpreise.
* `ZNS_SCHNITTSTELLE.md` für Felder und Positionsangaben der LIZENZ01.dat.
@@ -192,13 +192,25 @@ data class Reiter(
/**
* Checks if the rider holds a license for a specific discipline.
* Simple logic for now: Any non-blank license field counts.
* Strikte Logik: Eine Dressur-Lizenz (RD1..RD3) gilt nur für DRESSUR,
* eine Reit-/Spring-Lizenz (R1..R4) nur für SPRINGEN. FAHREN nutzt die separate Fahr-Lizenz.
*/
fun hasLizenzForSparte(sparte: at.mocode.core.domain.model.SparteE): Boolean {
val lk = lizenzKlasse
return when (sparte) {
at.mocode.core.domain.model.SparteE.DRESSUR -> !reiterLizenz.isNullOrBlank()
at.mocode.core.domain.model.SparteE.SPRINGEN -> !reiterLizenz.isNullOrBlank()
at.mocode.core.domain.model.SparteE.DRESSUR ->
lk == ReiterLizenzKlasseE.RD1 ||
lk == ReiterLizenzKlasseE.RD2 ||
lk == ReiterLizenzKlasseE.RD3
at.mocode.core.domain.model.SparteE.SPRINGEN ->
lk == ReiterLizenzKlasseE.R1 ||
lk == ReiterLizenzKlasseE.R2 ||
lk == ReiterLizenzKlasseE.R3 ||
lk == ReiterLizenzKlasseE.R4
at.mocode.core.domain.model.SparteE.FAHREN -> !fahrLizenz.isNullOrBlank()
else -> hasLizenz()
}
}
@@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
@Serializable
data class ReitLizenz(
data class Reiterlizenz(
@Serializable(with = UuidSerializer::class)
val lizenzId: Uuid = Uuid.random(),
val code: String,
@@ -3,14 +3,14 @@
package at.mocode.masterdata.domain.repository
import at.mocode.masterdata.domain.model.FahrLizenz
import at.mocode.masterdata.domain.model.ReitLizenz
import at.mocode.masterdata.domain.model.Reiterlizenz
import at.mocode.masterdata.domain.model.Startkarte
/**
* Repository für alle Lizenz-Stammdaten (Reit, Fahr, Startkarten).
*/
interface MasterdataLicenseRepository {
suspend fun findReitLizenzByCode(code: String): ReitLizenz?
interface LizenzRepository {
suspend fun findReitLizenzByCode(code: String): Reiterlizenz?
suspend fun findFahrLizenzByCode(code: String): FahrLizenz?
suspend fun findStartkarteByCode(code: String): Startkarte?
}
@@ -1,57 +0,0 @@
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.LicenseMatrixEntry
import at.mocode.masterdata.domain.model.TurnierklasseDefinition
/**
* Standard-Implementierung des [LicenseMatrixService] gemäß ÖTO.
*/
class LicenseMatrixServiceImpl : LicenseMatrixService {
private val classHierarchy = listOf("E", "A", "L", "LM", "M", "S")
override fun isEligible(
reiter: Reiter,
turnierklasse: TurnierklasseDefinition,
sparte: SparteE,
matrix: List<LicenseMatrixEntry>,
alleKlassen: List<TurnierklasseDefinition>
): Boolean {
// 1. Basis-Check: Hat der Reiter überhaupt eine Lizenz für diese Sparte?
if (!reiter.hasLizenzForSparte(sparte)) return false
// 2. Max Turnierklasse aus Matrix ermitteln
val maxClassCode = getMaxTurnierklasse(reiter, sparte, matrix) ?: return false
// 3. Hierarchie-Check (maxClassCode vs. turnierklasse.code)
val maxIndex = classHierarchy.indexOf(maxClassCode)
val targetIndex = classHierarchy.indexOf(turnierklasse.code)
if (maxIndex == -1 || targetIndex == -1) return false
return targetIndex <= maxIndex
}
override fun getMaxTurnierklasse(
reiter: Reiter,
sparte: SparteE,
matrix: List<LicenseMatrixEntry>
): String? {
// Suche passenden Eintrag in der Matrix für (Sparte, Lizenzklasse)
val entry = matrix.find { it.sparte == sparte && it.lizenzKlasse == reiter.lizenzKlasse }
?: matrix.find { it.sparte == SparteE.DRESSUR && sparte == SparteE.DRESSUR && it.lizenzKlasse == reiter.lizenzKlasse } // Fallback/Spezial
?: if (reiter.lizenzKlasse == ReiterLizenzKlasseE.R1 ||
reiter.lizenzKlasse == ReiterLizenzKlasseE.R2 ||
reiter.lizenzKlasse == ReiterLizenzKlasseE.R3 ||
reiter.lizenzKlasse == ReiterLizenzKlasseE.R4) {
// Fallback für Dressur, wenn man eine Springlizenz hat (R1 gilt oft auch als RD1 etc. in manchen Kontexten,
// aber hier schauen wir primär ob die Matrix einen generischen Eintrag hat)
matrix.find { it.sparte == sparte && it.lizenzKlasse == ReiterLizenzKlasseE.LIZENZFREI }
} else null
return entry?.maxTurnierklasseCode
}
}
@@ -8,7 +8,7 @@ import at.mocode.masterdata.domain.model.TurnierklasseDefinition
/**
* Service zur Prüfung der Teilnahmeberechtigung basierend auf der Lizenz-Matrix.
*/
interface LicenseMatrixService {
interface LizenzMatrixService {
/**
* Prüft, ob ein Reiter mit seiner aktuellen Lizenz in einer bestimmten Turnierklasse startberechtigt ist.
@@ -0,0 +1,72 @@
package at.mocode.masterdata.domain.service
import at.mocode.core.domain.model.ReiterLizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.Reiter
import at.mocode.masterdata.domain.model.LicenseMatrixEntry
import at.mocode.masterdata.domain.model.TurnierklasseDefinition
/**
* Standard-Implementierung des [LizenzMatrixService] gemäß ÖTO.
*/
class LizenzMatrixServiceImpl : LizenzMatrixService {
private val classHierarchy = listOf("E", "A", "L", "LM", "M", "S")
override fun isEligible(
reiter: Reiter,
turnierklasse: TurnierklasseDefinition,
sparte: SparteE,
matrix: List<LicenseMatrixEntry>,
alleKlassen: List<TurnierklasseDefinition>
): Boolean {
// 1. Basis-Check: Hat der Reiter überhaupt eine Lizenz für diese Sparte?
if (!reiter.hasLizenzForSparte(sparte)) return false
// 2. Max Turnierklasse aus Matrix ermitteln
val maxClassCode = getMaxTurnierklasse(reiter, sparte, matrix) ?: return false
// 3. Hierarchie-Check (maxClassCode vs. turnierklasse.code)
val maxIndex = classHierarchy.indexOf(maxClassCode)
val targetIndex = classHierarchy.indexOf(turnierklasse.code)
if (maxIndex == -1 || targetIndex == -1) return false
return targetIndex <= maxIndex
}
override fun getMaxTurnierklasse(
reiter: Reiter,
sparte: SparteE,
matrix: List<LicenseMatrixEntry>
): String? {
// 1) Direkter Treffer in Matrix für (Sparte, Lizenzklasse)
val direct = matrix.find { it.sparte == sparte && it.lizenzKlasse == reiter.lizenzKlasse }
if (direct != null) return direct.maxTurnierklasseCode
// 2) Cross-Discipline Mapping (R<->RD) gemäß ÖTO-Äquivalenzen
// Hinweis: RD4 ist im aktuellen Enum nicht modelliert. R4 wird für Zwecke der
// Dressur-Kappung wie RD3 behandelt (max. S), bis RD4 eingeführt wird.
val mappedLizenz = when (sparte) {
SparteE.DRESSUR -> when (reiter.lizenzKlasse) {
ReiterLizenzKlasseE.R1 -> ReiterLizenzKlasseE.RD1
ReiterLizenzKlasseE.R2 -> ReiterLizenzKlasseE.RD2
ReiterLizenzKlasseE.R3, ReiterLizenzKlasseE.R4 -> ReiterLizenzKlasseE.RD3 // RD4 derzeit nicht modelliert
else -> reiter.lizenzKlasse
}
SparteE.SPRINGEN -> when (reiter.lizenzKlasse) {
ReiterLizenzKlasseE.RD1 -> ReiterLizenzKlasseE.R1
ReiterLizenzKlasseE.RD2 -> ReiterLizenzKlasseE.R2
ReiterLizenzKlasseE.RD3 -> ReiterLizenzKlasseE.R3
else -> reiter.lizenzKlasse
}
else -> reiter.lizenzKlasse
}
val cross = matrix.find { it.sparte == sparte && it.lizenzKlasse == mappedLizenz }
if (cross != null) return cross.maxTurnierklasseCode
// 3) Letzter Fallback: LIZENZFREI in gewünschter Sparte
return matrix.find { it.sparte == sparte && it.lizenzKlasse == ReiterLizenzKlasseE.LIZENZFREI }
?.maxTurnierklasseCode
}
}
@@ -13,9 +13,9 @@ import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.uuid.Uuid
class LicenseMatrixServiceTest {
class LiznezMatrixServiceTest {
private val service = LicenseMatrixServiceImpl()
private val service = LizenzMatrixServiceImpl()
private val nun = Clock.System.now()
private val matrix = listOf(
@@ -29,7 +29,7 @@ class ExposedRegulationRepository : RegulationRepository {
}
override suspend fun findAllLicenseMatrixEntries(): List<LicenseMatrixEntry> = DatabaseFactory.dbQuery {
LicenseTable.selectAll()
LizenzTable.selectAll()
.map { it.toLicenseMatrixEntry() }
}
@@ -89,15 +89,15 @@ class ExposedRegulationRepository : RegulationRepository {
)
private fun ResultRow.toLicenseMatrixEntry() = LicenseMatrixEntry(
licenseId = this[LicenseTable.id],
sparte = SparteE.valueOf(this[LicenseTable.sparte]),
lizenzKlasse = ReiterLizenzKlasseE.valueOf(this[LicenseTable.lizenzKlasse]),
maxTurnierklasseCode = this[LicenseTable.maxTurnierklasseCode],
validFrom = this[LicenseTable.validFrom].toKtInstant(),
validTo = this[LicenseTable.validTo]?.toOptionalKtInstant(),
istAktiv = this[LicenseTable.istAktiv],
createdAt = this[LicenseTable.createdAt].toKtInstant(),
updatedAt = this[LicenseTable.updatedAt].toKtInstant()
licenseId = this[LizenzTable.id],
sparte = SparteE.valueOf(this[LizenzTable.sparte]),
lizenzKlasse = ReiterLizenzKlasseE.valueOf(this[LizenzTable.lizenzKlasse]),
maxTurnierklasseCode = this[LizenzTable.maxTurnierklasseCode],
validFrom = this[LizenzTable.validFrom].toKtInstant(),
validTo = this[LizenzTable.validTo]?.toOptionalKtInstant(),
istAktiv = this[LizenzTable.istAktiv],
createdAt = this[LizenzTable.createdAt].toKtInstant(),
updatedAt = this[LizenzTable.updatedAt].toKtInstant()
)
private fun ResultRow.toRichtverfahrenDefinition() = RichtverfahrenDefinition(
@@ -10,7 +10,7 @@ import org.jetbrains.exposed.v1.datetime.timestamp
* Exposed-Tabellendefinition für die Lizenz-Matrix (Reiter-Lizenz vs. Turnierklasse).
* Basierend auf ÖTO 2026.
*/
object LicenseTable : Table("license_matrix") {
object LizenzTable : Table("license_matrix") {
val id = uuid("license_id")
val sparte = varchar("sparte", 20) // DRESSUR, SPRINGEN, ALLGEMEIN
val lizenzKlasse = varchar("lizenz_klasse", 20) // R1, R2, R3, RD1, RD2, RD3, LF
@@ -4,21 +4,21 @@ package at.mocode.masterdata.infrastructure.persistence.reiter
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.domain.model.FahrLizenz
import at.mocode.masterdata.domain.model.ReitLizenz
import at.mocode.masterdata.domain.model.Reiterlizenz
import at.mocode.masterdata.domain.model.Startkarte
import at.mocode.masterdata.domain.repository.MasterdataLicenseRepository
import at.mocode.masterdata.domain.repository.LizenzRepository
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
/**
* Exposed-basierte Implementierung des MasterdataLicenseRepository.
* Exposed-basierte Implementierung des LizenzRepository.
*/
class MasterdataLicenseExposedRepository : MasterdataLicenseRepository {
class MasterdataLicenseExposedRepository : LizenzRepository {
override suspend fun findReitLizenzByCode(code: String): ReitLizenz? = DatabaseFactory.dbQuery {
override suspend fun findReitLizenzByCode(code: String): Reiterlizenz? = DatabaseFactory.dbQuery {
ReitLizenzenTable.selectAll().where { ReitLizenzenTable.code eq code }
.map {
ReitLizenz(
Reiterlizenz(
lizenzId = it[ReitLizenzenTable.id],
code = it[ReitLizenzenTable.code],
bezeichnung = it[ReitLizenzenTable.bezeichnung],
@@ -29,7 +29,7 @@ class RegulationSeedVerificationTest {
transaction {
SchemaUtils.create(
TurnierKlassenTable,
LicenseTable,
LizenzTable,
RichtverfahrenTable,
GebuehrTable,
RegulationConfigTable,
@@ -71,7 +71,7 @@ class RegulationSeedVerificationTest {
@Test
fun `verify domain logic with simulated oeto data`() {
val service = at.mocode.masterdata.domain.service.LicenseMatrixServiceImpl()
val service = at.mocode.masterdata.domain.service.LizenzMatrixServiceImpl()
val now = Clock.System.now()
val oetoMatrix = listOf(
@@ -59,7 +59,7 @@ class MasterdataDatabaseConfiguration(
TurnierKlassenTable,
TurnierSpartenTable,
TurnierKategorienTable,
LicenseTable,
LizenzTable,
RichtverfahrenTable,
GebuehrTable,
RegulationConfigTable
@@ -112,7 +112,7 @@ class MasterdataTestDatabaseConfiguration {
TurnierKlassenTable,
TurnierSpartenTable,
TurnierKategorienTable,
LicenseTable,
LizenzTable,
RichtverfahrenTable,
GebuehrTable,
RegulationConfigTable
@@ -0,0 +1,79 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.service.config
import at.mocode.masterdata.infrastructure.persistence.LizenzTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.core.*
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 org.slf4j.LoggerFactory
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.DependsOn
import org.springframework.context.annotation.Profile
import kotlin.uuid.Uuid
/**
* Seeder für Reiterlizenzen (license_matrix) gemäß ÖTO 2026.
*
* Ziel: Sicherstellen, dass bei Service-Start alle benötigten Lizenzeinträge
* für Dressur und Springen vorhanden sind auch wenn die DB leer ist oder
* frühere Migrationen/Seeds nicht gelaufen sind.
*
* Idempotent: pro (sparte, lizenz_klasse) exakt ein Datensatz; bei Änderungen
* der max_turnierklasse_code wird dieser upgedatet.
*/
@Configuration
@Profile("!test")
@DependsOn("masterdataDatabaseConfiguration")
class ReiterlizenzenSeeder {
private val log = LoggerFactory.getLogger(ReiterlizenzenSeeder::class.java)
@PostConstruct
fun seed() {
log.info("Starte Seeding der Reiterlizenzen (license_matrix)...")
transaction {
// Springen: LIZENZFREI→E, R1→L, R2→M, R3→S, R4→S
upsert("SPRINGEN", "LIZENZFREI", "E")
upsert("SPRINGEN", "R1", "L")
upsert("SPRINGEN", "R2", "M")
upsert("SPRINGEN", "R3", "S")
upsert("SPRINGEN", "R4", "S")
// Dressur: LIZENZFREI→E, RD1→L, RD2→M, RD3→S
upsert("DRESSUR", "LIZENZFREI", "E")
upsert("DRESSUR", "RD1", "L")
upsert("DRESSUR", "RD2", "M")
upsert("DRESSUR", "RD3", "S")
}
log.info("Seeding der Reiterlizenzen abgeschlossen.")
}
private fun upsert(sparte: String, lizenzKlasse: String, maxTurnierklasseCode: String) {
val existing = LizenzTable.selectAll()
.where { (LizenzTable.sparte eq sparte) and (LizenzTable.lizenzKlasse eq lizenzKlasse) }
.singleOrNull()
if (existing == null) {
LizenzTable.insert {
it[id] = Uuid.random()
it[LizenzTable.sparte] = sparte
it[LizenzTable.lizenzKlasse] = lizenzKlasse
it[LizenzTable.maxTurnierklasseCode] = maxTurnierklasseCode
it[istAktiv] = true
}
log.debug("Lizenz-Matrix angelegt: {} / {} -> {}", sparte, lizenzKlasse, maxTurnierklasseCode)
} else {
val currentMax = existing[LizenzTable.maxTurnierklasseCode]
val currentActive = existing[LizenzTable.istAktiv]
if (currentMax != maxTurnierklasseCode || !currentActive) {
LizenzTable.update({ (LizenzTable.sparte eq sparte) and (LizenzTable.lizenzKlasse eq lizenzKlasse) }) {
it[LizenzTable.maxTurnierklasseCode] = maxTurnierklasseCode
it[LizenzTable.istAktiv] = true
}
log.debug("Lizenz-Matrix aktualisiert: {} / {} -> {}", sparte, lizenzKlasse, maxTurnierklasseCode)
}
}
}
}
@@ -1,36 +1,79 @@
-- V013__Cleanup_and_Standardize_Masterdata.sql
-- Datum: 6. April 2026
-- 1. Bundesland -> bundeslaender
ALTER TABLE bundesland RENAME TO bundeslaender;
ALTER TABLE bundeslaender RENAME COLUMN id TO bundesland_id;
ALTER INDEX IF EXISTS pk_bundesland RENAME TO pk_bundeslaender;
ALTER INDEX IF EXISTS idx_bundesland_oeps RENAME TO idx_bundeslaender_oeps;
ALTER INDEX IF EXISTS idx_bundesland_iso RENAME TO idx_bundeslaender_iso;
ALTER INDEX IF EXISTS ux_bundesland_land_kuerzel RENAME TO ux_bundeslaender_land_kuerzel;
ALTER INDEX IF EXISTS bundesland_bundesland_nr_unique RENAME TO bundeslaender_bundesland_nr_unique;
-- 1. Bundesland -> bundeslaender (idempotent)
DO $$
BEGIN
IF to_regclass('public.bundesland') IS NOT NULL AND to_regclass('public.bundeslaender') IS NULL THEN
ALTER TABLE bundesland RENAME TO bundeslaender;
END IF;
-- 2. qualifikation_master -> funktionaers_qualifikationen
ALTER TABLE qualifikation_master RENAME TO funktionaers_qualifikationen;
-- Spaltenumbenennung nur, wenn noch "id" existiert
IF to_regclass('public.bundeslaender') IS NOT NULL THEN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'bundeslaender' AND column_name = 'id'
) THEN
ALTER TABLE bundeslaender RENAME COLUMN id TO bundesland_id;
END IF;
END IF;
-- Optionale historische Index-Umbenennungen (nur wenn vorhanden)
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'pk_bundesland') THEN
ALTER INDEX pk_bundesland RENAME TO pk_bundeslaender;
END IF;
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_bundesland_oeps') THEN
ALTER INDEX idx_bundesland_oeps RENAME TO idx_bundeslaender_oeps;
END IF;
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'idx_bundesland_iso') THEN
ALTER INDEX idx_bundesland_iso RENAME TO idx_bundeslaender_iso;
END IF;
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'ux_bundesland_land_kuerzel') THEN
ALTER INDEX ux_bundesland_land_kuerzel RENAME TO ux_bundeslaender_land_kuerzel;
END IF;
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = 'bundesland_bundesland_nr_unique') THEN
ALTER INDEX bundesland_bundesland_nr_unique RENAME TO bundeslaender_bundesland_nr_unique;
END IF;
END $$;
-- 2. qualifikation_master -> funktionaers_qualifikationen (idempotent)
DO $$
BEGIN
IF to_regclass('public.qualifikation_master') IS NOT NULL AND to_regclass('public.funktionaers_qualifikationen') IS NULL THEN
ALTER TABLE qualifikation_master RENAME TO funktionaers_qualifikationen;
END IF;
END $$;
-- Die Join-Tabelle funktionaer_qualifikation bleibt als solche bestehen,
-- referenziert aber nun funktionaers_qualifikationen.
-- (Der Name der Join-Tabelle ist bereits fast korrekt, wir lassen sie vorerst so,
-- da sie die Verknüpfung zw. Funktionär und Qualifikation darstellt.)
-- Update: Der User möchte "funktionaers_qualifikationen" als Name für die Qualifikationen.
-- 3. reiter_lizenz -> reit_lizenzen
ALTER TABLE reiter_lizenz RENAME TO reit_lizenzen;
-- 3. reiter_lizenz -> reit_lizenzen (idempotent)
DO $$
BEGIN
IF to_regclass('public.reiter_lizenz') IS NOT NULL AND to_regclass('public.reit_lizenzen') IS NULL THEN
ALTER TABLE reiter_lizenz RENAME TO reit_lizenzen;
END IF;
END $$;
ALTER INDEX IF EXISTS pk_reiter_lizenz RENAME TO pk_reit_lizenzen;
-- 4. reiter_sparte entfernen
DROP TABLE IF EXISTS reiter_sparte;
-- 5. turnierklasse -> turnier_klassen
ALTER TABLE turnierklasse RENAME TO turnier_klassen;
-- 5. turnierklasse -> turnier_klassen (idempotent)
-- Hinweis: In der aktuellen Codebasis verwendet Exposed `bewerbs_klassen` als technische Tabelle
-- für Turnier-/Bewerbsklassen. Daher nur umbenennen, wenn die Alt-Tabelle tatsächlich existiert.
DO $$
BEGIN
IF to_regclass('public.turnierklasse') IS NOT NULL AND to_regclass('public.turnier_klassen') IS NULL THEN
ALTER TABLE turnierklasse RENAME TO turnier_klassen;
END IF;
END $$;
ALTER INDEX IF EXISTS pk_turnierklasse RENAME TO pk_turnier_klassen;
ALTER INDEX IF EXISTS idx_turnierklasse_sparte_code RENAME TO idx_turnier_klassen_sparte_code;
-- 6. turnier_sparten erstellen
CREATE TABLE IF NOT EXISTS turnier_sparten (
sparte_id UUID PRIMARY KEY,
code VARCHAR(10) UNIQUE NOT NULL, -- z.B. D, S, V, F, R, C
@@ -40,6 +83,5 @@ CREATE TABLE IF NOT EXISTS turnier_sparten (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 7. Constraints aktualisieren (falls nötig)
-- Da wir nur Tabellen umbenannt haben, bleiben die Foreign Keys in PostgreSQL erhalten
-- und zeigen automatisch auf die neuen Tabellennamen.
@@ -17,7 +17,7 @@ class ZnsImportServiceApplication {
funktionaerRepository: FunktionaerRepository,
landRepository: LandRepository,
bundeslandRepository: BundeslandRepository,
licenseRepository: MasterdataLicenseRepository,
licenseRepository: LizenzRepository,
altersklassenRepository: AltersklassenRepository
): ZnsImportService {
return ZnsImportService(
@@ -33,7 +33,7 @@ class RepositoryConfiguration {
fun funktionaerRepository(): FunktionaerRepository = FunktionaerExposedRepository()
@Bean
fun licenseRepository(): MasterdataLicenseRepository = MasterdataLicenseExposedRepository()
fun licenseRepository(): LizenzRepository = MasterdataLicenseExposedRepository()
@Bean
fun altersklassenRepository(): AltersklassenRepository = AltersklassenExposedRepository()