package handlers import ( "fmt" "gestion/db" "gestion/models" "gestion/utils" "log" "math" "net/http" "strings" "github.com/gin-gonic/gin" ) // normalizeRewardCategoryType retombe sur "free_product" pour toute valeur // vide ou inconnue — rétrocompatibilité avec les configurations enregistrées // avant l'introduction du type par catégorie (RewardCategoryConfig.Type). func normalizeRewardCategoryType(t string) string { if t == "half_price_product" { return "half_price_product" } return "free_product" } // categoryRewardCandidate représente un produit éligible à la récompense pour // une config de catégorie donnée : son type ("free_product" | // "half_price_product") et la quantité configurée pour cette catégorie. type categoryRewardCandidate struct { Category string Type string ProductID int Name string Quantity float64 } // resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de // la récompense, la liste des produits éligibles — tous ceux du catalogue si // AllProducts, sinon la sélection explicite — avec le type et la quantité // configurés directement dans le bloc catégorie (RewardCategoryConfig). // Il n'existe plus de liste "reward_items" saisie à part : la catégorie est // l'unique source de vérité (type + produits + quantité). func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) { candidates := make([]categoryRewardCandidate, 0) if reward == nil { return candidates, nil } catalogCache := make(map[string][]models.Product) for _, cfg := range reward.CategoryConfigs { rewardType := normalizeRewardCategoryType(cfg.Type) if cfg.AllProducts { products, ok := catalogCache[cfg.Category] if !ok { var err error products, err = database.GetProductsByCategory(cfg.Category) if err != nil { return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err) } catalogCache[cfg.Category] = products } for _, p := range products { candidates = append(candidates, categoryRewardCandidate{ Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity, }) } } else if len(cfg.ProductIDs) > 0 { names, err := database.GetProductNamesByIDs(cfg.ProductIDs) if err != nil { return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err) } for _, pid := range cfg.ProductIDs { candidates = append(candidates, categoryRewardCandidate{ Category: cfg.Category, Type: rewardType, ProductID: pid, Name: names[pid], Quantity: cfg.Quantity, }) } } } return candidates, nil } // effectiveRewardPrice calcule le prix réellement facturé pour une quantité // donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour // "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour // "half_price_product". Erreur si le prix catalogue est introuvable (produit // désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas // être proposée/réclamée plutôt que de facturer un montant incorrect. func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) { if rewardType != "half_price_product" { return 0, nil } catalogPrice, err := database.GetActiveProductPrice(productID, quantity) if err != nil { return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err) } return math.Round(catalogPrice/2*100) / 100, nil } // 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) { username := c.GetString("username") if username == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) return } database := c.MustGet("database").(*db.Database) settings, err := database.GetSettings() if err != nil { utils.ServerErr(c, "Erreur lecture paramètres", err) return } if !settings.PointsEnabled || len(settings.PointsPools) == 0 { c.JSON(http.StatusOK, gin.H{"enabled": false, "pools": []gin.H{}, "reward": nil}) return } pointsExtra, pointsRedeemed, err := database.GetClientPointsAndRewards(username) if err != nil { utils.ServerErr(c, "Erreur lecture points", err) return } reward := settings.PointsReward type EligibleConfigResponse struct { Category string `json:"category"` Type string `json:"type"` AllProducts bool `json:"all_products"` ProductIDs []int `json:"product_ids"` ProductNames []string `json:"product_names"` Quantity float64 `json:"quantity"` } type RewardItemResponse struct { ProductID int `json:"product_id"` ProductName string `json:"product_name"` Quantity float64 `json:"quantity"` Price float64 `json:"price"` Type string `json:"type"` } 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"` EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"` } candidates, err := resolveCategoryRewardCandidates(database, reward) if err != nil { log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err) } pools := make([]PoolInfo, 0, len(settings.PointsPools)) for _, pool := range settings.PointsPools { pts := pointsExtra[pool.Key] redeemed := pointsRedeemed[pool.Key] var earned, available int if reward != nil && reward.Threshold > 0 { earned = pts / reward.Threshold available = earned - redeemed available = max(earned-redeemed, 0) } poolCats := make(map[string]bool, len(pool.Categories)) for _, c := range pool.Categories { poolCats[c] = true } eligibleConfigs := make([]EligibleConfigResponse, 0) if reward != nil { for _, cfg := range reward.CategoryConfigs { if !poolCats[cfg.Category] { continue } names := make([]string, 0, len(cfg.ProductIDs)) for _, pid := range cfg.ProductIDs { for _, cand := range candidates { if cand.ProductID == pid && cand.Category == cfg.Category { names = append(names, cand.Name) break } } } eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{ Category: cfg.Category, Type: normalizeRewardCategoryType(cfg.Type), AllProducts: cfg.AllProducts, ProductIDs: cfg.ProductIDs, ProductNames: names, Quantity: cfg.Quantity, }) } } eligibleRewardItems := make([]RewardItemResponse, 0) for _, cand := range candidates { if !poolCats[cand.Category] { continue } price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) if err != nil { log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err) continue } eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{ ProductID: cand.ProductID, ProductName: cand.Name, Quantity: cand.Quantity, Price: price, Type: cand.Type, }) } pools = append(pools, PoolInfo{ Key: pool.Key, Name: pool.Name, Points: pts, RewardsEarned: earned, RewardsClaimed: redeemed, RewardsAvailable: available, EligibleConfigs: eligibleConfigs, EligibleRewardItems: eligibleRewardItems, }) } // Aperçu global des produits récompense, indépendant d'un pool précis — le // type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items. var rewardMeta gin.H if reward != nil { rewardItems := make([]RewardItemResponse, 0, len(candidates)) for _, cand := range candidates { price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) if err != nil { continue } rewardItems = append(rewardItems, RewardItemResponse{ ProductID: cand.ProductID, ProductName: cand.Name, Quantity: cand.Quantity, Price: price, Type: cand.Type, }) } rewardMeta = gin.H{ "threshold": reward.Threshold, "description": reward.Description, "reward_items": rewardItems, } } c.JSON(http.StatusOK, gin.H{"enabled": true, "pools": pools, "reward": rewardMeta}) } // ClaimMyReward réclame une récompense sur un pool donné si le client a atteint le seuil. func ClaimMyReward(c *gin.Context) { username := c.GetString("username") if username == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) return } var req struct { PoolKey string `json:"pool_key" binding:"required"` ProductID int `json:"product_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"}) return } database := c.MustGet("database").(*db.Database) settings, err := database.GetSettings() if err != nil { utils.ServerErr(c, "Erreur lecture paramètres", err) return } if !settings.PointsEnabled { c.JSON(http.StatusForbidden, gin.H{"error": "Système de points désactivé"}) return } reward := settings.PointsReward if reward == nil || reward.Threshold <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Aucune récompense configurée"}) return } // 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 selectedPool == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"}) return } // Un produit récompense n'est éligible pour ce pool que si sa catégorie // fait partie des catégories du pool — 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 } candidates, err := resolveCategoryRewardCandidates(database, reward) if err != nil { utils.ServerErr(c, "Erreur résolution produits récompense", err) return } // Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici, // avant toute écriture — si un item ne peut pas être tarifé (produit sans // palier de prix actif), la réclamation entière échoue proprement, avant // même de démarrer la transaction de consommation de points. eligibleItems := make([]models.RewardItem, 0, len(candidates)) for _, cand := range candidates { if !poolCategories[cand.Category] { continue } price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) if err != nil { log.Printf("❌ [CLAIM] %s: %v", username, err) c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"}) return } eligibleItems = append(eligibleItems, models.RewardItem{ ProductID: cand.ProductID, Quantity: cand.Quantity, Price: price, }) } 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 } productAdded := len(added) > 0 var productNames []string 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{ "success": true, "description": reward.Description, "remaining_rewards": remaining, "product_added": productAdded, "product_names": productNames, }) } func AdminResetClientRedeemed(c *gin.Context) { username := c.Param("username") poolKey := c.Query("pool_key") database := c.MustGet("database").(*db.Database) if err := database.ResetClientRedeemed(username, poolKey); err != nil { utils.ServerErr(c, "Erreur reset récompenses", err) return } c.JSON(http.StatusOK, gin.H{"success": true}) }