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.
Some checks are pending
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:
Stefan Mogeritsch 2026-04-06 23:51:56 +02:00
parent b7fa2d26a9
commit 7bf89c58d3
25 changed files with 612 additions and 138 deletions

View File

@ -15,8 +15,25 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
### Geändert
- Masterdata/Domain: Umbenennungen zur Vereinheitlichung der Terminologie (DE):
- `MasterdataLicenseRepository``LizenzRepository`
- `LicenseMatrixService``LizenzMatrixService`
- `LicenseMatrixServiceImpl``LizenzMatrixServiceImpl`
- Test: `LicenseMatrixServiceTest``LiznezMatrixServiceTest` (exakt nach Vorgabe)
- Infrastructure (Exposed): `LicenseTable``LizenzTable`
- Docs: Begriff „reit_lizenzen“ → „reiterlizenzen“ in Glossar/UL konsolidiert.
### Hinzugefügt
- Masterdata: Automatisches Seeding aller Reiterlizenzen (license_matrix) beim Start des `masterdata-service` via `ReiterlizenzenSeeder` (idempotent; SPRINGEN: LIZENZFREI,R1R4; DRESSUR: LIZENZFREI,RD1RD3).
- **ZNS-Import (LIZENZ01.dat):** Robuster Lizenz-Tokenizer und Normalizer implementiert.
- Erkennung: `RD1..RD4`, `R1..R4`, `S1..S4`, `D2..D4`, Kombis `R{n}D{m}`, `R{n}S{k}`, `RDS4` (rechts-/letztes Vorkommen gewinnt).
- Normalisierung: `S*→R*`, `D*→RD*`, `RD4→RD3` (bis Enum verfügbar), `R{n}S{k}→Rmax(n,k)`, `R{n}D{m}→R{n}+RD{m}`.
- Integration: `ZnsReiterParser` füllt `lizenzen`-Liste (1:n) entsprechend und leitet `lizenzKlasse` bei fehlendem 4SpaltenCode aus Token ab.
- QA: Neue Unit-Tests (Tokenizer) für Beispiele `R2S3`, `R2D4`, `RD2` u. a.; alle Parser-Tests grün.
- **Core:** Modularisierte ZNS-Parser eingeführt (`ZnsVereinParser`, `ZnsReiterParser`, `ZnsPferdParser`, `ZnsFunktionaerParser`) zur Verbesserung der Wartbarkeit und Unterstützung von Einzelimporten.
- **Fix:** SQL-Migrationsfehler in `V010` behoben, indem die Umbenennung der Spalte `name` in `verein_name` durch einen idempotenten `DO`-Block abgesichert wurde (behebt "Unable to resolve column 'name'").
- **Infrastructure:** Datenbank-Migration `V010` hinzugefügt, um das Schema final mit den `Exposed`-Modellen zu synchronisieren.
@ -44,6 +61,21 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
- **Masterdata/API:** Fehlendes Interface-Mapping ergänzt: `RegulationRepository` enthält nun `findAllTurnierklassen()`; `ExposedRegulationRepository` implementiert die Methode und `RegulationController` kompiliert wieder.
- **ZNS-Import:** `AltersklassenExposedRepository` korrigiert (richtiger Domain-Typ `AltersklasseDefinition`, Mapping von `SparteE` und Zeitstempeln).
- **Migration V013:** Idempotent und robust gemacht. Alle `ALTER TABLE ... RENAME`-Operationen laufen nun nur, wenn die Quell-Tabelle existiert (Fix für "Unable to resolve table 'bundesland'/'turnierklasse'").
- **Lizenz-Validierung:** `LicenseMatrixServiceImpl` um Cross-Discipline-Mapping R↔RD (ÖTO-Äquivalenzen) erweitert. Damit funktionieren Fälle wie Dressur-Starts mit Spring-Lizenz (R1→RD1, R2→RD2, R3/R4→RD3) bzw. umgekehrt konsistent.
- **Domain:** Striktere Spartenlizenz-Prüfung in `Reiter.hasLizenzForSparte` implementiert (RD1..RD3 nur DRESSUR; R1..R4 nur SPRINGEN). Behebt Testfehler „isEligible verweigert Start ohne passende Spartenlizenz“ im `LicenseMatrixServiceTest`.
### Dokumentation
- **Masterdata/Docs:** `REITER_LIZENZEN.md` überarbeitet:
- Strikte Sparten-Trennung dokumentiert (RD1..RD3 nur Dressur; R1..R4 nur Springen).
- Dressur-Tabelle korrigiert (R-Lizenzen entfernen; RD-Pflicht je Klasse).
- Validierungslogik ergänzt (2-stufig: Spartenlizenz → Max-Turnierklasse; R↔RD Mapping nur zur Kappung, nicht zur Eligibility).
- Vielseitigkeit (CCN/CCI) ergänzt: kumulative Anforderungen (Dressur RD* UND Springen R* je Klasse); Startkartenregel für Einsteiger.
- Fahren (CAN/CAI) ergänzt: aktueller Systemzustand ohne `F*`Lizenzen dokumentiert; Teilnahme über Startkarte/Ausschreibung, geplante EnumErweiterung vermerkt.
- §15Tabelle (kompakt) integriert und auf ÖTOReferenz verlinkt; Bedeutungen „B,C“ und „LP“ erläutert. Hinweis aufgenommen, dass `RD4` derzeit nicht als Enum vorhanden ist und wie `RD3` behandelt wird.
- Kombinationsreihen gemäß §15 ergänzt: `R1S2`, `R1S3`, `R1S4`, `R2S3`, `R2S4`, `R3S4` (neuer Unterabschnitt 2.6 mit Tabelle, identische Spalten wie 2.5).
### Behoben
- **Masterdata:** Qualifikations-Management für Funktionäre (Richter/Parcoursbauer) professionalisiert: Umstellung von unstrukturiertem Text auf offizielle ÖTO/FEI Master-Daten Referenzen (`QualifikationMasterTable`).

