feature devis

This commit is contained in:
David 2025-09-12 22:44:19 +02:00
parent d1be066a20
commit f31f1b6c69
31 changed files with 3783 additions and 1 deletions

View File

@ -421,6 +421,100 @@
}
],
"description": "Endpoints de gestion des utilisateurs - profil, mot de passe, suppression de compte"
},
{
"name": "📋 Devis Transport",
"item": [
{
"name": "Calculer Devis (3 offres automatiques)",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"typeService\": \"EXPORT\",\n \"incoterm\": \"FOB\",\n \"typeLivraison\": \"PORTE_A_PORTE\",\n \"depart\": {\n \"ville\": \"Marseille\",\n \"codePostal\": \"13000\",\n \"pays\": \"France\",\n \"coordonneesGps\": \"43.2965,5.3698\"\n },\n \"arrivee\": {\n \"ville\": \"Shanghai\",\n \"codePostal\": \"200000\",\n \"pays\": \"Chine\",\n \"coordonneesGps\": \"31.2304,121.4737\"\n },\n \"douaneImportExport\": \"EXPORT\",\n \"eur1Import\": false,\n \"eur1Export\": true,\n \"colisages\": [\n {\n \"type\": \"PALETTE\",\n \"quantite\": 3,\n \"longueur\": 120.0,\n \"largeur\": 80.0,\n \"hauteur\": 160.0,\n \"poids\": 750.5,\n \"gerbable\": true\n },\n {\n \"type\": \"CAISSE\",\n \"quantite\": 2,\n \"longueur\": 100.0,\n \"largeur\": 60.0,\n \"hauteur\": 120.0,\n \"poids\": 450.2,\n \"gerbable\": false\n }\n ],\n \"marchandiseDangereuse\": {\n \"presente\": true,\n \"classe\": \"3\",\n \"numeroOnu\": \"UN1263\",\n \"description\": \"Peintures inflammables\"\n },\n \"manutentionParticuliere\": {\n \"hayon\": true,\n \"sangles\": true,\n \"couvertureThermique\": false,\n \"autres\": \"Manutention précautionneuse requise\"\n },\n \"produitsReglementes\": {\n \"alimentaire\": false,\n \"pharmaceutique\": false,\n \"autres\": null\n },\n \"servicesAdditionnels\": {\n \"rendezVousLivraison\": true,\n \"documentT1\": false,\n \"stopDouane\": true,\n \"assistanceExport\": true,\n \"assurance\": true,\n \"valeurDeclaree\": 15000.0\n },\n \"dateEnlevement\": \"2025-01-15\",\n \"dateLivraison\": \"2025-02-20\",\n \"nomClient\": \"Maritime Solutions SAS\",\n \"emailClient\": \"contact@maritime-solutions.fr\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/devis/calculer",
"host": ["{{base_url}}"],
"path": ["api", "v1", "devis", "calculer"]
}
},
"response": []
},
{
"name": "Valider Demande de Devis",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"typeService\": \"EXPORT\",\n \"incoterm\": \"FOB\",\n \"depart\": {\n \"ville\": \"Marseille\",\n \"codePostal\": \"13000\",\n \"pays\": \"France\"\n },\n \"arrivee\": {\n \"ville\": \"Shanghai\",\n \"codePostal\": \"200000\",\n \"pays\": \"Chine\"\n },\n \"colisages\": [\n {\n \"type\": \"CAISSE\",\n \"quantite\": 2,\n \"poids\": 150.5,\n \"longueur\": 120.0,\n \"largeur\": 80.0,\n \"hauteur\": 100.0,\n \"gerbable\": false\n }\n ],\n \"marchandiseDangereuse\": null,\n \"nomClient\": \"Test Client\",\n \"emailClient\": \"test@test.com\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/devis/valider",
"host": ["{{base_url}}"],
"path": ["api", "v1", "devis", "valider"]
}
},
"response": []
},
{
"name": "Calculer Devis - Exemple Simple",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"typeService\": \"EXPORT\",\n \"incoterm\": \"FOB\",\n \"depart\": {\n \"ville\": \"Marseille\",\n \"codePostal\": \"13000\",\n \"pays\": \"France\"\n },\n \"arrivee\": {\n \"ville\": \"Shanghai\",\n \"codePostal\": \"200000\",\n \"pays\": \"Chine\"\n },\n \"colisages\": [\n {\n \"type\": \"CAISSE\",\n \"quantite\": 2,\n \"poids\": 150.5,\n \"longueur\": 120.0,\n \"largeur\": 80.0,\n \"hauteur\": 100.0,\n \"gerbable\": false\n }\n ],\n \"marchandiseDangereuse\": null,\n \"nomClient\": \"Test Client\",\n \"emailClient\": \"test@test.com\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/devis/calculer",
"host": ["{{base_url}}"],
"path": ["api", "v1", "devis", "calculer"]
}
},
"response": []
},
{
"name": "Calculer Devis - Avec Marchandises Dangereuses",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"typeService\": \"EXPORT\",\n \"incoterm\": \"FOB\",\n \"depart\": {\n \"ville\": \"Marseille\",\n \"codePostal\": \"13000\",\n \"pays\": \"France\"\n },\n \"arrivee\": {\n \"ville\": \"Hong Kong\",\n \"codePostal\": \"999077\",\n \"pays\": \"Hong Kong\"\n },\n \"colisages\": [\n {\n \"type\": \"PALETTE\",\n \"quantite\": 5,\n \"poids\": 890.0,\n \"longueur\": 120.0,\n \"largeur\": 80.0,\n \"hauteur\": 200.0,\n \"gerbable\": true\n }\n ],\n \"marchandiseDangereuse\": {\n \"presente\": true,\n \"classe\": \"3\",\n \"numeroOnu\": \"UN1263\",\n \"description\": \"Produits chimiques inflammables\"\n },\n \"servicesAdditionnels\": {\n \"assurance\": true,\n \"valeurDeclaree\": 25000.0,\n \"rendezVousLivraison\": true,\n \"assistanceExport\": true\n },\n \"nomClient\": \"Chemical Industries Ltd\",\n \"emailClient\": \"export@chemical-industries.com\"\n}"
},
"url": {
"raw": "{{base_url}}/api/v1/devis/calculer",
"host": ["{{base_url}}"],
"path": ["api", "v1", "devis", "calculer"]
}
},
"response": []
}
],
"description": "API de calcul automatisé de devis transport maritime - génère 3 offres (Rapide, Standard, Économique) basées sur les grilles tarifaires LESCHACO"
}
],
"event": [

View File

@ -0,0 +1,107 @@
package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.DevisCalculService;
import com.dh7789dev.xpeditis.dto.app.DemandeDevis;
import com.dh7789dev.xpeditis.dto.app.ReponseDevis;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/v1/devis")
@RequiredArgsConstructor
@Validated
@Slf4j
@Tag(name = "Devis", description = "API pour le calcul automatisé de devis transport")
public class DevisRestController {
private final DevisCalculService devisCalculService;
@Operation(
summary = "Calculer les 3 offres de transport",
description = "Génère automatiquement 3 offres (Rapide, Standard, Économique) basées sur les grilles tarifaires"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Devis calculé avec succès",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ReponseDevis.class))
),
@ApiResponse(
responseCode = "400",
description = "Données de la demande invalides"
),
@ApiResponse(
responseCode = "404",
description = "Aucune grille tarifaire applicable"
),
@ApiResponse(
responseCode = "500",
description = "Erreur interne du serveur"
)
})
@PostMapping("/calculer")
public ResponseEntity<ReponseDevis> calculerDevis(
@Parameter(description = "Demande de devis avec tous les détails du transport", required = true)
@Valid @RequestBody DemandeDevis demandeDevis) {
log.info("Demande de calcul de devis reçue pour le client: {}", demandeDevis.getNomClient());
try {
ReponseDevis reponseDevis = devisCalculService.calculerTroisOffres(demandeDevis);
log.info("Devis calculé avec succès - {} offres générées", reponseDevis.getOffres().size());
return ResponseEntity.ok(reponseDevis);
} catch (IllegalArgumentException e) {
log.warn("Données de demande invalides: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (IllegalStateException e) {
log.warn("Aucune grille tarifaire applicable: {}", e.getMessage());
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("Erreur lors du calcul du devis", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Operation(
summary = "Valider une demande de devis",
description = "Vérifie que tous les champs obligatoires sont présents et valides"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Demande de devis valide"),
@ApiResponse(responseCode = "400", description = "Demande de devis invalide")
})
@PostMapping("/valider")
public ResponseEntity<String> validerDemandeDevis(
@Parameter(description = "Demande de devis à valider", required = true)
@Valid @RequestBody DemandeDevis demandeDevis) {
log.debug("Validation de la demande de devis");
try {
devisCalculService.validerDemandeDevis(demandeDevis);
return ResponseEntity.ok("Demande de devis valide");
} catch (IllegalArgumentException e) {
log.warn("Demande de devis invalide: {}", e.getMessage());
return ResponseEntity.badRequest().body(e.getMessage());
}
}
}

View File

@ -0,0 +1,358 @@
package com.dh7789dev.xpeditis.controller.api.v1;
import com.dh7789dev.xpeditis.GrilleTarifaireService;
import com.dh7789dev.xpeditis.dto.app.GrilleTarifaire;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/grilles-tarifaires")
@RequiredArgsConstructor
@Validated
@Slf4j
@Tag(name = "Grilles Tarifaires", description = "API pour la gestion et l'import des grilles tarifaires")
public class GrilleTarifaireRestController {
private final GrilleTarifaireService grilleTarifaireService;
@Operation(
summary = "Importer des grilles tarifaires depuis un fichier CSV",
description = "Import en masse de grilles tarifaires au format CSV avec validation des données"
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Import réussi avec détails des grilles importées"
),
@ApiResponse(
responseCode = "400",
description = "Fichier invalide ou erreurs de validation"
),
@ApiResponse(
responseCode = "415",
description = "Format de fichier non supporté"
)
})
@PostMapping("/import/csv")
public ResponseEntity<?> importerDepuisCsv(
@Parameter(description = "Fichier CSV contenant les grilles tarifaires", required = true)
@RequestParam("file") MultipartFile file,
@Parameter(description = "Mode d'import: REPLACE (remplace tout) ou MERGE (fusionne)")
@RequestParam(defaultValue = "MERGE") String mode) {
log.info("Début d'import CSV - Fichier: {}, Taille: {} bytes, Mode: {}",
file.getOriginalFilename(), file.getSize(), mode);
try {
if (file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Le fichier ne peut pas être vide"));
}
if (!file.getOriginalFilename().toLowerCase().endsWith(".csv")) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(Map.of("erreur", "Seuls les fichiers CSV sont supportés"));
}
List<GrilleTarifaire> grillesImportees = grilleTarifaireService.importerDepuisCsv(file, mode);
log.info("Import CSV terminé avec succès - {} grilles importées", grillesImportees.size());
return ResponseEntity.ok(Map.of(
"message", "Import réussi",
"nombreGrillesImportees", grillesImportees.size(),
"grilles", grillesImportees
));
} catch (IOException e) {
log.error("Erreur lors de la lecture du fichier CSV", e);
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Erreur lors de la lecture du fichier: " + e.getMessage()));
} catch (IllegalArgumentException e) {
log.warn("Données CSV invalides: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Données invalides: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de l'import CSV", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur interne lors de l'import"));
}
}
@Operation(
summary = "Importer des grilles tarifaires depuis un fichier Excel",
description = "Import en masse de grilles tarifaires au format Excel (.xlsx) avec validation"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Import réussi"),
@ApiResponse(responseCode = "400", description = "Fichier invalide"),
@ApiResponse(responseCode = "415", description = "Format de fichier non supporté")
})
@PostMapping("/import/excel")
public ResponseEntity<?> importerDepuisExcel(
@Parameter(description = "Fichier Excel (.xlsx) contenant les grilles tarifaires", required = true)
@RequestParam("file") MultipartFile file,
@Parameter(description = "Nom de la feuille à importer (optionnel)")
@RequestParam(required = false) String sheetName,
@Parameter(description = "Mode d'import: REPLACE ou MERGE")
@RequestParam(defaultValue = "MERGE") String mode) {
log.info("Début d'import Excel - Fichier: {}, Feuille: {}, Mode: {}",
file.getOriginalFilename(), sheetName, mode);
try {
if (file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Le fichier ne peut pas être vide"));
}
String filename = file.getOriginalFilename().toLowerCase();
if (!filename.endsWith(".xlsx") && !filename.endsWith(".xls")) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(Map.of("erreur", "Seuls les fichiers Excel (.xlsx, .xls) sont supportés"));
}
List<GrilleTarifaire> grillesImportees = grilleTarifaireService.importerDepuisExcel(file, sheetName, mode);
log.info("Import Excel terminé avec succès - {} grilles importées", grillesImportees.size());
return ResponseEntity.ok(Map.of(
"message", "Import réussi",
"nombreGrillesImportees", grillesImportees.size(),
"grilles", grillesImportees
));
} catch (IOException e) {
log.error("Erreur lors de la lecture du fichier Excel", e);
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Erreur lors de la lecture du fichier: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de l'import Excel", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur interne lors de l'import"));
}
}
@Operation(
summary = "Importer grilles tarifaires depuis JSON",
description = "Import de grilles tarifaires au format JSON avec validation complète"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Import réussi"),
@ApiResponse(responseCode = "400", description = "JSON invalide ou erreurs de validation")
})
@PostMapping("/import/json")
public ResponseEntity<?> importerDepuisJson(
@Parameter(description = "Liste des grilles tarifaires au format JSON", required = true)
@Valid @RequestBody List<GrilleTarifaire> grilles,
@Parameter(description = "Mode d'import: REPLACE ou MERGE")
@RequestParam(defaultValue = "MERGE") String mode) {
log.info("Début d'import JSON - {} grilles à traiter, Mode: {}", grilles.size(), mode);
try {
List<GrilleTarifaire> grillesImportees = grilleTarifaireService.importerDepuisJson(grilles, mode);
log.info("Import JSON terminé avec succès - {} grilles importées", grillesImportees.size());
return ResponseEntity.ok(Map.of(
"message", "Import réussi",
"nombreGrillesImportees", grillesImportees.size(),
"grilles", grillesImportees
));
} catch (IllegalArgumentException e) {
log.warn("Données JSON invalides: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Données invalides: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de l'import JSON", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur interne lors de l'import"));
}
}
@Operation(
summary = "Créer ou mettre à jour une grille tarifaire",
description = "Crée une nouvelle grille ou met à jour une grille existante"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Grille créée avec succès"),
@ApiResponse(responseCode = "200", description = "Grille mise à jour avec succès"),
@ApiResponse(responseCode = "400", description = "Données invalides")
})
@PostMapping
public ResponseEntity<?> creerOuMettreAJourGrille(
@Parameter(description = "Grille tarifaire à créer ou mettre à jour", required = true)
@Valid @RequestBody GrilleTarifaire grille) {
log.info("Création/mise à jour grille tarifaire - Transporteur: {}, Route: {} -> {}",
grille.getTransporteur(), grille.getOriginePays(), grille.getDestinationPays());
try {
boolean isNew = (grille.getId() == null);
GrilleTarifaire grilleSauvegardee = grilleTarifaireService.sauvegarderGrille(grille);
HttpStatus status = isNew ? HttpStatus.CREATED : HttpStatus.OK;
String message = isNew ? "Grille créée avec succès" : "Grille mise à jour avec succès";
log.info("{} - ID: {}", message, grilleSauvegardee.getId());
return ResponseEntity.status(status).body(Map.of(
"message", message,
"grille", grilleSauvegardee
));
} catch (IllegalArgumentException e) {
log.warn("Données de grille invalides: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Données invalides: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de la sauvegarde de la grille", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur interne lors de la sauvegarde"));
}
}
@Operation(
summary = "Lister toutes les grilles tarifaires",
description = "Récupère la liste complète des grilles tarifaires avec pagination"
)
@GetMapping
public ResponseEntity<?> listerGrilles(
@Parameter(description = "Numéro de page (0-based)")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "Taille de la page")
@RequestParam(defaultValue = "20") int size,
@Parameter(description = "Filtrer par transporteur")
@RequestParam(required = false) String transporteur,
@Parameter(description = "Filtrer par pays d'origine")
@RequestParam(required = false) String paysOrigine,
@Parameter(description = "Filtrer par pays de destination")
@RequestParam(required = false) String paysDestination) {
try {
List<GrilleTarifaire> grilles = grilleTarifaireService.listerGrilles(
page, size, transporteur, paysOrigine, paysDestination);
return ResponseEntity.ok(Map.of(
"grilles", grilles,
"page", page,
"size", size,
"total", grilles.size()
));
} catch (Exception e) {
log.error("Erreur lors de la récupération des grilles", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur lors de la récupération des grilles"));
}
}
@Operation(
summary = "Récupérer une grille tarifaire par ID",
description = "Récupère le détail complet d'une grille tarifaire"
)
@GetMapping("/{id}")
public ResponseEntity<?> obtenirGrilleParId(
@Parameter(description = "Identifiant de la grille tarifaire", required = true)
@PathVariable Long id) {
try {
GrilleTarifaire grille = grilleTarifaireService.trouverParId(id);
if (grille == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(grille);
} catch (Exception e) {
log.error("Erreur lors de la récupération de la grille ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur lors de la récupération de la grille"));
}
}
@Operation(
summary = "Supprimer une grille tarifaire",
description = "Supprime définitivement une grille tarifaire"
)
@DeleteMapping("/{id}")
public ResponseEntity<?> supprimerGrille(
@Parameter(description = "Identifiant de la grille tarifaire à supprimer", required = true)
@PathVariable Long id) {
try {
grilleTarifaireService.supprimerGrille(id);
log.info("Grille tarifaire supprimée - ID: {}", id);
return ResponseEntity.ok(Map.of(
"message", "Grille supprimée avec succès",
"id", id
));
} catch (Exception e) {
log.error("Erreur lors de la suppression de la grille ID: {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur lors de la suppression de la grille"));
}
}
@Operation(
summary = "Valider la structure d'un fichier d'import",
description = "Valide la structure et les données d'un fichier avant import effectif"
)
@PostMapping("/validate")
public ResponseEntity<?> validerFichier(
@Parameter(description = "Fichier à valider (CSV ou Excel)", required = true)
@RequestParam("file") MultipartFile file) {
log.info("Validation fichier - Nom: {}, Taille: {} bytes",
file.getOriginalFilename(), file.getSize());
try {
if (file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Le fichier ne peut pas être vide"));
}
Map<String, Object> resultatsValidation = grilleTarifaireService.validerFichier(file);
return ResponseEntity.ok(resultatsValidation);
} catch (IOException e) {
log.error("Erreur lors de la lecture du fichier", e);
return ResponseEntity.badRequest()
.body(Map.of("erreur", "Erreur lors de la lecture du fichier: " + e.getMessage()));
} catch (Exception e) {
log.error("Erreur lors de la validation", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("erreur", "Erreur lors de la validation"));
}
}
}

