Files
projet_gestion_commande/backend/gestion/services/geo_services.go
T
Nuxgrid 4de42cff57
Backend - Build & Lint / build (push) Failing after 28m23s
chore: build
2026-07-12 15:20:53 +02:00

547 lines
15 KiB
Go

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
}