package services import ( "context" "encoding/json" "fmt" "gestion/models" "io" "log" "math" "net/http" "net/url" "strings" "time" "github.com/redis/go-redis/v9" ) const ( NominatimBaseURL = "https://nominatim.openstreetmap.org/search" EarthRadiusKm = 6371.0 DefaultSpeed = 30.0 // km/h en ville PreparationTime = 5.0 // minutes TrafficMarginPerKm = 2.0 // minutes par km MinETA = 3 // ⚡ RÉDUIT: minimum 3 minutes (était 10) MaxETA = 120 // minutes GeocacheTTL = 7 * 24 * time.Hour LocationTTL = 1 * time.Hour ) type GeoLocation struct { Latitude float64 `json:"lat,string"` Longitude float64 `json:"lon,string"` DisplayName string `json:"display_name"` } type Coordinates struct { Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` } type DeliveryDistance struct { Username string `json:"username"` Location Coordinates `json:"location"` Distance float64 `json:"distance_km"` EstimatedTime int `json:"eta_minutes"` } type GeoService struct { redis *redis.Client ctx context.Context httpClient *http.Client correctionService *AddressCorrectionService } func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService { gs := &GeoService{ redis: redisClient, ctx: ctx, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } // Le correctionService est initialisé après, car il a besoin de gs lui-même gs.correctionService = NewAddressCorrectionService(gs) return gs } func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) { // 1. Cache Redis (adresse originale) if location, err := gs.getFromCache(address); err == nil { return location, nil } // 2. Tentative directe via Nominatim if location, err := gs.fetchFromNominatim(address); err == nil { gs.saveToCache(address, location) return location, nil } // 3. ── NOUVEAU : correction automatique de l'adresse ────────────────── // Déclenché uniquement si le géocodage direct a échoué. log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address) suggestion, err := gs.correctionService.ResolveAddress(address) if err != nil { log.Printf("❌ [GEO] Correction impossible pour '%s': %v", address, err) return nil, fmt.Errorf("adresse introuvable : '%s'", address) } if suggestion.CorrectionApplied { log.Printf( "✅ [GEO] Correction appliquée (confiance %.0f%%) : '%s' → '%s'", suggestion.Confidence*100, address, suggestion.CorrectedAddress, ) } location := &GeoLocation{ Latitude: suggestion.Coordinates.Latitude, Longitude: suggestion.Coordinates.Longitude, DisplayName: suggestion.CorrectedAddress, } // Mettre en cache avec l'adresse originale pour les prochains appels gs.saveToCache(address, location) // Mettre en cache aussi avec l'adresse corrigée if suggestion.CorrectionApplied { gs.saveToCache(suggestion.CorrectedAddress, location) } return location, nil } // getFromCache récupère depuis le cache Redis func (gs *GeoService) getFromCache(address string) (*GeoLocation, error) { key := gs.getCacheKey(address) data, err := gs.redis.Get(gs.ctx, key).Result() if err != nil { return nil, err } var location GeoLocation err = json.Unmarshal([]byte(data), &location) if err != nil { return nil, err } return &location, nil } // saveToCache sauvegarde dans le cache Redis func (gs *GeoService) saveToCache(address string, location *GeoLocation) error { key := gs.getCacheKey(address) data, err := json.Marshal(location) if err != nil { return err } return gs.redis.Set(gs.ctx, key, data, GeocacheTTL).Err() } // fetchFromNominatim récupère les coordonnées depuis l'API func (gs *GeoService) fetchFromNominatim(address string) (*GeoLocation, error) { // Vérifier que l'adresse n'est pas vide address = strings.TrimSpace(address) if address == "" { return nil, fmt.Errorf("adresse vide, impossible de géocoder") } // Préparer les paramètres URL params := url.Values{} params.Set("q", address) // adresse params.Set("format", "json") // format supporté par Nominatim params.Set("limit", "1") // une seule réponse fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode()) var lastErr error for i := 0; i < 3; i++ { // retry jusqu'à 3 fois req, err := http.NewRequest("GET", fullURL, nil) if err != nil { return nil, fmt.Errorf("erreur création requête: %w", err) } req.Header.Set("User-Agent", "DeliveryApp/1.0") resp, err := gs.httpClient.Do(req) if err != nil { lastErr = err time.Sleep(time.Second * time.Duration(i+1)) // délai croissant continue } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf("API error: status %d", resp.StatusCode) time.Sleep(time.Second * time.Duration(i+1)) continue } body, err := io.ReadAll(resp.Body) if err != nil { lastErr = err time.Sleep(time.Second * time.Duration(i+1)) continue } var locations []GeoLocation err = json.Unmarshal(body, &locations) if err != nil { lastErr = err time.Sleep(time.Second * time.Duration(i+1)) continue } if len(locations) == 0 { lastErr = fmt.Errorf("adresse introuvable: %s", address) time.Sleep(time.Second * time.Duration(i+1)) continue } return &locations[0], nil // succès } return nil, fmt.Errorf("échec après 3 tentatives: %w", lastErr) } // getCacheKey génère une clé de cache func (gs *GeoService) getCacheKey(address string) string { return fmt.Sprintf("geocode:cache:%s", address) } // CalculateDistance calcule la distance entre deux points (formule Haversine) func CalculateDistance(from, to Coordinates) float64 { // Conversion en radians lat1Rad := toRadians(from.Latitude) lon1Rad := toRadians(from.Longitude) lat2Rad := toRadians(to.Latitude) lon2Rad := toRadians(to.Longitude) dLat := lat2Rad - lat1Rad dLon := lon2Rad - lon1Rad // Formule Haversine a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)* math.Sin(dLon/2)*math.Sin(dLon/2) c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return EarthRadiusKm * c } // CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback) func CalculateETA(distanceKm float64) int { if distanceKm < 0.1 { return MinETA } travelTime := (distanceKm / 25.0) * 60.0 // Ajouter une marge pour le trafic (environ 20%) totalMinutes := int(travelTime * 1.2) // Appliquer les limites if totalMinutes < MinETA { return MinETA } if totalMinutes > MaxETA { return MaxETA } return totalMinutes } func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) { if len(tomTomKeys.keys) == 0 { distance := CalculateDistance(from, to) return CalculateETA(distance), distance, nil } client := &http.Client{Timeout: 8 * time.Second} buildReq := func(key string) (*http.Request, error) { u := &url.URL{ Scheme: "https", Host: "api.tomtom.com", Path: fmt.Sprintf("/routing/1/calculateRoute/%f,%f:%f,%f/json", from.Latitude, from.Longitude, to.Latitude, to.Longitude), } q := url.Values{} q.Set("key", key) q.Set("traffic", "true") q.Set("travelMode", "car") u.RawQuery = q.Encode() return http.NewRequest(http.MethodGet, u.String(), nil) } resp, err := tomTomKeys.Do(client, buildReq) if err != nil { distance := CalculateDistance(from, to) eta := CalculateETA(distance) fmt.Printf("⚠️ TomTom indisponible, fallback: %.2f km -> %d min (%v)\n", distance, eta, err) return eta, distance, nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { distance := CalculateDistance(from, to) eta := CalculateETA(distance) fmt.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min\n", resp.StatusCode, distance, eta) return eta, distance, nil } body, err := io.ReadAll(resp.Body) if err != nil { distance := CalculateDistance(from, to) return CalculateETA(distance), distance, nil } var routeResponse models.RouteResponse if err := json.Unmarshal(body, &routeResponse); err != nil { distance := CalculateDistance(from, to) return CalculateETA(distance), distance, nil } if len(routeResponse.Routes) == 0 { distance := CalculateDistance(from, to) return CalculateETA(distance), distance, nil } summary := routeResponse.Routes[0].Summary etaMinutes := (summary.TravelTimeInSeconds + 59) / 60 distanceKm := float64(summary.LengthInMeters) / 1000.0 if etaMinutes < MinETA { etaMinutes = MinETA } fmt.Printf("🛣️ TomTom: %.2f km -> %d min (trafic réel)\n", distanceKm, etaMinutes) return etaMinutes, distanceKm, nil } // toRadians convertit des degrés en radians func toRadians(degrees float64) float64 { return degrees * math.Pi / 180.0 } // ============================================ // VALIDATION // ============================================ // ValidateCoordinates valide des coordonnées GPS func ValidateCoordinates(coords Coordinates) error { if coords.Latitude < -90 || coords.Latitude > 90 { return fmt.Errorf("latitude invalide: %.6f (doit être entre -90 et 90)", coords.Latitude) } if coords.Longitude < -180 || coords.Longitude > 180 { return fmt.Errorf("longitude invalide: %.6f (doit être entre -180 et 180)", coords.Longitude) } return nil } // IsValidAddress vérifie si une adresse peut être géocodée func (gs *GeoService) IsValidAddress(address string) bool { _, err := gs.GeocodeAddress(address) return err == nil } // ============================================ // GESTION DES POSITIONS DES LIVREURS // ============================================ // SaveDeliveryPersonLocation sauvegarde la position d'un livreur func (gs *GeoService) SaveDeliveryPersonLocation(username string, coords Coordinates) error { if err := ValidateCoordinates(coords); err != nil { return err } key := gs.getLocationKey(username) location := map[string]interface{}{ "latitude": coords.Latitude, "longitude": coords.Longitude, "last_update": time.Now().Unix(), } data, err := json.Marshal(location) if err != nil { return err } return gs.redis.Set(gs.ctx, key, data, LocationTTL).Err() } // GetDeliveryPersonLocation récupère la position d'un livreur func (gs *GeoService) GetDeliveryPersonLocation(username string) (*Coordinates, error) { key := gs.getLocationKey(username) data, err := gs.redis.Get(gs.ctx, key).Result() if err != nil { return nil, fmt.Errorf("position non trouvée: %w", err) } var location map[string]interface{} err = json.Unmarshal([]byte(data), &location) if err != nil { return nil, err } coords := &Coordinates{ Latitude: location["latitude"].(float64), Longitude: location["longitude"].(float64), } return coords, nil } // getLocationKey génère une clé pour la position func (gs *GeoService) getLocationKey(username string) string { return fmt.Sprintf("delivery:location:%s", username) } // ============================================ // RECHERCHE DE LIVREURS // ============================================ // FindNearestDeliveryPerson trouve le livreur le plus proche func (gs *GeoService) FindNearestDeliveryPerson(target Coordinates, availableUsernames []string) (*DeliveryDistance, error) { if len(availableUsernames) == 0 { return nil, fmt.Errorf("aucun livreur disponible") } var nearest *DeliveryDistance minDistance := math.MaxFloat64 for _, username := range availableUsernames { location, err := gs.GetDeliveryPersonLocation(username) if err != nil { continue // Ignorer les livreurs sans position GPS } distance := CalculateDistance(*location, target) if distance < minDistance { minDistance = distance // ⚡ AMÉLIORATION: Utiliser TomTom pour l'ETA si disponible eta, _, _ := CalculateETAWithTomTom(*location, target) nearest = &DeliveryDistance{ Username: username, Location: *location, Distance: distance, EstimatedTime: eta, } } } if nearest == nil { return nil, fmt.Errorf("aucun livreur avec position GPS valide") } return nearest, nil } // FindNearestDeliveryPersonFast trouve le livreur le plus proche (sans TomTom, plus rapide) func (gs *GeoService) FindNearestDeliveryPersonFast(target Coordinates, availableUsernames []string) (*DeliveryDistance, error) { if len(availableUsernames) == 0 { return nil, fmt.Errorf("aucun livreur disponible") } var nearest *DeliveryDistance minDistance := math.MaxFloat64 for _, username := range availableUsernames { location, err := gs.GetDeliveryPersonLocation(username) if err != nil { continue // Ignorer les livreurs sans position GPS } distance := CalculateDistance(*location, target) if distance < minDistance { minDistance = distance nearest = &DeliveryDistance{ Username: username, Location: *location, Distance: distance, EstimatedTime: CalculateETA(distance), // Calcul local rapide } } } if nearest == nil { return nil, fmt.Errorf("aucun livreur avec position GPS valide") } return nearest, nil } // GetAllDeliveryDistances retourne tous les livreurs triés par distance func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsernames []string) ([]DeliveryDistance, error) { if len(availableUsernames) == 0 { return nil, fmt.Errorf("aucun livreur disponible") } var distances []DeliveryDistance for _, username := range availableUsernames { location, err := gs.GetDeliveryPersonLocation(username) if err != nil { continue } distance := CalculateDistance(*location, target) distances = append(distances, DeliveryDistance{ Username: username, Location: *location, Distance: distance, EstimatedTime: CalculateETA(distance), }) } // Tri par distance (bubble sort) for i := 0; i < len(distances)-1; i++ { for j := 0; j < len(distances)-i-1; j++ { if distances[j].Distance > distances[j+1].Distance { distances[j], distances[j+1] = distances[j+1], distances[j] } } } return distances, nil } // ============================================ // HEATMAP ET VISUALISATION // ============================================ // GetDeliveryHeatmap retourne toutes les positions des livreurs func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]any, error) { keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result() if err != nil { return nil, err } var heatmap []map[string]any for _, key := range keys { data, err := gs.redis.Get(gs.ctx, key).Result() if err != nil { continue } var location map[string]any json.Unmarshal([]byte(data), &location) username := key[len("delivery:location:"):] location["username"] = username heatmap = append(heatmap, location) } return heatmap, nil } func (gs *GeoService) CorrectionService() *AddressCorrectionService { return gs.correctionService }