package db import ( "fmt" "log" "slices" "strings" "time" "gorm.io/gorm" ) // commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client. type commandItemFull struct { CommandID int `gorm:"column:command_id"` Produit string `gorm:"column:produit"` ProductID int `gorm:"column:product_id"` Quantite float64 `gorm:"column:quantite"` Prix float64 `gorm:"column:prix"` IsReward bool `gorm:"column:is_reward"` RewardPoolKey string `gorm:"column:reward_pool_key"` PromoDiscount float64 `gorm:"column:promo_discount"` ClientUsername string `gorm:"column:client_username"` ClientNom string `gorm:"column:client_nom"` ClientPrenom string `gorm:"column:client_prenom"` ClientTelephone string `gorm:"column:client_telephone"` DeliveryAddress string `gorm:"column:delivery_address"` Status string `gorm:"column:status"` } func (commandItemFull) TableName() string { return "command_items" } // InsertCommandItemsBatch insère plusieurs items en une seule requête. func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error { if len(items) == 0 { return nil } return d.GDB.Create(&items).Error } // ============================================ // VALIDATION HELPERS func validateCommandID(commandID int) error { if commandID <= 0 { return fmt.Errorf("ID commande invalide: %d", commandID) } if commandID > 2147483647 { return fmt.Errorf("ID commande trop grand") } return nil } func validateItemID(itemID int) error { if itemID <= 0 { return fmt.Errorf("ID item invalide: %d", itemID) } if itemID > 2147483647 { return fmt.Errorf("ID item trop grand") } return nil } func validateQuantite(quantite float64) error { if quantite <= 0 { return fmt.Errorf("quantité doit être > 0") } if quantite > 10000 { return fmt.Errorf("quantité trop élevée (max 10000)") } return nil } func validatePrix(prix float64) error { if prix <= 0 { return fmt.Errorf("prix doit être > 0") } if prix > 100000 { return fmt.Errorf("prix trop élevé (max 100000)") } return nil } func validateUsername(username string) error { if len(username) == 0 { return fmt.Errorf("username vide") } if len(username) > 100 { return fmt.Errorf("username trop long (max 100)") } // Sanitize if strings.Contains(username, "..") || strings.Contains(username, "/") { return fmt.Errorf("username invalide") } return nil } func validateDeliveryAddress(address string) error { if len(address) == 0 { return fmt.Errorf("adresse vide") } if len(address) > 500 { return fmt.Errorf("adresse trop longue (max 500)") } return nil } func validateItemStatus(status string) error { validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled"} status = strings.ToLower(strings.TrimSpace(status)) if !slices.Contains(validStatuses, status) { return fmt.Errorf("statut invalide: %s", status) } return nil } // ============================================ // INSERT COMMAND ITEM - VERSION SÉCURISÉE // ============================================ func (d *Database) InsertCommandItemWithClientInfo( commandID int, produit string, productID int, quantite float64, prix float64, isReward bool, rewardPoolKey string, clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string, ) error { log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit) // ✅ VALIDATION COMPLÈTE if err := validateCommandID(commandID); err != nil { return err } if err := validateProductID(productID); err != nil { return err } if err := validateQuantite(quantite); err != nil { return err } // Les articles récompense ont prix=0, on saute la validation de prix pour eux if !isReward { if err := validatePrix(prix); err != nil { return err } } if err := validateUsername(clientUsername); err != nil { return err } if err := validateDeliveryAddress(deliveryAddress); err != nil { return err } // ✅ SANITIZE STRINGS produit = strings.TrimSpace(produit) if len(produit) > 200 { produit = produit[:200] } clientNom = strings.TrimSpace(clientNom) if len(clientNom) > 100 { clientNom = clientNom[:100] } clientPrenom = strings.TrimSpace(clientPrenom) if len(clientPrenom) > 100 { clientPrenom = clientPrenom[:100] } clientTelephone = strings.TrimSpace(clientTelephone) if len(clientTelephone) > 20 { clientTelephone = clientTelephone[:20] } // ✅ VÉRIFIER QUE LA COMMANDE EXISTE var exists bool if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM commandes WHERE id = ?)`, commandID).Scan(&exists).Error; err != nil { log.Printf("❌ Erreur vérification commande: %v", err) return fmt.Errorf("erreur vérification commande: %w", err) } if !exists { return fmt.Errorf("commande %d n'existe pas", commandID) } // ✅ INSERT err := d.GDB.Exec(` INSERT INTO command_items ( command_id, produit, product_id, quantite, prix, is_reward, reward_pool_key, client_username, client_nom, client_prenom, client_telephone, delivery_address, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, commandID, produit, productID, quantite, prix, isReward, rewardPoolKey, clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress, ).Error if err != nil { log.Printf("❌ Erreur INSERT command_items: %v", err) return fmt.Errorf("erreur insertion item: %w", err) } log.Printf("✅ Item inséré avec infos client: %s %s", clientNom, clientPrenom) return nil } // ============================================ // GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL // ============================================ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) { log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID) // ✅ VALIDATION if err := validateCommandID(commandID); err != nil { log.Printf("❌ [GetCommandItems] %v", err) return nil, err } var rows []struct { ID int `gorm:"column:id"` CommandID int `gorm:"column:command_id"` Produit string `gorm:"column:produit"` ProductID *int64 `gorm:"column:product_id"` Quantite float64 `gorm:"column:quantite"` Prix float64 `gorm:"column:prix"` IsReward bool `gorm:"column:is_reward"` RewardPoolKey string `gorm:"column:reward_pool_key"` ClientUsername string `gorm:"column:client_username"` ClientNom string `gorm:"column:client_nom"` ClientPrenom string `gorm:"column:client_prenom"` ClientTelephone string `gorm:"column:client_telephone"` DeliveryAddress *string `gorm:"column:delivery_address"` Status *string `gorm:"column:status"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` CommandStatus *string `gorm:"column:command_status"` CommandAddress *string `gorm:"column:command_address"` TotalPrix float64 `gorm:"column:total_prix"` ReferralUsed float64 `gorm:"column:referral_used"` LivreurAssign *string `gorm:"column:livreur_assign"` CommandCreatedAt *time.Time `gorm:"column:command_created_at"` Category string `gorm:"column:category"` Unit string `gorm:"column:unit"` ClientOrderNumber int `gorm:"column:client_order_number"` } err := d.GDB.Raw(` SELECT ci.id, ci.command_id, ci.produit, ci.product_id, ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key, ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone, ci.delivery_address, ci.status, ci.created_at, ci.updated_at, c.status as command_status, c.adresse as command_address, c.total_prix, c.referral_used, c.livreur_assign, c.created_at as command_created_at, COALESCE(p.category, '') as category, COALESCE(p.unit, '') as unit, c.client_order_id as client_order_number FROM command_items ci LEFT JOIN commandes c ON ci.command_id = c.id LEFT JOIN products p ON ci.product_id = p.id WHERE ci.command_id = ? ORDER BY ci.id ASC`, commandID).Scan(&rows).Error if err != nil { log.Printf("❌ Erreur query: %v", err) return nil, fmt.Errorf("erreur récupération items: %w", err) } items := make([]map[string]any, 0, len(rows)) for _, row := range rows { productIDValue := 0 if row.ProductID != nil { productIDValue = int(*row.ProductID) } var commandCreatedAt any if row.CommandCreatedAt != nil { commandCreatedAt = *row.CommandCreatedAt } item := map[string]any{ "id": row.ID, "command_id": row.CommandID, "produit": row.Produit, "product_id": productIDValue, "quantite": row.Quantite, "prix": row.Prix, "is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey, "client_username": row.ClientUsername, "client_nom": row.ClientNom, "client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone, "delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status), "created_at": row.CreatedAt, "updated_at": row.UpdatedAt, // Infos commande "command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress), "total_prix": row.TotalPrix, "referral_used": row.ReferralUsed, "livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt, "category": row.Category, "unit": row.Unit, "client_order_number": row.ClientOrderNumber, } items = append(items, item) } log.Printf("✅ %d items récupérés avec infos client et catégories", len(items)) return items, nil } // GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête. // Retourne map[commandID][]items, même structure que GetCommandItems. func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) { result := make(map[int][]map[string]any, len(commandIDs)) if len(commandIDs) == 0 { return result, nil } var rows []struct { ID int `gorm:"column:id"` CommandID int `gorm:"column:command_id"` Produit string `gorm:"column:produit"` ProductID *int64 `gorm:"column:product_id"` Quantite float64 `gorm:"column:quantite"` Prix float64 `gorm:"column:prix"` IsReward bool `gorm:"column:is_reward"` RewardPoolKey string `gorm:"column:reward_pool_key"` ClientUsername string `gorm:"column:client_username"` ClientNom string `gorm:"column:client_nom"` ClientPrenom string `gorm:"column:client_prenom"` ClientTelephone string `gorm:"column:client_telephone"` DeliveryAddress *string `gorm:"column:delivery_address"` Status *string `gorm:"column:status"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` CommandStatus *string `gorm:"column:command_status"` CommandAddress *string `gorm:"column:command_address"` TotalPrix float64 `gorm:"column:total_prix"` ReferralUsed float64 `gorm:"column:referral_used"` LivreurAssign *string `gorm:"column:livreur_assign"` CommandCreatedAt *time.Time `gorm:"column:command_created_at"` Category string `gorm:"column:category"` Unit string `gorm:"column:unit"` ClientOrderNumber int `gorm:"column:client_order_number"` } err := d.GDB.Raw(` SELECT ci.id, ci.command_id, ci.produit, ci.product_id, ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key, ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone, ci.delivery_address, ci.status, ci.created_at, ci.updated_at, c.status as command_status, c.adresse as command_address, c.total_prix, c.referral_used, c.livreur_assign, c.created_at as command_created_at, COALESCE(p.category, '') as category, COALESCE(p.unit, '') as unit, c.client_order_id as client_order_number FROM command_items ci LEFT JOIN commandes c ON ci.command_id = c.id LEFT JOIN products p ON ci.product_id = p.id WHERE ci.command_id IN ? ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("erreur récupération items batch: %w", err) } for _, row := range rows { productIDValue := 0 if row.ProductID != nil { productIDValue = int(*row.ProductID) } var commandCreatedAt any if row.CommandCreatedAt != nil { commandCreatedAt = *row.CommandCreatedAt } item := map[string]any{ "id": row.ID, "command_id": row.CommandID, "produit": row.Produit, "product_id": productIDValue, "quantite": row.Quantite, "prix": row.Prix, "is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey, "client_username": row.ClientUsername, "client_nom": row.ClientNom, "client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone, "delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status), "created_at": row.CreatedAt, "updated_at": row.UpdatedAt, "command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress), "total_prix": row.TotalPrix, "referral_used": row.ReferralUsed, "livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt, "category": row.Category, "unit": row.Unit, "client_order_number": row.ClientOrderNumber, } result[row.CommandID] = append(result[row.CommandID], item) } return result, nil } // ptrStr retourne la valeur d'un *string ou "" si nil func ptrStr(s *string) string { if s == nil { return "" } return *s } // DeleteCommandItem supprime un item d'une commande et restaure son stock si // la commande n'est pas déjà dans un état terminal. Le statut de la commande // est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction // que la suppression et le remboursement, pour éviter une course avec une // annulation concurrente de la commande entière (qui rembourserait déjà cet // item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin. func (d *Database) DeleteCommandItem(commandID, itemID int) error { log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID) if err := validateCommandID(commandID); err != nil { return err } if err := validateItemID(itemID); err != nil { return err } return d.GDB.Transaction(func(tx *gorm.DB) error { var cmdStatus string if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil { return fmt.Errorf("erreur vérification commande: %w", err) } if cmdStatus == "" { return fmt.Errorf("commande %d non trouvée", commandID) } var result struct { Prix float64 `gorm:"column:prix"` Quantite float64 `gorm:"column:quantite"` ProductID int `gorm:"column:product_id"` } if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil { return fmt.Errorf("erreur vérification item: %w", err) } if result.Prix == 0 && result.Quantite == 0 { return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID) } if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil { log.Printf("❌ Erreur DELETE command_items: %v", err) return fmt.Errorf("erreur suppression item: %w", err) } if err := tx.Exec( `UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`, result.Prix*result.Quantite, commandID, ).Error; err != nil { log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err) return fmt.Errorf("erreur mise à jour total commande: %w", err) } noRestoreStatuses := []string{"cancelled", "approved", "livre"} restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus) if restoreStock { if err := tx.Exec( `UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, result.Quantite, result.ProductID, ).Error; err != nil { log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err) return fmt.Errorf("erreur restauration stock: %w", err) } log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID) } return nil }) } func (d *Database) UpdateCommandItemStatus(itemID int, status string) error { if err := validateItemID(itemID); err != nil { log.Printf("❌ [UpdateCommandItemStatus] %v", err) return err } if err := validateItemStatus(status); err != nil { log.Printf("❌ [UpdateCommandItemStatus] %v", err) return err } var exists bool if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM command_items WHERE id = ?)`, itemID).Scan(&exists).Error; err != nil { log.Printf("❌ Erreur vérification: %v", err) return fmt.Errorf("erreur vérification item: %w", err) } if !exists { log.Printf("❌ Item %d non trouvé", itemID) return fmt.Errorf("item %d non trouvé", itemID) } result := d.GDB.Exec(` UPDATE command_items SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, status, itemID) if result.Error != nil { log.Printf("❌ Erreur UPDATE: %v", result.Error) return fmt.Errorf("erreur mise à jour statut: %w", result.Error) } if result.RowsAffected == 0 { log.Printf("❌ Item %d non trouvé", itemID) return fmt.Errorf("item non trouvé") } return nil }