diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go index 685a2f54..1b7d63ad 100644 --- a/backend/gestion/handlers/points.go +++ b/backend/gestion/handlers/points.go @@ -64,14 +64,18 @@ func resolveCategoryRewardCandidates(database *db.Database, reward *models.Point 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) + } else if len(cfg.Products) > 0 { + ids := make([]int, len(cfg.Products)) + for i, pq := range cfg.Products { + ids[i] = pq.ProductID + } + names, err := database.GetProductNamesByIDs(ids) if err != nil { return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err) } - for _, pid := range cfg.ProductIDs { + for _, pq := range cfg.Products { candidates = append(candidates, categoryRewardCandidate{ - Category: cfg.Category, Type: rewardType, ProductID: pid, Name: names[pid], Quantity: cfg.Quantity, + Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity, }) } } @@ -126,13 +130,18 @@ func GetMyPointsRewards(c *gin.Context) { reward := settings.PointsReward + type ConfigProductResponse struct { + ProductID int `json:"product_id"` + ProductName string `json:"product_name"` + Quantity float64 `json:"quantity"` + } + 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"` + Category string `json:"category"` + Type string `json:"type"` + AllProducts bool `json:"all_products"` + Products []ConfigProductResponse `json:"products"` + Quantity float64 `json:"quantity"` } type RewardItemResponse struct { @@ -181,22 +190,27 @@ func GetMyPointsRewards(c *gin.Context) { if !poolCats[cfg.Category] { continue } - names := make([]string, 0, len(cfg.ProductIDs)) - for _, pid := range cfg.ProductIDs { + products := make([]ConfigProductResponse, 0, len(cfg.Products)) + for _, pq := range cfg.Products { + name := "" for _, cand := range candidates { - if cand.ProductID == pid && cand.Category == cfg.Category { - names = append(names, cand.Name) + if cand.ProductID == pq.ProductID && cand.Category == cfg.Category { + name = cand.Name break } } + products = append(products, ConfigProductResponse{ + ProductID: pq.ProductID, + ProductName: name, + Quantity: pq.Quantity, + }) } eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{ - Category: cfg.Category, - Type: normalizeRewardCategoryType(cfg.Type), - AllProducts: cfg.AllProducts, - ProductIDs: cfg.ProductIDs, - ProductNames: names, - Quantity: cfg.Quantity, + Category: cfg.Category, + Type: normalizeRewardCategoryType(cfg.Type), + AllProducts: cfg.AllProducts, + Products: products, + Quantity: cfg.Quantity, }) } } diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index 112e7594..64ff4f08 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -14,17 +14,30 @@ type PointsTier struct { Points int `json:"points"` } +// RewardProductQuantity associe un produit à sa propre quantité offerte / à +// -50%, pour le cas où une catégorie n'est pas configurée en "tous les +// produits" — ex: produit A à 2g offerts, produit B à 1g offert, tous deux +// dans la même catégorie et le même type de récompense. +type RewardProductQuantity struct { + ProductID int `json:"product_id"` + Quantity float64 `json:"quantity"` +} + // RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense, // le type de récompense appliqué pour cette catégorie précise, et la quantité // concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier // de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur // libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€. +// +// Si AllProducts = true, Quantity s'applique uniformément à tous les produits +// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans +// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré). type RewardCategoryConfig struct { - Category string `json:"category"` // nom de la catégorie - Type string `json:"type"` // "free_product" (défaut) | "half_price_product" - AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie - ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false - Quantity float64 `json:"quantity"` // quantité offerte / à -50% pour ce type dans cette catégorie + Category string `json:"category"` // nom de la catégorie + Type string `json:"type"` // "free_product" (défaut) | "half_price_product" + AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie + Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true + Products []RewardProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false } // RewardItem représente un produit résolu à ajouter au panier lors d'un diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go index 3c7ab4fb..88ea1514 100644 --- a/backend/gestion/tests/rewards_handler_test.go +++ b/backend/gestion/tests/rewards_handler_test.go @@ -189,7 +189,7 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) { } } -// Si un produit configuré par l'admin (via ProductIDs explicite) pointe vers +// Si un produit configuré par l'admin (via Products explicite) pointe vers // un produit supprimé/inexistant, la réclamation entière doit échouer — la // récompense ne doit pas être consommée sans qu'aucun produit ne soit livré // au client (ClaimPoolReward + AddRewardsToBasket sont dans la même @@ -202,7 +202,7 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) configureRewardSettings(t, &models.PointsReward{ Threshold: 20, CategoryConfigs: []models.RewardCategoryConfig{ - {Category: "test", Type: "free_product", ProductIDs: []int{999999999}, Quantity: 1}, + {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: 999999999, Quantity: 1}}}, }, }) setClientPoolPoints(t, username, "pool_0", 20) @@ -241,8 +241,8 @@ func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T Threshold: 20, Description: "Un produit offert + un produit à -50%", CategoryConfigs: []models.RewardCategoryConfig{ - {Category: "test", Type: "free_product", ProductIDs: []int{freeProductID}, Quantity: 1}, - {Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}, Quantity: 1}, + {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: freeProductID, Quantity: 1}}}, + {Category: "test", Type: "half_price_product", Products: []models.RewardProductQuantity{{ProductID: halfProductID, Quantity: 1}}}, }, }) setClientPoolPoints(t, username, "pool_0", 20) @@ -279,3 +279,58 @@ func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T t.Errorf("produit de la config half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", halfRow.Price) } } + +// Quand une catégorie n'est pas configurée en "tous les produits", chaque +// produit sélectionné a sa propre quantité (ex: produit A à 2g offerts, +// produit B à 1g offert, tous deux dans la même catégorie et le même type). +func TestClaimMyReward_HTTPFlow_PerProductQuantityWithinSameCategoryAndType(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_per_product_qty") + productA := newTestProduct(t, "RewardHTTPPerProductA", 5) + productB := newTestProduct(t, "RewardHTTPPerProductB", 5) + // newTestProduct crée les deux produits dans la catégorie "test". + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Description: "Produit A 2g offert, produit B 1g offert", + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{ + {ProductID: productA, Quantity: 2}, + {ProductID: productB, Quantity: 1}, + }}, + }, + }) + setClientPoolPoints(t, username, "pool_0", 20) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + rows := basketRewardItems(t, username) + if len(rows) != 2 { + t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows) + } + + var rowA, rowB *rewardBasketRow + for i := range rows { + switch rows[i].ProductID { + case productA: + rowA = &rows[i] + case productB: + rowB = &rows[i] + } + } + if rowA == nil || rowB == nil { + t.Fatalf("les deux produits attendus doivent être présents: %+v", rows) + } + if rowA.Quantity != 2 { + t.Errorf("produit A: quantité attendue=2, got=%.2f", rowA.Quantity) + } + if rowB.Quantity != 1 { + t.Errorf("produit B: quantité attendue=1, got=%.2f", rowB.Quantity) + } +}