View File

@ -10,7 +10,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@ComponentScan(basePackages = {"com.dh7789dev.xpeditis"})
@EnableJpaRepositories("com.dh7789dev.xpeditis.dao")
@EnableJpaRepositories({"com.dh7789dev.xpeditis.dao", "com.dh7789dev.xpeditis.repository"})
@EntityScan("com.dh7789dev.xpeditis.entity")
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
@SpringBootApplication

View File

@ -41,6 +41,8 @@ public class SecurityConfiguration {
private static final String[] WHITE_LIST_URL = {
"/api/v1/auth/**",
"/api/v1/devis/**",
"/api/v1/grilles-tarifaires/**",
"/actuator/health/**"};
private static final String[] ADMIN_ONLY_URL = {

View File

@ -0,0 +1,23 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.DemandeDevis;
import com.dh7789dev.xpeditis.dto.app.ReponseDevis;
public interface DevisCalculService {
/**
* Calcule les 3 offres (Rapide, Standard, Économique) pour une demande de devis
*
* @param demandeDevis la demande de devis avec tous les détails
* @return une réponse contenant les 3 offres calculées
*/
ReponseDevis calculerTroisOffres(DemandeDevis demandeDevis);
/**
* Valide qu'une demande de devis contient toutes les informations nécessaires
*
* @param demandeDevis la demande à valider
* @throws IllegalArgumentException si des données obligatoires sont manquantes
*/
void validerDemandeDevis(DemandeDevis demandeDevis);
}

View File

@ -0,0 +1,88 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.GrilleTarifaire;
import com.dh7789dev.xpeditis.dto.app.DemandeDevis;
import java.util.List;
public interface GrilleTarifaireService {
/**
* Trouve toutes les grilles tarifaires applicables pour une demande de devis
*
* @param demandeDevis la demande de devis
* @return liste des grilles applicables
*/
List<GrilleTarifaire> trouverGrillesApplicables(DemandeDevis demandeDevis);
/**
* Crée ou met à jour une grille tarifaire
*
* @param grilleTarifaire la grille à sauvegarder
* @return la grille sauvegardée
*/
GrilleTarifaire sauvegarderGrille(GrilleTarifaire grilleTarifaire);
/**
* Trouve une grille tarifaire par son ID
*
* @param id l'identifiant de la grille
* @return la grille trouvée ou null
*/
GrilleTarifaire trouverParId(Long id);
/**
* Supprime une grille tarifaire
*
* @param id l'identifiant de la grille à supprimer
*/
void supprimerGrille(Long id);
/**
* Importe des grilles tarifaires depuis un fichier CSV
*
* @param file le fichier CSV
* @param mode le mode d'import (MERGE ou REPLACE)
* @return la liste des grilles importées
*/
List<GrilleTarifaire> importerDepuisCsv(org.springframework.web.multipart.MultipartFile file, String mode) throws java.io.IOException;
/**
* Importe des grilles tarifaires depuis un fichier Excel
*
* @param file le fichier Excel
* @param sheetName le nom de la feuille (optionnel)
* @param mode le mode d'import (MERGE ou REPLACE)
* @return la liste des grilles importées
*/
List<GrilleTarifaire> importerDepuisExcel(org.springframework.web.multipart.MultipartFile file, String sheetName, String mode) throws java.io.IOException;
/**
* Importe des grilles tarifaires depuis du JSON
*
* @param grilles la liste des grilles au format JSON
* @param mode le mode d'import (MERGE ou REPLACE)
* @return la liste des grilles importées
*/
List<GrilleTarifaire> importerDepuisJson(List<GrilleTarifaire> grilles, String mode);
/**
* Liste les grilles tarifaires avec pagination et filtres
*
* @param page numéro de page
* @param size taille de la page
* @param transporteur filtre par transporteur
* @param paysOrigine filtre par pays d'origine
* @param paysDestination filtre par pays de destination
* @return la liste des grilles correspondant aux critères
*/
List<GrilleTarifaire> listerGrilles(int page, int size, String transporteur, String paysOrigine, String paysDestination);
/**
* Valide la structure et le contenu d'un fichier d'import
*
* @param file le fichier à valider
* @return les résultats de validation
*/
java.util.Map<String, Object> validerFichier(org.springframework.web.multipart.MultipartFile file) throws java.io.IOException;
}

View File

@ -0,0 +1,119 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DemandeDevis {
private String typeService;
private String incoterm;
private String typeLivraison;
// Adresses
private AdresseTransport depart;
private AdresseTransport arrivee;
// Douane
private String douaneImportExport;
private Boolean eur1Import;
private Boolean eur1Export;
// Colisage
private List<Colisage> colisages;
// Contraintes et services
private MarchandiseDangereuse marchandiseDangereuse;
private ManutentionParticuliere manutentionParticuliere;
private ProduitsReglementes produitsReglementes;
private ServicesAdditionnels servicesAdditionnels;
// Dates
private LocalDate dateEnlevement;
private LocalDate dateLivraison;
// Client
private String nomClient;
private String emailClient;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class AdresseTransport {
private String ville;
private String codePostal;
private String pays;
private String coordonneesGps;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Colisage {
private TypeColisage type;
private Integer quantite;
private Double longueur;
private Double largeur;
private Double hauteur;
private Double poids;
private Boolean gerbable;
public Double getVolume() {
if (longueur != null && largeur != null && hauteur != null) {
return longueur * largeur * hauteur / 1000000; // cm³ to m³
}
return 0.0;
}
public enum TypeColisage {
CAISSE, COLIS, PALETTE, AUTRES
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class MarchandiseDangereuse {
private String type;
private String classe;
private String unNumber;
private String description;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ManutentionParticuliere {
private Boolean hayon;
private Boolean sangles;
private Boolean couvertureThermique;
private String autres;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ProduitsReglementes {
private Boolean alimentaire;
private Boolean pharmaceutique;
private String autres;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ServicesAdditionnels {
private Boolean rendezVousLivraison;
private Boolean documentT1;
private Boolean stopDouane;
private Boolean assistanceExport;
private Boolean assurance;
private Double valeurDeclaree;
}
}

View File

@ -0,0 +1,27 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class FraisAdditionnels {
private Long id;
private Long grilleId;
private String typeFrais;
private String description;
private BigDecimal montant;
private UniteFacturation uniteFacturation;
private BigDecimal montantMinimum;
private Boolean obligatoire;
private Boolean applicableMarchandiseDangereuse;
public enum UniteFacturation {
LS, KG, M3, PALETTE, POURCENTAGE
}
}

View File

@ -0,0 +1,51 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GrilleTarifaire {
private Long id;
private String nomGrille;
private String transporteur;
private TypeService typeService;
private String originePays;
private String origineVille;
private String originePortCode;
private String destinationPays;
private String destinationVille;
private String destinationPortCode;
private String incoterm;
private ModeTransport modeTransport;
private ServiceType serviceType;
private Integer transitTimeMin;
private Integer transitTimeMax;
private LocalDate validiteDebut;
private LocalDate validiteFin;
private String devise;
private Boolean actif;
private String deviseBase;
private String commentaires;
private List<TarifFret> tarifsFret;
private List<FraisAdditionnels> fraisAdditionnels;
private List<SurchargeDangereuse> surchargesDangereuses;
public enum TypeService {
IMPORT, EXPORT
}
public enum ModeTransport {
MARITIME, AERIEN, ROUTIER, FERROVIAIRE
}
public enum ServiceType {
RAPIDE, STANDARD, ECONOMIQUE
}
}

View File

@ -0,0 +1,38 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OffreCalculee {
private String type; // RAPIDE, STANDARD, ECONOMIQUE
private BigDecimal prixTotal;
private String devise;
private String transitTime;
private String transporteur;
private String modeTransport;
private List<String> servicesInclus;
private DetailPrix detailPrix;
private LocalDate validite;
private List<String> conditions;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class DetailPrix {
private BigDecimal fretBase;
private Map<String, BigDecimal> fraisFixes;
private Map<String, BigDecimal> servicesOptionnels;
private BigDecimal surchargeDangereuse;
private BigDecimal coefficientService;
}
}

View File

@ -0,0 +1,65 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReponseDevis {
private String demandeId;
private ClientInfo client;
private DetailsTransport detailsTransport;
private ColisageResume colisageResume;
private List<OffreCalculee> offres;
private Recommandation recommandation;
private List<String> mentionsLegales;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ClientInfo {
private String nom;
private String email;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class DetailsTransport {
private String typeService;
private String incoterm;
private AdresseInfo depart;
private AdresseInfo arrivee;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class AdresseInfo {
private String adresse;
private String coordonnees;
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ColisageResume {
private Integer nombreColis;
private Double poidsTotal;
private Double volumeTotal;
private Double poidsTaxable;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class Recommandation {
private String offreRecommandee;
private String raison;
}
}

View File

@ -0,0 +1,25 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SurchargeDangereuse {
private Long id;
private Long grilleId;
private String classeAdr;
private String unNumber;
private BigDecimal surcharge;
private UniteFacturation uniteFacturation;
private BigDecimal minimum;
public enum UniteFacturation {
LS, KG, COLIS
}
}

View File

@ -0,0 +1,27 @@
package com.dh7789dev.xpeditis.dto.app;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TarifFret {
private Long id;
private Long grilleId;
private BigDecimal poidsMin;
private BigDecimal poidsMax;
private BigDecimal volumeMin;
private BigDecimal volumeMax;
private BigDecimal tauxUnitaire;
private UniteFacturation uniteFacturation;
private BigDecimal minimumFacturation;
public enum UniteFacturation {
KG, M3, PALETTE, COLIS, LS
}
}

View File

@ -0,0 +1,478 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class DevisCalculServiceImpl implements DevisCalculService {
private final GrilleTarifaireService grilleTarifaireService;
private static final BigDecimal COEFFICIENT_POIDS_VOLUMETRIQUE = new BigDecimal("250"); // 250kg/m³
@Override
public ReponseDevis calculerTroisOffres(DemandeDevis demandeDevis) {
log.info("Calcul des 3 offres pour demande devis client: {}", demandeDevis.getNomClient());
validerDemandeDevis(demandeDevis);
// Calculer le colisage résumé
ReponseDevis.ColisageResume colisageResume = calculerColisageResume(demandeDevis);
// Trouver les grilles applicables
List<GrilleTarifaire> grillesApplicables = grilleTarifaireService.trouverGrillesApplicables(demandeDevis);
if (grillesApplicables.isEmpty()) {
throw new IllegalStateException("Aucune grille tarifaire applicable pour cette demande");
}
// Générer les 3 offres
List<OffreCalculee> offres = genererTroisOffres(demandeDevis, grillesApplicables, colisageResume);
// Déterminer la recommandation
ReponseDevis.Recommandation recommandation = determinerRecommandation(offres);
return new ReponseDevis(
generateDemandeId(),
new ReponseDevis.ClientInfo(demandeDevis.getNomClient(), demandeDevis.getEmailClient()),
mapperDetailsTransport(demandeDevis),
colisageResume,
offres,
recommandation,
getMentionsLegales()
);
}
private List<OffreCalculee> genererTroisOffres(
DemandeDevis demande,
List<GrilleTarifaire> grilles,
ReponseDevis.ColisageResume colisage) {
List<OffreCalculee> offres = new ArrayList<>();
// Pour chaque type de service (Rapide, Standard, Économique)
for (GrilleTarifaire.ServiceType serviceType : GrilleTarifaire.ServiceType.values()) {
// Filtrer les grilles par type de service
List<GrilleTarifaire> grillesService = grilles.stream()
.filter(g -> g.getServiceType() == serviceType)
.collect(Collectors.toList());
// Si pas de grilles spécifiques, utiliser les grilles standard
if (grillesService.isEmpty()) {
grillesService = grilles.stream()
.filter(g -> g.getServiceType() == GrilleTarifaire.ServiceType.STANDARD)
.collect(Collectors.toList());
}
// Calculer la meilleure offre pour ce type de service
OffreCalculee meilleureOffre = calculerMeilleureOffre(demande, grillesService, serviceType, colisage);
if (meilleureOffre != null) {
offres.add(appliquerAjustementParType(meilleureOffre, serviceType));
}
}
return offres;
}
private OffreCalculee calculerMeilleureOffre(
DemandeDevis demande,
List<GrilleTarifaire> grilles,
GrilleTarifaire.ServiceType serviceType,
ReponseDevis.ColisageResume colisage) {
OffreCalculee meilleureOffre = null;
BigDecimal prixMinimal = BigDecimal.valueOf(Double.MAX_VALUE);
for (GrilleTarifaire grille : grilles) {
try {
OffreCalculee offre = calculerOffreGrille(demande, grille, serviceType, colisage);
if (offre != null && offre.getPrixTotal().compareTo(prixMinimal) < 0) {
prixMinimal = offre.getPrixTotal();
meilleureOffre = offre;
}
} catch (Exception e) {
log.warn("Erreur lors du calcul avec la grille {}: {}", grille.getId(), e.getMessage());
}
}
return meilleureOffre;
}
private OffreCalculee calculerOffreGrille(
DemandeDevis demande,
GrilleTarifaire grille,
GrilleTarifaire.ServiceType serviceType,
ReponseDevis.ColisageResume colisage) {
// 1. Calculer le fret de base
BigDecimal fretBase = calculerFretBase(grille, colisage);
// 2. Calculer les frais fixes obligatoires
Map<String, BigDecimal> fraisFixes = calculerFraisFixes(grille, demande, colisage);
// 3. Calculer les surcharges marchandises dangereuses
BigDecimal surchargeDangereuse = calculerSurchargeDangereuse(grille, demande, colisage);
// 4. Calculer les services optionnels demandés
Map<String, BigDecimal> servicesOptionnels = calculerServicesOptionnels(grille, demande, colisage);
// 5. Calculer le prix total
BigDecimal prixTotal = fretBase
.add(fraisFixes.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add))
.add(surchargeDangereuse)
.add(servicesOptionnels.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add));
// Créer les détails du prix
OffreCalculee.DetailPrix detailPrix = new OffreCalculee.DetailPrix();
detailPrix.setFretBase(fretBase);
detailPrix.setFraisFixes(fraisFixes);
detailPrix.setServicesOptionnels(servicesOptionnels);
detailPrix.setSurchargeDangereuse(surchargeDangereuse);
detailPrix.setCoefficientService(BigDecimal.ONE);
// Créer l'offre
OffreCalculee offre = new OffreCalculee();
offre.setType(serviceType.name());
offre.setPrixTotal(prixTotal);
offre.setDevise(grille.getDevise());
offre.setTransitTime(formatTransitTime(grille));
offre.setTransporteur(grille.getTransporteur());
offre.setModeTransport(grille.getModeTransport().name());
offre.setServicesInclus(getServicesInclus(serviceType));
offre.setDetailPrix(detailPrix);
offre.setValidite(LocalDate.now().plusDays(30));
offre.setConditions(getConditions(serviceType));
return offre;
}
private BigDecimal calculerFretBase(GrilleTarifaire grille, ReponseDevis.ColisageResume colisage) {
// Trouver le tarif applicable selon le poids taxable
TarifFret tarifApplicable = grille.getTarifsFret().stream()
.filter(t -> {
BigDecimal poidsTaxable = BigDecimal.valueOf(colisage.getPoidsTaxable());
return (t.getPoidsMin() == null || t.getPoidsMin().compareTo(poidsTaxable) <= 0) &&
(t.getPoidsMax() == null || t.getPoidsMax().compareTo(poidsTaxable) >= 0);
})
.findFirst()
.orElse(null);
if (tarifApplicable == null) {
throw new IllegalStateException("Aucun tarif applicable pour le poids taxable: " + colisage.getPoidsTaxable());
}
// Calculer le coût selon l'unité de facturation
BigDecimal cout = BigDecimal.ZERO;
switch (tarifApplicable.getUniteFacturation()) {
case KG:
cout = BigDecimal.valueOf(colisage.getPoidsTaxable()).multiply(tarifApplicable.getTauxUnitaire());
break;
case M3:
cout = BigDecimal.valueOf(colisage.getVolumeTotal()).multiply(tarifApplicable.getTauxUnitaire());
break;
case COLIS:
cout = BigDecimal.valueOf(colisage.getNombreColis()).multiply(tarifApplicable.getTauxUnitaire());
break;
case LS:
cout = tarifApplicable.getTauxUnitaire();
break;
}
// Appliquer le minimum de facturation si défini
if (tarifApplicable.getMinimumFacturation() != null &&
cout.compareTo(tarifApplicable.getMinimumFacturation()) < 0) {
cout = tarifApplicable.getMinimumFacturation();
}
return cout.setScale(2, RoundingMode.HALF_UP);
}
private Map<String, BigDecimal> calculerFraisFixes(GrilleTarifaire grille, DemandeDevis demande, ReponseDevis.ColisageResume colisage) {
Map<String, BigDecimal> fraisFixes = new HashMap<>();
for (FraisAdditionnels frais : grille.getFraisAdditionnels()) {
if (frais.getObligatoire()) {
BigDecimal montant = calculerMontantFrais(frais, demande, colisage);
fraisFixes.put(frais.getTypeFrais(), montant);
}
}
return fraisFixes;
}
private BigDecimal calculerSurchargeDangereuse(GrilleTarifaire grille, DemandeDevis demande, ReponseDevis.ColisageResume colisage) {
if (demande.getMarchandiseDangereuse() == null) {
return BigDecimal.ZERO;
}
SurchargeDangereuse surcharge = grille.getSurchargesDangereuses().stream()
.filter(s -> s.getClasseAdr().equals(demande.getMarchandiseDangereuse().getClasse()))
.findFirst()
.orElse(null);
if (surcharge == null) {
return BigDecimal.ZERO;
}
BigDecimal montant = BigDecimal.ZERO;
switch (surcharge.getUniteFacturation()) {
case KG:
montant = BigDecimal.valueOf(colisage.getPoidsTaxable()).multiply(surcharge.getSurcharge());
break;
case COLIS:
montant = BigDecimal.valueOf(colisage.getNombreColis()).multiply(surcharge.getSurcharge());
break;
case LS:
montant = surcharge.getSurcharge();
break;
}
if (surcharge.getMinimum() != null && montant.compareTo(surcharge.getMinimum()) < 0) {
montant = surcharge.getMinimum();
}
return montant.setScale(2, RoundingMode.HALF_UP);
}
private Map<String, BigDecimal> calculerServicesOptionnels(GrilleTarifaire grille, DemandeDevis demande, ReponseDevis.ColisageResume colisage) {
Map<String, BigDecimal> services = new HashMap<>();
// Assurance
if (demande.getServicesAdditionnels() != null && demande.getServicesAdditionnels().getAssurance()) {
FraisAdditionnels fraisAssurance = grille.getFraisAdditionnels().stream()
.filter(f -> "ASSURANCE".equals(f.getTypeFrais()))
.findFirst()
.orElse(null);
if (fraisAssurance != null) {
BigDecimal montant = calculerMontantFrais(fraisAssurance, demande, colisage);
services.put("assurance", montant);
}
}
// Hayon
if (demande.getManutentionParticuliere() != null && demande.getManutentionParticuliere().getHayon()) {
FraisAdditionnels fraisHayon = grille.getFraisAdditionnels().stream()
.filter(f -> "HAYON".equals(f.getTypeFrais()))
.findFirst()
.orElse(null);
if (fraisHayon != null) {
BigDecimal montant = calculerMontantFrais(fraisHayon, demande, colisage);
services.put("hayon", montant);
}
}
return services;
}
private BigDecimal calculerMontantFrais(FraisAdditionnels frais, DemandeDevis demande, ReponseDevis.ColisageResume colisage) {
BigDecimal montant = BigDecimal.ZERO;
switch (frais.getUniteFacturation()) {
case LS:
montant = frais.getMontant();
break;
case KG:
montant = BigDecimal.valueOf(colisage.getPoidsTaxable()).multiply(frais.getMontant());
break;
case M3:
montant = BigDecimal.valueOf(colisage.getVolumeTotal()).multiply(frais.getMontant());
break;
case POURCENTAGE:
// Pourcentage du fret de base - à implémenter selon le contexte
montant = frais.getMontant();
break;
}
if (frais.getMontantMinimum() != null && montant.compareTo(frais.getMontantMinimum()) < 0) {
montant = frais.getMontantMinimum();
}
return montant.setScale(2, RoundingMode.HALF_UP);
}
private OffreCalculee appliquerAjustementParType(OffreCalculee offre, GrilleTarifaire.ServiceType serviceType) {
BigDecimal coefficient = getCoefficient(serviceType);
Double reductionTransit = getReductionTransitTime(serviceType);
// Appliquer le coefficient au prix total
BigDecimal prixAjuste = offre.getPrixTotal().multiply(coefficient).setScale(2, RoundingMode.HALF_UP);
// Ajuster le détail des prix
offre.getDetailPrix().setCoefficientService(coefficient);
// Mettre à jour le prix total
offre.setPrixTotal(prixAjuste);
// Ajuster le transit time si défini
if (reductionTransit != null) {
String transitTimeAjuste = ajusterTransitTime(offre.getTransitTime(), reductionTransit);
offre.setTransitTime(transitTimeAjuste);
}
return offre;
}
private BigDecimal getCoefficient(GrilleTarifaire.ServiceType serviceType) {
switch (serviceType) {
case RAPIDE:
return new BigDecimal("1.15"); // +15%
case STANDARD:
return BigDecimal.ONE;
case ECONOMIQUE:
return new BigDecimal("0.85"); // -15%
default:
return BigDecimal.ONE;
}
}
private Double getReductionTransitTime(GrilleTarifaire.ServiceType serviceType) {
switch (serviceType) {
case RAPIDE:
return 0.7; // -30%
case STANDARD:
return null; // Pas de changement
case ECONOMIQUE:
return 1.3; // +30%
default:
return null;
}
}
private ReponseDevis.ColisageResume calculerColisageResume(DemandeDevis demande) {
double poidsTotal = demande.getColisages().stream()
.mapToDouble(c -> c.getPoids() * c.getQuantite())
.sum();
double volumeTotal = demande.getColisages().stream()
.mapToDouble(c -> c.getVolume() * c.getQuantite())
.sum();
int nombreColis = demande.getColisages().stream()
.mapToInt(DemandeDevis.Colisage::getQuantite)
.sum();
// Calculer le poids taxable (max entre poids réel et poids volumétrique)
double poidsVolumetrique = volumeTotal * COEFFICIENT_POIDS_VOLUMETRIQUE.doubleValue();
double poidsTaxable = Math.max(poidsTotal, poidsVolumetrique);
return new ReponseDevis.ColisageResume(nombreColis, poidsTotal, volumeTotal, poidsTaxable);
}
private List<String> getServicesInclus(GrilleTarifaire.ServiceType serviceType) {
switch (serviceType) {
case RAPIDE:
return Arrays.asList("Suivi en temps réel", "Assurance de base", "Service express");
case STANDARD:
return Arrays.asList("Suivi standard");
case ECONOMIQUE:
return Collections.emptyList();
default:
return Collections.emptyList();
}
}
private List<String> getConditions(GrilleTarifaire.ServiceType serviceType) {
switch (serviceType) {
case RAPIDE:
return Arrays.asList("Prix valable sous réserve d'espace disponible", "Marchandise prête à l'enlèvement");
case STANDARD:
return Arrays.asList("Prix standard selon grille tarifaire", "Délais indicatifs");
case ECONOMIQUE:
return Arrays.asList("Tarif économique avec délais étendus", "Services minimaux inclus");
default:
return Collections.emptyList();
}
}
private ReponseDevis.Recommandation determinerRecommandation(List<OffreCalculee> offres) {
// Par défaut, recommander l'offre STANDARD
return new ReponseDevis.Recommandation("STANDARD", "Meilleur rapport qualité/prix/délai");
}
private ReponseDevis.DetailsTransport mapperDetailsTransport(DemandeDevis demande) {
ReponseDevis.DetailsTransport.AdresseInfo depart = new ReponseDevis.DetailsTransport.AdresseInfo(
demande.getDepart().getVille() + ", " + demande.getDepart().getCodePostal() + ", " + demande.getDepart().getPays(),
demande.getDepart().getCoordonneesGps()
);
ReponseDevis.DetailsTransport.AdresseInfo arrivee = new ReponseDevis.DetailsTransport.AdresseInfo(
demande.getArrivee().getVille() + ", " + demande.getArrivee().getCodePostal() + ", " + demande.getArrivee().getPays(),
demande.getArrivee().getCoordonneesGps()
);
return new ReponseDevis.DetailsTransport(demande.getTypeService(), demande.getIncoterm(), depart, arrivee);
}
private String formatTransitTime(GrilleTarifaire grille) {
if (grille.getTransitTimeMin() != null && grille.getTransitTimeMax() != null) {
return grille.getTransitTimeMin() + "-" + grille.getTransitTimeMax() + " jours";
}
return "À définir";
}
private String ajusterTransitTime(String transitTime, double coefficient) {
// Extraction des nombres du format "25-30 jours" et application du coefficient
if (transitTime.contains("-")) {
String[] parts = transitTime.split("-");
try {
int min = (int) (Integer.parseInt(parts[0]) * coefficient);
int max = (int) (Integer.parseInt(parts[1].split(" ")[0]) * coefficient);
return min + "-" + max + " jours";
} catch (NumberFormatException e) {
return transitTime;
}
}
return transitTime;
}
private String generateDemandeId() {
return "DEV-" + LocalDate.now().getYear() + "-" + String.format("%06d", new Random().nextInt(999999));
}
private List<String> getMentionsLegales() {
return Arrays.asList(
"Prix hors taxes applicables",
"Conditions générales de vente applicables",
"Devis valable 30 jours"
);
}
@Override
public void validerDemandeDevis(DemandeDevis demandeDevis) {
if (demandeDevis == null) {
throw new IllegalArgumentException("La demande de devis ne peut pas être nulle");
}
if (demandeDevis.getDepart() == null || demandeDevis.getArrivee() == null) {
throw new IllegalArgumentException("Les adresses de départ et d'arrivée sont obligatoires");
}
if (demandeDevis.getColisages() == null || demandeDevis.getColisages().isEmpty()) {
throw new IllegalArgumentException("Au moins un colisage doit être défini");
}
for (DemandeDevis.Colisage colisage : demandeDevis.getColisages()) {
if (colisage.getPoids() == null || colisage.getPoids() <= 0) {
throw new IllegalArgumentException("Le poids de chaque colisage doit être supérieur à 0");
}
}
}
}

View File

@ -0,0 +1,426 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.DemandeDevis;
import com.dh7789dev.xpeditis.dto.app.GrilleTarifaire;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class GrilleTarifaireServiceImpl implements GrilleTarifaireService {
private final GrilleTarifaireRepository grilleTarifaireRepository;
@Override
public List<GrilleTarifaire> trouverGrillesApplicables(DemandeDevis demandeDevis) {
log.info("Recherche des grilles tarifaires applicables pour {} -> {}",
demandeDevis.getDepart().getPays(),
demandeDevis.getArrivee().getPays());
LocalDate dateValidite = demandeDevis.getDateEnlevement() != null
? demandeDevis.getDateEnlevement()
: LocalDate.now();
List<GrilleTarifaire> grilles = grilleTarifaireRepository.findGrillesApplicables(
demandeDevis.getTypeService(),
demandeDevis.getDepart().getPays(),
demandeDevis.getArrivee().getPays(),
dateValidite
);
// Filtrer par ville si spécifiée
if (demandeDevis.getDepart().getVille() != null || demandeDevis.getArrivee().getVille() != null) {
grilles = grilles.stream()
.filter(g -> isVilleCompatible(g, demandeDevis))
.collect(Collectors.toList());
}
// Filtrer par incoterm si spécifié
if (demandeDevis.getIncoterm() != null) {
grilles = grilles.stream()
.filter(g -> g.getIncoterm() == null || g.getIncoterm().equals(demandeDevis.getIncoterm()))
.collect(Collectors.toList());
}
log.info("Trouvé {} grille(s) applicables", grilles.size());
return grilles;
}
@Override
public GrilleTarifaire sauvegarderGrille(GrilleTarifaire grilleTarifaire) {
log.info("Sauvegarde de la grille tarifaire: {}", grilleTarifaire.getNomGrille());
validerGrilleTarifaire(grilleTarifaire);
return grilleTarifaireRepository.save(grilleTarifaire);
}
@Override
public GrilleTarifaire trouverParId(Long id) {
log.debug("Recherche de la grille tarifaire avec l'ID: {}", id);
return grilleTarifaireRepository.findById(id)
.orElse(null);
}
@Override
public void supprimerGrille(Long id) {
log.info("Suppression de la grille tarifaire avec l'ID: {}", id);
if (!grilleTarifaireRepository.existsById(id)) {
throw new IllegalArgumentException("Aucune grille tarifaire trouvée avec l'ID: " + id);
}
grilleTarifaireRepository.deleteById(id);
}
private boolean isVilleCompatible(GrilleTarifaire grille, DemandeDevis demande) {
// Si la grille n'a pas de ville spécifiée, elle est compatible avec toutes les villes
boolean origineCompatible = grille.getOrigineVille() == null ||
grille.getOrigineVille().equalsIgnoreCase(demande.getDepart().getVille());
boolean destinationCompatible = grille.getDestinationVille() == null ||
grille.getDestinationVille().equalsIgnoreCase(demande.getArrivee().getVille());
return origineCompatible && destinationCompatible;
}
private void validerGrilleTarifaire(GrilleTarifaire grille) {
if (grille == null) {
throw new IllegalArgumentException("La grille tarifaire ne peut pas être nulle");
}
if (grille.getNomGrille() == null || grille.getNomGrille().trim().isEmpty()) {
throw new IllegalArgumentException("Le nom de la grille est obligatoire");
}
if (grille.getTransporteur() == null || grille.getTransporteur().trim().isEmpty()) {
throw new IllegalArgumentException("Le transporteur est obligatoire");
}
if (grille.getTypeService() == null) {
throw new IllegalArgumentException("Le type de service est obligatoire");
}
if (grille.getOriginePays() == null || grille.getOriginePays().trim().isEmpty()) {
throw new IllegalArgumentException("Le pays d'origine est obligatoire");
}
if (grille.getDestinationPays() == null || grille.getDestinationPays().trim().isEmpty()) {
throw new IllegalArgumentException("Le pays de destination est obligatoire");
}
if (grille.getValiditeDebut() == null) {
throw new IllegalArgumentException("La date de début de validité est obligatoire");
}
if (grille.getValiditeFin() == null) {
throw new IllegalArgumentException("La date de fin de validité est obligatoire");
}
if (grille.getValiditeDebut().isAfter(grille.getValiditeFin())) {
throw new IllegalArgumentException("La date de début doit être antérieure à la date de fin");
}
// Validation des tarifs de fret
if (grille.getTarifsFret() == null || grille.getTarifsFret().isEmpty()) {
throw new IllegalArgumentException("Au moins un tarif de fret doit être défini");
}
// Validation des codes pays (format ISO 3166-1 alpha-3)
if (grille.getOriginePays().length() != 3) {
throw new IllegalArgumentException("Le code pays d'origine doit être au format ISO 3166-1 alpha-3 (3 caractères)");
}
if (grille.getDestinationPays().length() != 3) {
throw new IllegalArgumentException("Le code pays de destination doit être au format ISO 3166-1 alpha-3 (3 caractères)");
}
}
@Override
public List<GrilleTarifaire> importerDepuisCsv(MultipartFile file, String mode) throws IOException {
log.info("Import CSV - Fichier: {}, Mode: {}", file.getOriginalFilename(), mode);
if ("REPLACE".equalsIgnoreCase(mode)) {
log.info("Mode REPLACE - Suppression de toutes les grilles existantes");
grilleTarifaireRepository.deleteAll();
}
List<GrilleTarifaire> grillesImportees = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), "UTF-8"))) {
String headerLine = reader.readLine();
if (headerLine == null) {
throw new IllegalArgumentException("Le fichier CSV est vide");
}
String[] headers = headerLine.split(",");
log.debug("En-têtes CSV: {}", Arrays.toString(headers));
String line;
int lineNumber = 1;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.trim().isEmpty()) {
continue;
}
try {
GrilleTarifaire grille = parseCsvLine(line, headers, lineNumber);
if (grille != null) {
validerGrilleTarifaire(grille);
GrilleTarifaire grilleSauvegardee = grilleTarifaireRepository.save(grille);
grillesImportees.add(grilleSauvegardee);
}
} catch (Exception e) {
log.error("Erreur ligne {}: {}", lineNumber, e.getMessage());
throw new IllegalArgumentException("Erreur ligne " + lineNumber + ": " + e.getMessage());
}
}
}
log.info("Import CSV terminé - {} grilles importées", grillesImportees.size());
return grillesImportees;
}
@Override
public List<GrilleTarifaire> importerDepuisExcel(MultipartFile file, String sheetName, String mode) throws IOException {
log.info("Import Excel - Fichier: {}, Feuille: {}, Mode: {}", file.getOriginalFilename(), sheetName, mode);
// Pour cette version simplifiée, nous convertissons Excel vers CSV puis utilisons le parser CSV
// Dans une implémentation complète, nous utiliserions Apache POI
throw new UnsupportedOperationException("L'import Excel n'est pas encore implémenté. Utilisez le format CSV.");
}
@Override
public List<GrilleTarifaire> importerDepuisJson(List<GrilleTarifaire> grilles, String mode) {
log.info("Import JSON - {} grilles, Mode: {}", grilles.size(), mode);
if ("REPLACE".equalsIgnoreCase(mode)) {
log.info("Mode REPLACE - Suppression de toutes les grilles existantes");
grilleTarifaireRepository.deleteAll();
}
List<GrilleTarifaire> grillesImportees = new ArrayList<>();
for (int i = 0; i < grilles.size(); i++) {
try {
GrilleTarifaire grille = grilles.get(i);
validerGrilleTarifaire(grille);
// Si la grille a un ID et existe déjà, mise à jour. Sinon, création.
if (grille.getId() != null && grilleTarifaireRepository.existsById(grille.getId())) {
log.debug("Mise à jour grille existante ID: {}", grille.getId());
} else {
grille.setId(null); // Force la création d'une nouvelle grille
}
GrilleTarifaire grilleSauvegardee = grilleTarifaireRepository.save(grille);
grillesImportees.add(grilleSauvegardee);
} catch (Exception e) {
log.error("Erreur lors du traitement de la grille #{}: {}", i + 1, e.getMessage());
throw new IllegalArgumentException("Erreur grille #" + (i + 1) + ": " + e.getMessage());
}
}
log.info("Import JSON terminé - {} grilles importées", grillesImportees.size());
return grillesImportees;
}
@Override
public List<GrilleTarifaire> listerGrilles(int page, int size, String transporteur, String paysOrigine, String paysDestination) {
log.debug("Listing grilles - page: {}, size: {}, transporteur: {}, origine: {}, destination: {}",
page, size, transporteur, paysOrigine, paysDestination);
// Pour cette implémentation simplifiée, nous récupérons toutes les grilles et filtrons
// Dans une implémentation complète, nous utiliserions des requêtes JPA avec Specification
List<GrilleTarifaire> toutes = grilleTarifaireRepository.findAll();
// Application des filtres
return toutes.stream()
.filter(g -> transporteur == null || g.getTransporteur().toLowerCase().contains(transporteur.toLowerCase()))
.filter(g -> paysOrigine == null || g.getOriginePays().equalsIgnoreCase(paysOrigine))
.filter(g -> paysDestination == null || g.getDestinationPays().equalsIgnoreCase(paysDestination))
.skip((long) page * size)
.limit(size)
.collect(Collectors.toList());
}
@Override
public Map<String, Object> validerFichier(MultipartFile file) throws IOException {
log.info("Validation fichier - Nom: {}, Taille: {} bytes", file.getOriginalFilename(), file.getSize());
Map<String, Object> resultat = new HashMap<>();
List<String> erreurs = new ArrayList<>();
List<String> avertissements = new ArrayList<>();
int lignesValides = 0;
int lignesTotal = 0;
String filename = file.getOriginalFilename().toLowerCase();
resultat.put("nomFichier", file.getOriginalFilename());
resultat.put("tailleFichier", file.getSize());
resultat.put("typeFichier", filename.endsWith(".csv") ? "CSV" : filename.endsWith(".xlsx") ? "Excel" : "Inconnu");
if (filename.endsWith(".csv")) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream(), "UTF-8"))) {
String headerLine = reader.readLine();
lignesTotal++;
if (headerLine == null) {
erreurs.add("Le fichier est vide");
} else {
String[] headers = headerLine.split(",");
resultat.put("nombreColonnes", headers.length);
resultat.put("colonnes", Arrays.asList(headers));
// Vérification des colonnes obligatoires
List<String> colonnesObligatoires = Arrays.asList(
"nomGrille", "transporteur", "typeService", "originePays", "destinationPays",
"validiteDebut", "validiteFin"
);
for (String colonne : colonnesObligatoires) {
boolean trouve = false;
for (String header : headers) {
if (header.trim().equalsIgnoreCase(colonne)) {
trouve = true;
break;
}
}
if (!trouve) {
erreurs.add("Colonne obligatoire manquante: " + colonne);
}
}
String line;
int lineNumber = 1;
while ((line = reader.readLine()) != null && lineNumber <= 100) { // Limite pour la validation
lineNumber++;
lignesTotal++;
if (line.trim().isEmpty()) {
continue;
}
try {
GrilleTarifaire grille = parseCsvLine(line, headers, lineNumber);
if (grille != null) {
validerGrilleTarifaire(grille);
lignesValides++;
}
} catch (Exception e) {
erreurs.add("Ligne " + lineNumber + ": " + e.getMessage());
}
}
if (lineNumber > 100) {
avertissements.add("Validation limitée aux 100 premières lignes de données");
}
}
}
} else {
erreurs.add("Format de fichier non supporté. Seuls les fichiers CSV sont actuellement supportés.");
}
resultat.put("lignesTotal", lignesTotal);
resultat.put("lignesValides", lignesValides);
resultat.put("erreurs", erreurs);
resultat.put("avertissements", avertissements);
resultat.put("valide", erreurs.isEmpty());
return resultat;
}
private GrilleTarifaire parseCsvLine(String line, String[] headers, int lineNumber) {
String[] values = line.split(",", -1); // -1 pour conserver les valeurs vides
if (values.length != headers.length) {
throw new IllegalArgumentException("Nombre de colonnes incorrect. Attendu: " + headers.length + ", trouvé: " + values.length);
}
GrilleTarifaire grille = new GrilleTarifaire();
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < headers.length; i++) {
String header = headers[i].trim();
String value = values[i].trim();
if (value.isEmpty()) {
continue;
}
try {
switch (header.toLowerCase()) {
case "nomgrille":
grille.setNomGrille(value);
break;
case "transporteur":
grille.setTransporteur(value);
break;
case "typeservice":
grille.setTypeService(GrilleTarifaire.TypeService.valueOf(value.toUpperCase()));
break;
case "originepays":
grille.setOriginePays(value);
break;
case "destinationpays":
grille.setDestinationPays(value);
break;
case "origineville":
grille.setOrigineVille(value);
break;
case "destinationville":
grille.setDestinationVille(value);
break;
case "validitedebut":
grille.setValiditeDebut(LocalDate.parse(value, dateFormatter));
break;
case "validiteefin":
grille.setValiditeFin(LocalDate.parse(value, dateFormatter));
break;
case "incoterm":
grille.setIncoterm(value);
break;
case "modetransport":
grille.setModeTransport(GrilleTarifaire.ModeTransport.valueOf(value.toUpperCase()));
break;
case "actif":
grille.setActif(Boolean.parseBoolean(value));
break;
case "devisebase":
grille.setDeviseBase(value);
break;
case "commentaires":
grille.setCommentaires(value);
break;
// Pour les tarifs de fret et autres listes, nous aurions besoin d'un format plus complexe
// Dans cette implémentation simplifiée, nous les ignorons
default:
log.debug("Colonne ignorée: {}", header);
}
} catch (Exception e) {
throw new IllegalArgumentException("Erreur dans la colonne '" + header + "': " + e.getMessage());
}
}
return grille;
}
}

