(vision) SCS/DDD
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package at.mocode.di
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Service Locator interface for dependency injection.
|
||||
* Provides a centralized way to register and resolve dependencies across the application.
|
||||
*/
|
||||
interface ServiceLocator {
|
||||
|
||||
/**
|
||||
* Register a service instance with the locator
|
||||
*/
|
||||
fun <T : Any> register(serviceClass: KClass<T>, instance: T)
|
||||
|
||||
/**
|
||||
* Register a service factory with the locator
|
||||
*/
|
||||
fun <T : Any> register(serviceClass: KClass<T>, factory: () -> T)
|
||||
|
||||
/**
|
||||
* Resolve a service instance from the locator
|
||||
*/
|
||||
fun <T : Any> resolve(serviceClass: KClass<T>): T
|
||||
|
||||
/**
|
||||
* Check if a service is registered
|
||||
*/
|
||||
fun <T : Any> isRegistered(serviceClass: KClass<T>): Boolean
|
||||
|
||||
/**
|
||||
* Clear all registered services
|
||||
*/
|
||||
fun clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of ServiceLocator
|
||||
*/
|
||||
class DefaultServiceLocator : ServiceLocator {
|
||||
|
||||
private val instances = mutableMapOf<KClass<*>, Any>()
|
||||
private val factories = mutableMapOf<KClass<*>, () -> Any>()
|
||||
|
||||
override fun <T : Any> register(serviceClass: KClass<T>, instance: T) {
|
||||
instances[serviceClass] = instance
|
||||
factories.remove(serviceClass) // Remove factory if exists
|
||||
}
|
||||
|
||||
override fun <T : Any> register(serviceClass: KClass<T>, factory: () -> T) {
|
||||
factories[serviceClass] = factory
|
||||
instances.remove(serviceClass) // Remove instance if exists
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> resolve(serviceClass: KClass<T>): T {
|
||||
// First check if we have a cached instance
|
||||
instances[serviceClass]?.let { return it as T }
|
||||
|
||||
// Then check if we have a factory
|
||||
factories[serviceClass]?.let { factory ->
|
||||
val instance = factory() as T
|
||||
instances[serviceClass] = instance // Cache the instance
|
||||
return instance
|
||||
}
|
||||
|
||||
throw IllegalArgumentException("Service ${serviceClass.simpleName} is not registered")
|
||||
}
|
||||
|
||||
override fun <T : Any> isRegistered(serviceClass: KClass<T>): Boolean {
|
||||
return instances.containsKey(serviceClass) || factories.containsKey(serviceClass)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
instances.clear()
|
||||
factories.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global service locator instance
|
||||
*/
|
||||
object ServiceRegistry {
|
||||
private var _serviceLocator: ServiceLocator = DefaultServiceLocator()
|
||||
|
||||
val serviceLocator: ServiceLocator
|
||||
get() = _serviceLocator
|
||||
|
||||
/**
|
||||
* Set a custom service locator implementation
|
||||
*/
|
||||
fun setServiceLocator(locator: ServiceLocator) {
|
||||
_serviceLocator = locator
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default service locator
|
||||
*/
|
||||
fun reset() {
|
||||
_serviceLocator = DefaultServiceLocator()
|
||||
}
|
||||
}
|
||||
|
||||
// Kotlin extension functions for easier usage
|
||||
inline fun <reified T : Any> ServiceLocator.register(instance: T) {
|
||||
register(T::class, instance)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> ServiceLocator.register(noinline factory: () -> T) {
|
||||
register(T::class, factory)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> ServiceLocator.resolve(): T {
|
||||
return resolve(T::class)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> ServiceLocator.isRegistered(): Boolean {
|
||||
return isRegistered(T::class)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package at.mocode.dto
|
||||
|
||||
import at.mocode.enums.FunktionaerRolle
|
||||
import at.mocode.enums.FunktionaerRolleE
|
||||
import at.mocode.enums.GeschlechtE
|
||||
import at.mocode.stammdaten.LizenzInfo
|
||||
import at.mocode.serializers.KotlinInstantSerializer
|
||||
@@ -36,7 +36,7 @@ data class PersonDto(
|
||||
val feiId: String?,
|
||||
val istGesperrt: Boolean,
|
||||
val sperrGrund: String?,
|
||||
val rollen: Set<FunktionaerRolle>,
|
||||
val rollen: Set<FunktionaerRolleE>,
|
||||
val lizenzen: List<LizenzInfo>,
|
||||
val qualifikationenRichter: List<String>,
|
||||
val qualifikationenParcoursbauer: List<String>,
|
||||
@@ -69,7 +69,7 @@ data class CreatePersonDto(
|
||||
val feiId: String? = null,
|
||||
val istGesperrt: Boolean = false,
|
||||
val sperrGrund: String? = null,
|
||||
val rollen: Set<FunktionaerRolle> = emptySet(),
|
||||
val rollen: Set<FunktionaerRolleE> = emptySet(),
|
||||
val lizenzen: List<LizenzInfo> = emptyList(),
|
||||
val qualifikationenRichter: List<String> = emptyList(),
|
||||
val qualifikationenParcoursbauer: List<String> = emptyList(),
|
||||
@@ -98,7 +98,7 @@ data class UpdatePersonDto(
|
||||
val feiId: String? = null,
|
||||
val istGesperrt: Boolean = false,
|
||||
val sperrGrund: String? = null,
|
||||
val rollen: Set<FunktionaerRolle> = emptySet(),
|
||||
val rollen: Set<FunktionaerRolleE> = emptySet(),
|
||||
val lizenzen: List<LizenzInfo> = emptyList(),
|
||||
val qualifikationenRichter: List<String> = emptyList(),
|
||||
val qualifikationenParcoursbauer: List<String> = emptyList(),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package at.mocode.dto.base
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Manages API and DTO versioning across the application.
|
||||
*/
|
||||
|
||||
@@ -29,7 +29,7 @@ class ArtikelDtoMigrator : VersionMigrator<ArtikelDto> {
|
||||
}
|
||||
}
|
||||
|
||||
// Example of future migration method
|
||||
// Example of a future migration method
|
||||
// private fun migrateFrom1_0To1_1(dto: ArtikelDto): ArtikelDto {
|
||||
// return dto.copy(
|
||||
// schemaVersion = "1.1",
|
||||
|
||||
@@ -55,15 +55,15 @@ enum class PlatzTypE { AUSTRAGUNG, VORBEREITUNG, LONGIEREN, SONSTIGES }
|
||||
enum class SparteE { DRESSUR, SPRINGEN, VIELSEITIGKEIT, FAHREN, VOLTIGIEREN, WESTERN, DISTANZ, ISLAND, PFERDESPORT_SPIEL, BASIS, KOMBINIERT, SONSTIGES }
|
||||
|
||||
@Serializable
|
||||
enum class BewerbStatus { GEPLANT, OFFEN_FUER_NENNUNG, GESCHLOSSEN_FUER_NENNUNG, LAEUFT, ABGESCHLOSSEN, ABGESAGT }
|
||||
enum class BewerbStatusE { GEPLANT, OFFEN_FUER_NENNUNG, GESCHLOSSEN_FUER_NENNUNG, LAEUFT, ABGESCHLOSSEN, ABGESAGT }
|
||||
@Serializable
|
||||
enum class Bedingungstyp { LIZENZ_REITER, LIZENZ_FAHRER, ALTER_PFERD, ALTER_REITER, RASSE_PFERD, GESCHLECHT_PFERD, GESCHLECHT_REITER, STARTKARTE, SONSTIGES }
|
||||
enum class BedingungstypE { LIZENZ_REITER, LIZENZ_FAHRER, ALTER_PFERD, ALTER_REITER, RASSE_PFERD, GESCHLECHT_PFERD, GESCHLECHT_REITER, STARTKARTE, SONSTIGES }
|
||||
@Serializable
|
||||
enum class BeginnzeitTypE { FIX_UM, NACH_BEWERB, CA_UM, ANSCHLIESSEND }
|
||||
@Serializable
|
||||
enum class Operator { GLEICH, UNGLEICH, MINDESTENS, MAXIMAL, ZWISCHEN, IN_LISTE, NICHT_IN_LISTE }
|
||||
enum class OperatorE { GLEICH, UNGLEICH, MINDESTENS, MAXIMAL, ZWISCHEN, IN_LISTE, NICHT_IN_LISTE }
|
||||
@Serializable
|
||||
enum class FunktionaerRolle { RICHTER, PARCOURSBAUER, PARCOURSBAU_ASSISTENT, TECHN_DELEGIERTER, TURNIERBEAUFTRAGTER, STEWARD, ZEITNEHMER, SCHREIBER, VERANSTALTER_KONTAKT, TURNIERLEITER, HELFER, SONSTIGE }
|
||||
enum class FunktionaerRolleE { RICHTER, PARCOURSBAUER, PARCOURSBAU_ASSISTENT, TECHN_DELEGIERTER, TURNIERBEAUFTRAGTER, STEWARD, ZEITNEHMER, SCHREIBER, VERANSTALTER_KONTAKT, TURNIERLEITER, HELFER, SONSTIGE }
|
||||
|
||||
@Serializable
|
||||
enum class RichterPositionE { C, E, H, M, B, VORSITZ, SEITENRICHTER, SONSTIGE }
|
||||
@@ -82,7 +82,7 @@ enum class PruefungsaufgabeRichtverfahrenModusE { GM, GT, NICHT_SPEZIFIZIERT }
|
||||
@Serializable
|
||||
enum class PruefungsaufgabeViereckE { VIERECK_20X40, VIERECK_20X60, ANDERE, UNBEKANNT }
|
||||
|
||||
// Horse related enums
|
||||
// Horse-related enums
|
||||
@Serializable
|
||||
enum class PferdeFarbeE {
|
||||
BRAUN, FUCHS, RAPPE, SCHIMMEL, SCHECKE, FALBE, ISABELL, CREMELLO, PERLINO,
|
||||
|
||||
@@ -14,7 +14,6 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Abteilung(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
|
||||
@@ -12,7 +12,6 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
@Serializable
|
||||
data class Bewerb(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
@@ -20,12 +19,12 @@ data class Bewerb(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val turnierId: Uuid,
|
||||
|
||||
// Allgemeine Infos
|
||||
// Allgemeine Informationen
|
||||
var nummer: String, // Offizielle Nummer aus Ausschreibung, z.B. "12"
|
||||
var bezeichnungOffiziell: String, // z.B. "Dressurprüfung Kl. L", "Standardspringprüfung 115cm"
|
||||
var bezeichnungOffiziell: String, // z.B. "Dressurprüfung Kl. L", "Standardspringprüfung 115 cm"
|
||||
var internerName: String?, // Für Listen, falls abweichend/kürzer
|
||||
var sparteE: SparteE,
|
||||
var klasse: String?, // z.B. "L", "115cm", "Reiterpass"
|
||||
var klasse: String?, // z.B. "L", "115 cm", "Reiterpass"
|
||||
var kategorieOetoDesBewerbs: String?, // ÖTO Kategorie, z.B. "CDN-C Neu". Kann vom Turnier abweichen/spezifischer sein.
|
||||
// Wird für die Gültigkeit von Regeln/Lizenzen herangezogen.
|
||||
var teilnahmebedingungenText: String? = null, // Freitext für spezielle Teilnahmebedingungen
|
||||
|
||||
@@ -4,7 +4,6 @@ import at.mocode.serializers.BigDecimalSerializer
|
||||
import com.ionspin.kotlin.bignum.decimal.BigDecimal
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
||||
@Serializable
|
||||
data class DotierungsAbstufung(
|
||||
val platz: Int, // Für welchen Platz gilt dieser Geldpreis (z.B. 1, 2, 3)
|
||||
|
||||
@@ -10,6 +10,8 @@ import kotlinx.serialization.Serializable
|
||||
data class Platz(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val id: Uuid = uuid4(),
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
var turnierId: Uuid,
|
||||
var name: String,
|
||||
var dimension: String?,
|
||||
var boden: String?,
|
||||
|
||||
@@ -54,7 +54,7 @@ data class DomPerson(
|
||||
var oepsSatzNr: String?, // Wird aus Person_ZNS_Staging.oepsSatzNrPerson befüllt, UNIQUE
|
||||
var nachname: String, // Wird aus Person_ZNS_Staging.familiennameRoh befüllt
|
||||
var vorname: String, // Wird aus Person_ZNS_Staging.vornameRoh befüllt
|
||||
var titel: String? = null, // Manuelle Eingabe oder ggf. später aus ZNS falls vorhanden
|
||||
var titel: String? = null, // Manuelle Eingabe ggf. später ZNS, falls vorhanden
|
||||
|
||||
@Serializable(with = KotlinLocalDateSerializer::class)
|
||||
var geburtsdatum: LocalDate? = null, // Konvertiert aus Person_ZNS_Staging.geburtsdatumTextRoh
|
||||
|
||||
@@ -36,7 +36,7 @@ data class BundeslandDefinition(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
var landId: Uuid, // FK zu LandDefinition.landId
|
||||
|
||||
var oepsCode: String?, // z.B. "01", "02", ... für Österreich; Eindeutig pro landId = Österreich
|
||||
var oepsCode: String?, // z.B. "01", "02", ... für Österreich; eindeutig pro landId = Österreich
|
||||
var iso3166_2_Code: String?, // z.B. "AT-1", "DE-BY"; Eindeutig global oder pro Land?
|
||||
var name: String, // z.B. "Niederösterreich", "Bayern"
|
||||
var kuerzel: String? = null, // z.B. "NÖ", "BY"
|
||||
|
||||
@@ -33,7 +33,7 @@ data class LandDefinition(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val landId: Uuid = uuid4(),
|
||||
|
||||
var isoAlpha2Code: String, // z.B. "AT" -> Fachlicher PK oder Unique Constraint
|
||||
var isoAlpha2Code: String, // z.B. "AT" → Fachlicher PK oder Unique Constraint
|
||||
var isoAlpha3Code: String, // z.B. "AUT" -> Unique Constraint
|
||||
var isoNumerischerCode: String? = null, // z.B. "040"
|
||||
var nameDeutsch: String, // z.B. "Österreich"
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import kotlinx.serialization.Serializable
|
||||
* @property pruefungAbteilungDbId Fremdschlüssel zur `Pruefung_Abteilung`. Teil des zusammengesetzten Primärschlüssels.
|
||||
* @property faktorFuerWertung Ein optionaler Faktor, mit dem das Ergebnis dieser Wertungsprüfung
|
||||
* in die Gesamtwertung des Cups/der Meisterschaft einfließt (Default ist 1.0).
|
||||
* @property bemerkung Optionale Bemerkung zu dieser spezifischen Wertungsprüfung im Kontext des Cups
|
||||
* @property bemerkung Optionale Bemerkungen zu dieser spezifischen Wertungsprüfung im Kontext des Cups
|
||||
* (z.B. "1. Vorrunde", "Finale", "Qualifikation West").
|
||||
* @property istPflichttermin Gibt an, ob die Teilnahme an dieser Wertungsprüfung für die Cup-Gesamtwertung verpflichtend ist.
|
||||
* @property mindestErgebnisNotwendig Optionales Mindestergebnis, das in dieser Prüfung erzielt werden muss,
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import kotlinx.serialization.Serializable
|
||||
*
|
||||
* @property mcsId Eindeutiger interner Identifikator für diese Meisterschaft/Cup/Serie (UUID).
|
||||
* @property name Der offizielle Name der Meisterschaft, des Cups oder der Serie
|
||||
* (z.B. "EQUIVERON Cup 2025", "NÖ Landesmeisterschaft Dressur Allgemeine Klasse").
|
||||
* (z.B. "EQUIVERON Cup 2025", "NÖ Landesmeisterschaft Dressur allgemeine Klasse").
|
||||
* @property typ Die Art des übergreifenden Wettbewerbs (siehe `CupSerieTypE`).
|
||||
* @property jahr Das Jahr, in dem diese Meisterschaft/Cup/Serie stattfindet oder gewertet wird.
|
||||
* @property sparte Die Pferdesportsparte, für die dieser Wettbewerb primär ausgeschrieben ist.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package at.mocode.stammdaten
|
||||
|
||||
import at.mocode.enums.FunktionaerRolle
|
||||
import at.mocode.enums.FunktionaerRolleE
|
||||
import at.mocode.enums.GeschlechtE
|
||||
import at.mocode.serializers.KotlinInstantSerializer
|
||||
import at.mocode.serializers.KotlinLocalDateSerializer
|
||||
@@ -36,7 +36,7 @@ data class Person(
|
||||
var feiId: String?,
|
||||
var istGesperrt: Boolean = false,
|
||||
var sperrGrund: String?,
|
||||
var rollen: Set<FunktionaerRolle> = emptySet(),
|
||||
var rollen: Set<FunktionaerRolleE> = emptySet(),
|
||||
var lizenzen: List<LizenzInfo> = emptyList(),
|
||||
var qualifikationenRichter: List<String> = emptyList(),
|
||||
var qualifikationenParcoursbauer: List<String> = emptyList(),
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package at.mocode.validation
|
||||
|
||||
import at.mocode.model.domaene.DomLizenz
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
/**
|
||||
* Validator for DomLizenz objects
|
||||
*/
|
||||
object DomLizenzValidator {
|
||||
|
||||
/**
|
||||
* Validates a DomLizenz object and returns validation result
|
||||
*/
|
||||
fun validate(lizenz: DomLizenz): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Length validations
|
||||
ValidationUtils.validateLength(lizenz.notiz, "notiz", 500)?.let { errors.add(it) }
|
||||
|
||||
// Validity year validation
|
||||
lizenz.gueltigBisJahr?.let { gueltigBisJahr ->
|
||||
ValidationUtils.validateYear(gueltigBisJahr, "gueltigBisJahr", 2000)?.let { errors.add(it) }
|
||||
}
|
||||
|
||||
// Issue date validation
|
||||
lizenz.ausgestelltAm?.let { ausgestelltAm ->
|
||||
ValidationUtils.validateBirthDate(ausgestelltAm, "ausgestelltAm")?.let { errors.add(it) }
|
||||
}
|
||||
|
||||
// Business logic validations
|
||||
validateBusinessRules(lizenz)?.let { errors.addAll(it) }
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates business-specific rules for DomLizenz
|
||||
*/
|
||||
private fun validateBusinessRules(lizenz: DomLizenz): List<ValidationError>? {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year
|
||||
|
||||
// Active/paid licenses should have validity year
|
||||
if (lizenz.istAktivBezahltOeps && lizenz.gueltigBisJahr == null) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigBisJahr",
|
||||
"Active/paid licenses should have validity year",
|
||||
"REQUIRED_FOR_ACTIVE"
|
||||
))
|
||||
}
|
||||
|
||||
// Validity year should not be too far in the past for active licenses
|
||||
lizenz.gueltigBisJahr?.let { gueltigBisJahr ->
|
||||
if (lizenz.istAktivBezahltOeps && gueltigBisJahr < currentYear - 1) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigBisJahr",
|
||||
"Active license appears to be expired (validity year is more than 1 year in the past)",
|
||||
"EXPIRED_LICENSE"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Issue date should not be in the future
|
||||
lizenz.ausgestelltAm?.let { ausgestelltAm ->
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
if (ausgestelltAm > today) {
|
||||
errors.add(ValidationError(
|
||||
"ausgestelltAm",
|
||||
"Issue date cannot be in the future",
|
||||
"FUTURE_DATE"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Issue date and validity year consistency check
|
||||
lizenz.ausgestelltAm?.let { ausgestelltAm ->
|
||||
lizenz.gueltigBisJahr?.let { gueltigBisJahr ->
|
||||
val issueYear = ausgestelltAm.year
|
||||
|
||||
// Validity year should be same or later than issue year
|
||||
if (gueltigBisJahr < issueYear) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigBisJahr",
|
||||
"Validity year cannot be earlier than issue year",
|
||||
"INVALID_DATE_RANGE"
|
||||
))
|
||||
}
|
||||
|
||||
// Validity year should not be too far from issue year (reasonable range)
|
||||
if (gueltigBisJahr > issueYear + 10) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigBisJahr",
|
||||
"Validity year seems too far from issue year (more than 10 years)",
|
||||
"SUSPICIOUS_DATE_RANGE"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inactive licenses should have a reason (note) if they were previously active
|
||||
if (!lizenz.istAktivBezahltOeps && lizenz.notiz.isNullOrBlank()) {
|
||||
// This is more of a recommendation than a hard error
|
||||
errors.add(ValidationError(
|
||||
"notiz",
|
||||
"Inactive licenses should have a note explaining the status",
|
||||
"RECOMMENDED_FOR_INACTIVE"
|
||||
))
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) null else errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates license expiry status
|
||||
*/
|
||||
fun isLicenseExpired(lizenz: DomLizenz): Boolean {
|
||||
val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year
|
||||
return lizenz.gueltigBisJahr?.let { it < currentYear } ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates license validity for a specific year
|
||||
*/
|
||||
fun isValidForYear(lizenz: DomLizenz, year: Int): Boolean {
|
||||
return lizenz.gueltigBisJahr?.let { it >= year } ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a DomLizenz and throws ValidationException if invalid
|
||||
*/
|
||||
fun validateAndThrow(lizenz: DomLizenz) {
|
||||
val result = validate(lizenz)
|
||||
if (result.isInvalid()) {
|
||||
throw ValidationException(result as ValidationResult.Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation check - returns true if valid
|
||||
*/
|
||||
fun isValid(lizenz: DomLizenz): Boolean {
|
||||
return validate(lizenz).isValid()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates multiple licenses for a person to check for conflicts
|
||||
*/
|
||||
fun validateLicenseSet(lizenzen: List<DomLizenz>): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Check for duplicate license types for the same person and year
|
||||
val licenseTypeYearCombinations = mutableSetOf<Pair<String, Int?>>()
|
||||
|
||||
lizenzen.forEachIndexed { index, lizenz ->
|
||||
val combination = Pair(lizenz.lizenzTypGlobalId.toString(), lizenz.gueltigBisJahr)
|
||||
|
||||
if (combination in licenseTypeYearCombinations) {
|
||||
errors.add(ValidationError(
|
||||
"lizenzen[$index]",
|
||||
"Duplicate license type for the same validity year",
|
||||
"DUPLICATE_LICENSE"
|
||||
))
|
||||
} else {
|
||||
licenseTypeYearCombinations.add(combination)
|
||||
}
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package at.mocode.validation
|
||||
|
||||
import at.mocode.model.domaene.DomPferd
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
/**
|
||||
* Validator for DomPferd objects
|
||||
*/
|
||||
object DomPferdValidator {
|
||||
|
||||
/**
|
||||
* Validates a DomPferd object and returns validation result
|
||||
*/
|
||||
fun validate(pferd: DomPferd): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Required fields validation
|
||||
ValidationUtils.validateNotBlank(pferd.name, "name")?.let { errors.add(it) }
|
||||
|
||||
// Length validations
|
||||
ValidationUtils.validateLength(pferd.name, "name", 100, 1)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.lebensnummer, "lebensnummer", 20)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.feiPassNr, "feiPassNr", 20)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.farbe, "farbe", 50)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.rasse, "rasse", 100)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.abstammungVaterName, "abstammungVaterName", 100)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.abstammungMutterName, "abstammungMutterName", 100)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.abstammungMutterVaterName, "abstammungMutterVaterName", 100)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.abstammungZusatzInfo, "abstammungZusatzInfo", 500)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(pferd.notizenIntern, "notizenIntern", 1000)?.let { errors.add(it) }
|
||||
|
||||
// OEPS Satznummer validation (10-digit number)
|
||||
pferd.oepsSatzNrPferd?.let { oepsSatzNr ->
|
||||
if (oepsSatzNr.isNotBlank()) {
|
||||
if (oepsSatzNr.length != 10 || !oepsSatzNr.all { it.isDigit() }) {
|
||||
errors.add(ValidationError(
|
||||
"oepsSatzNrPferd",
|
||||
"OEPS Satznummer must be exactly 10 digits",
|
||||
"INVALID_FORMAT"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OEPS Kopfnummer validation (4-digit number)
|
||||
pferd.oepsKopfNr?.let { oepsKopfNr ->
|
||||
if (oepsKopfNr.isNotBlank()) {
|
||||
if (oepsKopfNr.length != 4 || !oepsKopfNr.all { it.isDigit() }) {
|
||||
errors.add(ValidationError(
|
||||
"oepsKopfNr",
|
||||
"OEPS Kopfnummer must be exactly 4 digits",
|
||||
"INVALID_FORMAT"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lebensnummer validation (UELN format - basic validation)
|
||||
pferd.lebensnummer?.let { lebensnummer ->
|
||||
if (lebensnummer.isNotBlank()) {
|
||||
// UELN should be 15 characters: 3-letter country code + 12 digits
|
||||
if (lebensnummer.length != 15 ||
|
||||
!lebensnummer.substring(0, 3).all { it.isLetter() } ||
|
||||
!lebensnummer.substring(3).all { it.isDigit() }) {
|
||||
errors.add(ValidationError(
|
||||
"lebensnummer",
|
||||
"Lebensnummer (UELN) must be 15 characters: 3 letters + 12 digits",
|
||||
"INVALID_FORMAT"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Birth year validation
|
||||
pferd.geburtsjahr?.let { geburtsjahr ->
|
||||
ValidationUtils.validateYear(geburtsjahr, "geburtsjahr", 1950)?.let { errors.add(it) }
|
||||
}
|
||||
|
||||
// Payment year validation
|
||||
pferd.letzteZahlungPferdegebuehrJahrOeps?.let { zahlungsjahr ->
|
||||
ValidationUtils.validateYear(zahlungsjahr, "letzteZahlungPferdegebuehrJahrOeps", 1990)?.let { errors.add(it) }
|
||||
}
|
||||
|
||||
// Stockmaß validation (reasonable range for horses)
|
||||
pferd.stockmassCm?.let { stockmass ->
|
||||
if (stockmass < 80 || stockmass > 220) {
|
||||
errors.add(ValidationError(
|
||||
"stockmassCm",
|
||||
"Stockmaß must be between 80 and 220 cm",
|
||||
"INVALID_RANGE"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Business logic validations
|
||||
validateBusinessRules(pferd)?.let { errors.addAll(it) }
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates business-specific rules for DomPferd
|
||||
*/
|
||||
private fun validateBusinessRules(pferd: DomPferd): List<ValidationError>? {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// OEPS horses should have OEPS numbers
|
||||
if (pferd.datenQuelle.name.contains("OEPS") && pferd.oepsSatzNrPferd.isNullOrBlank()) {
|
||||
errors.add(ValidationError(
|
||||
"oepsSatzNrPferd",
|
||||
"OEPS horses should have OEPS Satznummer",
|
||||
"REQUIRED_FOR_OEPS"
|
||||
))
|
||||
}
|
||||
|
||||
// Active horses should have birth year
|
||||
if (pferd.istAktiv && pferd.geburtsjahr == null) {
|
||||
errors.add(ValidationError(
|
||||
"geburtsjahr",
|
||||
"Birth year is recommended for active horses",
|
||||
"RECOMMENDED_FOR_ACTIVE"
|
||||
))
|
||||
}
|
||||
|
||||
// Active horses should have gender
|
||||
if (pferd.istAktiv && pferd.geschlecht == null) {
|
||||
errors.add(ValidationError(
|
||||
"geschlecht",
|
||||
"Gender is recommended for active horses",
|
||||
"RECOMMENDED_FOR_ACTIVE"
|
||||
))
|
||||
}
|
||||
|
||||
// Horses with payment info should have birth year for age verification
|
||||
pferd.letzteZahlungPferdegebuehrJahrOeps?.let { zahlungsjahr ->
|
||||
pferd.geburtsjahr?.let { geburtsjahr ->
|
||||
val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year
|
||||
val age = currentYear - geburtsjahr
|
||||
|
||||
// Horses should be at least 3 years old for competition
|
||||
if (age < 3) {
|
||||
errors.add(ValidationError(
|
||||
"geburtsjahr",
|
||||
"Horse appears to be too young for competition (under 3 years)",
|
||||
"AGE_WARNING"
|
||||
))
|
||||
}
|
||||
|
||||
// Warning for very old horses
|
||||
if (age > 30) {
|
||||
errors.add(ValidationError(
|
||||
"geburtsjahr",
|
||||
"Horse appears to be very old (over 30 years)",
|
||||
"AGE_WARNING"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) null else errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a DomPferd and throws ValidationException if invalid
|
||||
*/
|
||||
fun validateAndThrow(pferd: DomPferd) {
|
||||
val result = validate(pferd)
|
||||
if (result.isInvalid()) {
|
||||
throw ValidationException(result as ValidationResult.Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation check - returns true if valid
|
||||
*/
|
||||
fun isValid(pferd: DomPferd): Boolean {
|
||||
return validate(pferd).isValid()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package at.mocode.validation
|
||||
|
||||
import at.mocode.model.domaene.DomQualifikation
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
/**
|
||||
* Validator for DomQualifikation objects
|
||||
*/
|
||||
object DomQualifikationValidator {
|
||||
|
||||
/**
|
||||
* Validates a DomQualifikation object and returns validation result
|
||||
*/
|
||||
fun validate(qualifikation: DomQualifikation): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Length validations
|
||||
ValidationUtils.validateLength(qualifikation.bemerkung, "bemerkung", 500)?.let { errors.add(it) }
|
||||
|
||||
// Date validations - basic date range validation (not birth date validation)
|
||||
qualifikation.gueltigVon?.let { gueltigVon ->
|
||||
// Only check that it's not too far in the past (reasonable minimum date)
|
||||
val minDate = kotlinx.datetime.LocalDate(1900, 1, 1)
|
||||
if (gueltigVon < minDate) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigVon",
|
||||
"Start date cannot be before year 1900",
|
||||
"INVALID_DATE"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
qualifikation.gueltigBis?.let { gueltigBis ->
|
||||
// Only check that it's not too far in the past (reasonable minimum date)
|
||||
val minDate = kotlinx.datetime.LocalDate(1900, 1, 1)
|
||||
if (gueltigBis < minDate) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigBis",
|
||||
"End date cannot be before year 1900",
|
||||
"INVALID_DATE"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Business logic validations
|
||||
validateBusinessRules(qualifikation)?.let { errors.addAll(it) }
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates business-specific rules for DomQualifikation
|
||||
*/
|
||||
private fun validateBusinessRules(qualifikation: DomQualifikation): List<ValidationError>? {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
|
||||
// Validity date range consistency check
|
||||
qualifikation.gueltigVon?.let { gueltigVon ->
|
||||
qualifikation.gueltigBis?.let { gueltigBis ->
|
||||
if (gueltigBis < gueltigVon) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigBis",
|
||||
"End date cannot be earlier than start date",
|
||||
"INVALID_DATE_RANGE"
|
||||
))
|
||||
}
|
||||
|
||||
// Check for unreasonably long qualification periods
|
||||
val daysBetween = gueltigBis.toEpochDays() - gueltigVon.toEpochDays()
|
||||
if (daysBetween > 365 * 20) { // More than 20 years
|
||||
errors.add(ValidationError(
|
||||
"gueltigBis",
|
||||
"Qualification validity period seems unreasonably long (more than 20 years)",
|
||||
"SUSPICIOUS_DATE_RANGE"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start date should not be in the future for active qualifications
|
||||
qualifikation.gueltigVon?.let { gueltigVon ->
|
||||
if (qualifikation.istAktiv && gueltigVon > today) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigVon",
|
||||
"Start date cannot be in the future for active qualifications",
|
||||
"FUTURE_DATE"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Active qualifications with end date should not be expired
|
||||
if (qualifikation.istAktiv) {
|
||||
qualifikation.gueltigBis?.let { gueltigBis ->
|
||||
if (gueltigBis < today) {
|
||||
errors.add(ValidationError(
|
||||
"istAktiv",
|
||||
"Qualification appears to be expired but is marked as active",
|
||||
"EXPIRED_QUALIFICATION"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inactive qualifications should have a reason (note)
|
||||
if (!qualifikation.istAktiv && qualifikation.bemerkung.isNullOrBlank()) {
|
||||
errors.add(ValidationError(
|
||||
"bemerkung",
|
||||
"Inactive qualifications should have a note explaining the status",
|
||||
"RECOMMENDED_FOR_INACTIVE"
|
||||
))
|
||||
}
|
||||
|
||||
// Active qualifications should have start date
|
||||
if (qualifikation.istAktiv && qualifikation.gueltigVon == null) {
|
||||
errors.add(ValidationError(
|
||||
"gueltigVon",
|
||||
"Active qualifications should have a start date",
|
||||
"RECOMMENDED_FOR_ACTIVE"
|
||||
))
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) null else errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates qualification expiry status
|
||||
*/
|
||||
fun isQualificationExpired(qualifikation: DomQualifikation): Boolean {
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
return qualifikation.gueltigBis?.let { it < today } ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates qualification validity for a specific date
|
||||
*/
|
||||
fun isValidForDate(qualifikation: DomQualifikation, date: kotlinx.datetime.LocalDate): Boolean {
|
||||
val validFrom = qualifikation.gueltigVon?.let { date >= it } ?: true
|
||||
val validUntil = qualifikation.gueltigBis?.let { date <= it } ?: true
|
||||
return validFrom && validUntil && qualifikation.istAktiv
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates qualification validity for today
|
||||
*/
|
||||
fun isCurrentlyValid(qualifikation: DomQualifikation): Boolean {
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
return isValidForDate(qualifikation, today)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a DomQualifikation and throws ValidationException if invalid
|
||||
*/
|
||||
fun validateAndThrow(qualifikation: DomQualifikation) {
|
||||
val result = validate(qualifikation)
|
||||
if (result.isInvalid()) {
|
||||
throw ValidationException(result as ValidationResult.Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation check - returns true if valid
|
||||
*/
|
||||
fun isValid(qualifikation: DomQualifikation): Boolean {
|
||||
return validate(qualifikation).isValid()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates multiple qualifications for a person to check for conflicts
|
||||
*/
|
||||
fun validateQualificationSet(qualifikationen: List<DomQualifikation>): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Check for overlapping active qualifications of the same type
|
||||
val activeQualifications = qualifikationen.filter { it.istAktiv }
|
||||
|
||||
for (i in activeQualifications.indices) {
|
||||
for (j in i + 1 until activeQualifications.size) {
|
||||
val qual1 = activeQualifications[i]
|
||||
val qual2 = activeQualifications[j]
|
||||
|
||||
// Same qualification type
|
||||
if (qual1.qualTypId == qual2.qualTypId) {
|
||||
// Check for overlapping periods
|
||||
val overlap = checkDateOverlap(
|
||||
qual1.gueltigVon, qual1.gueltigBis,
|
||||
qual2.gueltigVon, qual2.gueltigBis
|
||||
)
|
||||
|
||||
if (overlap) {
|
||||
errors.add(ValidationError(
|
||||
"qualifikationen",
|
||||
"Overlapping active qualifications of the same type found",
|
||||
"OVERLAPPING_QUALIFICATIONS"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if two date ranges overlap
|
||||
*/
|
||||
private fun checkDateOverlap(
|
||||
start1: kotlinx.datetime.LocalDate?, end1: kotlinx.datetime.LocalDate?,
|
||||
start2: kotlinx.datetime.LocalDate?, end2: kotlinx.datetime.LocalDate?
|
||||
): Boolean {
|
||||
// If any qualification has no dates, assume no overlap
|
||||
if (start1 == null && end1 == null) return false
|
||||
if (start2 == null && end2 == null) return false
|
||||
|
||||
// Use very early/late dates for missing bounds
|
||||
val earlyDate = kotlinx.datetime.LocalDate(1900, 1, 1)
|
||||
val lateDate = kotlinx.datetime.LocalDate(2100, 12, 31)
|
||||
|
||||
val actualStart1 = start1 ?: earlyDate
|
||||
val actualEnd1 = end1 ?: lateDate
|
||||
val actualStart2 = start2 ?: earlyDate
|
||||
val actualEnd2 = end2 ?: lateDate
|
||||
|
||||
// Check if ranges overlap
|
||||
return actualStart1 <= actualEnd2 && actualStart2 <= actualEnd1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package at.mocode.validation
|
||||
|
||||
import at.mocode.model.domaene.DomVerein
|
||||
|
||||
/**
|
||||
* Validator for DomVerein objects
|
||||
*/
|
||||
object DomVereinValidator {
|
||||
|
||||
/**
|
||||
* Validates a DomVerein object and returns validation result
|
||||
*/
|
||||
fun validate(verein: DomVerein): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Required fields validation
|
||||
ValidationUtils.validateNotBlank(verein.name, "name")?.let { errors.add(it) }
|
||||
|
||||
// Length validations
|
||||
ValidationUtils.validateLength(verein.name, "name", 100, 1)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(verein.kuerzel, "kuerzel", 20)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(verein.adresseStrasse, "adresseStrasse", 200)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(verein.ort, "ort", 100)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(verein.webseiteUrl, "webseiteUrl", 255)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(verein.notizenIntern, "notizenIntern", 1000)?.let { errors.add(it) }
|
||||
|
||||
// Format validations
|
||||
ValidationUtils.validateEmail(verein.emailAllgemein, "emailAllgemein")?.let { errors.add(it) }
|
||||
ValidationUtils.validatePhoneNumber(verein.telefonAllgemein, "telefonAllgemein")?.let { errors.add(it) }
|
||||
ValidationUtils.validatePostalCode(verein.plz, "plz")?.let { errors.add(it) }
|
||||
|
||||
// OEPS Vereinsnummer validation (4-digit number)
|
||||
verein.oepsVereinsNr?.let { oepsNr ->
|
||||
if (oepsNr.isNotBlank()) {
|
||||
if (oepsNr.length != 4 || !oepsNr.all { it.isDigit() }) {
|
||||
errors.add(ValidationError(
|
||||
"oepsVereinsNr",
|
||||
"OEPS Vereinsnummer must be exactly 4 digits",
|
||||
"INVALID_FORMAT"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Website URL validation
|
||||
verein.webseiteUrl?.let { url ->
|
||||
if (url.isNotBlank()) {
|
||||
val urlRegex = "^https?://[\\w\\-]+(\\.[\\w\\-]+)+([\\w\\-\\.,@?^=%&:/~\\+#]*[\\w\\-\\@?^=%&/~\\+#])?$".toRegex()
|
||||
if (!urlRegex.matches(url)) {
|
||||
errors.add(ValidationError(
|
||||
"webseiteUrl",
|
||||
"Invalid website URL format",
|
||||
"INVALID_FORMAT"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Business logic validations
|
||||
validateBusinessRules(verein)?.let { errors.addAll(it) }
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates business-specific rules for DomVerein
|
||||
*/
|
||||
private fun validateBusinessRules(verein: DomVerein): List<ValidationError>? {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// OEPS clubs should have OEPS number
|
||||
if (verein.datenQuelle.name.contains("OEPS") && verein.oepsVereinsNr.isNullOrBlank()) {
|
||||
errors.add(ValidationError(
|
||||
"oepsVereinsNr",
|
||||
"OEPS clubs should have OEPS Vereinsnummer",
|
||||
"REQUIRED_FOR_OEPS"
|
||||
))
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) null else errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a DomVerein and throws ValidationException if invalid
|
||||
*/
|
||||
fun validateAndThrow(verein: DomVerein) {
|
||||
val result = validate(verein)
|
||||
if (result.isInvalid()) {
|
||||
throw ValidationException(result as ValidationResult.Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation check - returns true if valid
|
||||
*/
|
||||
fun isValid(verein: DomVerein): Boolean {
|
||||
return validate(verein).isValid()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package at.mocode.validation
|
||||
|
||||
import at.mocode.stammdaten.Person
|
||||
|
||||
/**
|
||||
* Validator for Person objects
|
||||
*/
|
||||
object PersonValidator {
|
||||
|
||||
/**
|
||||
* Validates a Person object and returns validation result
|
||||
*/
|
||||
fun validate(person: Person): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Required fields validation
|
||||
ValidationUtils.validateNotBlank(person.vorname, "vorname")?.let { errors.add(it) }
|
||||
ValidationUtils.validateNotBlank(person.nachname, "nachname")?.let { errors.add(it) }
|
||||
|
||||
// Length validations
|
||||
ValidationUtils.validateLength(person.vorname, "vorname", 100, 1)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(person.nachname, "nachname", 100, 1)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(person.titel, "titel", 50)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(person.adresse, "adresse", 500)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(person.ort, "ort", 100)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(person.mitgliedsNummerIntern, "mitgliedsNummerIntern", 50)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(person.feiId, "feiId", 50)?.let { errors.add(it) }
|
||||
ValidationUtils.validateLength(person.sperrGrund, "sperrGrund", 500)?.let { errors.add(it) }
|
||||
|
||||
// Format validations
|
||||
ValidationUtils.validateEmail(person.email)?.let { errors.add(it) }
|
||||
ValidationUtils.validatePhoneNumber(person.telefon)?.let { errors.add(it) }
|
||||
ValidationUtils.validatePostalCode(person.plz)?.let { errors.add(it) }
|
||||
ValidationUtils.validateCountryCode(person.nationalitaet)?.let { errors.add(it) }
|
||||
ValidationUtils.validateOepsSatzNr(person.oepsSatzNr)?.let { errors.add(it) }
|
||||
|
||||
// Date validations
|
||||
ValidationUtils.validateBirthDate(person.geburtsdatum)?.let { errors.add(it) }
|
||||
ValidationUtils.validateYear(person.letzteZahlungJahr, "letzteZahlungJahr", 1990)?.let { errors.add(it) }
|
||||
|
||||
// Business logic validations
|
||||
validateBusinessRules(person)?.let { errors.addAll(it) }
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates business-specific rules for Person
|
||||
*/
|
||||
private fun validateBusinessRules(person: Person): List<ValidationError>? {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// If person is blocked, there must be a reason
|
||||
if (person.istGesperrt && person.sperrGrund.isNullOrBlank()) {
|
||||
errors.add(ValidationError(
|
||||
"sperrGrund",
|
||||
"Block reason is required when person is blocked",
|
||||
"REQUIRED_WHEN_BLOCKED"
|
||||
))
|
||||
}
|
||||
|
||||
// If person is not blocked, there shouldn't be a block reason
|
||||
if (!person.istGesperrt && !person.sperrGrund.isNullOrBlank()) {
|
||||
errors.add(ValidationError(
|
||||
"sperrGrund",
|
||||
"Block reason should be empty when person is not blocked",
|
||||
"INVALID_WHEN_NOT_BLOCKED"
|
||||
))
|
||||
}
|
||||
|
||||
// Email is required for active persons (business rule example)
|
||||
if (person.istAktiv && person.email.isNullOrBlank()) {
|
||||
errors.add(ValidationError(
|
||||
"email",
|
||||
"Email is required for active persons",
|
||||
"REQUIRED_FOR_ACTIVE"
|
||||
))
|
||||
}
|
||||
|
||||
// Validate license information consistency
|
||||
person.lizenzen.forEachIndexed { index, lizenz ->
|
||||
// Validate license level if provided
|
||||
lizenz.stufe?.let { stufe ->
|
||||
if (stufe.isBlank()) {
|
||||
errors.add(ValidationError(
|
||||
"lizenzen[$index].stufe",
|
||||
"License level cannot be blank if provided",
|
||||
"REQUIRED"
|
||||
))
|
||||
}
|
||||
if (stufe.length > 50) {
|
||||
errors.add(ValidationError(
|
||||
"lizenzen[$index].stufe",
|
||||
"License level cannot exceed 50 characters",
|
||||
"MAX_LENGTH"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate license validity year
|
||||
lizenz.gueltigBisJahr?.let { jahr ->
|
||||
ValidationUtils.validateYear(jahr, "lizenzen[$index].gueltigBisJahr", 2000)?.let {
|
||||
errors.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) null else errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Person and throws ValidationException if invalid
|
||||
*/
|
||||
fun validateAndThrow(person: Person) {
|
||||
val result = validate(person)
|
||||
if (result.isInvalid()) {
|
||||
throw ValidationException(result as ValidationResult.Invalid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation check - returns true if valid
|
||||
*/
|
||||
fun isValid(person: Person): Boolean {
|
||||
return validate(person).isValid()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package at.mocode.validation
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the result of a validation operation
|
||||
*/
|
||||
@Serializable
|
||||
sealed class ValidationResult {
|
||||
@Serializable
|
||||
object Valid : ValidationResult()
|
||||
|
||||
@Serializable
|
||||
data class Invalid(val errors: List<ValidationError>) : ValidationResult()
|
||||
|
||||
fun isValid(): Boolean = this is Valid
|
||||
fun isInvalid(): Boolean = this is Invalid
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single validation error
|
||||
*/
|
||||
@Serializable
|
||||
data class ValidationError(
|
||||
val field: String,
|
||||
val message: String,
|
||||
val code: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Exception thrown when validation fails
|
||||
*/
|
||||
class ValidationException(
|
||||
val validationResult: ValidationResult.Invalid
|
||||
) : IllegalArgumentException(
|
||||
"Validation failed: ${validationResult.errors.joinToString(", ") { "${it.field}: ${it.message}" }}"
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
package at.mocode.validation
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
/**
|
||||
* Common validation utilities
|
||||
*/
|
||||
object ValidationUtils {
|
||||
|
||||
/**
|
||||
* Validates that a string is not blank
|
||||
*/
|
||||
fun validateNotBlank(value: String?, fieldName: String): ValidationError? {
|
||||
return if (value.isNullOrBlank()) {
|
||||
ValidationError(fieldName, "$fieldName cannot be blank", "REQUIRED")
|
||||
} else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates string length
|
||||
*/
|
||||
fun validateLength(value: String?, fieldName: String, maxLength: Int, minLength: Int = 0): ValidationError? {
|
||||
if (value == null) return null
|
||||
|
||||
return when {
|
||||
value.length < minLength -> ValidationError(
|
||||
fieldName,
|
||||
"$fieldName must be at least $minLength characters long",
|
||||
"MIN_LENGTH"
|
||||
)
|
||||
value.length > maxLength -> ValidationError(
|
||||
fieldName,
|
||||
"$fieldName cannot exceed $maxLength characters",
|
||||
"MAX_LENGTH"
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates email format
|
||||
*/
|
||||
fun validateEmail(email: String?, fieldName: String = "email"): ValidationError? {
|
||||
if (email.isNullOrBlank()) return null
|
||||
|
||||
val emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$".toRegex()
|
||||
return if (!emailRegex.matches(email)) {
|
||||
ValidationError(fieldName, "Invalid email format", "INVALID_FORMAT")
|
||||
} else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates phone number format (basic validation)
|
||||
*/
|
||||
fun validatePhoneNumber(phone: String?, fieldName: String = "telefon"): ValidationError? {
|
||||
if (phone.isNullOrBlank()) return null
|
||||
|
||||
// Remove common separators and spaces
|
||||
val cleanPhone = phone.replace(Regex("[\\s\\-\\(\\)\\+]"), "")
|
||||
|
||||
return if (cleanPhone.length < 6 || cleanPhone.length > 20 || !cleanPhone.all { it.isDigit() }) {
|
||||
ValidationError(fieldName, "Invalid phone number format", "INVALID_FORMAT")
|
||||
} else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates postal code format (basic validation for various countries)
|
||||
*/
|
||||
fun validatePostalCode(postalCode: String?, fieldName: String = "plz"): ValidationError? {
|
||||
if (postalCode.isNullOrBlank()) return null
|
||||
|
||||
// Basic validation: 3-10 alphanumeric characters
|
||||
return if (postalCode.length < 3 || postalCode.length > 10 || !postalCode.all { it.isLetterOrDigit() }) {
|
||||
ValidationError(fieldName, "Invalid postal code format", "INVALID_FORMAT")
|
||||
} else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates 3-letter country code
|
||||
*/
|
||||
fun validateCountryCode(countryCode: String?, fieldName: String = "nationalitaet"): ValidationError? {
|
||||
if (countryCode.isNullOrBlank()) return null
|
||||
|
||||
return if (countryCode.length != 3 || !countryCode.all { it.isLetter() }) {
|
||||
ValidationError(fieldName, "Country code must be exactly 3 letters", "INVALID_FORMAT")
|
||||
} else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates birth date
|
||||
*/
|
||||
fun validateBirthDate(birthDate: LocalDate?, fieldName: String = "geburtsdatum"): ValidationError? {
|
||||
if (birthDate == null) return null
|
||||
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
val minDate = LocalDate(1900, 1, 1)
|
||||
|
||||
return when {
|
||||
birthDate > today -> ValidationError(
|
||||
fieldName,
|
||||
"Birth date cannot be in the future",
|
||||
"FUTURE_DATE"
|
||||
)
|
||||
birthDate < minDate -> ValidationError(
|
||||
fieldName,
|
||||
"Birth date cannot be before year 1900",
|
||||
"INVALID_DATE"
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates year value
|
||||
*/
|
||||
fun validateYear(year: Int?, fieldName: String, minYear: Int = 1900): ValidationError? {
|
||||
if (year == null) return null
|
||||
|
||||
val currentYear = Clock.System.todayIn(TimeZone.currentSystemDefault()).year
|
||||
|
||||
return when {
|
||||
year < minYear -> ValidationError(
|
||||
fieldName,
|
||||
"Year cannot be before $minYear",
|
||||
"INVALID_YEAR"
|
||||
)
|
||||
year > currentYear + 10 -> ValidationError(
|
||||
fieldName,
|
||||
"Year cannot be more than 10 years in the future",
|
||||
"FUTURE_YEAR"
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates OEPS Satz number format (Austrian specific)
|
||||
*/
|
||||
fun validateOepsSatzNr(oepsSatzNr: String?, fieldName: String = "oepsSatzNr"): ValidationError? {
|
||||
if (oepsSatzNr.isNullOrBlank()) return null
|
||||
|
||||
// Basic validation: should be numeric and reasonable length
|
||||
return if (oepsSatzNr.length < 3 || oepsSatzNr.length > 20 || !oepsSatzNr.all { it.isDigit() }) {
|
||||
ValidationError(fieldName, "Invalid OEPS Satz number format", "INVALID_FORMAT")
|
||||
} else null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user