From 9a2f0d91adba841e690713b9cbea2e610b6671b7 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Tue, 12 May 2026 19:35:02 +0200 Subject: [PATCH] fix: manage quantity && update geoloca --- README.md | 142 ++++- backend/gestion/db/db_basket.go | 52 +- backend/gestion/go.mod | 2 +- backend/gestion/handlers/geoloca.go | 36 +- .../gestion/services/adresses_correction.go | 542 ++++++++++++++++++ backend/gestion/services/geo_services.go | 66 ++- scripts/t.md | 17 + 7 files changed, 815 insertions(+), 42 deletions(-) create mode 100644 backend/gestion/services/adresses_correction.go create mode 100644 scripts/t.md diff --git a/README.md b/README.md index 5e1ede5c..772fc794 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,7 @@ sequenceDiagram participant API as Backend API participant DB as PostgreSQL participant R as Redis + participant TG as Telegram rect rgb(200, 230, 200) Note over U,R: Registration @@ -374,15 +375,36 @@ sequenceDiagram end rect rgb(200, 200, 230) - Note over U,R: Login + Note over U,TG: Login Standard (2FA desactivee) U->>F: Entrer credentials F->>API: POST /auth/login API->>DB: Verifier credentials DB-->>API: User valide API->>API: Generer JWT API->>R: Stocker session - API-->>F: 200 OK + JWT Token - F->>F: Stocker token (localStorage) + API-->>F: 200 OK + { access_token, user } + F->>F: Stocker token + F-->>U: Connecte + end + + rect rgb(255, 240, 200) + Note over U,TG: Login avec 2FA (Telegram active) + U->>F: Entrer credentials + F->>API: POST /auth/login + API->>DB: Verifier credentials + verifier 2FA active + DB-->>API: User valide, 2FA requise + API->>R: Stocker session_token (TTL 5min) + API->>TG: Envoyer code 6 chiffres via bot Telegram + API-->>F: 200 OK + { requires_2fa: true, session_token } + F-->>U: Afficher saisie du code Telegram + U->>F: Entrer code recu sur Telegram + F->>API: POST /auth/2fa/verify { session_token, code } + API->>R: Verifier code + session_token + R-->>API: Code valide + API->>API: Generer JWT + API->>R: Stocker session + API-->>F: 200 OK + { access_token, user } + F->>F: Stocker token F-->>U: Connecte end @@ -867,6 +889,51 @@ Content-Type: application/json } ``` +**Réponse standard (200 OK) — 2FA désactivée:** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "expires_in": 18000, + "user": { + "username": "jean_dupont", + "role": "client" + } +} +``` + +**Réponse avec 2FA (200 OK) — quand 2FA activée par le client ET Telegram lié ET admin l'a activée:** +```json +{ + "requires_2fa": true, + "session_token": "550e8400-e29b-41d4-a716-446655440000" +} +``` +> Un code à 6 chiffres est envoyé automatiquement sur le compte Telegram lié. Le `session_token` expire dans **5 minutes**. + +**Erreurs possibles:** +- `400` - Données manquantes +- `401` - Identifiants incorrects + +--- + +### Vérifier le code 2FA + +**Valider le code Telegram reçu pour finaliser la connexion** + +```bash +POST /api/v1/auth/2fa/verify +Content-Type: application/json +``` + +**Requête:** +```json +{ + "session_token": "550e8400-e29b-41d4-a716-446655440000", + "code": "483721" +} +``` + **Réponse (200 OK):** ```json { @@ -881,8 +948,73 @@ Content-Type: application/json ``` **Erreurs possibles:** -- `400` - Données manquantes -- `401` - Identifiants incorrects +- `400` - `session_token` ou `code` manquant +- `401` - Code incorrect ou session expirée (TTL 5 min) +- `429` - Trop de tentatives (rate limiting) + +--- + +### Statut 2FA du compte + +**Obtenir l'état de la 2FA pour le compte connecté** + +```bash +GET /api/v1/two-fa/status +Authorization: Bearer +``` + +**Réponse (200 OK):** +```json +{ + "two_fa_enabled": true, + "telegram_linked": true, + "admin_2fa_enabled": true +} +``` + +| Champ | Description | +|-------|-------------| +| `two_fa_enabled` | La 2FA est activée sur ce compte client | +| `telegram_linked` | Un compte Telegram est lié (prérequis pour activer) | +| `admin_2fa_enabled` | L'admin a activé la 2FA dans les paramètres globaux | + +--- + +### Activer / Désactiver la 2FA + +**Basculer l'état de la 2FA sur son propre compte** + +```bash +POST /api/v1/two-fa/toggle +Authorization: Bearer +Content-Type: application/json +``` + +**Requête:** +```json +{ + "enabled": true +} +``` + +**Réponse (200 OK):** +```json +{ + "success": true, + "two_fa_enabled": true +} +``` + +**Prérequis pour activer (`"enabled": true`):** +1. Le client doit avoir lié son compte Telegram (via `/api/v1/auth/link-telegram`) +2. L'administrateur doit avoir activé `telegram_2fa_enabled` dans les paramètres globaux + +**Erreurs possibles:** +- `400` - Telegram non lié (impossible d'activer sans compte Telegram) +- `403` - La 2FA n'est pas autorisée par l'administrateur +- `401` - Non authentifié + +> **Interface utilisateur:** Sur le frontend web (page profil) et l'app mobile, un toggle permet d'activer/désactiver la 2FA directement depuis les paramètres du compte. --- diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index a970a73e..889af64b 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -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 diff --git a/backend/gestion/go.mod b/backend/gestion/go.mod index 3f409a19..79eef9b8 100644 --- a/backend/gestion/go.mod +++ b/backend/gestion/go.mod @@ -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 ) diff --git a/backend/gestion/handlers/geoloca.go b/backend/gestion/handlers/geoloca.go index 8423bff7..b79ad92e 100644 --- a/backend/gestion/handlers/geoloca.go +++ b/backend/gestion/handlers/geoloca.go @@ -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) diff --git a/backend/gestion/services/adresses_correction.go b/backend/gestion/services/adresses_correction.go new file mode 100644 index 00000000..8a11aaf5 --- /dev/null +++ b/backend/gestion/services/adresses_correction.go @@ -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 +} diff --git a/backend/gestion/services/geo_services.go b/backend/gestion/services/geo_services.go index e28f3854..22d1e028 100644 --- a/backend/gestion/services/geo_services.go +++ b/backend/gestion/services/geo_services.go @@ -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 +} diff --git a/scripts/t.md b/scripts/t.md new file mode 100644 index 00000000..ada54f0e --- /dev/null +++ b/scripts/t.md @@ -0,0 +1,17 @@ +```mermaid +graph TD + A[Client ajoute au panier] -->|Décrémente stock| B[Stock -= quantité] + B --> C[Ajout au panier] + C -->|❌ Si échec| D[Stock déjà décrémenté!] + + E[Client supprime du panier] -->|Transaction DB| F[Stock += quantité] + F --> G[Suppression du panier] + + H[Client valide commande] --> I[Panier vidé] + I -->|Sans restaurer stock| J[Commande créée] + + K[Client annule commande] -->|Transaction DB| L[Stock += quantité] + L --> M[Commande annulée] + + N[Paiement crypto échoue] -->|Transaction DB| O[Stock += quantité] +```