chore: build
This commit is contained in:
@@ -23,72 +23,75 @@ func normalizeRewardCategoryType(t string) string {
|
|||||||
return "free_product"
|
return "free_product"
|
||||||
}
|
}
|
||||||
|
|
||||||
// eligibleRewardProducts détermine, pour un pool donné, quels product_id de
|
// categoryRewardCandidate représente un produit éligible à la récompense pour
|
||||||
// reward.RewardItems sont éligibles et avec quel type de récompense
|
// une config de catégorie donnée : son type ("free_product" |
|
||||||
// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs)
|
// "half_price_product") et la quantité configurée pour cette catégorie.
|
||||||
// doit faire partie des catégories du pool, soit par whitelist explicite
|
type categoryRewardCandidate struct {
|
||||||
// (ProductIDs) soit par correspondance de catégorie produit (AllProducts).
|
Category string
|
||||||
func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string {
|
Type string
|
||||||
eligible := make(map[int]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 {
|
if reward == nil {
|
||||||
return eligible
|
return candidates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
catalogCache := make(map[string][]models.Product)
|
||||||
for _, cfg := range reward.CategoryConfigs {
|
for _, cfg := range reward.CategoryConfigs {
|
||||||
if !poolCategories[cfg.Category] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rewardType := normalizeRewardCategoryType(cfg.Type)
|
rewardType := normalizeRewardCategoryType(cfg.Type)
|
||||||
if cfg.AllProducts {
|
if cfg.AllProducts {
|
||||||
for pid, cat := range productCategories {
|
products, ok := catalogCache[cfg.Category]
|
||||||
if cat == cfg.Category {
|
if !ok {
|
||||||
eligible[pid] = rewardType
|
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)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
for _, pid := range cfg.ProductIDs {
|
for _, pid := range cfg.ProductIDs {
|
||||||
eligible[pid] = rewardType
|
candidates = append(candidates, categoryRewardCandidate{
|
||||||
|
Category: cfg.Category, Type: rewardType, ProductID: pid, Name: names[pid], Quantity: cfg.Quantity,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return eligible
|
return candidates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// categoryConfigTypeForProduct détermine le type de récompense applicable à un
|
// effectiveRewardPrice calcule le prix réellement facturé pour une quantité
|
||||||
// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé
|
// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
|
||||||
// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis.
|
// "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour
|
||||||
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
|
// "half_price_product". Erreur si le prix catalogue est introuvable (produit
|
||||||
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
|
// 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.
|
// ê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) {
|
func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) {
|
||||||
if rewardType != "half_price_product" {
|
if rewardType != "half_price_product" {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity)
|
catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err)
|
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err)
|
||||||
}
|
}
|
||||||
return math.Round(catalogPrice/2*100) / 100, nil
|
return math.Round(catalogPrice/2*100) / 100, nil
|
||||||
}
|
}
|
||||||
@@ -129,6 +132,7 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
AllProducts bool `json:"all_products"`
|
AllProducts bool `json:"all_products"`
|
||||||
ProductIDs []int `json:"product_ids"`
|
ProductIDs []int `json:"product_ids"`
|
||||||
ProductNames []string `json:"product_names"`
|
ProductNames []string `json:"product_names"`
|
||||||
|
Quantity float64 `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RewardItemResponse struct {
|
type RewardItemResponse struct {
|
||||||
@@ -150,22 +154,10 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
|
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collecter tous les product_ids nécessaires en un seul passage
|
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||||
allProductIDs := make([]int, 0)
|
if err != nil {
|
||||||
if reward != nil {
|
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
|
||||||
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))
|
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
||||||
for _, pool := range settings.PointsPools {
|
for _, pool := range settings.PointsPools {
|
||||||
@@ -191,8 +183,11 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
names := make([]string, 0, len(cfg.ProductIDs))
|
names := make([]string, 0, len(cfg.ProductIDs))
|
||||||
for _, pid := range cfg.ProductIDs {
|
for _, pid := range cfg.ProductIDs {
|
||||||
if n, ok := productNames[pid]; ok {
|
for _, cand := range candidates {
|
||||||
names = append(names, n)
|
if cand.ProductID == pid && cand.Category == cfg.Category {
|
||||||
|
names = append(names, cand.Name)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||||
@@ -201,31 +196,28 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
AllProducts: cfg.AllProducts,
|
AllProducts: cfg.AllProducts,
|
||||||
ProductIDs: cfg.ProductIDs,
|
ProductIDs: cfg.ProductIDs,
|
||||||
ProductNames: names,
|
ProductNames: names,
|
||||||
|
Quantity: cfg.Quantity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
|
|
||||||
eligibleRewardItems := make([]RewardItemResponse, 0)
|
eligibleRewardItems := make([]RewardItemResponse, 0)
|
||||||
if reward != nil {
|
for _, cand := range candidates {
|
||||||
for _, item := range reward.RewardItems {
|
if !poolCats[cand.Category] {
|
||||||
rewardType, ok := eligibleProducts[item.ProductID]
|
continue
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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{
|
pools = append(pools, PoolInfo{
|
||||||
@@ -240,28 +232,22 @@ func GetMyPointsRewards(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construire la liste des produits récompense avec leurs noms (aperçu
|
// Aperçu global des produits récompense, indépendant d'un pool précis — le
|
||||||
// global, indépendant d'un pool précis — le type/prix effectif par pool
|
// type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items.
|
||||||
// est celui exposé dans pools[].eligible_reward_items).
|
|
||||||
var rewardMeta gin.H
|
var rewardMeta gin.H
|
||||||
if reward != nil {
|
if reward != nil {
|
||||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
rewardItems := make([]RewardItemResponse, 0, len(candidates))
|
||||||
for _, item := range reward.RewardItems {
|
for _, cand := range candidates {
|
||||||
if item.ProductID <= 0 {
|
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||||
|
if err != nil {
|
||||||
continue
|
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{
|
rewardItems = append(rewardItems, RewardItemResponse{
|
||||||
ProductID: item.ProductID,
|
ProductID: cand.ProductID,
|
||||||
ProductName: name,
|
ProductName: cand.Name,
|
||||||
Quantity: item.Quantity,
|
Quantity: cand.Quantity,
|
||||||
Price: price,
|
Price: price,
|
||||||
Type: rewardType,
|
Type: cand.Type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
rewardMeta = gin.H{
|
rewardMeta = gin.H{
|
||||||
@@ -324,46 +310,40 @@ func ClaimMyReward(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
|
// 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
|
// fait partie des catégories du pool — sans ce filtre, un client pourrait
|
||||||
// filtre, un client pourrait réclamer n'importe quel produit récompense
|
// réclamer n'importe quel produit récompense (toutes catégories
|
||||||
// (toutes catégories confondues) avec les points d'un pool quelconque.
|
// confondues) avec les points d'un pool quelconque.
|
||||||
poolCategories := make(map[string]bool, len(selectedPool.Categories))
|
poolCategories := make(map[string]bool, len(selectedPool.Categories))
|
||||||
for _, cat := range selectedPool.Categories {
|
for _, cat := range selectedPool.Categories {
|
||||||
poolCategories[cat] = true
|
poolCategories[cat] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
rewardProductIDs := make([]int, 0, len(reward.RewardItems))
|
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||||
for _, item := range reward.RewardItems {
|
|
||||||
if item.ProductID > 0 {
|
|
||||||
rewardProductIDs = append(rewardProductIDs, item.ProductID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ServerErr(c, "Erreur lecture catégories produits", err)
|
utils.ServerErr(c, "Erreur résolution produits récompense", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories)
|
|
||||||
|
|
||||||
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
|
// 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
|
// 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
|
// palier de prix actif), la réclamation entière échoue proprement, avant
|
||||||
// même de démarrer la transaction de consommation de points.
|
// même de démarrer la transaction de consommation de points.
|
||||||
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
|
eligibleItems := make([]models.RewardItem, 0, len(candidates))
|
||||||
for _, item := range reward.RewardItems {
|
for _, cand := range candidates {
|
||||||
rewardType, ok := eligibleProducts[item.ProductID]
|
if !poolCategories[cand.Category] {
|
||||||
if !ok {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
price, err := effectiveRewardPrice(database, item, rewardType)
|
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("❌ [CLAIM] %s: %v", username, err)
|
log.Printf("❌ [CLAIM] %s: %v", username, err)
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item.Price = price
|
eligibleItems = append(eligibleItems, models.RewardItem{
|
||||||
eligibleItems = append(eligibleItems, item)
|
ProductID: cand.ProductID,
|
||||||
|
Quantity: cand.Quantity,
|
||||||
|
Price: price,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsToAdd := eligibleItems
|
itemsToAdd := eligibleItems
|
||||||
|
|||||||
@@ -15,29 +15,35 @@ type PointsTier struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
// ainsi que le type de récompense appliqué pour cette catégorie précise.
|
// 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€.
|
||||||
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
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé
|
// RewardItem représente un produit résolu à ajouter au panier lors d'un
|
||||||
|
// claim (ProductID + Quantity + Price effectif) — construit dynamiquement à
|
||||||
|
// partir des CategoryConfigs au moment du claim, plus une liste saisie à part.
|
||||||
type RewardItem struct {
|
type RewardItem struct {
|
||||||
ProductID int `json:"product_id"` // ID du produit ajouté au panier
|
ProductID int `json:"product_id"` // ID du produit ajouté au panier
|
||||||
Quantity float64 `json:"quantity"` // quantité offerte
|
Quantity float64 `json:"quantity"` // quantité offerte
|
||||||
Price float64 `json:"price"` // valeur indicative affichée au client
|
Price float64 `json:"price"` // prix effectif facturé (0 si offert, 50% du prix catalogue si -50%)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
|
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
|
||||||
// Le type de récompense (gratuit ou -50%) n'est plus global : il est défini par catégorie
|
// Le type de récompense (gratuit ou -50%) et la quantité concernée sont
|
||||||
// dans CategoryConfigs (voir RewardCategoryConfig.Type).
|
// définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) —
|
||||||
|
// les produits éligibles et leur quantité ne sont plus saisis à part.
|
||||||
type PointsReward struct {
|
type PointsReward struct {
|
||||||
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
||||||
Description string `json:"description"` // description libre affichée au client
|
Description string `json:"description"` // description libre affichée au client
|
||||||
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie
|
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie
|
||||||
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
// configureRewardSettings applique la récompense donnée, avec pool_0 mappé
|
// configureRewardSettings applique la récompense donnée, avec pool_0 mappé
|
||||||
// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts
|
// sur la catégorie "test" — nécessaire pour que resolveCategoryRewardCandidates
|
||||||
// (qui croise pool.Categories et reward.CategoryConfigs) considère les
|
// (qui croise pool.Categories et reward.CategoryConfigs) considère les
|
||||||
// reward_items comme éligibles.
|
// produits de la catégorie comme éligibles.
|
||||||
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
|
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
settings := db.DefaultSettings()
|
settings := db.DefaultSettings()
|
||||||
@@ -39,17 +39,20 @@ func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter
|
// Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter
|
||||||
// le produit récompense configuré au panier et décompter la récompense.
|
// le produit récompense configuré au panier et décompter la récompense. Le
|
||||||
|
// produit éligible et sa quantité sont désormais définis directement dans le
|
||||||
|
// bloc catégorie (RewardCategoryConfig), plus de liste "reward_items" à part.
|
||||||
func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) {
|
func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) {
|
||||||
cleanupStockTestData(t)
|
cleanupStockTestData(t)
|
||||||
username := newTestClient(t, "reward_http_flow")
|
username := newTestClient(t, "reward_http_flow")
|
||||||
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
|
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
|
||||||
|
|
||||||
configureRewardSettings(t, &models.PointsReward{
|
configureRewardSettings(t, &models.PointsReward{
|
||||||
Threshold: 20,
|
Threshold: 20,
|
||||||
Description: "Un produit offert",
|
Description: "Un produit offert",
|
||||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
|
CategoryConfigs: []models.RewardCategoryConfig{
|
||||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
{Category: "test", Type: "free_product", AllProducts: true, Quantity: 1},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
setClientPoolPoints(t, username, "pool_0", 20)
|
setClientPoolPoints(t, username, "pool_0", 20)
|
||||||
|
|
||||||
@@ -85,9 +88,9 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catégorie configurée en "half_price_product" : le produit récompense doit
|
// Catégorie configurée en "half_price_product" avec quantité=1 : le produit
|
||||||
// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix
|
// récompense doit être ajouté au panier à 50% du prix catalogue actif pour
|
||||||
// indicatif RewardItem.Price saisi par l'admin).
|
// cette quantité (palier ≤ 1), pas 0€.
|
||||||
func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) {
|
func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) {
|
||||||
cleanupStockTestData(t)
|
cleanupStockTestData(t)
|
||||||
username := newTestClient(t, "reward_http_halfprice")
|
username := newTestClient(t, "reward_http_halfprice")
|
||||||
@@ -95,10 +98,11 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri
|
|||||||
// newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
|
// newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
|
||||||
|
|
||||||
configureRewardSettings(t, &models.PointsReward{
|
configureRewardSettings(t, &models.PointsReward{
|
||||||
Threshold: 20,
|
Threshold: 20,
|
||||||
Description: "Un produit à moitié prix",
|
Description: "Un produit à moitié prix",
|
||||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "half_price_product", AllProducts: true}},
|
CategoryConfigs: []models.RewardCategoryConfig{
|
||||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré
|
{Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 1},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
setClientPoolPoints(t, username, "pool_0", 20)
|
setClientPoolPoints(t, username, "pool_0", 20)
|
||||||
|
|
||||||
@@ -119,15 +123,60 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// La quantité configurée dans le bloc catégorie détermine le palier de prix
|
||||||
|
// utilisé pour le calcul du -50% (ex: 30€ le palier quantity=1 → 15€ facturé),
|
||||||
|
// pas un prix indicatif saisi ailleurs.
|
||||||
|
func TestClaimMyReward_HTTPFlow_HalfPriceUsesConfiguredQuantityForPriceTier(t *testing.T) {
|
||||||
|
cleanupStockTestData(t)
|
||||||
|
username := newTestClient(t, "reward_http_halfprice_qty")
|
||||||
|
rewardProductID := newTestProduct(t, "RewardHTTPHalfPriceQty", 20)
|
||||||
|
// Ajoute un palier quantity=3 à 30€ (en plus du palier quantity=1 à 10€ créé par newTestProduct).
|
||||||
|
if err := testDB.GDB.Exec(
|
||||||
|
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 3, 30.00, true)`,
|
||||||
|
rewardProductID,
|
||||||
|
).Error; err != nil {
|
||||||
|
t.Fatalf("création palier de prix supplémentaire: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configureRewardSettings(t, &models.PointsReward{
|
||||||
|
Threshold: 20,
|
||||||
|
Description: "Un produit à moitié prix, quantité 3",
|
||||||
|
CategoryConfigs: []models.RewardCategoryConfig{
|
||||||
|
{Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 3},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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) != 1 || rows[0].ProductID != rewardProductID {
|
||||||
|
t.Fatalf("le produit récompense doit être dans le panier: %+v", rows)
|
||||||
|
}
|
||||||
|
if rows[0].Quantity != 3 {
|
||||||
|
t.Errorf("la quantité en panier doit être celle configurée pour la catégorie: got=%.2f want=3", rows[0].Quantity)
|
||||||
|
}
|
||||||
|
if rows[0].Price != 15.0 {
|
||||||
|
t.Errorf("palier quantity=3 à 30€ : prix attendu = 50%% = 15.00€: got=%.2f", rows[0].Price)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
||||||
cleanupStockTestData(t)
|
cleanupStockTestData(t)
|
||||||
username := newTestClient(t, "reward_http_below")
|
username := newTestClient(t, "reward_http_below")
|
||||||
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
|
newTestProduct(t, "RewardHTTPBelow", 5)
|
||||||
|
|
||||||
configureRewardSettings(t, &models.PointsReward{
|
configureRewardSettings(t, &models.PointsReward{
|
||||||
Threshold: 20,
|
Threshold: 20,
|
||||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
|
CategoryConfigs: []models.RewardCategoryConfig{
|
||||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
{Category: "test", Type: "free_product", AllProducts: true, Quantity: 1},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
setClientPoolPoints(t, username, "pool_0", 5)
|
setClientPoolPoints(t, username, "pool_0", 5)
|
||||||
|
|
||||||
@@ -140,22 +189,21 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si un item récompense configuré par l'admin pointe vers un produit
|
// Si un produit configuré par l'admin (via ProductIDs explicite) pointe vers
|
||||||
// supprimé/inexistant, la réclamation entière doit échouer — la récompense
|
// un produit supprimé/inexistant, la réclamation entière doit échouer — la
|
||||||
// ne doit pas être consommée sans qu'aucun produit ne soit livré au client
|
// récompense ne doit pas être consommée sans qu'aucun produit ne soit livré
|
||||||
// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même
|
// au client (ClaimPoolReward + AddRewardsToBasket sont dans la même
|
||||||
// transaction via ClaimPoolRewardAndAddToBasket).
|
// transaction via ClaimPoolRewardAndAddToBasket ; la contrainte de clé
|
||||||
|
// étrangère sur baskets.product_id fait échouer l'insertion).
|
||||||
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
|
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
|
||||||
cleanupStockTestData(t)
|
cleanupStockTestData(t)
|
||||||
username := newTestClient(t, "reward_http_missing_product")
|
username := newTestClient(t, "reward_http_missing_product")
|
||||||
|
|
||||||
configureRewardSettings(t, &models.PointsReward{
|
configureRewardSettings(t, &models.PointsReward{
|
||||||
Threshold: 20,
|
Threshold: 20,
|
||||||
// ProductIDs explicite (pas AllProducts) : le produit n'existe pas en
|
CategoryConfigs: []models.RewardCategoryConfig{
|
||||||
// base, donc il n'apparaîtrait jamais dans productCategories et ne
|
{Category: "test", Type: "free_product", ProductIDs: []int{999999999}, Quantity: 1},
|
||||||
// serait jamais éligible via une correspondance AllProducts.
|
},
|
||||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", ProductIDs: []int{999999999}}},
|
|
||||||
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
|
|
||||||
})
|
})
|
||||||
setClientPoolPoints(t, username, "pool_0", 20)
|
setClientPoolPoints(t, username, "pool_0", 20)
|
||||||
|
|
||||||
@@ -178,10 +226,9 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
|
|||||||
|
|
||||||
// Une même catégorie peut avoir les deux types de récompense actifs en
|
// Une même catégorie peut avoir les deux types de récompense actifs en
|
||||||
// parallèle (un lot de produits offerts + un lot de produits à -50%), chacun
|
// parallèle (un lot de produits offerts + un lot de produits à -50%), chacun
|
||||||
// avec sa propre sélection de produits (voir eligibleRewardProducts, qui
|
// avec sa propre sélection de produits et sa propre quantité. Un seul claim
|
||||||
// n'impose aucune unicité de catégorie dans CategoryConfigs). Un seul claim
|
|
||||||
// doit alors ajouter les deux produits au panier, chacun tarifé selon son
|
// doit alors ajouter les deux produits au panier, chacun tarifé selon son
|
||||||
// propre type.
|
// propre type et sa propre quantité.
|
||||||
func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) {
|
func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) {
|
||||||
cleanupStockTestData(t)
|
cleanupStockTestData(t)
|
||||||
username := newTestClient(t, "reward_http_dual_type")
|
username := newTestClient(t, "reward_http_dual_type")
|
||||||
@@ -194,12 +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}},
|
{Category: "test", Type: "free_product", ProductIDs: []int{freeProductID}, Quantity: 1},
|
||||||
{Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}},
|
{Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}, Quantity: 1},
|
||||||
},
|
|
||||||
RewardItems: []models.RewardItem{
|
|
||||||
{ProductID: freeProductID, Quantity: 1, Price: 12},
|
|
||||||
{ProductID: halfProductID, Quantity: 1, Price: 12},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
setClientPoolPoints(t, username, "pool_0", 20)
|
setClientPoolPoints(t, username, "pool_0", 20)
|
||||||
|
|||||||
@@ -2172,7 +2172,7 @@ export type RewardCategoryConfig = {
|
|||||||
all_products: boolean;
|
all_products: boolean;
|
||||||
product_ids: number[];
|
product_ids: number[];
|
||||||
product_names: string[];
|
product_names: string[];
|
||||||
amount: number;
|
quantity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RewardItemConfig = {
|
export type RewardItemConfig = {
|
||||||
|
|||||||
Reference in New Issue
Block a user