diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go
index a1be038e..80a80308 100644
--- a/backend/gestion/db/db_commands.go
+++ b/backend/gestion/db/db_commands.go
@@ -462,6 +462,36 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
return nil
}
+// UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA
+// PROPRE commande, tant qu'elle n'est pas encore prise en charge par un
+// livreur (statut "en_route") ni terminée. La vérification d'appartenance et
+// de statut se fait dans la clause WHERE, atomiquement : impossible de
+// modifier la commande d'un autre client ou une commande déjà en route.
+func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error {
+ if err := validateAddress(deliveryAddress); err != nil {
+ return err
+ }
+
+ result := d.GDB.Exec(`
+ UPDATE commandes
+ SET adresse = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`,
+ deliveryAddress, commandID, clientUsername)
+ if result.Error != nil {
+ return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
+ }
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("commande introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client")
+ }
+
+ d.AddCommandLog(commandID, "address_updated",
+ fmt.Sprintf("Adresse corrigée par le client %s", clientUsername),
+ clientUsername)
+
+ log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername)
+ return nil
+}
+
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
if err := validateAddress(proposedAddress); err != nil {
diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go
index e71ce7cc..62932761 100644
--- a/backend/gestion/handlers/commands.go
+++ b/backend/gestion/handlers/commands.go
@@ -263,6 +263,62 @@ func RespondToAddressProposal(c *gin.Context) {
})
}
+// UpdateOwnCommandAddress permet à un client de corriger l'adresse de sa
+// propre commande (ex: suite à un échec de géocodage bloquant l'assignation
+// auto). Refusé si la commande est déjà en_route ou terminée (voir requête
+// SQL dans db.UpdateOwnCommandAddress).
+func UpdateOwnCommandAddress(c *gin.Context) {
+ database := c.MustGet("database").(*db.Database)
+ geoService := c.MustGet("geoService").(*services.GeoService)
+
+ userRole := c.GetString("role")
+ if !utils.CheckRoleClient(c, userRole) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
+ return
+ }
+ clientUsername, err := safeGetUsername(c)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
+ return
+ }
+
+ rateLimitKey := fmt.Sprintf("update_own_addr:%s", clientUsername)
+ if !checkRateLimit(rateLimitKey) {
+ c.JSON(http.StatusTooManyRequests, gin.H{"error": "Trop de requêtes, réessayez plus tard"})
+ return
+ }
+
+ commandID, err := strconv.Atoi(c.Param("id"))
+ if err != nil || commandID <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
+ return
+ }
+
+ var req struct {
+ DeliveryAddress string `json:"delivery_address" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
+ return
+ }
+
+ if !geoService.IsValidAddress(req.DeliveryAddress) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe ou le code postal"})
+ return
+ }
+
+ if err := database.UpdateOwnCommandAddress(commandID, clientUsername, req.DeliveryAddress); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername)
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Adresse mise à jour",
+ })
+}
+
func ExportApprovedCommandsCSV(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
diff --git a/backend/gestion/handlers/validation_deleviry.go b/backend/gestion/handlers/validation_deleviry.go
index 9d8abfbb..a567f72b 100644
--- a/backend/gestion/handlers/validation_deleviry.go
+++ b/backend/gestion/handlers/validation_deleviry.go
@@ -1,8 +1,10 @@
package handlers
import (
+ "encoding/json"
"fmt"
"gestion/db"
+ "gestion/services"
"log"
"net/http"
"strconv"
@@ -30,6 +32,7 @@ const (
// POST /api/v1/deliveries/:id/start
func StartDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
+ geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
@@ -114,11 +117,47 @@ func StartDelivery(c *gin.Context) {
}
}
}
- if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 0 {
+ if etaMinutes == 0 {
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
+
+ // Fallback 1 : cache Redis (géocodage déjà fait à l'assignation
+ // mais pas encore persisté en DB — cf. goroutine async dans
+ // handlers/commands.go AssignCommandToDeliveryman).
+ if destLat == 0 || destLon == 0 {
+ destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
+ if destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result(); err == nil && destData != "" {
+ var coords struct {
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ }
+ if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
+ destLat, destLon = coords.Lat, coords.Lon
+ }
+ }
+ }
+
+ // Fallback 2 : géocodage synchrone de l'adresse. Couvre le cas où
+ // le livreur démarre la livraison avant que la goroutine async
+ // d'assignation ait fini de géocoder (race condition).
+ if (destLat == 0 || destLon == 0) && geoService != nil {
+ if adresse, _ := command["adresse"].(string); adresse != "" {
+ if location, err := geoService.GeocodeAddress(adresse); err == nil && location != nil {
+ destLat, destLon = location.Latitude, location.Longitude
+ database.GDB.Exec(
+ "UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
+ destLat, destLon, commandID,
+ )
+ }
+ }
+ }
+
if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
+ } else {
+ // Fallback 3 : aucune coordonnée exploitable — ETA par
+ // défaut plutôt que pas d'ETA du tout dans le message.
+ etaMinutes = 30
}
}
if etaMinutes > 0 {
diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go
index 9ca89c9b..2cb026ff 100644
--- a/backend/gestion/routes/routes.go
+++ b/backend/gestion/routes/routes.go
@@ -80,6 +80,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// Approbation livraison
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
+ cartGroupV1.PUT("/commands/:id/address", handlers.UpdateOwnCommandAddress)
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
diff --git a/backend/gestion/workers/cron_auto_assign.go b/backend/gestion/workers/cron_auto_assign.go
index 846afb06..c7afcb56 100644
--- a/backend/gestion/workers/cron_auto_assign.go
+++ b/backend/gestion/workers/cron_auto_assign.go
@@ -83,7 +83,8 @@ func processAutoAssignmentWithPriority(database *db.Database, geoService *servic
}
// Tenter l'assignation
- success := tryAssignCommandWithPriority(database, geoService, commandID, address, priority, int(waitingTime.Minutes()))
+ username, _ := cmd["username"].(string)
+ success := tryAssignCommandWithPriority(database, geoService, commandID, username, address, priority, int(waitingTime.Minutes()))
if success {
assignedCount++
} else {
@@ -103,6 +104,7 @@ func tryAssignCommandWithPriority(
database *db.Database,
geoService *services.GeoService,
commandID int,
+ username string,
address string,
priority int,
waitingMinutes int,
@@ -114,6 +116,7 @@ func tryAssignCommandWithPriority(
location, err := geoService.GeocodeAddress(address)
if err != nil {
log.Printf("❌ [CRON] Cmd %d - Géocodage échoué: %v", commandID, err)
+ notifyGeocodeFailure(database, username, commandID, address)
return false
}
@@ -198,3 +201,26 @@ func tryAssignCommandWithPriority(
return true
}
+
+// notifyGeocodeFailure avertit le client que l'adresse de sa commande n'a pas
+// pu être localisée, pour qu'il puisse la corriger. Le cron retente chaque
+// minute tant que la commande reste pending : un cooldown Redis d'une heure
+// évite de spammer le client à chaque cycle avec la même erreur.
+func notifyGeocodeFailure(database *db.Database, username string, commandID int, address string) {
+ if username == "" {
+ return
+ }
+ cooldownKey := fmt.Sprintf("notif:cooldown:geocode_fail:%d", commandID)
+ set, err := db.Redis.SetNX(db.RedisCtx, cooldownKey, "1", time.Hour).Result()
+ if err != nil || !set {
+ return
+ }
+ clientOrderID := database.GetClientOrderID(commandID)
+ msg := fmt.Sprintf(
+ "Ta commande #%d ne peut pas être assignée : l'adresse \"%s\" n'a pas été trouvée. Merci de vérifier et corriger l'adresse de livraison.",
+ clientOrderID, address,
+ )
+ if err := database.NotifyClient(username, commandID, "address_error", msg); err != nil {
+ log.Printf("⚠️ [CRON] Cmd %d - Erreur notification échec géocodage: %v", commandID, err)
+ }
+}
diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts
index 6845f306..d8ac5ea8 100644
--- a/frontend-prep/src/api/api.ts
+++ b/frontend-prep/src/api/api.ts
@@ -1440,6 +1440,55 @@ export const cancelCommand = async (
}
};
+// Le client corrige lui-même l'adresse de sa commande (refusé si déjà en
+// livraison ou terminée, cf. UpdateOwnCommandAddress côté backend).
+export const updateOwnCommandAddress = async (
+ commandId: number,
+ deliveryAddress: string,
+): Promise<{ success: boolean; message: string }> => {
+ const token = sessionStorage.getItem("token");
+
+ if (!token) {
+ return { success: false, message: "Session invalide" };
+ }
+
+ try {
+ const response = await fetch(
+ `${API_URL}/commands/${commandId}/address`,
+ {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ delivery_address: deliveryAddress }),
+ },
+ );
+
+ const data = await safeJson(response);
+
+ if (!response.ok) {
+ return {
+ success: false,
+ message: data.error || "Erreur lors de la mise à jour",
+ };
+ }
+
+ return {
+ success: true,
+ message: data.message || "Adresse mise à jour",
+ };
+ } catch (error) {
+ return {
+ success: false,
+ message:
+ error instanceof Error
+ ? error.message
+ : "Erreur lors de la mise à jour de l'adresse",
+ };
+ }
+};
+
/**
* ✅ GET MY CANCELLATION HISTORY - Historique des annulations
* GET /api/v1/my-cancellation-history
diff --git a/frontend-prep/src/pages/User/SuiviLivraison.css b/frontend-prep/src/pages/User/SuiviLivraison.css
index 3f0cfcd5..00a43039 100644
--- a/frontend-prep/src/pages/User/SuiviLivraison.css
+++ b/frontend-prep/src/pages/User/SuiviLivraison.css
@@ -489,6 +489,40 @@
font-weight: 600;
color: var(--text);
}
+.address-edit-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-top: 0.4rem;
+ background: none;
+ border: none;
+ color: var(--primary);
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ padding: 0;
+}
+.address-edit-toggle:hover {
+ text-decoration: underline;
+}
+.address-edit {
+ margin-top: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+.address-edit-input {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ font-size: 0.85rem;
+ background: var(--bg-secondary, #fff);
+ color: var(--text);
+}
+.address-edit-actions {
+ display: flex;
+ gap: 0.5rem;
+}
.contact {
color: var(--text-muted);
font-size: 0.85rem;
diff --git a/frontend-prep/src/pages/User/SuiviLivraison.tsx b/frontend-prep/src/pages/User/SuiviLivraison.tsx
index 8c46ba28..59fb8d54 100644
--- a/frontend-prep/src/pages/User/SuiviLivraison.tsx
+++ b/frontend-prep/src/pages/User/SuiviLivraison.tsx
@@ -14,6 +14,7 @@ import {
getOrderETA,
confirmReception,
cancelCommand,
+ updateOwnCommandAddress,
isUserAuthenticated,
getPublicSettings,
} from "../../api/api";
@@ -51,6 +52,7 @@ import {
faWind,
faChevronUp,
faChevronDown,
+ faPen,
} from "@fortawesome/free-solid-svg-icons";
interface OrderWithTracking extends OrderDetail {
@@ -282,6 +284,11 @@ function SuiviLivraison() {
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [orderToCancel, setOrderToCancel] = useState