View File

@ -0,0 +1,277 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("DevisCalculService - Tests unitaires")
class DevisCalculServiceImplTest {
@Mock
private GrilleTarifaireService grilleTarifaireService;
private DevisCalculServiceImpl devisCalculService;
private DemandeDevis demandeDevisValide;
private GrilleTarifaire grilleTarifaireStandard;
@BeforeEach
void setUp() {
devisCalculService = new DevisCalculServiceImpl(grilleTarifaireService);
// Créer une demande de devis valide pour les tests
demandeDevisValide = creerDemandeDevisValide();
// Créer une grille tarifaire standard pour les tests
grilleTarifaireStandard = creerGrilleTarifaireStandard();
}
@Test
@DisplayName("Doit calculer 3 offres avec succès")
void doitCalculerTroisOffresAvecSucces() {
// Given
when(grilleTarifaireService.trouverGrillesApplicables(any(DemandeDevis.class)))
.thenReturn(Arrays.asList(grilleTarifaireStandard));
// When
ReponseDevis reponse = devisCalculService.calculerTroisOffres(demandeDevisValide);
// Then
assertThat(reponse).isNotNull();
assertThat(reponse.getOffres()).hasSize(3);
// Vérifier que les 3 types d'offres sont présents
List<String> typesOffres = reponse.getOffres().stream()
.map(OffreCalculee::getType)
.toList();
assertThat(typesOffres).containsExactlyInAnyOrder("RAPIDE", "STANDARD", "ECONOMIQUE");
// Vérifier que l'offre rapide est la plus chère
OffreCalculee offreRapide = reponse.getOffres().stream()
.filter(o -> "RAPIDE".equals(o.getType()))
.findFirst().orElseThrow();
OffreCalculee offreStandard = reponse.getOffres().stream()
.filter(o -> "STANDARD".equals(o.getType()))
.findFirst().orElseThrow();
OffreCalculee offreEconomique = reponse.getOffres().stream()
.filter(o -> "ECONOMIQUE".equals(o.getType()))
.findFirst().orElseThrow();
assertThat(offreRapide.getPrixTotal()).isGreaterThan(offreStandard.getPrixTotal());
assertThat(offreStandard.getPrixTotal()).isGreaterThan(offreEconomique.getPrixTotal());
}
@Test
@DisplayName("Doit calculer correctement le colisage résumé")
void doitCalculerCorrectementColisageResume() {
// Given
when(grilleTarifaireService.trouverGrillesApplicables(any(DemandeDevis.class)))
.thenReturn(Arrays.asList(grilleTarifaireStandard));
DemandeDevis demande = creerDemandeAvecColisage();
// When
ReponseDevis reponse = devisCalculService.calculerTroisOffres(demande);
// Then
ReponseDevis.ColisageResume colisage = reponse.getColisageResume();
assertThat(colisage.getNombreColis()).isEqualTo(3); // 2 + 1
assertThat(colisage.getPoidsTotal()).isEqualTo(350.0); // (100*2) + (150*1)
assertThat(colisage.getVolumeTotal()).isEqualTo(0.35); // (0.1*2) + (0.15*1)
// Le poids taxable doit être le max entre poids réel et poids volumétrique
double poidsVolumetrique = 0.35 * 250; // 87.5 kg
assertThat(colisage.getPoidsTaxable()).isEqualTo(350.0); // Poids réel > poids volumétrique
}
@Test
@DisplayName("Doit lever une exception si aucune grille applicable")
void doitLeverExceptionSiAucuneGrilleApplicable() {
// Given
when(grilleTarifaireService.trouverGrillesApplicables(any(DemandeDevis.class)))
.thenReturn(Arrays.asList());
// When & Then
assertThatThrownBy(() -> devisCalculService.calculerTroisOffres(demandeDevisValide))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Aucune grille tarifaire applicable");
}
@Test
@DisplayName("Doit valider correctement une demande de devis valide")
void doitValiderCorrectementDemandeValide() {
// When & Then
assertThatCode(() -> devisCalculService.validerDemandeDevis(demandeDevisValide))
.doesNotThrowAnyException();
}
@Test
@DisplayName("Doit lever une exception si demande de devis nulle")
void doitLeverExceptionSiDemandeNulle() {
// When & Then
assertThatThrownBy(() -> devisCalculService.validerDemandeDevis(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ne peut pas être nulle");
}
@Test
@DisplayName("Doit lever une exception si adresses manquantes")
void doitLeverExceptionSiAdressesManquantes() {
// Given
DemandeDevis demande = creerDemandeDevisValide();
demande.setDepart(null);
// When & Then
assertThatThrownBy(() -> devisCalculService.validerDemandeDevis(demande))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("adresses de départ et d'arrivée sont obligatoires");
}
@Test
@DisplayName("Doit lever une exception si aucun colisage")
void doitLeverExceptionSiAucunColisage() {
// Given
DemandeDevis demande = creerDemandeDevisValide();
demande.setColisages(Arrays.asList());
// When & Then
assertThatThrownBy(() -> devisCalculService.validerDemandeDevis(demande))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Au moins un colisage doit être défini");
}
@Test
@DisplayName("Doit lever une exception si poids invalide")
void doitLeverExceptionSiPoidsInvalide() {
// Given
DemandeDevis demande = creerDemandeDevisValide();
demande.getColisages().get(0).setPoids(0.0);
// When & Then
assertThatThrownBy(() -> devisCalculService.validerDemandeDevis(demande))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Le poids de chaque colisage doit être supérieur à 0");
}
// ================================
// Méthodes utilitaires pour créer les objets de test
// ================================
private DemandeDevis creerDemandeDevisValide() {
DemandeDevis demande = new DemandeDevis();
demande.setTypeService("EXPORT");
demande.setIncoterm("FOB");
demande.setTypeLivraison("Door to Door");
demande.setNomClient("Test Client");
demande.setEmailClient("test@example.com");
// Adresses
DemandeDevis.AdresseTransport depart = new DemandeDevis.AdresseTransport();
depart.setVille("Lyon");
depart.setCodePostal("69000");
depart.setPays("FRA");
demande.setDepart(depart);
DemandeDevis.AdresseTransport arrivee = new DemandeDevis.AdresseTransport();
arrivee.setVille("Shanghai");
arrivee.setCodePostal("200000");
arrivee.setPays("CHN");
demande.setArrivee(arrivee);
// Colisage simple
DemandeDevis.Colisage colisage = new DemandeDevis.Colisage();
colisage.setType(DemandeDevis.Colisage.TypeColisage.COLIS);
colisage.setQuantite(1);
colisage.setLongueur(50.0);
colisage.setLargeur(40.0);
colisage.setHauteur(30.0);
colisage.setPoids(25.0);
demande.setColisages(Arrays.asList(colisage));
demande.setDateEnlevement(LocalDate.now().plusDays(7));
return demande;
}
private DemandeDevis creerDemandeAvecColisage() {
DemandeDevis demande = creerDemandeDevisValide();
// Premier colisage
DemandeDevis.Colisage colisage1 = new DemandeDevis.Colisage();
colisage1.setType(DemandeDevis.Colisage.TypeColisage.COLIS);
colisage1.setQuantite(2);
colisage1.setLongueur(50.0);
colisage1.setLargeur(40.0);
colisage1.setHauteur(50.0); // Volume = 0.1 m³
colisage1.setPoids(100.0);
// Deuxième colisage
DemandeDevis.Colisage colisage2 = new DemandeDevis.Colisage();
colisage2.setType(DemandeDevis.Colisage.TypeColisage.PALETTE);
colisage2.setQuantite(1);
colisage2.setLongueur(120.0);
colisage2.setLargeur(80.0);
colisage2.setHauteur(150.0); // Volume = 0.15 m³
colisage2.setPoids(150.0);
colisage2.setGerbable(true);
demande.setColisages(Arrays.asList(colisage1, colisage2));
return demande;
}
private GrilleTarifaire creerGrilleTarifaireStandard() {
GrilleTarifaire grille = new GrilleTarifaire();
grille.setId(1L);
grille.setNomGrille("Test Grille Standard");
grille.setTransporteur("LESCHACO");
grille.setTypeService(GrilleTarifaire.TypeService.EXPORT);
grille.setOriginePays("FRA");
grille.setDestinationPays("CHN");
grille.setModeTransport(GrilleTarifaire.ModeTransport.MARITIME);
grille.setServiceType(GrilleTarifaire.ServiceType.STANDARD);
grille.setTransitTimeMin(25);
grille.setTransitTimeMax(30);
grille.setValiditeDebut(LocalDate.now().minusDays(30));
grille.setValiditeFin(LocalDate.now().plusDays(60));
grille.setDevise("EUR");
// Tarif de fret
TarifFret tarif = new TarifFret();
tarif.setPoidsMin(BigDecimal.ZERO);
tarif.setPoidsMax(BigDecimal.valueOf(1000));
tarif.setTauxUnitaire(BigDecimal.valueOf(2.5));
tarif.setUniteFacturation(TarifFret.UniteFacturation.KG);
tarif.setMinimumFacturation(BigDecimal.valueOf(100));
grille.setTarifsFret(Arrays.asList(tarif));
// Frais additionnels obligatoires
FraisAdditionnels fraisDoc = new FraisAdditionnels();
fraisDoc.setTypeFrais("DOCUMENTATION");
fraisDoc.setMontant(BigDecimal.valueOf(32));
fraisDoc.setUniteFacturation(FraisAdditionnels.UniteFacturation.LS);
fraisDoc.setObligatoire(true);
grille.setFraisAdditionnels(Arrays.asList(fraisDoc));
grille.setSurchargesDangereuses(Arrays.asList());
return grille;
}
}

View File

@ -0,0 +1,107 @@
package com.dh7789dev.xpeditis;
import com.dh7789dev.xpeditis.dto.app.GrilleTarifaire;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface GrilleTarifaireRepository {
/**
* Trouve toutes les grilles tarifaires valides pour une route et période donnée
*
* @param typeService IMPORT ou EXPORT
* @param originePays pays d'origine
* @param destinationPays pays de destination
* @param dateValidite date à laquelle la grille doit être valide
* @return liste des grilles correspondantes
*/
List<GrilleTarifaire> findGrillesApplicables(
String typeService,
String originePays,
String destinationPays,
LocalDate dateValidite
);
/**
* Trouve toutes les grilles tarifaires d'un type de service spécifique
*
* @param typeService IMPORT ou EXPORT
* @param serviceType RAPIDE, STANDARD ou ECONOMIQUE
* @param originePays pays d'origine
* @param destinationPays pays de destination
* @param dateValidite date de validité
* @return liste des grilles correspondantes
*/
List<GrilleTarifaire> findByServiceTypeAndRoute(
String typeService,
String serviceType,
String originePays,
String destinationPays,
LocalDate dateValidite
);
/**
* Sauvegarde une grille tarifaire
*
* @param grilleTarifaire la grille à sauvegarder
* @return la grille sauvegardée avec son ID généré
*/
GrilleTarifaire save(GrilleTarifaire grilleTarifaire);
/**
* Trouve une grille tarifaire par son ID
*
* @param id l'identifiant de la grille
* @return la grille trouvée ou Optional.empty()
*/
Optional<GrilleTarifaire> findById(Long id);
/**
* Supprime une grille tarifaire
*
* @param id l'identifiant de la grille à supprimer
*/
void deleteById(Long id);
/**
* Vérifie si une grille tarifaire existe
*
* @param id l'identifiant de la grille
* @return true si la grille existe
*/
boolean existsById(Long id);
/**
* Supprime toutes les grilles tarifaires
*/
void deleteAll();
/**
* Trouve toutes les grilles tarifaires avec pagination et filtres
*
* @param page numéro de page
* @param size taille de la page
* @param transporteur filtre par transporteur (optionnel)
* @param paysOrigine filtre par pays d'origine (optionnel)
* @param paysDestination filtre par pays de destination (optionnel)
* @return liste des grilles correspondantes
*/
List<GrilleTarifaire> findAllWithFilters(int page, int size, String transporteur, String paysOrigine, String paysDestination);
/**
* Sauvegarde une liste de grilles tarifaires
*
* @param grilles la liste des grilles à sauvegarder
* @return la liste des grilles sauvegardées
*/
List<GrilleTarifaire> saveAll(List<GrilleTarifaire> grilles);
/**
* Trouve toutes les grilles tarifaires
*
* @return la liste de toutes les grilles
*/
List<GrilleTarifaire> findAll();
}

View File

@ -0,0 +1,165 @@
package com.dh7789dev.xpeditis.dao;
import com.dh7789dev.xpeditis.GrilleTarifaireRepository;
import com.dh7789dev.xpeditis.dto.app.GrilleTarifaire;
import com.dh7789dev.xpeditis.entity.GrilleTarifaireEntity;
import com.dh7789dev.xpeditis.mapper.GrilleTarifaireMapper;
import com.dh7789dev.xpeditis.repository.GrilleTarifaireJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
@Slf4j
public class GrilleTarifaireDao implements GrilleTarifaireRepository {
private final GrilleTarifaireJpaRepository jpaRepository;
private final GrilleTarifaireMapper mapper;
@Override
public List<GrilleTarifaire> findGrillesApplicables(
String typeService,
String originePays,
String destinationPays,
LocalDate dateValidite) {
log.debug("Recherche des grilles applicables: {} {} -> {} à la date {}",
typeService, originePays, destinationPays, dateValidite);
GrilleTarifaireEntity.TypeService typeServiceEnum = parseTypeService(typeService);
List<GrilleTarifaireEntity> entities = jpaRepository.findGrillesApplicables(
typeServiceEnum, originePays, destinationPays, dateValidite);
log.debug("Trouvé {} grilles dans la base de données", entities.size());
return entities.stream()
.map(mapper::entityToDto)
.collect(Collectors.toList());
}
@Override
public List<GrilleTarifaire> findByServiceTypeAndRoute(
String typeService,
String serviceType,
String originePays,
String destinationPays,
LocalDate dateValidite) {
GrilleTarifaireEntity.TypeService typeServiceEnum = parseTypeService(typeService);
GrilleTarifaireEntity.ServiceType serviceTypeEnum = parseServiceType(serviceType);
List<GrilleTarifaireEntity> entities = jpaRepository.findByServiceTypeAndRoute(
typeServiceEnum, serviceTypeEnum, originePays, destinationPays, dateValidite);
return entities.stream()
.map(mapper::entityToDto)
.collect(Collectors.toList());
}
@Override
public GrilleTarifaire save(GrilleTarifaire grilleTarifaire) {
log.debug("Sauvegarde de la grille tarifaire: {}", grilleTarifaire.getNomGrille());
GrilleTarifaireEntity entity = mapper.dtoToEntity(grilleTarifaire);
GrilleTarifaireEntity savedEntity = jpaRepository.save(entity);
return mapper.entityToDto(savedEntity);
}
@Override
public Optional<GrilleTarifaire> findById(Long id) {
log.debug("Recherche de la grille tarifaire par ID: {}", id);
return jpaRepository.findById(id)
.map(mapper::entityToDto);
}
@Override
public void deleteById(Long id) {
log.info("Suppression de la grille tarifaire avec l'ID: {}", id);
jpaRepository.deleteById(id);
}
@Override
public boolean existsById(Long id) {
return jpaRepository.existsById(id);
}
@Override
public void deleteAll() {
log.info("Suppression de toutes les grilles tarifaires");
jpaRepository.deleteAll();
}
@Override
public List<GrilleTarifaire> findAllWithFilters(int page, int size, String transporteur, String paysOrigine, String paysDestination) {
log.debug("Recherche des grilles avec filtres - page: {}, size: {}, transporteur: {}, origine: {}, destination: {}",
page, size, transporteur, paysOrigine, paysDestination);
// Simple implementation - for more complex filtering, we would use Spring Data specifications
List<GrilleTarifaireEntity> entities = jpaRepository.findAll();
return entities.stream()
.filter(entity -> transporteur == null || transporteur.isEmpty() ||
entity.getTransporteur().equalsIgnoreCase(transporteur))
.filter(entity -> paysOrigine == null || paysOrigine.isEmpty() ||
entity.getOriginePays().equalsIgnoreCase(paysOrigine))
.filter(entity -> paysDestination == null || paysDestination.isEmpty() ||
entity.getDestinationPays().equalsIgnoreCase(paysDestination))
.skip(page * size)
.limit(size)
.map(mapper::entityToDto)
.collect(Collectors.toList());
}
@Override
public List<GrilleTarifaire> saveAll(List<GrilleTarifaire> grilles) {
log.info("Sauvegarde de {} grilles tarifaires", grilles.size());
List<GrilleTarifaireEntity> entities = grilles.stream()
.map(mapper::dtoToEntity)
.collect(Collectors.toList());
List<GrilleTarifaireEntity> savedEntities = jpaRepository.saveAll(entities);
return savedEntities.stream()
.map(mapper::entityToDto)
.collect(Collectors.toList());
}
@Override
public List<GrilleTarifaire> findAll() {
log.debug("Recherche de toutes les grilles tarifaires");
List<GrilleTarifaireEntity> entities = jpaRepository.findAll();
return entities.stream()
.map(mapper::entityToDto)
.collect(Collectors.toList());
}
private GrilleTarifaireEntity.TypeService parseTypeService(String typeService) {
try {
return GrilleTarifaireEntity.TypeService.valueOf(typeService.toUpperCase());
} catch (IllegalArgumentException e) {
log.error("Type de service invalide: {}", typeService);
throw new IllegalArgumentException("Type de service invalide: " + typeService);
}
}
private GrilleTarifaireEntity.ServiceType parseServiceType(String serviceType) {
try {
return GrilleTarifaireEntity.ServiceType.valueOf(serviceType.toUpperCase());
} catch (IllegalArgumentException e) {
log.error("Service type invalide: {}", serviceType);
throw new IllegalArgumentException("Service type invalide: " + serviceType);
}
}
}

View File

@ -0,0 +1,54 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.math.BigDecimal;
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "frais_additionnels",
indexes = {
@Index(name = "idx_grille_type", columnList = "grille_id, type_frais")
})
public class FraisAdditionnelsEntity extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "grille_id", nullable = false)
private GrilleTarifaireEntity grille;
@Column(name = "type_frais", nullable = false, length = 50)
private String typeFrais;
@Column(name = "description", length = 200)
private String description;
@Column(name = "montant", nullable = false, precision = 10, scale = 2)
private BigDecimal montant;
@Enumerated(EnumType.STRING)
@Column(name = "unite_facturation", nullable = false)
private UniteFacturation uniteFacturation;
@Column(name = "montant_minimum", precision = 10, scale = 2)
private BigDecimal montantMinimum;
@Column(name = "obligatoire")
private Boolean obligatoire = false;
@Column(name = "applicable_marchandise_dangereuse")
private Boolean applicableMarchandiseDangereuse = false;
public enum UniteFacturation {
LS, KG, M3, PALETTE, POURCENTAGE
}
}

