625 lines
17 KiB
Go
625 lines
17 KiB
Go
// ============================================
|
|
// handlers/traffic_handlers.go - COMPLET
|
|
// ============================================
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"gestion/db"
|
|
"gestion/models"
|
|
"gestion/services"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ============================================
|
|
// INCIDENTS TRAFFIC TOMTOM
|
|
// ============================================
|
|
|
|
// GetIncidentsAroundDeliveryPerson récupère les incidents autour d'un livreur
|
|
// GET /api/v2/admin/traffic/delivery/:username/incidents
|
|
func GetIncidentsAroundDeliveryPerson(c *gin.Context) {
|
|
database := c.MustGet("database").(*db.Database)
|
|
|
|
userRole := c.GetString("role")
|
|
if userRole != "admin" && userRole != "livreur" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
|
return
|
|
}
|
|
|
|
username := c.Param("username")
|
|
if username == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
|
return
|
|
}
|
|
|
|
// Récupérer position du livreur
|
|
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": "Position livreur non trouvée",
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Rayon de recherche par défaut: 5 km
|
|
radius := 5000 // mètres
|
|
|
|
// Récupérer incidents TomTom
|
|
incidents, err := fetchIncidents(lat, lon, radius)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "Erreur récupération incidents",
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"username": username,
|
|
"location": gin.H{"latitude": lat, "longitude": lon},
|
|
"radius_km": radius / 1000,
|
|
"incidents": incidents,
|
|
"count": len(incidents),
|
|
})
|
|
}
|
|
|
|
// GetIncidentsForAllDeliveries récupère incidents + routes pour tous livreurs actifs
|
|
// GET /api/v2/admin/traffic/incidents/all
|
|
func GetIncidentsForAllDeliveries(c *gin.Context) {
|
|
database := c.MustGet("database").(*db.Database)
|
|
|
|
userRole := c.GetString("role")
|
|
if userRole != "admin" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
|
return
|
|
}
|
|
|
|
// Récupérer tous les livreurs disponibles depuis Redis
|
|
livreurs, err := database.GetAvailableDeliveryPersonsRedis()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "Erreur récupération livreurs",
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
results := []gin.H{}
|
|
|
|
for _, livreur := range livreurs {
|
|
username := livreur.Username
|
|
status := livreur.Status
|
|
|
|
// Sauter les livreurs offline
|
|
if status == "offline" {
|
|
continue
|
|
}
|
|
|
|
// Position livreur
|
|
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
|
if err != nil {
|
|
log.Printf("⚠️ Position non trouvée pour %s", username)
|
|
continue
|
|
}
|
|
|
|
// Vérifier s'il a une commande en cours
|
|
commandID := livreur.CurrentCommand
|
|
|
|
if commandID == 0 {
|
|
// Pas de livraison en cours
|
|
results = append(results, gin.H{
|
|
"username": username,
|
|
"status": status,
|
|
"location": gin.H{"latitude": lat, "longitude": lon},
|
|
"has_delivery": false,
|
|
"incidents": []gin.H{},
|
|
"route": nil,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Récupérer la commande
|
|
command, err := database.GetCommandByID(commandID)
|
|
if err != nil {
|
|
log.Printf("⚠️ Commande %d non trouvée", commandID)
|
|
continue
|
|
}
|
|
|
|
// Coordonnées destination
|
|
var destLat, destLon float64
|
|
|
|
if dLat, ok := getFloatFromMap(command, "dest_latitude"); ok && dLat != 0 {
|
|
destLat = dLat
|
|
}
|
|
if dLon, ok := getFloatFromMap(command, "dest_longitude"); ok && dLon != 0 {
|
|
destLon = dLon
|
|
}
|
|
|
|
// Si pas de coordonnées, géocoder
|
|
if destLat == 0 || destLon == 0 {
|
|
address, ok := command["adresse"].(string)
|
|
if !ok || address == "" || address == "Adresse non spécifiée" {
|
|
continue
|
|
}
|
|
|
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
|
location, err := geoService.GeocodeAddress(address)
|
|
if err != nil {
|
|
log.Printf("⚠️ Géocodage échoué pour %s", address)
|
|
continue
|
|
}
|
|
|
|
destLat = location.Latitude
|
|
destLon = location.Longitude
|
|
}
|
|
|
|
// Récupérer incidents sur le trajet
|
|
incidents, _ := fetchIncidentsOnRoute(lat, lon, destLat, destLon)
|
|
|
|
// Convertir incidents en gin.H pour JSON
|
|
incidentsJSON := make([]gin.H, len(incidents))
|
|
for i, inc := range incidents {
|
|
incidentsJSON[i] = gin.H{
|
|
"type": inc.Type,
|
|
"icon": inc.Icon,
|
|
"description": inc.Description,
|
|
}
|
|
}
|
|
|
|
// Calculer route avec trafic
|
|
routeSummary, err := fetchRouteSummary(lat, lon, destLat, destLon)
|
|
if err != nil {
|
|
log.Printf("⚠️ Erreur calcul route pour %s", username)
|
|
routeSummary = models.RouteSummary{}
|
|
}
|
|
|
|
results = append(results, gin.H{
|
|
"username": username,
|
|
"status": status,
|
|
"location": gin.H{"latitude": lat, "longitude": lon},
|
|
"destination": gin.H{"latitude": destLat, "longitude": destLon},
|
|
"has_delivery": true,
|
|
"command_id": commandID,
|
|
"incidents": incidentsJSON,
|
|
"route": routeSummary,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"deliveries": results,
|
|
"count": len(results),
|
|
})
|
|
}
|
|
|
|
// ============================================
|
|
// MISE À JOUR ETA AVEC TRAFIC
|
|
// ============================================
|
|
func UpdateETAWithRealTraffic(c *gin.Context) {
|
|
database := c.MustGet("database").(*db.Database)
|
|
|
|
userRole := c.GetString("role")
|
|
if userRole != "admin" && userRole != "livreur" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
|
return
|
|
}
|
|
|
|
// Récupérer l'ID de la commande
|
|
commandID, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
|
return
|
|
}
|
|
|
|
// Récupérer la commande
|
|
command, err := database.GetCommandByID(commandID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": "Commande non trouvée",
|
|
"command_id": commandID,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Vérifier qu'un livreur est assigné
|
|
livreurAssign, ok := command["livreur_assign"].(string)
|
|
if !ok || livreurAssign == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "Aucun livreur assigné",
|
|
"command_id": commandID,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Position actuelle du livreur
|
|
lat, lon, err := database.GetDeliveryPersonLocation(livreurAssign)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": "Position livreur introuvable",
|
|
"livreur": livreurAssign,
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
var destLat, destLon float64
|
|
|
|
// 🔹 1. Tenter de récupérer depuis le cache Redis (clé spécifique pour destination)
|
|
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
|
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
|
|
if err == nil && destData != "" {
|
|
var coords struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
}
|
|
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
|
|
destLat = coords.Lat
|
|
destLon = coords.Lon
|
|
log.Printf("📍 Destination trouvée dans cache Redis pour commande %d", commandID)
|
|
}
|
|
}
|
|
|
|
// 🔹 2. Fallback: récupérer depuis la DB
|
|
if destLat == 0 || destLon == 0 {
|
|
if dLat, okLat := getFloatFromMap(command, "dest_latitude"); okLat && dLat != 0 {
|
|
destLat = dLat
|
|
}
|
|
if dLon, okLon := getFloatFromMap(command, "dest_longitude"); okLon && dLon != 0 {
|
|
destLon = dLon
|
|
}
|
|
}
|
|
|
|
// 🔹 3. Si toujours pas de coordonnées, géocoder l'adresse
|
|
if destLat == 0 || destLon == 0 {
|
|
address, ok := command["adresse"].(string)
|
|
if !ok || address == "" || address == "Adresse non spécifiée" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "Adresse de destination manquante ou invalide",
|
|
"command_id": commandID,
|
|
})
|
|
return
|
|
}
|
|
|
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
|
location, err := geoService.GeocodeAddress(address)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "Impossible de géocoder l'adresse",
|
|
"address": address,
|
|
"command_id": commandID,
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
destLat = location.Latitude
|
|
destLon = location.Longitude
|
|
log.Printf("📍 Adresse géocodée pour commande %d: %s -> (%.6f, %.6f)",
|
|
commandID, address, destLat, destLon)
|
|
}
|
|
|
|
// 🔹 4. Sauvegarder les coordonnées destination dans le cache Redis
|
|
coordsJSON, _ := json.Marshal(map[string]float64{
|
|
"lat": destLat,
|
|
"lon": destLon,
|
|
})
|
|
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
|
|
log.Printf("⚠️ Impossible de sauvegarder destination dans Redis: %v", err)
|
|
}
|
|
|
|
// 🔹 5. Calculer le temps réel avec TomTom Routing API
|
|
routeSummary, err := fetchRouteSummary(lat, lon, destLat, destLon)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "Impossible de calculer l'itinéraire",
|
|
"command_id": commandID,
|
|
"from": gin.H{"lat": lat, "lon": lon},
|
|
"to": gin.H{"lat": destLat, "lon": destLon},
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 🔹 6. Mettre à jour l'ETA dans Redis
|
|
err = database.SetCommandETA(commandID, routeSummary.TravelTimeInMinutes)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "Erreur mise à jour ETA",
|
|
"command_id": commandID,
|
|
"details": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
log.Printf("✅ ETA mis à jour pour commande %d: %d min (trafic réel inclus)",
|
|
commandID, routeSummary.TravelTimeInMinutes)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"command_id": commandID,
|
|
"eta_minutes": routeSummary.TravelTimeInMinutes,
|
|
"distance_km": routeSummary.LengthInKm,
|
|
"with_traffic": true,
|
|
"route_summary": routeSummary,
|
|
})
|
|
}
|
|
|
|
// ============================================
|
|
// FONCTIONS HELPERS - TOMTOM API
|
|
// ============================================
|
|
|
|
// fetchIncidents récupère les incidents de trafic autour d'une position
|
|
func fetchIncidents(lat, lon float64, radius int) ([]models.Incident, error) {
|
|
apiKey := os.Getenv("TOMTOM_API_KEY")
|
|
if apiKey == "" {
|
|
return nil, fmt.Errorf("TOMTOM_API_KEY non configurée")
|
|
}
|
|
|
|
// API TomTom Traffic Incidents
|
|
url := fmt.Sprintf(
|
|
"https://api.tomtom.com/traffic/services/5/incidentDetails?key=%s&bbox=%f,%f,%f,%f&fields={incidents{type,geometry{type,coordinates},properties{iconCategory,magnitudeOfDelay,events{description,code,iconCategory}}}}",
|
|
apiKey,
|
|
lon-0.05, lat-0.05, // Southwest corner
|
|
lon+0.05, lat+0.05, // Northeast corner
|
|
)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("erreur requête incidents: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API incidents error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("erreur lecture réponse: %w", err)
|
|
}
|
|
|
|
var incidentResponse models.IncidentResponse
|
|
err = json.Unmarshal(body, &incidentResponse)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("erreur parsing incidents: %w", err)
|
|
}
|
|
|
|
// Convertir en []models.Incident
|
|
incidents := make([]models.Incident, len(incidentResponse.Incidents))
|
|
for i, inc := range incidentResponse.Incidents {
|
|
incidents[i] = models.Incident{
|
|
Type: inc.Type,
|
|
Icon: inc.Icon,
|
|
Description: inc.Description,
|
|
}
|
|
}
|
|
|
|
return incidents, nil
|
|
}
|
|
|
|
// fetchIncidentsOnRoute récupère les incidents sur un trajet
|
|
func fetchIncidentsOnRoute(startLat, startLon, destLat, destLon float64) ([]models.Incident, error) {
|
|
// Calculer la bounding box du trajet
|
|
minLat := min(startLat, destLat) - 0.02
|
|
maxLat := max(startLat, destLat) + 0.02
|
|
minLon := min(startLon, destLon) - 0.02
|
|
maxLon := max(startLon, destLon) + 0.02
|
|
|
|
apiKey := os.Getenv("TOMTOM_API_KEY")
|
|
if apiKey == "" {
|
|
return []models.Incident{}, nil
|
|
}
|
|
|
|
url := fmt.Sprintf(
|
|
"https://api.tomtom.com/traffic/services/5/incidentDetails?key=%s&bbox=%f,%f,%f,%f&fields={incidents{type,geometry{type,coordinates},properties{iconCategory,magnitudeOfDelay,events{description,code,iconCategory}}}}",
|
|
apiKey,
|
|
minLon, minLat,
|
|
maxLon, maxLat,
|
|
)
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return []models.Incident{}, nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return []models.Incident{}, nil
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
var incidentResponse models.IncidentResponse
|
|
if err := json.Unmarshal(body, &incidentResponse); err != nil {
|
|
return []models.Incident{}, nil
|
|
}
|
|
|
|
// Convertir en []models.Incident
|
|
incidents := make([]models.Incident, len(incidentResponse.Incidents))
|
|
for i, inc := range incidentResponse.Incidents {
|
|
incidents[i] = models.Incident{
|
|
Type: inc.Type,
|
|
Icon: inc.Icon,
|
|
Description: inc.Description,
|
|
}
|
|
}
|
|
|
|
return incidents, nil
|
|
}
|
|
|
|
// récupère le temps de trajet réel via l'API TomTom Routing
|
|
// fetchRouteSummary récupère le temps de trajet réel via l'API TomTom Routing
|
|
func fetchRouteSummary(startLat, startLon, destLat, destLon float64) (models.RouteSummary, error) {
|
|
apiKey := os.Getenv("TOMTOM_API_KEY")
|
|
if apiKey == "" {
|
|
return models.RouteSummary{}, fmt.Errorf("TOMTOM_API_KEY non configurée")
|
|
}
|
|
|
|
// Validation des coordonnées
|
|
if startLat < -90 || startLat > 90 || destLat < -90 || destLat > 90 {
|
|
return models.RouteSummary{}, fmt.Errorf("latitude invalide: start=%.6f, dest=%.6f", startLat, destLat)
|
|
}
|
|
if startLon < -180 || startLon > 180 || destLon < -180 || destLon > 180 {
|
|
return models.RouteSummary{}, fmt.Errorf("longitude invalide: start=%.6f, dest=%.6f", startLon, destLon)
|
|
}
|
|
|
|
// API TomTom Routing: Calculate Route
|
|
url := fmt.Sprintf(
|
|
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
|
|
startLat, startLon, destLat, destLon, apiKey,
|
|
)
|
|
|
|
log.Printf("🛣️ Appel TomTom: (%.6f,%.6f) -> (%.6f,%.6f)", startLat, startLon, destLat, destLon)
|
|
|
|
// Timeout réduit à 8 secondes
|
|
client := &http.Client{Timeout: 8 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
// Fallback: estimation basée sur distance Haversine
|
|
distance := haversineDistance(startLat, startLon, destLat, destLon)
|
|
estimatedMinutes := int(distance/25*60) + 3 // ~25 km/h en ville + 3 min marge
|
|
if estimatedMinutes < 5 {
|
|
estimatedMinutes = 5
|
|
}
|
|
|
|
log.Printf("⚠️ TomTom timeout/erreur, fallback: %.2f km -> %d min estimé", distance, estimatedMinutes)
|
|
|
|
return models.RouteSummary{
|
|
TravelTimeInMinutes: estimatedMinutes,
|
|
LengthInKm: distance,
|
|
}, nil // Pas d'erreur, on retourne l'estimation
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
io.ReadAll(resp.Body) // Lire et ignorer le body pour fermer proprement
|
|
|
|
// Fallback en cas d'erreur API
|
|
distance := haversineDistance(startLat, startLon, destLat, destLon)
|
|
estimatedMinutes := int(distance/25*60) + 3
|
|
if estimatedMinutes < 5 {
|
|
estimatedMinutes = 5
|
|
}
|
|
|
|
log.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min", resp.StatusCode, distance, estimatedMinutes)
|
|
|
|
return models.RouteSummary{
|
|
TravelTimeInMinutes: estimatedMinutes,
|
|
LengthInKm: distance,
|
|
}, nil
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return models.RouteSummary{}, fmt.Errorf("erreur lecture réponse: %w", err)
|
|
}
|
|
|
|
var routeResponse models.RouteResponse
|
|
err = json.Unmarshal(body, &routeResponse)
|
|
if err != nil {
|
|
return models.RouteSummary{}, fmt.Errorf("erreur parsing routing: %w", err)
|
|
}
|
|
|
|
if len(routeResponse.Routes) == 0 {
|
|
return models.RouteSummary{}, fmt.Errorf("aucun itinéraire trouvé")
|
|
}
|
|
|
|
summary := routeResponse.Routes[0].Summary
|
|
summary.TravelTimeInMinutes = (summary.TravelTimeInSeconds + 59) / 60
|
|
summary.LengthInKm = float64(summary.LengthInMeters) / 1000.0
|
|
|
|
log.Printf("🛣️ Route calculée: %.2f km, %d min (trafic inclus)", summary.LengthInKm, summary.TravelTimeInMinutes)
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// haversineDistance calcule la distance en km entre deux points GPS
|
|
func haversineDistance(lat1, lon1, lat2, lon2 float64) float64 {
|
|
const R = 6371.0 // Rayon Terre en km
|
|
const toRad = math.Pi / 180.0
|
|
|
|
dLat := (lat2 - lat1) * toRad
|
|
dLon := (lon2 - lon1) * toRad
|
|
|
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
|
math.Cos(lat1*toRad)*math.Cos(lat2*toRad)*
|
|
math.Sin(dLon/2)*math.Sin(dLon/2)
|
|
|
|
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
|
|
|
return R * c
|
|
}
|
|
|
|
// getFloatFromMap récupère un float64 depuis une map avec différents types
|
|
func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
|
|
value, exists := m[key]
|
|
if !exists || value == nil {
|
|
return 0, false
|
|
}
|
|
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return v, true
|
|
case float32:
|
|
return float64(v), true
|
|
case int:
|
|
return float64(v), true
|
|
case int64:
|
|
return float64(v), true
|
|
case int32:
|
|
return float64(v), true
|
|
case json.Number:
|
|
f, err := v.Float64()
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return f, true
|
|
case string:
|
|
f, err := strconv.ParseFloat(v, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return f, true
|
|
case []byte:
|
|
s := string(v)
|
|
f, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return f, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// min retourne le minimum entre deux float64
|
|
func min(a, b float64) float64 {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// max retourne le maximum entre deux float64
|
|
func max(a, b float64) float64 {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|