chore: add ansible backend docker frontend-prep
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// CONSTANTES
|
||||
// ============================================
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES
|
||||
// ============================================
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONSTRUCTEUR
|
||||
// ============================================
|
||||
|
||||
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
|
||||
return &GeoService{
|
||||
redis: redisClient,
|
||||
ctx: ctx,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GÉOCODAGE - API NOMINATIM
|
||||
// ============================================
|
||||
|
||||
// GeocodeAddress convertit une adresse en coordonnées GPS
|
||||
func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
|
||||
// 1. Vérifier le cache Redis
|
||||
location, err := gs.getFromCache(address)
|
||||
if err == nil {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 2. Appeler l'API Nominatim
|
||||
location, err = gs.fetchFromNominatim(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Sauvegarder en cache
|
||||
gs.saveToCache(address, 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())
|
||||
fmt.Println("URL Nominatim:", fullURL) // debug
|
||||
|
||||
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]interface{}, error) {
|
||||
keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var heatmap []map[string]interface{}
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := gs.redis.Get(gs.ctx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var location map[string]interface{}
|
||||
json.Unmarshal([]byte(data), &location)
|
||||
|
||||
username := key[len("delivery:location:"):]
|
||||
location["username"] = username
|
||||
|
||||
heatmap = append(heatmap, location)
|
||||
}
|
||||
|
||||
return heatmap, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// ============================================
|
||||
// services/tomtom.go - SERVICE TOMTOM ROUTING
|
||||
// ============================================
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetETAWithTraffic calcule l'ETA avec le trafic réel via TomTom
|
||||
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
|
||||
apiKey := os.Getenv("TOMTOM_API_KEY")
|
||||
if apiKey == "" {
|
||||
return 0, 0, fmt.Errorf("TOMTOM_API_KEY non configurée")
|
||||
}
|
||||
|
||||
// API TomTom Routing: Calculate Route avec trafic
|
||||
url := 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: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur requête TomTom: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return 0, 0, fmt.Errorf("erreur API TomTom %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur lecture: %w", err)
|
||||
}
|
||||
|
||||
var routeResponse models.RouteResponse
|
||||
if err := json.Unmarshal(body, &routeResponse); err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur parsing: %w", err)
|
||||
}
|
||||
|
||||
if len(routeResponse.Routes) == 0 {
|
||||
return 0, 0, fmt.Errorf("aucun itinéraire trouvé")
|
||||
}
|
||||
|
||||
summary := routeResponse.Routes[0].Summary
|
||||
|
||||
// Calculer ETA en minutes (arrondi supérieur)
|
||||
etaMinutes = (summary.TravelTimeInSeconds + 59) / 60
|
||||
distanceKm = float64(summary.LengthInMeters) / 1000.0
|
||||
|
||||
log.Printf("🛣️ TomTom Routing: %.2f km → %d min (trafic inclus)", distanceKm, etaMinutes)
|
||||
|
||||
return etaMinutes, distanceKm, nil
|
||||
}
|
||||
Reference in New Issue
Block a user