chore: add corrige adresse

This commit is contained in:
2026-03-06 19:29:49 +01:00
parent 3c2083c393
commit 81907b04aa
12 changed files with 598 additions and 31 deletions
+45
View File
@@ -407,6 +407,51 @@ func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]inte
return items, nil return items, nil
} }
// ============================================
// DELETE COMMAND ITEM - ADMIN ONLY
// ============================================
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
if err := validateCommandID(commandID); err != nil {
return err
}
if err := validateItemID(itemID); err != nil {
return err
}
// Récupérer le prix et la quantité avant suppression pour mettre à jour le total
var prix, quantite float64
checkQuery := `SELECT prix, quantite FROM command_items WHERE id = $1 AND command_id = $2`
err := d.QueryRow(checkQuery, itemID, commandID).Scan(&prix, &quantite)
if err == sql.ErrNoRows {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
if err != nil {
return fmt.Errorf("erreur vérification item: %w", err)
}
// Supprimer l'item
_, err = d.Exec(`DELETE FROM command_items WHERE id = $1`, itemID)
if err != nil {
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
// Recalculer le total de la commande
_, err = d.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - $1) WHERE id = $2`,
prix*quantite, commandID,
)
if err != nil {
log.Printf("⚠️ [DeleteCommandItem] Erreur maj total commande: %v", err)
}
log.Printf("✅ [DeleteCommandItem] Item %d supprimé de la commande %d", itemID, commandID)
return nil
}
// ============================================ // ============================================
// UPDATE COMMAND ITEM STATUS - VERSION SÉCURISÉE // UPDATE COMMAND ITEM STATUS - VERSION SÉCURISÉE
// ============================================ // ============================================
+110 -21
View File
@@ -48,6 +48,7 @@ func validateCommandStatus(status string) error {
"pending": true, "pending": true,
"assigned": true, "assigned": true,
"en_route": true, "en_route": true,
"arrived": true,
"livre": true, "livre": true,
"approved": true, "approved": true,
"cancelled": true, "cancelled": true,
@@ -275,7 +276,9 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
rowsAffected, _ := result.RowsAffected() rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 { if rowsAffected == 0 {
return nil, fmt.Errorf("stock insuffisant pour produit %d", item.ProductID) // Le stock était déjà réservé lors de l'ajout au panier (DecrementProductStockByID).
// On ne bloque pas la commande : tous les articles doivent être insérés.
log.Printf("⚠️ [CHECKOUT] Stock déjà réservé pour produit %d (double réservation panier/checkout)", item.ProductID)
} }
} }
@@ -324,7 +327,8 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa
} }
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix, query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
c.livreur_assign, c.created_at, c.updated_at c.livreur_assign, c.created_at, c.updated_at,
c.proposed_address, c.address_proposal_status
FROM commandes c FROM commandes c
WHERE 1=1` WHERE 1=1`
@@ -333,7 +337,7 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa
// Filtrage du statut // Filtrage du statut
if status == "" { if status == "" {
query += ` AND c.status IN ('pending', 'assigned', 'en_route', 'livre')` query += ` AND c.status IN ('pending', 'assigned', 'en_route', 'arrived', 'livre')`
} else { } else {
query += fmt.Sprintf(" AND c.status = $%d", argPosition) query += fmt.Sprintf(" AND c.status = $%d", argPosition)
args = append(args, status) args = append(args, status)
@@ -362,10 +366,12 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa
var id int var id int
var username, status, adresse string var username, status, adresse string
var livreurAssign sql.NullString var livreurAssign sql.NullString
var proposedAddress sql.NullString
var addressProposalStatus string
var totalPrix float64 var totalPrix float64
var createdAt, updatedAt time.Time var createdAt, updatedAt time.Time
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &livreurAssign, &createdAt, &updatedAt) err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &livreurAssign, &createdAt, &updatedAt, &proposedAddress, &addressProposalStatus)
if err != nil { if err != nil {
return nil, fmt.Errorf("erreur lors du scan de la commande: %w", err) return nil, fmt.Errorf("erreur lors du scan de la commande: %w", err)
} }
@@ -374,13 +380,14 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa
adresse = sanitizeString(adresse) adresse = sanitizeString(adresse)
command := map[string]interface{}{ command := map[string]interface{}{
"id": id, "id": id,
"username": username, "username": username,
"status": status, "status": status,
"adresse": adresse, "adresse": adresse,
"total_prix": totalPrix, "total_prix": totalPrix,
"created_at": createdAt, "created_at": createdAt,
"updated_at": updatedAt, "updated_at": updatedAt,
"address_proposal_status": addressProposalStatus,
} }
if livreurAssign.Valid { if livreurAssign.Valid {
@@ -389,6 +396,12 @@ func (d *Database) GetAllCommands(status, username string) ([]map[string]interfa
command["livreur_assign"] = nil command["livreur_assign"] = nil
} }
if proposedAddress.Valid {
command["proposed_address"] = proposedAddress.String
} else {
command["proposed_address"] = nil
}
commands = append(commands, command) commands = append(commands, command)
} }
@@ -412,12 +425,15 @@ func (d *Database) GetCommandCount() (int, error) {
func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) { func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
// ✅ Déjà sécurisé avec paramètre $1 // ✅ Déjà sécurisé avec paramètre $1
query := `SELECT id, username, status, adresse, total_prix, livreur_assign, created_at, updated_at query := `SELECT id, username, status, adresse, total_prix, livreur_assign, created_at, updated_at,
proposed_address, address_proposal_status
FROM commandes WHERE id = $1` FROM commandes WHERE id = $1`
var commandID int var commandID int
var username, status, adresse string var username, status, adresse string
var livreurAssign sql.NullString var livreurAssign sql.NullString
var proposedAddress sql.NullString
var addressProposalStatus string
var totalPrix float64 var totalPrix float64
var createdAt, updatedAt time.Time var createdAt, updatedAt time.Time
@@ -430,6 +446,8 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
&livreurAssign, &livreurAssign,
&createdAt, &createdAt,
&updatedAt, &updatedAt,
&proposedAddress,
&addressProposalStatus,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -440,13 +458,14 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
} }
command := map[string]interface{}{ command := map[string]interface{}{
"id": commandID, "id": commandID,
"username": username, "username": username,
"status": status, "status": status,
"adresse": adresse, "adresse": adresse,
"total_prix": totalPrix, "total_prix": totalPrix,
"created_at": createdAt, "created_at": createdAt,
"updated_at": updatedAt, "updated_at": updatedAt,
"address_proposal_status": addressProposalStatus,
} }
if livreurAssign.Valid { if livreurAssign.Valid {
@@ -455,6 +474,12 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
command["livreur_assign"] = nil command["livreur_assign"] = nil
} }
if proposedAddress.Valid {
command["proposed_address"] = proposedAddress.String
} else {
command["proposed_address"] = nil
}
return command, nil return command, nil
} }
@@ -503,10 +528,74 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
return nil 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 {
return err
}
query := `UPDATE commandes
SET proposed_address = $1, address_proposal_status = 'pending', updated_at = CURRENT_TIMESTAMP
WHERE id = $2`
result, err := d.Exec(query, proposedAddress, commandID)
if err != nil {
return fmt.Errorf("erreur proposition adresse: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
d.AddCommandLog(commandID, "address_proposed",
fmt.Sprintf("Nouvelle adresse proposée par %s: %s", proposedBy, proposedAddress),
proposedBy)
log.Printf("✅ Adresse proposée pour commande %d par %s", commandID, proposedBy)
return nil
}
// RespondToAddressProposal accepte ou refuse la proposition d'adresse
func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error {
var query string
if accepted {
// Remplace l'adresse par la proposition
query = `UPDATE commandes
SET adresse = proposed_address, proposed_address = NULL,
address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND username = $2 AND address_proposal_status = 'pending'`
} else {
query = `UPDATE commandes
SET proposed_address = NULL, address_proposal_status = 'rejected',
updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND username = $2 AND address_proposal_status = 'pending'`
}
result, err := d.Exec(query, commandID, clientUsername)
if err != nil {
return fmt.Errorf("erreur réponse proposition adresse: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("aucune proposition en attente pour cette commande")
}
action := "refusée"
if accepted {
action = "acceptée"
}
d.AddCommandLog(commandID, "address_proposal_"+action,
fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername),
clientUsername)
log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID)
return nil
}
// UpdateCommandStatus met à jour le statut d'une commande // UpdateCommandStatus met à jour le statut d'une commande
func (d *Database) UpdateCommandStatus(commandID int, status string) error { func (d *Database) UpdateCommandStatus(commandID int, status string) error {
// ✅ SÉCURITÉ: Validation du statut // ✅ SÉCURITÉ: Validation du statut
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled", "support"} validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled", "support"}
isValid := false isValid := false
for _, vs := range validStatuses { for _, vs := range validStatuses {
if status == vs { if status == vs {
@@ -617,7 +706,7 @@ func (d *Database) GetCommandsWithFilter(status, username string, excludeApprove
// ✅ Filtrer par status si fourni avec validation // ✅ Filtrer par status si fourni avec validation
if status != "" { if status != "" {
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled", "support"} validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled", "support"}
isValid := false isValid := false
for _, vs := range validStatuses { for _, vs := range validStatuses {
if status == vs { if status == vs {
+8
View File
@@ -86,6 +86,14 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration push_token users: %v", err) log.Fatalf("❌ Erreur migration push_token users: %v", err)
} }
// Migration: proposition de modification d'adresse par admin/cabine
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS proposed_address TEXT`); err != nil {
log.Fatalf("❌ Erreur migration proposed_address: %v", err)
}
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS address_proposal_status VARCHAR(20) NOT NULL DEFAULT 'none'`); err != nil {
log.Fatalf("❌ Erreur migration address_proposal_status: %v", err)
}
// Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u) // Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u)
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil { if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil {
log.Fatalf("❌ Erreur migration unit products: %v", err) log.Fatalf("❌ Erreur migration unit products: %v", err)
+10 -8
View File
@@ -131,14 +131,16 @@ func GetMyCommandsWithTracking(c *gin.Context) {
} }
enrichedCommands[i] = gin.H{ enrichedCommands[i] = gin.H{
"id": cmd["id"], "id": cmd["id"],
"status": cmd["status"], "status": cmd["status"],
"status_message": getStatusMessage(cmd["status"].(string)), "status_message": getStatusMessage(cmd["status"].(string)),
"adresse": cmd["adresse"], "adresse": cmd["adresse"],
"total_prix": cmd["total_prix"], "total_prix": cmd["total_prix"],
"created_at": cmd["created_at"], "created_at": cmd["created_at"],
"livreur": livreurInfo, "livreur": livreurInfo,
"eta": etaData, "eta": etaData,
"proposed_address": cmd["proposed_address"],
"address_proposal_status": cmd["address_proposal_status"],
} }
} }
+260
View File
@@ -156,6 +156,120 @@ func UpdateCommandAddress(c *gin.Context) {
}) })
} }
// ProposeAddressChange propose une nouvelle adresse au client pour validation
// POST /api/v2/admin/protected/orders/:id/propose-address
// POST /api/v1/cabine/commands/:id/propose-address
func ProposeAddressChange(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
staffUsername, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
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 {
ProposedAddress string `json:"proposed_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
if err := validateAddress(req.ProposedAddress); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Récupérer la commande pour vérifier statut et obtenir username client
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
status, _ := command["status"].(string)
if status == "livre" || status == "approved" || status == "cancelled" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible de modifier l'adresse d'une commande terminée"})
return
}
if err := database.ProposeAddressChange(commandID, req.ProposedAddress, staffUsername); err != nil {
log.Printf("❌ [PROPOSE_ADDR] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Notifier le client
clientUsername, _ := command["username"].(string)
if clientUsername != "" {
msg := fmt.Sprintf("📍 Une nouvelle adresse de livraison vous est proposée pour la commande #%d : %s. Veuillez l'accepter ou la refuser dans le suivi de commande.", commandID, req.ProposedAddress)
database.NotifyClient(clientUsername, commandID, "address_proposal", msg)
}
log.Printf("✅ [PROPOSE_ADDR] Commande %d - nouvelle adresse proposée par %s", commandID, staffUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Nouvelle adresse proposée au client",
"command_id": commandID,
})
}
// RespondToAddressProposal permet au client d'accepter ou refuser une proposition d'adresse
// POST /api/v1/commands/:id/address/respond
func RespondToAddressProposal(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
clientUsername, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
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 {
Accepted bool `json:"accepted"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil {
log.Printf("❌ [RESPOND_ADDR] Erreur: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
action := "refusée"
if req.Accepted {
action = "acceptée"
}
log.Printf("✅ [RESPOND_ADDR] Commande %d - proposition %s par %s", commandID, action, clientUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("Proposition d'adresse %s", action),
})
}
func GetAllCommands(c *gin.Context) { func GetAllCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -768,6 +882,54 @@ func GetClientCommandsHistory(c *gin.Context) {
c.JSON(http.StatusOK, resp) c.JSON(http.StatusOK, resp)
} }
// ============================================
// NOTIFICATIONS CLIENT
// ============================================
// NotifyClientToDescend envoie une notification push au client pour descendre récupérer sa commande
// POST /api/v2/admin/protected/orders/:id/notify-client
// POST /api/v1/cabine/commands/:id/notify-client
func NotifyClientToDescend(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
role := c.GetString("role")
if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
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
}
cmd, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable"})
return
}
clientUsername, ok := cmd["username"].(string)
if !ok || clientUsername == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Client introuvable"})
return
}
staffUsername, _ := c.Get("username")
msg := fmt.Sprintf("Votre commande #%d est prête ! Vous pouvez descendre la récupérer.", commandID)
database.NotifyClient(clientUsername, commandID, "ready_pickup", msg)
database.AddCommandLog(commandID, "notification", fmt.Sprintf("Client notifié de descendre par %s", staffUsername), staffUsername.(string))
log.Printf("🔔 [NOTIFY] Client %s notifié pour commande %d par %s", clientUsername, commandID, staffUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Client notifié",
"client_username": clientUsername,
})
}
// ============================================ // ============================================
// DÉTAILS COMMANDES & ITEMS // DÉTAILS COMMANDES & ITEMS
// ============================================ // ============================================
@@ -1145,6 +1307,104 @@ func GetCommandFullDetails(c *gin.Context) {
}) })
} }
// DeleteCommandItem supprime un item d'une commande
// DELETE /api/v2/admin/protected/orders/:id/items/:item_id
func DeleteCommandItem(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
adminUsername, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
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
}
itemID, err := strconv.Atoi(c.Param("item_id"))
if err != nil || itemID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID d'item invalide"})
return
}
log.Printf("🗑️ [DEL_ITEM] Admin %s supprime item %d de cmd %d", adminUsername, itemID, commandID)
if err := database.DeleteCommandItem(commandID, itemID); err != nil {
log.Printf("❌ [DEL_ITEM] Erreur: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
database.AddCommandLog(commandID, "item_deleted",
fmt.Sprintf("Item %d supprimé par admin %s", itemID, adminUsername),
adminUsername)
log.Printf("✅ [DEL_ITEM] Item %d supprimé", itemID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Item supprimé",
"command_id": commandID,
"item_id": itemID,
})
}
// UpdateCommandStatusAdmin met à jour le statut d'une commande (Admin)
// PUT /api/v2/admin/protected/orders/:id/status
func UpdateCommandStatusAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
if c.GetString("role") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
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 {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Statut manquant"})
return
}
allowed := map[string]bool{
"pending": true, "assigned": true, "en_route": true,
"livre": true, "approved": true, "cancelled": true,
}
if !allowed[req.Status] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Statut invalide: " + req.Status})
return
}
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
log.Printf("❌ [STATUS_ADMIN] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
log.Printf("✅ [STATUS_ADMIN] Cmd %d → %s", commandID, req.Status)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Statut mis à jour",
"command_id": commandID,
"new_status": req.Status,
})
}
// GetCommandItemsStats récupère les stats d'une commande // GetCommandItemsStats récupère les stats d'une commande
// GET /api/v1/commands/:id/stats // GET /api/v1/commands/:id/stats
func GetCommandItemsStats(c *gin.Context) { func GetCommandItemsStats(c *gin.Context) {
+1 -1
View File
@@ -373,7 +373,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
clientMsg = fmt.Sprintf("Votre commande #%d est en route !", commandID) clientMsg = fmt.Sprintf("Votre commande #%d est en route !", commandID)
} }
case "arrived": case "arrived":
clientMsg = fmt.Sprintf("Votre livreur est arrivé pour la commande #%d", commandID) clientMsg = fmt.Sprintf("🛵 Votre livreur est là ! Il sera chez vous dans 5 minutes (commande #%d)", commandID)
case "livre": case "livre":
clientMsg = fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID) clientMsg = fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID)
case "failed": case "failed":
+1 -1
View File
@@ -104,7 +104,7 @@ func main() {
// Configuration CORS // Configuration CORS
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"https://uber-stup.club", "https://mln-uber.club", "http://localhost:5173"}, AllowOrigins: []string{"https://uber-stup.club", "https://mln-uber.club", "http://localhost:5173", "http://5.181.0.112"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
ExposeHeaders: []string{"Content-Length"}, ExposeHeaders: []string{"Content-Length"},
+9
View File
@@ -79,6 +79,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cartGroupV1.GET("/commands/:id/items", handlers.GetCommandItemsWithDetails) cartGroupV1.GET("/commands/:id/items", handlers.GetCommandItemsWithDetails)
// Approbation livraison // Approbation livraison
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery) cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES // ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems) cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory) cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
@@ -167,8 +168,14 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.GET("/orders", handlers.GetAllCommands) adminGroupV2.GET("/orders", handlers.GetAllCommands)
adminGroupV2.GET("/orders/:id", handlers.GetCommandByID) adminGroupV2.GET("/orders/:id", handlers.GetCommandByID)
adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress) adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress)
adminGroupV2.POST("/orders/:id/propose-address", handlers.ProposeAddressChange)
adminGroupV2.PUT("/orders/:id/status", handlers.UpdateCommandStatusAdmin)
adminGroupV2.POST("/orders/:id/force-validate", handlers.ValidateDelivery) adminGroupV2.POST("/orders/:id/force-validate", handlers.ValidateDelivery)
adminGroupV2.POST("/orders/:id/confirm-reception", handlers.StaffApproveDelivery) adminGroupV2.POST("/orders/:id/confirm-reception", handlers.StaffApproveDelivery)
adminGroupV2.POST("/orders/:id/notify-client", handlers.NotifyClientToDescend)
adminGroupV2.GET("/orders/:id/items", handlers.ShowItems)
adminGroupV2.DELETE("/orders/:id/items/:item_id", handlers.DeleteCommandItem)
adminGroupV2.DELETE("/orders/:id", handlers.DeleteCommandByCabine)
// ============================================ // ============================================
// ⭐ AUTO-ASSIGNATION GPS - ROUTES CRITIQUES // ⭐ AUTO-ASSIGNATION GPS - ROUTES CRITIQUES
@@ -237,10 +244,12 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems) cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery) cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson) cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
cabineGroupV1.POST("/commands/:id/notify-client", handlers.NotifyClientToDescend)
cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus) cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus)
cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand) cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand)
cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen) cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen)
cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine) cabineGroupV1.DELETE("/commands/:id", handlers.DeleteCommandByCabine)
cabineGroupV1.POST("/commands/:id/propose-address", handlers.ProposeAddressChange)
// ⭐ NOUVEAU - ANNULATION PAR CABINE // ⭐ NOUVEAU - ANNULATION PAR CABINE
cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders) cabineGroupV1.GET("/commands/cancelled", handlers.GetAllCancelledOrders)
cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client cabineGroupV1.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
+8
View File
@@ -173,6 +173,14 @@ export const validateCommand = async (commandId: number) => {
return { success: true, message: data.message }; return { success: true, message: data.message };
}; };
export const proposeAddressChangeAdmin = async (commandId: number, proposedAddress: string) => {
const { data } = await apiClient.post(
`${V2}/admin/protected/orders/${commandId}/propose-address`,
{ proposed_address: proposedAddress },
);
return { success: true, message: data.message };
};
export const notifyClientToDescend = async (commandId: number) => { export const notifyClientToDescend = async (commandId: number) => {
const { data } = await apiClient.post( const { data } = await apiClient.post(
`${V2}/admin/protected/orders/${commandId}/notify-client`, `${V2}/admin/protected/orders/${commandId}/notify-client`,
+8
View File
@@ -117,6 +117,14 @@ export const deleteCommand = async (commandId: number) => {
return { success: true, message: data.message }; return { success: true, message: data.message };
}; };
export const proposeAddressChangeCabine = async (commandId: number, proposedAddress: string) => {
const { data } = await apiClient.post(
`${API}/commands/${commandId}/propose-address`,
{ proposed_address: proposedAddress },
);
return { success: true, message: data.message };
};
export const notifyClientToDescendCabine = async (commandId: number) => { export const notifyClientToDescendCabine = async (commandId: number) => {
const { data } = await apiClient.post( const { data } = await apiClient.post(
`${API}/commands/${commandId}/notify-client`, `${API}/commands/${commandId}/notify-client`,
@@ -7,6 +7,7 @@ import {
TouchableOpacity, TouchableOpacity,
RefreshControl, RefreshControl,
ScrollView, ScrollView,
TextInput,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native"; import { useNavigation } from "@react-navigation/native";
@@ -23,6 +24,7 @@ import {
confirmReceptionAdmin, confirmReceptionAdmin,
deleteCommand, deleteCommand,
deleteCommandItem, deleteCommandItem,
proposeAddressChangeAdmin,
} from "../../api/api_admin"; } from "../../api/api_admin";
import type { CommandResponse } from "../../api/types"; import type { CommandResponse } from "../../api/types";
import type { AdminStackParamList } from "../../navigation/types"; import type { AdminStackParamList } from "../../navigation/types";
@@ -62,6 +64,12 @@ export default function OrdersScreen() {
}>({ visible: false, commandId: null }); }>({ visible: false, commandId: null });
const [livreurs, setLivreurs] = useState<any[]>([]); const [livreurs, setLivreurs] = useState<any[]>([]);
const [addressModal, setAddressModal] = useState<{
visible: boolean;
commandId: number | null;
input: string;
}>({ visible: false, commandId: null, input: "" });
const [itemsModal, setItemsModal] = useState<{ const [itemsModal, setItemsModal] = useState<{
visible: boolean; visible: boolean;
commandId: number | null; commandId: number | null;
@@ -180,6 +188,17 @@ export default function OrdersScreen() {
); );
}; };
const handleProposeAddress = async () => {
if (!addressModal.commandId || !addressModal.input.trim()) return;
try {
await proposeAddressChangeAdmin(addressModal.commandId, addressModal.input.trim());
setAddressModal({ visible: false, commandId: null, input: "" });
showSuccess("Proposition envoyée", "Le client a été notifié de la nouvelle adresse proposée");
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleDeleteItem = (commandId: number, itemId: number, itemName: string) => { const handleDeleteItem = (commandId: number, itemId: number, itemName: string) => {
showConfirm( showConfirm(
"Supprimer l'article", "Supprimer l'article",
@@ -372,6 +391,28 @@ export default function OrdersScreen() {
padding: spacing.xs, padding: spacing.xs,
marginLeft: spacing.s, marginLeft: spacing.s,
}, },
addressInput: {
backgroundColor: colors.bgPrimary,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.sm,
color: colors.textWhite,
paddingHorizontal: spacing.m,
paddingVertical: spacing.m,
fontSize: fontSize.md,
marginBottom: spacing.m,
},
addressConfirmBtn: {
backgroundColor: colors.accent,
borderRadius: borderRadius.sm,
paddingVertical: spacing.m,
alignItems: "center",
},
addressConfirmText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
}), }),
[colors], [colors],
); );
@@ -394,6 +435,12 @@ export default function OrdersScreen() {
icon: "receipt-outline" as keyof typeof Ionicons.glyphMap, icon: "receipt-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => { setOpenMenuId(null); openItems(item.id); }, onPress: () => { setOpenMenuId(null); openItems(item.id); },
}, },
{
label: "Proposer adresse",
icon: "location-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => { setOpenMenuId(null); setAddressModal({ visible: true, commandId: item.id, input: "" }); },
condition: !isDone,
},
{ {
label: "Le client est là", label: "Le client est là",
icon: "notifications-outline" as keyof typeof Ionicons.glyphMap, icon: "notifications-outline" as keyof typeof Ionicons.glyphMap,
@@ -649,6 +696,29 @@ export default function OrdersScreen() {
)} )}
</Modal> </Modal>
{/* Modal proposition adresse */}
<Modal
visible={addressModal.visible}
onClose={() => setAddressModal({ visible: false, commandId: null, input: "" })}
title={`Proposer adresse — commande #${addressModal.commandId}`}
icon="location-outline"
>
<TextInput
style={styles.addressInput}
placeholder="Nouvelle adresse..."
placeholderTextColor={colors.textMuted}
value={addressModal.input}
onChangeText={(t) => setAddressModal((prev) => ({ ...prev, input: t }))}
multiline
/>
<TouchableOpacity
style={styles.addressConfirmBtn}
onPress={handleProposeAddress}
>
<Text style={styles.addressConfirmText}>Envoyer la proposition</Text>
</TouchableOpacity>
</Modal>
<AlertModal <AlertModal
visible={alert.visible} visible={alert.visible}
type={alert.type} type={alert.type}
@@ -7,6 +7,7 @@ import {
RefreshControl, RefreshControl,
ScrollView, ScrollView,
TouchableOpacity, TouchableOpacity,
TextInput,
} from "react-native"; } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme"; import { spacing, fontSize, borderRadius } from "../../theme";
@@ -20,6 +21,7 @@ import {
getCabineLivreursList, getCabineLivreursList,
assignDeliveryPersonByCabine, assignDeliveryPersonByCabine,
resetClientPoints, resetClientPoints,
proposeAddressChangeCabine,
} from "../../api/api_cabine"; } from "../../api/api_cabine";
import type { CommandResponse } from "../../api/types"; import type { CommandResponse } from "../../api/types";
import StatusBadge from "../../components/StatusBadge"; import StatusBadge from "../../components/StatusBadge";
@@ -71,6 +73,11 @@ export default function OrdersScreen() {
visible: boolean; visible: boolean;
commandId: number | null; commandId: number | null;
}>({ visible: false, commandId: null }); }>({ visible: false, commandId: null });
const [addressModal, setAddressModal] = useState<{
visible: boolean;
commandId: number | null;
input: string;
}>({ visible: false, commandId: null, input: "" });
const [livreurs, setLivreurs] = useState<{ id: number; username: string }[]>([]); const [livreurs, setLivreurs] = useState<{ id: number; username: string }[]>([]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
@@ -148,6 +155,17 @@ export default function OrdersScreen() {
} }
}; };
const handleProposeAddress = async () => {
if (!addressModal.commandId || !addressModal.input.trim()) return;
try {
await proposeAddressChangeCabine(addressModal.commandId, addressModal.input.trim());
setAddressModal({ visible: false, commandId: null, input: "" });
showSuccess("Proposition envoyée", "Le client a été notifié de la nouvelle adresse proposée");
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleResetPoints = (clientUsername: string) => { const handleResetPoints = (clientUsername: string) => {
setOpenMenuId(null); setOpenMenuId(null);
showConfirm( showConfirm(
@@ -283,6 +301,28 @@ export default function OrdersScreen() {
color: colors.danger, color: colors.danger,
fontSize: fontSize.sm, fontSize: fontSize.sm,
}, },
addressInput: {
backgroundColor: colors.bgPrimary,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.sm,
color: colors.textWhite,
paddingHorizontal: spacing.m,
paddingVertical: spacing.m,
fontSize: fontSize.md,
marginBottom: spacing.m,
},
addressConfirmBtn: {
backgroundColor: colors.accent,
borderRadius: borderRadius.sm,
paddingVertical: spacing.m,
alignItems: "center",
},
addressConfirmText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
// Modal summary // Modal summary
modalSummary: { modalSummary: {
backgroundColor: colors.bgCard, backgroundColor: colors.bgCard,
@@ -396,6 +436,11 @@ export default function OrdersScreen() {
icon: "receipt-outline" as keyof typeof Ionicons.glyphMap, icon: "receipt-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => openItems(item.id), onPress: () => openItems(item.id),
}, },
{
label: "Proposer adresse",
icon: "location-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => { setOpenMenuId(null); setAddressModal({ visible: true, commandId: item.id, input: "" }); },
},
{ {
label: "Le client est là", label: "Le client est là",
icon: "notifications-outline" as keyof typeof Ionicons.glyphMap, icon: "notifications-outline" as keyof typeof Ionicons.glyphMap,
@@ -622,6 +667,29 @@ export default function OrdersScreen() {
)} )}
</Modal> </Modal>
{/* Modal proposition adresse */}
<Modal
visible={addressModal.visible}
onClose={() => setAddressModal({ visible: false, commandId: null, input: "" })}
title={`Proposer adresse — commande #${addressModal.commandId}`}
icon="location-outline"
>
<TextInput
style={styles.addressInput}
placeholder="Nouvelle adresse..."
placeholderTextColor={colors.textMuted}
value={addressModal.input}
onChangeText={(t) => setAddressModal((prev) => ({ ...prev, input: t }))}
multiline
/>
<TouchableOpacity
style={styles.addressConfirmBtn}
onPress={handleProposeAddress}
>
<Text style={styles.addressConfirmText}>Envoyer la proposition</Text>
</TouchableOpacity>
</Modal>
<AlertModal <AlertModal
visible={alert.visible} visible={alert.visible}
type={alert.type} type={alert.type}