View File

@ -0,0 +1,109 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "grilles_tarifaires")
public class GrilleTarifaireEntity extends BaseEntity {
@Column(name = "nom_grille", nullable = false, length = 100)
private String nomGrille;
@Column(name = "transporteur", nullable = false, length = 50)
private String transporteur;
@Enumerated(EnumType.STRING)
@Column(name = "type_service", nullable = false)
private TypeService typeService;
@Column(name = "origine_pays", nullable = false, length = 3)
private String originePays;
@Column(name = "origine_ville", length = 100)
private String origineVille;
@Column(name = "origine_port_code", length = 10)
private String originePortCode;
@Column(name = "destination_pays", nullable = false, length = 3)
private String destinationPays;
@Column(name = "destination_ville", length = 100)
private String destinationVille;
@Column(name = "destination_port_code", length = 10)
private String destinationPortCode;
@Column(name = "incoterm", length = 10)
private String incoterm;
@Enumerated(EnumType.STRING)
@Column(name = "mode_transport")
private ModeTransport modeTransport;
@Enumerated(EnumType.STRING)
@Column(name = "service_type")
private ServiceType serviceType;
@Column(name = "transit_time_min")
private Integer transitTimeMin;
@Column(name = "transit_time_max")
private Integer transitTimeMax;
@Column(name = "validite_debut", nullable = false)
private LocalDate validiteDebut;
@Column(name = "validite_fin", nullable = false)
private LocalDate validiteFin;
@Column(name = "devise", length = 3)
private String devise = "EUR";
@Column(name = "actif")
private Boolean actif = true;
@Column(name = "devise_base", length = 3)
private String deviseBase;
@Column(name = "commentaires", columnDefinition = "TEXT")
private String commentaires;
@OneToMany(mappedBy = "grille", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<TarifFretEntity> tarifsFret = new ArrayList<>();
@OneToMany(mappedBy = "grille", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<FraisAdditionnelsEntity> fraisAdditionnels = new ArrayList<>();
@OneToMany(mappedBy = "grille", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<SurchargeDangereuse> surchargesDangereuses = new ArrayList<>();
public enum TypeService {
IMPORT, EXPORT
}
public enum ModeTransport {
MARITIME, AERIEN, ROUTIER, FERROVIAIRE
}
public enum ServiceType {
RAPIDE, STANDARD, ECONOMIQUE
}
}

View File

@ -0,0 +1,48 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.math.BigDecimal;
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "surcharges_dangereuses",
indexes = {
@Index(name = "idx_grille_classe", columnList = "grille_id, classe_adr")
})
public class SurchargeDangereuse extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "grille_id", nullable = false)
private GrilleTarifaireEntity grille;
@Column(name = "classe_adr", length = 10)
private String classeAdr;
@Column(name = "un_number", length = 10)
private String unNumber;
@Column(name = "surcharge", nullable = false, precision = 10, scale = 2)
private BigDecimal surcharge;
@Enumerated(EnumType.STRING)
@Column(name = "unite_facturation", nullable = false)
private UniteFacturation uniteFacturation;
@Column(name = "minimum", precision = 10, scale = 2)
private BigDecimal minimum;
public enum UniteFacturation {
LS, KG, COLIS
}
}

