@@ -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,
|
Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if len(cfg.ProductIDs) > 0 {
|
} else if len(cfg.Products) > 0 {
|
||||||
names, err := database.GetProductNamesByIDs(cfg.ProductIDs)
|
ids := make([]int, len(cfg.Products))
|
||||||
|
for i, pq := range cfg.Products {
|
||||||
|
ids[i] = pq.ProductID
|
||||||
|
}
|
||||||
|
names, err := database.GetProductNamesByIDs(ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err)
|
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{
|
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
|
reward := settings.PointsReward
|
||||||
|
|
||||||
|
type ConfigProductResponse struct {
|
||||||
|
ProductID int `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
type EligibleConfigResponse struct {
|
type EligibleConfigResponse struct {
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
AllProducts bool `json:"all_products"`
|
AllProducts bool `json:"all_products"`
|
||||||
ProductIDs []int `json:"product_ids"`
|
Products []ConfigProductResponse `json:"products"`
|
||||||
ProductNames []string `json:"product_names"`
|
Quantity float64 `json:"quantity"`
|
||||||
Quantity float64 `json:"quantity"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type RewardItemResponse struct {
|
type RewardItemResponse struct {
|
||||||
@@ -181,22 +190,27 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
if !poolCats[cfg.Category] {
|
if !poolCats[cfg.Category] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
names := make([]string, 0, len(cfg.ProductIDs))
|
products := make([]ConfigProductResponse, 0, len(cfg.Products))
|
||||||
for _, pid := range cfg.ProductIDs {
|
for _, pq := range cfg.Products {
|
||||||
|
name := ""
|
||||||
for _, cand := range candidates {
|
for _, cand := range candidates {
|
||||||
if cand.ProductID == pid && cand.Category == cfg.Category {
|
if cand.ProductID == pq.ProductID && cand.Category == cfg.Category {
|
||||||
names = append(names, cand.Name)
|
name = cand.Name
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
products = append(products, ConfigProductResponse{
|
||||||
|
ProductID: pq.ProductID,
|
||||||
|
ProductName: name,
|
||||||
|
Quantity: pq.Quantity,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||||
Category: cfg.Category,
|
Category: cfg.Category,
|
||||||
Type: normalizeRewardCategoryType(cfg.Type),
|
Type: normalizeRewardCategoryType(cfg.Type),
|
||||||
AllProducts: cfg.AllProducts,
|
AllProducts: cfg.AllProducts,
|
||||||
ProductIDs: cfg.ProductIDs,
|
Products: products,
|
||||||
ProductNames: names,
|
Quantity: cfg.Quantity,
|
||||||
Quantity: cfg.Quantity,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,17 +14,30 @@ type PointsTier struct {
|
|||||||
Points int `json:"points"`
|
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,
|
// 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é
|
// 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
|
// concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier
|
||||||
// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur
|
// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur
|
||||||
// libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€.
|
// 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 {
|
type RewardCategoryConfig struct {
|
||||||
Category string `json:"category"` // nom de la catégorie
|
Category string `json:"category"` // nom de la catégorie
|
||||||
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
|
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
|
||||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
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é uniforme si AllProducts = true
|
||||||
Quantity float64 `json:"quantity"` // quantité offerte / à -50% pour ce type dans cette catégorie
|
Products []RewardProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RewardItem représente un produit résolu à ajouter au panier lors d'un
|
// 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
|
// 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é
|
// récompense ne doit pas être consommée sans qu'aucun produit ne soit livré
|
||||||
// au client (ClaimPoolReward + AddRewardsToBasket sont dans la même
|
// au client (ClaimPoolReward + AddRewardsToBasket sont dans la même
|
||||||
@@ -202,7 +202,7 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
|
|||||||
configureRewardSettings(t, &models.PointsReward{
|
configureRewardSettings(t, &models.PointsReward{
|
||||||
Threshold: 20,
|
Threshold: 20,
|
||||||
CategoryConfigs: []models.RewardCategoryConfig{
|
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)
|
setClientPoolPoints(t, username, "pool_0", 20)
|
||||||
@@ -241,8 +241,8 @@ func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T
|
|||||||
Threshold: 20,
|
Threshold: 20,
|
||||||
Description: "Un produit offert + un produit à -50%",
|
Description: "Un produit offert + un produit à -50%",
|
||||||
CategoryConfigs: []models.RewardCategoryConfig{
|
CategoryConfigs: []models.RewardCategoryConfig{
|
||||||
{Category: "test", Type: "free_product", ProductIDs: []int{freeProductID}, Quantity: 1},
|
{Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: freeProductID, Quantity: 1}}},
|
||||||
{Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}, Quantity: 1},
|
{Category: "test", Type: "half_price_product", Products: []models.RewardProductQuantity{{ProductID: halfProductID, Quantity: 1}}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
setClientPoolPoints(t, username, "pool_0", 20)
|
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)
|
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