409 lines
13 KiB
Go
409 lines
13 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"
|
|
}
|
|
|
|
// 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})
|
|
}
|