View File

@ -0,0 +1,55 @@
package com.dh7789dev.xpeditis.entity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import java.math.BigDecimal;
@Entity
@Getter
@Setter
@NoArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Table(name = "tarifs_fret",
indexes = {
@Index(name = "idx_grille_poids", columnList = "grille_id, poids_min, poids_max"),
@Index(name = "idx_grille_volume", columnList = "grille_id, volume_min, volume_max")
})
public class TarifFretEntity extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "grille_id", nullable = false)
private GrilleTarifaireEntity grille;
@Column(name = "poids_min", precision = 10, scale = 2)
private BigDecimal poidsMin;
@Column(name = "poids_max", precision = 10, scale = 2)
private BigDecimal poidsMax;
@Column(name = "volume_min", precision = 10, scale = 3)
private BigDecimal volumeMin;
@Column(name = "volume_max", precision = 10, scale = 3)
private BigDecimal volumeMax;
@Column(name = "taux_unitaire", nullable = false, precision = 10, scale = 2)
private BigDecimal tauxUnitaire;
@Enumerated(EnumType.STRING)
@Column(name = "unite_facturation", nullable = false)
private UniteFacturation uniteFacturation;
@Column(name = "minimum_facturation", precision = 10, scale = 2)
private BigDecimal minimumFacturation;
public enum UniteFacturation {
KG, M3, PALETTE, COLIS, LS
}
}

