diff --git a/README.md b/README.md new file mode 100644 index 0000000..1284f89 --- /dev/null +++ b/README.md @@ -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 d’ex æ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//.git +cd +``` + +### 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 d’environnement baseUrl = http://localhost:8080 +2. Exécuter les requêtes dans l’ordre : + - 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 d’un 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 d’une course. | +| `PUT` | `/api/resultats/course/{courseId}` | Mettre à jour le résultat d’une course. | +| `DELETE` | `/api/resultats/course/{courseId}` | Supprimer le résultat d’une 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 d’un 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 d’une 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 d’une 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 d’ex æ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 ): !!! \ No newline at end of file diff --git a/src/main/java/com/pmumali/plr/config/GlobalApiExceptionHandler.java b/src/main/java/com/pmumali/plr/config/GlobalApiExceptionHandler.java new file mode 100644 index 0000000..8889638 --- /dev/null +++ b/src/main/java/com/pmumali/plr/config/GlobalApiExceptionHandler.java @@ -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 body(HttpStatus status, String message, WebRequest req) { + var map = new LinkedHashMap(); + 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 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 badRequestValidation(MethodArgumentNotValidException e, WebRequest req) { + var map = body(HttpStatus.BAD_REQUEST, "Validation error", req); + List> fieldErrors = e.getBindingResult().getFieldErrors().stream() + .map(fe -> { + Map 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 badRequestConstraint(ConstraintViolationException e, WebRequest req) { + var map = body(HttpStatus.BAD_REQUEST, "Constraint violation", req); + List> violations = e.getConstraintViolations().stream() + .map(v -> violationEntry(v)).toList(); + map.put("errors", violations); + return map; + } + + private Map violationEntry(ConstraintViolation v) { + var m = new LinkedHashMap(); + 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 badRequestParse(Exception e, WebRequest req) { + return body(HttpStatus.BAD_REQUEST, e.getMessage(), req); + } + + // ---------- 409: DB constraints ---------- + @ExceptionHandler(DataIntegrityViolationException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public Map 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 methodNotAllowed(HttpRequestMethodNotSupportedException e, WebRequest req) { + return body(HttpStatus.METHOD_NOT_ALLOWED, e.getMessage(), req); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + public Map 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 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 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); + } +} diff --git a/src/main/java/com/pmumali/plr/controllers/ChevalController.java b/src/main/java/com/pmumali/plr/controllers/ChevalController.java index d685aa4..185ee14 100644 --- a/src/main/java/com/pmumali/plr/controllers/ChevalController.java +++ b/src/main/java/com/pmumali/plr/controllers/ChevalController.java @@ -31,13 +31,12 @@ public class ChevalController { public ResponseEntity create(@RequestBody ChevalDto dto) { Cheval ch = new Cheval(); ch.setNom(dto.nom()); - ch.setNumero(dto.numero()); ch.setNomEcurie(dto.nomEcurie()); ch.setBirthYear(dto.birthYear()); 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()); return ResponseEntity.created(URI.create("/api/chevaux/" + ch.getId())).body(response); } @@ -46,7 +45,7 @@ public class ChevalController { @GetMapping public ResponseEntity> all() { List 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(); return ResponseEntity.ok(list); } @@ -55,7 +54,7 @@ public class ChevalController { @GetMapping("/{id}") public ResponseEntity one(@PathVariable Long 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); } @@ -64,12 +63,11 @@ public class ChevalController { public ResponseEntity update(@PathVariable Long id, @RequestBody ChevalDto dto) { Cheval ch = new Cheval(); ch.setNom(dto.nom()); - ch.setNumero(dto.numero()); ch.setNomEcurie(dto.nomEcurie()); ch.setBirthYear(dto.birthYear()); 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()); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/pmumali/plr/controllers/CourseController.java b/src/main/java/com/pmumali/plr/controllers/CourseController.java index 267b254..4cd30a9 100644 --- a/src/main/java/com/pmumali/plr/controllers/CourseController.java +++ b/src/main/java/com/pmumali/plr/controllers/CourseController.java @@ -44,10 +44,12 @@ public class CourseController { c.setNom(dto.nom()); c.setLieu(dto.lieu()); c.setDepartureDateTime(dto.departureDateTime()); + c.setDistance(dto.distance()); c.setStatus(dto.status()); 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); } @@ -59,7 +61,7 @@ public class CourseController { @PathVariable Long id) { Course course = courseService.get(id); CourseDto response = new CourseDto(course.getId(), course.getNom(), course.getLieu(), - course.getDepartureDateTime(), course.getStatus()); + course.getDepartureDateTime(), course.getDistance(), course.getStatus()); return ResponseEntity.ok(response); } @@ -72,7 +74,8 @@ public class CourseController { : courseService.getCoursesByStatus(status); List 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(); return ResponseEntity.ok(dtos); @@ -87,12 +90,13 @@ public class CourseController { update.setNom(dto.nom()); update.setLieu(dto.lieu()); update.setDepartureDateTime(dto.departureDateTime()); + update.setDistance(dto.distance()); update.setStatus(dto.status()); Course updated = courseService.updateCourse(id, update); CourseDto result = new CourseDto(updated.getId(), updated.getNom(), updated.getLieu(), - updated.getDepartureDateTime(), updated.getStatus()); + updated.getDepartureDateTime(), updated.getDistance(), updated.getStatus()); return ResponseEntity.ok(result); } @@ -103,20 +107,11 @@ public class CourseController { */ @Operation(summary = "Supprimer une course") @DeleteMapping("/{id}") - public ResponseEntity deleteCourse(@PathVariable Long id, - @RequestParam(value = "hard", required = false, defaultValue = "false") boolean hard) { + public ResponseEntity deleteCourse(@PathVariable Long id) { try { - courseService.deleteCourse(id, hard); - if (!hard) { - // return the cancelled course representation - 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(); - } + courseService.deleteCourse(id); + + return ResponseEntity.noContent().build(); } catch (DataIntegrityViolationException ex) { 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 @Operation(summary = "Ajouter un cheval à une course") - @PostMapping("/{id}/chevaux") - public ResponseEntity ajouterCheval(@PathVariable Long id, @RequestBody ChevalCourseDto dto) { - ChevalCourse rh = courseService.ajouterCheval(id, dto.chevalId(), dto.numeroCheval()); + @PostMapping("/chevaux") + public ResponseEntity ajouterCheval(@RequestBody ChevalCourseDto dto) { + ChevalCourse rh = courseService.ajouterCheval(dto.courseId(), dto.chevalId(), dto.numeroCheval()); ChevalCourseDto response = new ChevalCourseDto( rh.getId(), @@ -136,21 +131,22 @@ public class CourseController { rh.getNonPartant(), 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") - @PostMapping("/{id}/chevaux/bulk") - public ResponseEntity bulkAddChevaux(@PathVariable("id") Long id, @RequestBody BulkChevalCourseRequest request) { + @PostMapping("/chevaux/bulk") + public ResponseEntity bulkAddChevaux(@RequestBody BulkChevalCourseRequest request) { try { - List saved = courseService.ajouterChevauxBulk(id, request); + List saved = courseService.ajouterChevauxBulk(request.courseId(), request); List dtos = saved.stream() .map(rh -> new ChevalCourseDto(rh.getId(), rh.getCourse().getId(), rh.getCheval().getId(), rh.getNumeroCheval(), rh.getNonPartant(), rh.getEstDisqualifie())) .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) { // doublons dans la requête return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); @@ -171,7 +167,7 @@ public class CourseController { } // 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") public ResponseEntity scratch(@PathVariable Long chevalCourseId, @RequestParam boolean value) { courseService.estNonPartant(chevalCourseId, value); @@ -213,7 +209,8 @@ public class CourseController { List courses = courseService.getCoursesByCourseStatue(status); List 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(); return ResponseEntity.ok(dtos); diff --git a/src/main/java/com/pmumali/plr/controllers/GainController.java b/src/main/java/com/pmumali/plr/controllers/GainController.java new file mode 100644 index 0000000..ec12b30 --- /dev/null +++ b/src/main/java/com/pmumali/plr/controllers/GainController.java @@ -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 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 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())); + } + } +} diff --git a/src/main/java/com/pmumali/plr/controllers/PariController.java b/src/main/java/com/pmumali/plr/controllers/PariController.java index 319aac3..5333142 100644 --- a/src/main/java/com/pmumali/plr/controllers/PariController.java +++ b/src/main/java/com/pmumali/plr/controllers/PariController.java @@ -1,22 +1,28 @@ package com.pmumali.plr.controllers; -import java.net.URI; +import java.math.BigDecimal; import java.util.List; +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.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.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.models.Pari; import com.pmumali.plr.services.PariService; import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.Data; @@ -29,61 +35,72 @@ public class PariController { @Operation(summary = "Placer un pari") @PostMapping - public ResponseEntity create(@RequestBody PariDto dto) { - Pari p = pariService.create(dto.courseId(), dto.chevalId(), dto.pariType(), dto.mise(), dto.bettorRef()); - PariDto response = new PariDto(p.getId(), p.getCourse().getId(), p.getCheval().getId(), p.getPariType(), - p.getMise(), p.getBettorRef()); - return ResponseEntity.created(URI.create("/api/paris/" + p.getId())).body(response); + public ResponseEntity createPari(@Valid @RequestBody CreatePariRequestDto requestDto) { + PariResponseDto createdPari = pariService.createPari(requestDto); + return new ResponseEntity<>(createdPari, HttpStatus.CREATED); + } + + @Operation(summary = "Mettre à jour un pari (seulement si la course est PLANIFIEE)") + @PutMapping("/{id}") + public ResponseEntity 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 deletePari(@PathVariable Long id) { + pariService.deletePari(id); + return ResponseEntity.noContent().build(); } @Operation(summary = "Lister tous les paris") @GetMapping - public ResponseEntity> all() { - List list = pariService.all().stream() - .map(h -> new PariDto(h.getId(), h.getCourse().getId(), h.getCheval().getId(), h.getPariType(), - h.getMise(), h.getBettorRef())) - .toList(); - return ResponseEntity.ok(list); + public ResponseEntity> all() { + return ResponseEntity.ok(pariService.all()); } @Operation(summary = "Récupérer un pari par id") @GetMapping("/{id}") - public ResponseEntity one(@PathVariable Long id) { - Pari p = pariService.get(id); - PariDto dto = new PariDto(p.getId(), p.getCourse().getId(), p.getCheval().getId(), p.getPariType(), p.getMise(), - p.getBettorRef()); - return ResponseEntity.ok(dto); + public ResponseEntity getPariById(@PathVariable Long id) { + PariResponseDto pari = pariService.getPariById(id); + return ResponseEntity.ok(pari); } // Recherche par type (ex: SIMPLE_GAGNANT) @Operation(summary = "Lister les paris par type") @GetMapping("/type/{pariType}") - public ResponseEntity> getAllByPariType(@PathVariable PariType pariType) { - List list = pariService.getParisByPariType(pariType).stream() - .map(h -> new PariDto(h.getId(), h.getCourse().getId(), h.getCheval().getId(), h.getPariType(), - h.getMise(), h.getBettorRef())) - .toList(); - return ResponseEntity.ok(list); + public ResponseEntity> getAllByPariType(@PathVariable PariType pariType) { + List list = pariService.getParisByPariType(pariType); + return new ResponseEntity<>(list, HttpStatus.OK); } // Recherche par course et type @Operation(summary = "Lister les paris par course + type") @GetMapping("/course/{courseId}/type/{pariType}") - public ResponseEntity> getByCourseAndType(@PathVariable Long courseId, + public ResponseEntity> getByCourseAndType(@PathVariable Long courseId, @PathVariable PariType pariType) { - List list = pariService.getParisByCourseAndType(courseId, pariType).stream() - .map(h -> new PariDto(h.getId(), h.getCourse().getId(), h.getCheval().getId(), h.getPariType(), - h.getMise(), h.getBettorRef())) - .toList(); - return ResponseEntity.ok(list); + List list = pariService.getParisByCourseAndType(courseId, pariType); + return new ResponseEntity<>(list, HttpStatus.OK); } // 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") @GetMapping("/course/{courseId}/type/{pariType}/sum") - public ResponseEntity sumMises(@PathVariable Long courseId, @PathVariable PariType pariType) { - java.math.BigDecimal total = pariService.sumMiseByCourseIdAndPariType(courseId, pariType); - return ResponseEntity.ok(total); + public ResponseEntity sumMises(@PathVariable Long courseId, @PathVariable PariType pariType) { + return ResponseEntity.ok(pariService.sumMiseByCourseIdAndPariType(courseId, pariType)); } + // ---------- 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 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 getSettlementReport(@PathVariable Long courseId) { + return ResponseEntity.ok(pariService.getSettlementReport(courseId)); + } } diff --git a/src/main/java/com/pmumali/plr/controllers/ResultatController.java b/src/main/java/com/pmumali/plr/controllers/ResultatController.java new file mode 100644 index 0000000..1cba57c --- /dev/null +++ b/src/main/java/com/pmumali/plr/controllers/ResultatController.java @@ -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> 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 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> 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 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> validate(@PathVariable Long courseId) { + return ResponseEntity.ok(resultatService.validate(courseId)); + } +} diff --git a/src/main/java/com/pmumali/plr/dtos/BulkChevalCourseRequest.java b/src/main/java/com/pmumali/plr/dtos/BulkChevalCourseRequest.java index 7585119..7115af5 100644 --- a/src/main/java/com/pmumali/plr/dtos/BulkChevalCourseRequest.java +++ b/src/main/java/com/pmumali/plr/dtos/BulkChevalCourseRequest.java @@ -2,7 +2,7 @@ package com.pmumali.plr.dtos; import java.util.List; -public record BulkChevalCourseRequest(List entries) { +public record BulkChevalCourseRequest(Long courseId, List entries) { public static record ChevalEntry(Long chevalId, Integer numeroCheval) { } } diff --git a/src/main/java/com/pmumali/plr/dtos/ChevalDto.java b/src/main/java/com/pmumali/plr/dtos/ChevalDto.java index 2885bf5..f2a5816 100644 --- a/src/main/java/com/pmumali/plr/dtos/ChevalDto.java +++ b/src/main/java/com/pmumali/plr/dtos/ChevalDto.java @@ -1,3 +1,4 @@ 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) { +} diff --git a/src/main/java/com/pmumali/plr/dtos/CourseDto.java b/src/main/java/com/pmumali/plr/dtos/CourseDto.java index fbca2f1..214bd6b 100644 --- a/src/main/java/com/pmumali/plr/dtos/CourseDto.java +++ b/src/main/java/com/pmumali/plr/dtos/CourseDto.java @@ -4,4 +4,6 @@ import java.time.LocalDateTime; import com.pmumali.plr.enums.CourseStatue; -public record CourseDto(Long id, String nom, String lieu, LocalDateTime departureDateTime, CourseStatue status){} \ No newline at end of file +public record CourseDto(Long id, String nom, String lieu, LocalDateTime departureDateTime, Integer distance, + CourseStatue status) { +} \ No newline at end of file diff --git a/src/main/java/com/pmumali/plr/dtos/CreatePariRequestDto.java b/src/main/java/com/pmumali/plr/dtos/CreatePariRequestDto.java new file mode 100644 index 0000000..2af716c --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/CreatePariRequestDto.java @@ -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; +} diff --git a/src/main/java/com/pmumali/plr/dtos/GainViewDto.java b/src/main/java/com/pmumali/plr/dtos/GainViewDto.java new file mode 100644 index 0000000..c1412d5 --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/GainViewDto.java @@ -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 selections, + LocalDateTime createdAt +) { + @Builder + public static record Selection(Long chevalId, String nomCheval, Integer position) {} +} diff --git a/src/main/java/com/pmumali/plr/dtos/PariResponseDto.java b/src/main/java/com/pmumali/plr/dtos/PariResponseDto.java new file mode 100644 index 0000000..beee8e1 --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/PariResponseDto.java @@ -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 selections; +} \ No newline at end of file diff --git a/src/main/java/com/pmumali/plr/dtos/PariSelectionDto.java b/src/main/java/com/pmumali/plr/dtos/PariSelectionDto.java new file mode 100644 index 0000000..fb6e3bd --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/PariSelectionDto.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/pmumali/plr/dtos/PariSelectionResponseDto.java b/src/main/java/com/pmumali/plr/dtos/PariSelectionResponseDto.java new file mode 100644 index 0000000..c332da7 --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/PariSelectionResponseDto.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/pmumali/plr/dtos/RapportDto.java b/src/main/java/com/pmumali/plr/dtos/RapportDto.java new file mode 100644 index 0000000..1b26628 --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/RapportDto.java @@ -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) +} \ No newline at end of file diff --git a/src/main/java/com/pmumali/plr/dtos/ResultatChevalDto.java b/src/main/java/com/pmumali/plr/dtos/ResultatChevalDto.java new file mode 100644 index 0000000..4e434e2 --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/ResultatChevalDto.java @@ -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; +} diff --git a/src/main/java/com/pmumali/plr/dtos/ResultatChevalView.java b/src/main/java/com/pmumali/plr/dtos/ResultatChevalView.java new file mode 100644 index 0000000..609b1ff --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/ResultatChevalView.java @@ -0,0 +1,4 @@ +package com.pmumali.plr.dtos; + + +public record ResultatChevalView(ChevalDto cheval, Integer numeroCheval, Integer rang) {} diff --git a/src/main/java/com/pmumali/plr/dtos/ResultatCreationDto.java b/src/main/java/com/pmumali/plr/dtos/ResultatCreationDto.java new file mode 100644 index 0000000..7efb4c7 --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/ResultatCreationDto.java @@ -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 resultatsChevaux; +} diff --git a/src/main/java/com/pmumali/plr/dtos/ResultatDto.java b/src/main/java/com/pmumali/plr/dtos/ResultatDto.java new file mode 100644 index 0000000..3009b8b --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/ResultatDto.java @@ -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 arrivee +) { + @Builder + public record LigneDto(Long chevalId, String chevalNom, Integer rang) {} +} diff --git a/src/main/java/com/pmumali/plr/dtos/SettlementSummaryDto.java b/src/main/java/com/pmumali/plr/dtos/SettlementSummaryDto.java new file mode 100644 index 0000000..2ab3dc8 --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/SettlementSummaryDto.java @@ -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 pools; // par type + private List 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 + } +} \ No newline at end of file diff --git a/src/main/java/com/pmumali/plr/dtos/UpdatePariRequestDto.java b/src/main/java/com/pmumali/plr/dtos/UpdatePariRequestDto.java new file mode 100644 index 0000000..612ef89 --- /dev/null +++ b/src/main/java/com/pmumali/plr/dtos/UpdatePariRequestDto.java @@ -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; +} diff --git a/src/main/java/com/pmumali/plr/enums/PariSousType.java b/src/main/java/com/pmumali/plr/enums/PariSousType.java new file mode 100644 index 0000000..1bb6bb0 --- /dev/null +++ b/src/main/java/com/pmumali/plr/enums/PariSousType.java @@ -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 +} diff --git a/src/main/java/com/pmumali/plr/enums/PariType.java b/src/main/java/com/pmumali/plr/enums/PariType.java index 060dfa6..9f7df7a 100644 --- a/src/main/java/com/pmumali/plr/enums/PariType.java +++ b/src/main/java/com/pmumali/plr/enums/PariType.java @@ -1,5 +1,22 @@ package com.pmumali.plr.enums; 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. } diff --git a/src/main/java/com/pmumali/plr/models/Cheval.java b/src/main/java/com/pmumali/plr/models/Cheval.java index 3efac73..0010769 100644 --- a/src/main/java/com/pmumali/plr/models/Cheval.java +++ b/src/main/java/com/pmumali/plr/models/Cheval.java @@ -1,6 +1,5 @@ package com.pmumali.plr.models; - import jakarta.persistence.Column; import jakarta.persistence.Entity; import lombok.Data; @@ -8,17 +7,14 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; - @Entity @Data @Getter @Setter -@EqualsAndHashCode(callSuper=false) +@EqualsAndHashCode(callSuper = false) public class Cheval extends BaseEntite { private String nom; - private Integer numero; - private String nomEcurie; @Column(name = "birth_year") diff --git a/src/main/java/com/pmumali/plr/models/Course.java b/src/main/java/com/pmumali/plr/models/Course.java index 7ad2e49..97d61d1 100644 --- a/src/main/java/com/pmumali/plr/models/Course.java +++ b/src/main/java/com/pmumali/plr/models/Course.java @@ -11,19 +11,20 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; - @Entity @Data @Getter @Setter -@EqualsAndHashCode(callSuper=false) +@EqualsAndHashCode(callSuper = false) public class Course extends BaseEntite { private String nom; - + private String lieu; - - @Column(name="date_depart") + + @Column(name = "date_depart") private LocalDateTime departureDateTime; - + + private Integer distance; + private CourseStatue status = CourseStatue.PLANIFIEE; } diff --git a/src/main/java/com/pmumali/plr/models/Jackpot.java b/src/main/java/com/pmumali/plr/models/Jackpot.java new file mode 100644 index 0000000..1ba4a8a --- /dev/null +++ b/src/main/java/com/pmumali/plr/models/Jackpot.java @@ -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; + } +} diff --git a/src/main/java/com/pmumali/plr/models/Pari.java b/src/main/java/com/pmumali/plr/models/Pari.java index a06c6cf..13bf5d2 100644 --- a/src/main/java/com/pmumali/plr/models/Pari.java +++ b/src/main/java/com/pmumali/plr/models/Pari.java @@ -1,12 +1,19 @@ package com.pmumali.plr.models; import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; import com.pmumali.plr.enums.PariType; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -16,18 +23,36 @@ import lombok.Setter; @Entity @Getter @Setter -@EqualsAndHashCode(callSuper=false) +@EqualsAndHashCode(callSuper = false) public class Pari extends BaseEntite { - @ManyToOne(optional=false) + @ManyToOne(optional = false) + @JoinColumn(name = "course_id") private Course course; - @ManyToOne(optional=false) - private Cheval cheval; - + @Enumerated(EnumType.STRING) + @Column(nullable = false) private PariType pariType; - @Column(nullable=false) + @Column(nullable = false, precision = 10, scale = 2) private BigDecimal mise; + @Column(name = "bettor_ref", nullable = false) 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 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; } diff --git a/src/main/java/com/pmumali/plr/models/PariSelection.java b/src/main/java/com/pmumali/plr/models/PariSelection.java new file mode 100644 index 0000000..0a1312b --- /dev/null +++ b/src/main/java/com/pmumali/plr/models/PariSelection.java @@ -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.) +} \ No newline at end of file diff --git a/src/main/java/com/pmumali/plr/models/Resultat.java b/src/main/java/com/pmumali/plr/models/Resultat.java new file mode 100644 index 0000000..eaca035 --- /dev/null +++ b/src/main/java/com/pmumali/plr/models/Resultat.java @@ -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 resultatsChevaux = new ArrayList<>();; + + // Gestion Quinté+ et Multi + @Column(name = "numero_plus_gagnant") + private String numeroPlusGagnant; +} diff --git a/src/main/java/com/pmumali/plr/models/ResultatCheval.java b/src/main/java/com/pmumali/plr/models/ResultatCheval.java new file mode 100644 index 0000000..7cad545 --- /dev/null +++ b/src/main/java/com/pmumali/plr/models/ResultatCheval.java @@ -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 +} diff --git a/src/main/java/com/pmumali/plr/repositories/ChevalCourseRepository.java b/src/main/java/com/pmumali/plr/repositories/ChevalCourseRepository.java index d0009de..95f0798 100644 --- a/src/main/java/com/pmumali/plr/repositories/ChevalCourseRepository.java +++ b/src/main/java/com/pmumali/plr/repositories/ChevalCourseRepository.java @@ -1,9 +1,11 @@ package com.pmumali.plr.repositories; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import com.pmumali.plr.models.Cheval; import com.pmumali.plr.models.ChevalCourse; import com.pmumali.plr.models.Course; @@ -11,6 +13,13 @@ public interface ChevalCourseRepository extends JpaRepository findByCourseAndNonPartantTrue(Course course); + List findByCourseIdAndChevalId(Long courseId, Long chevalId); + + Optional findByCourseAndChevalId(Course course, Long chevalId); + + // Si tu veux aussi récupérer via l'entité Cheval + Optional findByCourseAndCheval(Course course, Cheval cheval); + // Retourne les chevaux disqualifiés pour une course List findByCourseAndEstDisqualifieTrue(Course course); @@ -35,4 +44,8 @@ public interface ChevalCourseRepository extends JpaRepository findByCourse(Course course); + + List findByCourseId(Long courseId); } diff --git a/src/main/java/com/pmumali/plr/repositories/JackpotRepository.java b/src/main/java/com/pmumali/plr/repositories/JackpotRepository.java new file mode 100644 index 0000000..393f05e --- /dev/null +++ b/src/main/java/com/pmumali/plr/repositories/JackpotRepository.java @@ -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{ + Optional findByPariType(PariType pariType); +} diff --git a/src/main/java/com/pmumali/plr/repositories/PariRepository.java b/src/main/java/com/pmumali/plr/repositories/PariRepository.java index b4b837a..b8fd362 100644 --- a/src/main/java/com/pmumali/plr/repositories/PariRepository.java +++ b/src/main/java/com/pmumali/plr/repositories/PariRepository.java @@ -2,25 +2,53 @@ package com.pmumali.plr.repositories; import java.math.BigDecimal; 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.Query; +import org.springframework.data.repository.query.Param; import com.pmumali.plr.enums.PariType; import com.pmumali.plr.models.Pari; public interface PariRepository extends JpaRepository { + List findByCourseIdAndEstGagnantTrue(Long courseId); + + List findByCourseIdAndPariTypeAndEstGagnantTrue(Long courseId, PariType pariType); + + @EntityGraph(attributePaths = {"course", "selections", "selections.cheval"}) + @Override + List findAll(); + + @EntityGraph(attributePaths = {"course", "selections", "selections.cheval"}) + List findByCourseId(Long courseId); + + + @EntityGraph(attributePaths = {"course", "selections", "selections.cheval"}) + List findByPariType(PariType pariType); + + @EntityGraph(attributePaths = {"course", "selections", "selections.cheval"}) List findByCourseIdAndPariType(Long courseId, PariType pariType); - List findByCourseIdAndPariTypeAndChevalId(Long courseId, PariType pariType, Long chevalId); + @EntityGraph(attributePaths = {"course", "selections", "selections.cheval"}) + @Override + Optional findById(Long id); - List findByPariType(PariType pariType); + @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 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(Long courseId, PariType pariType); + @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 where p.course.id=:courseId and p.pariType=:pariType and p.cheval.id=:chevalId") - BigDecimal sumMiseByCourseIdAndPariTypeAndChevalId(Long courseId, PariType pariType, Long chevalId); + @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); } diff --git a/src/main/java/com/pmumali/plr/repositories/ResultatChevalRepository.java b/src/main/java/com/pmumali/plr/repositories/ResultatChevalRepository.java new file mode 100644 index 0000000..809acfa --- /dev/null +++ b/src/main/java/com/pmumali/plr/repositories/ResultatChevalRepository.java @@ -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 { + List findByResultatOrderByRangAsc(Resultat resultat); + + List findByResultat_Course_IdOrderByRangAsc(Long courseId); + + List findByResultatAndRang(Resultat resultat, Integer rang); + + boolean existsByResultatAndChevalId(Resultat resultat, Long chevalId); + + Optional 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 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 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 findWithNumeroByResultat(@Param("resultat") Resultat resultat); +} diff --git a/src/main/java/com/pmumali/plr/repositories/ResultatRepository.java b/src/main/java/com/pmumali/plr/repositories/ResultatRepository.java new file mode 100644 index 0000000..5dbe995 --- /dev/null +++ b/src/main/java/com/pmumali/plr/repositories/ResultatRepository.java @@ -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 { + Optional findByCourseId(Long courseId); +} diff --git a/src/main/java/com/pmumali/plr/services/ChevalService.java b/src/main/java/com/pmumali/plr/services/ChevalService.java index dcea465..921b99e 100644 --- a/src/main/java/com/pmumali/plr/services/ChevalService.java +++ b/src/main/java/com/pmumali/plr/services/ChevalService.java @@ -20,26 +20,25 @@ public class ChevalService { return chevalRepository.save(cheval); } - public List all(){ + public List all() { return chevalRepository.findAll(); } - public Cheval get(Long id){ + public Cheval get(Long id) { return chevalRepository.findById(id).orElseThrow(); } - public Cheval update(Long id, Cheval data){ + public Cheval update(Long id, Cheval data) { Cheval h = get(id); h.setNom(data.getNom()); - h.setNumero(data.getNumero()); h.setNomEcurie(data.getNomEcurie()); h.setBirthYear(data.getBirthYear()); return chevalRepository.save(h); } - public void delete(Long id){ - chevalRepository.deleteById(id); + public void delete(Long id) { + chevalRepository.deleteById(id); } } diff --git a/src/main/java/com/pmumali/plr/services/CourseService.java b/src/main/java/com/pmumali/plr/services/CourseService.java index f2ecfc3..1b9756f 100644 --- a/src/main/java/com/pmumali/plr/services/CourseService.java +++ b/src/main/java/com/pmumali/plr/services/CourseService.java @@ -68,6 +68,8 @@ public class CourseService { existing.setLieu(data.getLieu()); if (Objects.nonNull(data.getDepartureDateTime())) existing.setDepartureDateTime(data.getDepartureDateTime()); + if (Objects.nonNull(data.getDistance())) + existing.setDistance(data.getDistance()); if (Objects.nonNull(data.getStatus())) existing.setStatus(data.getStatus()); @@ -75,16 +77,9 @@ public class CourseService { } @Transactional - public void deleteCourse(Long id, boolean hard) { + public void deleteCourse(Long id) { Course course = get(id); - if (!hard) { - // soft delete: mark as canceled - course.setStatus(CourseStatue.ANNULEE); - courseRepository.save(course); - return; - } - // hard delete: check constraints int pariCount = pariRepository.countByCourseId(id); int inscriptionCount = chevalCourseRepository.countByCourse(course); diff --git a/src/main/java/com/pmumali/plr/services/GainService.java b/src/main/java/com/pmumali/plr/services/GainService.java new file mode 100644 index 0000000..de0eef4 --- /dev/null +++ b/src/main/java/com/pmumali/plr/services/GainService.java @@ -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 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 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 listWinnersByType(Long courseId, PariType pariType) { + Course course = mustGetCourse(courseId); + if (course.getStatus() == CourseStatue.CLOTUREE && resultatRepository.findByCourseId(courseId).isPresent()) { + List 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> rankMap = buildRankToChevalSet(courseId); + Set validChevaux = validParticipants(pari.getCourse().getId()); + boolean win = isWinningBetDeadHeat(pari.getPariType(), pari, rankMap, validChevaux); + return toWinnerDtoProvisional(pari, win); + } + + // ====== PROVISIONAL COMPUTE ====== + + private List computeProvisionalWinners(Long courseId, PariType filter) { + Resultat res = resultatRepository.findByCourseId(courseId) + .orElseThrow(() -> new EntityNotFoundException("Resultat not found for course: " + courseId)); + + Map> rankMap = buildRankToChevalSet(res); + Set validChevaux = validParticipants(courseId); + + List 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> rankMap, + Set validChevaux) { + Map posToCheval = p.getSelections().stream() + .collect(Collectors.toMap(PariSelection::getPosition, s -> s.getCheval().getId())); + + Set ticketChevaux = p.getSelections().stream() + .map(s -> s.getCheval().getId()).collect(Collectors.toSet()); + + if (!validChevaux.containsAll(ticketChevaux)) return false; + + switch (type) { + case SIMPLE_GAGNANT -> { + Set 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 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 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 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 top4 = topNWithDeadHeat(rankMap, 4); + return ticketChevaux.size() == 4 && top4.containsAll(ticketChevaux); + } + case QUINTE -> { + Set top5 = topNWithDeadHeat(rankMap, 5); + return ticketChevaux.size() == 5 && top5.containsAll(ticketChevaux); + } + case DEUX_SUR_QUATRE -> { + Set top4 = topNWithDeadHeat(rankMap, 4); + long match = ticketChevaux.stream().filter(top4::contains).count(); + return match >= 2; + } + case MULTI -> { + Set 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 topNWithDeadHeat(Map> rankMap, int n) { + Set out = new LinkedHashSet<>(); + for (int i = 1; i <= n; i++) { + Set ids = rankMap.get(i); + if (ids != null) out.addAll(ids); + } + return out; + } + + private Map> buildRankToChevalSet(Long courseId) { + Resultat res = resultatRepository.findByCourseId(courseId) + .orElseThrow(() -> new EntityNotFoundException("Resultat not found for course: " + courseId)); + return buildRankToChevalSet(res); + } + + private Map> 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 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)); + } +} diff --git a/src/main/java/com/pmumali/plr/services/PariService.java b/src/main/java/com/pmumali/plr/services/PariService.java index 0c53a84..a388265 100644 --- a/src/main/java/com/pmumali/plr/services/PariService.java +++ b/src/main/java/com/pmumali/plr/services/PariService.java @@ -1,65 +1,776 @@ package com.pmumali.plr.services; 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.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.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.models.Cheval; import com.pmumali.plr.models.Course; +import com.pmumali.plr.models.Jackpot; 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.ChevalRepository; import com.pmumali.plr.repositories.CourseRepository; +import com.pmumali.plr.repositories.JackpotRepository; 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.Data; @Service -@Data @AllArgsConstructor public class PariService { private final PariRepository pariRepository; private final CourseRepository courseRepository; - private final ChevalRepository chevalRepository; 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 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 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 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 selections = validateAndCreateSelections(requestDto, pari, course); + pari.setSelections(selections); + + Pari saved = pariRepository.save(pari); + return mapToDto(saved); + } @Transactional - public Pari create(Long courseId, Long chevalId, PariType pariType, BigDecimal mise, String bettorRef) { - Cheval cheval = chevalRepository.findById(chevalId).orElseThrow(); - Course course = courseRepository.findById(courseId).orElseThrow(); + public PariResponseDto updatePari(Long id, UpdatePariRequestDto dto) { + validateMise(dto.getMise()); + 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); - p.setCourse(course); - p.setPariType(pariType); - p.setMise(mise); - p.setBettorRef(bettorRef); + // Rebuild selections + existing.setPariType(dto.getPariType()); + existing.setMise(dto.getMise()); + existing.setBettorRef(dto.getBettorRef()); - return pariRepository.save(p); + // Clear to avoid unique constraint (pari_id, cheval_id) before inserting new ones + existing.getSelections().clear(); + pariRepository.saveAndFlush(existing); + + List 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) { - return pariRepository.findById(id).orElseThrow(); + private CreatePariRequestDto toCreateDto(Long courseId, UpdatePariRequestDto dto){ + 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 all() { - return pariRepository.findAll(); + @Transactional + 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 getParisByPariType(PariType pariType) { - return pariRepository.findByPariType(pariType); + // ------------- READ ------------- + @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 getParisByCourseAndType(Long courseId, PariType pariType) { - return pariRepository.findByCourseIdAndPariType(courseId, pariType); + @Transactional(readOnly = true) + public List all() { + return pariRepository.findAll().stream().map(this::mapToDto).toList(); } - public java.math.BigDecimal sumMiseByCourseIdAndPariType(Long courseId, PariType pariType) { + @Transactional(readOnly = true) + public List getParisByPariType(PariType pariType) { + return pariRepository.findByPariType(pariType).stream().map(this::mapToDto).toList(); + } + + @Transactional(readOnly = true) + public List 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); } + + // ------------- 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> rankMap = buildRankToChevalSet(courseId); + + // participants valides (hors NP/DQ) + Set 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 poolMap = new LinkedHashMap<>(); + List rapports = new ArrayList<>(); + + // pour chaque type présent dans la course + List paris = pariRepository.findByCourseId(courseId); + Map> byType = paris.stream().collect(Collectors.groupingBy(Pari::getPariType)); + + // ----- QUINTE_PLUS ----- + List 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 envelope = new LinkedHashMap<>(); + for (var e : QP_REPARTITION.entrySet()) { + envelope.put(e.getKey(), cagnotteQP.multiply(e.getValue()).setScale(2, RoundingMode.DOWN)); + } + + Map> 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 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 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 ordreWinners = winnersByCat.getOrDefault(PariSousType.QP_ORDRE, List.of()); + List 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 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 multi = byType.getOrDefault(PariType.MULTI, List.of()); + + if (!multi.isEmpty()) { + Map> 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 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 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 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 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 paris = pariRepository.findByCourseId(courseId); + Map> byType = paris.stream().collect(Collectors.groupingBy(Pari::getPariType)); + + Map pools = new LinkedHashMap<>(); + List rapports = new ArrayList<>(); + + for (var e : byType.entrySet()) { + PariType t = e.getKey(); + List 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 validateAndCreateSelections(CreatePariRequestDto requestDto, Pari pari, Course course) { + PariType pariType = requestDto.getPariType(); + List 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 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 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 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 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> 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 topNWithDeadHeat(Map> rankMap, int n) { + Set out = new LinkedHashSet<>(); + for (int i = 1; i <= n; i++) { + Set ids = rankMap.get(i); + if (ids != null) out.addAll(ids); + } + return out; + } + + private boolean isMultiWinningDeadHeat(Pari p, Map> rankMap) { + Set pick = p.getSelections().stream().map(s -> s.getCheval().getId()).collect(Collectors.toSet()); + Set top4 = topNWithDeadHeat(rankMap, 4); + return pick.containsAll(top4); + } + + private boolean isWinningBetDeadHeat(PariType type, Pari p, + Map> rankMap, + Set validChevaux) { + + Map posToCheval = p.getSelections().stream() + .collect(Collectors.toMap(PariSelection::getPosition, s -> s.getCheval().getId())); + + Set ticketChevaux = p.getSelections().stream() + .map(s -> s.getCheval().getId()).collect(Collectors.toSet()); + + if (!validChevaux.containsAll(ticketChevaux)) return false; + + switch (type) { + case SIMPLE_GAGNANT -> { + Set 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 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 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 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 top4 = topNWithDeadHeat(rankMap, 4); + return ticketChevaux.size() == 4 && top4.containsAll(ticketChevaux); + } + case QUINTE -> { + Set top5 = topNWithDeadHeat(rankMap, 5); + return ticketChevaux.size() == 5 && top5.containsAll(ticketChevaux); + } + case DEUX_SUR_QUATRE -> { + Set top4 = topNWithDeadHeat(rankMap, 4); + long match = ticketChevaux.stream().filter(top4::contains).count(); + return match >= 2; + } + case MULTI -> { + Set 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> 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> rankMap, int n) { + Map 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> rankMap, int n) { + Set pick = p.getSelections().stream().map(s -> s.getCheval().getId()).collect(Collectors.toSet()); + Set topN = topNWithDeadHeat(rankMap, n); + return pick.size()==n && topN.containsAll(pick); + } + + private boolean matchKofNSet(Pari p, Map> rankMap, int k, int n) { + Set pick = p.getSelections().stream().map(s -> s.getCheval().getId()).collect(Collectors.toSet()); + Set 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; + }; + } } diff --git a/src/main/java/com/pmumali/plr/services/ResultatService.java b/src/main/java/com/pmumali/plr/services/ResultatService.java new file mode 100644 index 0000000..7cc40d5 --- /dev/null +++ b/src/main/java/com/pmumali/plr/services/ResultatService.java @@ -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 it’s effectively final for lambdas + final Resultat savedRes = resultatRepository.save(res); + + List 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 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 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 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(); + 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(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 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 validate(Long courseId) { + Resultat res = resultatRepository.findByCourseId(courseId) + .orElseThrow(() -> new EntityNotFoundException("Result not found for course: " + courseId)); + List lines = resultatChevalRepository.findByResultatOrderByRangAsc(res); + + List errors = new ArrayList<>(); + + // Tous les chevaux doivent être inscrits & non NP/DQ + Set 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 countsByRank = lines.stream() + .collect(Collectors.groupingBy(ResultatCheval::getRang, LinkedHashMap::new, Collectors.counting())); + Map deadHeats = countsByRank.entrySet().stream() + .filter(e -> e.getValue() > 1L) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + boolean ok = errors.isEmpty(); + + Map 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.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> buildRankToChevalSet(Long courseId) { + // Resultat res = findResultatByCourseId(courseId); + // List lines = resultatChevalRepository.findByResultatOrderByRangAsc(res); + // Map> map = new LinkedHashMap<>(); + // for (ResultatCheval rc : lines) { + // map.computeIfAbsent(rc.getRang(), k -> new LinkedHashSet<>()).add(rc.getCheval().getId()); + // } + // return map; + // } + private Map> 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 l’ensemble des chevaux occupant les rangs 1..N (avec Dead-Heat) + @Transactional(readOnly = true) + public Set topNWithDeadHeat(Long courseId, int n) { + Map> rankMap = buildRankToChevalSet(courseId); + Set out = new LinkedHashSet<>(); + for (int i = 1; i <= n; i++) { + Set ids = rankMap.get(i); + if (ids != null) out.addAll(ids); + } + return out; + } + + // ----------- validations utilitaires internes ----------- + + private void validateHorsesForCourseAndStatus(Course course, List items) { + // Tous doivent être inscrits dans la course + Set courseChevalIds = chevalCourseRepository.findByCourse(course).stream() + .map(cc -> cc.getCheval().getId()).collect(Collectors.toSet()); + + // Et non NP/DQ + Map 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()); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 38855dc..f63dcfd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,10 +11,6 @@ spring: jpa: hibernate: ddl-auto: update - properties: - hibernate: - jdbc: - time_zone: UTC open-in-view: false flyway: enabled: false