557 lines
15 KiB
Go
557 lines
15 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"gestion/models"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"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)
|
|
}
|
|
|
|
// ============================================
|
|
// CALCULS GÉOGRAPHIQUES
|
|
// ============================================
|
|
|
|
// 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)
|
|
|
|
// Différences
|
|
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 {
|
|
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
|
|
if distanceKm < 0.1 {
|
|
return MinETA // Très proche: minimum 3 minutes
|
|
}
|
|
|
|
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
|
|
// Plus réaliste que 30 km/h
|
|
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
|
|
}
|
|
|
|
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
|
|
// Retourne (etaMinutes, distanceKm, error)
|
|
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
|
|
apiKey := os.Getenv("TOMTOM_API_KEY")
|
|
if apiKey == "" {
|
|
// Fallback sur calcul local si pas de clé API
|
|
distance := CalculateDistance(from, to)
|
|
return CalculateETA(distance), distance, nil
|
|
}
|
|
|
|
// API TomTom Routing: Calculate Route avec trafic
|
|
apiURL := fmt.Sprintf(
|
|
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
|
|
from.Latitude, from.Longitude, to.Latitude, to.Longitude, apiKey,
|
|
)
|
|
|
|
client := &http.Client{Timeout: 8 * time.Second}
|
|
resp, err := client.Get(apiURL)
|
|
if err != nil {
|
|
// Fallback sur calcul local en cas d'erreur réseau
|
|
distance := CalculateDistance(from, to)
|
|
eta := CalculateETA(distance)
|
|
fmt.Printf("⚠️ TomTom timeout, fallback: %.2f km -> %d min\n", distance, eta)
|
|
return eta, distance, nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
// Fallback sur calcul local en cas d'erreur API
|
|
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
|
|
|
|
// Calculer ETA en minutes (arrondi supérieur)
|
|
etaMinutes := (summary.TravelTimeInSeconds + 59) / 60
|
|
distanceKm := float64(summary.LengthInMeters) / 1000.0
|
|
|
|
// Appliquer minimum
|
|
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
|
|
}
|