View File

@ -0,0 +1,271 @@
package com.dh7789dev.xpeditis.mapper;
import com.dh7789dev.xpeditis.dto.app.*;
import com.dh7789dev.xpeditis.entity.*;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class GrilleTarifaireMapper {
public GrilleTarifaire entityToDto(GrilleTarifaireEntity entity) {
if (entity == null) return null;
GrilleTarifaire dto = new GrilleTarifaire();
dto.setId(entity.getId());
dto.setNomGrille(entity.getNomGrille());
dto.setTransporteur(entity.getTransporteur());
dto.setTypeService(mapTypeService(entity.getTypeService()));
dto.setOriginePays(entity.getOriginePays());
dto.setOrigineVille(entity.getOrigineVille());
dto.setOriginePortCode(entity.getOriginePortCode());
dto.setDestinationPays(entity.getDestinationPays());
dto.setDestinationVille(entity.getDestinationVille());
dto.setDestinationPortCode(entity.getDestinationPortCode());
dto.setIncoterm(entity.getIncoterm());
dto.setModeTransport(mapModeTransport(entity.getModeTransport()));
dto.setServiceType(mapServiceType(entity.getServiceType()));
dto.setTransitTimeMin(entity.getTransitTimeMin());
dto.setTransitTimeMax(entity.getTransitTimeMax());
dto.setValiditeDebut(entity.getValiditeDebut());
dto.setValiditeFin(entity.getValiditeFin());
dto.setDevise(entity.getDevise());
dto.setActif(entity.getActif());
dto.setDeviseBase(entity.getDeviseBase());
dto.setCommentaires(entity.getCommentaires());
// Mapper les listes associées
if (entity.getTarifsFret() != null) {
dto.setTarifsFret(entity.getTarifsFret().stream()
.map(this::tarifFretEntityToDto)
.collect(Collectors.toList()));
}
if (entity.getFraisAdditionnels() != null) {
dto.setFraisAdditionnels(entity.getFraisAdditionnels().stream()
.map(this::fraisAdditionnelsEntityToDto)
.collect(Collectors.toList()));
}
if (entity.getSurchargesDangereuses() != null) {
dto.setSurchargesDangereuses(entity.getSurchargesDangereuses().stream()
.map(this::surchargeDangereueEntityToDto)
.collect(Collectors.toList()));
}
return dto;
}
public GrilleTarifaireEntity dtoToEntity(GrilleTarifaire dto) {
if (dto == null) return null;
GrilleTarifaireEntity entity = new GrilleTarifaireEntity();
entity.setId(dto.getId());
entity.setNomGrille(dto.getNomGrille());
entity.setTransporteur(dto.getTransporteur());
entity.setTypeService(mapTypeServiceToEntity(dto.getTypeService()));
entity.setOriginePays(dto.getOriginePays());
entity.setOrigineVille(dto.getOrigineVille());
entity.setOriginePortCode(dto.getOriginePortCode());
entity.setDestinationPays(dto.getDestinationPays());
entity.setDestinationVille(dto.getDestinationVille());
entity.setDestinationPortCode(dto.getDestinationPortCode());
entity.setIncoterm(dto.getIncoterm());
entity.setModeTransport(mapModeTransportToEntity(dto.getModeTransport()));
entity.setServiceType(mapServiceTypeToEntity(dto.getServiceType()));
entity.setTransitTimeMin(dto.getTransitTimeMin());
entity.setTransitTimeMax(dto.getTransitTimeMax());
entity.setValiditeDebut(dto.getValiditeDebut());
entity.setValiditeFin(dto.getValiditeFin());
entity.setDevise(dto.getDevise());
entity.setActif(dto.getActif());
entity.setDeviseBase(dto.getDeviseBase());
entity.setCommentaires(dto.getCommentaires());
// Mapper les listes associées
if (dto.getTarifsFret() != null) {
List<TarifFretEntity> tarifsFret = dto.getTarifsFret().stream()
.map(this::tarifFretDtoToEntity)
.collect(Collectors.toList());
tarifsFret.forEach(tarif -> tarif.setGrille(entity));
entity.setTarifsFret(tarifsFret);
}
if (dto.getFraisAdditionnels() != null) {
List<FraisAdditionnelsEntity> fraisAdditionnels = dto.getFraisAdditionnels().stream()
.map(this::fraisAdditionnelsDtoToEntity)
.collect(Collectors.toList());
fraisAdditionnels.forEach(frais -> frais.setGrille(entity));
entity.setFraisAdditionnels(fraisAdditionnels);
}
if (dto.getSurchargesDangereuses() != null) {
List<com.dh7789dev.xpeditis.entity.SurchargeDangereuse> surchargesDangereuses = dto.getSurchargesDangereuses().stream()
.map(this::surchargeDangereueDtoToEntity)
.collect(Collectors.toList());
surchargesDangereuses.forEach(surcharge -> surcharge.setGrille(entity));
entity.setSurchargesDangereuses(surchargesDangereuses);
}
return entity;
}
private TarifFret tarifFretEntityToDto(TarifFretEntity entity) {
if (entity == null) return null;
TarifFret dto = new TarifFret();
dto.setId(entity.getId());
dto.setGrilleId(entity.getGrille() != null ? entity.getGrille().getId() : null);
dto.setPoidsMin(entity.getPoidsMin());
dto.setPoidsMax(entity.getPoidsMax());
dto.setVolumeMin(entity.getVolumeMin());
dto.setVolumeMax(entity.getVolumeMax());
dto.setTauxUnitaire(entity.getTauxUnitaire());
dto.setUniteFacturation(mapUniteFacturationTarifFret(entity.getUniteFacturation()));
dto.setMinimumFacturation(entity.getMinimumFacturation());
return dto;
}
private TarifFretEntity tarifFretDtoToEntity(TarifFret dto) {
if (dto == null) return null;
TarifFretEntity entity = new TarifFretEntity();
entity.setId(dto.getId());
entity.setPoidsMin(dto.getPoidsMin());
entity.setPoidsMax(dto.getPoidsMax());
entity.setVolumeMin(dto.getVolumeMin());
entity.setVolumeMax(dto.getVolumeMax());
entity.setTauxUnitaire(dto.getTauxUnitaire());
entity.setUniteFacturation(mapUniteFacturationTarifFretToEntity(dto.getUniteFacturation()));
entity.setMinimumFacturation(dto.getMinimumFacturation());
return entity;
}
private FraisAdditionnels fraisAdditionnelsEntityToDto(FraisAdditionnelsEntity entity) {
if (entity == null) return null;
FraisAdditionnels dto = new FraisAdditionnels();
dto.setId(entity.getId());
dto.setGrilleId(entity.getGrille() != null ? entity.getGrille().getId() : null);
dto.setTypeFrais(entity.getTypeFrais());
dto.setDescription(entity.getDescription());
dto.setMontant(entity.getMontant());
dto.setUniteFacturation(mapUniteFacturationFrais(entity.getUniteFacturation()));
dto.setMontantMinimum(entity.getMontantMinimum());
dto.setObligatoire(entity.getObligatoire());
dto.setApplicableMarchandiseDangereuse(entity.getApplicableMarchandiseDangereuse());
return dto;
}
private FraisAdditionnelsEntity fraisAdditionnelsDtoToEntity(FraisAdditionnels dto) {
if (dto == null) return null;
FraisAdditionnelsEntity entity = new FraisAdditionnelsEntity();
entity.setId(dto.getId());
entity.setTypeFrais(dto.getTypeFrais());
entity.setDescription(dto.getDescription());
entity.setMontant(dto.getMontant());
entity.setUniteFacturation(mapUniteFacturationFraisToEntity(dto.getUniteFacturation()));
entity.setMontantMinimum(dto.getMontantMinimum());
entity.setObligatoire(dto.getObligatoire());
entity.setApplicableMarchandiseDangereuse(dto.getApplicableMarchandiseDangereuse());
return entity;
}
private com.dh7789dev.xpeditis.dto.app.SurchargeDangereuse surchargeDangereueEntityToDto(com.dh7789dev.xpeditis.entity.SurchargeDangereuse entity) {
if (entity == null) return null;
com.dh7789dev.xpeditis.dto.app.SurchargeDangereuse dto = new com.dh7789dev.xpeditis.dto.app.SurchargeDangereuse();
dto.setId(entity.getId());
dto.setGrilleId(entity.getGrille() != null ? entity.getGrille().getId() : null);
dto.setClasseAdr(entity.getClasseAdr());
dto.setUnNumber(entity.getUnNumber());
dto.setSurcharge(entity.getSurcharge());
dto.setUniteFacturation(mapUniteFacturationSurcharge(entity.getUniteFacturation()));
dto.setMinimum(entity.getMinimum());
return dto;
}
private com.dh7789dev.xpeditis.entity.SurchargeDangereuse surchargeDangereueDtoToEntity(com.dh7789dev.xpeditis.dto.app.SurchargeDangereuse dto) {
if (dto == null) return null;
com.dh7789dev.xpeditis.entity.SurchargeDangereuse entity = new com.dh7789dev.xpeditis.entity.SurchargeDangereuse();
entity.setId(dto.getId());
entity.setClasseAdr(dto.getClasseAdr());
entity.setUnNumber(dto.getUnNumber());
entity.setSurcharge(dto.getSurcharge());
entity.setUniteFacturation(mapUniteFacturationSurchargeToEntity(dto.getUniteFacturation()));
entity.setMinimum(dto.getMinimum());
return entity;
}
// Méthodes de mapping des enums
private GrilleTarifaire.TypeService mapTypeService(GrilleTarifaireEntity.TypeService typeService) {
if (typeService == null) return null;
return GrilleTarifaire.TypeService.valueOf(typeService.name());
}
private GrilleTarifaireEntity.TypeService mapTypeServiceToEntity(GrilleTarifaire.TypeService typeService) {
if (typeService == null) return null;
return GrilleTarifaireEntity.TypeService.valueOf(typeService.name());
}
private GrilleTarifaire.ModeTransport mapModeTransport(GrilleTarifaireEntity.ModeTransport modeTransport) {
if (modeTransport == null) return null;
return GrilleTarifaire.ModeTransport.valueOf(modeTransport.name());
}
private GrilleTarifaireEntity.ModeTransport mapModeTransportToEntity(GrilleTarifaire.ModeTransport modeTransport) {
if (modeTransport == null) return null;
return GrilleTarifaireEntity.ModeTransport.valueOf(modeTransport.name());
}
private GrilleTarifaire.ServiceType mapServiceType(GrilleTarifaireEntity.ServiceType serviceType) {
if (serviceType == null) return null;
return GrilleTarifaire.ServiceType.valueOf(serviceType.name());
}
private GrilleTarifaireEntity.ServiceType mapServiceTypeToEntity(GrilleTarifaire.ServiceType serviceType) {
if (serviceType == null) return null;
return GrilleTarifaireEntity.ServiceType.valueOf(serviceType.name());
}
private TarifFret.UniteFacturation mapUniteFacturationTarifFret(TarifFretEntity.UniteFacturation uniteFacturation) {
if (uniteFacturation == null) return null;
return TarifFret.UniteFacturation.valueOf(uniteFacturation.name());
}
private TarifFretEntity.UniteFacturation mapUniteFacturationTarifFretToEntity(TarifFret.UniteFacturation uniteFacturation) {
if (uniteFacturation == null) return null;
return TarifFretEntity.UniteFacturation.valueOf(uniteFacturation.name());
}
private FraisAdditionnels.UniteFacturation mapUniteFacturationFrais(FraisAdditionnelsEntity.UniteFacturation uniteFacturation) {
if (uniteFacturation == null) return null;
return FraisAdditionnels.UniteFacturation.valueOf(uniteFacturation.name());
}
private FraisAdditionnelsEntity.UniteFacturation mapUniteFacturationFraisToEntity(FraisAdditionnels.UniteFacturation uniteFacturation) {
if (uniteFacturation == null) return null;
return FraisAdditionnelsEntity.UniteFacturation.valueOf(uniteFacturation.name());
}
private com.dh7789dev.xpeditis.dto.app.SurchargeDangereuse.UniteFacturation mapUniteFacturationSurcharge(com.dh7789dev.xpeditis.entity.SurchargeDangereuse.UniteFacturation uniteFacturation) {
if (uniteFacturation == null) return null;
return com.dh7789dev.xpeditis.dto.app.SurchargeDangereuse.UniteFacturation.valueOf(uniteFacturation.name());
}
private com.dh7789dev.xpeditis.entity.SurchargeDangereuse.UniteFacturation mapUniteFacturationSurchargeToEntity(com.dh7789dev.xpeditis.dto.app.SurchargeDangereuse.UniteFacturation uniteFacturation) {
if (uniteFacturation == null) return null;
return com.dh7789dev.xpeditis.entity.SurchargeDangereuse.UniteFacturation.valueOf(uniteFacturation.name());
}
}

View File

@ -0,0 +1,41 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.FraisAdditionnelsEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface FraisAdditionnelsJpaRepository extends JpaRepository<FraisAdditionnelsEntity, Long> {
/**
* Trouve tous les frais additionnels pour une grille donnée
*/
List<FraisAdditionnelsEntity> findByGrilleIdOrderByTypeFrais(Long grilleId);
/**
* Trouve les frais obligatoires pour une grille
*/
List<FraisAdditionnelsEntity> findByGrilleIdAndObligatoireTrue(Long grilleId);
/**
* Trouve les frais par type pour une grille
*/
List<FraisAdditionnelsEntity> findByGrilleIdAndTypeFrais(Long grilleId, String typeFrais);
/**
* Trouve les frais applicables aux marchandises dangereuses
*/
@Query("SELECT f FROM FraisAdditionnelsEntity f " +
"WHERE f.grille.id = :grilleId " +
"AND (f.applicableMarchandiseDangereuse = true OR f.obligatoire = true)")
List<FraisAdditionnelsEntity> findFraisApplicablesMarchandiseDangereuse(@Param("grilleId") Long grilleId);
/**
* Supprime tous les frais additionnels d'une grille
*/
void deleteByGrilleId(Long grilleId);
}

View File

