Files
projet_gestion_commande/backend/gestion/db/db_commands.go
T
Xor290 e82ad3ae4d
Backend - Build & Lint / build (push) Canceled after 29m7s
chore: build
2026-08-18 20:02:59 +02:00

981 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&currentStock).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")
}
// Historiquement restreint à "livre" seul (cf. commentaire de
// TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
// incident réel où une vérification GPS en amont (coordonnées de
// destination périmées après un changement d'adresse, cf.
// updateCommandDestinationCoords) a bloqué la transition du livreur
// vers "livre" : la commande restait alors coincée, sans qu'admin ni
// cabine ne puissent confirmer la réception. On accepte désormais tout
// statut non terminal ("arrived" inclus), à l'image de
// ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
// pour que le staff garde toujours un moyen de débloquer une commande
// légitime indépendamment d'un blocage en amont côté livreur.
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
if !slices.Contains(validStatuses, cmd.Status) {
return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ?`, commandID, cmd.Status)
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
}