done all the implementations of each parts yet

This commit is contained in:
Aboubacar SANGARE
2025-09-25 13:22:35 +00:00
parent 4b7b8649d3
commit 9ceaec5bec
42 changed files with 2816 additions and 136 deletions

317
README.md Normal file
View File

@@ -0,0 +1,317 @@
# Documentation API — PMU PLR
> **API de gestion des paris hippiques (PMU)**
> Ce projet implémente la logique complète de gestion de **chevaux**, **courses**, **paris**, **résultats** et **gains**.
---
## Table des matières
1. [Présentation générale](#présentation-générale)
2. [Installation & tests](#installation--tests)
- [Prérequis](#prérequis)
- [Clonage du projet](#clonage-du-projet)
- [Compilation & lancement](#compilation--lancement)
- [Tests avec Postman](#tests-avec-postman)
3. [Conventions API](#conventions-api)
4. [Flux typique (Happy Path)](#flux-typique-happy-path)
5. [Entités & Endpoints](#entités--endpoints)
- [Chevaux](#1-chevaux)
- [Courses & Inscriptions](#2-courses--inscriptions)
- [Paris](#3-paris)
- [Résultats](#4-résultats)
- [Gains](#5-gains)
6. [Règles métier & validations](#règles-métier--validations)
7. [Recettes de test](#recettes-de-test)
- [A) Flux complet](#a-flux-complet-end-to-end)
- [B) Validation & contraintes](#b-validation--contraintes)
- [C) Cas dex æquo (Dead-Heat)](#c-cas-dex-æquo-dead-heat)
- [D) Gestion NP/DQ](#d-gestion-npdq)
- [E) Idempotence du règlement](#e-idempotence-du-règlement)
8. [Exemples de payloads](#exemples-de-payloads)
---
## Présentation générale
Cette API gère le cycle complet du **Pari Mutuel Urbain (PMU)** :
- Gestion des **courses** (création, planification, mise à jour, clôture).
- Inscription et gestion des **chevaux** (insertion, participation, non-partant, disqualification).
- Création et mise à jour des **paris** (tous types : simple, couple, trio, quarté, quinté, multi, etc.).
- Déclaration des **résultats officiels**, avec gestion des ex æquo (Dead-Heat).
- Calcul et attribution des **gains** selon les règles PMU.
---
## Installation & tests
### Prérequis
- **Java 17+**
- **Maven**
- **PostgreSQL** (base `pmu`)
- **Postman** (pour importer la collection fournie)
### Clonage du projet
```bash
git clone https://github.com/<ton-org>/<ton-repo>.git
cd <ton-repo>
```
### Compilation & lancement
```bash
./mvnw clean install
./mvnw spring-boot:run
```
Le serveur démarre sur :
👉 http://localhost:8080
### Tests avec Postman
Importer la collection :
1. PMU PLR API - Full Test Collection.postman_collection.json
2. Définir la variable denvironnement baseUrl = http://localhost:8080
2. Exécuter les requêtes dans lordre :
- Création course → Ajout chevaux → Paris → Résultat → Règlement → Vérification gains.
## Conventions API
- Base URL : {{baseUrl}}
- Format JSON
- Enums :
- CourseStatue = **PLANIFIEE | EN_COURS | CLOTUREE | ANNULEE**
- PariType = **SIMPLE_GAGNANT | SIMPLE_PLACE | COUPLE_GAGNANT | COUPLE_ORDRE | TRIO | TRIO_ORDRE | TIERCE | QUARTE | QUINTE | QUINTE_PLUS | MULTI**
- Dead-Heat : plusieurs chevaux peuvent partager le même rang.
## Flux typique (Happy Path)
1. Créer une course (statut = PLANIFIEE).
2. Inscrire les chevaux (simple ou bulk).
3. Créer des paris (tant que la course est PLANIFIEE).
4. Déclarer les résultats officiels (Dead-Heat permis).
5. Fixer le Numéro+ pour Quinté+.
6. Régler la course (calcul des gains).
7. Vérifier les rapports et gains.
## Entités & Endpoints
### 1. Chevaux
Endpoints pour la gestion des données des chevaux, indépendamment de leur inscription à une course.
| Méthode | Endpoint | Description |
| :--- | :--- | :--- |
| `GET` | `/api/chevaux` | Lister tous les chevaux. |
| `GET` | `/api/chevaux/{id}` | Obtenir les détails d'un cheval. |
| `POST` | `/api/chevaux` | Créer un nouveau cheval. |
| `PUT` | `/api/chevaux/{id}` | Modifier les informations d'un cheval existant. |
| `DELETE` | `/api/chevaux/{id}` | Supprimer un cheval de la base de données. |
---
### 2. Courses & Inscriptions
Gérer la création et la modification des courses et des inscriptions de chevaux.
| Méthode | Endpoint | Description |
| :--- | :--- | :--- |
| `GET` | `/api/courses` | Lister toutes les courses. |
| `GET` | `/api/courses/{id}` | Obtenir les détails d'une course spécifique. |
| `GET` | `/api/courses/{STATUS: CourseStatue}` | Obtenir les courses spécifique par status (CourseStatue). |
| `POST` | `/api/courses` | Créer une nouvelle course. |
| `PUT` | `/api/courses/{id}` | Modifier une course existante. |
| `DELETE` | `/api/courses/{id}` | Supprimer une course. |
| `POST` | `/api/courses/chevaux` | Ajouter un seul cheval à une course. |
| `POST` | `/api/courses/chevaux` | Ajouter un cheval à une course en une seule requête. |
| `POST` | `/api/courses/chevaux/bulk` | Ajouter plusieurs chevaux à une course en une seule requête. |
| `GET` | `/api/courses/{id}/chevaux` | Lister les chevaux inscrites à une course. |
| `PUT` | `/api/courses/cheval-course/{id}/scratch?value={true\|false}` | (Dé)marquer un cheval comme non-partant (NP). |
| `PUT` | `/api/courses/cheval-course/{id}/disqualify?value={true\|false}` | (Dé)qualifier un cheval d'une course. |
| `DELETE` | `/api/courses/cheval-course/{id}` | Supprimer un cheval d'une course quelqu'en soit la course. |
| `DELETE` | `/api/courses/cheval-course/{id}` | Supprimer un cheval d'une course quelqu'en soit la course. |
---
### 3. Paris
Points d'accès pour la création, la modification et la gestion des paris.
| Méthode | Endpoint | Description |
| :--- | :--- | :--- |
| `POST` | `/api/paris` | Créer un nouveau pari. |
| `GET` | `/api/paris/{id}` | Obtenir le détail dun pari spécifique. |
| `PUT` | `/api/paris/{id}` | Modifier un pari existant. |
| `DELETE` | `/api/paris/{id}` | Supprimer un pari. |
| `GET` | `/api/paris` | Lister tous les paris (avec filtres possibles). |
| `GET` | `/api/paris/course/{courseId}` | Obtenir la liste de tous les paris pour une course spécifique. |
| `POST` | `/api/paris/course/settle/{courseId}` | Régler tous les paris d'une course après officialisation des résultats. |
| `GET` | `/api/paris/course/report/{courseId}` | Générer un rapport détaillé des paris d'une course. |
| `GET` | `/api/gains/pari/{pariId}` | Vérifier le gain associé à un pari donné. |
| `GET` | `/api/gains/course/{courseId}` | Lister tous les gains générés pour une course après règlement. |
---
### 4. Résultats
Gérer les résultats officiels et le classement d'une course.
| Méthode | Endpoint | Description |
| :--- | :--- | :--- |
| `POST` | `/api/resultats` | Créer un résultat pour une course. |
| `GET` | `/api/resultats` | Lister tous les résultats. |
| `GET` | `/api/resultats/course/{courseId}` | Récupérer le résultat dune course. |
| `PUT` | `/api/resultats/course/{courseId}` | Mettre à jour le résultat dune course. |
| `DELETE` | `/api/resultats/course/{courseId}` | Supprimer le résultat dune course. |
| `GET` | `/api/resultats/course/{courseId}/chevaux` | Lister les chevaux avec leurs rangs. |
| `POST` | `/api/resultats/course/{courseId}/chevaux` | Ajouter un cheval dans le résultat. |
| `PUT` | `/api/resultats/course/{courseId}/chevaux/{chevalId}` | Mettre à jour ou insérer le rang dun cheval. |
| `DELETE` | `/api/resultats/course/{courseId}/chevaux/{chevalId}` | Supprimer un cheval du résultat. |
| `PATCH` | `/api/resultats/course/{courseId}/numero-plus` | Fixer le **Numéro+ gagnant** (Quinté+). |
| `PUT` | `/api/resultats/course/{courseId}/numero-plus/draw` | Tirer au sort automatiquement le Numéro+. |
---
### 5. Gains
Points d'accès pour la vérification des gains et la génération de rapports.
| Méthode | Endpoint | Description |
| :--- | :--- | :--- |
| `GET` | `/api/gains/course/{courseId}` | Obtenir les gains de tous les paris dune course. |
| `GET` | `/api/gains/pari/{pariId}` | Obtenir les détails des gains pour un pari donné. |
| `POST` | `/api/paris/course/settle/{courseId}` | Régler et distribuer les gains dune course. |
## Règles métier & validations
Les règles métier et les validations appliquées aux différentes étapes du cycle de vie d'une course, des paris aux résultats.
---
### 1. Gestion des courses
* **Course PLANIFIÉE :** Il s'agit de la seule phase pendant laquelle il est possible d'enregistrer des paris. Toute tentative de créer un pari en dehors de ce statut sera rejetée.
* **Résultats créés :** Une fois que les résultats officiels sont enregistrés, le statut de la course est automatiquement mis à jour à **CLÔTURÉE**.
---
### 2. Validations des paris
Les règles suivantes sont appliquées pour garantir la validité des paris :
* **Positions obligatoires :** Pour les paris ordonnés, toutes les positions doivent être renseignées.
* **Doublons interdits :** Un ticket de pari ne peut pas contenir de doublons de cheval ou de position.
* **Dead-Heat :** Le système autorise les ex æquo (chevaux arrivant à égalité) dans les résultats.
* **Quinté+ :** Chaque ticket de Quinté+ se voit attribuer un **Numéro+** aléatoire au moment de la création du pari.
---
## Recettes de test
Ce document détaille les différentes recettes de test pour valider le bon fonctionnement de l'API.
---
### A) Flux complet (end-to-end)
1. Créer une course.
2. Inscrire des chevaux à la course.
3. Placer des paris sur la course.
4. Déclarer les résultats officiels.
5. Régler la course (calcul des gains).
6. Vérifier les gains générés pour les paris gagnants.
---
### B) Validation & contraintes
* **Pari avec cheval en double :** Tenter de placer un pari contenant deux fois le même cheval. Le système doit retourner une erreur **400 (Bad Request)**.
* **Pari avec cheval NP :** Tenter de placer un pari incluant un cheval marqué comme non-partant (NP). Le système doit rejeter le pari.
---
### C) Cas dex æquo (Dead-Heat)
* Déclarer deux chevaux ayant le même rang (par exemple, `rang=1`).
* Vérifier que le calcul du pari de type **SIMPLE_GAGNANT** fonctionne correctement pour l'un ou l'autre des chevaux à égalité.
---
### D) Gestion NP/DQ
* **Réintégration (NP) :** Envoyer une requête `PUT` avec `scratch?value=false` pour réintégrer un cheval précédemment non-partant.
* **Requalification (DQ) :** Envoyer une requête `PUT` avec `disqualify?value=false` pour requalifier un cheval précédemment disqualifié.
---
### E) Idempotence du règlement
* Tenter de régler une même course à deux reprises (`POST /api/paris/course/settle/{courseId}`). Les résultats du calcul des gains doivent être **identiques** à chaque exécution.
---
## Exemples de payloads
### 1. Données du pari
* **Endpoint :** `POST /api/paris`
* **Description :** Création d'un pari TRIO.
* **Corps de la requête :**
```json
{
"courseId": 4,
"pariType": "TRIO",
"mise": 3000.00,
"bettorRef": "punter-C",
"selections": [
{ "chevalId": 7, "position": 1 },
{ "chevalId": 9, "position": 2 },
{ "chevalId": 10, "position": 3 }
]
}
```
---
### 2. Données des résultats
* **Endpoint :** `POST /api/resultats`
* **Description :** Enregistrement des résultats officiels de la course.
* **Corps de la requête :**
```json
[
{ "chevalId": 9, "rang": 1 },
{ "chevalId": 7, "rang": 2 },
{ "chevalId": 10, "rang": 3 }
]
```
---
### 3. Numéro+ (non pertinent pour ce pari)
* **Endpoint :** `PATCH /api/resultats/course/{courseId}/numero-plus`
* **Description :** Enregistrement du Numéro+ pour la course.
* **Corps de la requête :**
```json
{ "numeroPlusGagnant": "654321" }
```
* **Note :** Le Numéro+ n'a pas d'impact sur le pari de type TRIO, mais est inclus pour documenter le flux complet de règlement d'une course.
---
### 4. Résultat attendu
Le pari est **gagnant** si les trois chevaux sélectionnés figurent parmi les trois premiers de l'arrivée, quel que soit l'ordre. Dans ce cas, les chevaux `7`, `9` et `10` sont bien dans les trois premières positions du classement officiel. Le pari doit être réglé comme un gain.
------
Bon testing ): !!!

View File

@@ -0,0 +1,140 @@
package com.pmumali.plr.config;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.context.request.ServletWebRequest;
import jakarta.persistence.EntityNotFoundException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
@RestControllerAdvice
public class GlobalApiExceptionHandler {
// ---------- Helpers ----------
private Map<String, Object> body(HttpStatus status, String message, WebRequest req) {
var map = new LinkedHashMap<String, Object>();
map.put("timestamp", OffsetDateTime.now().toString());
map.put("status", status.value());
map.put("error", status.getReasonPhrase());
map.put("message", message != null ? message : "");
map.put("path", extractPath(req));
return map;
}
private String extractPath(WebRequest req) {
if (req instanceof ServletWebRequest swr && swr.getRequest() != null) {
return swr.getRequest().getRequestURI();
}
return "";
}
// ---------- 404 ----------
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, Object> notFound(EntityNotFoundException e, WebRequest req) {
return body(HttpStatus.NOT_FOUND, e.getMessage(), req);
}
// ---------- 400: @Valid on @RequestBody (field errors) ----------
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> badRequestValidation(MethodArgumentNotValidException e, WebRequest req) {
var map = body(HttpStatus.BAD_REQUEST, "Validation error", req);
List<Map<String,Object>> fieldErrors = e.getBindingResult().getFieldErrors().stream()
.map(fe -> {
Map<String,Object> m = new LinkedHashMap<>();
m.put("field", fe.getField());
m.put("message", fe.getDefaultMessage());
m.put("rejectedValue", fe.getRejectedValue());
return m;
})
.toList();
map.put("errors", fieldErrors);
return map;
}
// ---------- 400: @Validated on params/path/query (ConstraintViolation) ----------
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> badRequestConstraint(ConstraintViolationException e, WebRequest req) {
var map = body(HttpStatus.BAD_REQUEST, "Constraint violation", req);
List<Map<String, Object>> violations = e.getConstraintViolations().stream()
.map(v -> violationEntry(v)).toList();
map.put("errors", violations);
return map;
}
private Map<String, Object> violationEntry(ConstraintViolation<?> v) {
var m = new LinkedHashMap<String, Object>();
m.put("property", v.getPropertyPath() != null ? v.getPropertyPath().toString() : "");
m.put("message", v.getMessage());
m.put("invalidValue", v.getInvalidValue());
return m;
}
// ---------- 400: bad JSON / wrong types / missing params ----------
@ExceptionHandler({
HttpMessageNotReadableException.class,
MissingServletRequestParameterException.class,
MethodArgumentTypeMismatchException.class
})
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> badRequestParse(Exception e, WebRequest req) {
return body(HttpStatus.BAD_REQUEST, e.getMessage(), req);
}
// ---------- 409: DB constraints ----------
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Map<String, Object> conflict(DataIntegrityViolationException e, WebRequest req) {
Throwable most = e.getMostSpecificCause(); // <- we use the result
String msg = (most != null && most.getMessage() != null) ? most.getMessage()
: (e.getMessage() != null ? e.getMessage() : "Data integrity violation");
return body(HttpStatus.CONFLICT, msg, req);
}
// ---------- 405 / 415 ----------
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public Map<String, Object> methodNotAllowed(HttpRequestMethodNotSupportedException e, WebRequest req) {
return body(HttpStatus.METHOD_NOT_ALLOWED, e.getMessage(), req);
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
@ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
public Map<String, Object> mediaType(HttpMediaTypeNotSupportedException e, WebRequest req) {
return body(HttpStatus.UNSUPPORTED_MEDIA_TYPE, e.getMessage(), req);
}
// ---------- 400: generic illegal argument ----------
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> badRequest(IllegalArgumentException e, WebRequest req) {
return body(HttpStatus.BAD_REQUEST, e.getMessage(), req);
}
// ---------- 500: catch-all ----------
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, Object> unhandled(Exception e, WebRequest req) {
// Avoid leaking stack traces; log them instead if you have a logger here.
String msg = (e.getMessage() != null) ? e.getMessage() : e.getClass().getSimpleName();
return body(HttpStatus.INTERNAL_SERVER_ERROR, msg, req);
}
}

View File

@@ -31,13 +31,12 @@ public class ChevalController {
public ResponseEntity<ChevalDto> create(@RequestBody ChevalDto dto) { public ResponseEntity<ChevalDto> create(@RequestBody ChevalDto dto) {
Cheval ch = new Cheval(); Cheval ch = new Cheval();
ch.setNom(dto.nom()); ch.setNom(dto.nom());
ch.setNumero(dto.numero());
ch.setNomEcurie(dto.nomEcurie()); ch.setNomEcurie(dto.nomEcurie());
ch.setBirthYear(dto.birthYear()); ch.setBirthYear(dto.birthYear());
ch = chevalService.create(ch); ch = chevalService.create(ch);
ChevalDto response = new ChevalDto(ch.getId(), ch.getNom(), ch.getNumero(), ch.getNomEcurie(), ChevalDto response = new ChevalDto(ch.getId(), ch.getNom(), ch.getNomEcurie(),
ch.getBirthYear()); ch.getBirthYear());
return ResponseEntity.created(URI.create("/api/chevaux/" + ch.getId())).body(response); return ResponseEntity.created(URI.create("/api/chevaux/" + ch.getId())).body(response);
} }
@@ -46,7 +45,7 @@ public class ChevalController {
@GetMapping @GetMapping
public ResponseEntity<List<ChevalDto>> all() { public ResponseEntity<List<ChevalDto>> all() {
List<ChevalDto> list = chevalService.all().stream() List<ChevalDto> list = chevalService.all().stream()
.map(h -> new ChevalDto(h.getId(), h.getNom(), h.getNumero(), h.getNomEcurie(), h.getBirthYear())) .map(h -> new ChevalDto(h.getId(), h.getNom(), h.getNomEcurie(), h.getBirthYear()))
.toList(); .toList();
return ResponseEntity.ok(list); return ResponseEntity.ok(list);
} }
@@ -55,7 +54,7 @@ public class ChevalController {
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<ChevalDto> one(@PathVariable Long id) { public ResponseEntity<ChevalDto> one(@PathVariable Long id) {
Cheval h = chevalService.get(id); Cheval h = chevalService.get(id);
ChevalDto dto = new ChevalDto(h.getId(), h.getNom(), h.getNumero(), h.getNomEcurie(), h.getBirthYear()); ChevalDto dto = new ChevalDto(h.getId(), h.getNom(), h.getNomEcurie(), h.getBirthYear());
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
@@ -64,12 +63,11 @@ public class ChevalController {
public ResponseEntity<ChevalDto> update(@PathVariable Long id, @RequestBody ChevalDto dto) { public ResponseEntity<ChevalDto> update(@PathVariable Long id, @RequestBody ChevalDto dto) {
Cheval ch = new Cheval(); Cheval ch = new Cheval();
ch.setNom(dto.nom()); ch.setNom(dto.nom());
ch.setNumero(dto.numero());
ch.setNomEcurie(dto.nomEcurie()); ch.setNomEcurie(dto.nomEcurie());
ch.setBirthYear(dto.birthYear()); ch.setBirthYear(dto.birthYear());
ch = chevalService.update(id, ch); ch = chevalService.update(id, ch);
ChevalDto response = new ChevalDto(ch.getId(), ch.getNom(), ch.getNumero(), ch.getNomEcurie(), ChevalDto response = new ChevalDto(ch.getId(), ch.getNom(), ch.getNomEcurie(),
ch.getBirthYear()); ch.getBirthYear());
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }

View File

@@ -44,10 +44,12 @@ public class CourseController {
c.setNom(dto.nom()); c.setNom(dto.nom());
c.setLieu(dto.lieu()); c.setLieu(dto.lieu());
c.setDepartureDateTime(dto.departureDateTime()); c.setDepartureDateTime(dto.departureDateTime());
c.setDistance(dto.distance());
c.setStatus(dto.status()); c.setStatus(dto.status());
c = courseService.create(c); c = courseService.create(c);
CourseDto result = new CourseDto(c.getId(), c.getNom(), c.getLieu(), c.getDepartureDateTime(), c.getStatus()); CourseDto result = new CourseDto(c.getId(), c.getNom(), c.getLieu(), c.getDepartureDateTime(), c.getDistance(),
c.getStatus());
return ResponseEntity.created(URI.create("/api/courses/" + c.getId())).body(result); return ResponseEntity.created(URI.create("/api/courses/" + c.getId())).body(result);
} }
@@ -59,7 +61,7 @@ public class CourseController {
@PathVariable Long id) { @PathVariable Long id) {
Course course = courseService.get(id); Course course = courseService.get(id);
CourseDto response = new CourseDto(course.getId(), course.getNom(), course.getLieu(), CourseDto response = new CourseDto(course.getId(), course.getNom(), course.getLieu(),
course.getDepartureDateTime(), course.getStatus()); course.getDepartureDateTime(), course.getDistance(), course.getStatus());
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@@ -72,7 +74,8 @@ public class CourseController {
: courseService.getCoursesByStatus(status); : courseService.getCoursesByStatus(status);
List<CourseDto> dtos = courses.stream() List<CourseDto> dtos = courses.stream()
.map(c -> new CourseDto(c.getId(), c.getNom(), c.getLieu(), c.getDepartureDateTime(), c.getStatus())) .map(c -> new CourseDto(c.getId(), c.getNom(), c.getLieu(), c.getDepartureDateTime(), c.getDistance(),
c.getStatus()))
.toList(); .toList();
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
@@ -87,12 +90,13 @@ public class CourseController {
update.setNom(dto.nom()); update.setNom(dto.nom());
update.setLieu(dto.lieu()); update.setLieu(dto.lieu());
update.setDepartureDateTime(dto.departureDateTime()); update.setDepartureDateTime(dto.departureDateTime());
update.setDistance(dto.distance());
update.setStatus(dto.status()); update.setStatus(dto.status());
Course updated = courseService.updateCourse(id, update); Course updated = courseService.updateCourse(id, update);
CourseDto result = new CourseDto(updated.getId(), updated.getNom(), updated.getLieu(), CourseDto result = new CourseDto(updated.getId(), updated.getNom(), updated.getLieu(),
updated.getDepartureDateTime(), updated.getStatus()); updated.getDepartureDateTime(), updated.getDistance(), updated.getStatus());
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@@ -103,20 +107,11 @@ public class CourseController {
*/ */
@Operation(summary = "Supprimer une course") @Operation(summary = "Supprimer une course")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<?> deleteCourse(@PathVariable Long id, public ResponseEntity<?> deleteCourse(@PathVariable Long id) {
@RequestParam(value = "hard", required = false, defaultValue = "false") boolean hard) {
try { try {
courseService.deleteCourse(id, hard); courseService.deleteCourse(id);
if (!hard) {
// return the cancelled course representation return ResponseEntity.noContent().build();
Course c = courseService.get(id);
CourseDto dto = new CourseDto(c.getId(), c.getNom(), c.getLieu(), c.getDepartureDateTime(),
c.getStatus());
return ResponseEntity.ok(dto);
} else {
// hard delete succeeded: no content
return ResponseEntity.noContent().build();
}
} catch (DataIntegrityViolationException ex) { } catch (DataIntegrityViolationException ex) {
return ResponseEntity.status(409).body(java.util.Collections.singletonMap("error", ex.getMessage())); return ResponseEntity.status(409).body(java.util.Collections.singletonMap("error", ex.getMessage()));
} }
@@ -124,9 +119,9 @@ public class CourseController {
// Ajout d'un cheval à la course -> POST /api/courses/{id}/chevaux // Ajout d'un cheval à la course -> POST /api/courses/{id}/chevaux
@Operation(summary = "Ajouter un cheval à une course") @Operation(summary = "Ajouter un cheval à une course")
@PostMapping("/{id}/chevaux") @PostMapping("/chevaux")
public ResponseEntity<ChevalCourseDto> ajouterCheval(@PathVariable Long id, @RequestBody ChevalCourseDto dto) { public ResponseEntity<ChevalCourseDto> ajouterCheval(@RequestBody ChevalCourseDto dto) {
ChevalCourse rh = courseService.ajouterCheval(id, dto.chevalId(), dto.numeroCheval()); ChevalCourse rh = courseService.ajouterCheval(dto.courseId(), dto.chevalId(), dto.numeroCheval());
ChevalCourseDto response = new ChevalCourseDto( ChevalCourseDto response = new ChevalCourseDto(
rh.getId(), rh.getId(),
@@ -136,21 +131,22 @@ public class CourseController {
rh.getNonPartant(), rh.getNonPartant(),
rh.getEstDisqualifie()); rh.getEstDisqualifie());
return ResponseEntity.created(URI.create("/api/courses/" + id + "/chevaux/" + rh.getId())).body(response); return ResponseEntity.created(URI.create("/api/courses/chevaux/" + rh.getId()))
.body(response);
} }
@Operation(summary = "Ajouter des chevaux à une course") @Operation(summary = "Ajouter des chevaux à une course")
@PostMapping("/{id}/chevaux/bulk") @PostMapping("/chevaux/bulk")
public ResponseEntity<?> bulkAddChevaux(@PathVariable("id") Long id, @RequestBody BulkChevalCourseRequest request) { public ResponseEntity<?> bulkAddChevaux(@RequestBody BulkChevalCourseRequest request) {
try { try {
List<ChevalCourse> saved = courseService.ajouterChevauxBulk(id, request); List<ChevalCourse> saved = courseService.ajouterChevauxBulk(request.courseId(), request);
List<ChevalCourseDto> dtos = saved.stream() List<ChevalCourseDto> dtos = saved.stream()
.map(rh -> new ChevalCourseDto(rh.getId(), rh.getCourse().getId(), rh.getCheval().getId(), .map(rh -> new ChevalCourseDto(rh.getId(), rh.getCourse().getId(), rh.getCheval().getId(),
rh.getNumeroCheval(), rh.getNonPartant(), rh.getEstDisqualifie())) rh.getNumeroCheval(), rh.getNonPartant(), rh.getEstDisqualifie()))
.collect(Collectors.toList()); .collect(Collectors.toList());
return ResponseEntity.created(URI.create("/api/courses/" + id + "/chevaux")).body(dtos); return ResponseEntity.created(URI.create("/api/courses/chevaux")).body(dtos);
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
// doublons dans la requête // doublons dans la requête
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
@@ -171,7 +167,7 @@ public class CourseController {
} }
// Marquer un cheval comme non-partant (scratch) // Marquer un cheval comme non-partant (scratch)
@Operation(summary = "Marquer un cheval comme non-partant (NP) à une course") @Operation(summary = "Marquer / Dé-marquer un cheval comme non-partant (NP) à une course")
@PutMapping("/cheval-course/{chevalCourseId}/scratch") @PutMapping("/cheval-course/{chevalCourseId}/scratch")
public ResponseEntity<Void> scratch(@PathVariable Long chevalCourseId, @RequestParam boolean value) { public ResponseEntity<Void> scratch(@PathVariable Long chevalCourseId, @RequestParam boolean value) {
courseService.estNonPartant(chevalCourseId, value); courseService.estNonPartant(chevalCourseId, value);
@@ -213,7 +209,8 @@ public class CourseController {
List<Course> courses = courseService.getCoursesByCourseStatue(status); List<Course> courses = courseService.getCoursesByCourseStatue(status);
List<CourseDto> dtos = courses.stream() List<CourseDto> dtos = courses.stream()
.map(c -> new CourseDto(c.getId(), c.getNom(), c.getLieu(), c.getDepartureDateTime(), c.getStatus())) .map(c -> new CourseDto(c.getId(), c.getNom(), c.getLieu(), c.getDepartureDateTime(), c.getDistance(),
c.getStatus()))
.toList(); .toList();
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);

View File

@@ -0,0 +1,59 @@
// com.pmumali.plr.controllers.WinnerController.java
package com.pmumali.plr.controllers;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.pmumali.plr.dtos.GainViewDto;
import com.pmumali.plr.enums.PariType;
import com.pmumali.plr.services.GainService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/gains")
@RequiredArgsConstructor
public class GainController {
private final GainService winnerService;
@Operation(summary = "Lister tous les gagnants d'une course (officiels si disponibles, sinon provisoires)")
@GetMapping("/course/{courseId}")
public ResponseEntity<?> listWinners(@PathVariable Long courseId) {
try {
List<GainViewDto> winners = winnerService.listWinners(courseId);
return ResponseEntity.ok(winners);
} catch (EntityNotFoundException e) {
return ResponseEntity.status(404).body(java.util.Map.of("error", e.getMessage()));
}
}
@Operation(summary = "Lister les gagnants d'une course pour un type de pari")
@GetMapping("/course/{courseId}/type/{pariType}")
public ResponseEntity<?> listWinnersByType(@PathVariable Long courseId, @PathVariable PariType pariType) {
try {
List<GainViewDto> winners = winnerService.listWinnersByType(courseId, pariType);
return ResponseEntity.ok(winners);
} catch (EntityNotFoundException e) {
return ResponseEntity.status(404).body(java.util.Map.of("error", e.getMessage()));
}
}
@Operation(summary = "Savoir si un pari est gagnant (officiel si disponible, sinon provisoire)")
@GetMapping("/pari/{pariId}")
public ResponseEntity<?> isWinner(@PathVariable Long pariId) {
try {
GainViewDto view = winnerService.isWinner(pariId);
return ResponseEntity.ok(view);
} catch (EntityNotFoundException e) {
return ResponseEntity.status(404).body(java.util.Map.of("error", e.getMessage()));
}
}
}

View File

@@ -1,22 +1,28 @@
package com.pmumali.plr.controllers; package com.pmumali.plr.controllers;
import java.net.URI; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.pmumali.plr.dtos.PariDto; import com.pmumali.plr.dtos.CreatePariRequestDto;
import com.pmumali.plr.dtos.PariResponseDto;
import com.pmumali.plr.dtos.SettlementSummaryDto;
import com.pmumali.plr.dtos.UpdatePariRequestDto;
import com.pmumali.plr.enums.PariType; import com.pmumali.plr.enums.PariType;
import com.pmumali.plr.models.Pari;
import com.pmumali.plr.services.PariService; import com.pmumali.plr.services.PariService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
@@ -29,61 +35,72 @@ public class PariController {
@Operation(summary = "Placer un pari") @Operation(summary = "Placer un pari")
@PostMapping @PostMapping
public ResponseEntity<PariDto> create(@RequestBody PariDto dto) { public ResponseEntity<PariResponseDto> createPari(@Valid @RequestBody CreatePariRequestDto requestDto) {
Pari p = pariService.create(dto.courseId(), dto.chevalId(), dto.pariType(), dto.mise(), dto.bettorRef()); PariResponseDto createdPari = pariService.createPari(requestDto);
PariDto response = new PariDto(p.getId(), p.getCourse().getId(), p.getCheval().getId(), p.getPariType(), return new ResponseEntity<>(createdPari, HttpStatus.CREATED);
p.getMise(), p.getBettorRef()); }
return ResponseEntity.created(URI.create("/api/paris/" + p.getId())).body(response);
@Operation(summary = "Mettre à jour un pari (seulement si la course est PLANIFIEE)")
@PutMapping("/{id}")
public ResponseEntity<PariResponseDto> updatePari(@PathVariable Long id,
@Valid @RequestBody UpdatePariRequestDto dto) {
return ResponseEntity.ok(pariService.updatePari(id, dto));
}
@Operation(summary = "Supprimer un pari (seulement si la course est PLANIFIEE)")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePari(@PathVariable Long id) {
pariService.deletePari(id);
return ResponseEntity.noContent().build();
} }
@Operation(summary = "Lister tous les paris") @Operation(summary = "Lister tous les paris")
@GetMapping @GetMapping
public ResponseEntity<List<PariDto>> all() { public ResponseEntity<List<PariResponseDto>> all() {
List<PariDto> list = pariService.all().stream() return ResponseEntity.ok(pariService.all());
.map(h -> new PariDto(h.getId(), h.getCourse().getId(), h.getCheval().getId(), h.getPariType(),
h.getMise(), h.getBettorRef()))
.toList();
return ResponseEntity.ok(list);
} }
@Operation(summary = "Récupérer un pari par id") @Operation(summary = "Récupérer un pari par id")
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<PariDto> one(@PathVariable Long id) { public ResponseEntity<PariResponseDto> getPariById(@PathVariable Long id) {
Pari p = pariService.get(id); PariResponseDto pari = pariService.getPariById(id);
PariDto dto = new PariDto(p.getId(), p.getCourse().getId(), p.getCheval().getId(), p.getPariType(), p.getMise(), return ResponseEntity.ok(pari);
p.getBettorRef());
return ResponseEntity.ok(dto);
} }
// Recherche par type (ex: SIMPLE_GAGNANT) // Recherche par type (ex: SIMPLE_GAGNANT)
@Operation(summary = "Lister les paris par type") @Operation(summary = "Lister les paris par type")
@GetMapping("/type/{pariType}") @GetMapping("/type/{pariType}")
public ResponseEntity<List<PariDto>> getAllByPariType(@PathVariable PariType pariType) { public ResponseEntity<List<PariResponseDto>> getAllByPariType(@PathVariable PariType pariType) {
List<PariDto> list = pariService.getParisByPariType(pariType).stream() List<PariResponseDto> list = pariService.getParisByPariType(pariType);
.map(h -> new PariDto(h.getId(), h.getCourse().getId(), h.getCheval().getId(), h.getPariType(), return new ResponseEntity<>(list, HttpStatus.OK);
h.getMise(), h.getBettorRef()))
.toList();
return ResponseEntity.ok(list);
} }
// Recherche par course et type // Recherche par course et type
@Operation(summary = "Lister les paris par course + type") @Operation(summary = "Lister les paris par course + type")
@GetMapping("/course/{courseId}/type/{pariType}") @GetMapping("/course/{courseId}/type/{pariType}")
public ResponseEntity<List<PariDto>> getByCourseAndType(@PathVariable Long courseId, public ResponseEntity<List<PariResponseDto>> getByCourseAndType(@PathVariable Long courseId,
@PathVariable PariType pariType) { @PathVariable PariType pariType) {
List<PariDto> list = pariService.getParisByCourseAndType(courseId, pariType).stream() List<PariResponseDto> list = pariService.getParisByCourseAndType(courseId, pariType);
.map(h -> new PariDto(h.getId(), h.getCourse().getId(), h.getCheval().getId(), h.getPariType(), return new ResponseEntity<>(list, HttpStatus.OK);
h.getMise(), h.getBettorRef()))
.toList();
return ResponseEntity.ok(list);
} }
// Somme des mises d'une course / type (utile pour calculs de pools) // Somme des mises d'une course / type (utile pour calculs de pools)
@Operation(summary = "Total des mises pour une course et un type de pari") @Operation(summary = "Total des mises pour une course et un type de pari")
@GetMapping("/course/{courseId}/type/{pariType}/sum") @GetMapping("/course/{courseId}/type/{pariType}/sum")
public ResponseEntity<java.math.BigDecimal> sumMises(@PathVariable Long courseId, @PathVariable PariType pariType) { public ResponseEntity<BigDecimal> sumMises(@PathVariable Long courseId, @PathVariable PariType pariType) {
java.math.BigDecimal total = pariService.sumMiseByCourseIdAndPariType(courseId, pariType); return ResponseEntity.ok(pariService.sumMiseByCourseIdAndPariType(courseId, pariType));
return ResponseEntity.ok(total);
} }
// ---------- Règlement & rapports ----------
@Operation(summary = "Régler tous les paris d'une course (calcul cagnotte, prélèvements, gains)")
@PostMapping("/course/settle/{courseId}")
public ResponseEntity<SettlementSummaryDto> settleCourse(@PathVariable Long courseId) {
return ResponseEntity.ok(pariService.settleCourse(courseId));
}
@Operation(summary = "Rapport de paiement d'une course (par type)")
@GetMapping("/course/report/{courseId}")
public ResponseEntity<SettlementSummaryDto> getSettlementReport(@PathVariable Long courseId) {
return ResponseEntity.ok(pariService.getSettlementReport(courseId));
}
} }

View File

@@ -0,0 +1,168 @@
package com.pmumali.plr.controllers;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.pmumali.plr.dtos.ResultatChevalDto;
import com.pmumali.plr.dtos.ResultatChevalView;
import com.pmumali.plr.dtos.ResultatCreationDto;
import com.pmumali.plr.dtos.ResultatDto;
import com.pmumali.plr.models.Resultat;
import com.pmumali.plr.models.ResultatCheval;
import com.pmumali.plr.services.ResultatService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.persistence.EntityNotFoundException;
import lombok.AllArgsConstructor;
@RestController
@RequestMapping("/api/resultats")
@AllArgsConstructor
public class ResultatController {
private final ResultatService resultatService;
// -------- Resultat CRUD --------
@Operation(summary = "Créer un résultat pour une course")
@PostMapping
public ResponseEntity<?> createResultat(@RequestBody ResultatCreationDto dto) {
try {
var r = resultatService.createResultat(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(r);
} catch (IllegalArgumentException | EntityNotFoundException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@Operation(summary = "Lister tous les résultats")
@GetMapping
public ResponseEntity<List<ResultatDto>> getAllResults() {
return ResponseEntity.ok(resultatService.findAllResults());
}
@Operation(summary = "Récupérer un résultat par course id")
@GetMapping("/course/{courseId}")
public ResponseEntity<?> getByCourse(@PathVariable Long courseId) {
try {
return ResponseEntity.ok(resultatService.findResultatByCourseId(courseId));
} catch (EntityNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage()));
}
}
@Operation(summary = "Mettre à jour le classement complet (reclassement)")
@PutMapping("/course/{courseId}")
public ResponseEntity<?> update(@PathVariable Long courseId, @RequestBody List<ResultatChevalDto> updated) {
try {
return ResponseEntity.ok(resultatService.updateResultat(courseId, updated));
} catch (IllegalArgumentException | EntityNotFoundException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@Operation(summary = "Supprimer le résultat d'une course")
@DeleteMapping("/course/{courseId}")
public ResponseEntity<?> delete(@PathVariable Long courseId) {
try {
resultatService.deleteResultat(courseId);
return ResponseEntity.noContent().build();
} catch (EntityNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage()));
}
}
// -------- Lignes (ResultatCheval) --------
@Operation(summary = "Lister les lignes de résultat (par course)")
@GetMapping("/course/{courseId}/chevaux")
public ResponseEntity<List<ResultatChevalView>> listChevauxOfResultExt(@PathVariable Long courseId) {
return ResponseEntity.ok(resultatService.listResultatChevauxViewExt(courseId));
}
@Operation(summary = "Ajouter une ligne au résultat")
@PostMapping("/course/{courseId}/chevaux")
public ResponseEntity<?> addLine(@PathVariable Long courseId, @RequestBody ResultatChevalDto dto) {
try {
ResultatChevalView view = resultatService.addResultatCheval(courseId, dto);
return ResponseEntity.status(HttpStatus.CREATED).body(view);
} catch (IllegalArgumentException | EntityNotFoundException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@Operation(summary = "Upsert/Modifier le rang d'un cheval dans le résultat")
@PutMapping("/course/{courseId}/chevaux")
public ResponseEntity<?> upsertLine(@PathVariable Long courseId,
@RequestBody ResultatChevalDto dto) {
try {
ResultatCheval rc = resultatService.upsertResultatCheval(courseId, dto);
return ResponseEntity.ok(rc);
} catch (IllegalArgumentException | EntityNotFoundException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@Operation(summary = "Supprimer une ligne (cheval) du résultat")
@DeleteMapping("/course/{courseId}/chevaux/{chevalId}")
public ResponseEntity<?> deleteLine(@PathVariable Long courseId, @PathVariable Long chevalId) {
try {
resultatService.deleteResultatCheval(courseId, chevalId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException | EntityNotFoundException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
// -------- Numero+ (Quinté+) --------
@Operation(summary = "Fixer le Numéro+ gagnant (Quinté+)")
@PatchMapping("/course/{courseId}/numero-plus")
public ResponseEntity<?> setNumeroPlus(@PathVariable Long courseId,
@RequestBody Map<String, String> body) {
try {
String numeroPlus = body.get("numeroPlusGagnant");
if (numeroPlus == null || numeroPlus.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "numeroPlusGagnant is required"));
}
Resultat r = resultatService.setNumeroPlus(courseId, numeroPlus);
return ResponseEntity.ok(Map.of(
"courseId", r.getCourse().getId(),
"numeroPlusGagnant", r.getNumeroPlusGagnant()
));
} catch (EntityNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage()));
}
}
@Operation(summary = "Tirage aléatoire du Numéro+ (Quinté+)")
@PatchMapping("/course/{courseId}/numero-plus/draw")
public ResponseEntity<?> drawNumeroPlus(@PathVariable Long courseId) {
try {
Resultat r = resultatService.drawNumeroPlus(courseId);
return ResponseEntity.ok(Map.of("numeroPlusGagnant", r.getNumeroPlusGagnant()));
} catch (EntityNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage()));
}
}
// -------- Validation --------
@Operation(summary = "Valider cohérence résultat (NP/DQ, Dead-Heat, etc.)")
@GetMapping("/course/{courseId}/validate")
public ResponseEntity<Map<String, Object>> validate(@PathVariable Long courseId) {
return ResponseEntity.ok(resultatService.validate(courseId));
}
}

View File

@@ -2,7 +2,7 @@ package com.pmumali.plr.dtos;
import java.util.List; import java.util.List;
public record BulkChevalCourseRequest(List<ChevalEntry> entries) { public record BulkChevalCourseRequest(Long courseId, List<ChevalEntry> entries) {
public static record ChevalEntry(Long chevalId, Integer numeroCheval) { public static record ChevalEntry(Long chevalId, Integer numeroCheval) {
} }
} }

View File

@@ -1,3 +1,4 @@
package com.pmumali.plr.dtos; package com.pmumali.plr.dtos;
public record ChevalDto(Long id, String nom, Integer numero, String nomEcurie, Integer birthYear) {} public record ChevalDto(Long id, String nom, String nomEcurie, Integer birthYear) {
}

View File

@@ -4,4 +4,6 @@ import java.time.LocalDateTime;
import com.pmumali.plr.enums.CourseStatue; import com.pmumali.plr.enums.CourseStatue;
public record CourseDto(Long id, String nom, String lieu, LocalDateTime departureDateTime, CourseStatue status){} public record CourseDto(Long id, String nom, String lieu, LocalDateTime departureDateTime, Integer distance,
CourseStatue status) {
}

View File

@@ -0,0 +1,32 @@
package com.pmumali.plr.dtos;
import java.math.BigDecimal;
import java.util.List;
import com.pmumali.plr.enums.PariType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class CreatePariRequestDto {
@NotNull(message = "Course ID cannot be null")
private Long courseId;
@NotNull(message = "PariType cannot be null")
private PariType pariType;
@NotNull(message = "Mise cannot be null")
@DecimalMin(value = "0", message = "Minimum mise is 0")
private BigDecimal mise;
@NotEmpty(message = "Bettor reference cannot be empty")
private String bettorRef;
@NotEmpty(message = "Selections cannot be empty")
private List<@Valid PariSelectionDto> selections;
}

View File

@@ -0,0 +1,28 @@
package com.pmumali.plr.dtos;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import com.pmumali.plr.enums.PariType;
import lombok.Builder;
@Builder
public record GainViewDto(
Long pariId,
Long courseId,
String courseNom,
PariType pariType,
String pariSousType, // e.g. "QP_ORDRE", "MULTI6" (optional)
String bettorRef,
BigDecimal mise,
BigDecimal gain, // official or 0 when provisional
String payoutMode, // "OFFICIAL" or "PROVISIONAL"
String numeroPlus, // for Quinté+ tickets (if any)
List<GainViewDto.Selection> selections,
LocalDateTime createdAt
) {
@Builder
public static record Selection(Long chevalId, String nomCheval, Integer position) {}
}

View File

@@ -0,0 +1,24 @@
package com.pmumali.plr.dtos;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import com.pmumali.plr.enums.PariType;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PariResponseDto {
private Long id;
private Long courseId;
private String courseNom;
private PariType pariType;
private BigDecimal mise;
private String bettorRef;
private Boolean estGagnant;
private BigDecimal gain;
private LocalDateTime createdAt;
private List<PariSelectionResponseDto> selections;
}

View File

@@ -0,0 +1,14 @@
package com.pmumali.plr.dtos;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class PariSelectionDto {
@NotNull
private Long chevalId;
@Min(1)
private Integer position;
}

View File

@@ -0,0 +1,12 @@
package com.pmumali.plr.dtos;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class PariSelectionResponseDto {
private Long chevalId;
private String nomCheval;
private Integer position;
}

View File

@@ -0,0 +1,16 @@
package com.pmumali.plr.dtos;
import java.math.BigDecimal;
import com.pmumali.plr.enums.PariType;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class RapportDto {
private Long courseId;
private PariType pariType;
private BigDecimal rapportParUnite; // cagnotte / total mise gagnants (0 si pas de gagnant)
}

View File

@@ -0,0 +1,15 @@
package com.pmumali.plr.dtos;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ResultatChevalDto {
@NotNull
private Long chevalId;
@NotNull
@Min(1)
private Integer rang;
}

View File

@@ -0,0 +1,4 @@
package com.pmumali.plr.dtos;
public record ResultatChevalView(ChevalDto cheval, Integer numeroCheval, Integer rang) {}

View File

@@ -0,0 +1,21 @@
package com.pmumali.plr.dtos;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class ResultatCreationDto {
@NotNull
private Long courseId;
// optionnel pour Quinté+
private String numeroPlusGagnant;
@NotEmpty
@Valid
private List<ResultatChevalDto> resultatsChevaux;
}

View File

@@ -0,0 +1,19 @@
package com.pmumali.plr.dtos;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Builder;
@Builder
public record ResultatDto(
Long id,
Long courseId,
String courseNom,
LocalDateTime dateOfficielle,
String numeroPlusGagnant,
List<LigneDto> arrivee
) {
@Builder
public record LigneDto(Long chevalId, String chevalNom, Integer rang) {}
}

View File

@@ -0,0 +1,27 @@
package com.pmumali.plr.dtos;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import com.pmumali.plr.enums.PariType;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class SettlementSummaryDto {
private Long courseId;
private Map<PariType, PoolLine> pools; // par type
private List<RapportDto> rapports; // par type (€/1 unité de mise)
private boolean closed; // course cloturée ?
@Data @Builder
public static class PoolLine {
private BigDecimal totalMise; // total misé
private BigDecimal prelevement; // montant prélevé
private BigDecimal cagnotte; // totalMise - prelevement
private BigDecimal totalMiseGagnants; // somme des mises gagnantes
}
}

View File

@@ -0,0 +1,28 @@
package com.pmumali.plr.dtos;
import java.math.BigDecimal;
import java.util.List;
import com.pmumali.plr.enums.PariType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class UpdatePariRequestDto {
@NotNull
private PariType pariType;
@NotNull @DecimalMin("0.01")
private BigDecimal mise;
@NotBlank
private String bettorRef;
@NotNull @Size(min = 1)
private List<@Valid PariSelectionDto> selections;
}

View File

@@ -0,0 +1,8 @@
package com.pmumali.plr.enums;
public enum PariSousType {
// Quinté+
QP_ORDRE, QP_DESORDRE, QP_BONUS4, QP_BONUS3,
// Multi
MULTI4, MULTI5, MULTI6, MULTI7
}

View File

@@ -1,5 +1,22 @@
package com.pmumali.plr.enums; package com.pmumali.plr.enums;
public enum PariType { public enum PariType {
SIMPLE_GAGNANT, SIMPLE_PLACE SIMPLE_GAGNANT, // Simple Gagnant : Parier sur le cheval qui termine premier.
SIMPLE_PLACE, // Simple Placé : Parier sur un cheval qui termine dans les 2 ou 3 premiers
// (selon le nombre de partants).
COUPLE_GAGNANT, // Couplé Gagnant : Parier sur les deux chevaux qui terminent premier et
// deuxième, sans tenir compte de l'ordre.
COUPLE_ORDRE, // Couplé Ordre : Parier sur les deux chevaux qui terminent premier et deuxième,
// dans l'ordre exact.
TRIO_ORDRE, // Trio Ordre : Parier sur les trois premiers chevaux, dans l'ordre exact.
TRIO, // Trio : Parier sur les trois premiers chevaux, dans le désordre.
TIERCE, // Tiercé : Parier sur les trois premiers chevaux, dans l'ordre ou le désordre.
QUARTE, // Quarté : Parier sur les quatre premiers chevaux, dans l'ordre ou le désordre.
QUINTE, // Quinté : Parier sur les cinq premiers chevaux, dans l'ordre ou le désordre,
// avec un bonus pour le numéro de la tirelire.
QUINTE_PLUS, // Quinté plus:
MULTI, // Multi : Parier sur les chevaux qui terminent dans les 4 premiers d'une
// course, en sélectionnant 4, 5, 6 ou 7 chevaux.
DEUX_SUR_QUATRE, // 2sur4 : Parier sur deux chevaux qui terminent parmi les quatre premiers.
PICK_CINQ // Pick 5 : Parier sur les cinq chevaux gagnants de cinq courses différentes.
} }

View File

@@ -1,6 +1,5 @@
package com.pmumali.plr.models; package com.pmumali.plr.models;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import lombok.Data; import lombok.Data;
@@ -8,17 +7,14 @@ import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Data @Data
@Getter @Getter
@Setter @Setter
@EqualsAndHashCode(callSuper=false) @EqualsAndHashCode(callSuper = false)
public class Cheval extends BaseEntite { public class Cheval extends BaseEntite {
private String nom; private String nom;
private Integer numero;
private String nomEcurie; private String nomEcurie;
@Column(name = "birth_year") @Column(name = "birth_year")

View File

@@ -11,19 +11,20 @@ import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Entity @Entity
@Data @Data
@Getter @Getter
@Setter @Setter
@EqualsAndHashCode(callSuper=false) @EqualsAndHashCode(callSuper = false)
public class Course extends BaseEntite { public class Course extends BaseEntite {
private String nom; private String nom;
private String lieu; private String lieu;
@Column(name="date_depart") @Column(name = "date_depart")
private LocalDateTime departureDateTime; private LocalDateTime departureDateTime;
private Integer distance;
private CourseStatue status = CourseStatue.PLANIFIEE; private CourseStatue status = CourseStatue.PLANIFIEE;
} }

View File

@@ -0,0 +1,53 @@
package com.pmumali.plr.models;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import com.pmumali.plr.enums.PariType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "jackpots", uniqueConstraints = @UniqueConstraint(columnNames = {"pari_type"}))
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper=false)
public class Jackpot extends BaseEntite {
@Enumerated(EnumType.STRING)
@Column(name = "pari_type", nullable = false)
private PariType pariType;
@Column(nullable = false, precision = 14, scale = 2)
private BigDecimal solde = BigDecimal.ZERO;
@Column(name = "last_updated", nullable = false)
private LocalDateTime lastUpdated = LocalDateTime.now();
public Jackpot(PariType pariType, BigDecimal solde, LocalDateTime lastUpdated) {
this.pariType = pariType;
this.solde = (solde != null ? solde : BigDecimal.ZERO);
this.lastUpdated = (lastUpdated != null ? lastUpdated : LocalDateTime.now());
}
public void credit(BigDecimal amount) {
if (amount != null && amount.signum() > 0) {
solde = solde.add(amount);
lastUpdated = LocalDateTime.now();
}
}
public BigDecimal drain() {
BigDecimal r = solde;
solde = BigDecimal.ZERO;
lastUpdated = LocalDateTime.now();
return r;
}
}

View File

@@ -1,12 +1,19 @@
package com.pmumali.plr.models; package com.pmumali.plr.models;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import com.pmumali.plr.enums.PariType; import com.pmumali.plr.enums.PariType;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
@@ -16,18 +23,36 @@ import lombok.Setter;
@Entity @Entity
@Getter @Getter
@Setter @Setter
@EqualsAndHashCode(callSuper=false) @EqualsAndHashCode(callSuper = false)
public class Pari extends BaseEntite { public class Pari extends BaseEntite {
@ManyToOne(optional=false) @ManyToOne(optional = false)
@JoinColumn(name = "course_id")
private Course course; private Course course;
@ManyToOne(optional=false) @Enumerated(EnumType.STRING)
private Cheval cheval; @Column(nullable = false)
private PariType pariType; private PariType pariType;
@Column(nullable=false) @Column(nullable = false, precision = 10, scale = 2)
private BigDecimal mise; private BigDecimal mise;
@Column(name = "bettor_ref", nullable = false)
private String bettorRef; private String bettorRef;
@Column(name = "est_gagnant")
private Boolean estGagnant; // null = pending, false = lost, true = won
@Column(precision = 10, scale = 2)
private BigDecimal gain;
// This is the major change: A Pari has many selections
@OneToMany(mappedBy = "pari", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PariSelection> selections = new ArrayList<>();
// Champs pour Quinté+ et Multi
@Column(name = "numero_plus") // nullable si pas Quinté+
private String numeroPlus;
@Column(name = "selection_count") // utile pour Multi : 4..7
private Integer selectionCount;
} }

View File

@@ -0,0 +1,41 @@
package com.pmumali.plr.models;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Data
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = false)
@Table(name = "pari_selections", uniqueConstraints = {
// A horse cannot be selected twice for the same bet
@UniqueConstraint(columnNames = { "pari_id", "cheval_id" }),
// For a single bet, each position can only be assigned once
@UniqueConstraint(columnNames = { "pari_id", "position" })
})
public class PariSelection extends BaseEntite {
@ManyToOne(optional = false)
@JoinColumn(name = "pari_id")
private Pari pari;
@ManyToOne(optional = false)
@JoinColumn(name = "cheval_id")
private Cheval cheval;
@Column(nullable = false)
private Integer position; // The order of the horse in the bet (1st, 2nd, etc.)
}

View File

@@ -0,0 +1,49 @@
package com.pmumali.plr.models;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
import com.fasterxml.jackson.annotation.ObjectIdGenerators;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "resultats")
@Data
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = false)
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class Resultat extends BaseEntite {
@OneToOne
@JoinColumn(name = "course_id", nullable = false, unique = true)
private Course course;
@Column(name = "date_officielle", nullable = false)
private LocalDateTime dateOfficielle = LocalDateTime.now();
@OneToMany(mappedBy = "resultat", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("rang ASC, id ASC")
private List<ResultatCheval> resultatsChevaux = new ArrayList<>();;
// Gestion Quinté+ et Multi
@Column(name = "numero_plus_gagnant")
private String numeroPlusGagnant;
}

View File

@@ -0,0 +1,45 @@
package com.pmumali.plr.models;
import com.fasterxml.jackson.annotation.JsonIdentityInfo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.ObjectIdGenerators;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "resultats_chevaux", uniqueConstraints = @UniqueConstraint(columnNames = { "resultat_id", "cheval_id" }))
@Data
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = false)
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class ResultatCheval extends BaseEntite {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "resultat_id", nullable = false)
@JsonIgnore
private Resultat resultat;
@ManyToOne
@JoinColumn(name = "cheval_id", nullable = false)
private Cheval cheval;
@Column(name = "rang", nullable = false)
private Integer rang;
// Dead Heat is prevented by the constraints by fixing the resultat_id and
// cheval_id
}

View File

@@ -1,9 +1,11 @@
package com.pmumali.plr.repositories; package com.pmumali.plr.repositories;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import com.pmumali.plr.models.Cheval;
import com.pmumali.plr.models.ChevalCourse; import com.pmumali.plr.models.ChevalCourse;
import com.pmumali.plr.models.Course; import com.pmumali.plr.models.Course;
@@ -11,6 +13,13 @@ public interface ChevalCourseRepository extends JpaRepository<ChevalCourse, Long
// Retourne les chevaux non-partants pour une course // Retourne les chevaux non-partants pour une course
List<ChevalCourse> findByCourseAndNonPartantTrue(Course course); List<ChevalCourse> findByCourseAndNonPartantTrue(Course course);
List<ChevalCourse> findByCourseIdAndChevalId(Long courseId, Long chevalId);
Optional<ChevalCourse> findByCourseAndChevalId(Course course, Long chevalId);
// Si tu veux aussi récupérer via l'entité Cheval
Optional<ChevalCourse> findByCourseAndCheval(Course course, Cheval cheval);
// Retourne les chevaux disqualifiés pour une course // Retourne les chevaux disqualifiés pour une course
List<ChevalCourse> findByCourseAndEstDisqualifieTrue(Course course); List<ChevalCourse> findByCourseAndEstDisqualifieTrue(Course course);
@@ -35,4 +44,8 @@ public interface ChevalCourseRepository extends JpaRepository<ChevalCourse, Long
boolean existsByCourseAndNumeroCheval(Course course, Integer numeroCheval); boolean existsByCourseAndNumeroCheval(Course course, Integer numeroCheval);
int countByCourse(Course course); int countByCourse(Course course);
List<ChevalCourse> findByCourse(Course course);
List<ChevalCourse> findByCourseId(Long courseId);
} }

View File

@@ -0,0 +1,12 @@
package com.pmumali.plr.repositories;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.pmumali.plr.enums.PariType;
import com.pmumali.plr.models.Jackpot;
public interface JackpotRepository extends JpaRepository<Jackpot, Long>{
Optional<Jackpot> findByPariType(PariType pariType);
}

View File

@@ -2,25 +2,53 @@ package com.pmumali.plr.repositories;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.pmumali.plr.enums.PariType; import com.pmumali.plr.enums.PariType;
import com.pmumali.plr.models.Pari; import com.pmumali.plr.models.Pari;
public interface PariRepository extends JpaRepository<Pari, Long> { public interface PariRepository extends JpaRepository<Pari, Long> {
List<Pari> findByCourseIdAndPariType(Long courseId, PariType pariType); List<Pari> findByCourseIdAndEstGagnantTrue(Long courseId);
List<Pari> findByCourseIdAndPariTypeAndChevalId(Long courseId, PariType pariType, Long chevalId); List<Pari> findByCourseIdAndPariTypeAndEstGagnantTrue(Long courseId, PariType pariType);
@EntityGraph(attributePaths = {"course", "selections", "selections.cheval"})
@Override
List<Pari> findAll();
@EntityGraph(attributePaths = {"course", "selections", "selections.cheval"})
List<Pari> findByCourseId(Long courseId);
@EntityGraph(attributePaths = {"course", "selections", "selections.cheval"})
List<Pari> findByPariType(PariType pariType); List<Pari> findByPariType(PariType pariType);
@Query("select coalesce(sum(p.mise),0) from Pari p where p.course.id=:courseId and p.pariType=:pariType") @EntityGraph(attributePaths = {"course", "selections", "selections.cheval"})
BigDecimal sumMiseByCourseIdAndPariType(Long courseId, PariType pariType); List<Pari> findByCourseIdAndPariType(Long courseId, PariType pariType);
@Query("select coalesce(sum(p.mise),0) from Pari p where p.course.id=:courseId and p.pariType=:pariType and p.cheval.id=:chevalId") @EntityGraph(attributePaths = {"course", "selections", "selections.cheval"})
BigDecimal sumMiseByCourseIdAndPariTypeAndChevalId(Long courseId, PariType pariType, Long chevalId); @Override
Optional<Pari> findById(Long id);
@Query("SELECT p FROM Pari p JOIN p.selections s WHERE p.course.id = :courseId AND p.pariType = :pariType AND s.cheval.id = :chevalId")
List<Pari> findByCourseAndTypeAndSelection(
@Param("courseId") Long courseId,
@Param("pariType") PariType pariType,
@Param("chevalId") Long chevalId);
@Query("select coalesce(sum(p.mise), 0) from Pari p where p.course.id = :courseId and p.pariType = :pariType")
BigDecimal sumMiseByCourseIdAndPariType(@Param("courseId") Long courseId, @Param("pariType") PariType pariType);
@Query("SELECT coalesce(sum(p.mise), 0) FROM Pari p JOIN p.selections s WHERE p.course.id = :courseId AND p.pariType = :pariType AND s.cheval.id = :chevalId")
BigDecimal sumMiseForSelection(
@Param("courseId") Long courseId,
@Param("pariType") PariType pariType,
@Param("chevalId") Long chevalId);
int countByCourseId(Long courseId); int countByCourseId(Long courseId);
} }

View File

@@ -0,0 +1,67 @@
package com.pmumali.plr.repositories;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import com.pmumali.plr.models.Resultat;
import com.pmumali.plr.models.ResultatCheval;
public interface ResultatChevalRepository extends JpaRepository<ResultatCheval, Long> {
List<ResultatCheval> findByResultatOrderByRangAsc(Resultat resultat);
List<ResultatCheval> findByResultat_Course_IdOrderByRangAsc(Long courseId);
List<ResultatCheval> findByResultatAndRang(Resultat resultat, Integer rang);
boolean existsByResultatAndChevalId(Resultat resultat, Long chevalId);
Optional<ResultatCheval> findByResultatAndChevalId(Resultat resultat, Long chevalId);
void deleteByResultatAndChevalId(Resultat resultat, Long chevalId);
@Modifying
@Transactional
@Query("delete from ResultatCheval rc where rc.resultat = :res")
void deleteByResultat(@org.springframework.data.repository.query.Param("res") Resultat res);
@Modifying
@Transactional
@Query("delete from ResultatCheval rc where rc.resultat.course.id = :courseId")
void deleteByCourseId(@org.springframework.data.repository.query.Param("courseId") Long courseId);
// @Query("""
// select rc
// from ResultatCheval rc
// join fetch rc.cheval ch
// where rc.resultat.course.id = :courseId
// order by rc.rang asc, rc.id asc
// """)
// List<ResultatCheval> findWithChevalByCourseIdOrderByRangAsc(Long courseId);
@Query("""
select rc, cc.numeroCheval
from ResultatCheval rc
join rc.cheval ch
join ChevalCourse cc on cc.cheval = ch and cc.course = rc.resultat.course
where rc.resultat.course.id = :courseId
order by rc.rang asc, rc.id asc
""")
List<Object[]> findWithChevalAndNumeroByCourseId(Long courseId);
// A) same query but with numeroCheval (via ChevalCourse) for DTO building:
@Query("""
select rc, cc.numeroCheval
from ResultatCheval rc
join rc.cheval ch
join ChevalCourse cc on cc.cheval = ch and cc.course = rc.resultat.course
where rc.resultat = :resultat
order by rc.rang asc, rc.id asc
""")
List<Object[]> findWithNumeroByResultat(@Param("resultat") Resultat resultat);
}

View File

@@ -0,0 +1,11 @@
package com.pmumali.plr.repositories;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.pmumali.plr.models.Resultat;
public interface ResultatRepository extends JpaRepository<Resultat, Long> {
Optional<Resultat> findByCourseId(Long courseId);
}

View File

@@ -20,26 +20,25 @@ public class ChevalService {
return chevalRepository.save(cheval); return chevalRepository.save(cheval);
} }
public List<Cheval> all(){ public List<Cheval> all() {
return chevalRepository.findAll(); return chevalRepository.findAll();
} }
public Cheval get(Long id){ public Cheval get(Long id) {
return chevalRepository.findById(id).orElseThrow(); return chevalRepository.findById(id).orElseThrow();
} }
public Cheval update(Long id, Cheval data){ public Cheval update(Long id, Cheval data) {
Cheval h = get(id); Cheval h = get(id);
h.setNom(data.getNom()); h.setNom(data.getNom());
h.setNumero(data.getNumero());
h.setNomEcurie(data.getNomEcurie()); h.setNomEcurie(data.getNomEcurie());
h.setBirthYear(data.getBirthYear()); h.setBirthYear(data.getBirthYear());
return chevalRepository.save(h); return chevalRepository.save(h);
} }
public void delete(Long id){ public void delete(Long id) {
chevalRepository.deleteById(id); chevalRepository.deleteById(id);
} }
} }

View File

@@ -68,6 +68,8 @@ public class CourseService {
existing.setLieu(data.getLieu()); existing.setLieu(data.getLieu());
if (Objects.nonNull(data.getDepartureDateTime())) if (Objects.nonNull(data.getDepartureDateTime()))
existing.setDepartureDateTime(data.getDepartureDateTime()); existing.setDepartureDateTime(data.getDepartureDateTime());
if (Objects.nonNull(data.getDistance()))
existing.setDistance(data.getDistance());
if (Objects.nonNull(data.getStatus())) if (Objects.nonNull(data.getStatus()))
existing.setStatus(data.getStatus()); existing.setStatus(data.getStatus());
@@ -75,16 +77,9 @@ public class CourseService {
} }
@Transactional @Transactional
public void deleteCourse(Long id, boolean hard) { public void deleteCourse(Long id) {
Course course = get(id); Course course = get(id);
if (!hard) {
// soft delete: mark as canceled
course.setStatus(CourseStatue.ANNULEE);
courseRepository.save(course);
return;
}
// hard delete: check constraints // hard delete: check constraints
int pariCount = pariRepository.countByCourseId(id); int pariCount = pariRepository.countByCourseId(id);
int inscriptionCount = chevalCourseRepository.countByCourse(course); int inscriptionCount = chevalCourseRepository.countByCourse(course);

View File

@@ -0,0 +1,288 @@
// com.pmumali.plr.services.WinnerService.java
package com.pmumali.plr.services;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.pmumali.plr.dtos.GainViewDto;
import com.pmumali.plr.enums.CourseStatue;
import com.pmumali.plr.enums.PariSousType;
import com.pmumali.plr.enums.PariType;
import com.pmumali.plr.models.Course;
import com.pmumali.plr.models.Pari;
import com.pmumali.plr.models.PariSelection;
import com.pmumali.plr.models.Resultat;
import com.pmumali.plr.models.ResultatCheval;
import com.pmumali.plr.repositories.ChevalCourseRepository;
import com.pmumali.plr.repositories.CourseRepository;
import com.pmumali.plr.repositories.PariRepository;
import com.pmumali.plr.repositories.ResultatChevalRepository;
import com.pmumali.plr.repositories.ResultatRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class GainService {
private final PariRepository pariRepository;
private final CourseRepository courseRepository;
private final ResultatRepository resultatRepository;
private final ResultatChevalRepository resultatChevalRepository;
private final ChevalCourseRepository chevalCourseRepository;
// ====== PUBLIC API ======
/**
* Winners for a course (all pari types).
* If the course is CLOTUREE and paris were settled, returns OFFICIAL winners (estGagnant=true with stored gains).
* Otherwise returns PROVISIONAL winners computed from official ranking (dead-heat aware) with gain=0.
*/
@Transactional(readOnly = true)
public List<GainViewDto> listWinners(Long courseId) {
Course course = mustGetCourse(courseId);
if (course.getStatus() == CourseStatue.CLOTUREE && resultatRepository.findByCourseId(courseId).isPresent()) {
// Prefer official winners if gains already computed (settlement executed)
List<Pari> official = pariRepository.findByCourseIdAndEstGagnantTrue(courseId);
if (!official.isEmpty()) {
return official.stream().map(this::toWinnerDtoOfficial).toList();
}
}
// Otherwise compute provisional from results (if any)
return computeProvisionalWinners(courseId, null);
}
/**
* Winners filtered by pari type.
*/
@Transactional(readOnly = true)
public List<GainViewDto> listWinnersByType(Long courseId, PariType pariType) {
Course course = mustGetCourse(courseId);
if (course.getStatus() == CourseStatue.CLOTUREE && resultatRepository.findByCourseId(courseId).isPresent()) {
List<Pari> official = pariRepository.findByCourseIdAndPariTypeAndEstGagnantTrue(courseId, pariType);
if (!official.isEmpty()) {
return official.stream().map(this::toWinnerDtoOfficial).toList();
}
}
return computeProvisionalWinners(courseId, pariType);
}
/**
* Quick check for a single pari: is it a winner?
* Uses official flag if available, otherwise computes provisional.
*/
@Transactional(readOnly = true)
public GainViewDto isWinner(Long pariId) {
Pari pari = pariRepository.findById(pariId)
.orElseThrow(() -> new EntityNotFoundException("Pari not found: " + pariId));
Long courseId = pari.getCourse().getId();
// If officially settled and marked
if (pari.getEstGagnant() != null) {
return toWinnerDtoOfficial(pari);
}
// Provisional compute (dead-heat aware)
Map<Integer, Set<Long>> rankMap = buildRankToChevalSet(courseId);
Set<Long> validChevaux = validParticipants(pari.getCourse().getId());
boolean win = isWinningBetDeadHeat(pari.getPariType(), pari, rankMap, validChevaux);
return toWinnerDtoProvisional(pari, win);
}
// ====== PROVISIONAL COMPUTE ======
private List<GainViewDto> computeProvisionalWinners(Long courseId, PariType filter) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Resultat not found for course: " + courseId));
Map<Integer, Set<Long>> rankMap = buildRankToChevalSet(res);
Set<Long> validChevaux = validParticipants(courseId);
List<Pari> paris = (filter == null)
? pariRepository.findByCourseId(courseId)
: pariRepository.findByCourseIdAndPariType(courseId, filter);
return paris.stream()
.filter(p -> isWinningBetDeadHeat(p.getPariType(), p, rankMap, validChevaux))
.map(p -> toWinnerDtoProvisional(p, true))
.toList();
}
// ====== MAPPING ======
private GainViewDto toWinnerDtoOfficial(Pari p) {
return GainViewDto.builder()
.pariId(p.getId())
.courseId(p.getCourse().getId())
.courseNom(p.getCourse().getNom())
.pariType(p.getPariType())
.pariSousType(detectSousTypeLabel(p)) // optional label for QUINTE+/MULTI
.bettorRef(p.getBettorRef())
.mise(p.getMise())
.gain(p.getGain() == null ? BigDecimal.ZERO : p.getGain())
.payoutMode("OFFICIAL")
.numeroPlus(p.getNumeroPlus())
.selections(p.getSelections().stream()
.map(s -> new GainViewDto.Selection(s.getCheval().getId(), s.getCheval().getNom(), s.getPosition()))
.toList())
.createdAt(p.getCreatedAt())
.build();
}
private GainViewDto toWinnerDtoProvisional(Pari p, boolean isWinner) {
return GainViewDto.builder()
.pariId(p.getId())
.courseId(p.getCourse().getId())
.courseNom(p.getCourse().getNom())
.pariType(p.getPariType())
.pariSousType(detectSousTypeLabel(p))
.bettorRef(p.getBettorRef())
.mise(p.getMise())
.gain(isWinner ? BigDecimal.ZERO : BigDecimal.ZERO) // no official payout yet
.payoutMode("PROVISIONAL")
.numeroPlus(p.getNumeroPlus())
.selections(p.getSelections().stream()
.map(s -> new GainViewDto.Selection(s.getCheval().getId(), s.getCheval().getNom(), s.getPosition()))
.toList())
.createdAt(p.getCreatedAt())
.build();
}
private String detectSousTypeLabel(Pari p) {
// Small helper to label QP/MULTI tickets
if (p.getPariType() == PariType.QUINTE_PLUS) {
// If you store a computed category, map it; otherwise leave null
return null;
}
if (p.getPariType() == PariType.MULTI) {
int n = (p.getSelectionCount() != null) ? p.getSelectionCount() : (p.getSelections() != null ? p.getSelections().size() : 0);
return switch (n) {
case 4 -> PariSousType.MULTI4.name();
case 5 -> PariSousType.MULTI5.name();
case 6 -> PariSousType.MULTI6.name();
case 7 -> PariSousType.MULTI7.name();
default -> null;
};
}
return null;
}
// ====== DEAD-HEAT AWARE WIN LOGIC (same as you use in PariService) ======
private boolean isWinningBetDeadHeat(PariType type, Pari p,
Map<Integer, Set<Long>> rankMap,
Set<Long> validChevaux) {
Map<Integer, Long> posToCheval = p.getSelections().stream()
.collect(Collectors.toMap(PariSelection::getPosition, s -> s.getCheval().getId()));
Set<Long> ticketChevaux = p.getSelections().stream()
.map(s -> s.getCheval().getId()).collect(Collectors.toSet());
if (!validChevaux.containsAll(ticketChevaux)) return false;
switch (type) {
case SIMPLE_GAGNANT -> {
Set<Long> firsts = rankMap.getOrDefault(1, Set.of());
return ticketChevaux.size() == 1 && firsts.contains(ticketChevaux.iterator().next());
}
case SIMPLE_PLACE -> {
int places = (validChevaux.size() <= 7) ? 2 : 3;
Set<Long> placeSet = new LinkedHashSet<>();
for (int r = 1; r <= places; r++) placeSet.addAll(rankMap.getOrDefault(r, Set.of()));
return ticketChevaux.size() == 1 && placeSet.contains(ticketChevaux.iterator().next());
}
case COUPLE_GAGNANT -> {
Set<Long> top2 = new LinkedHashSet<>();
top2.addAll(rankMap.getOrDefault(1, Set.of()));
top2.addAll(rankMap.getOrDefault(2, Set.of()));
return ticketChevaux.size() == 2 && top2.containsAll(ticketChevaux);
}
case COUPLE_ORDRE -> {
Long c1 = posToCheval.get(1);
Long c2 = posToCheval.get(2);
return c1 != null && c2 != null
&& rankMap.getOrDefault(1, Set.of()).contains(c1)
&& rankMap.getOrDefault(2, Set.of()).contains(c2);
}
case TRIO, TIERCE -> {
Set<Long> top3 = topNWithDeadHeat(rankMap, 3);
return ticketChevaux.size() == 3 && top3.containsAll(ticketChevaux);
}
case TRIO_ORDRE -> {
Long c1 = posToCheval.get(1), c2 = posToCheval.get(2), c3 = posToCheval.get(3);
return c1 != null && c2 != null && c3 != null
&& rankMap.getOrDefault(1, Set.of()).contains(c1)
&& rankMap.getOrDefault(2, Set.of()).contains(c2)
&& rankMap.getOrDefault(3, Set.of()).contains(c3);
}
case QUARTE -> {
Set<Long> top4 = topNWithDeadHeat(rankMap, 4);
return ticketChevaux.size() == 4 && top4.containsAll(ticketChevaux);
}
case QUINTE -> {
Set<Long> top5 = topNWithDeadHeat(rankMap, 5);
return ticketChevaux.size() == 5 && top5.containsAll(ticketChevaux);
}
case DEUX_SUR_QUATRE -> {
Set<Long> top4 = topNWithDeadHeat(rankMap, 4);
long match = ticketChevaux.stream().filter(top4::contains).count();
return match >= 2;
}
case MULTI -> {
Set<Long> mustHave = topNWithDeadHeat(rankMap, 4);
return ticketChevaux.containsAll(mustHave);
}
case QUINTE_PLUS, PICK_CINQ -> {
// Winners are paid by your settlement engine (QP sous-types); for “provisional”
// you can compute like QUINTE (désordre/ordre) if desired. Here: return false (neutral).
return false;
}
default -> { return false; }
}
}
private Set<Long> topNWithDeadHeat(Map<Integer, Set<Long>> rankMap, int n) {
Set<Long> out = new LinkedHashSet<>();
for (int i = 1; i <= n; i++) {
Set<Long> ids = rankMap.get(i);
if (ids != null) out.addAll(ids);
}
return out;
}
private Map<Integer, Set<Long>> buildRankToChevalSet(Long courseId) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Resultat not found for course: " + courseId));
return buildRankToChevalSet(res);
}
private Map<Integer, Set<Long>> buildRankToChevalSet(Resultat res) {
return resultatChevalRepository.findByResultatOrderByRangAsc(res).stream()
.collect(Collectors.groupingBy(
ResultatCheval::getRang,
LinkedHashMap::new,
Collectors.mapping(rc -> rc.getCheval().getId(), Collectors.toCollection(LinkedHashSet::new))
));
}
private Set<Long> validParticipants(Long courseId) {
return chevalCourseRepository.findByCourseId(courseId).stream()
.filter(cc -> !Boolean.TRUE.equals(cc.getNonPartant()) && !Boolean.TRUE.equals(cc.getEstDisqualifie()))
.map(cc -> cc.getCheval().getId())
.collect(Collectors.toSet());
}
private Course mustGetCourse(Long id) {
return courseRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Course not found: " + id));
}
}

View File

@@ -1,65 +1,776 @@
package com.pmumali.plr.services; package com.pmumali.plr.services;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.pmumali.plr.dtos.CreatePariRequestDto;
import com.pmumali.plr.dtos.PariResponseDto;
import com.pmumali.plr.dtos.PariSelectionDto;
import com.pmumali.plr.dtos.PariSelectionResponseDto;
import com.pmumali.plr.dtos.RapportDto;
import com.pmumali.plr.dtos.SettlementSummaryDto;
import com.pmumali.plr.dtos.UpdatePariRequestDto;
import com.pmumali.plr.enums.CourseStatue;
import com.pmumali.plr.enums.PariSousType;
import com.pmumali.plr.enums.PariType; import com.pmumali.plr.enums.PariType;
import com.pmumali.plr.models.Cheval;
import com.pmumali.plr.models.Course; import com.pmumali.plr.models.Course;
import com.pmumali.plr.models.Jackpot;
import com.pmumali.plr.models.Pari; import com.pmumali.plr.models.Pari;
import com.pmumali.plr.models.PariSelection;
import com.pmumali.plr.models.Resultat;
import com.pmumali.plr.repositories.ChevalCourseRepository; import com.pmumali.plr.repositories.ChevalCourseRepository;
import com.pmumali.plr.repositories.ChevalRepository;
import com.pmumali.plr.repositories.CourseRepository; import com.pmumali.plr.repositories.CourseRepository;
import com.pmumali.plr.repositories.JackpotRepository;
import com.pmumali.plr.repositories.PariRepository; import com.pmumali.plr.repositories.PariRepository;
import com.pmumali.plr.repositories.ResultatChevalRepository;
import com.pmumali.plr.repositories.ResultatRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data;
@Service @Service
@Data
@AllArgsConstructor @AllArgsConstructor
public class PariService { public class PariService {
private final PariRepository pariRepository; private final PariRepository pariRepository;
private final CourseRepository courseRepository; private final CourseRepository courseRepository;
private final ChevalRepository chevalRepository;
private final ChevalCourseRepository chevalCourseRepository; private final ChevalCourseRepository chevalCourseRepository;
private final ResultatRepository resultatRepository;
private final ResultatChevalRepository resultatChevalRepository;
private final JackpotRepository jackpotRepository;
// --- config prélèvements (pourcentage) — à terme: application.yml
private static final Map<PariType, BigDecimal> PRELEVEMENTS = Map.of(
PariType.SIMPLE_GAGNANT, new BigDecimal("0.18"),
PariType.SIMPLE_PLACE, new BigDecimal("0.18"),
PariType.COUPLE_GAGNANT, new BigDecimal("0.20")
);
// Quinté+ : affectation des parts de la cagnotte (après prélèvement)
private static final Map<PariSousType, BigDecimal> QP_REPARTITION = Map.of(
PariSousType.QP_ORDRE, new BigDecimal("0.50"),
PariSousType.QP_DESORDRE, new BigDecimal("0.30"),
PariSousType.QP_BONUS4, new BigDecimal("0.15"),
PariSousType.QP_BONUS3, new BigDecimal("0.05")
);
// Multi : une cagnotte par sous-type (chacun a sa propre cagnotte)
private static final Map<PariSousType, BigDecimal> MULTI_PRELEV = Map.of(
PariSousType.MULTI4, new BigDecimal("0.20"),
PariSousType.MULTI5, new BigDecimal("0.20"),
PariSousType.MULTI6, new BigDecimal("0.20"),
PariSousType.MULTI7, new BigDecimal("0.20")
);
private BigDecimal prelevementPour(PariType t) {
return switch (t) {
case QUINTE_PLUS -> new BigDecimal("0.22");
case MULTI -> new BigDecimal("0.20"); // non utilisé si on ventile par sous-type
default -> PRELEVEMENTS.getOrDefault(t, new BigDecimal("0.20"));
};
}
// ------------- CRUD -------------
@Transactional
public PariResponseDto createPari(CreatePariRequestDto requestDto) {
validateMise(requestDto.getMise());
Course course = mustGetCourse(requestDto.getCourseId());
ensureBettable(course);
Pari pari = new Pari();
pari.setCourse(course);
pari.setPariType(requestDto.getPariType());
pari.setMise(requestDto.getMise());
pari.setBettorRef(requestDto.getBettorRef());
if (requestDto.getPariType() == PariType.QUINTE_PLUS) {
pari.setNumeroPlus(String.format("%06d", new java.util.Random().nextInt(1_000_000)));
} else {
pari.setNumeroPlus(null);
}
pari.setSelectionCount(requestDto.getSelections() != null ? requestDto.getSelections().size() : null);
List<PariSelection> selections = validateAndCreateSelections(requestDto, pari, course);
pari.setSelections(selections);
Pari saved = pariRepository.save(pari);
return mapToDto(saved);
}
@Transactional @Transactional
public Pari create(Long courseId, Long chevalId, PariType pariType, BigDecimal mise, String bettorRef) { public PariResponseDto updatePari(Long id, UpdatePariRequestDto dto) {
Cheval cheval = chevalRepository.findById(chevalId).orElseThrow(); validateMise(dto.getMise());
Course course = courseRepository.findById(courseId).orElseThrow(); Pari existing = pariRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Pari not found: " + id));
Pari p = new Pari(); Course course = existing.getCourse();
ensureBettable(course); // no update if not PLANIFIEE
p.setCheval(cheval); // Rebuild selections
p.setCourse(course); existing.setPariType(dto.getPariType());
p.setPariType(pariType); existing.setMise(dto.getMise());
p.setMise(mise); existing.setBettorRef(dto.getBettorRef());
p.setBettorRef(bettorRef);
return pariRepository.save(p); // Clear to avoid unique constraint (pari_id, cheval_id) before inserting new ones
existing.getSelections().clear();
pariRepository.saveAndFlush(existing);
List<PariSelection> newSelections = validateAndCreateSelections(
toCreateDto(existing.getCourse().getId(), dto), existing, course);
existing.getSelections().addAll(newSelections);
Pari saved = pariRepository.save(existing);
return mapToDto(saved);
} }
public Pari get(Long id) { private CreatePariRequestDto toCreateDto(Long courseId, UpdatePariRequestDto dto){
return pariRepository.findById(id).orElseThrow(); CreatePariRequestDto c = new CreatePariRequestDto();
c.setCourseId(courseId);
c.setPariType(dto.getPariType());
c.setMise(dto.getMise());
c.setBettorRef(dto.getBettorRef());
c.setSelections(dto.getSelections());
return c;
} }
public List<Pari> all() { @Transactional
return pariRepository.findAll(); public void deletePari(Long id) {
Pari existing = pariRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Pari not found: " + id));
ensureBettable(existing.getCourse());
pariRepository.deleteById(id);
} }
public List<Pari> getParisByPariType(PariType pariType) { // ------------- READ -------------
return pariRepository.findByPariType(pariType); @Transactional(readOnly = true)
public PariResponseDto getPariById(Long id) {
Pari p = pariRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Pari not found with id: " + id));
return mapToDto(p);
} }
public List<Pari> getParisByCourseAndType(Long courseId, PariType pariType) { @Transactional(readOnly = true)
return pariRepository.findByCourseIdAndPariType(courseId, pariType); public List<PariResponseDto> all() {
return pariRepository.findAll().stream().map(this::mapToDto).toList();
} }
public java.math.BigDecimal sumMiseByCourseIdAndPariType(Long courseId, PariType pariType) { @Transactional(readOnly = true)
public List<PariResponseDto> getParisByPariType(PariType pariType) {
return pariRepository.findByPariType(pariType).stream().map(this::mapToDto).toList();
}
@Transactional(readOnly = true)
public List<PariResponseDto> getParisByCourseAndType(Long courseId, PariType pariType) {
return pariRepository.findByCourseIdAndPariType(courseId, pariType).stream().map(this::mapToDto).toList();
}
@Transactional(readOnly = true)
public BigDecimal sumMiseByCourseIdAndPariType(Long courseId, PariType pariType) {
return pariRepository.sumMiseByCourseIdAndPariType(courseId, pariType); return pariRepository.sumMiseByCourseIdAndPariType(courseId, pariType);
} }
// ------------- RÈGLEMENT -------------
@Transactional
public SettlementSummaryDto settleCourse(Long courseId) {
Course course = mustGetCourse(courseId);
Resultat resultat = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Resultat not found for course: " + courseId));
// Dead-Heat map: rank -> set of chevalIds
Map<Integer, Set<Long>> rankMap = buildRankToChevalSet(courseId);
// participants valides (hors NP/DQ)
Set<Long> validChevaux = chevalCourseRepository.findByCourse(course).stream()
.filter(cc -> !Boolean.TRUE.equals(cc.getNonPartant()) && !Boolean.TRUE.equals(cc.getEstDisqualifie()))
.map(cc -> cc.getCheval().getId())
.collect(Collectors.toSet());
Map<PariType, SettlementSummaryDto.PoolLine> poolMap = new LinkedHashMap<>();
List<RapportDto> rapports = new ArrayList<>();
// pour chaque type présent dans la course
List<Pari> paris = pariRepository.findByCourseId(courseId);
Map<PariType, List<Pari>> byType = paris.stream().collect(Collectors.groupingBy(Pari::getPariType));
// ----- QUINTE_PLUS -----
List<Pari> qp = byType.getOrDefault(PariType.QUINTE_PLUS, List.of());
if (!qp.isEmpty()) {
BigDecimal totalQP = qp.stream().map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal prelevQP = totalQP.multiply(prelevementPour(PariType.QUINTE_PLUS)).setScale(2, RoundingMode.DOWN);
BigDecimal cagnotteQP = totalQP.subtract(prelevQP).max(BigDecimal.ZERO);
Map<PariSousType, BigDecimal> envelope = new LinkedHashMap<>();
for (var e : QP_REPARTITION.entrySet()) {
envelope.put(e.getKey(), cagnotteQP.multiply(e.getValue()).setScale(2, RoundingMode.DOWN));
}
Map<PariSousType, List<Pari>> winnersByCat = new EnumMap<>(PariSousType.class);
for (Pari p : qp) {
PariSousType cat = computeQuintePlusSousTypeDeadHeat(p, rankMap);
if (cat != null) {
winnersByCat.computeIfAbsent(cat, k -> new ArrayList<>()).add(p);
}
}
for (var cat : List.of(PariSousType.QP_ORDRE, PariSousType.QP_DESORDRE, PariSousType.QP_BONUS4, PariSousType.QP_BONUS3)) {
List<Pari> winners = winnersByCat.getOrDefault(cat, List.of());
BigDecimal bucket = envelope.getOrDefault(cat, BigDecimal.ZERO);
BigDecimal miseGagnante = winners.stream().map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal rapport = (miseGagnante.signum()==0) ? BigDecimal.ZERO
: bucket.divide(miseGagnante, 4, RoundingMode.HALF_UP);
for (Pari p : winners) {
BigDecimal gain = p.getMise().multiply(rapport).setScale(2, RoundingMode.DOWN);
p.setGain(gain);
p.setEstGagnant(true);
}
}
// Perdants QP
Set<Long> paidIds = winnersByCat.values().stream().flatMap(List::stream).map(Pari::getId).collect(Collectors.toSet());
for (Pari p : qp) {
if (!paidIds.contains(p.getId())) { p.setGain(BigDecimal.ZERO); p.setEstGagnant(false); }
}
// TIRELIRE : Ordre + NumeroPlus
Jackpot jp = jackpotRepository.findByPariType(PariType.QUINTE_PLUS)
.orElseGet(() -> {
Jackpot j = new Jackpot();
j.setPariType(PariType.QUINTE_PLUS);
j.setSolde(BigDecimal.ZERO);
j.setLastUpdated(java.time.LocalDateTime.now());
return jackpotRepository.save(j);
});
String drawn = resultat.getNumeroPlusGagnant();
List<Pari> ordreWinners = winnersByCat.getOrDefault(PariSousType.QP_ORDRE, List.of());
List<Pari> plusWinners = (drawn == null) ? List.of()
: ordreWinners.stream().filter(p -> drawn.equals(p.getNumeroPlus())).toList();
if (!plusWinners.isEmpty() && jp.getSolde().signum() > 0) {
BigDecimal totalMisePlus = plusWinners.stream().map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalMisePlus.signum() > 0) {
BigDecimal jackpot = jp.getSolde();
jp.setSolde(BigDecimal.ZERO);
jp.setLastUpdated(java.time.LocalDateTime.now());
for (Pari p : plusWinners) {
BigDecimal part = p.getMise().divide(totalMisePlus, 8, RoundingMode.HALF_UP);
BigDecimal bonus = jackpot.multiply(part).setScale(2, RoundingMode.DOWN);
p.setGain(p.getGain().add(bonus));
}
jackpotRepository.save(jp);
}
} else {
// créditer la tirelire d'une part (ex: 5% du totalQP)
BigDecimal aliment = totalQP.multiply(new BigDecimal("0.05")).setScale(2, RoundingMode.DOWN);
jp.setSolde(jp.getSolde().add(aliment));
jp.setLastUpdated(java.time.LocalDateTime.now());
jackpotRepository.save(jp);
}
pariRepository.saveAll(qp);
// Pool line
poolMap.put(PariType.QUINTE_PLUS, SettlementSummaryDto.PoolLine.builder()
.totalMise(totalQP).prelevement(prelevQP).cagnotte(cagnotteQP)
.totalMiseGagnants(
qp.stream().filter(b -> Boolean.TRUE.equals(b.getEstGagnant()))
.map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add))
.build());
// Rapports (€/€ misé) indicatifs
for (var cat : QP_REPARTITION.keySet()) {
List<Pari> winners = winnersByCat.getOrDefault(cat, List.of());
BigDecimal bucket = envelope.getOrDefault(cat, BigDecimal.ZERO);
BigDecimal miseG = winners.stream().map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal rapport = (miseG.signum()==0) ? BigDecimal.ZERO : bucket.divide(miseG, 4, RoundingMode.HALF_UP);
rapports.add(RapportDto.builder()
.courseId(courseId).pariType(PariType.QUINTE_PLUS)
.rapportParUnite(rapport.setScale(2, RoundingMode.DOWN))
.build());
}
}
// ----- MULTI -----
List<Pari> multi = byType.getOrDefault(PariType.MULTI, List.of());
if (!multi.isEmpty()) {
Map<PariSousType, List<Pari>> bySub = multi.stream()
.collect(Collectors.groupingBy(this::computeMultiSousType, () -> new EnumMap<>(PariSousType.class), Collectors.toList()));
for (var sub : List.of(PariSousType.MULTI4, PariSousType.MULTI5, PariSousType.MULTI6, PariSousType.MULTI7)) {
List<Pari> bucketBets = bySub.getOrDefault(sub, List.of());
if (bucketBets.isEmpty()) continue;
BigDecimal total = bucketBets.stream().map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal prelev = total.multiply(MULTI_PRELEV.getOrDefault(sub, prelevementPour(PariType.MULTI))).setScale(2, RoundingMode.DOWN);
BigDecimal cagnotte = total.subtract(prelev).max(BigDecimal.ZERO);
// gagnants = tickets qui contiennent tous les chevaux classés dans les 4 premiers (Dead-Heat)
List<Pari> winners = bucketBets.stream().filter(p -> isMultiWinningDeadHeat(p, rankMap)).toList();
BigDecimal miseG = winners.stream().map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal rapport = (miseG.signum()==0) ? BigDecimal.ZERO
: cagnotte.divide(miseG, 4, RoundingMode.HALF_UP);
for (Pari p : bucketBets) {
if (winners.contains(p)) {
BigDecimal gain = p.getMise().multiply(rapport).setScale(2, RoundingMode.DOWN);
p.setGain(gain);
p.setEstGagnant(true);
} else {
p.setGain(BigDecimal.ZERO);
p.setEstGagnant(false);
}
}
pariRepository.saveAll(bucketBets);
// Agréger au niveau type MULTI
poolMap.merge(PariType.MULTI,
SettlementSummaryDto.PoolLine.builder()
.totalMise(total)
.prelevement(prelev)
.cagnotte(cagnotte)
.totalMiseGagnants(miseG)
.build(),
(a,b) -> SettlementSummaryDto.PoolLine.builder()
.totalMise(a.getTotalMise().add(b.getTotalMise()))
.prelevement(a.getPrelevement().add(b.getPrelevement()))
.cagnotte(a.getCagnotte().add(b.getCagnotte()))
.totalMiseGagnants(a.getTotalMiseGagnants().add(b.getTotalMiseGagnants()))
.build());
rapports.add(RapportDto.builder()
.courseId(courseId)
.pariType(PariType.MULTI)
.rapportParUnite(rapport.setScale(2, RoundingMode.DOWN))
.build());
}
}
// ----- AUTRES TYPES (hors QUINTE_PLUS & MULTI déjà traités) -----
for (var entry : byType.entrySet()) {
PariType type = entry.getKey();
if (type == PariType.QUINTE_PLUS || type == PariType.MULTI) continue; // déjà payé ci-dessus
List<Pari> bets = entry.getValue();
BigDecimal totalMise = bets.stream().map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal prelev = totalMise.multiply(prelevementPour(type)).setScale(2, RoundingMode.DOWN);
BigDecimal cagnotte = totalMise.subtract(prelev).max(BigDecimal.ZERO);
// gagnants (Dead-Heat aware)
List<Pari> gagnants = bets.stream()
.filter(b -> isWinningBetDeadHeat(type, b, rankMap, validChevaux))
.toList();
BigDecimal miseGagnante = gagnants.stream()
.map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal rapport = (miseGagnante.signum() == 0)
? BigDecimal.ZERO
: cagnotte.divide(miseGagnante, 4, RoundingMode.HALF_UP);
for (Pari p : bets) {
if (gagnants.contains(p)) {
BigDecimal gain = p.getMise().multiply(rapport).setScale(2, RoundingMode.DOWN);
p.setGain(gain);
p.setEstGagnant(true);
} else {
p.setGain(BigDecimal.ZERO);
p.setEstGagnant(false);
}
}
pariRepository.saveAll(bets);
poolMap.put(type, SettlementSummaryDto.PoolLine.builder()
.totalMise(totalMise)
.prelevement(prelev)
.cagnotte(cagnotte)
.totalMiseGagnants(miseGagnante)
.build());
rapports.add(RapportDto.builder()
.courseId(courseId)
.pariType(type)
.rapportParUnite(rapport.setScale(2, RoundingMode.DOWN))
.build());
}
// Clôturer la course
if (course.getStatus() != CourseStatue.CLOTUREE) {
course.setStatus(CourseStatue.CLOTUREE);
courseRepository.save(course);
}
return SettlementSummaryDto.builder()
.courseId(courseId)
.pools(poolMap)
.rapports(rapports)
.closed(true)
.build();
}
@Transactional(readOnly = true)
public SettlementSummaryDto getSettlementReport(Long courseId) {
Course course = mustGetCourse(courseId);
List<Pari> paris = pariRepository.findByCourseId(courseId);
Map<PariType, List<Pari>> byType = paris.stream().collect(Collectors.groupingBy(Pari::getPariType));
Map<PariType, SettlementSummaryDto.PoolLine> pools = new LinkedHashMap<>();
List<RapportDto> rapports = new ArrayList<>();
for (var e : byType.entrySet()) {
PariType t = e.getKey();
List<Pari> bets = e.getValue();
BigDecimal total = bets.stream().map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal prelev = total.multiply(prelevementPour(t)).setScale(2, RoundingMode.DOWN);
BigDecimal cagnotte = total.subtract(prelev).max(BigDecimal.ZERO);
BigDecimal miseG = bets.stream().filter(b -> Boolean.TRUE.equals(b.getEstGagnant()))
.map(Pari::getMise).reduce(BigDecimal.ZERO, BigDecimal::add);
pools.put(t, SettlementSummaryDto.PoolLine.builder()
.totalMise(total).prelevement(prelev).cagnotte(cagnotte).totalMiseGagnants(miseG).build());
BigDecimal rapport = (miseG.signum()==0) ? BigDecimal.ZERO :
cagnotte.divide(miseG, 4, RoundingMode.HALF_UP);
rapports.add(RapportDto.builder()
.courseId(courseId).pariType(t).rapportParUnite(rapport.setScale(2, RoundingMode.DOWN)).build());
}
return SettlementSummaryDto.builder()
.courseId(courseId)
.pools(pools)
.rapports(rapports)
.closed(course.getStatus()==CourseStatue.CLOTUREE)
.build();
}
// --------- Helpers & matching ----------
private void validateMise(BigDecimal mise){
if (mise == null || mise.signum() <= 0) throw new IllegalArgumentException("La mise doit être > 0.");
}
private Course mustGetCourse(Long id){
return courseRepository.findById(id).orElseThrow(() -> new EntityNotFoundException("Course not found: " + id));
}
private void ensureBettable(Course c){
if (c.getStatus() != CourseStatue.PLANIFIEE)
throw new IllegalStateException("Opération interdite: la course n'est pas en statut PLANIFIEE.");
}
private PariResponseDto mapToDto(Pari pari) {
var selectionDtos = pari.getSelections().stream()
.map(s -> PariSelectionResponseDto.builder()
.chevalId(s.getCheval().getId())
.nomCheval(s.getCheval().getNom())
.position(s.getPosition())
.build())
.toList();
return PariResponseDto.builder()
.id(pari.getId())
.courseId(pari.getCourse().getId())
.courseNom(pari.getCourse().getNom())
.pariType(pari.getPariType())
.mise(pari.getMise())
.bettorRef(pari.getBettorRef())
.estGagnant(pari.getEstGagnant())
.gain(pari.getGain())
.createdAt(pari.getCreatedAt())
.selections(selectionDtos)
.build();
}
// --- VALIDATION selections existante (ta version, consolidée) ---
private List<PariSelection> validateAndCreateSelections(CreatePariRequestDto requestDto, Pari pari, Course course) {
PariType pariType = requestDto.getPariType();
List<PariSelectionDto> selDtos = requestDto.getSelections();
if (selDtos == null || selDtos.isEmpty()) {
throw new IllegalArgumentException("Au moins une sélection est requise.");
}
int minExpected;
int maxExpected;
boolean positionsMustBeFullRange = false;
switch (pariType) {
case SIMPLE_GAGNANT, SIMPLE_PLACE -> minExpected = maxExpected = 1;
case COUPLE_GAGNANT -> minExpected = maxExpected = 2;
case COUPLE_ORDRE -> { minExpected = maxExpected = 2; positionsMustBeFullRange = true; }
case TRIO_ORDRE -> { minExpected = maxExpected = 3; positionsMustBeFullRange = true; }
case TRIO, TIERCE -> minExpected = maxExpected = 3;
case QUARTE -> minExpected = maxExpected = 4;
case QUINTE, QUINTE_PLUS -> minExpected = maxExpected = 5;
case MULTI -> { minExpected = 4; maxExpected = 7; }
case DEUX_SUR_QUATRE -> minExpected = maxExpected = 2;
case PICK_CINQ -> minExpected = maxExpected = 5;
default -> throw new IllegalArgumentException("Type de pari non supporté: " + pariType);
}
int provided = selDtos.size();
if (provided < minExpected || provided > maxExpected) {
String attendu = (minExpected == maxExpected) ? String.valueOf(minExpected) : (minExpected + " à " + maxExpected);
throw new IllegalArgumentException(
"Nombre de sélections invalide pour " + pariType + " : " + attendu + " attendu(s) mais " + provided + " fourni(s)"
+ (positionsMustBeFullRange ? " (positions 1.." + provided + " requises)" : "")
);
}
Set<Long> chevalIdSet = new java.util.HashSet<>();
for (PariSelectionDto s : selDtos) {
if (!chevalIdSet.add(s.getChevalId())) {
throw new IllegalArgumentException("Duplication de chevalId dans les sélections: " + s.getChevalId());
}
}
Map<Integer, Long> posToCheval = new java.util.HashMap<>();
boolean anyPositionProvided = false;
for (PariSelectionDto s : selDtos) {
Integer pos = s.getPosition();
if (pos != null) {
anyPositionProvided = true;
if (pos <= 0) {
throw new IllegalArgumentException(
"La position doit être un entier positif. Position invalide pour chevalId " + s.getChevalId() + " : " + pos
);
}
if (posToCheval.containsKey(pos)) {
throw new IllegalArgumentException("Position dupliquée fournie : " + pos);
}
posToCheval.put(pos, s.getChevalId());
}
}
if (positionsMustBeFullRange) {
int expectedN = provided;
if (!anyPositionProvided) {
throw new IllegalArgumentException(
"Les positions sont requises pour le pari ordonné " + pariType + ". Fournir les positions 1.." + expectedN
);
}
for (int i = 1; i <= expectedN; i++) {
if (!posToCheval.containsKey(i)) {
throw new IllegalArgumentException(
"Le pari ordonné " + pariType + " nécessite toutes les positions de 1 à " + expectedN + ". Position manquante : " + i
);
}
}
}
List<PariSelection> resultSelections = new ArrayList<>(provided);
int assignPosCounter = 1;
for (PariSelectionDto s : selDtos) {
Long chevalId = s.getChevalId();
var chevalCourseOpt = chevalCourseRepository.findByCourseAndChevalId(course, chevalId);
var chevalCourse = chevalCourseOpt.orElseThrow(
() -> new jakarta.persistence.EntityNotFoundException(
"Cheval avec id " + chevalId + " non inscrit à la course " + course.getId()
)
);
if (Boolean.TRUE.equals(chevalCourse.getNonPartant())) {
throw new IllegalStateException(
"Cheval id " + chevalId + " est marqué Non-Partant pour la course " + course.getId()
);
}
if (Boolean.TRUE.equals(chevalCourse.getEstDisqualifie())) {
throw new IllegalStateException(
"Cheval id " + chevalId + " est Disqualifié pour la course " + course.getId()
);
}
PariSelection selection = new PariSelection();
selection.setPari(pari);
selection.setCheval(chevalCourse.getCheval());
Integer providedPos = s.getPosition();
if (providedPos != null) {
selection.setPosition(providedPos);
} else {
selection.setPosition(assignPosCounter++);
}
resultSelections.add(selection);
}
Set<Integer> finalPositions = new java.util.HashSet<>();
for (PariSelection ps : resultSelections) {
if (!finalPositions.add(ps.getPosition())) {
throw new IllegalArgumentException(
"Positions dupliquées dans les sélections finalisées : " + ps.getPosition()
);
}
}
return resultSelections;
}
// --- Dead-Heat matching helpers ---
private Map<Integer, Set<Long>> buildRankToChevalSet(Long courseId) {
var res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new jakarta.persistence.EntityNotFoundException("Resultat not found for course: " + courseId));
return resultatChevalRepository.findByResultatOrderByRangAsc(res).stream()
.collect(Collectors.groupingBy(
rc -> rc.getRang(),
LinkedHashMap::new,
Collectors.mapping(rc -> rc.getCheval().getId(), Collectors.toCollection(LinkedHashSet::new))
));
}
private Set<Long> topNWithDeadHeat(Map<Integer, Set<Long>> rankMap, int n) {
Set<Long> out = new LinkedHashSet<>();
for (int i = 1; i <= n; i++) {
Set<Long> ids = rankMap.get(i);
if (ids != null) out.addAll(ids);
}
return out;
}
private boolean isMultiWinningDeadHeat(Pari p, Map<Integer, Set<Long>> rankMap) {
Set<Long> pick = p.getSelections().stream().map(s -> s.getCheval().getId()).collect(Collectors.toSet());
Set<Long> top4 = topNWithDeadHeat(rankMap, 4);
return pick.containsAll(top4);
}
private boolean isWinningBetDeadHeat(PariType type, Pari p,
Map<Integer, Set<Long>> rankMap,
Set<Long> validChevaux) {
Map<Integer, Long> posToCheval = p.getSelections().stream()
.collect(Collectors.toMap(PariSelection::getPosition, s -> s.getCheval().getId()));
Set<Long> ticketChevaux = p.getSelections().stream()
.map(s -> s.getCheval().getId()).collect(Collectors.toSet());
if (!validChevaux.containsAll(ticketChevaux)) return false;
switch (type) {
case SIMPLE_GAGNANT -> {
Set<Long> firsts = rankMap.getOrDefault(1, Set.of());
return ticketChevaux.size() == 1 && firsts.contains(ticketChevaux.iterator().next());
}
case SIMPLE_PLACE -> {
int places = (validChevaux.size() <= 7) ? 2 : 3;
Set<Long> placeSet = new LinkedHashSet<>();
for (int r = 1; r <= places; r++) {
placeSet.addAll(rankMap.getOrDefault(r, Set.of()));
}
return ticketChevaux.size() == 1 && placeSet.contains(ticketChevaux.iterator().next());
}
case COUPLE_GAGNANT -> {
Set<Long> top2 = new LinkedHashSet<>();
top2.addAll(rankMap.getOrDefault(1, Set.of()));
top2.addAll(rankMap.getOrDefault(2, Set.of()));
return ticketChevaux.size() == 2 && top2.containsAll(ticketChevaux);
}
case COUPLE_ORDRE -> {
Long c1 = posToCheval.get(1);
Long c2 = posToCheval.get(2);
return c1 != null && c2 != null
&& rankMap.getOrDefault(1, Set.of()).contains(c1)
&& rankMap.getOrDefault(2, Set.of()).contains(c2);
}
case TRIO, TIERCE -> {
Set<Long> top3 = topNWithDeadHeat(rankMap, 3);
return ticketChevaux.size() == 3 && top3.containsAll(ticketChevaux);
}
case TRIO_ORDRE -> {
Long c1 = posToCheval.get(1);
Long c2 = posToCheval.get(2);
Long c3 = posToCheval.get(3);
return c1 != null && c2 != null && c3 != null
&& rankMap.getOrDefault(1, Set.of()).contains(c1)
&& rankMap.getOrDefault(2, Set.of()).contains(c2)
&& rankMap.getOrDefault(3, Set.of()).contains(c3);
}
case QUARTE -> {
Set<Long> top4 = topNWithDeadHeat(rankMap, 4);
return ticketChevaux.size() == 4 && top4.containsAll(ticketChevaux);
}
case QUINTE -> {
Set<Long> top5 = topNWithDeadHeat(rankMap, 5);
return ticketChevaux.size() == 5 && top5.containsAll(ticketChevaux);
}
case DEUX_SUR_QUATRE -> {
Set<Long> top4 = topNWithDeadHeat(rankMap, 4);
long match = ticketChevaux.stream().filter(top4::contains).count();
return match >= 2;
}
case MULTI -> {
Set<Long> mustHave = topNWithDeadHeat(rankMap, 4);
return ticketChevaux.containsAll(mustHave);
}
case QUINTE_PLUS, PICK_CINQ -> {
return false;
}
default -> {
return false;
}
}
}
// Quinté+ category with Dead-Heat
private PariSousType computeQuintePlusSousTypeDeadHeat(Pari p, Map<Integer, Set<Long>> rankMap) {
if (matchOrdreSet(p, rankMap, 5)) return PariSousType.QP_ORDRE;
if (matchDesordreSet(p, rankMap, 5)) return PariSousType.QP_DESORDRE;
if (matchKofNSet(p, rankMap, 4, 5)) return PariSousType.QP_BONUS4;
if (matchKofNSet(p, rankMap, 3, 5)) return PariSousType.QP_BONUS3;
return null;
}
private boolean matchOrdreSet(Pari p, Map<Integer, Set<Long>> rankMap, int n) {
Map<Integer, Long> pos = p.getSelections().stream()
.collect(Collectors.toMap(PariSelection::getPosition, s -> s.getCheval().getId()));
for (int i = 1; i <= n; i++) {
Long pick = pos.get(i);
if (pick == null || !rankMap.getOrDefault(i, Set.of()).contains(pick)) return false;
}
return true;
}
private boolean matchDesordreSet(Pari p, Map<Integer, Set<Long>> rankMap, int n) {
Set<Long> pick = p.getSelections().stream().map(s -> s.getCheval().getId()).collect(Collectors.toSet());
Set<Long> topN = topNWithDeadHeat(rankMap, n);
return pick.size()==n && topN.containsAll(pick);
}
private boolean matchKofNSet(Pari p, Map<Integer, Set<Long>> rankMap, int k, int n) {
Set<Long> pick = p.getSelections().stream().map(s -> s.getCheval().getId()).collect(Collectors.toSet());
Set<Long> topN = topNWithDeadHeat(rankMap, n);
long matched = pick.stream().filter(topN::contains).count();
return matched >= k;
}
private PariSousType computeMultiSousType(Pari p) {
Integer nObj = p.getSelectionCount();
int n = (nObj != null) ? nObj : (p.getSelections() != null ? p.getSelections().size() : 0);
return switch (n) {
case 4 -> PariSousType.MULTI4;
case 5 -> PariSousType.MULTI5;
case 6 -> PariSousType.MULTI6;
case 7 -> PariSousType.MULTI7;
default -> null;
};
}
} }

View File

@@ -0,0 +1,386 @@
package com.pmumali.plr.services;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.pmumali.plr.dtos.ChevalDto;
import com.pmumali.plr.dtos.ResultatChevalDto;
import com.pmumali.plr.dtos.ResultatChevalView;
import com.pmumali.plr.dtos.ResultatCreationDto;
import com.pmumali.plr.dtos.ResultatDto;
import com.pmumali.plr.enums.CourseStatue;
import com.pmumali.plr.models.Cheval;
import com.pmumali.plr.models.ChevalCourse;
import com.pmumali.plr.models.Course;
import com.pmumali.plr.models.Resultat;
import com.pmumali.plr.models.ResultatCheval;
import com.pmumali.plr.repositories.ChevalCourseRepository;
import com.pmumali.plr.repositories.ChevalRepository;
import com.pmumali.plr.repositories.CourseRepository;
import com.pmumali.plr.repositories.ResultatChevalRepository;
import com.pmumali.plr.repositories.ResultatRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.AllArgsConstructor;
@Service
@AllArgsConstructor
public class ResultatService {
private final ResultatRepository resultatRepository;
private final ResultatChevalRepository resultatChevalRepository;
private final CourseRepository courseRepository;
private final ChevalRepository chevalRepository;
private final ChevalCourseRepository chevalCourseRepository;
// ----------------- CREATE -----------------
@Transactional
public ResultatDto createResultat(ResultatCreationDto dto) {
Course course = courseRepository.findById(dto.getCourseId())
.orElseThrow(() -> new EntityNotFoundException("Course not found with ID: " + dto.getCourseId()));
if (resultatRepository.findByCourseId(course.getId()).isPresent()) {
throw new IllegalArgumentException("A result already exists for this course.");
}
// Validate horses belong to course & are not NP/DQ
validateHorsesForCourseAndStatus(course, dto.getResultatsChevaux());
Resultat res = new Resultat();
res.setCourse(course);
res.setDateOfficielle(LocalDateTime.now());
if (dto.getNumeroPlusGagnant() != null && !dto.getNumeroPlusGagnant().isBlank()) {
res.setNumeroPlusGagnant(dto.getNumeroPlusGagnant());
}
// ⬇️ Save into a new final variable so its effectively final for lambdas
final Resultat savedRes = resultatRepository.save(res);
List<ResultatCheval> lignes = dto.getResultatsChevaux().stream()
.map(rc -> {
Cheval cheval = chevalRepository.findById(rc.getChevalId())
.orElseThrow(() -> new EntityNotFoundException("Cheval not found: " + rc.getChevalId()));
ResultatCheval line = new ResultatCheval();
line.setResultat(savedRes); // ⬅️ use savedRes here
line.setCheval(cheval);
line.setRang(rc.getRang()); // Dead-Heat allowed
return line;
})
.toList();
resultatChevalRepository.saveAll(lignes);
savedRes.setResultatsChevaux(lignes);
// Close course (official)
course.setStatus(CourseStatue.CLOTUREE);
courseRepository.save(course);
return toDto(savedRes);
}
// ----------------- READ -----------------
@Transactional(readOnly = true)
public List<ResultatDto> findAllResults() {
return resultatRepository.findAll().stream().map(this::toDto).toList();
}
@Transactional(readOnly = true)
public ResultatDto findResultatByCourseId(Long courseId) {
return toDto(resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId)));
}
@Transactional(readOnly = true)
public List<ResultatChevalView> listResultatChevauxViewExt(Long courseId) {
return resultatChevalRepository.findWithChevalAndNumeroByCourseId(courseId).stream()
.map(row -> {
var rc = (ResultatCheval) row[0];
var numero = (Integer) row[1];
var ch = rc.getCheval();
var chevalDto = new ChevalDto(ch.getId(), ch.getNom(), ch.getNomEcurie(), ch.getBirthYear());
return new ResultatChevalView(chevalDto, numero, rc.getRang());
})
.toList();
}
// ----------------- UPDATE (full reorder) -----------------
@Transactional
public Resultat updateResultat(Long courseId, List<ResultatChevalDto> updated) {
if (updated == null || updated.isEmpty()) {
throw new IllegalArgumentException("resultatsChevaux is required.");
}
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId));
// validate (registered, not NP/DQ, ranks > 0)
validateHorsesForCourseAndStatus(res.getCourse(), updated);
// guard: no duplicate chevalId in payload
var seen = new java.util.HashSet<Long>();
for (var rc : updated) {
if (!seen.add(rc.getChevalId())) {
throw new IllegalArgumentException("Duplicate chevalId in payload: " + rc.getChevalId());
}
}
// 1) delete all existing children for this resultat
resultatChevalRepository.deleteByResultat(res);
// 2) FLUSH NOW so DB is clean before inserts
resultatChevalRepository.flush();
// 3) reinsert new lines
var lines = new java.util.ArrayList<ResultatCheval>(updated.size());
for (var dto : updated) {
Cheval cheval = chevalRepository.findById(dto.getChevalId())
.orElseThrow(() -> new EntityNotFoundException("Cheval not found: " + dto.getChevalId()));
var line = new ResultatCheval();
line.setResultat(res);
line.setCheval(cheval);
line.setRang(dto.getRang()); // Dead-Heat allowed
lines.add(line);
}
resultatChevalRepository.saveAll(lines);
// keep the managed association in sync (no replace of the list instance)
res.getResultatsChevaux().clear();
res.getResultatsChevaux().addAll(lines);
return resultatRepository.save(res);
}
// ----------------- DELETE (by course) -----------------
@Transactional
public void deleteResultat(Long courseId) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId));
// delete children then parent
List<ResultatCheval> lines = resultatChevalRepository.findByResultatOrderByRangAsc(res);
resultatChevalRepository.deleteAll(lines);
resultatRepository.delete(res);
// Optionnel: repasser la course en PLANIFIEE ou laissée CLOTUREE (ici on ne change pas)
}
// ----------------- CHILD OPS (ligne résultat) -----------------
@Transactional
public ResultatChevalView addResultatCheval(Long courseId, ResultatChevalDto dto) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId));
ensureChevalIsValidForCourse(res.getCourse(), dto.getChevalId());
if (resultatChevalRepository.existsByResultatAndChevalId(res, dto.getChevalId())) {
throw new IllegalArgumentException("Cheval already present in this result.");
}
Cheval cheval = chevalRepository.findById(dto.getChevalId())
.orElseThrow(() -> new EntityNotFoundException("Cheval not found: " + dto.getChevalId()));
ResultatCheval line = new ResultatCheval();
line.setResultat(res);
line.setCheval(cheval);
line.setRang(dto.getRang()); // Dead-Heat allowed
line = resultatChevalRepository.save(line);
// (A) fetch numeroCheval for this horse in this course
Integer numero = chevalCourseRepository.findByCourseAndCheval(res.getCourse(), cheval)
.map(cc -> cc.getNumeroCheval())
.orElse(null);
ChevalDto chevalDto = new ChevalDto(cheval.getId(), cheval.getNom(), cheval.getNomEcurie(), cheval.getBirthYear());
return new ResultatChevalView(chevalDto, numero, line.getRang());
}
@Transactional
public ResultatCheval upsertResultatCheval(Long courseId, ResultatChevalDto dto) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId));
ensureChevalIsValidForCourse(res.getCourse(), dto.getChevalId());
ResultatCheval line = resultatChevalRepository.findByResultatAndChevalId(res, dto.getChevalId())
.orElseGet(() -> {
ResultatCheval created = new ResultatCheval();
created.setResultat(res);
created.setCheval(chevalRepository.findById(dto.getChevalId())
.orElseThrow(() -> new EntityNotFoundException("Cheval not found: " + dto.getChevalId())));
return created;
});
line.setRang(dto.getRang()); // Dead-Heat autorisé
return resultatChevalRepository.save(line);
}
@Transactional
public void deleteResultatCheval(Long courseId, Long chevalId) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId));
resultatChevalRepository.deleteByResultatAndChevalId(res, chevalId);
}
// ----------------- Numero+ (Quinté+) -----------------
@Transactional
public Resultat setNumeroPlus(Long courseId, String numeroPlus) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId));
res.setNumeroPlusGagnant(numeroPlus);
return resultatRepository.save(res);
}
@Transactional
public Resultat drawNumeroPlus(Long courseId) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId));
String drawn = String.format("%06d", new java.util.Random().nextInt(1_000_000));
res.setNumeroPlusGagnant(drawn);
return resultatRepository.save(res);
}
// ----------------- Validation & Helpers règlement -----------------
@Transactional(readOnly = true)
public Map<String, Object> validate(Long courseId) {
Resultat res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId));
List<ResultatCheval> lines = resultatChevalRepository.findByResultatOrderByRangAsc(res);
List<String> errors = new ArrayList<>();
// Tous les chevaux doivent être inscrits & non NP/DQ
Set<Long> validChevaux = chevalCourseRepository.findByCourse(res.getCourse()).stream()
.filter(cc -> !Boolean.TRUE.equals(cc.getNonPartant()) && !Boolean.TRUE.equals(cc.getEstDisqualifie()))
.map(cc -> cc.getCheval().getId()).collect(Collectors.toSet());
for (ResultatCheval rc : lines) {
if (!validChevaux.contains(rc.getCheval().getId())) {
errors.add("Cheval " + rc.getCheval().getId() + " not valid (NP/DQ or not registered).");
}
}
// Détecter Dead-Heat: rang -> nb chevaux
Map<Integer, Long> countsByRank = lines.stream()
.collect(Collectors.groupingBy(ResultatCheval::getRang, LinkedHashMap::new, Collectors.counting()));
Map<Integer, Long> deadHeats = countsByRank.entrySet().stream()
.filter(e -> e.getValue() > 1L)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
boolean ok = errors.isEmpty();
Map<String, Object> out = new LinkedHashMap<>();
out.put("ok", ok);
out.put("errors", errors);
out.put("deadHeats", deadHeats); // ex: {1:2} => rang 1 a 2 chevaux ex æquo
return out;
}
public ResultatDto toDto(Resultat r) {
var lignes = r.getResultatsChevaux() == null ? List.<ResultatDto.LigneDto>of()
: r.getResultatsChevaux().stream()
.map(rc -> ResultatDto.LigneDto.builder()
.chevalId(rc.getCheval().getId())
.chevalNom(rc.getCheval().getNom())
.rang(rc.getRang())
.build())
.toList();
return ResultatDto.builder()
.id(r.getId())
.courseId(r.getCourse().getId())
.courseNom(r.getCourse().getNom())
.dateOfficielle(r.getDateOfficielle())
.numeroPlusGagnant(r.getNumeroPlusGagnant())
.arrivee(lignes)
.build();
}
/**
* Helper pour le moteur de paiement:
* construit une map rang -> set de chevalId (Dead-Heat friendly).
* Exemple: si rang 1 a deux chevaux, map.get(1) = {idA, idB}.
*/
// @Transactional(readOnly = true)
// public Map<Integer, Set<Long>> buildRankToChevalSet(Long courseId) {
// Resultat res = findResultatByCourseId(courseId);
// List<ResultatCheval> lines = resultatChevalRepository.findByResultatOrderByRangAsc(res);
// Map<Integer, Set<Long>> map = new LinkedHashMap<>();
// for (ResultatCheval rc : lines) {
// map.computeIfAbsent(rc.getRang(), k -> new LinkedHashSet<>()).add(rc.getCheval().getId());
// }
// return map;
// }
private Map<Integer, Set<Long>> buildRankToChevalSet(Long courseId) {
var res = resultatRepository.findByCourseId(courseId)
.orElseThrow(() -> new jakarta.persistence.EntityNotFoundException("Resultat not found for course: " + courseId));
return resultatChevalRepository.findByResultatOrderByRangAsc(res).stream()
.collect(Collectors.groupingBy(
rc -> rc.getRang(),
LinkedHashMap::new,
Collectors.mapping(rc -> rc.getCheval().getId(), Collectors.toCollection(LinkedHashSet::new))
));
}
// Renvoie lensemble des chevaux occupant les rangs 1..N (avec Dead-Heat)
@Transactional(readOnly = true)
public Set<Long> topNWithDeadHeat(Long courseId, int n) {
Map<Integer, Set<Long>> rankMap = buildRankToChevalSet(courseId);
Set<Long> out = new LinkedHashSet<>();
for (int i = 1; i <= n; i++) {
Set<Long> ids = rankMap.get(i);
if (ids != null) out.addAll(ids);
}
return out;
}
// ----------- validations utilitaires internes -----------
private void validateHorsesForCourseAndStatus(Course course, List<ResultatChevalDto> items) {
// Tous doivent être inscrits dans la course
Set<Long> courseChevalIds = chevalCourseRepository.findByCourse(course).stream()
.map(cc -> cc.getCheval().getId()).collect(Collectors.toSet());
// Et non NP/DQ
Map<Long, ChevalCourse> index = chevalCourseRepository.findByCourse(course).stream()
.collect(Collectors.toMap(cc -> cc.getCheval().getId(), cc -> cc));
for (ResultatChevalDto dto : items) {
if (!courseChevalIds.contains(dto.getChevalId())) {
throw new IllegalArgumentException("Cheval " + dto.getChevalId() + " not registered in course " + course.getId());
}
ChevalCourse cc = index.get(dto.getChevalId());
if (Boolean.TRUE.equals(cc.getNonPartant())) {
throw new IllegalArgumentException("Cheval " + dto.getChevalId() + " is NP in course " + course.getId());
}
if (Boolean.TRUE.equals(cc.getEstDisqualifie())) {
throw new IllegalArgumentException("Cheval " + dto.getChevalId() + " is DQ in course " + course.getId());
}
if (dto.getRang() == null || dto.getRang() <= 0) {
throw new IllegalArgumentException("Invalid rang for cheval " + dto.getChevalId());
}
}
}
private void ensureChevalIsValidForCourse(Course course, Long chevalId) {
ChevalCourse cc = chevalCourseRepository.findByCourseAndChevalId(course, chevalId)
.orElseThrow(() -> new IllegalArgumentException("Cheval " + chevalId + " not registered in course " + course.getId()));
if (Boolean.TRUE.equals(cc.getNonPartant())) {
throw new IllegalArgumentException("Cheval " + chevalId + " is NP in course " + course.getId());
}
if (Boolean.TRUE.equals(cc.getEstDisqualifie())) {
throw new IllegalArgumentException("Cheval " + chevalId + " is DQ in course " + course.getId());
}
}
}

View File

@@ -11,10 +11,6 @@ spring:
jpa: jpa:
hibernate: hibernate:
ddl-auto: update ddl-auto: update
properties:
hibernate:
jdbc:
time_zone: UTC
open-in-view: false open-in-view: false
flyway: flyway:
enabled: false enabled: false