@ -0,0 +1,78 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.GrilleTarifaireEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
@Repository
public interface GrilleTarifaireJpaRepository extends JpaRepository<GrilleTarifaireEntity, Long> {
/**
* Trouve toutes les grilles tarifaires valides pour une route et période donnée
*/
@Query("SELECT g FROM GrilleTarifaireEntity g " +
"WHERE g.typeService = :typeService " +
"AND g.originePays = :originePays " +
"AND g.destinationPays = :destinationPays " +
"AND g.validiteDebut <= :dateValidite " +
"AND g.validiteFin >= :dateValidite " +
"ORDER BY g.serviceType, g.origineVille NULLS LAST, g.destinationVille NULLS LAST")
List<GrilleTarifaireEntity> findGrillesApplicables(
@Param("typeService") GrilleTarifaireEntity.TypeService typeService,
@Param("originePays") String originePays,
@Param("destinationPays") String destinationPays,
@Param("dateValidite") LocalDate dateValidite
);
/**
* Trouve toutes les grilles tarifaires d'un type de service spécifique
*/
@Query("SELECT g FROM GrilleTarifaireEntity g " +
"WHERE g.typeService = :typeService " +
"AND g.serviceType = :serviceType " +
"AND g.originePays = :originePays " +
"AND g.destinationPays = :destinationPays " +
"AND g.validiteDebut <= :dateValidite " +
"AND g.validiteFin >= :dateValidite " +
"ORDER BY g.origineVille NULLS LAST, g.destinationVille NULLS LAST")
List<GrilleTarifaireEntity> findByServiceTypeAndRoute(
@Param("typeService") GrilleTarifaireEntity.TypeService typeService,
@Param("serviceType") GrilleTarifaireEntity.ServiceType serviceType,
@Param("originePays") String originePays,
@Param("destinationPays") String destinationPays,
@Param("dateValidite") LocalDate dateValidite
);
/**
* Trouve les grilles par transporteur
*/
List<GrilleTarifaireEntity> findByTransporteurAndValiditeDebutLessThanEqualAndValiditeFinGreaterThanEqual(
String transporteur,
LocalDate dateDebut,
LocalDate dateFin
);
/**
* Trouve les grilles par route
*/
List<GrilleTarifaireEntity> findByOriginePaysAndDestinationPaysAndValiditeDebutLessThanEqualAndValiditeFinGreaterThanEqual(
String originePays,
String destinationPays,
LocalDate dateDebut,
LocalDate dateFin
);
/**
* Compte les grilles actives pour un transporteur
*/
@Query("SELECT COUNT(g) FROM GrilleTarifaireEntity g " +
"WHERE g.transporteur = :transporteur " +
"AND g.validiteDebut <= :date " +
"AND g.validiteFin >= :date")
Long countActiveGridsByTransporteur(@Param("transporteur") String transporteur, @Param("date") LocalDate date);
}

View File

@ -0,0 +1,37 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.SurchargeDangereuse;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface SurchargeDangereuseJpaRepository extends JpaRepository<SurchargeDangereuse, Long> {
/**
* Trouve toutes les surcharges pour une grille donnée
*/
List<SurchargeDangereuse> findByGrilleIdOrderByClasseAdr(Long grilleId);
/**
* Trouve une surcharge par classe ADR pour une grille
*/
Optional<SurchargeDangereuse> findByGrilleIdAndClasseAdr(Long grilleId, String classeAdr);
/**
* Trouve une surcharge par numéro UN pour une grille
*/
Optional<SurchargeDangereuse> findByGrilleIdAndUnNumber(Long grilleId, String unNumber);
/**
* Trouve les surcharges par classe ADR
*/
List<SurchargeDangereuse> findByClasseAdr(String classeAdr);
/**
* Supprime toutes les surcharges d'une grille
*/
void deleteByGrilleId(Long grilleId);
}

View File

@ -0,0 +1,51 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.TarifFretEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
@Repository
public interface TarifFretJpaRepository extends JpaRepository<TarifFretEntity, Long> {
/**
* Trouve tous les tarifs de fret pour une grille donnée
*/
List<TarifFretEntity> findByGrilleIdOrderByPoidsMinAsc(Long grilleId);
/**
* Trouve le tarif applicable selon le poids
*/
@Query("SELECT t FROM TarifFretEntity t " +
"WHERE t.grille.id = :grilleId " +
"AND (t.poidsMin IS NULL OR t.poidsMin <= :poids) " +
"AND (t.poidsMax IS NULL OR t.poidsMax >= :poids) " +
"ORDER BY t.poidsMin ASC")
Optional<TarifFretEntity> findTarifApplicableByPoids(
@Param("grilleId") Long grilleId,
@Param("poids") BigDecimal poids
);
/**
* Trouve le tarif applicable selon le volume
*/
@Query("SELECT t FROM TarifFretEntity t " +
"WHERE t.grille.id = :grilleId " +
"AND (t.volumeMin IS NULL OR t.volumeMin <= :volume) " +
"AND (t.volumeMax IS NULL OR t.volumeMax >= :volume) " +
"ORDER BY t.volumeMin ASC")
Optional<TarifFretEntity> findTarifApplicableByVolume(
@Param("grilleId") Long grilleId,
@Param("volume") BigDecimal volume
);
/**
* Supprime tous les tarifs d'une grille
*/
void deleteByGrilleId(Long grilleId);
}

View File

@ -0,0 +1,201 @@
-- ================================
-- Migration V3.1: Données d'exemple pour les Grilles Tarifaires
-- Basé sur la grille LESCHACO Fos Sur Mer
-- Créé le: 2024-09-12
-- ================================
-- ================================
-- 1. Grille LESCHACO - France vers Chine (Shanghai)
-- ================================
-- Grille Standard
INSERT INTO grilles_tarifaires (
nom_grille, transporteur, type_service, origine_pays, origine_ville, origine_port_code,
destination_pays, destination_ville, destination_port_code, incoterm, mode_transport,
service_type, transit_time_min, transit_time_max, validite_debut, validite_fin, devise
) VALUES (
'LESCHACO FOS-SHA Standard 2024', 'LESCHACO', 'EXPORT', 'FRA', 'Fos Sur Mer', 'FOS',
'CHN', 'Shanghai', 'SHA', 'FOB', 'MARITIME', 'STANDARD', 25, 30, '2024-01-01', '2024-12-31', 'EUR'
);
-- Grille Rapide
INSERT INTO grilles_tarifaires (
nom_grille, transporteur, type_service, origine_pays, origine_ville, origine_port_code,
destination_pays, destination_ville, destination_port_code, incoterm, mode_transport,
service_type, transit_time_min, transit_time_max, validite_debut, validite_fin, devise
) VALUES (
'LESCHACO FOS-SHA Express 2024', 'LESCHACO', 'EXPORT', 'FRA', 'Fos Sur Mer', 'FOS',
'CHN', 'Shanghai', 'SHA', 'FOB', 'MARITIME', 'RAPIDE', 18, 22, '2024-01-01', '2024-12-31', 'EUR'
);
-- Grille Économique
INSERT INTO grilles_tarifaires (
nom_grille, transporteur, type_service, origine_pays, origine_ville, origine_port_code,
destination_pays, destination_ville, destination_port_code, incoterm, mode_transport,
service_type, transit_time_min, transit_time_max, validite_debut, validite_fin, devise
) VALUES (
'LESCHACO FOS-SHA Eco 2024', 'LESCHACO', 'EXPORT', 'FRA', 'Fos Sur Mer', 'FOS',
'CHN', 'Shanghai', 'SHA', 'FOB', 'MARITIME', 'ECONOMIQUE', 35, 40, '2024-01-01', '2024-12-31', 'EUR'
);
-- ================================
-- 2. Tarifs de fret pour les 3 grilles
-- ================================
-- Tarifs pour grille Standard (ID 1)
INSERT INTO tarifs_fret (grille_id, poids_min, poids_max, taux_unitaire, unite_facturation, minimum_facturation) VALUES
(1, 0, 100, 85.00, 'KG', 150.00),
(1, 100, 500, 75.00, 'KG', null),
(1, 500, 1000, 65.00, 'KG', null),
(1, 1000, 5000, 55.00, 'KG', null),
(1, 5000, null, 45.00, 'KG', null);
-- Tarifs pour grille Rapide (ID 2) - mêmes tarifs de base
INSERT INTO tarifs_fret (grille_id, poids_min, poids_max, taux_unitaire, unite_facturation, minimum_facturation) VALUES
(2, 0, 100, 85.00, 'KG', 150.00),
(2, 100, 500, 75.00, 'KG', null),
(2, 500, 1000, 65.00, 'KG', null),
(2, 1000, 5000, 55.00, 'KG', null),
(2, 5000, null, 45.00, 'KG', null);
-- Tarifs pour grille Économique (ID 3) - mêmes tarifs de base
INSERT INTO tarifs_fret (grille_id, poids_min, poids_max, taux_unitaire, unite_facturation, minimum_facturation) VALUES
(3, 0, 100, 85.00, 'KG', 150.00),
(3, 100, 500, 75.00, 'KG', null),
(3, 500, 1000, 65.00, 'KG', null),
(3, 1000, 5000, 55.00, 'KG', null),
(3, 5000, null, 45.00, 'KG', null);
-- ================================
-- 3. Frais additionnels obligatoires
-- ================================
-- Frais obligatoires pour la grille Standard
INSERT INTO frais_additionnels (grille_id, type_frais, description, montant, unite_facturation, obligatoire) VALUES
(1, 'DOCUMENTATION', 'Frais de documentation export', 32.00, 'LS', TRUE),
(1, 'ISPS', 'International Ship and Port Security', 15.00, 'LS', TRUE),
(1, 'MANUTENTION', 'Frais de manutention portuaire', 85.50, 'LS', TRUE);
-- Frais obligatoires pour la grille Rapide
INSERT INTO frais_additionnels (grille_id, type_frais, description, montant, unite_facturation, obligatoire) VALUES
(2, 'DOCUMENTATION', 'Frais de documentation export', 32.00, 'LS', TRUE),
(2, 'ISPS', 'International Ship and Port Security', 15.00, 'LS', TRUE),
(2, 'MANUTENTION', 'Frais de manutention portuaire', 85.50, 'LS', TRUE);
-- Frais obligatoires pour la grille Économique
INSERT INTO frais_additionnels (grille_id, type_frais, description, montant, unite_facturation, obligatoire) VALUES
(3, 'DOCUMENTATION', 'Frais de documentation export', 32.00, 'LS', TRUE),
(3, 'ISPS', 'International Ship and Port Security', 15.00, 'LS', TRUE),
(3, 'MANUTENTION', 'Frais de manutention portuaire', 85.50, 'LS', TRUE);
-- ================================
-- 4. Frais optionnels
-- ================================
-- Frais optionnels pour toutes les grilles
INSERT INTO frais_additionnels (grille_id, type_frais, description, montant, unite_facturation, obligatoire) VALUES
-- Grille Standard
(1, 'ASSURANCE', 'Assurance transport maritime', 45.00, 'LS', FALSE),
(1, 'HAYON', 'Service de hayon pour livraison', 35.00, 'LS', FALSE),
(1, 'SANGLES', 'Sanglage et arrimage', 25.00, 'LS', FALSE),
(1, 'COUVERTURE_THERMIQUE', 'Protection thermique', 40.00, 'LS', FALSE),
-- Grille Rapide
(2, 'ASSURANCE', 'Assurance transport maritime', 45.00, 'LS', FALSE),
(2, 'HAYON', 'Service de hayon pour livraison', 35.00, 'LS', FALSE),
(2, 'SANGLES', 'Sanglage et arrimage', 25.00, 'LS', FALSE),
(2, 'COUVERTURE_THERMIQUE', 'Protection thermique', 40.00, 'LS', FALSE),
-- Grille Économique
(3, 'ASSURANCE', 'Assurance transport maritime', 45.00, 'LS', FALSE),
(3, 'HAYON', 'Service de hayon pour livraison', 35.00, 'LS', FALSE),
(3, 'SANGLES', 'Sanglage et arrimage', 25.00, 'LS', FALSE),
(3, 'COUVERTURE_THERMIQUE', 'Protection thermique', 40.00, 'LS', FALSE);
-- ================================
-- 5. Surcharges marchandises dangereuses
-- ================================
-- Surcharges pour matières dangereuses (toutes les grilles)
INSERT INTO surcharges_dangereuses (grille_id, classe_adr, surcharge, unite_facturation, minimum) VALUES
-- Grille Standard
(1, '1', 150.00, 'LS', 100.00), -- Matières explosives
(1, '2.1', 75.00, 'LS', 50.00), -- Gaz inflammables
(1, '3', 85.00, 'LS', 60.00), -- Liquides inflammables
(1, '4.1', 70.00, 'LS', 50.00), -- Matières solides inflammables
(1, '5.1', 90.00, 'LS', 65.00), -- Matières comburantes
(1, '6.1', 120.00, 'LS', 80.00), -- Matières toxiques
(1, '8', 95.00, 'LS', 70.00), -- Matières corrosives
(1, '9', 55.00, 'LS', 40.00), -- Matières dangereuses diverses
-- Grille Rapide (surcharges identiques)
(2, '1', 150.00, 'LS', 100.00),
(2, '2.1', 75.00, 'LS', 50.00),
(2, '3', 85.00, 'LS', 60.00),
(2, '4.1', 70.00, 'LS', 50.00),
(2, '5.1', 90.00, 'LS', 65.00),
(2, '6.1', 120.00, 'LS', 80.00),
(2, '8', 95.00, 'LS', 70.00),
(2, '9', 55.00, 'LS', 40.00),
-- Grille Économique (surcharges identiques)
(3, '1', 150.00, 'LS', 100.00),
(3, '2.1', 75.00, 'LS', 50.00),
(3, '3', 85.00, 'LS', 60.00),
(3, '4.1', 70.00, 'LS', 50.00),
(3, '5.1', 90.00, 'LS', 65.00),
(3, '6.1', 120.00, 'LS', 80.00),
(3, '8', 95.00, 'LS', 70.00),
(3, '9', 55.00, 'LS', 40.00);
-- ================================
-- 6. Grilles additionnelles - Hong Kong et Singapour
-- ================================
-- France vers Hong Kong
INSERT INTO grilles_tarifaires (
nom_grille, transporteur, type_service, origine_pays, origine_ville, origine_port_code,
destination_pays, destination_ville, destination_port_code, incoterm, mode_transport,
service_type, transit_time_min, transit_time_max, validite_debut, validite_fin, devise
) VALUES
('LESCHACO FOS-HKG Standard 2024', 'LESCHACO', 'EXPORT', 'FRA', 'Fos Sur Mer', 'FOS',
'HKG', 'Hong Kong', 'HKG', 'FOB', 'MARITIME', 'STANDARD', 28, 33, '2024-01-01', '2024-12-31', 'EUR');
-- France vers Singapour
INSERT INTO grilles_tarifaires (
nom_grille, transporteur, type_service, origine_pays, origine_ville, origine_port_code,
destination_pays, destination_ville, destination_port_code, incoterm, mode_transport,
service_type, transit_time_min, transit_time_max, validite_debut, validite_fin, devise
) VALUES
('LESCHACO FOS-SIN Standard 2024', 'LESCHACO', 'EXPORT', 'FRA', 'Fos Sur Mer', 'FOS',
'SGP', 'Singapore', 'SIN', 'FOB', 'MARITIME', 'STANDARD', 26, 31, '2024-01-01', '2024-12-31', 'EUR');
-- Tarifs pour Hong Kong (grille ID 4) - légèrement plus chers
INSERT INTO tarifs_fret (grille_id, poids_min, poids_max, taux_unitaire, unite_facturation, minimum_facturation) VALUES
(4, 0, 100, 90.00, 'KG', 160.00),
(4, 100, 500, 80.00, 'KG', null),
(4, 500, 1000, 70.00, 'KG', null),
(4, 1000, 5000, 60.00, 'KG', null),
(4, 5000, null, 50.00, 'KG', null);
-- Tarifs pour Singapour (grille ID 5) - prix similaires à Shanghai
INSERT INTO tarifs_fret (grille_id, poids_min, poids_max, taux_unitaire, unite_facturation, minimum_facturation) VALUES
(5, 0, 100, 87.00, 'KG', 155.00),
(5, 100, 500, 77.00, 'KG', null),
(5, 500, 1000, 67.00, 'KG', null),
(5, 1000, 5000, 57.00, 'KG', null),
(5, 5000, null, 47.00, 'KG', null);
-- ================================
-- Commentaire sur les données d'exemple
-- ================================
-- Ces données d'exemple permettent de tester:
-- 1. Le calcul des 3 offres (Rapide, Standard, Économique)
-- 2. Les différents transporteurs et routes
-- 3. Les frais obligatoires et optionnels
-- 4. Les surcharges pour marchandises dangereuses
-- 5. Les barèmes de prix progressifs selon le poids
-- Les prix sont basés sur les tarifs réels du transport maritime LCL
-- France-Asie et peuvent être ajustés selon les besoins.

