Files
projet_gestion_commande/backend/gestion/handlers/points.go
T
Xor290 46652bb925
Backend - Build & Lint / build (push) Failing after 30m2s
Frontend Client - EAS Build / build (push) Canceled after 7m55s
Frontend Web - Build & Lint / build (push) Failing after 10m18s
chore: build
2026-08-21 13:08:56 +02:00

429 lines
14 KiB
Go

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"
}
// eligibleRewardProducts détermine, pour un pool donné, quels product_id de
// reward.RewardItems sont éligibles et avec quel type de récompense
// ("free_product" | "half_price_product") : 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 eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string {
eligible := make(map[int]string)
if reward == nil {
return eligible
}
for _, cfg := range reward.CategoryConfigs {
if !poolCategories[cfg.Category] {
continue
}
rewardType := normalizeRewardCategoryType(cfg.Type)
if cfg.AllProducts {
for pid, cat := range productCategories {
if cat == cfg.Category {
eligible[pid] = rewardType
}
}
} else {
for _, pid := range cfg.ProductIDs {
eligible[pid] = rewardType
}
}
}
return eligible
}
// categoryConfigTypeForProduct détermine le type de récompense applicable à un
// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé
// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis.
func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string {
for _, cfg := range reward.CategoryConfigs {
matches := false
if cfg.AllProducts {
matches = cfg.Category == productCategory
} else {
for _, pid := range cfg.ProductIDs {
if pid == productID {
matches = true
break
}
}
}
if matches {
return normalizeRewardCategoryType(cfg.Type)
}
}
return "free_product"
}
// effectiveRewardPrice calcule le prix réellement facturé pour un item
// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du
// prix catalogue actif (palier correspondant à la quantité) 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, item models.RewardItem, rewardType string) (float64, error) {
if rewardType != "half_price_product" {
return 0, nil
}
catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity)
if err != nil {
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.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"`
}
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"`
}
// Collecter tous les product_ids nécessaires en un seul passage
allProductIDs := make([]int, 0)
if reward != nil {
for _, cfg := range reward.CategoryConfigs {
if !cfg.AllProducts {
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
}
}
for _, item := range reward.RewardItems {
if item.ProductID > 0 {
allProductIDs = append(allProductIDs, item.ProductID)
}
}
}
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
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 {
if n, ok := productNames[pid]; ok {
names = append(names, n)
}
}
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
Category: cfg.Category,
Type: normalizeRewardCategoryType(cfg.Type),
AllProducts: cfg.AllProducts,
ProductIDs: cfg.ProductIDs,
ProductNames: names,
})
}
}
eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
eligibleRewardItems := make([]RewardItemResponse, 0)
if reward != nil {
for _, item := range reward.RewardItems {
rewardType, ok := eligibleProducts[item.ProductID]
if !ok {
continue
}
price, err := effectiveRewardPrice(database, item, rewardType)
if err != nil {
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
continue
}
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: productNames[item.ProductID],
Quantity: item.Quantity,
Price: price,
Type: rewardType,
})
}
}
pools = append(pools, PoolInfo{
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 (aperçu
// global, 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(reward.RewardItems))
for _, item := range reward.RewardItems {
if item.ProductID <= 0 {
continue
}
rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID])
price, err := effectiveRewardPrice(database, item, rewardType)
if err != nil {
price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible
}
name := productNames[item.ProductID]
rewardItems = append(rewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: name,
Quantity: item.Quantity,
Price: price,
Type: rewardType,
})
}
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 (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
}
eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories)
// 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(reward.RewardItems))
for _, item := range reward.RewardItems {
rewardType, ok := eligibleProducts[item.ProductID]
if !ok {
continue
}
price, err := effectiveRewardPrice(database, item, rewardType)
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
}
item.Price = price
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
}
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})
}