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(null); const [cancelReason, setCancelReason] = useState(""); + const [editingAddressOrder, setEditingAddressOrder] = useState< + number | null + >(null); + const [newAddressValue, setNewAddressValue] = useState(""); + const [savingAddress, setSavingAddress] = useState(false); const [showPenaltyWarning, setShowPenaltyWarning] = useState(false); const [penaltyWarningData, setPenaltyWarningData] = useState(null); @@ -383,6 +390,28 @@ function SuiviLivraison() { } }; + const handleUpdateAddress = async (orderId: number) => { + setSavingAddress(true); + try { + const res = await updateOwnCommandAddress( + orderId, + newAddressValue, + ); + if (res.success) { + showToast(res.message || "Adresse mise à jour", "success"); + setEditingAddressOrder(null); + setNewAddressValue(""); + loadOrders(); + } else { + showToast(res.message || "Erreur", "error"); + } + } catch { + showToast("Erreur mise à jour adresse", "error"); + } finally { + setSavingAddress(false); + } + }; + const showToast = ( message: string, type: "success" | "error" | "warning" | "info", @@ -889,6 +918,88 @@ function SuiviLivraison() { order, )}

+ {(statusLow === + "pending" || + statusLow === + "assigned") && + (editingAddressOrder === + order.id ? ( +
+ + setNewAddressValue( + e + .target + .value, + ) + } + placeholder="Ex: 24 Rue Docteur Brindeau, 44000 Nantes" + /> +
+ + +
+
+ ) : ( + + ))} {(() => { const info = getClientInfo(