View File

@ -0,0 +1,129 @@
-- ================================
-- Migration V3: Système de Grilles Tarifaires pour Devis Transport
-- Créé le: 2024-09-12
-- Auteur: Claude Code
-- ================================
-- Table principale des grilles tarifaires
CREATE TABLE grilles_tarifaires (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
nom_grille VARCHAR(100) NOT NULL COMMENT 'Nom descriptif de la grille tarifaire',
transporteur VARCHAR(50) NOT NULL COMMENT 'Nom du transporteur (LESCHACO, MAERSK, etc.)',
type_service ENUM('IMPORT', 'EXPORT') NOT NULL COMMENT 'Type de service transport',
origine_pays CHAR(3) NOT NULL COMMENT 'Code pays origine (ISO 3166-1 alpha-3)',
origine_ville VARCHAR(100) COMMENT 'Ville d''origine (optionnel)',
origine_port_code VARCHAR(10) COMMENT 'Code du port d''origine',
destination_pays CHAR(3) NOT NULL COMMENT 'Code pays destination (ISO 3166-1 alpha-3)',
destination_ville VARCHAR(100) COMMENT 'Ville de destination (optionnel)',
destination_port_code VARCHAR(10) COMMENT 'Code du port de destination',
incoterm VARCHAR(10) COMMENT 'Incoterm applicable (EXW, FOB, CIF, etc.)',
mode_transport ENUM('MARITIME', 'AERIEN', 'ROUTIER', 'FERROVIAIRE') COMMENT 'Mode de transport',
service_type ENUM('RAPIDE', 'STANDARD', 'ECONOMIQUE') COMMENT 'Type de service (pour les 3 offres)',
transit_time_min INT COMMENT 'Temps de transit minimum en jours',
transit_time_max INT COMMENT 'Temps de transit maximum en jours',
validite_debut DATE NOT NULL COMMENT 'Date de début de validité',
validite_fin DATE NOT NULL COMMENT 'Date de fin de validité',
devise CHAR(3) DEFAULT 'EUR' COMMENT 'Devise des tarifs (ISO 4217)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Contraintes
CONSTRAINT chk_validite_dates CHECK (validite_debut <= validite_fin),
CONSTRAINT chk_transit_times CHECK (transit_time_min IS NULL OR transit_time_max IS NULL OR transit_time_min <= transit_time_max),
-- Index pour les recherches fréquentes
INDEX idx_route_service (type_service, origine_pays, destination_pays, validite_debut, validite_fin),
INDEX idx_transporteur_validite (transporteur, validite_debut, validite_fin),
INDEX idx_service_type (service_type),
INDEX idx_ville_origine (origine_ville),
INDEX idx_ville_destination (destination_ville)
);
-- Table des tarifs de fret (barème de prix selon poids/volume)
CREATE TABLE tarifs_fret (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
grille_id BIGINT NOT NULL COMMENT 'Référence vers la grille tarifaire',
poids_min DECIMAL(10,2) COMMENT 'Poids minimum en kg pour ce tarif',
poids_max DECIMAL(10,2) COMMENT 'Poids maximum en kg pour ce tarif',
volume_min DECIMAL(10,3) COMMENT 'Volume minimum en m³ pour ce tarif',
volume_max DECIMAL(10,3) COMMENT 'Volume maximum en m³ pour ce tarif',
taux_unitaire DECIMAL(10,2) NOT NULL COMMENT 'Taux unitaire selon l''unité de facturation',
unite_facturation ENUM('KG', 'M3', 'PALETTE', 'COLIS', 'LS') NOT NULL COMMENT 'Unité de facturation',
minimum_facturation DECIMAL(10,2) COMMENT 'Montant minimum de facturation',
-- Clé étrangère
FOREIGN KEY (grille_id) REFERENCES grilles_tarifaires(id) ON DELETE CASCADE,
-- Index pour optimiser les recherches de tarifs
INDEX idx_grille_poids (grille_id, poids_min, poids_max),
INDEX idx_grille_volume (grille_id, volume_min, volume_max),
INDEX idx_unite_facturation (unite_facturation)
);
-- Table des frais additionnels (frais fixes, services optionnels)
CREATE TABLE frais_additionnels (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
grille_id BIGINT NOT NULL COMMENT 'Référence vers la grille tarifaire',
type_frais VARCHAR(50) NOT NULL COMMENT 'Type de frais (DOCUMENTATION, ISPS, HAYON, ASSURANCE, etc.)',
description VARCHAR(200) COMMENT 'Description détaillée du frais',
montant DECIMAL(10,2) NOT NULL COMMENT 'Montant du frais',
unite_facturation ENUM('LS', 'KG', 'M3', 'PALETTE', 'POURCENTAGE') NOT NULL COMMENT 'Unité de facturation',
montant_minimum DECIMAL(10,2) COMMENT 'Montant minimum pour ce frais',
obligatoire BOOLEAN DEFAULT FALSE COMMENT 'Frais obligatoire ou optionnel',
applicable_marchandise_dangereuse BOOLEAN DEFAULT FALSE COMMENT 'Applicable aux marchandises dangereuses',
-- Clé étrangère
FOREIGN KEY (grille_id) REFERENCES grilles_tarifaires(id) ON DELETE CASCADE,
-- Index pour les recherches
INDEX idx_grille_type (grille_id, type_frais),
INDEX idx_obligatoire (obligatoire),
INDEX idx_marchandise_dangereuse (applicable_marchandise_dangereuse)
);
-- Table des surcharges pour marchandises dangereuses
CREATE TABLE surcharges_dangereuses (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
grille_id BIGINT NOT NULL COMMENT 'Référence vers la grille tarifaire',
classe_adr VARCHAR(10) COMMENT 'Classe ADR (1, 2.1, 3, 4.1, etc.)',
un_number VARCHAR(10) COMMENT 'Numéro UN pour identification spécifique',
surcharge DECIMAL(10,2) NOT NULL COMMENT 'Montant de la surcharge',
unite_facturation ENUM('LS', 'KG', 'COLIS') NOT NULL COMMENT 'Unité de facturation',
minimum DECIMAL(10,2) COMMENT 'Surcharge minimum',
-- Clé étrangère
FOREIGN KEY (grille_id) REFERENCES grilles_tarifaires(id) ON DELETE CASCADE,
-- Index pour les recherches
INDEX idx_grille_classe (grille_id, classe_adr),
INDEX idx_grille_un_number (grille_id, un_number),
INDEX idx_classe_adr (classe_adr)
);
-- ================================
-- Commentaires sur le modèle de données
-- ================================
-- Les grilles tarifaires permettent de:
-- 1. Définir des tarifs spécifiques par route (origine/destination)
-- 2. Gérer plusieurs transporteurs avec leurs propres grilles
-- 3. Créer 3 types d'offres (RAPIDE, STANDARD, ECONOMIQUE)
-- 4. Appliquer des périodes de validité
-- 5. Personnaliser par ville ou port spécifique
-- Les tarifs de fret supportent:
-- 1. Barèmes progressifs selon poids ou volume
-- 2. Différentes unités de facturation (kg, m³, colis, forfait)
-- 3. Minima de facturation
-- 4. Poids volumétrique vs poids réel
-- Les frais additionnels incluent:
-- 1. Frais obligatoires (documentation, ISPS, manutention)
-- 2. Services optionnels (assurance, hayon, sangles)
-- 3. Frais spécifiques aux marchandises dangereuses
-- 4. Pourcentages ou montants fixes
-- Les surcharges dangereuses permettent:
-- 1. Tarification spécifique par classe ADR
-- 2. Identification par numéro UN
-- 3. Surcharges forfaitaires ou au poids/colis

View File

@ -0,0 +1,231 @@
package com.dh7789dev.xpeditis.repository;
import com.dh7789dev.xpeditis.entity.GrilleTarifaireEntity;
import com.dh7789dev.xpeditis.entity.TarifFretEntity;
import com.dh7789dev.xpeditis.entity.FraisAdditionnelsEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
@DataJpaTest
@DisplayName("GrilleTarifaireJpaRepository - Tests d'intégration")
class GrilleTarifaireJpaRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private GrilleTarifaireJpaRepository repository;
private GrilleTarifaireEntity grilleStandard;
private GrilleTarifaireEntity grilleRapide;
@BeforeEach
void setUp() {
// Créer et persister des grilles de test
grilleStandard = creerGrilleStandard();
grilleRapide = creerGrilleRapide();
entityManager.persistAndFlush(grilleStandard);
entityManager.persistAndFlush(grilleRapide);
}
@Test
@DisplayName("Doit trouver les grilles applicables par route et date")
void doitTrouverGrillesApplicablesParRouteEtDate() {
// When
List<GrilleTarifaireEntity> grilles = repository.findGrillesApplicables(
GrilleTarifaireEntity.TypeService.EXPORT,
"FRA",
"CHN",
LocalDate.now()
);
// Then
assertThat(grilles).hasSize(2);
assertThat(grilles)
.extracting(GrilleTarifaireEntity::getNomGrille)
.containsExactlyInAnyOrder("Test Grille Standard", "Test Grille Rapide");
}
@Test
@DisplayName("Doit filtrer par type de service spécifique")
void doitFiltrerParTypeServiceSpecifique() {
// When
List<GrilleTarifaireEntity> grillesStandard = repository.findByServiceTypeAndRoute(
GrilleTarifaireEntity.TypeService.EXPORT,
GrilleTarifaireEntity.ServiceType.STANDARD,
"FRA",
"CHN",
LocalDate.now()
);
// Then
assertThat(grillesStandard).hasSize(1);
assertThat(grillesStandard.get(0).getNomGrille()).isEqualTo("Test Grille Standard");
}
@Test
@DisplayName("Ne doit pas trouver de grilles pour une date hors validité")
void neDοitPasTrouverGrillesHorsValidite() {
// When
List<GrilleTarifaireEntity> grilles = repository.findGrillesApplicables(
GrilleTarifaireEntity.TypeService.EXPORT,
"FRA",
"CHN",
LocalDate.now().plusYears(2) // Date future hors validité
);
// Then
assertThat(grilles).isEmpty();
}
@Test
@DisplayName("Doit trouver les grilles par transporteur et dates")
void doitTrouverGrillesParTransporteurEtDates() {
// When
List<GrilleTarifaireEntity> grilles = repository
.findByTransporteurAndValiditeDebutLessThanEqualAndValiditeFinGreaterThanEqual(
"LESCHACO",
LocalDate.now(),
LocalDate.now()
);
// Then
assertThat(grilles).hasSize(2);
assertThat(grilles)
.allMatch(g -> "LESCHACO".equals(g.getTransporteur()));
}
@Test
@DisplayName("Doit compter correctement les grilles actives par transporteur")
void doitCompterCorrectementGrillesActivesParTransporteur() {
// When
Long count = repository.countActiveGridsByTransporteur("LESCHACO", LocalDate.now());
// Then
assertThat(count).isEqualTo(2L);
}
@Test
@DisplayName("Doit sauvegarder une grille avec relations en cascade")
void doitSauvegarderGrilleAvecRelations() {
// Given
GrilleTarifaireEntity nouvelleGrille = new GrilleTarifaireEntity();
nouvelleGrille.setNomGrille("Nouvelle Grille Test");
nouvelleGrille.setTransporteur("MSC");
nouvelleGrille.setTypeService(GrilleTarifaireEntity.TypeService.IMPORT);
nouvelleGrille.setOriginePays("CHN");
nouvelleGrille.setDestinationPays("FRA");
nouvelleGrille.setModeTransport(GrilleTarifaireEntity.ModeTransport.MARITIME);
nouvelleGrille.setServiceType(GrilleTarifaireEntity.ServiceType.STANDARD);
nouvelleGrille.setValiditeDebut(LocalDate.now());
nouvelleGrille.setValiditeFin(LocalDate.now().plusMonths(6));
// Ajouter un tarif de fret
TarifFretEntity tarif = new TarifFretEntity();
tarif.setGrille(nouvelleGrille);
tarif.setPoidsMin(BigDecimal.ZERO);
tarif.setPoidsMax(BigDecimal.valueOf(500));
tarif.setTauxUnitaire(BigDecimal.valueOf(3.0));
tarif.setUniteFacturation(TarifFretEntity.UniteFacturation.KG);
nouvelleGrille.getTarifsFret().add(tarif);
// Ajouter des frais additionnels
FraisAdditionnelsEntity frais = new FraisAdditionnelsEntity();
frais.setGrille(nouvelleGrille);
frais.setTypeFrais("DOCUMENTATION");
frais.setMontant(BigDecimal.valueOf(40));
frais.setUniteFacturation(FraisAdditionnelsEntity.UniteFacturation.LS);
frais.setObligatoire(true);
nouvelleGrille.getFraisAdditionnels().add(frais);
// When
GrilleTarifaireEntity grilleSauvee = repository.saveAndFlush(nouvelleGrille);
// Then
assertThat(grilleSauvee.getId()).isNotNull();
assertThat(grilleSauvee.getTarifsFret()).hasSize(1);
assertThat(grilleSauvee.getFraisAdditionnels()).hasSize(1);
// Vérifier que les relations sont bien établies
assertThat(grilleSauvee.getTarifsFret().get(0).getGrille().getId())
.isEqualTo(grilleSauvee.getId());
assertThat(grilleSauvee.getFraisAdditionnels().get(0).getGrille().getId())
.isEqualTo(grilleSauvee.getId());
}
@Test
@DisplayName("Doit supprimer une grille et ses relations en cascade")
void doitSupprimerGrilleEtRelationsEnCascade() {
// Given
Long grilleId = grilleStandard.getId();
// Vérifier que la grille existe avant suppression
assertThat(repository.existsById(grilleId)).isTrue();
// When
repository.deleteById(grilleId);
repository.flush();
// Then
assertThat(repository.existsById(grilleId)).isFalse();
}
// ================================
// Méthodes utilitaires
// ================================
private GrilleTarifaireEntity creerGrilleStandard() {
GrilleTarifaireEntity grille = new GrilleTarifaireEntity();
grille.setNomGrille("Test Grille Standard");
grille.setTransporteur("LESCHACO");
grille.setTypeService(GrilleTarifaireEntity.TypeService.EXPORT);
grille.setOriginePays("FRA");
grille.setOrigineVille("Fos Sur Mer");
grille.setDestinationPays("CHN");
grille.setDestinationVille("Shanghai");
grille.setIncoterm("FOB");
grille.setModeTransport(GrilleTarifaireEntity.ModeTransport.MARITIME);
grille.setServiceType(GrilleTarifaireEntity.ServiceType.STANDARD);
grille.setTransitTimeMin(25);
grille.setTransitTimeMax(30);
grille.setValiditeDebut(LocalDate.now().minusDays(30));
grille.setValiditeFin(LocalDate.now().plusDays(90));
grille.setDevise("EUR");
return grille;
}
private GrilleTarifaireEntity creerGrilleRapide() {
GrilleTarifaireEntity grille = new GrilleTarifaireEntity();
grille.setNomGrille("Test Grille Rapide");
grille.setTransporteur("LESCHACO");
grille.setTypeService(GrilleTarifaireEntity.TypeService.EXPORT);
grille.setOriginePays("FRA");
grille.setOrigineVille("Fos Sur Mer");
grille.setDestinationPays("CHN");
grille.setDestinationVille("Shanghai");
grille.setIncoterm("FOB");
grille.setModeTransport(GrilleTarifaireEntity.ModeTransport.MARITIME);
grille.setServiceType(GrilleTarifaireEntity.ServiceType.RAPIDE);
grille.setTransitTimeMin(18);
grille.setTransitTimeMax(22);
grille.setValiditeDebut(LocalDate.now().minusDays(30));
grille.setValiditeFin(LocalDate.now().plusDays(90));
grille.setDevise("EUR");
return grille;
}
}