fix: manage quantity && update geoloca
This commit is contained in:
@@ -287,20 +287,46 @@ func (d *Database) GetBasketItemCount(username string) (int, error) {
|
||||
return result.Count, nil
|
||||
}
|
||||
|
||||
// UpdateBasketItemQuantity met à jour la quantité d'un item du panier
|
||||
func (d *Database) UpdateBasketItemQuantity(basketID int, quantity float64) error {
|
||||
if quantity <= 0 {
|
||||
return fmt.Errorf("la quantité doit être supérieure à 0")
|
||||
func (d *Database) UpdateBasketItemQuantity(basketID int, newQuantity float64) error {
|
||||
if newQuantity <= 0 {
|
||||
return fmt.Errorf("quantité invalide")
|
||||
}
|
||||
result := d.GDB.Exec(`UPDATE baskets SET quantity = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
quantity, basketID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
return nil
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var item struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE id = ? FOR UPDATE`, basketID).Scan(&item).Error; err != nil {
|
||||
return fmt.Errorf("produit non trouvé: %w", err)
|
||||
}
|
||||
if item.ProductID == 0 {
|
||||
return fmt.Errorf("panier item introuvable: %d", basketID)
|
||||
}
|
||||
|
||||
diff := newQuantity - item.Quantity
|
||||
|
||||
if diff > 0 {
|
||||
result := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?`,
|
||||
diff, item.ProductID, diff)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur stock: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
} else if diff < 0 {
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock + ? WHERE id = ?`,
|
||||
-diff, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur stock: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`UPDATE baskets SET quantity = ? WHERE id = ?`,
|
||||
newQuantity, basketID).Error; err != nil {
|
||||
return fmt.Errorf("erreur panier: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ExtendBasketReservations prolonge les réservations
|
||||
|
||||
@@ -13,6 +13,7 @@ require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/redis/go-redis/v9 v9.17.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/text v0.27.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
@@ -55,7 +56,6 @@ require (
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// ============================================
|
||||
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
|
||||
// ============================================
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -20,6 +16,7 @@ import (
|
||||
// ============================================
|
||||
// GÉOCODAGE D'ADRESSES
|
||||
// ============================================
|
||||
// geo_handlers.go
|
||||
|
||||
func GeocodeAddress(c *gin.Context) {
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
@@ -34,19 +31,40 @@ func GeocodeAddress(c *gin.Context) {
|
||||
|
||||
location, err := geoService.GeocodeAddress(req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Impossible de géocoder cette adresse"})
|
||||
// Tentative de correction — resolveAddress ne touche pas à c.JSON
|
||||
suggestion, err := resolveAddress(geoService, req.Address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ Adresse corrigée: '%s' → '%s' (confiance %.0f%%)",
|
||||
req.Address, suggestion.CorrectedAddress, suggestion.Confidence*100)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": suggestion.Coordinates.Latitude,
|
||||
"longitude": suggestion.Coordinates.Longitude,
|
||||
"display_name": suggestion.CorrectedAddress,
|
||||
"correction_applied": suggestion.CorrectionApplied,
|
||||
"confidence": suggestion.Confidence,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
"success": true,
|
||||
"latitude": location.Latitude,
|
||||
"longitude": location.Longitude,
|
||||
"display_name": location.DisplayName,
|
||||
"correction_applied": false,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveAddress : logique pure, sans toucher à gin.Context
|
||||
func resolveAddress(geoService *services.GeoService, address string) (*services.AddressSuggestion, error) {
|
||||
return geoService.CorrectionService().ResolveAddress(address)
|
||||
}
|
||||
|
||||
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
|
||||
func FindNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
// ============================================
|
||||
// services/address_correction.go
|
||||
// Correction automatique des adresses mal orthographiées
|
||||
// Stratégie : Nominatim fuzzy → suggestions structurées → fallback
|
||||
// ============================================
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
// AddressSuggestion représente une suggestion de correction
|
||||
type AddressSuggestion struct {
|
||||
OriginalAddress string `json:"original_address"`
|
||||
CorrectedAddress string `json:"corrected_address"`
|
||||
Coordinates Coordinates `json:"coordinates"`
|
||||
Confidence float64 `json:"confidence"` // 0.0 à 1.0
|
||||
CorrectionApplied bool `json:"correction_applied"` // true si une correction a été faite
|
||||
Source string `json:"source"` // "exact", "fuzzy", "structured"
|
||||
}
|
||||
|
||||
// NominatimSuggestion représente une réponse de l'API Nominatim
|
||||
type NominatimSuggestion struct {
|
||||
Latitude float64 `json:"lat,string"`
|
||||
Longitude float64 `json:"lon,string"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Importance float64 `json:"importance"`
|
||||
Type string `json:"type"`
|
||||
Class string `json:"class"`
|
||||
Address struct {
|
||||
HouseNumber string `json:"house_number"`
|
||||
Road string `json:"road"`
|
||||
City string `json:"city"`
|
||||
Town string `json:"town"`
|
||||
Village string `json:"village"`
|
||||
Postcode string `json:"postcode"`
|
||||
Country string `json:"country"`
|
||||
CountryCode string `json:"country_code"`
|
||||
} `json:"address"`
|
||||
}
|
||||
|
||||
// AddressCorrectionService gère la correction des adresses
|
||||
type AddressCorrectionService struct {
|
||||
httpClient *http.Client
|
||||
geoService *GeoService
|
||||
}
|
||||
|
||||
// NewAddressCorrectionService crée une instance du service de correction
|
||||
func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionService {
|
||||
return &AddressCorrectionService{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
geoService: geoService,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// POINT D'ENTRÉE PRINCIPAL
|
||||
// ============================================
|
||||
|
||||
// ResolveAddress tente de géocoder une adresse avec correction automatique.
|
||||
// Retourne toujours une suggestion, même approximative.
|
||||
// Ordre de résolution :
|
||||
// 1. Géocodage exact → succès immédiat
|
||||
// 2. Nominatim fuzzy search (addressdetails + limit=5)
|
||||
// 3. Décomposition structurée de l'adresse
|
||||
// 4. Erreur explicite avec suggestions si dispo
|
||||
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
|
||||
rawAddress = strings.TrimSpace(rawAddress)
|
||||
if rawAddress == "" {
|
||||
return nil, fmt.Errorf("adresse vide")
|
||||
}
|
||||
|
||||
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
|
||||
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: rawAddress,
|
||||
CorrectedAddress: rawAddress,
|
||||
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
|
||||
Confidence: 1.0,
|
||||
CorrectionApplied: false,
|
||||
Source: "exact",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Étape 2 : fuzzy search Nominatim ──
|
||||
if suggestion, err := acs.nominatimFuzzySearch(rawAddress); err == nil {
|
||||
return suggestion, nil
|
||||
}
|
||||
|
||||
// ── Étape 3 : décomposition structurée ──
|
||||
if suggestion, err := acs.structuredSearch(rawAddress); err == nil {
|
||||
return suggestion, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
|
||||
// ============================================
|
||||
|
||||
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
|
||||
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
|
||||
variants := buildAddressVariants(address)
|
||||
|
||||
for _, variant := range variants {
|
||||
suggestions, err := acs.queryNominatim(variant, 5)
|
||||
if err != nil || len(suggestions) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
best := suggestions[0]
|
||||
confidence := computeConfidence(address, best.DisplayName, best.Importance)
|
||||
|
||||
// On accepte si la confiance est suffisante
|
||||
if confidence >= 0.40 {
|
||||
corrected := formatNominatimAddress(best)
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: address,
|
||||
CorrectedAddress: corrected,
|
||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||
Confidence: confidence,
|
||||
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
|
||||
Source: "fuzzy",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
|
||||
}
|
||||
|
||||
// queryNominatim exécute une requête vers l'API Nominatim
|
||||
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("requête vide")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("format", "json")
|
||||
params.Set("addressdetails", "1")
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
params.Set("accept-language", "fr")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", NominatimBaseURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "DeliveryApp/1.0 (address-correction)")
|
||||
|
||||
// Respect du rate-limit Nominatim : 1 req/s
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
resp, err := acs.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []NominatimSuggestion
|
||||
if err := json.Unmarshal(body, &results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 3 : RECHERCHE STRUCTURÉE
|
||||
// ============================================
|
||||
|
||||
// structuredSearch décompose l'adresse et cherche les parties clés
|
||||
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
|
||||
parts := parseAddressParts(address)
|
||||
|
||||
// Essai 1 : numéro + rue + ville (sans code postal)
|
||||
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
|
||||
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
|
||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||
s.OriginalAddress = address
|
||||
s.Source = "structured"
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Essai 2 : rue + code postal uniquement
|
||||
if parts.streetName != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
|
||||
if s, err := acs.nominatimFuzzySearch(q); err == nil {
|
||||
s.OriginalAddress = address
|
||||
s.Source = "structured"
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Essai 3 : ville + code postal comme zone de repli
|
||||
if parts.city != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
|
||||
suggestions, err := acs.queryNominatim(q, 3)
|
||||
if err == nil && len(suggestions) > 0 {
|
||||
best := suggestions[0]
|
||||
return &AddressSuggestion{
|
||||
OriginalAddress: address,
|
||||
CorrectedAddress: best.DisplayName,
|
||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||
Confidence: 0.30, // faible : seulement ville/CP trouvés
|
||||
CorrectionApplied: true,
|
||||
Source: "structured_partial",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("recherche structurée échouée")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIANTES D'ADRESSE
|
||||
// ============================================
|
||||
|
||||
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
|
||||
func buildAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
normalized := normalize(address)
|
||||
|
||||
// Variante sans accents
|
||||
if normalized != address {
|
||||
variants = append(variants, normalized)
|
||||
}
|
||||
|
||||
// Variante avec "France" si absent
|
||||
if !strings.Contains(strings.ToLower(address), "france") {
|
||||
variants = append(variants, address+", France")
|
||||
}
|
||||
|
||||
// Variante en corrigeant les abréviations courantes françaises
|
||||
expanded := expandFrenchAbbreviations(address)
|
||||
if expanded != address {
|
||||
variants = append(variants, expanded)
|
||||
variants = append(variants, expanded+", France")
|
||||
}
|
||||
|
||||
// Variante en supprimant les mots de liaison potentiellement mal orthographiés
|
||||
simplified := simplifyStreetName(address)
|
||||
if simplified != address {
|
||||
variants = append(variants, simplified)
|
||||
}
|
||||
|
||||
// Dédoublonnage tout en conservant l'ordre
|
||||
seen := map[string]bool{}
|
||||
unique := make([]string, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
unique = append(unique, v)
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
// expandFrenchAbbreviations remplace les abréviations courantes
|
||||
func expandFrenchAbbreviations(address string) string {
|
||||
replacements := []struct{ from, to string }{
|
||||
{"Av.", "Avenue"},
|
||||
{"Ave.", "Avenue"},
|
||||
{"Bd.", "Boulevard"},
|
||||
{"Bld.", "Boulevard"},
|
||||
{"Blvd.", "Boulevard"},
|
||||
{"Rte.", "Route"},
|
||||
{"Rte ", "Route "},
|
||||
{"Imp.", "Impasse"},
|
||||
{"Cité", "Cité"},
|
||||
{"Sq.", "Square"},
|
||||
{"Pl.", "Place"},
|
||||
{"Rés.", "Résidence"},
|
||||
}
|
||||
|
||||
result := address
|
||||
for _, r := range replacements {
|
||||
result = strings.ReplaceAll(result, r.from, r.to)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// simplifyStreetName essaie de nettoyer la rue (retire les particules ambiguës)
|
||||
func simplifyStreetName(address string) string {
|
||||
// Ex: "20 Rue Gabriel le Pan de Ligny" → essai sans "le" → "20 Rue Gabriel Pan de Ligny"
|
||||
// Heuristique légère : on ne modifie que si la chaîne est suffisamment longue
|
||||
words := strings.Fields(address)
|
||||
if len(words) < 5 {
|
||||
return address
|
||||
}
|
||||
|
||||
// Retire les articles intégrés dans le nom de rue (heuristique)
|
||||
articles := map[string]bool{"le": true, "la": true, "les": true, "de": true, "du": true, "des": true, "d": true}
|
||||
filtered := make([]string, 0, len(words))
|
||||
for i, w := range words {
|
||||
lower := strings.ToLower(w)
|
||||
// Garder le premier mot (numéro) et les mots non-articles, ou les articles en début de nom de rue
|
||||
if i < 2 || !articles[lower] {
|
||||
filtered = append(filtered, w)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Join(filtered, " ")
|
||||
if result == address {
|
||||
return address
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES
|
||||
// ============================================
|
||||
|
||||
// addressParts regroupe les composants décomposés d'une adresse
|
||||
type addressParts struct {
|
||||
streetNumber string
|
||||
streetName string
|
||||
postcode string
|
||||
city string
|
||||
}
|
||||
|
||||
// parseAddressParts analyse une adresse libre pour en extraire les composants
|
||||
func parseAddressParts(address string) addressParts {
|
||||
var parts addressParts
|
||||
|
||||
// Extraction du code postal (5 chiffres consécutifs)
|
||||
words := strings.Fields(address)
|
||||
remaining := make([]string, 0, len(words))
|
||||
|
||||
for _, w := range words {
|
||||
if isPostcode(w) {
|
||||
parts.postcode = w
|
||||
} else {
|
||||
remaining = append(remaining, w)
|
||||
}
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
return parts
|
||||
}
|
||||
|
||||
// Premier mot numérique → numéro de rue
|
||||
if isNumeric(remaining[0]) {
|
||||
parts.streetNumber = remaining[0]
|
||||
remaining = remaining[1:]
|
||||
}
|
||||
|
||||
// Détection de la ville : dernier groupe après le code postal
|
||||
// Heuristique : si le dernier mot est une ville connue ou commence par une maj
|
||||
if len(remaining) > 0 {
|
||||
last := remaining[len(remaining)-1]
|
||||
if len(last) > 2 && last[0] >= 'A' && last[0] <= 'Z' {
|
||||
parts.city = last
|
||||
remaining = remaining[:len(remaining)-1]
|
||||
}
|
||||
}
|
||||
|
||||
parts.streetName = strings.Join(remaining, " ")
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
|
||||
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
|
||||
origNorm := normalize(strings.ToLower(original))
|
||||
suggNorm := normalize(strings.ToLower(suggested))
|
||||
|
||||
// Score de similarité sur les mots communs
|
||||
origWords := strings.Fields(origNorm)
|
||||
suggWords := strings.Fields(suggNorm)
|
||||
|
||||
commonCount := 0
|
||||
for _, ow := range origWords {
|
||||
if len(ow) < 3 {
|
||||
continue // ignorer les petits mots
|
||||
}
|
||||
for _, sw := range suggWords {
|
||||
if strings.Contains(sw, ow) || strings.Contains(ow, sw) || levenshteinRatio(ow, sw) > 0.75 {
|
||||
commonCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var wordScore float64
|
||||
if len(origWords) > 0 {
|
||||
wordScore = float64(commonCount) / float64(len(origWords))
|
||||
}
|
||||
|
||||
// Combinaison : 70% similarité textuelle + 30% importance Nominatim
|
||||
importance := math.Min(nominatimImportance, 1.0)
|
||||
return wordScore*0.70 + importance*0.30
|
||||
}
|
||||
|
||||
// formatNominatimAddress formate l'adresse complète depuis une suggestion Nominatim
|
||||
func formatNominatimAddress(s NominatimSuggestion) string {
|
||||
addr := s.Address
|
||||
var parts []string
|
||||
|
||||
if addr.HouseNumber != "" && addr.Road != "" {
|
||||
parts = append(parts, addr.HouseNumber+" "+addr.Road)
|
||||
} else if addr.Road != "" {
|
||||
parts = append(parts, addr.Road)
|
||||
}
|
||||
|
||||
city := addr.City
|
||||
if city == "" {
|
||||
city = addr.Town
|
||||
}
|
||||
if city == "" {
|
||||
city = addr.Village
|
||||
}
|
||||
|
||||
if addr.Postcode != "" {
|
||||
parts = append(parts, addr.Postcode)
|
||||
}
|
||||
if city != "" {
|
||||
parts = append(parts, city)
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return s.DisplayName
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// normalize supprime les accents et normalise les espaces
|
||||
func normalize(s string) string {
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
result, _, _ := transform.String(t, s)
|
||||
return strings.Join(strings.Fields(result), " ")
|
||||
}
|
||||
|
||||
// isPostcode retourne true si le mot ressemble à un code postal français
|
||||
func isPostcode(s string) bool {
|
||||
if len(s) != 5 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isNumeric retourne true si la chaîne est entièrement numérique
|
||||
func isNumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
// levenshteinRatio retourne un ratio de similarité entre 0 et 1
|
||||
func levenshteinRatio(a, b string) float64 {
|
||||
d := levenshtein(a, b)
|
||||
maxLen := math.Max(float64(len(a)), float64(len(b)))
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
return 1.0 - float64(d)/maxLen
|
||||
}
|
||||
|
||||
// levenshtein calcule la distance de Levenshtein entre deux chaînes
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
la, lb := len(ra), len(rb)
|
||||
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
|
||||
dp := make([][]int, la+1)
|
||||
for i := range dp {
|
||||
dp[i] = make([]int, lb+1)
|
||||
dp[i][0] = i
|
||||
}
|
||||
for j := 0; j <= lb; j++ {
|
||||
dp[0][j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= la; i++ {
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
dp[i][j] = min3(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+cost)
|
||||
}
|
||||
}
|
||||
return dp[la][lb]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -47,35 +48,68 @@ type DeliveryDistance struct {
|
||||
}
|
||||
|
||||
type GeoService struct {
|
||||
redis *redis.Client
|
||||
ctx context.Context
|
||||
httpClient *http.Client
|
||||
redis *redis.Client
|
||||
ctx context.Context
|
||||
httpClient *http.Client
|
||||
correctionService *AddressCorrectionService
|
||||
}
|
||||
|
||||
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
|
||||
return &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. Vérifier le cache Redis
|
||||
location, err := gs.getFromCache(address)
|
||||
if err == nil {
|
||||
// 1. Cache Redis (adresse originale)
|
||||
if location, err := gs.getFromCache(address); err == nil {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
location, err = gs.fetchFromNominatim(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 2. Tentative directe via Nominatim
|
||||
if location, err := gs.fetchFromNominatim(address); err == nil {
|
||||
gs.saveToCache(address, location)
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 3. Sauvegarder en cache
|
||||
// 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
|
||||
}
|
||||
@@ -491,13 +525,13 @@ func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsern
|
||||
// ============================================
|
||||
|
||||
// GetDeliveryHeatmap retourne toutes les positions des livreurs
|
||||
func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
||||
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]interface{}
|
||||
var heatmap []map[string]any
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := gs.redis.Get(gs.ctx, key).Result()
|
||||
@@ -505,7 +539,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
var location map[string]interface{}
|
||||
var location map[string]any
|
||||
json.Unmarshal([]byte(data), &location)
|
||||
|
||||
username := key[len("delivery:location:"):]
|
||||
@@ -516,3 +550,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
||||
|
||||
return heatmap, nil
|
||||
}
|
||||
|
||||
func (gs *GeoService) CorrectionService() *AddressCorrectionService {
|
||||
return gs.correctionService
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user