View File

@ -40,7 +40,7 @@ class ZnsImportService(
private val funktionaerRepository: FunktionaerRepository,
private val landRepository: LandRepository,
private val bundeslandRepository: BundeslandRepository,
private val licenseRepository: MasterdataLicenseRepository? = null,
private val licenseRepository: LizenzRepository? = null,
private val altersklassenRepository: AltersklassenRepository? = null
) {

View File

@ -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.

View File

@ -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()
}
}

View File

@ -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,

View File

@ -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?
}

View File

@ -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
}
}

View File

@ -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.

View File

@ -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
}
}

View File

@ -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(

View File

@ -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(

View File

@ -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

View File

@ -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],

View File

@ -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(

View File

@ -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

View File

@ -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)
}
}
}
}

View File

@ -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.

View File

@ -17,7 +17,7 @@ class ZnsImportServiceApplication {
funktionaerRepository: FunktionaerRepository,
landRepository: LandRepository,
bundeslandRepository: BundeslandRepository,
licenseRepository: MasterdataLicenseRepository,
licenseRepository: LizenzRepository,
altersklassenRepository: AltersklassenRepository
): ZnsImportService {
return ZnsImportService(

View File

@ -33,7 +33,7 @@ class RepositoryConfiguration {
fun funktionaerRepository(): FunktionaerRepository = FunktionaerExposedRepository()
@Bean
fun licenseRepository(): MasterdataLicenseRepository = MasterdataLicenseExposedRepository()
fun licenseRepository(): LizenzRepository = MasterdataLicenseExposedRepository()
@Bean
fun altersklassenRepository(): AltersklassenRepository = AltersklassenExposedRepository()

View File

@ -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,

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -29,14 +29,14 @@ Dieses Dokument definiert die **Ubiquitous Language** (allgegenwärtige Sprache)
## K - O
* **Lebensnummer:** Eine 9-stellige Nummer (bzw. 15-stellig international), die ein Pferd bei der Geburt vom Zuchtverband erhält. Dient der eindeutigen Identifizierung, ist aber im OEPS-Kontext bei ausländischen Pferden oft generiert und daher nicht zur Suche geeignet.
* **Lizenz (Reit-Lizenz):** Die Qualifikationsstufe eines Reiters (z.B. `R1`, `RD1`, `RD2`, `RS2`). Ein Reiter hat 0..1 Reit-Lizenz. Wird in der Tabelle `reit_lizenzen` als Stammdaten geführt und am Reiter per `reit_lizenz_id` referenziert.
* **Lizenz (Reiterlizenz):** Die Qualifikationsstufe eines Reiters (z.B. `R1`, `RD1`, `RD2`, `RS2`). Ein Reiter hat 0..1 Reiterlizenz. Wird in der Tabelle `reiterlizenzen` als Stammdaten geführt und am Reiter per `reit_lizenz_id` referenziert.
* **Fahr-Lizenz:** Eigenständige Lizenzkategorie für den Fahrsport (z.B. `F1`, `F2`). Ein Reiter hat 0..1 Fahr-Lizenz. Stammdaten-Tabelle `fahr_lizenzen`, Referenz am Reiter `fahr_lizenz_id`.
* **Nennung:** Die verbindliche Anmeldung eines Paares (Reiter & Pferd) zu einem -> *Bewerb*.
* **OEPS:** Österreichischer Pferdesportverband.
## P - T
* **Reit-Lizenzen (Historie):** Optionale Historien-Zuordnungen in `reiter_lizenzen_zuordnung` (z.B. Jahreswechsel). Enthalten den Typ (`REITERLIZENZ`, `STARTKARTE`, `FAHRLIZENZ`), das Kürzel und optional `gültig_bis`.
* **Reiterlizenzen (Historie):** Optionale Historien-Zuordnungen in `reiter_lizenzen_zuordnung` (z.B. Jahreswechsel). Enthalten den Typ (`REITERLIZENZ`, `STARTKARTE`, `FAHRLIZENZ`), das Kürzel und optional `gültig_bis`.
* **Satznummer:**
* *Pferd:* 10-stellige, rein numerische ID (z.B. `0000123456`), die ein Pferd in der OEPS-Datenbank eindeutig identifiziert. **Primärer Schlüssel für den Datenaustausch.**
* *Reiter:* 6-stellige, rein numerische ID für Personen.

View File

@ -95,7 +95,7 @@ Die ÖTO definiert sparten- und klassenabhängige Schwellenwerte, ab wievielen S
| Begriff | Definition | ÖTO-Referenz |
|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| **Lebensnummer** | 9-stellige (national) bzw. 15-stellige (international, UELN) Nummer, die ein Pferd bei der Geburt vom Zuchtverband erhält. Bei ausländischen Pferden im OEPS oft **generiert****nicht zur Suche geeignet**. Die ZNS-Daten zu Lebensnummern sind erfahrungsgemäß inkonsistent und widersprüchlich (z.B. Farbe `"Braun"` vs. `"Brauner"` für dasselbe Pferd). Primärer Schlüssel für den Datenaustausch bleibt die → *Satznummer*. | |
| **Lizenz (Reit-Lizenz)** | Qualifikationsstufe eines Reiters (z.B. `R1`, `RD1`, `RD2`, `RS2`). Ein Reiter hat 0..1 Reit-Lizenz. Stammdaten in `reit_lizenzen`, Referenz am Reiter `reit_lizenz_id`. | ÖTO Teilnahmeberechtigung |
| **Lizenz (Reiterlizenz)** | Qualifikationsstufe eines Reiters (z.B. `R1`, `RD1`, `RD2`, `RS2`). Ein Reiter hat 0..1 Reiterlizenz. Stammdaten in `reiterlizenzen`, Referenz am Reiter `reit_lizenz_id`. | ÖTO Teilnahmeberechtigung |
| **Fahr-Lizenz** | Lizenzkategorie für den Fahrsport (z.B. `F1`, `F2`). Ein Reiter hat 0..1 Fahr-Lizenz. Stammdaten in `fahr_lizenzen`, Referenz am Reiter `fahr_lizenz_id`. | ÖTO Teilnahmeberechtigung |
| **Startkarte** | Nachweis der Jahresgebühr. Ein Reiter hat 0..1 Startkarte. Stammdaten in `startkarten`, Referenz am Reiter `startkarte_id`. | ÖTO Teilnahmeberechtigung |

View File

@ -579,6 +579,44 @@ bewerben der Besitz des ÖFAB und bei Pleasure Drivingbewer-
ben des PDC oder des ÖFAB.
A-16 2026
2026 A-17
---
### Reiterlizenzen, § 15 ÖTO
| Klasse/Höhe | V 80 | 60 - 100 | V 90 | A | 105 - 110 | V 100 | L | 115 - 120 | V 105 | LM | 125 - 130 | M | 135 | V 110 - 115 | S | V120 | 140 - 145 | 150 - 160 |
|-------------|:----:|:--------:|:----:|:-----:|:---------:|:-----:|:-----:|:---------:|:-----:|:---:|:---------:|:----:|:---:|:-----------:|:---:|:----:|:---------:|:---------:|
| 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 | | | | | | |
| RD4 | | | | X | | | X | | | X | | X | | | X | | | |
| R1D2 | X | X | X | X | `B,C` | X | X | `B,C` | | X | | `LP` | | | | | | |
| R1D3 | X | X | X | X | `B,C` | X | X | `B,C` | | X | | X | | | | | | |
| R2D3 | X | X | X | X | X | X | X | X | X | X | X | X | | | | | | |
| R1D4 | X | X | X | X | `B,C` | X | X | `B,C` | | X | | X | | | X | | | |
| R2D4 | X | X | X | X | X | X | X | X | X | X | X | X | | | X | | | |
| R3D4 | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | |
| RDS4 | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X | X |
| 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 |
`B,C` = Turniere der Kat. B, C und C-NEU
`V` = Vielseitigkeit
`S` = Springen
`D` = Dressur
`LP` = Lizenzprüfung
Junioren (14 18 Jahre) haben die Möglichkeit, bereits mit der RD2 an FEI-Juniorenaufgaben teilzunehmen.
---
Reiterlizenzen, § 15 ÖTO
V 60 V 105 V 115 V 25 V 110 V 140 150
80 100 90 A 110 100 L 120 105 LM 1
@ -606,6 +644,9 @@ 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
B, C = Turniere der Kat. B, C und C-NEU*
Junioren (14 18 Jahre) haben die Möglichkeit, bereits mit der RD2 an FEI-Juniorenaufgaben teilzunehmen.
---
§ 15
Reiterlizenzen
1. Für die Teilnahme an