@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user