Merge pull request #18
* MP-19 Refactoring: Einführung der "Registry" & "Masterdata" Trennung … * MP-19 Refactoring: Frontend Tabula Rasa * MP-19 Refactoring: Frontend Tabula Rasa * refactoring: * MP-20 fix(docker/clients): include `:domains` module in web/desktop b… * MP-20 fix(web-app build): resolve JS compile error and add dev/prod b… * MP-20 fix(web-app): remove vendor.js reference and harden JS bootstra… * MP-20 fixing: clients * MP-20 fixing: clients
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
# Horses Module
|
||||
|
||||
## Überblick
|
||||
|
||||
Das Horses-Modul ist eine umfassende Lösung zur Verwaltung von Pferden für Pferdesportorganisationen. Es implementiert eine saubere Architektur mit Domain-Driven Design und bietet vollständige CRUD-Operationen sowie erweiterte Geschäftslogik für die Pferderegistrierung und -verwaltung.
|
||||
|
||||
## Funktionalität
|
||||
|
||||
### Verwaltete Entität
|
||||
|
||||
#### Pferd (DomPferd)
|
||||
|
||||
- **Grundinformationen**: Name, Geschlecht, Geburtsdatum, Rasse, Farbe
|
||||
- **Besitz und Verantwortung**: Besitzer-ID, verantwortliche Person
|
||||
- **Zuchtinformationen**: Züchtername, Zuchtbuchnummer
|
||||
- **Identifikationsnummern**: Lebensnummer, Chipnummer, Passnummer, OEPS-Nummer, FEI-Nummer
|
||||
- **Abstammung**: Vater, Mutter, Muttervater
|
||||
- **Körperliche Merkmale**: Stockmaß (Höhe in cm)
|
||||
- **Status und Verwaltung**: Aktivitätsstatus, Bemerkungen, Datenquelle
|
||||
- **Audit-Felder**: Erstellungs- und Aktualisierungszeitstempel
|
||||
|
||||
### Geschäftsoperationen
|
||||
|
||||
Das Modul bietet 25+ spezialisierte Repository-Operationen:
|
||||
|
||||
#### Basis-CRUD-Operationen
|
||||
|
||||
- `findById(id)` - Pferd nach UUID suchen
|
||||
- `save(horse)` - Pferd speichern (erstellen/aktualisieren)
|
||||
- `delete(id)` - Pferd löschen
|
||||
|
||||
#### Such-Operationen nach Identifikationsnummern
|
||||
|
||||
- `findByLebensnummer(lebensnummer)` - Nach Lebensnummer suchen
|
||||
- `findByChipNummer(chipNummer)` - Nach Chipnummer suchen
|
||||
- `findByPassNummer(passNummer)` - Nach Passnummer suchen
|
||||
- `findByOepsNummer(oepsNummer)` - Nach OEPS-Nummer suchen
|
||||
- `findByFeiNummer(feiNummer)` - Nach FEI-Nummer suchen
|
||||
|
||||
#### Such-Operationen nach Eigenschaften
|
||||
|
||||
- `findByName(searchTerm, limit)` - Nach Namen suchen (Teilübereinstimmung)
|
||||
- `findByOwnerId(ownerId, activeOnly)` - Pferde eines Besitzers
|
||||
- `findByResponsiblePersonId(personId, activeOnly)` - Pferde einer verantwortlichen Person
|
||||
- `findByGeschlecht(geschlecht, activeOnly, limit)` - Nach Geschlecht filtern
|
||||
- `findByRasse(rasse, activeOnly, limit)` - Nach Rasse filtern
|
||||
|
||||
#### Datumsbasierte Abfragen
|
||||
|
||||
- `findByBirthYear(birthYear, activeOnly)` - Pferde nach Geburtsjahr
|
||||
- `findByBirthYearRange(fromYear, toYear, activeOnly)` - Pferde nach Geburtsjahr-Bereich
|
||||
|
||||
#### Registrierungs-Abfragen
|
||||
|
||||
- `findAllActive(limit)` - Alle aktiven Pferde
|
||||
- `findOepsRegistered(activeOnly)` - OEPS-registrierte Pferde
|
||||
- `findFeiRegistered(activeOnly)` - FEI-registrierte Pferde
|
||||
|
||||
#### Validierungs-Operationen
|
||||
|
||||
- `existsByLebensnummer(lebensnummer)` - Prüfung auf doppelte Lebensnummer
|
||||
- `existsByChipNummer(chipNummer)` - Prüfung auf doppelte Chipnummer
|
||||
- `existsByPassNummer(passNummer)` - Prüfung auf doppelte Passnummer
|
||||
- `existsByOepsNummer(oepsNummer)` - Prüfung auf doppelte OEPS-Nummer
|
||||
- `existsByFeiNummer(feiNummer)` - Prüfung auf doppelte FEI-Nummer
|
||||
|
||||
#### Zähl-Operationen
|
||||
|
||||
- `countActive()` - Anzahl aktiver Pferde
|
||||
- `countByOwnerId(ownerId, activeOnly)` - Anzahl Pferde pro Besitzer
|
||||
- `countOepsRegistered(activeOnly)` - Anzahl OEPS-registrierter Pferde ✨ **NEU**
|
||||
- `countFeiRegistered(activeOnly)` - Anzahl FEI-registrierter Pferde ✨ **NEU**
|
||||
|
||||
## Architektur
|
||||
|
||||
Das Modul folgt der Clean Architecture mit klarer Trennung der Verantwortlichkeiten:
|
||||
|
||||
```
|
||||
horses/
|
||||
├── horses-domain/ # Domain Layer
|
||||
│ ├── model/ # Domain Models
|
||||
│ │ └── DomPferd.kt # Pferd-Entität mit Geschäftslogik
|
||||
│ └── repository/ # Repository Interfaces
|
||||
│ └── HorseRepository.kt # 25+ Geschäftsoperationen
|
||||
├── horses-application/ # Application Layer
|
||||
│ └── usecase/ # Use Cases
|
||||
│ ├── CreateHorseUseCase.kt
|
||||
│ ├── GetHorseUseCase.kt
|
||||
│ ├── UpdateHorseUseCase.kt
|
||||
│ └── DeleteHorseUseCase.kt
|
||||
├── horses-infrastructure/ # Infrastructure Layer
|
||||
│ └── persistence/ # Database Implementation
|
||||
│ ├── HorseRepositoryImpl.kt
|
||||
│ └── HorseTable.kt
|
||||
├── horses-api/ # API Layer
|
||||
│ └── rest/ # REST Controllers
|
||||
│ └── HorseController.kt
|
||||
└── horses-service/ # Service Layer
|
||||
├── HorsesServiceApplication.kt
|
||||
└── test/ # Integration Tests
|
||||
└── HorseServiceIntegrationTest.kt
|
||||
```
|
||||
|
||||
### Domain Layer
|
||||
|
||||
- **1 Domain Model** mit reichhaltiger Geschäftslogik
|
||||
- **1 Repository Interface** mit 25+ Geschäftsoperationen
|
||||
- **Geschäftsregeln** für Pferderegistrierung und -validierung
|
||||
- **Keine Abhängigkeiten** zu anderen Layern
|
||||
|
||||
### Application Layer
|
||||
|
||||
- **Use Cases** für CRUD-Operationen
|
||||
- **Orchestrierung** von Domain-Services
|
||||
- **Anwendungslogik** ohne UI-Abhängigkeiten
|
||||
|
||||
### Infrastructure Layer
|
||||
|
||||
- **Datenbankzugriff** mit Exposed ORM
|
||||
- **Repository-Implementierung** mit PostgreSQL
|
||||
- **Datenbankschema** und Migrationen
|
||||
|
||||
### API Layer
|
||||
|
||||
- **REST-Controller** für HTTP-Endpunkte
|
||||
|
||||
## 🚀 Aktuelle Optimierungen (2025-07-25)
|
||||
|
||||
Das Horses-Modul wurde kürzlich analysiert, vervollständigt und optimiert. Folgende Verbesserungen wurden implementiert:
|
||||
|
||||
### ✨ Neue Funktionalitäten
|
||||
|
||||
#### Erweiterte Such-Endpunkte
|
||||
|
||||
Neue REST-Endpunkte für vollständige Identifikationsnummer-Suche:
|
||||
|
||||
- `GET /api/horses/search/passport/{nummer}` - Suche nach Passnummer
|
||||
- `GET /api/horses/search/oeps/{nummer}` - Suche nach OEPS-Nummer
|
||||
- `GET /api/horses/search/fei/{nummer}` - Suche nach FEI-Nummer
|
||||
|
||||
#### Optimierte Statistik-Operationen
|
||||
|
||||
- Neue effiziente Zähl-Methoden für OEPS und FEI registrierte Pferde
|
||||
- Performance-Verbesserung von O(n) auf O(1) Komplexität für Statistiken
|
||||
- Datenbankoptimierte COUNT-Abfragen statt Laden aller Datensätze
|
||||
|
||||
### ⚡ Performance-Optimierungen
|
||||
|
||||
#### Datenbankeffizienz
|
||||
|
||||
- **Vorher**: Statistik-Endpunkt lud alle Pferde und verwendete `.size`
|
||||
- **Nachher**: Effiziente COUNT-Abfragen direkt in der Datenbank
|
||||
- **Auswirkung**: Drastische Reduzierung der Speichernutzung und Antwortzeiten
|
||||
|
||||
#### Architektur-Konsistenz
|
||||
|
||||
- Alle API-Endpunkte verwenden jetzt konsistent die Use-Case-Schicht
|
||||
- Eliminierung direkter Repository-Aufrufe in der API-Schicht
|
||||
- Saubere Trennung der Architektur-Schichten
|
||||
|
||||
### 🏗️ Architektur-Verbesserungen
|
||||
|
||||
#### Clean Architecture Compliance
|
||||
|
||||
- **Konsistente Schichtung**: Alle Endpunkte folgen dem Use-Case-Pattern
|
||||
- **Fehlerbehandlung**: Einheitliche Fehlerantworten über alle Endpunkte
|
||||
- **Validierung**: Umfassende Eingabevalidierung mit geteilten Utilities
|
||||
- **HTTP-Standards**: Korrekte Status-Codes und REST-Konventionen
|
||||
|
||||
#### Code-Qualität
|
||||
|
||||
- Verbesserte Lesbarkeit und Wartbarkeit
|
||||
- Konsistente Namenskonventionen
|
||||
- Umfassende Dokumentation aller neuen Funktionen
|
||||
|
||||
### 📊 Qualitätsmetriken
|
||||
|
||||
#### Vor der Optimierung
|
||||
|
||||
- ❌ Fehlende Such-Endpunkte für 3 Identifikationstypen
|
||||
- ❌ Ineffiziente Statistik-Abfragen (O(n) Komplexität)
|
||||
- ❌ Inkonsistente Architektur (einige Endpunkte umgingen Use Cases)
|
||||
- ❌ Performance-Probleme bei großen Datensätzen
|
||||
|
||||
#### Nach der Optimierung
|
||||
|
||||
- ✅ Vollständige API-Abdeckung für alle Identifikationstypen
|
||||
- ✅ Effiziente Statistik-Abfragen (O(1) Komplexität)
|
||||
- ✅ Konsistente Clean Architecture durchgehend
|
||||
- ✅ Optimierte Performance für alle Operationen
|
||||
|
||||
### 🔮 Zukünftige Empfehlungen
|
||||
|
||||
#### Caching-Schicht
|
||||
|
||||
- Implementierung einer Caching-Schicht für häufig abgerufene Daten
|
||||
- Individuelle Pferde-Lookups mit angemessener TTL
|
||||
- Statistiken und Zählungen mit Cache-Invalidierung
|
||||
|
||||
#### Async-Operationen
|
||||
|
||||
- Asynchrone Verarbeitung für Batch-Operationen
|
||||
- Komplexe Such-Abfragen mit Async-Pattern
|
||||
- Statistik-Berechnungen im Hintergrund
|
||||
|
||||
#### Monitoring und Logging
|
||||
|
||||
- Umfassendes Monitoring für API-Antwortzeiten
|
||||
- Datenbank-Query-Performance-Überwachung
|
||||
- Fehlerrate-Tracking und -Analyse
|
||||
- **DTO-Mapping** zwischen Domain und API
|
||||
- **Validierung** und Fehlerbehandlung
|
||||
|
||||
### Service Layer
|
||||
|
||||
- **Spring Boot Anwendung**
|
||||
- **Dependency Injection** Konfiguration
|
||||
- **Integrationstests**
|
||||
|
||||
## Domain Model Details
|
||||
|
||||
### DomPferd-Entität
|
||||
|
||||
```kotlin
|
||||
data class DomPferd(
|
||||
val pferdId: Uuid,
|
||||
|
||||
// Grundinformationen
|
||||
var pferdeName: String,
|
||||
var geschlecht: PferdeGeschlechtE,
|
||||
var geburtsdatum: LocalDate? = null,
|
||||
var rasse: String? = null,
|
||||
var farbe: String? = null,
|
||||
|
||||
// Besitz und Verantwortung
|
||||
var besitzerId: Uuid? = null,
|
||||
var verantwortlichePersonId: Uuid? = null,
|
||||
|
||||
// Zuchtinformationen
|
||||
var zuechterName: String? = null,
|
||||
var zuchtbuchNummer: String? = null,
|
||||
|
||||
// Identifikationsnummern
|
||||
var lebensnummer: String? = null,
|
||||
var chipNummer: String? = null,
|
||||
var passNummer: String? = null,
|
||||
var oepsNummer: String? = null,
|
||||
var feiNummer: String? = null,
|
||||
|
||||
// Abstammung
|
||||
var vaterName: String? = null,
|
||||
var mutterName: String? = null,
|
||||
var mutterVaterName: String? = null,
|
||||
|
||||
// Körperliche Merkmale
|
||||
var stockmass: Int? = null, // Höhe in cm
|
||||
|
||||
// Status und Verwaltung
|
||||
var istAktiv: Boolean = true,
|
||||
var bemerkungen: String? = null,
|
||||
var datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL,
|
||||
|
||||
// Audit-Felder
|
||||
val createdAt: Instant,
|
||||
var updatedAt: Instant
|
||||
)
|
||||
```
|
||||
|
||||
### Geschäftslogik-Methoden
|
||||
|
||||
- `getDisplayName()` - Anzeigename mit Geburtsjahr
|
||||
- `hasCompleteIdentification()` - Prüfung auf vollständige Identifikation
|
||||
- `isOepsRegistered()` - OEPS-Registrierungsstatus
|
||||
- `isFeiRegistered()` - FEI-Registrierungsstatus
|
||||
- `getAge()` - Altersberechnung in Jahren
|
||||
- `validateForRegistration()` - Validierung für Registrierung
|
||||
- `withUpdatedTimestamp()` - Kopie mit aktualisiertem Zeitstempel
|
||||
|
||||
### Enumerationen
|
||||
|
||||
#### PferdeGeschlechtE
|
||||
|
||||
- `HENGST` - Hengst (männlich, nicht kastriert)
|
||||
- `STUTE` - Stute (weiblich)
|
||||
- `WALLACH` - Wallach (männlich, kastriert)
|
||||
|
||||
#### DatenQuelleE
|
||||
|
||||
- `MANUELL` - Manuelle Eingabe
|
||||
- `IMPORT` - Datenimport
|
||||
- `SYNCHRONISATION` - Synchronisation mit externen Systemen
|
||||
|
||||
## Repository-Operationen
|
||||
|
||||
### Erweiterte Such-Features
|
||||
|
||||
```kotlin
|
||||
// Pferde nach Identifikationsnummer suchen
|
||||
val horse = horseRepository.findByLebensnummer("AT123456789")
|
||||
val chipHorse = horseRepository.findByChipNummer("982000123456789")
|
||||
|
||||
// Pferde eines Besitzers finden
|
||||
val ownerHorses = horseRepository.findByOwnerId(ownerId, activeOnly = true)
|
||||
|
||||
// Pferde nach Eigenschaften filtern
|
||||
val stallions = horseRepository.findByGeschlecht(PferdeGeschlechtE.HENGST)
|
||||
val warmbloods = horseRepository.findByRasse("Warmblut", activeOnly = true)
|
||||
|
||||
// Pferde nach Geburtsjahr suchen
|
||||
val youngHorses = horseRepository.findByBirthYearRange(2020, 2024)
|
||||
```
|
||||
|
||||
### Registrierungs-Abfragen
|
||||
|
||||
```kotlin
|
||||
// OEPS-registrierte Pferde finden
|
||||
val oepsHorses = horseRepository.findOepsRegistered(activeOnly = true)
|
||||
|
||||
// FEI-registrierte Pferde finden
|
||||
val feiHorses = horseRepository.findFeiRegistered(activeOnly = true)
|
||||
|
||||
// Alle aktiven Pferde
|
||||
val activeHorses = horseRepository.findAllActive(limit = 1000)
|
||||
```
|
||||
|
||||
### Validierung und Duplikatsprüfung
|
||||
|
||||
```kotlin
|
||||
// Prüfung auf doppelte Identifikationsnummern
|
||||
val lebensnummerExists = horseRepository.existsByLebensnummer("AT123456789")
|
||||
val chipExists = horseRepository.existsByChipNummer("982000123456789")
|
||||
val oepsExists = horseRepository.existsByOepsNummer("AUT12345")
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### CreateHorseUseCase
|
||||
|
||||
Erstellt ein neues Pferd mit Validierung und Duplikatsprüfung.
|
||||
|
||||
```kotlin
|
||||
class CreateHorseUseCase(
|
||||
private val horseRepository: HorseRepository
|
||||
) {
|
||||
suspend fun execute(horse: DomPferd): DomPferd {
|
||||
// Validierung
|
||||
val errors = horse.validateForRegistration()
|
||||
if (errors.isNotEmpty()) {
|
||||
throw ValidationException(errors)
|
||||
}
|
||||
|
||||
// Duplikatsprüfung
|
||||
horse.lebensnummer?.let { nummer ->
|
||||
if (horseRepository.existsByLebensnummer(nummer)) {
|
||||
throw DuplicateException("Lebensnummer bereits vorhanden")
|
||||
}
|
||||
}
|
||||
|
||||
return horseRepository.save(horse)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GetHorseUseCase
|
||||
|
||||
Ruft Pferdeinformationen ab mit verschiedenen Suchkriterien.
|
||||
|
||||
### UpdateHorseUseCase
|
||||
|
||||
Aktualisiert Pferdeinformationen mit Validierung.
|
||||
|
||||
### DeleteHorseUseCase
|
||||
|
||||
Löscht ein Pferd (soft delete durch Deaktivierung).
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
Das Horses-Modul stellt REST-Endpunkte über den HorseController bereit:
|
||||
|
||||
- `GET /api/horses` - Alle aktiven Pferde abrufen
|
||||
- `GET /api/horses/{id}` - Pferd nach ID abrufen
|
||||
- `GET /api/horses/search?name={name}` - Pferde nach Namen suchen
|
||||
- `GET /api/horses/owner/{ownerId}` - Pferde eines Besitzers
|
||||
- `GET /api/horses/identification/{number}` - Pferd nach Identifikationsnummer
|
||||
- `GET /api/horses/oeps-registered` - OEPS-registrierte Pferde
|
||||
- `GET /api/horses/fei-registered` - FEI-registrierte Pferde
|
||||
- `POST /api/horses` - Neues Pferd erstellen
|
||||
- `PUT /api/horses/{id}` - Pferd aktualisieren
|
||||
- `DELETE /api/horses/{id}` - Pferd löschen
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Datenbankschema
|
||||
|
||||
Das Modul verwendet eine `horses`-Tabelle mit folgenden Spalten:
|
||||
|
||||
- `pferd_id` (UUID, Primary Key)
|
||||
- `pferde_name` (Required)
|
||||
- `geschlecht` (Enum: HENGST, STUTE, WALLACH)
|
||||
- `geburtsdatum`, `rasse`, `farbe` (Optional)
|
||||
- `besitzer_id`, `verantwortliche_person_id` (UUID, Foreign Keys)
|
||||
- `zuechter_name`, `zuchtbuch_nummer` (Optional)
|
||||
- `lebensnummer`, `chip_nummer`, `pass_nummer` (Unique, Optional)
|
||||
- `oeps_nummer`, `fei_nummer` (Unique, Optional)
|
||||
- `vater_name`, `mutter_name`, `mutter_vater_name` (Optional)
|
||||
- `stockmass` (Integer, Optional)
|
||||
- `ist_aktiv` (Boolean)
|
||||
- `bemerkungen` (Text, Optional)
|
||||
- `daten_quelle` (Enum)
|
||||
- `created_at`, `updated_at` (Timestamps)
|
||||
|
||||
### Service-Konfiguration
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
horses:
|
||||
service:
|
||||
name: horses-service
|
||||
port: 8083
|
||||
database:
|
||||
url: jdbc:postgresql://localhost:5432/meldestelle
|
||||
table: horses
|
||||
validation:
|
||||
require-identification: true
|
||||
allow-duplicate-names: false
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Das Modul enthält umfassende Integrationstests:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `should create horse with valid data`() {
|
||||
// Test für Pferdeerstellung
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horses by owner`() {
|
||||
// Test für Besitzer-basierte Suche
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should validate unique identification numbers`() {
|
||||
// Test für Eindeutigkeit der Identifikationsnummern
|
||||
}
|
||||
```
|
||||
|
||||
### Test-Datenbank
|
||||
|
||||
Verwendet H2 In-Memory-Datenbank für Tests mit automatischem Schema-Setup.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
FROM openjdk:21-jre-slim
|
||||
COPY horses-service.jar app.jar
|
||||
EXPOSE 8083
|
||||
ENTRYPOINT ["java", "-jar", "/app.jar"]
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: horses-service
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: horses-service
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: horses-service
|
||||
image: meldestelle/horses-service:latest
|
||||
ports:
|
||||
- containerPort: 8083
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Metriken
|
||||
|
||||
- Anzahl aktiver Pferde
|
||||
- Anzahl registrierter Pferde (OEPS/FEI)
|
||||
- API-Response-Zeiten
|
||||
- Datenbankverbindungs-Pool
|
||||
- Validierungsfehler-Rate
|
||||
|
||||
### Health Checks
|
||||
|
||||
- Datenbankverbindung
|
||||
- Service-Verfügbarkeit
|
||||
- Speicherverbrauch
|
||||
- Externe System-Verbindungen
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Lokale Entwicklung
|
||||
|
||||
```bash
|
||||
# Service starten
|
||||
./gradlew :horses:horses-service:bootRun
|
||||
|
||||
# Tests ausführen
|
||||
./gradlew :horses:test
|
||||
|
||||
# Integration Tests
|
||||
./gradlew :horses:horses-service:test
|
||||
```
|
||||
|
||||
### Code-Qualität
|
||||
|
||||
- **Kotlin Coding Standards**
|
||||
- **100% Test Coverage** für Domain Layer
|
||||
- **Integration Tests** für alle Use Cases
|
||||
- **API-Dokumentation** mit OpenAPI
|
||||
|
||||
## Compliance und Standards
|
||||
|
||||
### OEPS-Integration
|
||||
|
||||
- Unterstützung für OEPS-Nummern
|
||||
- Validierung nach OEPS-Standards
|
||||
- Synchronisation mit OEPS-Datenbank
|
||||
|
||||
### FEI-Integration
|
||||
|
||||
- Unterstützung für FEI-Nummern
|
||||
- Internationale Registrierungsstandards
|
||||
- Compliance mit FEI-Regularien
|
||||
|
||||
### Datenschutz
|
||||
|
||||
- DSGVO-konforme Datenhaltung
|
||||
- Anonymisierung von Testdaten
|
||||
- Audit-Trail für alle Änderungen
|
||||
|
||||
## Zukünftige Erweiterungen
|
||||
|
||||
1. **Gesundheitsdaten** - Veterinärmedizinische Aufzeichnungen
|
||||
2. **Leistungsdaten** - Turnierergebnisse und Bewertungen
|
||||
3. **Versicherungsdaten** - Integration mit Versicherungssystemen
|
||||
4. **Foto-Management** - Bildverwaltung für Pferde
|
||||
5. **Stammbaum-Visualisierung** - Grafische Darstellung der Abstammung
|
||||
6. **Import/Export** - Datenimport aus externen Systemen
|
||||
7. **Mobile App** - Mobile Anwendung für Pferdebesitzer
|
||||
8. **QR-Code-Integration** - QR-Codes für schnelle Identifikation
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung**: 25. Juli 2025
|
||||
|
||||
Für weitere Informationen zur Gesamtarchitektur siehe [README.md](../../README.md).
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.spring)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
|
||||
// Das Ktor-Plugin wird hier nicht benötigt, da Ktor als Bibliothek in Spring Boot läuft.
|
||||
// Das 'application'-Plugin wird vom Spring Boot Plugin bereitgestellt.
|
||||
alias(libs.plugins.spring.boot)
|
||||
alias(libs.plugins.spring.dependencyManagement)
|
||||
}
|
||||
|
||||
// Der springBoot-Block konfiguriert die Anwendung, wenn sie als JAR-Datei ausgeführt wird.
|
||||
springBoot {
|
||||
mainClass.set("at.mocode.horses.api.ApplicationKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Interne Module
|
||||
implementation(projects.platform.platformDependencies)
|
||||
implementation(projects.horses.horsesDomain)
|
||||
implementation(projects.horses.horsesApplication)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
|
||||
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
|
||||
|
||||
// Spring dependencies
|
||||
implementation(libs.spring.web)
|
||||
|
||||
// Ktor Server (als embedded Server in Spring)
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.netty)
|
||||
implementation(libs.ktor.server.contentNegotiation)
|
||||
implementation(libs.ktor.server.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.server.statusPages)
|
||||
implementation(libs.ktor.server.auth)
|
||||
implementation(libs.ktor.server.authJwt)
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.ktor.server.tests)
|
||||
testImplementation(libs.spring.boot.starter.test)
|
||||
}
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.api.rest
|
||||
|
||||
import at.mocode.core.domain.model.ApiResponse
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.horses.application.usecase.CreateHorseUseCase
|
||||
import at.mocode.horses.application.usecase.DeleteHorseUseCase
|
||||
import at.mocode.horses.application.usecase.GetHorseUseCase
|
||||
import at.mocode.horses.application.usecase.UpdateHorseUseCase
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.utils.validation.ApiValidationUtils
|
||||
import kotlin.uuid.Uuid
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* REST API controller for horse registry operations.
|
||||
*
|
||||
* This controller provides HTTP endpoints for all horse-related operations
|
||||
* following REST conventions and proper HTTP status codes.
|
||||
*/
|
||||
class HorseController(
|
||||
private val horseRepository: HorseRepository
|
||||
) {
|
||||
|
||||
private val getHorseUseCase = GetHorseUseCase(horseRepository)
|
||||
private val createHorseUseCase = CreateHorseUseCase(horseRepository)
|
||||
private val updateHorseUseCase = UpdateHorseUseCase(horseRepository)
|
||||
private val deleteHorseUseCase = DeleteHorseUseCase(horseRepository)
|
||||
|
||||
/**
|
||||
* Configures the horse-related routes.
|
||||
*/
|
||||
fun configureRoutes(routing: Routing) {
|
||||
routing.route("/api/horses") {
|
||||
|
||||
// GET /api/horses - Get all horses with optional filtering
|
||||
get {
|
||||
try {
|
||||
// Validate query parameters
|
||||
val validationErrors = ApiValidationUtils.validateQueryParameters(
|
||||
limit = call.request.queryParameters["limit"],
|
||||
search = call.request.queryParameters["search"]
|
||||
)
|
||||
|
||||
if (!ApiValidationUtils.isValid(validationErrors)) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
|
||||
)
|
||||
return@get
|
||||
}
|
||||
|
||||
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
|
||||
val limit = call.request.queryParameters["limit"]?.toInt() ?: 100
|
||||
val ownerId = call.request.queryParameters["ownerId"]?.let {
|
||||
ApiValidationUtils.validateUuidString(it) ?: return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse.error<Any>("Invalid ownerId format")
|
||||
)
|
||||
}
|
||||
val geschlecht = call.request.queryParameters["geschlecht"]?.let {
|
||||
try {
|
||||
PferdeGeschlechtE.valueOf(it)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return@get call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse.error<Any>("Invalid geschlecht value. Valid values: ${PferdeGeschlechtE.entries.joinToString(", ")}")
|
||||
)
|
||||
}
|
||||
}
|
||||
val rasse = call.request.queryParameters["rasse"]
|
||||
val searchTerm = call.request.queryParameters["search"]
|
||||
|
||||
val horses = when {
|
||||
searchTerm != null -> getHorseUseCase.searchByName(searchTerm, limit)
|
||||
ownerId != null -> getHorseUseCase.getByOwnerId(ownerId, activeOnly)
|
||||
geschlecht != null -> getHorseUseCase.getByGeschlecht(geschlecht, activeOnly, limit)
|
||||
rasse != null -> getHorseUseCase.getByRasse(rasse, activeOnly, limit)
|
||||
else -> getHorseUseCase.getAllActive(limit)
|
||||
}
|
||||
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve horses: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/{id} - Get horse by ID
|
||||
get("/{id}") {
|
||||
try {
|
||||
val horseId = Uuid.parse(call.parameters["id"]!!)
|
||||
val horse = getHorseUseCase.getById(horseId)
|
||||
|
||||
if (horse != null) {
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse not found"))
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/search/lebensnummer/{nummer} - Find by life number
|
||||
get("/search/lebensnummer/{nummer}") {
|
||||
try {
|
||||
val lebensnummer = call.parameters["nummer"]!!
|
||||
val horse = getHorseUseCase.getByLebensnummer(lebensnummer)
|
||||
|
||||
if (horse != null) {
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with life number '$lebensnummer' not found"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/search/chip/{nummer} - Find by chip number
|
||||
get("/search/chip/{nummer}") {
|
||||
try {
|
||||
val chipNummer = call.parameters["nummer"]!!
|
||||
val horse = getHorseUseCase.getByChipNummer(chipNummer)
|
||||
|
||||
if (horse != null) {
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with chip number '$chipNummer' not found"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/search/passport/{nummer} - Find by passport number
|
||||
get("/search/passport/{nummer}") {
|
||||
try {
|
||||
val passNummer = call.parameters["nummer"]!!
|
||||
val horse = getHorseUseCase.getByPassNummer(passNummer)
|
||||
|
||||
if (horse != null) {
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with passport number '$passNummer' not found"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/search/oeps/{nummer} - Find by OEPS number
|
||||
get("/search/oeps/{nummer}") {
|
||||
try {
|
||||
val oepsNummer = call.parameters["nummer"]!!
|
||||
val horse = getHorseUseCase.getByOepsNummer(oepsNummer)
|
||||
|
||||
if (horse != null) {
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with OEPS number '$oepsNummer' not found"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/search/fei/{nummer} - Find by FEI number
|
||||
get("/search/fei/{nummer}") {
|
||||
try {
|
||||
val feiNummer = call.parameters["nummer"]!!
|
||||
val horse = getHorseUseCase.getByFeiNummer(feiNummer)
|
||||
|
||||
if (horse != null) {
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with FEI number '$feiNummer' not found"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/oeps-registered - Get OEPS registered horses
|
||||
get("/oeps-registered") {
|
||||
try {
|
||||
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
|
||||
val horses = getHorseUseCase.getOepsRegistered(activeOnly)
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve OEPS horses: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/fei-registered - Get FEI registered horses
|
||||
get("/fei-registered") {
|
||||
try {
|
||||
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
|
||||
val horses = getHorseUseCase.getFeiRegistered(activeOnly)
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve FEI horses: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/horses/stats - Get horse statistics
|
||||
get("/stats") {
|
||||
try {
|
||||
val activeCount = getHorseUseCase.countActive()
|
||||
val oepsCount = getHorseUseCase.countOepsRegistered(true)
|
||||
val feiCount = getHorseUseCase.countFeiRegistered(true)
|
||||
|
||||
val stats = HorseStats(
|
||||
totalActive = activeCount,
|
||||
oepsRegistered = oepsCount,
|
||||
feiRegistered = feiCount
|
||||
)
|
||||
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(stats))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve statistics: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/horses - Create new horse
|
||||
post {
|
||||
try {
|
||||
val createRequest = call.receive<CreateHorseUseCase.CreateHorseRequest>()
|
||||
|
||||
// Validate input using shared validation utilities
|
||||
val validationErrors = ApiValidationUtils.validateHorseRequest(
|
||||
pferdeName = createRequest.pferdeName,
|
||||
lebensnummer = createRequest.lebensnummer,
|
||||
chipNummer = createRequest.chipNummer,
|
||||
oepsNummer = createRequest.oepsNummer,
|
||||
feiNummer = createRequest.feiNummer
|
||||
)
|
||||
|
||||
if (!ApiValidationUtils.isValid(validationErrors)) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
val response = createHorseUseCase.execute(createRequest)
|
||||
|
||||
if (response.success) {
|
||||
call.respond(HttpStatusCode.Created, ApiResponse.success(response.data!!))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Validation failed"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to create horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/horses/{id} - Update horse
|
||||
put("/{id}") {
|
||||
try {
|
||||
val horseId = Uuid.parse(call.parameters["id"]!!)
|
||||
val updateData = call.receive<UpdateHorseRequest>()
|
||||
|
||||
// Validate input using shared validation utilities
|
||||
val validationErrors = ApiValidationUtils.validateHorseRequest(
|
||||
pferdeName = updateData.pferdeName,
|
||||
lebensnummer = updateData.lebensnummer,
|
||||
chipNummer = updateData.chipNummer,
|
||||
oepsNummer = updateData.oepsNummer,
|
||||
feiNummer = updateData.feiNummer
|
||||
)
|
||||
|
||||
if (!ApiValidationUtils.isValid(validationErrors)) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
|
||||
)
|
||||
return@put
|
||||
}
|
||||
|
||||
val updateRequest = UpdateHorseUseCase.UpdateHorseRequest(
|
||||
pferdId = horseId,
|
||||
pferdeName = updateData.pferdeName,
|
||||
geschlecht = updateData.geschlecht,
|
||||
geburtsdatum = updateData.geburtsdatum,
|
||||
rasse = updateData.rasse,
|
||||
farbe = updateData.farbe,
|
||||
besitzerId = updateData.besitzerId,
|
||||
verantwortlichePersonId = updateData.verantwortlichePersonId,
|
||||
zuechterName = updateData.zuechterName,
|
||||
zuchtbuchNummer = updateData.zuchtbuchNummer,
|
||||
lebensnummer = updateData.lebensnummer,
|
||||
chipNummer = updateData.chipNummer,
|
||||
passNummer = updateData.passNummer,
|
||||
oepsNummer = updateData.oepsNummer,
|
||||
feiNummer = updateData.feiNummer,
|
||||
vaterName = updateData.vaterName,
|
||||
mutterName = updateData.mutterName,
|
||||
mutterVaterName = updateData.mutterVaterName,
|
||||
stockmass = updateData.stockmass,
|
||||
istAktiv = updateData.istAktiv,
|
||||
bemerkungen = updateData.bemerkungen,
|
||||
datenQuelle = updateData.datenQuelle
|
||||
)
|
||||
|
||||
val response = updateHorseUseCase.execute(updateRequest)
|
||||
|
||||
if (response.success && response.horse != null) {
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(response.horse))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Update failed: ${response.errors.joinToString(", ")}"))
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to update horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/horses/{id} - Delete horse
|
||||
delete("/{id}") {
|
||||
try {
|
||||
val horseId = Uuid.parse(call.parameters["id"]!!)
|
||||
val forceDelete = call.request.queryParameters["force"]?.toBoolean() ?: false
|
||||
|
||||
val deleteRequest = DeleteHorseUseCase.DeleteHorseRequest(horseId, forceDelete)
|
||||
val response = deleteHorseUseCase.execute(deleteRequest)
|
||||
|
||||
if (response.success) {
|
||||
val message = if (response.warnings.isNotEmpty()) {
|
||||
"Horse deleted successfully. Warnings: ${response.warnings.joinToString(", ")}"
|
||||
} else {
|
||||
"Horse deleted successfully"
|
||||
}
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(message))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Delete failed: ${response.errors.joinToString(", ")}"))
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to delete horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/horses/{id}/soft-delete - Soft delete horse (mark as inactive)
|
||||
post("/{id}/soft-delete") {
|
||||
try {
|
||||
val horseId = Uuid.parse(call.parameters["id"]!!)
|
||||
val response = deleteHorseUseCase.softDelete(horseId)
|
||||
|
||||
if (response.success) {
|
||||
val message = if (response.warnings.isNotEmpty()) {
|
||||
"Horse marked as inactive. Warnings: ${response.warnings.joinToString(", ")}"
|
||||
} else {
|
||||
"Horse marked as inactive"
|
||||
}
|
||||
call.respond(HttpStatusCode.OK, ApiResponse.success(message))
|
||||
} else {
|
||||
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Soft delete failed: ${response.errors.joinToString(", ")}"))
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to soft delete horse: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/horses/batch-delete - Batch delete multiple horses
|
||||
post("/batch-delete") {
|
||||
try {
|
||||
val batchRequest = call.receive<BatchDeleteRequest>()
|
||||
val response = deleteHorseUseCase.batchDelete(batchRequest.horseIds, batchRequest.forceDelete)
|
||||
|
||||
val statusCode = if (response.overallSuccess) HttpStatusCode.OK else HttpStatusCode.PartialContent
|
||||
call.respond(statusCode, ApiResponse.success(response))
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to batch delete horses: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating horse data via API.
|
||||
*/
|
||||
@Serializable
|
||||
data class UpdateHorseRequest(
|
||||
val pferdeName: String,
|
||||
val geschlecht: PferdeGeschlechtE,
|
||||
val geburtsdatum: kotlinx.datetime.LocalDate? = null,
|
||||
val rasse: String? = null,
|
||||
val farbe: String? = null,
|
||||
@Contextual val besitzerId: Uuid? = null,
|
||||
@Contextual val verantwortlichePersonId: Uuid? = null,
|
||||
val zuechterName: String? = null,
|
||||
val zuchtbuchNummer: String? = null,
|
||||
val lebensnummer: String? = null,
|
||||
val chipNummer: String? = null,
|
||||
val passNummer: String? = null,
|
||||
val oepsNummer: String? = null,
|
||||
val feiNummer: String? = null,
|
||||
val vaterName: String? = null,
|
||||
val mutterName: String? = null,
|
||||
val mutterVaterName: String? = null,
|
||||
val stockmass: Int? = null,
|
||||
val istAktiv: Boolean = true,
|
||||
val bemerkungen: String? = null,
|
||||
val datenQuelle: at.mocode.core.domain.model.DatenQuelleE = at.mocode.core.domain.model.DatenQuelleE.MANUELL
|
||||
)
|
||||
|
||||
/**
|
||||
* DTO for batch delete request.
|
||||
*/
|
||||
@Serializable
|
||||
data class BatchDeleteRequest(
|
||||
val horseIds: List<@Contextual Uuid>,
|
||||
val forceDelete: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* DTO for horse statistics.
|
||||
*/
|
||||
@Serializable
|
||||
data class HorseStats(
|
||||
val totalActive: Long,
|
||||
val oepsRegistered: Long,
|
||||
val feiRegistered: Long
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.horses.horsesDomain)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.application.usecase
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
import at.mocode.core.domain.model.ApiResponse
|
||||
import at.mocode.core.domain.model.ErrorDto
|
||||
import at.mocode.core.domain.model.ValidationResult
|
||||
import at.mocode.core.domain.model.ValidationError
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
/**
|
||||
* Use case for creating a new horse in the registry.
|
||||
*
|
||||
* This use case handles the business logic for horse registration including
|
||||
* validation, uniqueness checks, and persistence.
|
||||
*/
|
||||
class CreateHorseUseCase(
|
||||
private val horseRepository: HorseRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Request data for creating a new horse.
|
||||
*/
|
||||
data class CreateHorseRequest(
|
||||
val pferdeName: String,
|
||||
val geschlecht: PferdeGeschlechtE,
|
||||
val geburtsdatum: LocalDate? = null,
|
||||
val rasse: String? = null,
|
||||
val farbe: String? = null,
|
||||
val besitzerId: Uuid? = null,
|
||||
val verantwortlichePersonId: Uuid? = null,
|
||||
val zuechterName: String? = null,
|
||||
val zuchtbuchNummer: String? = null,
|
||||
val lebensnummer: String? = null,
|
||||
val chipNummer: String? = null,
|
||||
val passNummer: String? = null,
|
||||
val oepsNummer: String? = null,
|
||||
val feiNummer: String? = null,
|
||||
val vaterName: String? = null,
|
||||
val mutterName: String? = null,
|
||||
val mutterVaterName: String? = null,
|
||||
val stockmass: Int? = null,
|
||||
val bemerkungen: String? = null,
|
||||
val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* Executes the horse creation use case.
|
||||
*
|
||||
* @param request The horse creation request data
|
||||
* @return ApiResponse with the created horse or validation errors
|
||||
*/
|
||||
suspend fun execute(request: CreateHorseRequest): ApiResponse<DomPferd> {
|
||||
// Create domain object
|
||||
val horse = DomPferd(
|
||||
pferdeName = request.pferdeName,
|
||||
geschlecht = request.geschlecht,
|
||||
geburtsdatum = request.geburtsdatum,
|
||||
rasse = request.rasse,
|
||||
farbe = request.farbe,
|
||||
besitzerId = request.besitzerId,
|
||||
verantwortlichePersonId = request.verantwortlichePersonId,
|
||||
zuechterName = request.zuechterName,
|
||||
zuchtbuchNummer = request.zuchtbuchNummer,
|
||||
lebensnummer = request.lebensnummer,
|
||||
chipNummer = request.chipNummer,
|
||||
passNummer = request.passNummer,
|
||||
oepsNummer = request.oepsNummer,
|
||||
feiNummer = request.feiNummer,
|
||||
vaterName = request.vaterName,
|
||||
mutterName = request.mutterName,
|
||||
mutterVaterName = request.mutterVaterName,
|
||||
stockmass = request.stockmass,
|
||||
bemerkungen = request.bemerkungen,
|
||||
datenQuelle = request.datenQuelle
|
||||
)
|
||||
|
||||
// Validate the horse
|
||||
val validationResult = validateHorse(horse)
|
||||
if (!validationResult.isValid()) {
|
||||
val errors = (validationResult as ValidationResult.Invalid).errors
|
||||
return ApiResponse(
|
||||
success = false,
|
||||
data = null,
|
||||
error = ErrorDto(
|
||||
code = "VALIDATION_ERROR",
|
||||
message = "Horse validation failed",
|
||||
details = errors.associate { it.field to it.message }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check for uniqueness constraints
|
||||
val uniquenessResult = checkUniquenessConstraints(horse)
|
||||
if (!uniquenessResult.isValid()) {
|
||||
val errors = (uniquenessResult as ValidationResult.Invalid).errors
|
||||
return ApiResponse(
|
||||
success = false,
|
||||
data = null,
|
||||
error = ErrorDto(
|
||||
code = "UNIQUENESS_ERROR",
|
||||
message = "Horse uniqueness validation failed",
|
||||
details = errors.associate { it.field to it.message }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Save the horse
|
||||
val savedHorse = horseRepository.save(horse)
|
||||
|
||||
return ApiResponse(
|
||||
success = true,
|
||||
data = savedHorse,
|
||||
message = "Horse created successfully"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the horse data according to business rules.
|
||||
*/
|
||||
private fun validateHorse(horse: DomPferd): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Use domain validation
|
||||
val domainErrors = horse.validateForRegistration()
|
||||
domainErrors.forEach { errorMessage ->
|
||||
errors.add(ValidationError("horse", errorMessage, "DOMAIN_VALIDATION"))
|
||||
}
|
||||
|
||||
// Additional business validations
|
||||
horse.stockmass?.let { height ->
|
||||
if (height < 50 || height > 220) {
|
||||
errors.add(ValidationError("stockmass", "Horse height must be between 50 and 220 cm", "INVALID_RANGE"))
|
||||
}
|
||||
}
|
||||
|
||||
horse.geburtsdatum?.let { birthDate ->
|
||||
val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year
|
||||
if (birthDate.year > currentYear) {
|
||||
errors.add(ValidationError("geburtsdatum", "Birth date cannot be in the future", "FUTURE_DATE"))
|
||||
}
|
||||
if (birthDate.year < (currentYear - 50)) {
|
||||
errors.add(ValidationError("geburtsdatum", "Birth date cannot be more than 50 years ago", "TOO_OLD"))
|
||||
}
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks uniqueness constraints for identification numbers.
|
||||
*/
|
||||
private suspend fun checkUniquenessConstraints(horse: DomPferd): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Check lebensnummer uniqueness
|
||||
horse.lebensnummer?.let { lebensnummer ->
|
||||
if (lebensnummer.isNotBlank() && horseRepository.existsByLebensnummer(lebensnummer)) {
|
||||
errors.add(ValidationError("lebensnummer", "A horse with this life number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check chip number uniqueness
|
||||
horse.chipNummer?.let { chipNummer ->
|
||||
if (chipNummer.isNotBlank() && horseRepository.existsByChipNummer(chipNummer)) {
|
||||
errors.add(ValidationError("chipNummer", "A horse with this chip number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check passport number uniqueness
|
||||
horse.passNummer?.let { passNummer ->
|
||||
if (passNummer.isNotBlank() && horseRepository.existsByPassNummer(passNummer)) {
|
||||
errors.add(ValidationError("passNummer", "A horse with this passport number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check OEPS number uniqueness
|
||||
horse.oepsNummer?.let { oepsNummer ->
|
||||
if (oepsNummer.isNotBlank() && horseRepository.existsByOepsNummer(oepsNummer)) {
|
||||
errors.add(ValidationError("oepsNummer", "A horse with this OEPS number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check FEI number uniqueness
|
||||
horse.feiNummer?.let { feiNummer ->
|
||||
if (feiNummer.isNotBlank() && horseRepository.existsByFeiNummer(feiNummer)) {
|
||||
errors.add(ValidationError("feiNummer", "A horse with this FEI number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.application.usecase
|
||||
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Use case for deleting a horse from the registry.
|
||||
*
|
||||
* This use case handles the business logic for horse deletion including
|
||||
* existence checks and business rule validation.
|
||||
*/
|
||||
class DeleteHorseUseCase(
|
||||
private val horseRepository: HorseRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Request data for deleting a horse.
|
||||
*/
|
||||
data class DeleteHorseRequest(
|
||||
val pferdId: Uuid,
|
||||
val forceDelete: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Response data for horse deletion.
|
||||
*/
|
||||
data class DeleteHorseResponse(
|
||||
val success: Boolean,
|
||||
val errors: List<String> = emptyList(),
|
||||
val warnings: List<String> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* Executes the horse deletion use case.
|
||||
*
|
||||
* @param request The horse deletion request data
|
||||
* @return DeleteHorseResponse indicating success or failure with errors
|
||||
*/
|
||||
suspend fun execute(request: DeleteHorseRequest): DeleteHorseResponse {
|
||||
// Check if horse exists
|
||||
val existingHorse = horseRepository.findById(request.pferdId)
|
||||
?: return DeleteHorseResponse(
|
||||
success = false,
|
||||
errors = listOf("Horse not found")
|
||||
)
|
||||
|
||||
// Check business rules for deletion
|
||||
val businessRuleErrors = checkBusinessRules(request, existingHorse.pferdeName)
|
||||
if (businessRuleErrors.isNotEmpty() && !request.forceDelete) {
|
||||
return DeleteHorseResponse(
|
||||
success = false,
|
||||
errors = businessRuleErrors
|
||||
)
|
||||
}
|
||||
|
||||
// Generate warnings for important information
|
||||
val warnings = generateWarnings(existingHorse.pferdeName, existingHorse.oepsNummer, existingHorse.feiNummer)
|
||||
|
||||
// Perform the deletion
|
||||
val deleted = horseRepository.delete(request.pferdId)
|
||||
|
||||
return if (deleted) {
|
||||
DeleteHorseResponse(
|
||||
success = true,
|
||||
warnings = warnings
|
||||
)
|
||||
} else {
|
||||
DeleteHorseResponse(
|
||||
success = false,
|
||||
errors = listOf("Failed to delete horse from database")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete alternative - marks horse as inactive instead of deleting.
|
||||
*/
|
||||
suspend fun softDelete(pferdId: Uuid): DeleteHorseResponse {
|
||||
val existingHorse = horseRepository.findById(pferdId)
|
||||
?: return DeleteHorseResponse(
|
||||
success = false,
|
||||
errors = listOf("Horse not found")
|
||||
)
|
||||
|
||||
if (!existingHorse.istAktiv) {
|
||||
return DeleteHorseResponse(
|
||||
success = false,
|
||||
errors = listOf("Horse is already inactive")
|
||||
)
|
||||
}
|
||||
|
||||
// Mark as inactive
|
||||
val inactiveHorse = existingHorse.copy(istAktiv = false).withUpdatedTimestamp()
|
||||
horseRepository.save(inactiveHorse)
|
||||
|
||||
return DeleteHorseResponse(
|
||||
success = true,
|
||||
warnings = listOf("Horse marked as inactive instead of deleted")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks business rules that might prevent deletion.
|
||||
*/
|
||||
private suspend fun checkBusinessRules(request: DeleteHorseRequest, horseName: String): List<String> {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// In a real system, you would check for:
|
||||
// - Active competitions/entries
|
||||
// - Historical records that should be preserved
|
||||
// - Breeding records
|
||||
// - License dependencies
|
||||
|
||||
// For now, we'll implement basic checks
|
||||
|
||||
// Example: Check if horse has OEPS or FEI registration
|
||||
val horse = horseRepository.findById(request.pferdId)
|
||||
if (horse != null) {
|
||||
if (horse.isOepsRegistered() && !request.forceDelete) {
|
||||
errors.add("Cannot delete OEPS registered horse without force delete flag")
|
||||
}
|
||||
|
||||
if (horse.isFeiRegistered() && !request.forceDelete) {
|
||||
errors.add("Cannot delete FEI registered horse without force delete flag")
|
||||
}
|
||||
|
||||
// Check if horse has breeding information (might be important for pedigree)
|
||||
if ((horse.vaterName != null || horse.mutterName != null) && !request.forceDelete) {
|
||||
errors.add("Horse has pedigree information that might be referenced by other horses")
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates warnings about the deletion.
|
||||
*/
|
||||
private fun generateWarnings(horseName: String, oepsNummer: String?, feiNummer: String?): List<String> {
|
||||
val warnings = mutableListOf<String>()
|
||||
|
||||
warnings.add("Horse '$horseName' will be permanently deleted")
|
||||
|
||||
if (!oepsNummer.isNullOrBlank()) {
|
||||
warnings.add("OEPS registration number '$oepsNummer' will be lost")
|
||||
}
|
||||
|
||||
if (!feiNummer.isNullOrBlank()) {
|
||||
warnings.add("FEI registration number '$feiNummer' will be lost")
|
||||
}
|
||||
|
||||
warnings.add("This action cannot be undone")
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete multiple horses.
|
||||
*/
|
||||
suspend fun batchDelete(horseIds: List<Uuid>, forceDelete: Boolean = false): BatchDeleteResponse {
|
||||
val results = mutableListOf<DeleteResult>()
|
||||
var successCount = 0
|
||||
var errorCount = 0
|
||||
|
||||
for (horseId in horseIds) {
|
||||
val request = DeleteHorseRequest(horseId, forceDelete)
|
||||
val response = execute(request)
|
||||
|
||||
results.add(
|
||||
DeleteResult(
|
||||
horseId = horseId,
|
||||
success = response.success,
|
||||
errors = response.errors,
|
||||
warnings = response.warnings
|
||||
)
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
successCount++
|
||||
} else {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
return BatchDeleteResponse(
|
||||
results = results,
|
||||
successCount = successCount,
|
||||
errorCount = errorCount,
|
||||
totalCount = horseIds.size
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result for individual horse deletion in batch operation.
|
||||
*/
|
||||
data class DeleteResult(
|
||||
val horseId: Uuid,
|
||||
val success: Boolean,
|
||||
val errors: List<String> = emptyList(),
|
||||
val warnings: List<String> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* Response for batch delete operation.
|
||||
*/
|
||||
data class BatchDeleteResponse(
|
||||
val results: List<DeleteResult>,
|
||||
val successCount: Int,
|
||||
val errorCount: Int,
|
||||
val totalCount: Int
|
||||
) {
|
||||
val overallSuccess: Boolean = errorCount == 0
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.application.usecase
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
/**
|
||||
* Use case for retrieving horse information.
|
||||
*
|
||||
* This use case encapsulates the business logic for fetching horse data
|
||||
* and provides a clean interface for the application layer.
|
||||
*/
|
||||
class GetHorseUseCase(
|
||||
private val horseRepository: HorseRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Retrieves a horse by its unique ID.
|
||||
*
|
||||
* @param horseId The unique identifier of the horse
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun getById(horseId: Uuid): DomPferd? {
|
||||
return horseRepository.findById(horseId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a horse by its life number.
|
||||
*
|
||||
* @param lebensnummer The life number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun getByLebensnummer(lebensnummer: String): DomPferd? {
|
||||
require(lebensnummer.isNotBlank()) { "Life number cannot be blank" }
|
||||
return horseRepository.findByLebensnummer(lebensnummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a horse by its chip number.
|
||||
*
|
||||
* @param chipNummer The chip number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun getByChipNummer(chipNummer: String): DomPferd? {
|
||||
require(chipNummer.isNotBlank()) { "Chip number cannot be blank" }
|
||||
return horseRepository.findByChipNummer(chipNummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a horse by its passport number.
|
||||
*
|
||||
* @param passNummer The passport number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun getByPassNummer(passNummer: String): DomPferd? {
|
||||
require(passNummer.isNotBlank()) { "Passport number cannot be blank" }
|
||||
return horseRepository.findByPassNummer(passNummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a horse by its OEPS number.
|
||||
*
|
||||
* @param oepsNummer The OEPS number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun getByOepsNummer(oepsNummer: String): DomPferd? {
|
||||
require(oepsNummer.isNotBlank()) { "OEPS number cannot be blank" }
|
||||
return horseRepository.findByOepsNummer(oepsNummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a horse by its FEI number.
|
||||
*
|
||||
* @param feiNummer The FEI number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun getByFeiNummer(feiNummer: String): DomPferd? {
|
||||
require(feiNummer.isNotBlank()) { "FEI number cannot be blank" }
|
||||
return horseRepository.findByFeiNummer(feiNummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for horses by name (partial match).
|
||||
*
|
||||
* @param searchTerm The search term to match against horse names
|
||||
* @param limit Maximum number of results to return (default: 50)
|
||||
* @return List of matching horses
|
||||
*/
|
||||
suspend fun searchByName(searchTerm: String, limit: Int = 50): List<DomPferd> {
|
||||
require(searchTerm.isNotBlank()) { "Search term cannot be blank" }
|
||||
require(limit > 0) { "Limit must be positive" }
|
||||
return horseRepository.findByName(searchTerm.trim(), limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all horses owned by a specific person.
|
||||
*
|
||||
* @param ownerId The ID of the owner
|
||||
* @param activeOnly Whether to return only active horses (default: true)
|
||||
* @return List of horses owned by the person
|
||||
*/
|
||||
suspend fun getByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): List<DomPferd> {
|
||||
return horseRepository.findByOwnerId(ownerId, activeOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all horses for which a person is responsible.
|
||||
*
|
||||
* @param responsiblePersonId The ID of the responsible person
|
||||
* @param activeOnly Whether to return only active horses (default: true)
|
||||
* @return List of horses for which the person is responsible
|
||||
*/
|
||||
suspend fun getByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean = true): List<DomPferd> {
|
||||
return horseRepository.findByResponsiblePersonId(responsiblePersonId, activeOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves horses by gender.
|
||||
*
|
||||
* @param geschlecht The gender to filter by
|
||||
* @param activeOnly Whether to return only active horses (default: true)
|
||||
* @param limit Maximum number of results to return (default: 100)
|
||||
* @return List of horses with the specified gender
|
||||
*/
|
||||
suspend fun getByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean = true, limit: Int = 100): List<DomPferd> {
|
||||
require(limit > 0) { "Limit must be positive" }
|
||||
return horseRepository.findByGeschlecht(geschlecht, activeOnly, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves horses by breed.
|
||||
*
|
||||
* @param rasse The breed to filter by
|
||||
* @param activeOnly Whether to return only active horses (default: true)
|
||||
* @param limit Maximum number of results to return (default: 100)
|
||||
* @return List of horses of the specified breed
|
||||
*/
|
||||
suspend fun getByRasse(rasse: String, activeOnly: Boolean = true, limit: Int = 100): List<DomPferd> {
|
||||
require(rasse.isNotBlank()) { "Breed cannot be blank" }
|
||||
require(limit > 0) { "Limit must be positive" }
|
||||
return horseRepository.findByRasse(rasse.trim(), activeOnly, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves horses by birth year.
|
||||
*
|
||||
* @param birthYear The birth year to filter by
|
||||
* @param activeOnly Whether to return only active horses (default: true)
|
||||
* @return List of horses born in the specified year
|
||||
*/
|
||||
suspend fun getByBirthYear(birthYear: Int, activeOnly: Boolean = true): List<DomPferd> {
|
||||
require(birthYear > 1900) { "Birth year must be after 1900" }
|
||||
require(birthYear <= kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year) {
|
||||
"Birth year cannot be in the future"
|
||||
}
|
||||
return horseRepository.findByBirthYear(birthYear, activeOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves horses by birth year range.
|
||||
*
|
||||
* @param fromYear The start year (inclusive)
|
||||
* @param toYear The end year (inclusive)
|
||||
* @param activeOnly Whether to return only active horses (default: true)
|
||||
* @return List of horses born within the specified year range
|
||||
*/
|
||||
suspend fun getByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean = true): List<DomPferd> {
|
||||
require(fromYear > 1900) { "From year must be after 1900" }
|
||||
require(toYear >= fromYear) { "To year must be greater than or equal to from year" }
|
||||
val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year
|
||||
require(toYear <= currentYear) { "To year cannot be in the future" }
|
||||
|
||||
return horseRepository.findByBirthYearRange(fromYear, toYear, activeOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all active horses.
|
||||
*
|
||||
* @param limit Maximum number of results to return (default: 1000)
|
||||
* @return List of active horses
|
||||
*/
|
||||
suspend fun getAllActive(limit: Int = 1000): List<DomPferd> {
|
||||
require(limit > 0) { "Limit must be positive" }
|
||||
return horseRepository.findAllActive(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves horses with OEPS registration.
|
||||
*
|
||||
* @param activeOnly Whether to return only active horses (default: true)
|
||||
* @return List of OEPS registered horses
|
||||
*/
|
||||
suspend fun getOepsRegistered(activeOnly: Boolean = true): List<DomPferd> {
|
||||
return horseRepository.findOepsRegistered(activeOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves horses with FEI registration.
|
||||
*
|
||||
* @param activeOnly Whether to return only active horses (default: true)
|
||||
* @return List of FEI registered horses
|
||||
*/
|
||||
suspend fun getFeiRegistered(activeOnly: Boolean = true): List<DomPferd> {
|
||||
return horseRepository.findFeiRegistered(activeOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given life number exists.
|
||||
*
|
||||
* @param lebensnummer The life number to check
|
||||
* @return true if a horse with this life number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByLebensnummer(lebensnummer: String): Boolean {
|
||||
require(lebensnummer.isNotBlank()) { "Life number cannot be blank" }
|
||||
return horseRepository.existsByLebensnummer(lebensnummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given chip number exists.
|
||||
*
|
||||
* @param chipNummer The chip number to check
|
||||
* @return true if a horse with this chip number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByChipNummer(chipNummer: String): Boolean {
|
||||
require(chipNummer.isNotBlank()) { "Chip number cannot be blank" }
|
||||
return horseRepository.existsByChipNummer(chipNummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given passport number exists.
|
||||
*
|
||||
* @param passNummer The passport number to check
|
||||
* @return true if a horse with this passport number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByPassNummer(passNummer: String): Boolean {
|
||||
require(passNummer.isNotBlank()) { "Passport number cannot be blank" }
|
||||
return horseRepository.existsByPassNummer(passNummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given OEPS number exists.
|
||||
*
|
||||
* @param oepsNummer The OEPS number to check
|
||||
* @return true if a horse with this OEPS number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByOepsNummer(oepsNummer: String): Boolean {
|
||||
require(oepsNummer.isNotBlank()) { "OEPS number cannot be blank" }
|
||||
return horseRepository.existsByOepsNummer(oepsNummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given FEI number exists.
|
||||
*
|
||||
* @param feiNummer The FEI number to check
|
||||
* @return true if a horse with this FEI number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByFeiNummer(feiNummer: String): Boolean {
|
||||
require(feiNummer.isNotBlank()) { "FEI number cannot be blank" }
|
||||
return horseRepository.existsByFeiNummer(feiNummer.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the total number of active horses.
|
||||
*
|
||||
* @return The total count of active horses
|
||||
*/
|
||||
suspend fun countActive(): Long {
|
||||
return horseRepository.countActive()
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts horses by owner.
|
||||
*
|
||||
* @param ownerId The ID of the owner
|
||||
* @param activeOnly Whether to count only active horses (default: true)
|
||||
* @return The count of horses owned by the person
|
||||
*/
|
||||
suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): Long {
|
||||
return horseRepository.countByOwnerId(ownerId, activeOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts horses with OEPS registration.
|
||||
*
|
||||
* @param activeOnly Whether to count only active horses (default: true)
|
||||
* @return The count of OEPS registered horses
|
||||
*/
|
||||
suspend fun countOepsRegistered(activeOnly: Boolean = true): Long {
|
||||
return horseRepository.countOepsRegistered(activeOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts horses with FEI registration.
|
||||
*
|
||||
* @param activeOnly Whether to count only active horses (default: true)
|
||||
* @return The count of FEI registered horses
|
||||
*/
|
||||
suspend fun countFeiRegistered(activeOnly: Boolean = true): Long {
|
||||
return horseRepository.countFeiRegistered(activeOnly)
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.application.usecase
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
import at.mocode.core.domain.model.ApiResponse
|
||||
import at.mocode.core.domain.model.ErrorDto
|
||||
import at.mocode.core.domain.model.ValidationResult
|
||||
import at.mocode.core.domain.model.ValidationError
|
||||
import at.mocode.core.utils.database.DatabaseFactory
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
/**
|
||||
* Transactional version of CreateHorseUseCase that ensures all database operations
|
||||
* run within a single transaction to maintain data consistency.
|
||||
*
|
||||
* This use case handles the business logic for horse registration including
|
||||
* validation, uniqueness checks, and persistence - all within a single transaction.
|
||||
*/
|
||||
class TransactionalCreateHorseUseCase(
|
||||
private val horseRepository: HorseRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Request data for creating a new horse.
|
||||
*/
|
||||
data class CreateHorseRequest(
|
||||
val pferdeName: String,
|
||||
val geschlecht: PferdeGeschlechtE,
|
||||
val geburtsdatum: LocalDate? = null,
|
||||
val rasse: String? = null,
|
||||
val farbe: String? = null,
|
||||
val besitzerId: Uuid? = null,
|
||||
val verantwortlichePersonId: Uuid? = null,
|
||||
val zuechterName: String? = null,
|
||||
val zuchtbuchNummer: String? = null,
|
||||
val lebensnummer: String? = null,
|
||||
val chipNummer: String? = null,
|
||||
val passNummer: String? = null,
|
||||
val oepsNummer: String? = null,
|
||||
val feiNummer: String? = null,
|
||||
val vaterName: String? = null,
|
||||
val mutterName: String? = null,
|
||||
val mutterVaterName: String? = null,
|
||||
val stockmass: Int? = null,
|
||||
val bemerkungen: String? = null,
|
||||
val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL
|
||||
)
|
||||
|
||||
/**
|
||||
* Executes the horse creation use case within a single transaction.
|
||||
*
|
||||
* @param request The horse creation request data
|
||||
* @return ApiResponse with the created horse or validation errors
|
||||
*/
|
||||
suspend fun execute(request: CreateHorseRequest): ApiResponse<DomPferd> {
|
||||
println("[DEBUG_LOG] TransactionalCreateHorseUseCase.execute() called for horse: ${request.pferdeName}")
|
||||
|
||||
// Wrap the entire use case logic in a single transaction
|
||||
return DatabaseFactory.dbQuery {
|
||||
println("[DEBUG_LOG] Inside transaction for horse: ${request.pferdeName}")
|
||||
// Create domain object
|
||||
val horse = DomPferd(
|
||||
pferdeName = request.pferdeName,
|
||||
geschlecht = request.geschlecht,
|
||||
geburtsdatum = request.geburtsdatum,
|
||||
rasse = request.rasse,
|
||||
farbe = request.farbe,
|
||||
besitzerId = request.besitzerId,
|
||||
verantwortlichePersonId = request.verantwortlichePersonId,
|
||||
zuechterName = request.zuechterName,
|
||||
zuchtbuchNummer = request.zuchtbuchNummer,
|
||||
lebensnummer = request.lebensnummer,
|
||||
chipNummer = request.chipNummer,
|
||||
passNummer = request.passNummer,
|
||||
oepsNummer = request.oepsNummer,
|
||||
feiNummer = request.feiNummer,
|
||||
vaterName = request.vaterName,
|
||||
mutterName = request.mutterName,
|
||||
mutterVaterName = request.mutterVaterName,
|
||||
stockmass = request.stockmass,
|
||||
bemerkungen = request.bemerkungen,
|
||||
datenQuelle = request.datenQuelle
|
||||
)
|
||||
|
||||
// Validate the horse
|
||||
println("[DEBUG_LOG] Starting validation for horse: ${horse.pferdeName}")
|
||||
val validationResult = validateHorse(horse)
|
||||
if (!validationResult.isValid()) {
|
||||
val errors = (validationResult as ValidationResult.Invalid).errors
|
||||
println("[DEBUG_LOG] Validation failed for horse: ${horse.pferdeName}, errors: ${errors.map { "${it.field}: ${it.message}" }}")
|
||||
return@dbQuery ApiResponse(
|
||||
success = false,
|
||||
data = null,
|
||||
error = ErrorDto(
|
||||
code = "VALIDATION_ERROR",
|
||||
message = "Horse validation failed",
|
||||
details = errors.associate { it.field to it.message }
|
||||
)
|
||||
)
|
||||
}
|
||||
println("[DEBUG_LOG] Validation passed for horse: ${horse.pferdeName}")
|
||||
|
||||
// Check for uniqueness constraints - all within the same transaction
|
||||
println("[DEBUG_LOG] Starting uniqueness check for horse: ${horse.pferdeName}")
|
||||
val uniquenessResult = checkUniquenessConstraints(horse)
|
||||
if (!uniquenessResult.isValid()) {
|
||||
val errors = (uniquenessResult as ValidationResult.Invalid).errors
|
||||
println("[DEBUG_LOG] Uniqueness check failed for horse: ${horse.pferdeName}, errors: ${errors.map { "${it.field}: ${it.message}" }}")
|
||||
return@dbQuery ApiResponse(
|
||||
success = false,
|
||||
data = null,
|
||||
error = ErrorDto(
|
||||
code = "UNIQUENESS_ERROR",
|
||||
message = "Horse uniqueness validation failed",
|
||||
details = errors.associate { it.field to it.message }
|
||||
)
|
||||
)
|
||||
}
|
||||
println("[DEBUG_LOG] Uniqueness check passed for horse: ${horse.pferdeName}")
|
||||
|
||||
// Save the horse - still within the same transaction
|
||||
println("[DEBUG_LOG] Saving horse: ${horse.pferdeName}")
|
||||
try {
|
||||
val savedHorse = horseRepository.save(horse)
|
||||
println("[DEBUG_LOG] Horse saved successfully: ${savedHorse.pferdeName} with ID: ${savedHorse.pferdId}")
|
||||
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = savedHorse,
|
||||
message = "Horse created successfully"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
println("[DEBUG_LOG] Database constraint violation for horse: ${horse.pferdeName}, error: ${e.message}")
|
||||
|
||||
// Handle database constraint violations (duplicate keys)
|
||||
if (e.message?.contains("unique", ignoreCase = true) == true ||
|
||||
e.message?.contains("duplicate", ignoreCase = true) == true) {
|
||||
|
||||
// Determine which field caused the constraint violation
|
||||
val constraintField = when {
|
||||
e.message?.contains("lebensnummer", ignoreCase = true) == true -> "lebensnummer"
|
||||
e.message?.contains("chip_nummer", ignoreCase = true) == true -> "chipNummer"
|
||||
e.message?.contains("pass_nummer", ignoreCase = true) == true -> "passNummer"
|
||||
e.message?.contains("oeps_nummer", ignoreCase = true) == true -> "oepsNummer"
|
||||
e.message?.contains("fei_nummer", ignoreCase = true) == true -> "feiNummer"
|
||||
else -> "identification"
|
||||
}
|
||||
|
||||
ApiResponse(
|
||||
success = false,
|
||||
data = null,
|
||||
error = ErrorDto(
|
||||
code = "UNIQUENESS_ERROR",
|
||||
message = "Horse uniqueness validation failed due to database constraint",
|
||||
details = mapOf(constraintField to "A horse with this ${constraintField} already exists")
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Re-throw other exceptions
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the horse data according to business rules.
|
||||
*/
|
||||
private fun validateHorse(horse: DomPferd): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Use domain validation
|
||||
val domainErrors = horse.validateForRegistration()
|
||||
domainErrors.forEach { errorMessage ->
|
||||
errors.add(ValidationError("horse", errorMessage, "DOMAIN_VALIDATION"))
|
||||
}
|
||||
|
||||
// Additional business validations
|
||||
horse.stockmass?.let { height ->
|
||||
if (height < 50 || height > 220) {
|
||||
errors.add(ValidationError("stockmass", "Horse height must be between 50 and 220 cm", "INVALID_RANGE"))
|
||||
}
|
||||
}
|
||||
|
||||
horse.geburtsdatum?.let { birthDate ->
|
||||
val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year
|
||||
if (birthDate.year > currentYear) {
|
||||
errors.add(ValidationError("geburtsdatum", "Birth date cannot be in the future", "FUTURE_DATE"))
|
||||
}
|
||||
if (birthDate.year < (currentYear - 50)) {
|
||||
errors.add(ValidationError("geburtsdatum", "Birth date cannot be more than 50 years ago", "TOO_OLD"))
|
||||
}
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks uniqueness constraints for identification numbers.
|
||||
* Note: This method is called within a transaction, so all repository calls
|
||||
* will use the same transaction context.
|
||||
*/
|
||||
private suspend fun checkUniquenessConstraints(horse: DomPferd): ValidationResult {
|
||||
val errors = mutableListOf<ValidationError>()
|
||||
|
||||
// Check lebensnummer uniqueness
|
||||
horse.lebensnummer?.let { lebensnummer ->
|
||||
if (lebensnummer.isNotBlank() && horseRepository.existsByLebensnummer(lebensnummer)) {
|
||||
errors.add(ValidationError("lebensnummer", "A horse with this life number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check chip number uniqueness
|
||||
horse.chipNummer?.let { chipNummer ->
|
||||
if (chipNummer.isNotBlank() && horseRepository.existsByChipNummer(chipNummer)) {
|
||||
errors.add(ValidationError("chipNummer", "A horse with this chip number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check passport number uniqueness
|
||||
horse.passNummer?.let { passNummer ->
|
||||
if (passNummer.isNotBlank() && horseRepository.existsByPassNummer(passNummer)) {
|
||||
errors.add(ValidationError("passNummer", "A horse with this passport number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check OEPS number uniqueness
|
||||
horse.oepsNummer?.let { oepsNummer ->
|
||||
if (oepsNummer.isNotBlank() && horseRepository.existsByOepsNummer(oepsNummer)) {
|
||||
errors.add(ValidationError("oepsNummer", "A horse with this OEPS number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check FEI number uniqueness
|
||||
horse.feiNummer?.let { feiNummer ->
|
||||
if (feiNummer.isNotBlank() && horseRepository.existsByFeiNummer(feiNummer)) {
|
||||
errors.add(ValidationError("feiNummer", "A horse with this FEI number already exists", "DUPLICATE"))
|
||||
}
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Valid
|
||||
} else {
|
||||
ValidationResult.Invalid(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.application.usecase
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
/**
|
||||
* Use case for updating an existing horse in the registry.
|
||||
*
|
||||
* This use case handles the business logic for horse updates including
|
||||
* validation, uniqueness checks, and persistence.
|
||||
*/
|
||||
class UpdateHorseUseCase(
|
||||
private val horseRepository: HorseRepository
|
||||
) {
|
||||
|
||||
/**
|
||||
* Request data for updating a horse.
|
||||
*/
|
||||
data class UpdateHorseRequest(
|
||||
val pferdId: Uuid,
|
||||
val pferdeName: String,
|
||||
val geschlecht: PferdeGeschlechtE,
|
||||
val geburtsdatum: LocalDate? = null,
|
||||
val rasse: String? = null,
|
||||
val farbe: String? = null,
|
||||
val besitzerId: Uuid? = null,
|
||||
val verantwortlichePersonId: Uuid? = null,
|
||||
val zuechterName: String? = null,
|
||||
val zuchtbuchNummer: String? = null,
|
||||
val lebensnummer: String? = null,
|
||||
val chipNummer: String? = null,
|
||||
val passNummer: String? = null,
|
||||
val oepsNummer: String? = null,
|
||||
val feiNummer: String? = null,
|
||||
val vaterName: String? = null,
|
||||
val mutterName: String? = null,
|
||||
val mutterVaterName: String? = null,
|
||||
val stockmass: Int? = null,
|
||||
val istAktiv: Boolean = true,
|
||||
val bemerkungen: String? = null,
|
||||
val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL
|
||||
)
|
||||
|
||||
/**
|
||||
* Response data for horse update.
|
||||
*/
|
||||
data class UpdateHorseResponse(
|
||||
val horse: DomPferd?,
|
||||
val success: Boolean,
|
||||
val errors: List<String> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* Executes the horse update use case.
|
||||
*
|
||||
* @param request The horse update request data
|
||||
* @return UpdateHorseResponse with the updated horse or validation errors
|
||||
*/
|
||||
suspend fun execute(request: UpdateHorseRequest): UpdateHorseResponse {
|
||||
// Check if horse exists
|
||||
val existingHorse = horseRepository.findById(request.pferdId)
|
||||
?: return UpdateHorseResponse(
|
||||
horse = null,
|
||||
success = false,
|
||||
errors = listOf("Horse not found")
|
||||
)
|
||||
|
||||
// Create updated domain object
|
||||
val updatedHorse = existingHorse.copy(
|
||||
pferdeName = request.pferdeName,
|
||||
geschlecht = request.geschlecht,
|
||||
geburtsdatum = request.geburtsdatum,
|
||||
rasse = request.rasse,
|
||||
farbe = request.farbe,
|
||||
besitzerId = request.besitzerId,
|
||||
verantwortlichePersonId = request.verantwortlichePersonId,
|
||||
zuechterName = request.zuechterName,
|
||||
zuchtbuchNummer = request.zuchtbuchNummer,
|
||||
lebensnummer = request.lebensnummer,
|
||||
chipNummer = request.chipNummer,
|
||||
passNummer = request.passNummer,
|
||||
oepsNummer = request.oepsNummer,
|
||||
feiNummer = request.feiNummer,
|
||||
vaterName = request.vaterName,
|
||||
mutterName = request.mutterName,
|
||||
mutterVaterName = request.mutterVaterName,
|
||||
stockmass = request.stockmass,
|
||||
istAktiv = request.istAktiv,
|
||||
bemerkungen = request.bemerkungen,
|
||||
datenQuelle = request.datenQuelle
|
||||
)
|
||||
|
||||
// Validate the updated horse
|
||||
val validationErrors = validateHorse(updatedHorse)
|
||||
if (validationErrors.isNotEmpty()) {
|
||||
return UpdateHorseResponse(
|
||||
horse = updatedHorse,
|
||||
success = false,
|
||||
errors = validationErrors
|
||||
)
|
||||
}
|
||||
|
||||
// Check for uniqueness constraints (excluding current horse)
|
||||
val uniquenessErrors = checkUniquenessConstraints(updatedHorse, existingHorse)
|
||||
if (uniquenessErrors.isNotEmpty()) {
|
||||
return UpdateHorseResponse(
|
||||
horse = updatedHorse,
|
||||
success = false,
|
||||
errors = uniquenessErrors
|
||||
)
|
||||
}
|
||||
|
||||
// Save the updated horse
|
||||
val savedHorse = horseRepository.save(updatedHorse)
|
||||
|
||||
return UpdateHorseResponse(
|
||||
horse = savedHorse,
|
||||
success = true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the horse data according to business rules.
|
||||
*/
|
||||
private fun validateHorse(horse: DomPferd): List<String> {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Basic validation
|
||||
if (horse.pferdeName.isBlank()) {
|
||||
errors.add("Horse name is required")
|
||||
}
|
||||
|
||||
// Height validation
|
||||
horse.stockmass?.let { height ->
|
||||
if (height < 50 || height > 220) {
|
||||
errors.add("Horse height must be between 50 and 220 cm")
|
||||
}
|
||||
}
|
||||
|
||||
// Birth date validation
|
||||
horse.geburtsdatum?.let { birthDate ->
|
||||
val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year
|
||||
if (birthDate.year > currentYear) {
|
||||
errors.add("Birth date cannot be in the future")
|
||||
}
|
||||
if (birthDate.year < (currentYear - 50)) {
|
||||
errors.add("Birth date cannot be more than 50 years ago")
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks uniqueness constraints for identification numbers, excluding the current horse.
|
||||
*/
|
||||
private suspend fun checkUniquenessConstraints(updatedHorse: DomPferd, existingHorse: DomPferd): List<String> {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
// Check lebensnummer uniqueness (if changed)
|
||||
updatedHorse.lebensnummer?.let { lebensnummer ->
|
||||
if (lebensnummer.isNotBlank() &&
|
||||
lebensnummer != existingHorse.lebensnummer &&
|
||||
horseRepository.existsByLebensnummer(lebensnummer)) {
|
||||
errors.add("A horse with this life number already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// Check chip number uniqueness (if changed)
|
||||
updatedHorse.chipNummer?.let { chipNummer ->
|
||||
if (chipNummer.isNotBlank() &&
|
||||
chipNummer != existingHorse.chipNummer &&
|
||||
horseRepository.existsByChipNummer(chipNummer)) {
|
||||
errors.add("A horse with this chip number already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// Check passport number uniqueness (if changed)
|
||||
updatedHorse.passNummer?.let { passNummer ->
|
||||
if (passNummer.isNotBlank() &&
|
||||
passNummer != existingHorse.passNummer &&
|
||||
horseRepository.existsByPassNummer(passNummer)) {
|
||||
errors.add("A horse with this passport number already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// Check OEPS number uniqueness (if changed)
|
||||
updatedHorse.oepsNummer?.let { oepsNummer ->
|
||||
if (oepsNummer.isNotBlank() &&
|
||||
oepsNummer != existingHorse.oepsNummer &&
|
||||
horseRepository.existsByOepsNummer(oepsNummer)) {
|
||||
errors.add("A horse with this OEPS number already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// Check FEI number uniqueness (if changed)
|
||||
updatedHorse.feiNummer?.let { feiNummer ->
|
||||
if (feiNummer.isNotBlank() &&
|
||||
feiNummer != existingHorse.feiNummer &&
|
||||
horseRepository.existsByFeiNummer(feiNummer)) {
|
||||
errors.add("A horse with this FEI number already exists")
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.domain.model
|
||||
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
import at.mocode.core.domain.serialization.KotlinInstantSerializer
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.todayIn
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Domain model representing a horse in the registry system.
|
||||
*
|
||||
* This entity contains all essential information about a horse including
|
||||
* identification, ownership, breeding information, and administrative data.
|
||||
* It serves as the core aggregate root for the horse-registry bounded context.
|
||||
*
|
||||
* @property pferdId Unique internal identifier for this horse (UUID).
|
||||
* @property pferdeName Name of the horse.
|
||||
* @property geschlecht Gender of the horse (Hengst, Stute, Wallach).
|
||||
* @property geburtsdatum Birth date of the horse.
|
||||
* @property rasse Breed of the horse.
|
||||
* @property farbe Color/coat of the horse.
|
||||
* @property besitzerId ID of the current owner (Person from member-management context).
|
||||
* @property verantwortlichePersonId ID of the responsible person (trainer, rider, etc.).
|
||||
* @property zuechterName Name of the breeder.
|
||||
* @property zuchtbuchNummer Studbook number if registered.
|
||||
* @property lebensnummer Life number (unique identification number).
|
||||
* @property chipNummer Microchip number for identification.
|
||||
* @property passNummer Passport number.
|
||||
* @property oepsNummer OEPS (Austrian Equestrian Federation) number.
|
||||
* @property feiNummer FEI (International Equestrian Federation) number.
|
||||
* @property vaterName Name of the sire (father).
|
||||
* @property mutterName Name of the dam (mother).
|
||||
* @property mutterVaterName Name of the maternal grandsire.
|
||||
* @property stockmass Height of the horse in cm.
|
||||
* @property istAktiv Whether the horse is currently active in the system.
|
||||
* @property bemerkungen Additional notes or comments.
|
||||
* @property datenQuelle Source of the data (manual entry, import, etc.).
|
||||
* @property createdAt Timestamp when this record was created.
|
||||
* @property updatedAt Timestamp when this record was last updated.
|
||||
*/
|
||||
@Serializable
|
||||
data class DomPferd(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val pferdId: Uuid = Uuid.random(),
|
||||
|
||||
// Basic Information
|
||||
var pferdeName: String,
|
||||
var geschlecht: PferdeGeschlechtE,
|
||||
var geburtsdatum: LocalDate? = null,
|
||||
var rasse: String? = null,
|
||||
var farbe: String? = null,
|
||||
|
||||
// Ownership and Responsibility
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
var besitzerId: Uuid? = null,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
var verantwortlichePersonId: Uuid? = null,
|
||||
|
||||
// Breeding Information
|
||||
var zuechterName: String? = null,
|
||||
var zuchtbuchNummer: String? = null,
|
||||
|
||||
// Identification Numbers
|
||||
var lebensnummer: String? = null,
|
||||
var chipNummer: String? = null,
|
||||
var passNummer: String? = null,
|
||||
var oepsNummer: String? = null,
|
||||
var feiNummer: String? = null,
|
||||
|
||||
// Pedigree Information
|
||||
var vaterName: String? = null,
|
||||
var mutterName: String? = null,
|
||||
var mutterVaterName: String? = null,
|
||||
|
||||
// Physical Characteristics
|
||||
var stockmass: Int? = null, // Height in cm
|
||||
|
||||
// Status and Administrative
|
||||
var istAktiv: Boolean = true,
|
||||
var bemerkungen: String? = null,
|
||||
var datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL,
|
||||
|
||||
// Audit Fields
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
val createdAt: Instant = Clock.System.now(),
|
||||
@Serializable(with = KotlinInstantSerializer::class)
|
||||
var updatedAt: Instant = Clock.System.now()
|
||||
) {
|
||||
/**
|
||||
* Returns the display name for the horse, combining name and birth year if available.
|
||||
*/
|
||||
fun getDisplayName(): String {
|
||||
return geburtsdatum?.let { birthDate ->
|
||||
"$pferdeName (${birthDate.year})"
|
||||
} ?: pferdeName
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the horse has complete identification information.
|
||||
*/
|
||||
fun hasCompleteIdentification(): Boolean {
|
||||
return !lebensnummer.isNullOrBlank() ||
|
||||
!chipNummer.isNullOrBlank() ||
|
||||
!passNummer.isNullOrBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the horse is registered with OEPS.
|
||||
*/
|
||||
fun isOepsRegistered(): Boolean {
|
||||
return !oepsNummer.isNullOrBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the horse is registered with FEI.
|
||||
*/
|
||||
fun isFeiRegistered(): Boolean {
|
||||
return !feiNummer.isNullOrBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the age of the horse in years, or null if birth date is unknown.
|
||||
*/
|
||||
fun getAge(): Int? {
|
||||
return geburtsdatum?.let { birthDate ->
|
||||
val today = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault())
|
||||
var age = today.year - birthDate.year
|
||||
|
||||
// Check if birthday has occurred this year
|
||||
if (today.monthNumber < birthDate.monthNumber ||
|
||||
(today.monthNumber == birthDate.monthNumber && today.dayOfMonth < birthDate.dayOfMonth)) {
|
||||
age--
|
||||
}
|
||||
|
||||
age
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that required fields are present for horse registration.
|
||||
*/
|
||||
fun validateForRegistration(): List<String> {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
if (pferdeName.isBlank()) {
|
||||
errors.add("Horse name is required")
|
||||
}
|
||||
|
||||
if (!hasCompleteIdentification()) {
|
||||
errors.add("At least one identification number (life number, chip number, or passport number) is required")
|
||||
}
|
||||
|
||||
if (besitzerId == null) {
|
||||
errors.add("Owner is required")
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of this horse with updated timestamp.
|
||||
*/
|
||||
fun withUpdatedTimestamp(): DomPferd {
|
||||
return this.copy(updatedAt = Clock.System.now())
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.domain.repository
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Repository interface for DomPferd (Horse) domain operations.
|
||||
*
|
||||
* This interface defines the contract for horse data access operations
|
||||
* without depending on specific implementation details (database, etc.).
|
||||
* Following the hexagonal architecture pattern, this interface belongs
|
||||
* to the domain layer and will be implemented in the infrastructure layer.
|
||||
*/
|
||||
interface HorseRepository {
|
||||
|
||||
/**
|
||||
* Finds a horse by its unique ID.
|
||||
*
|
||||
* @param id The unique identifier of the horse
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun findById(id: Uuid): DomPferd?
|
||||
|
||||
/**
|
||||
* Finds a horse by its life number (Lebensnummer).
|
||||
*
|
||||
* @param lebensnummer The life number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun findByLebensnummer(lebensnummer: String): DomPferd?
|
||||
|
||||
/**
|
||||
* Finds a horse by its chip number.
|
||||
*
|
||||
* @param chipNummer The chip number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun findByChipNummer(chipNummer: String): DomPferd?
|
||||
|
||||
/**
|
||||
* Finds a horse by its passport number.
|
||||
*
|
||||
* @param passNummer The passport number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun findByPassNummer(passNummer: String): DomPferd?
|
||||
|
||||
/**
|
||||
* Finds a horse by its OEPS number.
|
||||
*
|
||||
* @param oepsNummer The OEPS number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun findByOepsNummer(oepsNummer: String): DomPferd?
|
||||
|
||||
/**
|
||||
* Finds a horse by its FEI number.
|
||||
*
|
||||
* @param feiNummer The FEI number to search for
|
||||
* @return The horse if found, null otherwise
|
||||
*/
|
||||
suspend fun findByFeiNummer(feiNummer: String): DomPferd?
|
||||
|
||||
/**
|
||||
* Finds horses by name (partial match).
|
||||
*
|
||||
* @param searchTerm The search term to match against horse names
|
||||
* @param limit Maximum number of results to return
|
||||
* @return List of matching horses
|
||||
*/
|
||||
suspend fun findByName(searchTerm: String, limit: Int = 50): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds all horses owned by a specific person.
|
||||
*
|
||||
* @param ownerId The ID of the owner (from member-management context)
|
||||
* @param activeOnly Whether to return only active horses
|
||||
* @return List of horses owned by the person
|
||||
*/
|
||||
suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds all horses for which a person is responsible.
|
||||
*
|
||||
* @param responsiblePersonId The ID of the responsible person
|
||||
* @param activeOnly Whether to return only active horses
|
||||
* @return List of horses for which the person is responsible
|
||||
*/
|
||||
suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean = true): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds horses by gender.
|
||||
*
|
||||
* @param geschlecht The gender to filter by
|
||||
* @param activeOnly Whether to return only active horses
|
||||
* @param limit Maximum number of results to return
|
||||
* @return List of horses with the specified gender
|
||||
*/
|
||||
suspend fun findByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean = true, limit: Int = 100): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds horses by breed.
|
||||
*
|
||||
* @param rasse The breed to filter by
|
||||
* @param activeOnly Whether to return only active horses
|
||||
* @param limit Maximum number of results to return
|
||||
* @return List of horses of the specified breed
|
||||
*/
|
||||
suspend fun findByRasse(rasse: String, activeOnly: Boolean = true, limit: Int = 100): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds horses by birth year.
|
||||
*
|
||||
* @param birthYear The birth year to filter by
|
||||
* @param activeOnly Whether to return only active horses
|
||||
* @return List of horses born in the specified year
|
||||
*/
|
||||
suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean = true): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds horses by birth year range.
|
||||
*
|
||||
* @param fromYear The start year (inclusive)
|
||||
* @param toYear The end year (inclusive)
|
||||
* @param activeOnly Whether to return only active horses
|
||||
* @return List of horses born within the specified year range
|
||||
*/
|
||||
suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean = true): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds all active horses.
|
||||
*
|
||||
* @param limit Maximum number of results to return
|
||||
* @return List of active horses
|
||||
*/
|
||||
suspend fun findAllActive(limit: Int = 1000): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds horses with OEPS registration.
|
||||
*
|
||||
* @param activeOnly Whether to return only active horses
|
||||
* @return List of OEPS registered horses
|
||||
*/
|
||||
suspend fun findOepsRegistered(activeOnly: Boolean = true): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Finds horses with FEI registration.
|
||||
*
|
||||
* @param activeOnly Whether to return only active horses
|
||||
* @return List of FEI registered horses
|
||||
*/
|
||||
suspend fun findFeiRegistered(activeOnly: Boolean = true): List<DomPferd>
|
||||
|
||||
/**
|
||||
* Saves a horse (create or update).
|
||||
*
|
||||
* @param horse The horse to save
|
||||
* @return The saved horse with updated timestamps
|
||||
*/
|
||||
suspend fun save(horse: DomPferd): DomPferd
|
||||
|
||||
/**
|
||||
* Deletes a horse by ID.
|
||||
*
|
||||
* @param id The unique identifier of the horse to delete
|
||||
* @return true if the horse was deleted, false if not found
|
||||
*/
|
||||
suspend fun delete(id: Uuid): Boolean
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given life number exists.
|
||||
*
|
||||
* @param lebensnummer The life number to check
|
||||
* @return true if a horse with this life number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByLebensnummer(lebensnummer: String): Boolean
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given chip number exists.
|
||||
*
|
||||
* @param chipNummer The chip number to check
|
||||
* @return true if a horse with this chip number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByChipNummer(chipNummer: String): Boolean
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given passport number exists.
|
||||
*
|
||||
* @param passNummer The passport number to check
|
||||
* @return true if a horse with this passport number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByPassNummer(passNummer: String): Boolean
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given OEPS number exists.
|
||||
*
|
||||
* @param oepsNummer The OEPS number to check
|
||||
* @return true if a horse with this OEPS number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByOepsNummer(oepsNummer: String): Boolean
|
||||
|
||||
/**
|
||||
* Checks if a horse with the given FEI number exists.
|
||||
*
|
||||
* @param feiNummer The FEI number to check
|
||||
* @return true if a horse with this FEI number exists, false otherwise
|
||||
*/
|
||||
suspend fun existsByFeiNummer(feiNummer: String): Boolean
|
||||
|
||||
/**
|
||||
* Counts the total number of active horses.
|
||||
*
|
||||
* @return The total count of active horses
|
||||
*/
|
||||
suspend fun countActive(): Long
|
||||
|
||||
/**
|
||||
* Counts horses by owner.
|
||||
*
|
||||
* @param ownerId The ID of the owner
|
||||
* @param activeOnly Whether to count only active horses
|
||||
* @return The count of horses owned by the person
|
||||
*/
|
||||
suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): Long
|
||||
|
||||
/**
|
||||
* Counts horses with OEPS registration.
|
||||
*
|
||||
* @param activeOnly Whether to count only active horses
|
||||
* @return The count of OEPS registered horses
|
||||
*/
|
||||
suspend fun countOepsRegistered(activeOnly: Boolean = true): Long
|
||||
|
||||
/**
|
||||
* Counts horses with FEI registration.
|
||||
*
|
||||
* @param activeOnly Whether to count only active horses
|
||||
* @return The count of FEI registered horses
|
||||
*/
|
||||
suspend fun countFeiRegistered(activeOnly: Boolean = true): Long
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
plugins {
|
||||
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.spring)
|
||||
// Das JPA-Plugin wird jetzt ebenfalls zentral verwaltet.
|
||||
alias(libs.plugins.kotlin.jpa)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Interne Module
|
||||
implementation(projects.platform.platformDependencies)
|
||||
implementation(projects.horses.horsesDomain)
|
||||
implementation(projects.horses.horsesApplication)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(projects.infrastructure.cache.cacheApi)
|
||||
implementation(projects.infrastructure.eventStore.eventStoreApi)
|
||||
implementation(projects.infrastructure.messaging.messagingClient)
|
||||
|
||||
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
|
||||
|
||||
// Spring Data JPA
|
||||
implementation(libs.spring.boot.starter.data.jpa)
|
||||
|
||||
// Datenbank-Treiber
|
||||
runtimeOnly(libs.postgresql.driver)
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.infrastructure.persistence
|
||||
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.utils.database.DatabaseFactory
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.datetime.Clock
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.statements.UpdateBuilder
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
/**
|
||||
* PostgreSQL implementation of the HorseRepository using Exposed ORM.
|
||||
*
|
||||
* This implementation provides database operations for horse entities,
|
||||
* mapping between the domain model (DomPferd) and the database table (HorseTable).
|
||||
*/
|
||||
@Repository
|
||||
class HorseRepositoryImpl : HorseRepository {
|
||||
|
||||
override suspend fun findById(id: Uuid): DomPferd? = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.id eq id }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByChipNummer(chipNummer: String): DomPferd? = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByPassNummer(passNummer: String): DomPferd? = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }
|
||||
.map { rowToDomPferd(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByName(searchTerm: String, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.pferdeName like "%$searchTerm%" }
|
||||
.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.limit(limit)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId }
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where { HorseTable.verantwortlichePersonId eq responsiblePersonId }
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where { HorseTable.geschlecht eq geschlecht }
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.limit(limit)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where { HorseTable.rasse eq rasse }
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.limit(limit)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where {
|
||||
HorseTable.geburtsdatum.isNotNull() and
|
||||
(CustomFunction(
|
||||
"EXTRACT",
|
||||
IntegerColumnType(),
|
||||
stringLiteral("YEAR FROM "),
|
||||
HorseTable.geburtsdatum
|
||||
) eq birthYear)
|
||||
}
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where {
|
||||
HorseTable.geburtsdatum.isNotNull() and
|
||||
(CustomFunction(
|
||||
"EXTRACT",
|
||||
IntegerColumnType(),
|
||||
stringLiteral("YEAR FROM "),
|
||||
HorseTable.geburtsdatum
|
||||
) greaterEq fromYear) and
|
||||
(CustomFunction(
|
||||
"EXTRACT",
|
||||
IntegerColumnType(),
|
||||
stringLiteral("YEAR FROM "),
|
||||
HorseTable.geburtsdatum
|
||||
) lessEq toYear)
|
||||
}
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.orderBy(HorseTable.geburtsdatum, SortOrder.DESC)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findAllActive(limit: Int): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
|
||||
.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.limit(limit)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findOepsRegistered(activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where { HorseTable.oepsNummer.isNotNull() }
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun findFeiRegistered(activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where { HorseTable.feiNummer.isNotNull() }
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.orderBy(HorseTable.pferdeName to SortOrder.ASC)
|
||||
.map { rowToDomPferd(it) }
|
||||
}
|
||||
|
||||
override suspend fun save(horse: DomPferd): DomPferd = DatabaseFactory.dbQuery {
|
||||
val now = Clock.System.now()
|
||||
val existingHorse = findById(horse.pferdId)
|
||||
|
||||
if (existingHorse != null) {
|
||||
// Update existing horse
|
||||
val updatedHorse = horse.copy(updatedAt = now)
|
||||
HorseTable.update({ HorseTable.id eq horse.pferdId }) {
|
||||
domPferdToStatement(it, updatedHorse)
|
||||
}
|
||||
updatedHorse
|
||||
} else {
|
||||
// Insert a new horse
|
||||
HorseTable.insert {
|
||||
it[id] = horse.pferdId
|
||||
domPferdToStatement(it, horse.copy(updatedAt = now))
|
||||
}
|
||||
horse.copy(updatedAt = now)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
|
||||
val deletedRows = HorseTable.deleteWhere { HorseTable.id eq id }
|
||||
deletedRows > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }
|
||||
.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByChipNummer(chipNummer: String): Boolean = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }
|
||||
.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByPassNummer(passNummer: String): Boolean = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }
|
||||
.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }
|
||||
.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByFeiNummer(feiNummer: String): Boolean = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }
|
||||
.count() > 0
|
||||
}
|
||||
|
||||
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
|
||||
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
|
||||
.count()
|
||||
}
|
||||
|
||||
override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId }
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.count()
|
||||
}
|
||||
|
||||
override suspend fun countOepsRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where {
|
||||
HorseTable.oepsNummer.isNotNull() and (HorseTable.oepsNummer neq "")
|
||||
}
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.count()
|
||||
}
|
||||
|
||||
override suspend fun countFeiRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
|
||||
val query = HorseTable.selectAll().where {
|
||||
HorseTable.feiNummer.isNotNull() and (HorseTable.feiNummer neq "")
|
||||
}
|
||||
|
||||
if (activeOnly) {
|
||||
query.andWhere { HorseTable.istAktiv eq true }
|
||||
} else {
|
||||
query
|
||||
}.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a database row to a DomPferd domain object.
|
||||
*/
|
||||
private fun rowToDomPferd(row: ResultRow): DomPferd {
|
||||
return DomPferd(
|
||||
pferdId = row[HorseTable.id].value,
|
||||
pferdeName = row[HorseTable.pferdeName],
|
||||
geschlecht = row[HorseTable.geschlecht],
|
||||
geburtsdatum = row[HorseTable.geburtsdatum],
|
||||
rasse = row[HorseTable.rasse],
|
||||
farbe = row[HorseTable.farbe],
|
||||
besitzerId = row[HorseTable.besitzerId],
|
||||
verantwortlichePersonId = row[HorseTable.verantwortlichePersonId],
|
||||
zuechterName = row[HorseTable.zuechterName],
|
||||
zuchtbuchNummer = row[HorseTable.zuchtbuchNummer],
|
||||
lebensnummer = row[HorseTable.lebensnummer],
|
||||
chipNummer = row[HorseTable.chipNummer],
|
||||
passNummer = row[HorseTable.passNummer],
|
||||
oepsNummer = row[HorseTable.oepsNummer],
|
||||
feiNummer = row[HorseTable.feiNummer],
|
||||
vaterName = row[HorseTable.vaterName],
|
||||
mutterName = row[HorseTable.mutterName],
|
||||
mutterVaterName = row[HorseTable.mutterVaterName],
|
||||
stockmass = row[HorseTable.stockmass],
|
||||
istAktiv = row[HorseTable.istAktiv],
|
||||
bemerkungen = row[HorseTable.bemerkungen],
|
||||
datenQuelle = row[HorseTable.datenQuelle],
|
||||
createdAt = row[HorseTable.createdAt],
|
||||
updatedAt = row[HorseTable.updatedAt]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a DomPferd domain object to database statement values.
|
||||
*/
|
||||
private fun domPferdToStatement(statement: UpdateBuilder<*>, horse: DomPferd) {
|
||||
statement[HorseTable.pferdeName] = horse.pferdeName
|
||||
statement[HorseTable.geschlecht] = horse.geschlecht
|
||||
statement[HorseTable.geburtsdatum] = horse.geburtsdatum
|
||||
statement[HorseTable.rasse] = horse.rasse
|
||||
statement[HorseTable.farbe] = horse.farbe
|
||||
statement[HorseTable.besitzerId] = horse.besitzerId
|
||||
statement[HorseTable.verantwortlichePersonId] = horse.verantwortlichePersonId
|
||||
statement[HorseTable.zuechterName] = horse.zuechterName
|
||||
statement[HorseTable.zuchtbuchNummer] = horse.zuchtbuchNummer
|
||||
statement[HorseTable.lebensnummer] = horse.lebensnummer
|
||||
statement[HorseTable.chipNummer] = horse.chipNummer
|
||||
statement[HorseTable.passNummer] = horse.passNummer
|
||||
statement[HorseTable.oepsNummer] = horse.oepsNummer
|
||||
statement[HorseTable.feiNummer] = horse.feiNummer
|
||||
statement[HorseTable.vaterName] = horse.vaterName
|
||||
statement[HorseTable.mutterName] = horse.mutterName
|
||||
statement[HorseTable.mutterVaterName] = horse.mutterVaterName
|
||||
statement[HorseTable.stockmass] = horse.stockmass
|
||||
statement[HorseTable.istAktiv] = horse.istAktiv
|
||||
statement[HorseTable.bemerkungen] = horse.bemerkungen
|
||||
statement[HorseTable.datenQuelle] = horse.datenQuelle
|
||||
statement[HorseTable.createdAt] = horse.createdAt
|
||||
statement[HorseTable.updatedAt] = horse.updatedAt
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package at.mocode.horses.infrastructure.persistence
|
||||
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
import org.jetbrains.exposed.dao.id.UUIDTable
|
||||
import org.jetbrains.exposed.sql.kotlin.datetime.date
|
||||
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
|
||||
|
||||
/**
|
||||
* Database table definition for horses in the horse-registry context.
|
||||
*
|
||||
* This table stores all horse information including identification,
|
||||
* ownership, breeding data, and administrative information.
|
||||
*/
|
||||
object HorseTable : UUIDTable("horses") {
|
||||
// Basic Information
|
||||
val pferdeName = varchar("pferde_name", 255)
|
||||
val geschlecht = enumerationByName<PferdeGeschlechtE>("geschlecht", 20)
|
||||
val geburtsdatum = date("geburtsdatum").nullable()
|
||||
val rasse = varchar("rasse", 100).nullable()
|
||||
val farbe = varchar("farbe", 100).nullable()
|
||||
|
||||
// Ownership and Responsibility
|
||||
val besitzerId = uuid("besitzer_id").nullable()
|
||||
val verantwortlichePersonId = uuid("verantwortliche_person_id").nullable()
|
||||
|
||||
// Breeding Information
|
||||
val zuechterName = varchar("zuechter_name", 255).nullable()
|
||||
val zuchtbuchNummer = varchar("zuchtbuch_nummer", 100).nullable()
|
||||
|
||||
// Identification Numbers
|
||||
val lebensnummer = varchar("lebensnummer", 50).nullable()
|
||||
val chipNummer = varchar("chip_nummer", 50).nullable()
|
||||
val passNummer = varchar("pass_nummer", 50).nullable()
|
||||
val oepsNummer = varchar("oeps_nummer", 50).nullable()
|
||||
val feiNummer = varchar("fei_nummer", 50).nullable()
|
||||
|
||||
// Pedigree Information
|
||||
val vaterName = varchar("vater_name", 255).nullable()
|
||||
val mutterName = varchar("mutter_name", 255).nullable()
|
||||
val mutterVaterName = varchar("mutter_vater_name", 255).nullable()
|
||||
|
||||
// Physical Characteristics
|
||||
val stockmass = integer("stockmass").nullable()
|
||||
|
||||
// Status and Administrative
|
||||
val istAktiv = bool("ist_aktiv").default(true)
|
||||
val bemerkungen = text("bemerkungen").nullable()
|
||||
val datenQuelle = enumerationByName<DatenQuelleE>("daten_quelle", 20).default(DatenQuelleE.MANUELL)
|
||||
|
||||
// Audit Fields
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
|
||||
init {
|
||||
// Indexes for performance
|
||||
index(false, pferdeName)
|
||||
index(false, besitzerId)
|
||||
index(false, istAktiv)
|
||||
|
||||
// Unique constraints for identification numbers
|
||||
// These ensure database-level uniqueness even under concurrent access
|
||||
uniqueIndex(lebensnummer)
|
||||
uniqueIndex(chipNummer)
|
||||
uniqueIndex(passNummer)
|
||||
uniqueIndex(oepsNummer)
|
||||
uniqueIndex(feiNummer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
plugins {
|
||||
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.spring)
|
||||
alias(libs.plugins.spring.boot)
|
||||
alias(libs.plugins.spring.dependencyManagement)
|
||||
}
|
||||
|
||||
// Der springBoot-Block konfiguriert die Anwendung, wenn sie als JAR-Datei ausgeführt wird.
|
||||
springBoot {
|
||||
mainClass.set("at.mocode.horses.service.HorsesServiceApplicationKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Interne Module
|
||||
implementation(projects.platform.platformDependencies)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.core.coreUtils)
|
||||
implementation(projects.horses.horsesDomain)
|
||||
implementation(projects.horses.horsesApplication)
|
||||
implementation(projects.horses.horsesInfrastructure)
|
||||
implementation(projects.horses.horsesApi)
|
||||
|
||||
// Infrastruktur-Clients
|
||||
implementation(projects.infrastructure.cache.redisCache)
|
||||
implementation(projects.infrastructure.messaging.messagingClient)
|
||||
implementation(projects.infrastructure.monitoring.monitoringClient)
|
||||
|
||||
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
|
||||
|
||||
// Spring Boot Starters
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
implementation(libs.spring.boot.starter.validation)
|
||||
implementation(libs.spring.boot.starter.actuator)
|
||||
|
||||
// Datenbank-Abhängigkeiten
|
||||
implementation(libs.exposed.core)
|
||||
implementation(libs.exposed.dao)
|
||||
implementation(libs.exposed.jdbc)
|
||||
implementation(libs.exposed.kotlin.datetime)
|
||||
implementation(libs.hikari.cp)
|
||||
runtimeOnly(libs.postgresql.driver)
|
||||
testRuntimeOnly(libs.h2.driver)
|
||||
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.spring.boot.starter.test)
|
||||
testImplementation(libs.logback.classic) // SLF4J provider for tests
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package at.mocode.horses.service
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.ComponentScan
|
||||
|
||||
/**
|
||||
* Main application class for the Horses Service.
|
||||
*
|
||||
* This service provides APIs for managing horses and their data.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ComponentScan(basePackages = [
|
||||
"at.mocode.horses.service",
|
||||
"at.mocode.horses.api",
|
||||
"at.mocode.horses.infrastructure",
|
||||
"at.mocode.infrastructure.messaging"
|
||||
])
|
||||
class HorsesServiceApplication
|
||||
|
||||
/**
|
||||
* Main entry point for the Horses Service application.
|
||||
*/
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<HorsesServiceApplication>(*args)
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package at.mocode.horses.service.config
|
||||
|
||||
import at.mocode.horses.application.usecase.CreateHorseUseCase
|
||||
import at.mocode.horses.application.usecase.TransactionalCreateHorseUseCase
|
||||
import at.mocode.horses.application.usecase.UpdateHorseUseCase
|
||||
import at.mocode.horses.application.usecase.DeleteHorseUseCase
|
||||
import at.mocode.horses.application.usecase.GetHorseUseCase
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/**
|
||||
* Application configuration for the Horses Service.
|
||||
*
|
||||
* This configuration wires the use cases as Spring beans.
|
||||
*/
|
||||
@Configuration
|
||||
class ApplicationConfiguration {
|
||||
|
||||
/**
|
||||
* Creates the CreateHorseUseCase as a Spring bean.
|
||||
*/
|
||||
@Bean
|
||||
fun createHorseUseCase(horseRepository: HorseRepository): CreateHorseUseCase {
|
||||
return CreateHorseUseCase(horseRepository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the TransactionalCreateHorseUseCase as a Spring bean.
|
||||
* This version ensures all database operations run within a single transaction.
|
||||
*/
|
||||
@Bean
|
||||
fun transactionalCreateHorseUseCase(horseRepository: HorseRepository): TransactionalCreateHorseUseCase {
|
||||
return TransactionalCreateHorseUseCase(horseRepository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the UpdateHorseUseCase as a Spring bean.
|
||||
*/
|
||||
@Bean
|
||||
fun updateHorseUseCase(horseRepository: HorseRepository): UpdateHorseUseCase {
|
||||
return UpdateHorseUseCase(horseRepository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the DeleteHorseUseCase as a Spring bean.
|
||||
*/
|
||||
@Bean
|
||||
fun deleteHorseUseCase(horseRepository: HorseRepository): DeleteHorseUseCase {
|
||||
return DeleteHorseUseCase(horseRepository)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the GetHorseUseCase as a Spring bean.
|
||||
*/
|
||||
@Bean
|
||||
fun getHorseUseCase(horseRepository: HorseRepository): GetHorseUseCase {
|
||||
return GetHorseUseCase(horseRepository)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package at.mocode.horses.service.config
|
||||
|
||||
import at.mocode.core.utils.database.DatabaseConfig
|
||||
import at.mocode.core.utils.database.DatabaseFactory
|
||||
import at.mocode.horses.infrastructure.persistence.HorseTable
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.stereotype.Component
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
|
||||
/**
|
||||
* Database configuration for the Horses Service.
|
||||
*
|
||||
* This configuration ensures that Database.connect() is called properly
|
||||
* before any Exposed operations are performed.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("!test")
|
||||
class HorsesDatabaseConfiguration {
|
||||
|
||||
private val log = LoggerFactory.getLogger(HorsesDatabaseConfiguration::class.java)
|
||||
|
||||
@PostConstruct
|
||||
fun initializeDatabase() {
|
||||
log.info("Initializing database schema for Horses Service...")
|
||||
|
||||
try {
|
||||
// Database connection is already initialized by the gateway
|
||||
// Only initialize the schema for this service
|
||||
transaction {
|
||||
SchemaUtils.createMissingTablesAndColumns(HorseTable)
|
||||
log.info("Horse database schema initialized successfully")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to initialize database schema", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun closeDatabase() {
|
||||
log.info("Closing database connection for Horses Service...")
|
||||
try {
|
||||
DatabaseFactory.close()
|
||||
log.info("Database connection closed successfully")
|
||||
} catch (e: Exception) {
|
||||
log.error("Error closing database connection", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-specific database configuration.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("test")
|
||||
class HorsesTestDatabaseConfiguration {
|
||||
|
||||
private val log = LoggerFactory.getLogger(HorsesTestDatabaseConfiguration::class.java)
|
||||
|
||||
@PostConstruct
|
||||
fun initializeTestDatabase() {
|
||||
log.info("Initializing test database connection for Horses Service...")
|
||||
|
||||
try {
|
||||
// Use H2 in-memory database for tests
|
||||
val testConfig = DatabaseConfig(
|
||||
jdbcUrl = "jdbc:h2:mem:horses_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||
username = "sa",
|
||||
password = "",
|
||||
driverClassName = "org.h2.Driver",
|
||||
maxPoolSize = 5,
|
||||
minPoolSize = 1,
|
||||
autoMigrate = true
|
||||
)
|
||||
|
||||
DatabaseFactory.init(testConfig)
|
||||
log.info("Test database connection initialized successfully")
|
||||
|
||||
// Initialize database schema for tests
|
||||
transaction {
|
||||
SchemaUtils.createMissingTablesAndColumns(HorseTable)
|
||||
log.info("Test horse database schema initialized successfully")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to initialize test database connection", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
fun closeTestDatabase() {
|
||||
log.info("Closing test database connection for Horses Service...")
|
||||
try {
|
||||
DatabaseFactory.close()
|
||||
log.info("Test database connection closed successfully")
|
||||
} catch (e: Exception) {
|
||||
log.error("Error closing test database connection", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package at.mocode.horses.service.integration
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.infrastructure.messaging.client.EventPublisher
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import io.mockk.mockk
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Integration tests for the Horses Service.
|
||||
*
|
||||
* These tests verify the complete functionality including:
|
||||
* - Repository operations
|
||||
* - Database persistence
|
||||
* - Domain model validation
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = [
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb",
|
||||
"spring.kafka.bootstrap-servers=localhost:9092"
|
||||
])
|
||||
@ContextConfiguration(classes = [HorseServiceIntegrationTest.TestConfig::class])
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class HorseServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var horseRepository: HorseRepository
|
||||
|
||||
@Configuration
|
||||
class TestConfig {
|
||||
@Bean
|
||||
fun eventPublisher(): EventPublisher = mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() = runBlocking {
|
||||
// Clean up database before each test
|
||||
println("[DEBUG_LOG] Setting up horse test - cleaning database")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create horse successfully`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing horse creation")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Thunder",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2020, 5, 15),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT123456789",
|
||||
chipNummer = "123456789012345",
|
||||
stockmass = 165,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
// When
|
||||
val savedHorse = horseRepository.save(horse)
|
||||
|
||||
// Then
|
||||
assertNotNull(savedHorse)
|
||||
assertEquals("Thunder", savedHorse.pferdeName)
|
||||
assertEquals(PferdeGeschlechtE.WALLACH, savedHorse.geschlecht)
|
||||
assertEquals("AT123456789", savedHorse.lebensnummer)
|
||||
assertEquals("123456789012345", savedHorse.chipNummer)
|
||||
assertEquals("Warmblut", savedHorse.rasse)
|
||||
assertTrue(savedHorse.istAktiv)
|
||||
|
||||
println("[DEBUG_LOG] Horse created successfully with ID: ${savedHorse.pferdId}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horse by lebensnummer`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find horse by lebensnummer")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Lightning",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2019, 3, 10),
|
||||
rasse = "Vollblut",
|
||||
farbe = "Schimmel",
|
||||
lebensnummer = "AT987654321",
|
||||
chipNummer = "987654321098765",
|
||||
stockmass = 160,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(horse)
|
||||
|
||||
// When
|
||||
val foundHorse = horseRepository.findByLebensnummer("AT987654321")
|
||||
|
||||
// Then
|
||||
assertNotNull(foundHorse)
|
||||
assertEquals("Lightning", foundHorse.pferdeName)
|
||||
assertEquals("AT987654321", foundHorse.lebensnummer)
|
||||
assertEquals(PferdeGeschlechtE.STUTE, foundHorse.geschlecht)
|
||||
assertEquals("Vollblut", foundHorse.rasse)
|
||||
|
||||
println("[DEBUG_LOG] Horse found by lebensnummer: ${foundHorse.pferdId}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horse by chip number`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find horse by chip number")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Storm",
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
geburtsdatum = LocalDate(2021, 8, 20),
|
||||
rasse = "Haflinger",
|
||||
farbe = "Fuchs",
|
||||
lebensnummer = "AT555666777",
|
||||
chipNummer = "555666777888999",
|
||||
stockmass = 150,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(horse)
|
||||
|
||||
// When
|
||||
val foundHorse = horseRepository.findByChipNummer("555666777888999")
|
||||
|
||||
// Then
|
||||
assertNotNull(foundHorse)
|
||||
assertEquals("Storm", foundHorse.pferdeName)
|
||||
assertEquals("555666777888999", foundHorse.chipNummer)
|
||||
assertEquals(PferdeGeschlechtE.HENGST, foundHorse.geschlecht)
|
||||
assertEquals("Haflinger", foundHorse.rasse)
|
||||
|
||||
println("[DEBUG_LOG] Horse found by chip number: ${foundHorse.pferdId}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horses by gender`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find horses by gender")
|
||||
|
||||
// Given
|
||||
val stallion = DomPferd(
|
||||
pferdeName = "Stallion Horse",
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
geburtsdatum = LocalDate(2018, 4, 12),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT111222333",
|
||||
chipNummer = "111222333444555",
|
||||
stockmass = 170,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
val mare = DomPferd(
|
||||
pferdeName = "Mare Horse",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2017, 6, 8),
|
||||
rasse = "Vollblut",
|
||||
farbe = "Rappe",
|
||||
lebensnummer = "AT444555666",
|
||||
chipNummer = "444555666777888",
|
||||
stockmass = 165,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
horseRepository.save(stallion)
|
||||
horseRepository.save(mare)
|
||||
|
||||
// When
|
||||
val stallions = horseRepository.findByGeschlecht(PferdeGeschlechtE.HENGST, true, 10)
|
||||
|
||||
// Then
|
||||
assertTrue(stallions.isNotEmpty(), "Should find at least one stallion")
|
||||
assertTrue(stallions.any { it.pferdeName == "Stallion Horse" }, "Should contain the stallion horse")
|
||||
assertTrue(stallions.all { it.geschlecht == PferdeGeschlechtE.HENGST }, "All returned horses should be stallions")
|
||||
|
||||
println("[DEBUG_LOG] Found ${stallions.size} stallions")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find horses by breed`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find horses by breed")
|
||||
|
||||
// Given
|
||||
val warmblutHorse = DomPferd(
|
||||
pferdeName = "Warmblut Horse",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2019, 9, 15),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT333444555",
|
||||
chipNummer = "333444555666777",
|
||||
stockmass = 168,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(warmblutHorse)
|
||||
|
||||
// When
|
||||
val warmblutHorses = horseRepository.findByRasse("Warmblut", true, 10)
|
||||
|
||||
// Then
|
||||
assertTrue(warmblutHorses.isNotEmpty(), "Should find at least one Warmblut horse")
|
||||
assertTrue(warmblutHorses.any { it.pferdeName == "Warmblut Horse" }, "Should contain the Warmblut horse")
|
||||
assertTrue(warmblutHorses.all { it.rasse == "Warmblut" }, "All returned horses should be Warmblut")
|
||||
|
||||
println("[DEBUG_LOG] Found ${warmblutHorses.size} Warmblut horses")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find OEPS registered horses`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find OEPS registered horses")
|
||||
|
||||
// Given
|
||||
val oepsHorse = DomPferd(
|
||||
pferdeName = "OEPS Horse",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2018, 7, 22),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT777888999",
|
||||
chipNummer = "777888999000111",
|
||||
oepsNummer = "OEPS123456",
|
||||
stockmass = 170,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
val nonOepsHorse = DomPferd(
|
||||
pferdeName = "Non-OEPS Horse",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2017, 11, 5),
|
||||
rasse = "Vollblut",
|
||||
farbe = "Rappe",
|
||||
lebensnummer = "AT000111222",
|
||||
chipNummer = "000111222333444",
|
||||
stockmass = 165,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
horseRepository.save(oepsHorse)
|
||||
horseRepository.save(nonOepsHorse)
|
||||
|
||||
// When
|
||||
val oepsHorses = horseRepository.findOepsRegistered(true)
|
||||
|
||||
// Then
|
||||
assertTrue(oepsHorses.isNotEmpty(), "Should find at least one OEPS registered horse")
|
||||
assertTrue(oepsHorses.any { it.pferdeName == "OEPS Horse" }, "Should contain the OEPS registered horse")
|
||||
assertTrue(oepsHorses.all { !it.oepsNummer.isNullOrBlank() }, "All returned horses should have OEPS numbers")
|
||||
|
||||
println("[DEBUG_LOG] Found ${oepsHorses.size} OEPS registered horses")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find FEI registered horses`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing find FEI registered horses")
|
||||
|
||||
// Given
|
||||
val feiHorse = DomPferd(
|
||||
pferdeName = "FEI Horse",
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
geburtsdatum = LocalDate(2016, 2, 14),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Schimmel",
|
||||
lebensnummer = "AT999000111",
|
||||
chipNummer = "999000111222333",
|
||||
feiNummer = "FEI789012",
|
||||
stockmass = 175,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(feiHorse)
|
||||
|
||||
// When
|
||||
val feiHorses = horseRepository.findFeiRegistered(true)
|
||||
|
||||
// Then
|
||||
assertTrue(feiHorses.isNotEmpty(), "Should find at least one FEI registered horse")
|
||||
assertTrue(feiHorses.any { it.pferdeName == "FEI Horse" }, "Should contain the FEI registered horse")
|
||||
assertTrue(feiHorses.all { !it.feiNummer.isNullOrBlank() }, "All returned horses should have FEI numbers")
|
||||
|
||||
println("[DEBUG_LOG] Found ${feiHorses.size} FEI registered horses")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should validate duplicate lebensnummer`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing duplicate lebensnummer validation")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "First Horse",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2019, 1, 1),
|
||||
rasse = "Warmblut",
|
||||
farbe = "Braun",
|
||||
lebensnummer = "AT123123123",
|
||||
chipNummer = "123123123456789",
|
||||
stockmass = 165,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(horse)
|
||||
|
||||
// When
|
||||
val exists = horseRepository.existsByLebensnummer("AT123123123")
|
||||
|
||||
// Then
|
||||
assertTrue(exists, "Should detect existing lebensnummer")
|
||||
|
||||
println("[DEBUG_LOG] Duplicate lebensnummer validation passed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should validate duplicate chip number`() = runBlocking {
|
||||
println("[DEBUG_LOG] Testing duplicate chip number validation")
|
||||
|
||||
// Given
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Chip Test Horse",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2020, 12, 25),
|
||||
rasse = "Haflinger",
|
||||
farbe = "Fuchs",
|
||||
lebensnummer = "AT456456456",
|
||||
chipNummer = "456456456789012",
|
||||
stockmass = 148,
|
||||
istAktiv = true
|
||||
)
|
||||
horseRepository.save(horse)
|
||||
|
||||
// When
|
||||
val exists = horseRepository.existsByChipNummer("456456456789012")
|
||||
|
||||
// Then
|
||||
assertTrue(exists, "Should detect existing chip number")
|
||||
|
||||
println("[DEBUG_LOG] Duplicate chip number validation passed")
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package at.mocode.horses.service.integration
|
||||
|
||||
import at.mocode.horses.domain.model.DomPferd
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* Integration tests to demonstrate and verify transaction context issues with coroutines.
|
||||
*
|
||||
* This test class reproduces the race condition that can occur when multiple
|
||||
* coroutines perform database operations without proper transaction boundaries.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = [
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop"
|
||||
])
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TransactionContextTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var horseRepository: HorseRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
runBlocking {
|
||||
// Clean up any existing test data
|
||||
// Note: This is a simplified cleanup - in a real scenario you'd have proper cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should demonstrate race condition without transaction boundaries`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting race condition test")
|
||||
|
||||
val lebensnummer = "TEST-RACE-001"
|
||||
val chipNummer = "CHIP-RACE-001"
|
||||
|
||||
// Create two horses with the same identifiers
|
||||
val horse1 = DomPferd(
|
||||
pferdeName = "Race Horse 1",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2020, 1, 1),
|
||||
lebensnummer = lebensnummer,
|
||||
chipNummer = chipNummer,
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
val horse2 = DomPferd(
|
||||
pferdeName = "Race Horse 2",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2020, 1, 2),
|
||||
lebensnummer = lebensnummer, // Same lebensnummer - should cause conflict
|
||||
chipNummer = chipNummer, // Same chipNummer - should cause conflict
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Created horses with duplicate identifiers")
|
||||
|
||||
// Simulate the use case logic: check uniqueness then save
|
||||
// This mimics what CreateHorseUseCase.execute() does without transactions
|
||||
suspend fun createHorseWithChecks(horse: DomPferd): Boolean {
|
||||
return try {
|
||||
// Check uniqueness constraints (like in checkUniquenessConstraints)
|
||||
val existsByLebensnummer = horse.lebensnummer?.let {
|
||||
horseRepository.existsByLebensnummer(it)
|
||||
} ?: false
|
||||
|
||||
val existsByChipNummer = horse.chipNummer?.let {
|
||||
horseRepository.existsByChipNummer(it)
|
||||
} ?: false
|
||||
|
||||
println("[DEBUG_LOG] ${horse.pferdeName}: existsByLebensnummer=$existsByLebensnummer, existsByChipNummer=$existsByChipNummer")
|
||||
|
||||
if (existsByLebensnummer || existsByChipNummer) {
|
||||
println("[DEBUG_LOG] ${horse.pferdeName}: Uniqueness check failed")
|
||||
false
|
||||
} else {
|
||||
// Save the horse (like in the use case)
|
||||
horseRepository.save(horse)
|
||||
println("[DEBUG_LOG] ${horse.pferdeName}: Saved successfully")
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("[DEBUG_LOG] ${horse.pferdeName}: Exception during creation: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Launch two concurrent coroutines to create horses
|
||||
val results = listOf(
|
||||
async {
|
||||
println("[DEBUG_LOG] Starting creation 1")
|
||||
createHorseWithChecks(horse1)
|
||||
},
|
||||
async {
|
||||
println("[DEBUG_LOG] Starting creation 2")
|
||||
createHorseWithChecks(horse2)
|
||||
}
|
||||
).awaitAll()
|
||||
|
||||
println("[DEBUG_LOG] Both operations completed")
|
||||
println("[DEBUG_LOG] Result 1 success: ${results[0]}")
|
||||
println("[DEBUG_LOG] Result 2 success: ${results[1]}")
|
||||
|
||||
// In a properly transactional system, exactly one should succeed
|
||||
val successCount = results.count { it }
|
||||
val failureCount = results.count { !it }
|
||||
|
||||
println("[DEBUG_LOG] Success count: $successCount, Failure count: $failureCount")
|
||||
|
||||
// Check what actually got saved in the database
|
||||
val savedByLebensnummer = horseRepository.findByLebensnummer(lebensnummer)
|
||||
val savedByChipNummer = horseRepository.findByChipNummer(chipNummer)
|
||||
|
||||
println("[DEBUG_LOG] Found by lebensnummer: ${savedByLebensnummer?.pferdeName}")
|
||||
println("[DEBUG_LOG] Found by chipNummer: ${savedByChipNummer?.pferdeName}")
|
||||
|
||||
// This test demonstrates the issue - without transactions, both operations might succeed
|
||||
// due to race conditions, or the behavior might be unpredictable
|
||||
// The fix should ensure exactly one succeeds and one fails with a proper error
|
||||
assertTrue(successCount >= 1, "At least one operation should succeed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should demonstrate transaction context propagation issue`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting transaction context propagation test")
|
||||
|
||||
// This test will show that without @Transactional, each repository call
|
||||
// runs in its own transaction context, which can lead to inconsistencies
|
||||
|
||||
val horse = DomPferd(
|
||||
pferdeName = "Transaction Test Horse",
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
lebensnummer = "TRANS-TEST-001",
|
||||
istAktiv = true
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Creating horse with repository operations")
|
||||
|
||||
// Simulate multiple repository operations that should be atomic
|
||||
val existsCheck = horseRepository.existsByLebensnummer("TRANS-TEST-001")
|
||||
println("[DEBUG_LOG] Exists check result: $existsCheck")
|
||||
|
||||
if (!existsCheck) {
|
||||
val savedHorse = horseRepository.save(horse)
|
||||
println("[DEBUG_LOG] Horse saved successfully: ${savedHorse.pferdeName}")
|
||||
assertNotNull(savedHorse)
|
||||
assertEquals("Transaction Test Horse", savedHorse.pferdeName)
|
||||
}
|
||||
|
||||
// The issue is that without @Transactional, if an exception occurs after
|
||||
// the uniqueness checks but before/during save, the database state
|
||||
// might be inconsistent
|
||||
val finalCheck = horseRepository.findByLebensnummer("TRANS-TEST-001")
|
||||
assertNotNull(finalCheck, "Horse should be saved in database")
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
package at.mocode.horses.service.integration
|
||||
|
||||
import at.mocode.horses.application.usecase.TransactionalCreateHorseUseCase
|
||||
import at.mocode.horses.domain.repository.HorseRepository
|
||||
import at.mocode.core.domain.model.PferdeGeschlechtE
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import org.springframework.test.context.TestPropertySource
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* Integration tests to verify that transaction context issues with coroutines are resolved.
|
||||
*
|
||||
* This test class verifies that the transactional use cases properly handle
|
||||
* concurrent operations and maintain data consistency.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@TestPropertySource(properties = [
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop"
|
||||
])
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TransactionalContextTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var horseRepository: HorseRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var transactionalCreateHorseUseCase: TransactionalCreateHorseUseCase
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
runBlocking {
|
||||
// Clean up any existing test data
|
||||
// Note: This is a simplified cleanup - in a real scenario you'd have proper cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle race condition properly with transaction boundaries`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting transactional race condition test")
|
||||
|
||||
val lebensnummer = "TRANS-RACE-001"
|
||||
val chipNummer = "TRANS-CHIP-001"
|
||||
|
||||
// Create two identical horse creation requests
|
||||
val ownerId = Uuid.random()
|
||||
val request1 = TransactionalCreateHorseUseCase.CreateHorseRequest(
|
||||
pferdeName = "Transactional Race Horse 1",
|
||||
geschlecht = PferdeGeschlechtE.WALLACH,
|
||||
geburtsdatum = LocalDate(2020, 1, 1),
|
||||
lebensnummer = lebensnummer,
|
||||
chipNummer = chipNummer,
|
||||
besitzerId = ownerId
|
||||
)
|
||||
|
||||
val request2 = TransactionalCreateHorseUseCase.CreateHorseRequest(
|
||||
pferdeName = "Transactional Race Horse 2",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2020, 1, 2),
|
||||
lebensnummer = lebensnummer, // Same lebensnummer - should cause conflict
|
||||
chipNummer = chipNummer, // Same chipNummer - should cause conflict
|
||||
besitzerId = ownerId
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Created requests with duplicate identifiers")
|
||||
|
||||
// Launch two concurrent coroutines to create horses using transactional use case
|
||||
val results = listOf(
|
||||
async {
|
||||
println("[DEBUG_LOG] Starting transactional creation 1")
|
||||
transactionalCreateHorseUseCase.execute(request1)
|
||||
},
|
||||
async {
|
||||
println("[DEBUG_LOG] Starting transactional creation 2")
|
||||
transactionalCreateHorseUseCase.execute(request2)
|
||||
}
|
||||
).awaitAll()
|
||||
|
||||
println("[DEBUG_LOG] Both transactional operations completed")
|
||||
println("[DEBUG_LOG] Result 1 success: ${results[0].success}")
|
||||
println("[DEBUG_LOG] Result 2 success: ${results[1].success}")
|
||||
|
||||
// With proper transaction boundaries, exactly one should succeed
|
||||
val successCount = results.count { it.success }
|
||||
val failureCount = results.count { !it.success }
|
||||
|
||||
println("[DEBUG_LOG] Success count: $successCount, Failure count: $failureCount")
|
||||
|
||||
// Verify that exactly one operation succeeded and one failed
|
||||
assertEquals(1, successCount, "Exactly one operation should succeed with proper transactions")
|
||||
assertEquals(1, failureCount, "Exactly one operation should fail with proper transactions")
|
||||
|
||||
// Check what actually got saved in the database
|
||||
val savedByLebensnummer = horseRepository.findByLebensnummer(lebensnummer)
|
||||
val savedByChipNummer = horseRepository.findByChipNummer(chipNummer)
|
||||
|
||||
println("[DEBUG_LOG] Found by lebensnummer: ${savedByLebensnummer?.pferdeName}")
|
||||
println("[DEBUG_LOG] Found by chipNummer: ${savedByChipNummer?.pferdeName}")
|
||||
|
||||
// Verify that exactly one horse was saved
|
||||
assertNotNull(savedByLebensnummer, "One horse should be saved with the lebensnummer")
|
||||
assertNotNull(savedByChipNummer, "One horse should be saved with the chipNummer")
|
||||
assertEquals(savedByLebensnummer?.pferdId, savedByChipNummer?.pferdId, "Both queries should return the same horse")
|
||||
|
||||
// Verify that the failed operation returned proper error
|
||||
val failedResult = results.find { !it.success }
|
||||
assertNotNull(failedResult, "There should be one failed result")
|
||||
assertEquals("UNIQUENESS_ERROR", failedResult?.error?.code, "Failed operation should return uniqueness error")
|
||||
|
||||
println("[DEBUG_LOG] Transactional test completed successfully - race condition properly handled")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should maintain transaction consistency on validation failure`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting transaction consistency test")
|
||||
|
||||
// Create a request with invalid data that will fail validation
|
||||
val request = TransactionalCreateHorseUseCase.CreateHorseRequest(
|
||||
pferdeName = "", // Empty name should fail validation
|
||||
geschlecht = PferdeGeschlechtE.HENGST,
|
||||
lebensnummer = "VALIDATION-TEST-001",
|
||||
stockmass = 300, // Invalid height should fail validation
|
||||
besitzerId = Uuid.random() // Add owner to pass basic validation
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Executing transactional create with invalid data")
|
||||
val result = transactionalCreateHorseUseCase.execute(request)
|
||||
|
||||
println("[DEBUG_LOG] Creation result: success=${result.success}")
|
||||
|
||||
// Verify that the operation failed due to validation
|
||||
assertTrue(!result.success, "Operation should fail due to validation errors")
|
||||
assertEquals("VALIDATION_ERROR", result.error?.code, "Should return validation error")
|
||||
|
||||
// Verify that no horse was saved in the database
|
||||
val savedHorse = horseRepository.findByLebensnummer("VALIDATION-TEST-001")
|
||||
assertTrue(savedHorse == null, "No horse should be saved when validation fails")
|
||||
|
||||
println("[DEBUG_LOG] Transaction consistency test completed - no data saved on validation failure")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should successfully create horse with valid data in transaction`(): Unit = runBlocking {
|
||||
println("[DEBUG_LOG] Starting successful transactional creation test")
|
||||
|
||||
val request = TransactionalCreateHorseUseCase.CreateHorseRequest(
|
||||
pferdeName = "Successful Transaction Horse",
|
||||
geschlecht = PferdeGeschlechtE.STUTE,
|
||||
geburtsdatum = LocalDate(2021, 6, 15),
|
||||
lebensnummer = "SUCCESS-TEST-001",
|
||||
chipNummer = "SUCCESS-CHIP-001",
|
||||
rasse = "Warmblut",
|
||||
stockmass = 165,
|
||||
besitzerId = Uuid.random() // Add required owner
|
||||
)
|
||||
|
||||
println("[DEBUG_LOG] Executing transactional create with valid data")
|
||||
val result = transactionalCreateHorseUseCase.execute(request)
|
||||
|
||||
println("[DEBUG_LOG] Creation result: success=${result.success}")
|
||||
|
||||
// Verify that the operation succeeded
|
||||
assertTrue(result.success, "Operation should succeed with valid data")
|
||||
assertNotNull(result.data, "Result should contain the created horse")
|
||||
assertEquals("Successful Transaction Horse", result.data?.pferdeName, "Horse name should match")
|
||||
|
||||
// Verify that the horse was saved in the database
|
||||
val savedHorse = horseRepository.findByLebensnummer("SUCCESS-TEST-001")
|
||||
assertNotNull(savedHorse, "Horse should be saved in database")
|
||||
assertEquals("Successful Transaction Horse", savedHorse.pferdeName, "Saved horse name should match")
|
||||
assertEquals("SUCCESS-CHIP-001", savedHorse.chipNummer, "Saved horse chip number should match")
|
||||
|
||||
println("[DEBUG_LOG] Successful transactional creation test completed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<configuration>
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
</root>
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user