package db import ( "encoding/json" "errors" "fmt" "gestion/models" "log" "slices" "strings" "time" "gorm.io/gorm" ) // errAlreadyApproved est retournée quand le client tente d'approuver une commande déjà approuvée. var errAlreadyApproved = errors.New("already_approved") func sanitizeString(s string) string { sanitized := strings.Map(func(r rune) rune { if r < 32 || r == 127 { return -1 } return r }, s) if len(sanitized) > 1000 { sanitized = sanitized[:1000] } return strings.TrimSpace(sanitized) } func validateAddress(address string) error { address = strings.TrimSpace(address) if address == "" { return fmt.Errorf("adresse vide non autorisée") } if len(address) > 500 { return fmt.Errorf("adresse trop longue (max 500 caractères)") } if strings.ContainsAny(address, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0E\x0F") { return fmt.Errorf("adresse contient des caractères de contrôle interdits") } return nil } type basketItem struct { ProductID int `gorm:"column:product_id"` Quantity float64 `gorm:"column:quantity"` Price float64 `gorm:"column:price"` IsReward bool `gorm:"column:is_reward"` RewardPoolKey string `gorm:"column:reward_pool_key"` } // validateCommandStatus vérifie si le statut est valide func validateCommandStatus(status string) error { validStatuses := map[string]bool{ "pending": true, "assigned": true, "en_route": true, "arrived": true, "livre": true, "approved": true, "cancelled": true, "disabled": true, } if !validStatuses[status] { return fmt.Errorf("statut invalide: %s", status) } return nil } func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) { if err := validateUsername(username); err != nil { return nil, err } if err := validateAddress(deliveryAddress); err != nil { return nil, err } client, err := d.GetClientByUsername(username) if err != nil { log.Printf("⚠️ Client non trouvé: %v", err) } clientNom := "" clientPrenom := "" clientTelephone := "" if client != nil { clientNom = sanitizeString(client.Nom) clientPrenom = sanitizeString(client.Prenom) clientTelephone = sanitizeString(client.Telephone) } var ( command *models.Command totalPrix float64 ) err = d.GDB.Transaction(func(tx *gorm.DB) error { // Verrou sur le panier : un double-submit concurrent du même client se // bloque ici puis échoue proprement ("panier vide") une fois le premier // passage terminé, au lieu de créer une commande fantôme. var basketItems []basketItem if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil { return fmt.Errorf("erreur récupération panier: %w", err) } if len(basketItems) == 0 { return fmt.Errorf("le panier est vide") } for _, item := range basketItems { if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 { return fmt.Errorf("données panier invalides") } totalPrix += item.Price } if totalPrix <= 0 || totalPrix > 100000 { return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix) } var cmdResult struct { ID int `gorm:"column:id"` ClientOrderID int `gorm:"column:client_order_id"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` } if err := tx.Raw(` INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at) VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id, client_order_id, created_at, updated_at`, username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil { return fmt.Errorf("erreur création commande: %w", err) } commandID := cmdResult.ID productIDs2 := make([]int, 0, len(basketItems)) for _, item := range basketItems { productIDs2 = append(productIDs2, item.ProductID) } productNames2, _ := d.GetProductNamesByIDs(productIDs2) batchItems := make([]commandItemFull, 0, len(basketItems)) for _, item := range basketItems { productName := productNames2[item.ProductID] if productName == "" { productName = fmt.Sprintf("Produit #%d", item.ProductID) } batchItems = append(batchItems, commandItemFull{ CommandID: commandID, Produit: productName, ProductID: item.ProductID, Quantite: item.Quantity, Prix: item.Price, IsReward: item.IsReward, RewardPoolKey: item.RewardPoolKey, ClientUsername: username, ClientNom: clientNom, ClientPrenom: clientPrenom, ClientTelephone: clientTelephone, DeliveryAddress: deliveryAddress, Status: "pending", }) } if err := tx.Create(&batchItems).Error; err != nil { return fmt.Errorf("erreur insertion items: %w", err) } // Les articles récompense (payés en points) restent des produits physiques // réellement distribués : le stock doit être décrémenté comme pour un // article payant. for _, item := range basketItems { var currentStock float64 if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil { return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err) } if currentStock < item.Quantity { return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID) } if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil { return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err) } } if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil { return err } command = &models.Command{ ID: commandID, ClientOrderID: cmdResult.ClientOrderID, Username: username, Status: "pending", Total: totalPrix, DeliveryAddress: deliveryAddress, CreatedAt: cmdResult.CreatedAt, UpdatedAt: cmdResult.UpdatedAt, } return nil }) if err != nil { log.Printf("❌ Erreur création commande: %v", err) return nil, err } sanitizedAddress := sanitizeLogMessage(deliveryAddress) d.AddCommandLog(command.ID, "created", fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s", sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)), username) return command, nil } func (d *Database) GetApprovedCommands() ([]models.Command, error) { var commands []models.Command err := d.GDB.Where("status = ?", "approved").Order("created_at DESC").Find(&commands).Error return commands, err } func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) { if username != "" { if err := validateUsername(username); err != nil { return nil, err } } if status != "" { if err := validateCommandStatus(status); err != nil { return nil, err } } var rows []struct { ID int `gorm:"column:id"` Username string `gorm:"column:username"` Status string `gorm:"column:status"` Adresse string `gorm:"column:adresse"` TotalPrix float64 `gorm:"column:total_prix"` LivreurAssign *string `gorm:"column:livreur_assign"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` ProposedAddress *string `gorm:"column:proposed_address"` AddressProposalStatus string `gorm:"column:address_proposal_status"` ClientOrderNumber int `gorm:"column:client_order_number"` ReferralUsed float64 `gorm:"column:referral_used"` CancelReason string `gorm:"column:cancel_reason"` } gdb := d.GDB.Table("commandes c"). Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign, c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status, c.client_order_id AS client_order_number, COALESCE(c.referral_used, 0) AS referral_used, COALESCE(c.cancel_reason, '') AS cancel_reason`) if status == "" { gdb = gdb.Where("c.status IN ?", []string{"pending", "assigned", "en_route", "arrived", "livre"}) } else { gdb = gdb.Where("c.status = ?", status) } if username != "" { gdb = gdb.Where("c.username = ?", username) } if err := gdb.Order("c.created_at DESC").Limit(1000).Scan(&rows).Error; err != nil { return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err) } commands := make([]map[string]any, 0, len(rows)) for _, row := range rows { command := map[string]any{ "id": row.ID, "username": row.Username, "status": row.Status, "adresse": sanitizeString(row.Adresse), "total_prix": row.TotalPrix, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt, "address_proposal_status": row.AddressProposalStatus, "client_order_number": row.ClientOrderNumber, "referral_used": row.ReferralUsed, "cancel_reason": row.CancelReason, } if row.LivreurAssign != nil { command["livreur_assign"] = *row.LivreurAssign } else { command["livreur_assign"] = nil } if row.ProposedAddress != nil { command["proposed_address"] = *row.ProposedAddress } else { command["proposed_address"] = nil } commands = append(commands, command) } return commands, nil } func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error { return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error } func (d *Database) GetCommandByID(id int) (map[string]any, error) { var row struct { ID int `gorm:"column:id"` Username string `gorm:"column:username"` Status string `gorm:"column:status"` Adresse string `gorm:"column:adresse"` TotalPrix float64 `gorm:"column:total_prix"` LivreurAssign *string `gorm:"column:livreur_assign"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` ProposedAddress *string `gorm:"column:proposed_address"` AddressProposalStatus string `gorm:"column:address_proposal_status"` ReferralUsed float64 `gorm:"column:referral_used"` ClientOrderNumber int `gorm:"column:client_order_number"` CancelReason string `gorm:"column:cancel_reason"` DestLatitude float64 `gorm:"column:dest_latitude"` DestLongitude float64 `gorm:"column:dest_longitude"` } if err := d.GDB.Table("commandes c"). Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign, c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status, c.referral_used, c.client_order_id AS client_order_number, COALESCE(c.cancel_reason, '') AS cancel_reason, COALESCE(c.dest_latitude, 0) AS dest_latitude, COALESCE(c.dest_longitude, 0) AS dest_longitude`). Where("c.id = ?", id). First(&row).Error; err != nil { return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err) } if row.ID == 0 { return nil, fmt.Errorf("commande non trouvée") } command := map[string]any{ "id": row.ID, "username": row.Username, "status": row.Status, "adresse": row.Adresse, "total_prix": row.TotalPrix, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt, "address_proposal_status": row.AddressProposalStatus, "referral_used": row.ReferralUsed, "client_order_number": row.ClientOrderNumber, "cancel_reason": row.CancelReason, "dest_latitude": row.DestLatitude, "dest_longitude": row.DestLongitude, } if row.LivreurAssign != nil { command["livreur_assign"] = *row.LivreurAssign } else { command["livreur_assign"] = nil } if row.ProposedAddress != nil { command["proposed_address"] = *row.ProposedAddress } else { command["proposed_address"] = nil } return command, nil } const lastDeliveryCoordsCacheTTL = 5 * time.Minute func lastDeliveryCoordsCacheKey(livreurUsername string) string { return fmt.Sprintf("livreur:last_delivery_coords:%s", livreurUsername) } // GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur. // Utilisé comme fallback quand le GPS temps réel est indisponible. Mis en cache quelques minutes // car appelé à chaque calcul d'ETA et la dernière livraison ne change pas souvent. func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) { cacheKey := lastDeliveryCoordsCacheKey(livreurUsername) if cached, err := Redis.Get(RedisCtx, cacheKey).Result(); err == nil { var coords struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` } if jsonErr := json.Unmarshal([]byte(cached), &coords); jsonErr == nil { return coords.Lat, coords.Lon, nil } } var result struct { DestLatitude float64 `gorm:"column:dest_latitude"` DestLongitude float64 `gorm:"column:dest_longitude"` } if err := d.GDB.Table("commandes"). Select("dest_latitude, dest_longitude"). Where("livreur_assign = ? AND status IN (?, ?, ?) AND dest_latitude IS NOT NULL AND dest_latitude != 0 AND dest_longitude IS NOT NULL AND dest_longitude != 0", livreurUsername, "livre", "delivered", "approved"). Order("updated_at DESC"). Limit(1). Scan(&result).Error; err != nil { return 0, 0, fmt.Errorf("aucune livraison précédente pour %s: %w", livreurUsername, err) } if result.DestLatitude == 0 || result.DestLongitude == 0 { return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername) } if coordsJSON, err := json.Marshal(map[string]float64{"lat": result.DestLatitude, "lon": result.DestLongitude}); err == nil { Redis.Set(RedisCtx, cacheKey, coordsJSON, lastDeliveryCoordsCacheTTL) } return result.DestLatitude, result.DestLongitude, nil } // GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global. // Retourne commandID en fallback si introuvable. func (d *Database) GetClientOrderID(commandID int) int { var result struct { ClientOrderID int `gorm:"column:client_order_id"` } if err := d.GDB.Model(&models.Command{}).Select("client_order_id").Where("id = ?", commandID).First(&result).Error; err != nil || result.ClientOrderID == 0 { return commandID } return result.ClientOrderID } func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error { if len(deliveryAddress) > 500 { return fmt.Errorf("adresse trop longue (max 500 caractères)") } if strings.TrimSpace(deliveryAddress) == "" { return fmt.Errorf("adresse vide non autorisée") } result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{ "adresse": deliveryAddress, "updated_at": time.Now(), }) if result.Error != nil { return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error) } if result.RowsAffected == 0 { return fmt.Errorf("commande non trouvée") } log.Printf("✅ Adresse commande %d mise à jour", commandID) return nil } // UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA // PROPRE commande, tant qu'elle n'est pas encore prise en charge par un // livreur (statut "en_route") ni terminée. La vérification d'appartenance et // de statut se fait dans la clause WHERE, atomiquement : impossible de // modifier la commande d'un autre client ou une commande déjà en route. func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error { if err := validateAddress(deliveryAddress); err != nil { return err } result := d.GDB.Exec(` UPDATE commandes SET adresse = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`, deliveryAddress, commandID, clientUsername) if result.Error != nil { return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error) } if result.RowsAffected == 0 { return fmt.Errorf("commande introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client") } d.AddCommandLog(commandID, "address_updated", fmt.Sprintf("Adresse corrigée par le client %s", clientUsername), clientUsername) log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername) return nil } // ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error { if err := validateAddress(proposedAddress); err != nil { return err } result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{ "proposed_address": proposedAddress, "address_proposal_status": "pending", "updated_at": time.Now(), }) if result.Error != nil { return fmt.Errorf("erreur proposition adresse: %w", result.Error) } if result.RowsAffected == 0 { return fmt.Errorf("commande non trouvée") } d.AddCommandLog(commandID, "address_proposed", fmt.Sprintf("Nouvelle adresse proposée par %s: %s", proposedBy, proposedAddress), proposedBy) log.Printf("✅ Adresse proposée pour commande %d par %s", commandID, proposedBy) return nil } // RespondToAddressProposal accepte ou refuse la proposition d'adresse func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error { var query string if accepted { query = `UPDATE commandes SET adresse = proposed_address, proposed_address = NULL, address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ? AND address_proposal_status = 'pending'` } else { query = `UPDATE commandes SET proposed_address = NULL, address_proposal_status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND username = ? AND address_proposal_status = 'pending'` } result := d.GDB.Exec(query, commandID, clientUsername) if result.Error != nil { return fmt.Errorf("erreur réponse proposition adresse: %w", result.Error) } if result.RowsAffected == 0 { return fmt.Errorf("aucune proposition en attente pour cette commande") } action := "refusée" if accepted { action = "acceptée" } d.AddCommandLog(commandID, "address_proposal_"+action, fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername), clientUsername) log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID) return nil } // UpdateCommandStatus met à jour le statut d'une commande func (d *Database) UpdateCommandStatus(commandID int, status string) error { if err := validateCommandStatus(status); err != nil { return err } result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{ "status": status, "updated_at": time.Now(), }) if result.Error != nil { return fmt.Errorf("erreur lors de la mise à jour du statut: %w", result.Error) } if result.RowsAffected == 0 { return fmt.Errorf("commande non trouvée") } return nil } func (d *Database) AddCommandLog(commandID int, status, message, author string) error { sanitizedMessage := sanitizeLogMessage(message) sanitizedAuthor := sanitizeLogMessage(author) if err := d.GDB.Create(&models.CommandLog{ CommandID: commandID, Status: status, Message: sanitizedMessage, Author: sanitizedAuthor, }).Error; err != nil { log.Printf("⚠️ Avertissement: impossible d'ajouter le log (table command_logs peut-être manquante): %v", err) return nil } return nil } // GetCommandLogs récupère tous les logs d'une commande func (d *Database) GetCommandLogs(commandID int) ([]map[string]any, error) { var rows []models.CommandLog if err := d.GDB.Where("command_id = ?", commandID).Order("created_at ASC").Find(&rows).Error; err != nil { log.Printf("⚠️ Avertissement: impossible de récupérer les logs: %v", err) return []map[string]any{}, nil } logs := make([]map[string]any, 0, len(rows)) for _, row := range rows { logs = append(logs, map[string]any{ "id": row.ID, "command_id": row.CommandID, "status": row.Status, "message": row.Message, "author": row.Author, "created_at": row.CreatedAt, }) } return logs, nil } func sanitizeLogMessage(message string) string { sanitized := strings.Map(func(r rune) rune { if r < 32 || r == 127 { return -1 } return r }, message) if len(sanitized) > 1000 { sanitized = sanitized[:1000] } return sanitized } func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (int, error) { var totalPoints int var cmdUsernameOut string var livreurAssignOut string err := d.GDB.Transaction(func(tx *gorm.DB) error { var cmd struct { Status string `gorm:"column:status"` Username string `gorm:"column:username"` LivreurAssign string `gorm:"column:livreur_assign"` TotalPrix float64 `gorm:"column:total_prix"` } if err := tx.Raw(` SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign, total_prix FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil { log.Printf("❌ [ValidateAtomic] Erreur SELECT: %v", err) return fmt.Errorf("erreur lecture commande: %w", err) } if cmd.Username == "" { log.Printf("❌ [ValidateAtomic] Commande %d non trouvée", commandID) return fmt.Errorf("commande non trouvée") } log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s", cmd.Status, cmd.Username, cmd.LivreurAssign) validStatuses := []string{"assigned", "en_route", "pending", "livre"} if !slices.Contains(validStatuses, cmd.Status) { log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status) return fmt.Errorf("statut invalide pour validation: %s", cmd.Status) } if cmd.Status == "approved" { log.Printf("⚠️ [ValidateAtomic] Commande %d déjà approuvée", commandID) return fmt.Errorf("commande déjà approuvée") } result := tx.Exec(` UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = ?`, commandID, cmd.Status) if result.Error != nil { log.Printf("❌ [ValidateAtomic] Erreur UPDATE: %v", result.Error) return fmt.Errorf("erreur mise à jour statut: %w", result.Error) } if result.RowsAffected == 0 { log.Printf("❌ [ValidateAtomic] Commande %d déjà modifiée (race condition évitée)", commandID) return fmt.Errorf("commande déjà modifiée par une autre requête") } if cmd.Username != "" { log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmd.Username) points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username) if err != nil { log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err) return fmt.Errorf("erreur attribution points: %w", err) } totalPoints = points log.Printf("✅ [ValidateAtomic] %d points attribués à %s", totalPoints, cmd.Username) if err := tx.Exec(` UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP WHERE username = ?`, cmd.Username).Error; err != nil { log.Printf("⚠️ [ValidateAtomic] Erreur incrémentation compteur: %v", err) } else { log.Printf("✅ [ValidateAtomic] Compteur commandes incrémenté pour %s", cmd.Username) } } if err := tx.Exec(` INSERT INTO command_logs (command_id, status, message, author, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`, commandID, "approved", fmt.Sprintf("Livraison validée par admin %s - %d points attribués", adminUsername, totalPoints), adminUsername).Error; err != nil { log.Printf("⚠️ [ValidateAtomic] Erreur ajout log: %v", err) } cmdUsernameOut = cmd.Username livreurAssignOut = cmd.LivreurAssign return nil }) if err != nil { return 0, err } log.Printf("🎉 [ValidateAtomic] SUCCÈS - Commande %d validée, %d points attribués", commandID, totalPoints) if livreurAssignOut != "" { log.Printf("📦 [ValidateAtomic] Optimisation queue pour livreur: %s", livreurAssignOut) go func() { if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil { log.Printf("⚠️ [ValidateAtomic] Erreur optimisation queue: %v", err) } }() } go func() { commandCacheKey := fmt.Sprintf("command:%d", commandID) Redis.Del(RedisCtx, commandCacheKey) if cmdUsernameOut != "" { clientCacheKey := fmt.Sprintf("client:%s", cmdUsernameOut) Redis.Del(RedisCtx, clientCacheKey) clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", cmdUsernameOut) Redis.Del(RedisCtx, clientCommandsCacheKey) } log.Printf("✅ [ValidateAtomic] Caches invalidés pour cmd %d", commandID) }() return totalPoints, nil } // ApproveDeliveryAtomic - Version atomique pour approbation client func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) { log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username) var totalPoints int var pointCategory string var livreurAssignOut string err := d.GDB.Transaction(func(tx *gorm.DB) error { var cmd struct { Status string `gorm:"column:status"` Username string `gorm:"column:username"` LivreurAssign string `gorm:"column:livreur_assign"` } if err := tx.Raw(` SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil { log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err) return fmt.Errorf("erreur lecture commande: %w", err) } if cmd.Username == "" { log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID) return fmt.Errorf("commande non trouvée") } log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", cmd.Status, cmd.Username) if cmd.Username != username { log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)", username, cmd.Username) return fmt.Errorf("cette commande ne vous appartient pas") } if cmd.Status == "approved" { log.Printf("ℹ️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID) return errAlreadyApproved } if cmd.Status != "livre" { log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status) return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status) } result := tx.Exec(` UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'livre' AND username = ?`, commandID, username) if result.Error != nil { log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", result.Error) return fmt.Errorf("erreur mise à jour statut: %w", result.Error) } if result.RowsAffected == 0 { log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID) return fmt.Errorf("commande déjà approuvée ou modifiée") } log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved") pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username) if err != nil { log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err) return fmt.Errorf("erreur attribution points: %w", err) } totalPoints = pts pointCategory = cat log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username) if err := tx.Exec(` UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP WHERE username = ?`, username).Error; err != nil { log.Printf("⚠️ [ApproveAtomic] Erreur incrémentation compteur: %v", err) } if err := tx.Exec(` INSERT INTO command_logs (command_id, status, message, author, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`, commandID, "approved", fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory), username).Error; err != nil { log.Printf("⚠️ [ApproveAtomic] Erreur ajout log: %v", err) } livreurAssignOut = cmd.LivreurAssign return nil }) if err != nil { if errors.Is(err, errAlreadyApproved) { return 0, "", nil } return 0, "", err } log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués", commandID, totalPoints, pointCategory) if livreurAssignOut != "" { log.Printf("📦 [ApproveAtomic] Optimisation queue pour livreur: %s", livreurAssignOut) go func() { if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil { log.Printf("⚠️ [ApproveAtomic] Erreur optimisation queue: %v", err) } }() } go func() { commandCacheKey := fmt.Sprintf("command:%d", commandID) Redis.Del(RedisCtx, commandCacheKey) clientCacheKey := fmt.Sprintf("client:%s", username) Redis.Del(RedisCtx, clientCacheKey) clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", username) Redis.Del(RedisCtx, clientCommandsCacheKey) log.Printf("✅ [ApproveAtomic] Caches invalidés") }() return totalPoints, pointCategory, nil } // ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) { log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername) var totalPoints int var pointCategory string var clientUsernameOut string var livreurAssignOut string err := d.GDB.Transaction(func(tx *gorm.DB) error { var cmd struct { Status string `gorm:"column:status"` Username string `gorm:"column:username"` LivreurAssign string `gorm:"column:livreur_assign"` } if err := tx.Raw(` SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil { return fmt.Errorf("erreur lecture commande: %w", err) } if cmd.Username == "" { return fmt.Errorf("commande non trouvée") } if cmd.Status != "livre" { return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status) } result := tx.Exec(` UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'livre'`, commandID) if result.Error != nil { return fmt.Errorf("erreur mise à jour statut: %w", result.Error) } if result.RowsAffected == 0 { return fmt.Errorf("commande déjà approuvée ou modifiée") } pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username) if err != nil { return fmt.Errorf("erreur attribution points: %w", err) } totalPoints = pts pointCategory = cat if err := tx.Exec(` UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP WHERE username = ?`, cmd.Username).Error; err != nil { log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err) } if err := tx.Exec(` INSERT INTO command_logs (command_id, status, message, author, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`, commandID, "approved", fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, cmd.Username, totalPoints), staffUsername).Error; err != nil { log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err) } clientUsernameOut = cmd.Username livreurAssignOut = cmd.LivreurAssign return nil }) if err != nil { return 0, "", "", err } log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s", commandID, staffUsername, totalPoints, clientUsernameOut) if livreurAssignOut != "" { go func() { if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil { log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err) } }() } go func() { Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID)) Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsernameOut)) Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsernameOut)) }() return totalPoints, pointCategory, clientUsernameOut, nil } func (d *Database) SetCommandCancelReason(commandID int, reason string) error { if len(reason) > 500 { reason = reason[:500] } return d.GDB.Exec( `UPDATE commandes SET cancel_reason = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, reason, commandID, ).Error }