chore: build
This commit is contained in:
@@ -27,7 +27,6 @@ func AlertPolice(c *gin.Context) {
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
// message optionnel — on ignore l'erreur de bind
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
usernameStr := username.(string)
|
||||
@@ -61,6 +60,25 @@ func DeleteAlert(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
// Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet.
|
||||
if userRole == "livreur" {
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
alert, err := database.GetAlertPolicy(alertID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
|
||||
return
|
||||
}
|
||||
if alert.Username != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err = database.DeleteAlertPolicy(alertID); err != nil {
|
||||
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
|
||||
return
|
||||
@@ -92,6 +110,15 @@ func GetAlert(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Un livreur ne peut consulter que ses propres alertes — admin/cabine gardent l'accès complet pour le dispatch
|
||||
if userRole == "livreur" {
|
||||
username, exists := c.Get("username")
|
||||
if !exists || alert.Username != username.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"alert": alert,
|
||||
|
||||
@@ -55,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) {
|
||||
claims := models.AdminClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role, // ← "admin" ou "cabine" ou "livreur"
|
||||
Role: user.Role,
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "api-admin", // Même issuer pour tous les admins
|
||||
Issuer: "api-admin",
|
||||
Subject: strconv.Itoa(user.ID),
|
||||
},
|
||||
}
|
||||
@@ -73,112 +73,6 @@ func generateAdminToken(user *models.User) (string, error) {
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// RegisterClient crée un nouveau compte client
|
||||
func RegisterClient(c *gin.Context) {
|
||||
var req models.RegisterClientRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Données invalides",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize text inputs
|
||||
req.Username = utils.StripHTML(req.Username)
|
||||
req.Nom = utils.StripHTML(req.Nom)
|
||||
req.Prenom = utils.StripHTML(req.Prenom)
|
||||
|
||||
// Validation téléphone
|
||||
if !utils.ValidatePhoneNumber(req.Telephone) {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Numéro de téléphone invalide",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// Vérifier username unique
|
||||
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Username déjà utilisé: %s", req.Username)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier téléphone unique
|
||||
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Téléphone déjà utilisé: %s", normalizedPhone)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// Hasher le mot de passe
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur bcrypt: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
|
||||
return
|
||||
}
|
||||
|
||||
// Créer le client
|
||||
client := &models.Client{
|
||||
Username: req.Username,
|
||||
Password: string(hashed),
|
||||
Nom: strings.TrimSpace(req.Nom),
|
||||
Prenom: strings.TrimSpace(req.Prenom),
|
||||
Telephone: normalizedPhone,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := database.CreateClient(client); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur création: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
|
||||
return
|
||||
}
|
||||
|
||||
// Générer le token
|
||||
token, err := generateClientToken(client)
|
||||
if err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur génération token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Sauvegarder le token
|
||||
expiresAt := time.Now().Add(clientTokenDuration)
|
||||
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
|
||||
log.Printf("❌ [REGISTER_CLIENT] Erreur SaveToken: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Créer la session Redis
|
||||
sessionID := uuid.New().String()
|
||||
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
|
||||
log.Printf("⚠️ [REGISTER_CLIENT] Erreur session Redis: %v", err)
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
c.JSON(http.StatusCreated, models.LoginResponse{
|
||||
AccessToken: token,
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
|
||||
func AdminCreateClient(c *gin.Context) {
|
||||
if userRole := c.GetString("role"); userRole != "admin" {
|
||||
@@ -561,6 +455,12 @@ func LoginAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role == "livreur" {
|
||||
if err := database.RecordLivreurLogin(user.Username); err != nil {
|
||||
log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
token, _ := generateAdminToken(user)
|
||||
|
||||
expiresAt := time.Now().Add(adminTokenDuration)
|
||||
@@ -602,62 +502,6 @@ func LogoutAdmin(c *gin.Context) {
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
// GetCurrentClient récupère le client actuel
|
||||
// GET /api/v1/profile/client
|
||||
func GetCurrentClient(c *gin.Context) {
|
||||
clientID := c.GetInt("client_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
client.Password = ""
|
||||
|
||||
log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"client": gin.H{
|
||||
"id": client.ID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"command": client.Command,
|
||||
"amende": client.Amende,
|
||||
"points_extra": client.PointsExtra,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetCurrentAdmin récupère l'admin/user actuel
|
||||
// GET /api/v1/profile/admin
|
||||
func GetCurrentAdmin(c *gin.Context) {
|
||||
userID := c.GetInt("user_id")
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
user, err := database.GetUserByID(userID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = ""
|
||||
|
||||
log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": models.ProfileResponse{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllUsers récupère tous les utilisateurs (Admin only)
|
||||
func GetAllUsers(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -855,3 +699,9 @@ func CreateUser(c *gin.Context) {
|
||||
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
|
||||
}
|
||||
|
||||
func Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,7 +195,6 @@ func CancelCommandByClient(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ AUTRES ERREURS
|
||||
switch err.Error() {
|
||||
case "commande non trouvée":
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
|
||||
@@ -312,7 +311,6 @@ func GetAllCancelledOrders(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
|
||||
var enrichedOrders []map[string]any
|
||||
for _, order := range cancelledOrders {
|
||||
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
|
||||
|
||||
@@ -109,6 +109,26 @@ func UpdateCategory(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func ReorderCategories(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
var req struct {
|
||||
IDs []int `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Liste d'IDs requise"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.ReorderCategories(req.IDs); err != nil {
|
||||
log.Printf("❌ [CATEGORIES] Reorder erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du réordonnancement"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func DeleteCategory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ func buildTimeline(logs []map[string]any) []gin.H {
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
message, _ := logEntry["message"].(string)
|
||||
createdAt, _ := logEntry["created_at"]
|
||||
createdAt := logEntry["created_at"]
|
||||
|
||||
timeline = append(timeline, gin.H{
|
||||
"status": status,
|
||||
|
||||
@@ -482,10 +482,6 @@ func StaffApproveDelivery(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPROBATION PAR ADMIN
|
||||
// ============================================
|
||||
|
||||
func ValidateDelivery(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -1145,19 +1141,11 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
if req.Status == "cancelled" {
|
||||
current, errCmd := database.GetCommandByID(commandID)
|
||||
if errCmd == nil {
|
||||
currentStatus, _ := current["status"].(string)
|
||||
alreadyDone := currentStatus == "cancelled" || currentStatus == "approved" || currentStatus == "livre"
|
||||
if !alreadyDone {
|
||||
if err := database.RestoreCommandStock(commandID); err != nil {
|
||||
log.Printf("⚠️ [STATUS_ADMIN] Erreur restauration stock cmd %d: %v", commandID, err)
|
||||
}
|
||||
}
|
||||
if err := database.CancelCommandByAdminAtomic(commandID); err != nil {
|
||||
utils.ServerErr(c, "Impossible d'annuler la commande", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IPNWebhook - POST /api/v1/webhook/nowpayments
|
||||
// IPNWebhook - POST /api/v1/webhooks/nowpayments
|
||||
func IPNWebhook(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
if !ok || np == nil {
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, ok := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !ok || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -40,14 +40,27 @@ func GetMyDeliveries(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Collecter tous les IDs et usernames en une passe pour éviter les N+1
|
||||
commandIDs := make([]int, 0, len(commands))
|
||||
clientUsernames := make([]string, 0, len(commands))
|
||||
for _, cmd := range commands {
|
||||
if cid, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])); cid > 0 {
|
||||
commandIDs = append(commandIDs, cid)
|
||||
}
|
||||
if u, _ := cmd["username"].(string); u != "" {
|
||||
clientUsernames = append(clientUsernames, u)
|
||||
}
|
||||
}
|
||||
allItems, _ := database.GetCommandItemsBatch(commandIDs)
|
||||
allClients, _ := database.GetClientsByUsernames(clientUsernames)
|
||||
|
||||
filteredCommands := make([]gin.H, len(commands))
|
||||
for i, cmd := range commands {
|
||||
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
|
||||
items, _ := database.GetCommandItems(commandID)
|
||||
items := allItems[commandID]
|
||||
|
||||
// Client info SANS téléphone
|
||||
clientUsername, _ := cmd["username"].(string)
|
||||
client, _ := database.GetClientByUsername(clientUsername)
|
||||
client := allClients[clientUsername]
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
@@ -244,7 +257,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
|
||||
log.Printf("📍 [GPS] Distance: %.2f m", distance)
|
||||
|
||||
if distance > 100 {
|
||||
if distance > 350 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Vous êtes trop loin de la destination",
|
||||
"current_distance": fmt.Sprintf("%.2f", distance),
|
||||
@@ -259,22 +272,34 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le statut
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour le statut.
|
||||
// Le cas "cancelled" passe par une transaction atomique dédiée (transition +
|
||||
// remboursement stock), pour empêcher tout double remboursement en cas de
|
||||
// double appel (double-tap, retry réseau, commande déjà annulée ailleurs).
|
||||
if req.Status == "cancelled" {
|
||||
alreadyCancelled, prevStatus, cancelErr := database.CancelDeliveryByLivreurAtomic(commandID)
|
||||
if cancelErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
if alreadyCancelled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande déjà annulée",
|
||||
"command_id": commandID,
|
||||
"status": "cancelled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cancelMsg := req.Notes
|
||||
if cancelMsg == "" {
|
||||
cancelMsg = "Annulé par le livreur"
|
||||
}
|
||||
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
|
||||
|
||||
prevStatus, _ := command["status"].(string)
|
||||
if prevStatus == "arrived" || prevStatus == "livre" {
|
||||
clientUsername, _ := command["username"].(string)
|
||||
if clientUsername != "" {
|
||||
@@ -285,6 +310,11 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
|
||||
@@ -441,12 +471,8 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "cancelled":
|
||||
// Transition + remboursement stock déjà effectués atomiquement plus haut.
|
||||
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
|
||||
if err := database.RestoreCommandStock(commandID); err != nil {
|
||||
log.Printf("⚠️ [STATUS_LIVREUR] Erreur restauration stock cmd %d: %v", commandID, err)
|
||||
} else {
|
||||
log.Printf("✅ [STATUS_LIVREUR] Stock restauré pour cmd %d", commandID)
|
||||
}
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "arrived":
|
||||
@@ -526,10 +552,8 @@ func ReportDeliveryIssue(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
||||
}
|
||||
|
||||
// GET /api/v1/livreur/stats
|
||||
func GetMyDeliveryStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
@@ -540,66 +564,30 @@ func GetMyDeliveryStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
gdb := database.GDB
|
||||
|
||||
type DayRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
type WeekRow struct {
|
||||
WeekNum int `gorm:"column:week_num"`
|
||||
Year int `gorm:"column:year"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
type MonthRow struct {
|
||||
MonthNum int `gorm:"column:month_num"`
|
||||
Year int `gorm:"column:year"`
|
||||
Count int `gorm:"column:count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
var dayRows []models.DayRowWithResult
|
||||
if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"})
|
||||
return
|
||||
}
|
||||
|
||||
var dayRows []DayRow
|
||||
gdb.Raw(`
|
||||
SELECT DATE(updated_at) AS day,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY DATE(updated_at)
|
||||
ORDER BY day
|
||||
`, usernameStr).Scan(&dayRows)
|
||||
var weekRows []models.WeekRow
|
||||
if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"})
|
||||
return
|
||||
}
|
||||
|
||||
var weekRows []WeekRow
|
||||
gdb.Raw(`
|
||||
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
|
||||
EXTRACT(YEAR FROM updated_at)::int AS year,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '12 weeks'
|
||||
GROUP BY week_num, year
|
||||
ORDER BY year, week_num
|
||||
`, usernameStr).Scan(&weekRows)
|
||||
var monthRows []models.MonthRow
|
||||
if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"})
|
||||
return
|
||||
}
|
||||
|
||||
var monthRows []MonthRow
|
||||
gdb.Raw(`
|
||||
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
|
||||
EXTRACT(YEAR FROM updated_at)::int AS year,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE livreur_assign = ?
|
||||
AND status IN ('livre', 'approved')
|
||||
AND updated_at >= NOW() - INTERVAL '12 months'
|
||||
GROUP BY month_num, year
|
||||
ORDER BY year, month_num
|
||||
`, usernameStr).Scan(&monthRows)
|
||||
var todayRow models.TodayRow
|
||||
if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"})
|
||||
return
|
||||
}
|
||||
|
||||
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
|
||||
|
||||
@@ -635,9 +623,11 @@ func GetMyDeliveryStats(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"by_day": byDay,
|
||||
"by_week": byWeek,
|
||||
"by_month": byMonth,
|
||||
"success": true,
|
||||
"by_day": byDay,
|
||||
"by_week": byWeek,
|
||||
"by_month": byMonth,
|
||||
"today_count": todayRow.Count,
|
||||
"today_revenue": todayRow.Revenue,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
|
||||
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
|
||||
@@ -63,9 +62,9 @@ func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
|
||||
// Utiliser la fonction GPS existante
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
var locationInfo map[string]interface{}
|
||||
var locationInfo map[string]any
|
||||
if err == nil {
|
||||
locationInfo = map[string]interface{}{
|
||||
locationInfo = map[string]any{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
userRole := c.GetString("role")
|
||||
|
||||
@@ -112,10 +111,8 @@ func GetOrderETA(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
|
||||
cmdStatus, _ := command["status"].(string)
|
||||
|
||||
// ✅ CORRECTION: Vérifier si commande terminée
|
||||
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
|
||||
log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -129,7 +126,6 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Pour pending/assigned: pas encore de position livreur disponible
|
||||
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
||||
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -142,7 +138,6 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Pour arrived: livreur sur place, ETA non pertinent
|
||||
if cmdStatus == "arrived" {
|
||||
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -154,9 +149,7 @@ func GetOrderETA(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// Pour en_route: calcul ETA réel via position du livreur
|
||||
|
||||
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||
|
||||
@@ -197,10 +190,8 @@ func GetOrderETA(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 7️⃣ Pas de cache valide - Recalculer l'ETA
|
||||
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
|
||||
|
||||
// Récupérer coordonnées destination
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
@@ -248,13 +239,10 @@ func GetOrderETA(c *gin.Context) {
|
||||
|
||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||
|
||||
// Cas 1 : GPS livreur disponible
|
||||
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
|
||||
if gpsErr != nil {
|
||||
// Cas 2 : GPS absent → dernière adresse de livraison
|
||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
|
||||
if lastErr != nil || lastLat == 0 {
|
||||
// Cas 3 : Aucune position → cache périmé ou message
|
||||
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
|
||||
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
||||
return
|
||||
@@ -263,7 +251,6 @@ func GetOrderETA(c *gin.Context) {
|
||||
log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon)
|
||||
}
|
||||
|
||||
// Calculer ETA avec TomTom
|
||||
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
|
||||
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
||||
|
||||
@@ -274,11 +261,10 @@ func GetOrderETA(c *gin.Context) {
|
||||
etaMinutes = services.CalculateETA(distanceKm)
|
||||
}
|
||||
|
||||
// Sauvegarder en cache
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
etaCache := map[string]interface{}{
|
||||
etaCache := map[string]any{
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
|
||||
@@ -169,10 +169,6 @@ func FindNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
|
||||
// ============================================
|
||||
|
||||
// GetAllDeliveryDistances retourne tous les livreurs triés par distance
|
||||
func GetAllDeliveryDistances(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -258,9 +254,6 @@ func GetAllDeliveryDistances(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
|
||||
// ============================================
|
||||
func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
@@ -347,12 +340,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
|
||||
|
||||
// Récupérer les livreurs actifs avec capacité disponible
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
|
||||
// Si aucun livreur avec capacité disponible
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
// Cas 1: Un seul livreur actif -> pas de limite
|
||||
if activeCount == 1 {
|
||||
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
|
||||
if err != nil {
|
||||
@@ -371,7 +361,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Passer les coordonnées à la fonction d'assignation
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -386,7 +375,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée au seul livreur actif (sans limite)",
|
||||
@@ -399,7 +387,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
"single_driver": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
@@ -410,13 +398,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
|
||||
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
|
||||
|
||||
if allAtCapacity && numActive > 1 {
|
||||
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
|
||||
|
||||
// Trouver le livreur le moins chargé (même s'il dépasse 10)
|
||||
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -424,8 +410,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer le temps de trajet avec TomTom
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -433,8 +417,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Assigner de force avec coordonnées
|
||||
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -449,7 +431,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée par distribution forcée (capacité max dépassée)",
|
||||
@@ -463,7 +444,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
"over_capacity": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
@@ -474,7 +455,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas 3: Erreur générique
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif avec capacité disponible",
|
||||
"active_count": activeCount,
|
||||
@@ -483,13 +463,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas normal: Au moins un livreur avec capacité disponible
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le livreur le plus proche (calcul rapide)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -498,7 +476,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Recalculer l'ETA avec TomTom pour plus de précision
|
||||
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err != nil {
|
||||
// Fallback sur le calcul initial
|
||||
@@ -509,7 +486,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
|
||||
|
||||
// ✅ Assigner à la queue du livreur avec coordonnées
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -524,7 +500,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée à la queue du livreur",
|
||||
@@ -537,7 +512,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
"single_driver": activeCount == 1,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
@@ -547,8 +522,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
|
||||
// POST /api/v2/admin/protected/commands/auto-assign-all
|
||||
func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
@@ -559,7 +532,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer toutes les commandes pending
|
||||
commands, err := database.GetAllCommands("pending", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -585,7 +557,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
for _, cmd := range commands {
|
||||
commandID, ok := cmd["id"].(int)
|
||||
if !ok {
|
||||
// Essayer avec float64
|
||||
if idFloat, ok := cmd["id"].(float64); ok {
|
||||
commandID = int(idFloat)
|
||||
} else {
|
||||
@@ -593,7 +564,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'adresse
|
||||
address, ok := cmd["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -603,7 +573,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Géocoder l'adresse
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -618,7 +587,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
// Récupérer les livreurs actifs
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -633,7 +601,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le livreur le plus proche (version rapide pour assignation masse)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -643,7 +610,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pour l'assignation en masse, on utilise le calcul rapide
|
||||
travelTime := nearest.EstimatedTime
|
||||
distance := nearest.Distance
|
||||
|
||||
@@ -657,13 +623,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du livreur
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
|
||||
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
var totalETA, waitTime interface{}
|
||||
var totalETA, waitTime any
|
||||
totalETA = "N/A"
|
||||
waitTime = "N/A"
|
||||
|
||||
@@ -688,7 +651,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
|
||||
}
|
||||
|
||||
// Récupérer l'overview des queues
|
||||
queuesOverview, _ := database.GetAllQueuesOverview()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -705,7 +667,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
|
||||
// GET /api/v2/admin/protected/delivery/queues
|
||||
func GetAllDeliveryQueues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -723,7 +684,6 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les détails de chaque livreur
|
||||
var deliverymenDetails []gin.H
|
||||
|
||||
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
|
||||
@@ -734,7 +694,7 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
||||
|
||||
// Récupérer le statut
|
||||
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
|
||||
var status map[string]interface{}
|
||||
var status map[string]any
|
||||
if statusData != "" {
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
|
||||
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
|
||||
|
||||
// Récupérer la position GPS du livreur
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
|
||||
@@ -46,7 +45,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validation des coordonnées
|
||||
if lat == 0 && lon == 0 {
|
||||
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -57,7 +55,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les liens de cartes
|
||||
mapLinks := database.GenerateMapLinks(lat, lon, username)
|
||||
|
||||
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
@@ -76,8 +73,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
|
||||
// GET /api/v1/livreur/deliveries/:id/nav-link
|
||||
func GetLivreurNavLink(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.GetString("username")
|
||||
@@ -100,7 +95,6 @@ func GetLivreurNavLink(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Priorité : coordonnées GPS de la destination
|
||||
var wazeLink string
|
||||
destLat, hasLat := command["dest_latitude"].(float64)
|
||||
destLon, hasLon := command["dest_longitude"].(float64)
|
||||
|
||||
@@ -4,92 +4,18 @@ import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetMyCompletedOrders(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [HISTORY] Utilisateur non authentifié")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authentification requise",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usernameStr := username.(string)
|
||||
log.Printf("📚 [HISTORY] Récupération historique pour: %s", usernameStr)
|
||||
|
||||
// ✅ Récupérer les commandes terminées (approved)
|
||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY] Erreur: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération de l'historique",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [HISTORY] %d commandes terminées trouvées", len(commands))
|
||||
|
||||
// ✅ Récupérer les infos client pour statistiques
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
// ✅ Récupérer les noms et clés des pools de points
|
||||
poolNames := []string{"Pool 1", "Pool 2"}
|
||||
var poolKeys []string
|
||||
if settings, sErr := database.GetSettings(); sErr == nil && len(settings.PointsPools) > 0 {
|
||||
poolNames = make([]string, len(settings.PointsPools))
|
||||
poolKeys = make([]string, len(settings.PointsPools))
|
||||
for i, p := range settings.PointsPools {
|
||||
poolNames[i] = p.Name
|
||||
poolKeys[i] = p.Key
|
||||
}
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"success": true,
|
||||
"commands": commands,
|
||||
"count": len(commands),
|
||||
}
|
||||
|
||||
if err == nil && client != nil {
|
||||
// Construire le tableau depuis points_extra[poolKey] pour tous les pools (stockage dynamique)
|
||||
poolPoints := make([]int, len(poolKeys))
|
||||
for i, key := range poolKeys {
|
||||
if key != "" {
|
||||
poolPoints[i] = client.PointsExtra[key]
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📊 [HISTORY] pool_names=%v pool_keys=%v pool_points=%v extra=%v",
|
||||
poolNames, poolKeys, poolPoints, client.PointsExtra)
|
||||
|
||||
response["client_stats"] = gin.H{
|
||||
"username": client.Username,
|
||||
"total_commands": client.Command,
|
||||
"points_extra": client.PointsExtra,
|
||||
"pool_points": poolPoints,
|
||||
"pool_names": poolNames,
|
||||
"penalties": client.Amende,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
|
||||
// GET /api/v1/my-commands/history/detailed
|
||||
func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
|
||||
@@ -102,7 +28,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
usernameStr := username.(string)
|
||||
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
|
||||
|
||||
// ✅ Récupérer les commandes terminées
|
||||
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
||||
@@ -112,7 +37,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Enrichir chaque commande avec ses items
|
||||
var enrichedCommands []map[string]any
|
||||
|
||||
for _, command := range commands {
|
||||
@@ -121,7 +45,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Récupérer les items de cette commande
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
|
||||
@@ -130,9 +53,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
|
||||
// Ajouter les items à la commande
|
||||
enrichedCommand := make(map[string]any)
|
||||
for k, v := range command {
|
||||
enrichedCommand[k] = v
|
||||
}
|
||||
maps.Copy(enrichedCommand, command)
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
@@ -141,7 +62,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
|
||||
|
||||
// ✅ Récupérer les infos client
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
response := gin.H{
|
||||
@@ -168,7 +88,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
func GetOrderHistory(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
|
||||
@@ -180,7 +99,6 @@ func GetOrderHistory(c *gin.Context) {
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Récupérer l'ID de la commande
|
||||
var commandID int
|
||||
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -191,7 +109,6 @@ func GetOrderHistory(c *gin.Context) {
|
||||
|
||||
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
|
||||
|
||||
// ✅ Vérifier que la commande existe
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
|
||||
@@ -201,7 +118,6 @@ func GetOrderHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Vérifier que la commande appartient au client
|
||||
cmdUsername, ok := command["username"].(string)
|
||||
if !ok || cmdUsername != usernameStr {
|
||||
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
|
||||
@@ -211,18 +127,16 @@ func GetOrderHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Récupérer les logs de la commande
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
|
||||
logs = []map[string]interface{}{}
|
||||
logs = []map[string]any{}
|
||||
}
|
||||
|
||||
// ✅ Récupérer les items
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
|
||||
items = []map[string]interface{}{}
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type loginHistoryWeek struct {
|
||||
Week int `json:"week"`
|
||||
Entries []db.LoginHistoryEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// GetLivreurLoginHistory retourne l'historique de connexion d'un livreur pour un mois donné,
|
||||
// regroupé par semaine ISO (détail complet, pas d'agrégation par compteur).
|
||||
func GetLivreurLoginHistory(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
year := now.Year()
|
||||
month := int(now.Month())
|
||||
|
||||
if y := c.Query("year"); y != "" {
|
||||
parsed, err := strconv.Atoi(y)
|
||||
if err != nil || parsed < 2000 || parsed > 2100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Année invalide"})
|
||||
return
|
||||
}
|
||||
year = parsed
|
||||
}
|
||||
if m := c.Query("month"); m != "" {
|
||||
parsed, err := strconv.Atoi(m)
|
||||
if err != nil || parsed < 1 || parsed > 12 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Mois invalide"})
|
||||
return
|
||||
}
|
||||
month = parsed
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
entries, err := database.GetLivreurLoginHistoryByMonth(username, year, month)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération historique de connexion", err)
|
||||
return
|
||||
}
|
||||
|
||||
weekOrder := make([]int, 0)
|
||||
weekMap := make(map[int]*loginHistoryWeek)
|
||||
for _, e := range entries {
|
||||
_, isoWeek := e.CreatedAt.ISOWeek()
|
||||
w, ok := weekMap[isoWeek]
|
||||
if !ok {
|
||||
w = &loginHistoryWeek{Week: isoWeek}
|
||||
weekMap[isoWeek] = w
|
||||
weekOrder = append(weekOrder, isoWeek)
|
||||
}
|
||||
w.Entries = append(w.Entries, e)
|
||||
}
|
||||
|
||||
weeks := make([]*loginHistoryWeek, 0, len(weekOrder))
|
||||
for _, wk := range weekOrder {
|
||||
weeks = append(weeks, weekMap[wk])
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"username": username,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"weeks": weeks,
|
||||
"count": len(entries),
|
||||
})
|
||||
}
|
||||
@@ -20,7 +20,6 @@ func GetClientNotifications(c *gin.Context) {
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
// Récupérer toutes les notifications (max 50)
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
@@ -128,7 +127,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
|
||||
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]interface{}
|
||||
var n map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -171,7 +170,7 @@ func MarkNotificationsRead(c *gin.Context) {
|
||||
// Réécrire chaque notification avec read=true
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]interface{}
|
||||
var n map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@ type BasketsRequest struct {
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: AddProductsBasket
|
||||
// ============================================
|
||||
// POST /api/v1/panier/add
|
||||
func AddProductsBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -90,7 +87,6 @@ func GetAllBaskets(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
|
||||
@@ -100,7 +96,6 @@ func GetAllBaskets(c *gin.Context) {
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
|
||||
if username != authUsernameStr {
|
||||
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
|
||||
log.Printf(" Username du JWT: %s", authUsernameStr)
|
||||
@@ -111,10 +106,8 @@ func GetAllBaskets(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
|
||||
username = authUsernameStr
|
||||
|
||||
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
|
||||
_, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
|
||||
@@ -130,7 +123,7 @@ func GetAllBaskets(c *gin.Context) {
|
||||
|
||||
var totalAmount float64
|
||||
for _, item := range baskets {
|
||||
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts)
|
||||
totalAmount += item.Price
|
||||
}
|
||||
|
||||
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
|
||||
@@ -156,7 +149,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
|
||||
@@ -168,7 +160,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
|
||||
|
||||
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
|
||||
itemUsername, err := database.GetBasketItemOwner(req.ID)
|
||||
|
||||
if err != nil {
|
||||
@@ -187,7 +178,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Supprimer l'article
|
||||
err = database.DeleteProductFromBasket(req.ID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la suppression", err)
|
||||
@@ -205,7 +195,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
func ClearBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
|
||||
@@ -262,8 +251,8 @@ func ValidateBasket(c *gin.Context) {
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
UseReferralBalance bool `json:"use_referral_balance"`
|
||||
PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto"
|
||||
PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto)
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
PayCurrency string `json:"pay_currency"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
@@ -277,7 +266,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
req.DeliveryAddress = cmd.DeliveryAddress
|
||||
|
||||
// Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées)
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
|
||||
@@ -287,9 +275,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
|
||||
|
||||
// ============================================
|
||||
// 1️⃣ Vérifier que le panier n'est pas vide
|
||||
// ============================================
|
||||
items, err := database.GetBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Impossible de récupérer le panier", err)
|
||||
@@ -304,9 +289,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||
|
||||
// ============================================
|
||||
// 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant
|
||||
// ============================================
|
||||
var cartTotal float64
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok {
|
||||
@@ -314,7 +296,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Détecter si le panier contient un article récompense (prix 0)
|
||||
hasRewardItem := false
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok && price == 0 {
|
||||
@@ -322,17 +303,13 @@ func ValidateBasket(c *gin.Context) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Si récompense présente mais aucun produit payant → refuser
|
||||
if hasRewardItem && cartTotal <= 0 {
|
||||
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les paramètres globaux (zones + parrainage)
|
||||
appSettings, _ := database.GetSettings()
|
||||
|
||||
// Récupérer le solde parrainage disponible (seulement si le système est activé)
|
||||
var referralBalance float64
|
||||
if req.UseReferralBalance && appSettings.ReferralEnabled {
|
||||
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
|
||||
@@ -366,8 +343,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
|
||||
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
|
||||
var referralUsed float64
|
||||
if req.UseReferralBalance && referralBalance > 0 {
|
||||
effectivePayment := cartTotal - referralBalance
|
||||
@@ -399,7 +374,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
|
||||
}
|
||||
|
||||
// Vérifier que tous les produits du panier ont encore un prix actif
|
||||
unavailable, err := database.GetUnavailableBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification produits", err)
|
||||
@@ -414,11 +388,11 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vérification option crypto
|
||||
isCrypto := req.PaymentMethod == "crypto"
|
||||
if isCrypto {
|
||||
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
if !npOk || np == nil {
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, npOk := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !npOk || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
|
||||
return
|
||||
}
|
||||
@@ -434,6 +408,10 @@ func ValidateBasket(c *gin.Context) {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
|
||||
if strings.Contains(err.Error(), "stock insuffisant") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Le stock ou le produit n'est plus disponible, repasse commande"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
|
||||
return
|
||||
}
|
||||
@@ -447,7 +425,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
|
||||
|
||||
// Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments.
|
||||
if isCrypto {
|
||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||
@@ -460,7 +437,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
payResp, err := np.CreatePayment(payReq)
|
||||
if err != nil {
|
||||
// Annuler la commande et restaurer le panier / parrainage
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
@@ -470,14 +446,21 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Passer la commande en 'pending_payment' (attente confirmation)
|
||||
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
|
||||
}
|
||||
|
||||
priceAmt, _ := payResp.PriceAmount.Float64()
|
||||
payAmt, _ := payResp.PayAmount.Float64()
|
||||
_, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt)
|
||||
if _, err := database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt); err != nil {
|
||||
log.Printf("❌ [CHECKOUT] Erreur enregistrement paiement crypto (commande %d, nowpayment %s): %v", commandID, payResp.PaymentID.String(), err)
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne lors de l'enregistrement du paiement"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
@@ -495,32 +478,8 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Notifier immédiatement tous les admins et agents cabine
|
||||
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
|
||||
|
||||
// ============================================
|
||||
// 3️⃣ Décrémenter le stock et vider le panier
|
||||
// ============================================
|
||||
err = database.ClearBasketOnCheckout(usernameStr)
|
||||
if err != nil {
|
||||
// Stock insuffisant au moment du checkout (concurrent) → annuler la commande
|
||||
if strings.Contains(err.Error(), "stock insuffisant") {
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
log.Printf("❌ [CHECKOUT] Stock insuffisant au moment de la validation: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Tu as trop attendu pour passer commande! Le stock ou le produit n'est plus disponible, repasse commande"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Impossible de valider le panier", err)
|
||||
return
|
||||
}
|
||||
log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé")
|
||||
|
||||
// ============================================
|
||||
// 4️⃣ Auto-assignation livreur (optionnel)
|
||||
// ============================================
|
||||
var assigned bool
|
||||
var assignInfo gin.H
|
||||
|
||||
@@ -540,7 +499,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
if err == nil {
|
||||
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
|
||||
|
||||
// ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic
|
||||
travelTime, distance, err := services.CalculateETAWithTomTom(
|
||||
nearest.Location,
|
||||
services.Coordinates{
|
||||
@@ -558,7 +516,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
|
||||
|
||||
// Assigner la commande au livreur
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(
|
||||
commandID,
|
||||
nearest.Username,
|
||||
@@ -571,13 +528,10 @@ func ValidateBasket(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
|
||||
} else {
|
||||
// Mettre à jour le statut du livreur
|
||||
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
|
||||
}
|
||||
|
||||
// Notifier le livreur de la nouvelle commande
|
||||
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
|
||||
if referralUsed > 0 {
|
||||
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
|
||||
@@ -585,8 +539,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
||||
}
|
||||
|
||||
// Notifier le client
|
||||
clientOrderID := database.GetClientOrderID(commandID)
|
||||
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
|
||||
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
|
||||
@@ -609,9 +561,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5️⃣ Réponse
|
||||
// ============================================
|
||||
newBalance, _ := database.GetClientReferralBalance(usernameStr)
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
@@ -638,7 +587,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// getBaseURL construit l'URL de base depuis la requête en cours
|
||||
func getBaseURL(c *gin.Context) string {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin)
|
||||
// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde.
|
||||
func SetClientParrainAdmin(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
@@ -28,14 +26,12 @@ func SetClientParrainAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le parrain existe
|
||||
parrain, err := database.GetClientByUsername(req.Parrain)
|
||||
if err != nil || parrain == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le client n'a pas déjà un parrain
|
||||
existing, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification parrain", err)
|
||||
@@ -46,25 +42,23 @@ func SetClientParrainAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil {
|
||||
settings, _ := database.GetSettings()
|
||||
creditAmount := 0.0
|
||||
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
|
||||
creditAmount = settings.ReferralAmount
|
||||
}
|
||||
|
||||
if err := database.SetClientParrainAndCredit(targetUsername, req.Parrain, creditAmount); err != nil {
|
||||
utils.ServerErr(c, "Erreur enregistrement parrain", err)
|
||||
return
|
||||
}
|
||||
|
||||
settings, _ := database.GetSettings()
|
||||
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
|
||||
if err := database.CreditClientReferral(req.Parrain, settings.ReferralAmount); err != nil {
|
||||
log.Printf("⚠️ [PARRAIN] Impossible de créditer %s: %v", req.Parrain, err)
|
||||
} else {
|
||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, settings.ReferralAmount)
|
||||
}
|
||||
}
|
||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Parrain enregistré",
|
||||
"client": targetUsername,
|
||||
"parrain": req.Parrain,
|
||||
"amount_credited": settings.ReferralAmount,
|
||||
"amount_credited": creditAmount,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,34 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// eligibleRewardProductIDs détermine, pour un pool donné, quels product_id de
|
||||
// reward.RewardItems sont éligibles : sa catégorie (via CategoryConfigs) doit
|
||||
// faire partie des catégories du pool, soit par whitelist explicite (ProductIDs)
|
||||
// soit par correspondance de catégorie produit (AllProducts).
|
||||
func eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]bool {
|
||||
eligible := make(map[int]bool)
|
||||
if reward == nil {
|
||||
return eligible
|
||||
}
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !poolCategories[cfg.Category] {
|
||||
continue
|
||||
}
|
||||
if cfg.AllProducts {
|
||||
for pid, cat := range productCategories {
|
||||
if cat == cfg.Category {
|
||||
eligible[pid] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
eligible[pid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return eligible
|
||||
}
|
||||
|
||||
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
|
||||
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
||||
func GetMyPointsRewards(c *gin.Context) {
|
||||
@@ -48,14 +76,22 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
ProductNames []string `json:"product_names"`
|
||||
}
|
||||
|
||||
type RewardItemResponse struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
type PoolInfo struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Points int `json:"points"`
|
||||
RewardsEarned int `json:"rewards_earned"`
|
||||
RewardsClaimed int `json:"rewards_claimed"`
|
||||
RewardsAvailable int `json:"rewards_available"`
|
||||
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Points int `json:"points"`
|
||||
RewardsEarned int `json:"rewards_earned"`
|
||||
RewardsClaimed int `json:"rewards_claimed"`
|
||||
RewardsAvailable int `json:"rewards_available"`
|
||||
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
||||
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
|
||||
}
|
||||
|
||||
// Collecter tous les product_ids nécessaires en un seul passage
|
||||
@@ -73,6 +109,7 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
|
||||
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
|
||||
|
||||
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
||||
for _, pool := range settings.PointsPools {
|
||||
@@ -83,12 +120,9 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
if reward != nil && reward.Threshold > 0 {
|
||||
earned = pts / reward.Threshold
|
||||
available = earned - redeemed
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
available = max(earned-redeemed, 0)
|
||||
}
|
||||
|
||||
// Filtrer les category_configs aux seules catégories du pool
|
||||
poolCats := make(map[string]bool, len(pool.Categories))
|
||||
for _, c := range pool.Categories {
|
||||
poolCats[c] = true
|
||||
@@ -114,24 +148,35 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
eligibleProductIDs := eligibleRewardProductIDs(reward, poolCats, productCategories)
|
||||
eligibleRewardItems := make([]RewardItemResponse, 0)
|
||||
if reward != nil {
|
||||
for _, item := range reward.RewardItems {
|
||||
if !eligibleProductIDs[item.ProductID] {
|
||||
continue
|
||||
}
|
||||
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
|
||||
ProductID: item.ProductID,
|
||||
ProductName: productNames[item.ProductID],
|
||||
Quantity: item.Quantity,
|
||||
Price: item.Price,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pools = append(pools, PoolInfo{
|
||||
Key: pool.Key,
|
||||
Name: pool.Name,
|
||||
Points: pts,
|
||||
RewardsEarned: earned,
|
||||
RewardsClaimed: redeemed,
|
||||
RewardsAvailable: available,
|
||||
EligibleConfigs: eligibleConfigs,
|
||||
Key: pool.Key,
|
||||
Name: pool.Name,
|
||||
Points: pts,
|
||||
RewardsEarned: earned,
|
||||
RewardsClaimed: redeemed,
|
||||
RewardsAvailable: available,
|
||||
EligibleConfigs: eligibleConfigs,
|
||||
EligibleRewardItems: eligibleRewardItems,
|
||||
})
|
||||
}
|
||||
|
||||
// Construire la liste des produits récompense avec leurs noms
|
||||
type RewardItemResponse struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
var rewardMeta gin.H
|
||||
if reward != nil {
|
||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
||||
@@ -168,7 +213,7 @@ func ClaimMyReward(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
PoolKey string `json:"pool_key" binding:"required"`
|
||||
ProductID int `json:"product_id"` // optionnel : 0 = automatique (1 seul item)
|
||||
ProductID int `json:"product_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
|
||||
@@ -194,53 +239,86 @@ func ClaimMyReward(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le pool existe
|
||||
poolExists := false
|
||||
for _, p := range settings.PointsPools {
|
||||
if p.Key == req.PoolKey {
|
||||
poolExists = true
|
||||
// Vérifier que le pool existe et récupérer ses catégories
|
||||
var selectedPool *models.PointsPool
|
||||
for i := range settings.PointsPools {
|
||||
if settings.PointsPools[i].Key == req.PoolKey {
|
||||
selectedPool = &settings.PointsPools[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !poolExists {
|
||||
if selectedPool == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold)
|
||||
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
|
||||
// fait partie des catégories du pool (via CategoryConfigs) — sans ce
|
||||
// filtre, un client pourrait réclamer n'importe quel produit récompense
|
||||
// (toutes catégories confondues) avec les points d'un pool quelconque.
|
||||
poolCategories := make(map[string]bool, len(selectedPool.Categories))
|
||||
for _, cat := range selectedPool.Categories {
|
||||
poolCategories[cat] = true
|
||||
}
|
||||
|
||||
rewardProductIDs := make([]int, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID > 0 {
|
||||
rewardProductIDs = append(rewardProductIDs, item.ProductID)
|
||||
}
|
||||
}
|
||||
productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture catégories produits", err)
|
||||
return
|
||||
}
|
||||
|
||||
eligibleProductIDs := eligibleRewardProductIDs(reward, poolCategories, productCategories)
|
||||
|
||||
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
if eligibleProductIDs[item.ProductID] {
|
||||
eligibleItems = append(eligibleItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
itemsToAdd := eligibleItems
|
||||
if req.ProductID > 0 {
|
||||
itemsToAdd = nil
|
||||
for _, item := range eligibleItems {
|
||||
if item.ProductID == req.ProductID {
|
||||
itemsToAdd = []models.RewardItem{item}
|
||||
break
|
||||
}
|
||||
}
|
||||
if itemsToAdd == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Ce produit n'est pas éligible pour cette récompense"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "produit récompense introuvable") {
|
||||
log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
utils.ServerErr(c, "Erreur réclamation récompense", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Si le client a sélectionné un produit spécifique parmi plusieurs, ne donner que celui-là
|
||||
itemsToAdd := reward.RewardItems
|
||||
if req.ProductID > 0 && len(reward.RewardItems) > 1 {
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID == req.ProductID {
|
||||
itemsToAdd = []models.RewardItem{item}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter les produits récompense au panier si configurés
|
||||
productAdded := false
|
||||
productAdded := len(added) > 0
|
||||
var productNames []string
|
||||
if len(itemsToAdd) > 0 {
|
||||
if added, addErr := database.AddRewardsToBasket(username, itemsToAdd, req.PoolKey); addErr == nil && len(added) > 0 {
|
||||
productAdded = true
|
||||
for _, item := range added {
|
||||
productNames = append(productNames, item.ProductName)
|
||||
}
|
||||
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
||||
} else if addErr != nil {
|
||||
log.Printf("⚠️ [CLAIM] Impossible d'ajouter produits récompense: %v", addErr)
|
||||
}
|
||||
for _, item := range added {
|
||||
productNames = append(productNames, item.ProductName)
|
||||
}
|
||||
if productAdded {
|
||||
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -252,7 +330,6 @@ func ClaimMyReward(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin).
|
||||
func AdminResetClientRedeemed(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
poolKey := c.Query("pool_key")
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -116,7 +116,6 @@ func validateCategory(database *db.Database, category string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
|
||||
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
@@ -130,7 +129,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
}
|
||||
|
||||
mimeType := mtype.String()
|
||||
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
|
||||
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
||||
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
@@ -142,28 +140,9 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
return mimeType, nil
|
||||
}
|
||||
|
||||
// ✅ PROTECTION CONTRE PATH TRAVERSAL
|
||||
func sanitizeFilePath(path string) (string, error) {
|
||||
// Nettoyer le chemin
|
||||
cleaned := filepath.Clean(path)
|
||||
|
||||
// Vérifier qu'il ne contient pas de ".."
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("path traversal détecté")
|
||||
}
|
||||
|
||||
// Vérifier qu'il commence par "uploads/"
|
||||
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
|
||||
return "", fmt.Errorf("chemin invalide")
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func CreateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -172,14 +151,12 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
username, _ := safeGetUsername(c)
|
||||
|
||||
// ✅ PARSER AVEC LIMITE DE TAILLE
|
||||
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
category := strings.TrimSpace(c.PostForm("category"))
|
||||
description := strings.TrimSpace(c.PostForm("description"))
|
||||
@@ -189,7 +166,6 @@ func CreateProduct(c *gin.Context) {
|
||||
unit = "u"
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
if err := validateProductName(name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -200,7 +176,6 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
category = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
@@ -219,7 +194,6 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VALIDER LE STOCK
|
||||
stock, err := strconv.ParseFloat(stockStr, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
|
||||
@@ -231,11 +205,10 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
|
||||
prices := []models.ProductPrice{}
|
||||
priceIndex := 0
|
||||
|
||||
for priceIndex < 100 { // Limite anti-spam
|
||||
for priceIndex < 100 {
|
||||
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
||||
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
||||
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
|
||||
@@ -323,7 +296,6 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LIMITER LE NOMBRE DE FICHIERS
|
||||
if len(files) > MaxFilesPerProduct {
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -336,13 +308,13 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
cleanProductName := cleanFileName(product.Name)
|
||||
uploadedMedia := []models.Media{}
|
||||
savedFiles := []string{}
|
||||
savedFiles := []models.Media{}
|
||||
storage := c.MustGet("storage").(services.Storage)
|
||||
var totalSize int64 = 0
|
||||
|
||||
for i, fileHeader := range files {
|
||||
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
|
||||
if fileHeader.Size > MaxFileSize {
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
|
||||
@@ -351,10 +323,8 @@ func CreateProduct(c *gin.Context) {
|
||||
}
|
||||
|
||||
totalSize += fileHeader.Size
|
||||
|
||||
// ✅ VÉRIFIER LA TAILLE TOTALE
|
||||
if totalSize > MaxTotalUploadSize {
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
|
||||
@@ -364,76 +334,50 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
|
||||
|
||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
|
||||
mimeType, err := validateFileMimeType(fileHeader)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ DÉTERMINER LE TYPE DE MÉDIA
|
||||
var mediaType string
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
mediaType = "image"
|
||||
} else if strings.HasPrefix(mimeType, "video/") {
|
||||
mediaType = "video"
|
||||
} else {
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
|
||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
|
||||
|
||||
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
|
||||
destFolder := filepath.Join("uploads", mediaType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"})
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(destFolder, uniqueFileName)
|
||||
|
||||
// ✅ VALIDER LE CHEMIN (protection path traversal)
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SAUVEGARDER LE FICHIER
|
||||
if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
||||
return
|
||||
}
|
||||
|
||||
savedFiles = append(savedFiles, safeFilePath)
|
||||
savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey})
|
||||
|
||||
// ✅ CRÉER L'ENTRÉE MÉDIA
|
||||
mediaURL := "/" + filepath.ToSlash(safeFilePath)
|
||||
media := models.Media{
|
||||
ProductID: product.ID,
|
||||
Type: mediaType,
|
||||
URL: mediaURL,
|
||||
Key: mediaKey,
|
||||
}
|
||||
|
||||
if err := database.CreateMedia(&media); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||
return
|
||||
@@ -480,7 +424,6 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
|
||||
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateCategory(database, category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
@@ -499,7 +442,6 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Charger les médias
|
||||
for i := range products {
|
||||
media, _ := database.GetMediaByProductID(products[i].ID)
|
||||
products[i].Media = media
|
||||
@@ -533,11 +475,9 @@ func GetProductByID(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// ✅ Charger les médias
|
||||
media, _ := database.GetMediaByProductID(product.ID)
|
||||
product.Media = media
|
||||
|
||||
// ✅ Filtrer les prix désactivés (sauf pour admin/cabine)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
filterActivepricesSingle(&product)
|
||||
@@ -552,7 +492,6 @@ func GetProductByID(c *gin.Context) {
|
||||
func UpdateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -567,7 +506,6 @@ func UpdateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
_, err = database.GetProductByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
@@ -589,7 +527,6 @@ func UpdateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
if err := validateProductName(updateData.Name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -733,8 +670,8 @@ func UpdateStock(c *gin.Context) {
|
||||
|
||||
func DeleteMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -753,28 +690,27 @@ func DeleteMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION
|
||||
filePath := strings.TrimPrefix(media.URL, "/")
|
||||
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DeleteMedia] Path invalide: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LE FICHIER PHYSIQUE
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err)
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER DE LA DB
|
||||
err = database.DeleteMedia(mediaID)
|
||||
if err != nil {
|
||||
if err := database.DeleteMedia(mediaID); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
return
|
||||
}
|
||||
|
||||
if media.Key != "" {
|
||||
if err := s3Service.DeleteFile(media.Key); err != nil {
|
||||
log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key)
|
||||
}
|
||||
} else {
|
||||
localStorage := services.NewLocalStorage("uploads")
|
||||
if err := localStorage.Delete(media.URL, ""); err != nil {
|
||||
log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Média supprimé",
|
||||
@@ -784,7 +720,6 @@ func DeleteMedia(c *gin.Context) {
|
||||
func UploadMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
@@ -792,19 +727,17 @@ func UploadMedia(c *gin.Context) {
|
||||
}
|
||||
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
|
||||
productID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || productID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
productName, err := database.GetProductNameByID(productID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
@@ -813,7 +746,6 @@ func UploadMedia(c *gin.Context) {
|
||||
|
||||
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
|
||||
|
||||
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
|
||||
fileType := c.PostForm("type")
|
||||
if fileType != "image" && fileType != "video" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
|
||||
@@ -827,7 +759,6 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER LA TAILLE
|
||||
const MaxFileSize = 10 * 1024 * 1024 // 10MB
|
||||
if file.Size > MaxFileSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -836,7 +767,6 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL
|
||||
detectedMime, err := validateFileMimeType(file)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
|
||||
@@ -846,7 +776,6 @@ func UploadMedia(c *gin.Context) {
|
||||
|
||||
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
|
||||
|
||||
// Vérifier que le MIME correspond au type déclaré
|
||||
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
|
||||
return
|
||||
@@ -856,40 +785,32 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ GÉNÉRER UN NOM UNIQUE
|
||||
cleanProductName := cleanFileName(productName)
|
||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
|
||||
|
||||
// ✅ CRÉER LE DOSSIER
|
||||
destFolder := filepath.Join("uploads", fileType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
|
||||
storage := c.MustGet("storage").(services.Storage)
|
||||
folder := fileType + "s"
|
||||
mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SAUVEGARDER LE FICHIER
|
||||
filePath := filepath.Join(destFolder, uniqueFileName)
|
||||
if err := c.SaveUploadedFile(file, filePath); err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ [UploadMedia] Fichier uploadé: %s", mediaURL)
|
||||
|
||||
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
|
||||
|
||||
// ✅ CRÉER L'ENTRÉE EN BASE
|
||||
mediaURL := "/" + filepath.ToSlash(filePath)
|
||||
media := models.Media{
|
||||
ProductID: productID,
|
||||
Type: fileType,
|
||||
URL: mediaURL,
|
||||
Key: mediaKey,
|
||||
}
|
||||
|
||||
err = database.CreateMedia(&media)
|
||||
if err != nil {
|
||||
// Rollback: supprimer le fichier
|
||||
os.Remove(filePath)
|
||||
if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil {
|
||||
log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr)
|
||||
}
|
||||
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||
return
|
||||
@@ -908,6 +829,28 @@ func UploadMedia(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func ServeMedia(c *gin.Context) {
|
||||
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||
|
||||
key := strings.TrimPrefix(c.Param("key"), "/")
|
||||
if key == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"})
|
||||
return
|
||||
}
|
||||
|
||||
body, contentType, err := s3Service.GetFile(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
|
||||
return
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, body)
|
||||
}
|
||||
|
||||
func ActivePrice(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
role := c.GetString("role")
|
||||
@@ -952,10 +895,6 @@ func DesActivePrice(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DELETE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func DeleteProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -976,32 +915,28 @@ func DeleteProduct(c *gin.Context) {
|
||||
|
||||
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
|
||||
|
||||
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
|
||||
mediaList, err := database.GetMediaByProductID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
|
||||
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||
localStorage := services.NewLocalStorage("uploads")
|
||||
for _, media := range mediaList {
|
||||
filePath := strings.TrimPrefix(media.URL, "/")
|
||||
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err)
|
||||
if media.Key != "" {
|
||||
if err := s3Service.DeleteFile(media.Key); err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
|
||||
}
|
||||
} else {
|
||||
if err := localStorage.Delete(media.URL, ""); err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
|
||||
database.DeleteMediaByProductID(id)
|
||||
|
||||
// ✅ SUPPRIMER LE PRODUIT
|
||||
err = database.DeleteProduct(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
|
||||
@@ -1016,17 +951,11 @@ func DeleteProduct(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
func rollbackFiles(files []string) {
|
||||
for _, file := range files {
|
||||
safeFilePath, err := sanitizeFilePath(file)
|
||||
if err != nil {
|
||||
continue
|
||||
func rollbackFiles(storage services.Storage, files []models.Media) {
|
||||
for _, f := range files {
|
||||
if err := storage.Delete(f.URL, f.Key); err != nil {
|
||||
log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err)
|
||||
}
|
||||
os.Remove(safeFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SubmitLivreurRating(c *gin.Context) {
|
||||
clientUsername := c.GetString("username")
|
||||
if clientUsername == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
orderID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || orderID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Rating int `json:"rating" binding:"required,min=1,max=5"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Note invalide (1 à 5 requis)"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
ownerUsername, livreurUsername, err := database.GetOrderForRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture commande", err)
|
||||
return
|
||||
}
|
||||
if ownerUsername == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable ou non terminée"})
|
||||
return
|
||||
}
|
||||
if ownerUsername != clientUsername {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non autorisée"})
|
||||
return
|
||||
}
|
||||
if livreurUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun livreur assigné à cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := database.GetOrderRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification avis", err)
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Vous avez déjà noté ce livreur pour cette commande"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.SubmitLivreurRating(orderID, livreurUsername, clientUsername, req.Rating, req.Comment); err != nil {
|
||||
utils.ServerErr(c, "Erreur enregistrement avis", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func GetLivreurRatings(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
ratings, avg, err := database.GetLivreurRatings(username)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur récupération avis", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ratings": ratings,
|
||||
"average": avg,
|
||||
"count": len(ratings),
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyRatings retourne les avis reçus par le livreur connecté (uniquement les siens).
|
||||
func GetMyRatings(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" || c.GetString("role") != "livreur" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
ratings, avg, err := database.GetLivreurRatings(username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération avis"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ratings": ratings,
|
||||
"average": avg,
|
||||
"count": len(ratings),
|
||||
})
|
||||
}
|
||||
|
||||
func GetOrderRatingStatus(c *gin.Context) {
|
||||
clientUsername := c.GetString("username")
|
||||
if clientUsername == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
orderID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || orderID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
rating, err := database.GetOrderRating(orderID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur", err)
|
||||
return
|
||||
}
|
||||
|
||||
if rating == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"rated": false})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rated": true, "rating": rating.Rating, "comment": rating.Comment})
|
||||
}
|
||||
@@ -307,19 +307,22 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible)
|
||||
// La clé est un hash (HSet), jamais une simple valeur — Redis.Get renvoie
|
||||
// une erreur WRONGTYPE dessus, silencieusement ignorée ici auparavant,
|
||||
// ce qui faisait toujours renvoyer etaMinutes=0.
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result()
|
||||
eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||
|
||||
var etaMinutes int = 0
|
||||
var etaSetAt int64 = 0
|
||||
if etaData != "" {
|
||||
var eta map[string]interface{}
|
||||
json.Unmarshal([]byte(etaData), &eta)
|
||||
if minutes, ok := eta["minutes"].(float64); ok {
|
||||
etaMinutes = int(minutes)
|
||||
if minutesStr, ok := eta["eta_minutes"]; ok {
|
||||
if minutes, err := strconv.Atoi(minutesStr); err == nil {
|
||||
etaMinutes = minutes
|
||||
}
|
||||
if timestamp, ok := eta["set_at"].(float64); ok {
|
||||
etaSetAt = int64(timestamp)
|
||||
}
|
||||
if updatedAtStr, ok := eta["updated_at"]; ok {
|
||||
if timestamp, err := strconv.ParseInt(updatedAtStr, 10, 64); err == nil {
|
||||
etaSetAt = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,36 +950,6 @@ func SubtractClientPointsAdmin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func GetRealtimeStats(c *gin.Context) {
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := db.Redis.HGetAll(db.RedisCtx, "stats:realtime").Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur lors de la récupération des statistiques",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Aucune statistique disponible pour le moment",
|
||||
"stats": map[string]interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
func refreshETAForActivDelivery(username string, lat, lon float64) {
|
||||
// 1. Récupérer le statut actuel du livreur
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||
@@ -1047,12 +1020,17 @@ func refreshETAForActivDelivery(username string, lat, lon float64) {
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"distance_km": distanceKm,
|
||||
"with_traffic": err == nil,
|
||||
"command_id": commandID,
|
||||
// eta_minutes ET total_eta_minutes doivent tous les deux être présents
|
||||
// (voir le commentaire de SetCommandETAWithDetails) — sans quoi les
|
||||
// lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien.
|
||||
"eta_minutes": etaMinutes,
|
||||
"total_eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
"distance_km": distanceKm,
|
||||
"with_traffic": err == nil,
|
||||
})
|
||||
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ func GetPublicSettings(c *gin.Context) {
|
||||
"shop_name": settings.ShopName,
|
||||
"two_fa_enabled": settings.Telegram2FAEnabled,
|
||||
"contact_telegram": settings.ContactTelegram,
|
||||
"client_color_primary": settings.ClientColorPrimary,
|
||||
"client_color_secondary": settings.ClientColorSecondary,
|
||||
"client_color_success": settings.ClientColorSuccess,
|
||||
"client_color_danger": settings.ClientColorDanger,
|
||||
"client_color_warning": settings.ClientColorWarning,
|
||||
"client_title_gradient_from": settings.ClientTitleGradientFrom,
|
||||
"client_title_gradient_to": settings.ClientTitleGradientTo,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+320
-121
@@ -1,38 +1,270 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
|
||||
|
||||
// sections valides pour le reset
|
||||
var validStatsSections = map[string]string{
|
||||
"commandes": "stats_reset_commandes_at",
|
||||
"revenus": "stats_reset_revenus_at",
|
||||
"produits": "stats_reset_produits_at",
|
||||
"heures": "stats_reset_heures_at",
|
||||
"jours": "stats_reset_jours_at",
|
||||
"doses": "stats_reset_doses_at",
|
||||
}
|
||||
|
||||
// ResetAdminStats réinitialise une section précise des statistiques.
|
||||
func ResetAdminStats(c *gin.Context) {
|
||||
section := c.Param("section")
|
||||
key, ok := validStatsSections[section]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if err := database.ResetAdminStat(key); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now})
|
||||
}
|
||||
|
||||
func dateFilter(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre
|
||||
// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de
|
||||
// commandes, le revenu et la quantité vendue. Les jours sans commande sont
|
||||
// inclus avec des valeurs à zéro.
|
||||
func GetAdminStatsByMonth(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
monthParam := c.Query("month")
|
||||
monthStart := time.Now()
|
||||
if monthParam != "" {
|
||||
parsed, err := time.Parse("2006-01", monthParam)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)})
|
||||
return
|
||||
}
|
||||
monthStart = parsed
|
||||
}
|
||||
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
|
||||
|
||||
resetCmd := database.ReadResetAt("stats_reset_commandes_at")
|
||||
|
||||
var rows []db.DailyMonthStatRow
|
||||
if err := database.StatsByDayForMonth(&rows, monthStart, resetCmd); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
rowByDay := make(map[string]db.DailyMonthStatRow, len(rows))
|
||||
for _, r := range rows {
|
||||
rowByDay[r.Day.Format("2006-01-02")] = r
|
||||
}
|
||||
|
||||
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
|
||||
byDay := make([]gin.H, daysInMonth)
|
||||
var totalOrders int
|
||||
var totalRevenue float64
|
||||
var totalQuantity float64
|
||||
|
||||
for i := range daysInMonth {
|
||||
day := monthStart.AddDate(0, 0, i)
|
||||
key := day.Format("2006-01-02")
|
||||
r, ok := rowByDay[key]
|
||||
if !ok {
|
||||
r = db.DailyMonthStatRow{Day: day}
|
||||
}
|
||||
byDay[i] = gin.H{
|
||||
"day": key,
|
||||
"label": day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
"revenue": r.Revenue,
|
||||
"quantity": r.Quantity,
|
||||
}
|
||||
totalOrders += r.Count
|
||||
totalRevenue += r.Revenue
|
||||
totalQuantity += r.Quantity
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"month": monthStart.Format("2006-01"),
|
||||
"summary": gin.H{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"total_quantity": totalQuantity,
|
||||
},
|
||||
"by_day": byDay,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAdminDailyDetail(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
dateParam := c.Query("date")
|
||||
date := time.Now()
|
||||
if dateParam != "" {
|
||||
parsed, err := time.Parse("2006-01-02", dateParam)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre date invalide (attendu YYYY-MM-DD) : %s", dateParam)})
|
||||
return
|
||||
}
|
||||
date = parsed
|
||||
}
|
||||
|
||||
var dailyRows []models.DailyProductRow
|
||||
if err := database.DailyProductDetailForDate(&dailyRows, date); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération du détail du jour: %s", err)})
|
||||
return
|
||||
}
|
||||
|
||||
type dailyCatGroup struct {
|
||||
Category string
|
||||
CategoryColor string
|
||||
TotalQuantity float64
|
||||
TotalRevenue float64
|
||||
Products []gin.H
|
||||
}
|
||||
var dailyCats []dailyCatGroup
|
||||
dailyCatIdx := map[string]int{}
|
||||
dailyTotalRevenue := 0.0
|
||||
dailyTotalQty := 0.0
|
||||
|
||||
for _, r := range dailyRows {
|
||||
dailyTotalRevenue += r.Revenue
|
||||
dailyTotalQty += r.TotalQuantity
|
||||
idx, ok := dailyCatIdx[r.Category]
|
||||
if !ok {
|
||||
idx = len(dailyCats)
|
||||
dailyCats = append(dailyCats, dailyCatGroup{
|
||||
Category: r.Category,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
dailyCatIdx[r.Category] = idx
|
||||
}
|
||||
dailyCats[idx].TotalQuantity += r.TotalQuantity
|
||||
dailyCats[idx].TotalRevenue += r.Revenue
|
||||
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.ProductName,
|
||||
"quantity": r.TotalQuantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
|
||||
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||
for i, g := range dailyCats {
|
||||
dailyCatsJSON[i] = gin.H{
|
||||
"category": g.Category,
|
||||
"category_color": g.CategoryColor,
|
||||
"total_quantity": g.TotalQuantity,
|
||||
"total_revenue": g.TotalRevenue,
|
||||
"products": g.Products,
|
||||
}
|
||||
}
|
||||
|
||||
dailyTotalOrders, _ := database.DailyOrdersCountForDate(date)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"date": date.Format("02/01/2006"),
|
||||
"total_orders": dailyTotalOrders,
|
||||
"total_quantity": dailyTotalQty,
|
||||
"total_revenue": dailyTotalRevenue,
|
||||
"categories": dailyCatsJSON,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
||||
func GetAdminStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
gdb := database.GDB
|
||||
|
||||
// ── Commandes par jour de la semaine (all time, non annulées) ──────────────
|
||||
var wdRows []models.WeekdayRow
|
||||
gdb.Raw(`
|
||||
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE status != 'cancelled'
|
||||
GROUP BY dow
|
||||
ORDER BY dow
|
||||
`).Scan(&wdRows)
|
||||
filters := database.LoadAdminStatsFilters()
|
||||
|
||||
// Toutes les requêtes sont indépendantes — on les lance en parallèle.
|
||||
var (
|
||||
wdRows []models.WeekdayRow
|
||||
dayRows []models.DayRow
|
||||
dayRevRows []models.DayRevenueRow
|
||||
hourRows []models.HourRow
|
||||
prodRows []models.ProductRow
|
||||
qtyRows []models.QuantityBreakdownRow
|
||||
dailyRows []models.DailyProductRow
|
||||
totalOrders int64
|
||||
totalRevenue float64
|
||||
dailyTotalOrders int64
|
||||
activeDays int64
|
||||
last30Count int64
|
||||
)
|
||||
|
||||
eg, _ := errgroup.WithContext(context.Background())
|
||||
eg.Go(func() error { return database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) })
|
||||
eg.Go(func() error { return database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) })
|
||||
eg.Go(func() error { return database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) })
|
||||
eg.Go(func() error { return database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) })
|
||||
eg.Go(func() error { return database.TopProducts(&prodRows, filters.ResetProduits, 15) })
|
||||
eg.Go(func() error { return database.QuantityBreakdown(&qtyRows, filters.ResetDoses) })
|
||||
eg.Go(func() error { return database.DailyProductDetail(&dailyRows) })
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
totalOrders, err = database.TotalOrders(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
dailyTotalOrders, err = database.DailyOrdersCount()
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
activeDays, err = database.ActiveDaysLast30(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
last30Count, err = database.OrdersCountLast30(filters.ResetCommandes)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des statistiques"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Commandes par jour de la semaine ──────────────────────────────────────
|
||||
byWeekday := make([]gin.H, 7)
|
||||
wdMap := make(map[int]int, len(wdRows))
|
||||
for _, r := range wdRows {
|
||||
wdMap[r.DOW] = r.Count
|
||||
}
|
||||
peakCount, peakWeekday := 0, ""
|
||||
for i := 0; i < 7; i++ {
|
||||
for i := range 7 {
|
||||
cnt := wdMap[i]
|
||||
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
||||
if cnt > peakCount {
|
||||
@@ -42,16 +274,6 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
||||
var dayRows []models.DayRow
|
||||
gdb.Raw(`
|
||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
AND status != 'cancelled'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day
|
||||
`).Scan(&dayRows)
|
||||
|
||||
byDay := make([]gin.H, len(dayRows))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
@@ -61,17 +283,7 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────
|
||||
var dayRevRows []models.DayRevenueRow
|
||||
gdb.Raw(`
|
||||
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
AND status = 'approved'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day
|
||||
`).Scan(&dayRevRows)
|
||||
|
||||
// ── Revenus par jour sur 30 jours ─────────────────────────────────────────
|
||||
byDayRevenue := make([]gin.H, len(dayRevRows))
|
||||
for i, r := range dayRevRows {
|
||||
byDayRevenue[i] = gin.H{
|
||||
@@ -81,25 +293,13 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes & revenus par heure (all time, non annulées) ───────────────
|
||||
var hourRows []models.HourRow
|
||||
gdb.Raw(`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM created_at)::int AS hour,
|
||||
COUNT(*) AS count,
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE status != 'cancelled'
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`).Scan(&hourRows)
|
||||
|
||||
// ── Commandes & revenus par heure ─────────────────────────────────────────
|
||||
hourMap := make(map[int]models.HourRow, len(hourRows))
|
||||
for _, r := range hourRows {
|
||||
hourMap[r.Hour] = r
|
||||
}
|
||||
byHour := make([]gin.H, 24)
|
||||
for h := 0; h < 24; h++ {
|
||||
for h := range 24 {
|
||||
r := hourMap[h]
|
||||
byHour[h] = gin.H{
|
||||
"hour": h,
|
||||
@@ -109,27 +309,7 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
|
||||
var prodRows []models.ProductRow
|
||||
gdb.Raw(`
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS name,
|
||||
SUM(ci.quantite) AS total_quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(ci.prix) AS revenue,
|
||||
COALESCE(p.category, '') AS category,
|
||||
COALESCE(cat.color, '#7c3aed') AS category_color
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
LEFT JOIN products p ON p.id = ci.product_id
|
||||
LEFT JOIN categories cat ON cat.name = p.category
|
||||
WHERE c.status != 'cancelled'
|
||||
GROUP BY ci.product_id, ci.produit, p.category, cat.color
|
||||
ORDER BY total_quantity DESC
|
||||
LIMIT 15
|
||||
`).Scan(&prodRows)
|
||||
|
||||
// ── Top produits ──────────────────────────────────────────────────────────
|
||||
topProducts := make([]gin.H, len(prodRows))
|
||||
topProductName := ""
|
||||
for i, r := range prodRows {
|
||||
@@ -147,26 +327,7 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Répartition des doses/quantités par produit ───────────────────────────
|
||||
var qtyRows []models.QuantityBreakdownRow
|
||||
gdb.Raw(`
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS product_name,
|
||||
ci.quantite AS quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(ci.quantite) AS total_sold,
|
||||
SUM(ci.prix) AS revenue,
|
||||
COALESCE(cat.color, '#7c3aed') AS category_color
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
LEFT JOIN products p ON p.id = ci.product_id
|
||||
LEFT JOIN categories cat ON cat.name = p.category
|
||||
WHERE c.status != 'cancelled'
|
||||
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
|
||||
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
|
||||
`).Scan(&qtyRows)
|
||||
|
||||
// ── Répartition des doses/quantités ───────────────────────────────────────
|
||||
type productGroup struct {
|
||||
ProductID int
|
||||
Name string
|
||||
@@ -195,7 +356,6 @@ func GetAdminStats(c *gin.Context) {
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
// Trier par total de commandes décroissant, garder 15 max
|
||||
for i := 0; i < len(groups)-1; i++ {
|
||||
for j := i + 1; j < len(groups); j++ {
|
||||
if groups[j].TotalOrders > groups[i].TotalOrders {
|
||||
@@ -207,39 +367,65 @@ func GetAdminStats(c *gin.Context) {
|
||||
groups = groups[:15]
|
||||
}
|
||||
byQuantity := make([]gin.H, len(groups))
|
||||
for i, g := range groups {
|
||||
for i, grp := range groups {
|
||||
byQuantity[i] = gin.H{
|
||||
"product_id": g.ProductID,
|
||||
"name": g.Name,
|
||||
"category_color": g.CategoryColor,
|
||||
"total_orders": g.TotalOrders,
|
||||
"quantities": g.Quantities,
|
||||
"product_id": grp.ProductID,
|
||||
"name": grp.Name,
|
||||
"category_color": grp.CategoryColor,
|
||||
"total_orders": grp.TotalOrders,
|
||||
"quantities": grp.Quantities,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Détail du jour ────────────────────────────────────────────────────────
|
||||
type dailyCatGroup struct {
|
||||
Category string
|
||||
CategoryColor string
|
||||
TotalQuantity float64
|
||||
TotalRevenue float64
|
||||
Products []gin.H
|
||||
}
|
||||
var dailyCats []dailyCatGroup
|
||||
dailyCatIdx := map[string]int{}
|
||||
dailyTotalRevenue := 0.0
|
||||
dailyTotalQty := 0.0
|
||||
for _, r := range dailyRows {
|
||||
dailyTotalRevenue += r.Revenue
|
||||
dailyTotalQty += r.TotalQuantity
|
||||
idx, ok := dailyCatIdx[r.Category]
|
||||
if !ok {
|
||||
idx = len(dailyCats)
|
||||
dailyCats = append(dailyCats, dailyCatGroup{
|
||||
Category: r.Category,
|
||||
CategoryColor: r.CategoryColor,
|
||||
})
|
||||
dailyCatIdx[r.Category] = idx
|
||||
}
|
||||
dailyCats[idx].TotalQuantity += r.TotalQuantity
|
||||
dailyCats[idx].TotalRevenue += r.Revenue
|
||||
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.ProductName,
|
||||
"quantity": r.TotalQuantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||
for i, grp := range dailyCats {
|
||||
dailyCatsJSON[i] = gin.H{
|
||||
"category": grp.Category,
|
||||
"category_color": grp.CategoryColor,
|
||||
"total_quantity": grp.TotalQuantity,
|
||||
"total_revenue": grp.TotalRevenue,
|
||||
"products": grp.Products,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Résumé global ─────────────────────────────────────────────────────────
|
||||
var totalOrders int64
|
||||
var totalRevenue float64
|
||||
gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders)
|
||||
gdb.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue)
|
||||
|
||||
avgPerDay := 0.0
|
||||
if totalOrders > 0 {
|
||||
// average over the last 30 days with data
|
||||
var activeDays int64
|
||||
gdb.Raw(`
|
||||
SELECT COUNT(DISTINCT DATE(created_at))
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
||||
`).Scan(&activeDays)
|
||||
if activeDays > 0 {
|
||||
var last30Count int64
|
||||
gdb.Raw(`
|
||||
SELECT COUNT(*) FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
||||
`).Scan(&last30Count)
|
||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||
}
|
||||
if totalOrders > 0 && activeDays > 0 {
|
||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -250,11 +436,24 @@ func GetAdminStats(c *gin.Context) {
|
||||
"top_product": topProductName,
|
||||
"avg_per_day": avgPerDay,
|
||||
},
|
||||
"by_weekday": byWeekday,
|
||||
"by_day_30": byDay,
|
||||
"by_day_revenue": byDayRevenue,
|
||||
"by_hour": byHour,
|
||||
"top_products": topProducts,
|
||||
"by_quantity": byQuantity,
|
||||
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
||||
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
||||
"reset_at_produits": dateFilter(filters.ResetProduits),
|
||||
"reset_at_heures": dateFilter(filters.ResetHeures),
|
||||
"reset_at_jours": dateFilter(filters.ResetJours),
|
||||
"reset_at_doses": dateFilter(filters.ResetDoses),
|
||||
"by_weekday": byWeekday,
|
||||
"by_day_30": byDay,
|
||||
"by_day_revenue": byDayRevenue,
|
||||
"by_hour": byHour,
|
||||
"top_products": topProducts,
|
||||
"by_quantity": byQuantity,
|
||||
"daily_detail": gin.H{
|
||||
"date": time.Now().Format("02/01/2006"),
|
||||
"total_orders": dailyTotalOrders,
|
||||
"total_quantity": dailyTotalQty,
|
||||
"total_revenue": dailyTotalRevenue,
|
||||
"categories": dailyCatsJSON,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,9 +198,6 @@ func UpdateClientByAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LOG DEBUG - Voir ce qui est reçu
|
||||
log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req)
|
||||
|
||||
// Récupérer le client actuel
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user