diff --git a/README.md b/README.md index ce858e42..843178b1 100644 --- a/README.md +++ b/README.md @@ -2150,11 +2150,7 @@ graph LR end ``` -| Cle Redis | Description | TTL | -|-----------|-------------|-----| -| `delivery:location:{username}` | Position actuelle du livreur | 2 heures | -| `delivery:status:{username}` | Statut + position du livreur | 1 heure | -| `geocode:cache:{address_hash}` | Cache geocodage adresse | 7 jours | + | `command:destination:{command_id}` | Coordonnees destination | 4 heures | --- @@ -2301,11 +2297,7 @@ Le systeme bascule automatiquement sur le calcul local: - Estime l'ETA avec une vitesse moyenne de 30 km/h - Un flag `fallback_used: true` est ajoute a la reponse -#### Position Livreur Obsolete -- Les positions de plus de 2 heures sont considerees obsoletes -- Le livreur doit mettre a jour sa position pour recevoir des commandes -- Un warning est affiche dans l'interface admin --- diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go index 9c4572e1..610a5bbf 100644 --- a/backend/gestion/db/db_commands.go +++ b/backend/gestion/db/db_commands.go @@ -1000,3 +1000,94 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e return totalPoints, nil } + +// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client +func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, error) { + log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername) + + tx, err := d.Begin() + if err != nil { + return 0, "", fmt.Errorf("erreur transaction: %w", err) + } + defer tx.Rollback() + + var currentStatus, clientUsername, livreurAssign string + err = tx.QueryRow(` + SELECT status, username, COALESCE(livreur_assign, '') + FROM commandes + WHERE id = $1 + FOR UPDATE + `, commandID).Scan(¤tStatus, &clientUsername, &livreurAssign) + + if err == sql.ErrNoRows { + return 0, "", fmt.Errorf("commande non trouvée") + } + if err != nil { + return 0, "", fmt.Errorf("erreur lecture commande: %w", err) + } + + if currentStatus != "livre" { + return 0, "", fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus) + } + + result, err := tx.Exec(` + UPDATE commandes + SET status = 'approved', updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND status = 'livre' + `, commandID) + if err != nil { + return 0, "", fmt.Errorf("erreur mise à jour statut: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return 0, "", fmt.Errorf("commande déjà approuvée ou modifiée") + } + + totalPoints, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, clientUsername) + if err != nil { + return 0, "", fmt.Errorf("erreur attribution points: %w", err) + } + + _, err = tx.Exec(` + UPDATE clients + SET command = command + 1, updated_at = CURRENT_TIMESTAMP + WHERE username = $1 + `, clientUsername) + if err != nil { + log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err) + } + + _, err = tx.Exec(` + INSERT INTO command_logs (command_id, status, message, author, created_at) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) + `, commandID, "approved", + fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, clientUsername, totalPoints), + staffUsername) + if err != nil { + log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err) + } + + if err := tx.Commit(); err != nil { + return 0, "", fmt.Errorf("erreur commit: %w", err) + } + + log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s", + commandID, staffUsername, totalPoints, clientUsername) + + if livreurAssign != "" { + go func() { + if err := d.CompleteDeliveryAndProcessNext(livreurAssign, commandID); err != nil { + log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err) + } + }() + } + + go func() { + Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID)) + Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsername)) + Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsername)) + }() + + return totalPoints, clientUsername, nil +} diff --git a/backend/gestion/db/db_product.go b/backend/gestion/db/db_product.go index a3252bfa..ad1e5da6 100644 --- a/backend/gestion/db/db_product.go +++ b/backend/gestion/db/db_product.go @@ -43,6 +43,7 @@ func (db *Database) CreateProduct(product interface{}) error { log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory()) log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription()) log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock()) + log.Printf("📦 [DB CreateProduct] Unit: %s", p.GetUnit()) log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices())) } diff --git a/backend/gestion/handlers/auth.go b/backend/gestion/handlers/auth.go index d1a2c0c9..783cb94f 100644 --- a/backend/gestion/handlers/auth.go +++ b/backend/gestion/handlers/auth.go @@ -80,7 +80,7 @@ type ProfileResponse struct { var ( clientTokenDuration = 5 * time.Hour - adminTokenDuration = 2 * time.Hour + adminTokenDuration = 10 * time.Hour userJWTSecret = []byte(os.Getenv("USER_JWT_SECRET")) // ✅ Pour clients adminJWTSecret = []byte(os.Getenv("ADMIN_JWT_SECRET")) // ✅ Pour admin/cabine/livreur ) @@ -287,7 +287,7 @@ func LoginClient(c *gin.Context) { database := c.MustGet("database").(*db.Database) client, err := database.GetClientByUsername(req.Username) - if err != nil { + if err != nil || client == nil { log.Printf("❌ [LOGIN_CLIENT] Client non trouvé: %s", req.Username) c.JSON(http.StatusUnauthorized, gin.H{"error": "Identifiants invalides"}) return @@ -327,13 +327,13 @@ func LoginClient(c *gin.Context) { TokenType: "Bearer", ExpiresIn: int(clientTokenDuration.Seconds()), User: gin.H{ - "id": client.ID, - "username": client.Username, - "nom": client.Nom, - "prenom": client.Prenom, - "telephone": client.Telephone, - "role": "client", - "session_id": sessionID, + "id": client.ID, + "username": client.Username, + "nom": client.Nom, + "prenom": client.Prenom, + "telephone": client.Telephone, + "role": "client", + "session_id": sessionID, "must_change_password": client.MustChangePassword, }, }) diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go index 5f9babf2..f98d48cc 100644 --- a/backend/gestion/handlers/commands.go +++ b/backend/gestion/handlers/commands.go @@ -305,6 +305,51 @@ func ApproveDelivery(c *gin.Context) { }) } +// ============================================ +// CONFIRMATION RÉCEPTION PAR STAFF (ADMIN / CABINE) +// ============================================ + +func StaffApproveDelivery(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 réservé aux admins et à la cabine"}) + return + } + + staffUsername, 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 invalide"}) + return + } + + log.Printf("✅ [STAFF_APPROVE] %s (%s) confirme réception cmd %d", staffUsername, role, commandID) + + totalPoints, clientUsername, err := database.ApproveDeliveryAtomicByStaff(commandID, staffUsername) + if err != nil { + log.Printf("❌ [STAFF_APPROVE] Erreur: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible de confirmer la réception: " + err.Error()}) + return + } + + log.Printf("✅ [STAFF_APPROVE] %d points attribués au client %s", totalPoints, clientUsername) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Réception confirmée", + "command_id": commandID, + "client_username": clientUsername, + "points_earned": totalPoints, + }) +} + // ============================================ // APPROBATION PAR ADMIN // ============================================ @@ -464,37 +509,49 @@ func GetAvailableDeliveryPersons(c *gin.Context) { } // AssignDeliveryPerson assigne manuellement un livreur à une commande -// POST /api/v1/admin/commands/:id/assign +// Admin: POST /api/v2/admin/protected/delivery-persons/:username/assign/:command_id +// Cabine: POST /api/v1/cabine/commands/:id/assign (body: {"livreur_username": "..."}) func AssignDeliveryPerson(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ SÉCURITÉ: Admin seulement - if c.GetString("role") != "admin" { + // ✅ SÉCURITÉ: Admin ou Cabine + 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 { + // Support deux formats de route: :command_id (admin) ou :id (cabine) + commandIDStr := c.Param("command_id") + if commandIDStr == "" { + commandIDStr = c.Param("id") + } + commandID, err := strconv.Atoi(commandIDStr) + if err != nil || commandID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"}) return } - var req struct { - LivreurUsername string `json:"livreur_username" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Données invalides", - "details": err.Error(), - }) - return + // Livreur depuis URL param (admin) ou body JSON (cabine) + livreurUsername := c.Param("username") + if livreurUsername == "" { + var req struct { + LivreurUsername string `json:"livreur_username" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Données invalides", + "details": err.Error(), + }) + return + } + livreurUsername = req.LivreurUsername } - log.Printf("👤 [ASSIGN] Assignation cmd %d à livreur %s", commandID, req.LivreurUsername) + log.Printf("👤 [ASSIGN] Assignation cmd %d à livreur %s", commandID, livreurUsername) - adminUsername, _ := c.Get("username") - if err := database.AssignDeliveryPerson(commandID, req.LivreurUsername); err != nil { + staffUsername, _ := c.Get("username") + if err := database.AssignDeliveryPerson(commandID, livreurUsername); err != nil { log.Printf("❌ [ASSIGN] Erreur: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Erreur assignation livreur", @@ -504,17 +561,17 @@ func AssignDeliveryPerson(c *gin.Context) { } database.AddCommandLog(commandID, "support", - fmt.Sprintf("Livreur '%s' assigné manuellement par %s", req.LivreurUsername, adminUsername), - adminUsername.(string)) + fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername), + staffUsername.(string)) - log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, req.LivreurUsername) + log.Printf("✅ [ASSIGN] Commande %d assignée à %s", commandID, livreurUsername) c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Livreur assigné avec succès", "command_id": commandID, - "livreur": req.LivreurUsername, - "assigned_by": adminUsername, + "livreur": livreurUsername, + "assigned_by": staffUsername, }) } diff --git a/backend/gestion/main.go b/backend/gestion/main.go index d27e4600..2c6f7785 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "os" + "time" "github.com/gin-contrib/cors" "github.com/gin-contrib/sessions" @@ -21,6 +22,13 @@ import ( ) func main() { + // Forcer la timezone Europe/Paris (UTC+1/+2) + if loc, err := time.LoadLocation("Europe/Paris"); err == nil { + time.Local = loc + } else { + log.Printf("⚠️ Impossible de charger la timezone Europe/Paris: %v", err) + } + // Chargement des variables d'environnement if err := godotenv.Load(); err != nil { log.Println("⚠️ Aucun fichier .env trouvé, utilisation des valeurs par défaut.") diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 30e14722..8468f979 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -168,6 +168,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.GET("/orders/:id", handlers.GetCommandByID) adminGroupV2.PUT("/orders/:id/address", handlers.UpdateCommandAddress) adminGroupV2.POST("/orders/:id/force-validate", handlers.ValidateDelivery) + adminGroupV2.POST("/orders/:id/confirm-reception", handlers.StaffApproveDelivery) // ============================================ // ⭐ AUTO-ASSIGNATION GPS - ROUTES CRITIQUES @@ -234,6 +235,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services cabineGroupV1.Use(middleware.CabineMiddleware) { cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems) + cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery) + cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson) cabineGroupV1.PUT("/items/:item_id/status", handlers.UpdateItemStatus) cabineGroupV1.GET("/commands/:id/deliveryman/location", handlers.GetDeliverymanLocationForCommand) cabineGroupV1.GET("/all/deliveryman", handlers.GetAllDeliveryMen) diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 8863aa37..4dfa5f6d 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -159,6 +159,18 @@ export const validateCommand = async (commandId: number) => { return { success: true, message: data.message }; }; +export const confirmReceptionAdmin = async (commandId: number) => { + const { data } = await apiClient.post( + `${V2}/admin/protected/orders/${commandId}/confirm-reception`, + ); + return { + success: true, + message: data.message, + points_earned: data.points_earned, + client_username: data.client_username, + }; +}; + // ============================================ // LIVREURS // ============================================ diff --git a/frontend-admin/src/api/api_cabine.ts b/frontend-admin/src/api/api_cabine.ts index de002c46..f89185e7 100644 --- a/frontend-admin/src/api/api_cabine.ts +++ b/frontend-admin/src/api/api_cabine.ts @@ -31,6 +31,18 @@ export const updateItemStatus = async (itemId: number, status: string) => { return { success: true, message: data.message }; }; +export const confirmReceptionCabine = async (commandId: number) => { + const { data } = await apiClient.post( + `${API}/commands/${commandId}/confirm-reception`, + ); + return { + success: true, + message: data.message, + points_earned: data.points_earned, + client_username: data.client_username, + }; +}; + // ============================================ // PENALITES // ============================================ @@ -105,6 +117,24 @@ export const deleteCommand = async (commandId: number) => { return { success: true, message: data.message }; }; +export const getCabineLivreursList = async (): Promise< + { id: number; username: string }[] +> => { + const { data } = await apiClient.get(`${API}/all/deliveryman`); + return data.users || []; +}; + +export const assignDeliveryPersonByCabine = async ( + commandId: number, + livreurUsername: string, +) => { + const { data } = await apiClient.post( + `${API}/commands/${commandId}/assign`, + { livreur_username: livreurUsername }, + ); + return { success: true, message: data.message }; +}; + export const getDeliverymanLocationForCommand = async (commandId: number) => { try { const { data } = await apiClient.get( diff --git a/frontend-admin/src/screens/admin/OrderDetailScreen.tsx b/frontend-admin/src/screens/admin/OrderDetailScreen.tsx index 43c2f69c..6650e0f6 100644 --- a/frontend-admin/src/screens/admin/OrderDetailScreen.tsx +++ b/frontend-admin/src/screens/admin/OrderDetailScreen.tsx @@ -18,6 +18,7 @@ import { getCommandItems, updateCommandStatus, validateCommand, + confirmReceptionAdmin, getDeliveryPersonDetails, } from "../../api/api_admin"; import { geocodeAddress, calculateRoute } from "../../api/tomtom"; @@ -75,6 +76,16 @@ export default function OrderDetailScreen() { load(); }, [orderId]); + useEffect(() => { + const interval = setInterval(async () => { + try { + const res = await getCommandByID(orderId); + setCommand(res.command); + } catch { /* ignore */ } + }, 20000); + return () => clearInterval(interval); + }, [orderId]); + const loadLivreurRoute = async ( livreurUsername: string, deliveryAddress: string, @@ -156,6 +167,20 @@ export default function OrderDetailScreen() { } }; + const handleConfirmReception = async () => { + try { + const res = await confirmReceptionAdmin(orderId); + showSuccess( + "Réception confirmée", + `${res.points_earned} point(s) attribués au client ${res.client_username}`, + ); + const updated = await getCommandByID(orderId); + setCommand(updated.command); + } catch (e: any) { + showError("Erreur", e.message); + } + }; + const styles = useMemo( () => StyleSheet.create({ @@ -622,6 +647,15 @@ export default function OrderDetailScreen() { style={{ marginTop: spacing.s }} /> )} + {command.status === "livre" && ( +