fix: manage quantity && update geoloca
This commit is contained in:
@@ -362,6 +362,7 @@ sequenceDiagram
|
|||||||
participant API as Backend API
|
participant API as Backend API
|
||||||
participant DB as PostgreSQL
|
participant DB as PostgreSQL
|
||||||
participant R as Redis
|
participant R as Redis
|
||||||
|
participant TG as Telegram
|
||||||
|
|
||||||
rect rgb(200, 230, 200)
|
rect rgb(200, 230, 200)
|
||||||
Note over U,R: Registration
|
Note over U,R: Registration
|
||||||
@@ -374,15 +375,36 @@ sequenceDiagram
|
|||||||
end
|
end
|
||||||
|
|
||||||
rect rgb(200, 200, 230)
|
rect rgb(200, 200, 230)
|
||||||
Note over U,R: Login
|
Note over U,TG: Login Standard (2FA desactivee)
|
||||||
U->>F: Entrer credentials
|
U->>F: Entrer credentials
|
||||||
F->>API: POST /auth/login
|
F->>API: POST /auth/login
|
||||||
API->>DB: Verifier credentials
|
API->>DB: Verifier credentials
|
||||||
DB-->>API: User valide
|
DB-->>API: User valide
|
||||||
API->>API: Generer JWT
|
API->>API: Generer JWT
|
||||||
API->>R: Stocker session
|
API->>R: Stocker session
|
||||||
API-->>F: 200 OK + JWT Token
|
API-->>F: 200 OK + { access_token, user }
|
||||||
F->>F: Stocker token (localStorage)
|
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
|
F-->>U: Connecte
|
||||||
end
|
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):**
|
**Réponse (200 OK):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -881,8 +948,73 @@ Content-Type: application/json
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Erreurs possibles:**
|
**Erreurs possibles:**
|
||||||
- `400` - Données manquantes
|
- `400` - `session_token` ou `code` manquant
|
||||||
- `401` - Identifiants incorrects
|
- `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 <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 <token>
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -287,20 +287,46 @@ func (d *Database) GetBasketItemCount(username string) (int, error) {
|
|||||||
return result.Count, nil
|
return result.Count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateBasketItemQuantity met à jour la quantité d'un item du panier
|
func (d *Database) UpdateBasketItemQuantity(basketID int, newQuantity float64) error {
|
||||||
func (d *Database) UpdateBasketItemQuantity(basketID int, quantity float64) error {
|
if newQuantity <= 0 {
|
||||||
if quantity <= 0 {
|
return fmt.Errorf("quantité invalide")
|
||||||
return fmt.Errorf("la quantité doit être supérieure à 0")
|
|
||||||
}
|
}
|
||||||
result := d.GDB.Exec(`UPDATE baskets SET quantity = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||||
quantity, basketID)
|
var item struct {
|
||||||
if result.Error != nil {
|
ProductID int `gorm:"column:product_id"`
|
||||||
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", result.Error)
|
Quantity float64 `gorm:"column:quantity"`
|
||||||
}
|
}
|
||||||
if result.RowsAffected == 0 {
|
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é dans le panier")
|
return fmt.Errorf("produit non trouvé: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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
|
// ExtendBasketReservations prolonge les réservations
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/redis/go-redis/v9 v9.17.0
|
github.com/redis/go-redis/v9 v9.17.0
|
||||||
golang.org/x/crypto v0.40.0
|
golang.org/x/crypto v0.40.0
|
||||||
|
golang.org/x/text v0.27.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@@ -55,7 +56,6 @@ require (
|
|||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.35.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
|
golang.org/x/tools v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
// ============================================
|
|
||||||
// handlers/geo_handlers.go - VERSION CORRIGÉE COMPLÈTE
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -20,6 +16,7 @@ import (
|
|||||||
// ============================================
|
// ============================================
|
||||||
// GÉOCODAGE D'ADRESSES
|
// GÉOCODAGE D'ADRESSES
|
||||||
// ============================================
|
// ============================================
|
||||||
|
// geo_handlers.go
|
||||||
|
|
||||||
func GeocodeAddress(c *gin.Context) {
|
func GeocodeAddress(c *gin.Context) {
|
||||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||||
@@ -34,19 +31,40 @@ func GeocodeAddress(c *gin.Context) {
|
|||||||
|
|
||||||
location, err := geoService.GeocodeAddress(req.Address)
|
location, err := geoService.GeocodeAddress(req.Address)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
log.Printf("📍 Adresse géocodée: %s -> (%.6f, %.6f)", req.Address, location.Latitude, location.Longitude)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"latitude": location.Latitude,
|
"latitude": location.Latitude,
|
||||||
"longitude": location.Longitude,
|
"longitude": location.Longitude,
|
||||||
"display_name": location.DisplayName,
|
"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
|
// FindNearestDeliveryPerson trouve le livreur le plus proche d'une adresse
|
||||||
func FindNearestDeliveryPerson(c *gin.Context) {
|
func FindNearestDeliveryPerson(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
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"
|
"fmt"
|
||||||
"gestion/models"
|
"gestion/models"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -47,35 +48,68 @@ type DeliveryDistance struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GeoService struct {
|
type GeoService struct {
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
correctionService *AddressCorrectionService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
|
func NewGeoService(redisClient *redis.Client, ctx context.Context) *GeoService {
|
||||||
return &GeoService{
|
gs := &GeoService{
|
||||||
redis: redisClient,
|
redis: redisClient,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
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) {
|
func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
|
||||||
// 1. Vérifier le cache Redis
|
// 1. Cache Redis (adresse originale)
|
||||||
location, err := gs.getFromCache(address)
|
if location, err := gs.getFromCache(address); err == nil {
|
||||||
if err == nil {
|
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
location, err = gs.fetchFromNominatim(address)
|
// 2. Tentative directe via Nominatim
|
||||||
if err != nil {
|
if location, err := gs.fetchFromNominatim(address); err == nil {
|
||||||
return nil, err
|
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)
|
gs.saveToCache(address, location)
|
||||||
|
// Mettre en cache aussi avec l'adresse corrigée
|
||||||
|
if suggestion.CorrectionApplied {
|
||||||
|
gs.saveToCache(suggestion.CorrectedAddress, location)
|
||||||
|
}
|
||||||
|
|
||||||
return location, nil
|
return location, nil
|
||||||
}
|
}
|
||||||
@@ -491,13 +525,13 @@ func (gs *GeoService) GetAllDeliveryDistances(target Coordinates, availableUsern
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// GetDeliveryHeatmap retourne toutes les positions des livreurs
|
// 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()
|
keys, err := gs.redis.Keys(gs.ctx, "delivery:location:*").Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var heatmap []map[string]interface{}
|
var heatmap []map[string]any
|
||||||
|
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
data, err := gs.redis.Get(gs.ctx, key).Result()
|
data, err := gs.redis.Get(gs.ctx, key).Result()
|
||||||
@@ -505,7 +539,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var location map[string]interface{}
|
var location map[string]any
|
||||||
json.Unmarshal([]byte(data), &location)
|
json.Unmarshal([]byte(data), &location)
|
||||||
|
|
||||||
username := key[len("delivery:location:"):]
|
username := key[len("delivery:location:"):]
|
||||||
@@ -516,3 +550,7 @@ func (gs *GeoService) GetDeliveryHeatmap() ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
return heatmap, nil
|
return heatmap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (gs *GeoService) CorrectionService() *AddressCorrectionService {
|
||||||
|
return gs.correctionService
|
||||||
|
}
|
||||||
|
|||||||
@@ -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é]
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user