diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index d11f119b..30b7c65d 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -130,11 +130,14 @@ func (d *Database) HasOnlyRewardItems(username string) (bool, error) { func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) { var basket models.Panier err := d.GDB.Transaction(func(tx *gorm.DB) error { - var currentStock float64 - if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(¤tStock).Error; err != nil { + var productInfo struct { + Stock float64 `gorm:"column:stock"` + Category string `gorm:"column:category"` + } + if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil { return fmt.Errorf("erreur lecture stock: %w", err) } - if currentStock < quantity { + if productInfo.Stock < quantity { return fmt.Errorf("stock insuffisant") } @@ -148,6 +151,12 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64) productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 { return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity) } + // Une promotion active pour ce produit/quantité/catégorie s'applique + // automatiquement au prix facturé — indépendamment des points de + // fidélité (contrairement aux récompenses par palier). + if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok { + priceResult.Price = discounted + } var existing struct { ID int `gorm:"column:id"` diff --git a/backend/gestion/db/db_promotions.go b/backend/gestion/db/db_promotions.go new file mode 100644 index 00000000..b1eccf24 --- /dev/null +++ b/backend/gestion/db/db_promotions.go @@ -0,0 +1,48 @@ +package db + +import ( + "gestion/models" + "math" +) + +// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un +// produit, sa catégorie catalogue et une quantité donnés, si une promotion +// configurée dans les settings couvre exactement ce couple (produit, +// quantité) — contrairement aux récompenses, aucun seuil de points n'entre +// en jeu : la promotion s'applique à toute commande de cette quantité. +func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) { + if settings == nil || !settings.PromotionsEnabled { + return 0, false + } + for _, promo := range settings.Promotions { + if promo.Category != category || promo.DiscountPercent <= 0 { + continue + } + if promo.AllProducts { + if promo.Quantity == quantity { + return promo.DiscountPercent, true + } + continue + } + for _, pq := range promo.Products { + if pq.ProductID == productID && pq.Quantity == quantity { + return promo.DiscountPercent, true + } + } + } + return 0, false +} + +// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce +// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime. +func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) { + settings, err := d.GetSettings() + if err != nil { + return price, false + } + discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity) + if !ok { + return price, false + } + return math.Round(price*(1-discount/100)*100) / 100, true +} diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index a9af1115..a476a9ab 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -142,6 +142,13 @@ func (d *Database) GetSettings() (models.AppSettings, error) { settings.PointsReward = &reward } } + case "promotions_enabled": + settings.PromotionsEnabled = row.Value == "true" + case "promotions": + var promotions []models.CategoryPromotionConfig + if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil { + settings.Promotions = promotions + } case "referral_enabled": settings.ReferralEnabled = row.Value == "true" case "referral_amount": @@ -263,6 +270,19 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { return fmt.Errorf("erreur sérialisation points_reward: %w", err) } + if s.Promotions == nil { + s.Promotions = []models.CategoryPromotionConfig{} + } + for i := range s.Promotions { + if s.Promotions[i].Products == nil { + s.Promotions[i].Products = []models.PromotionProductQuantity{} + } + } + promotionsJSON, err := json.Marshal(s.Promotions) + if err != nil { + return fmt.Errorf("erreur sérialisation promotions: %w", err) + } + if s.NowPaymentsCurrencies == nil { s.NowPaymentsCurrencies = []string{} } @@ -302,6 +322,8 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { {"points_enabled", boolStr(s.PointsEnabled)}, {"points_pools", string(poolsJSON)}, {"points_reward", string(rewardJSON)}, + {"promotions_enabled", boolStr(s.PromotionsEnabled)}, + {"promotions", string(promotionsJSON)}, {"referral_enabled", boolStr(s.ReferralEnabled)}, {"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)}, {"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)}, diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index 8cd984e3..b59601a5 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -8,6 +8,7 @@ import ( "gestion/utils" "io" "log" + "math" "mime/multipart" "net/http" "strconv" @@ -412,6 +413,7 @@ func GetAllProducts(c *gin.Context) { if role != "admin" && role != "cabine" { products = filterActivePrices(products) } + products = applyPromotions(products, database) c.JSON(http.StatusOK, gin.H{ "success": true, "data": products, @@ -450,6 +452,7 @@ func GetProductsByCategory(c *gin.Context) { if roleCtx != "admin" && roleCtx != "cabine" { products = filterActivePrices(products) } + products = applyPromotions(products, database) c.JSON(http.StatusOK, gin.H{ "success": true, "data": products, @@ -482,6 +485,7 @@ func GetProductByID(c *gin.Context) { if role != "admin" && role != "cabine" { filterActivepricesSingle(&product) } + applyPromotionsSingle(&product, database) c.JSON(http.StatusOK, gin.H{ "success": true, @@ -1002,3 +1006,32 @@ func filterActivepricesSingle(product *models.Product) { } product.Prices = activePrices } + +// applyPromotions annote chaque palier de prix éligible avec le prix promo +// (PromoPrice/PromoPercent) si une promotion couvre ce produit/quantité — +// affichage seulement, le prix catalogue (Price) n'est jamais modifié ici ; +// le prix réellement facturé est recalculé indépendamment dans AddToBasket. +func applyPromotions(products []models.Product, database *db.Database) []models.Product { + settings, err := database.GetSettings() + if err != nil || !settings.PromotionsEnabled { + return products + } + for i := range products { + for j := range products[i].Prices { + pr := &products[i].Prices[j] + discount, ok := db.ResolvePromotionDiscount(&settings, products[i].ID, products[i].Category, pr.Quantity) + if !ok { + continue + } + promoPrice := math.Round(pr.Price*(1-discount/100)*100) / 100 + pr.PromoPrice = &promoPrice + pr.PromoPercent = discount + } + } + return products +} + +func applyPromotionsSingle(product *models.Product, database *db.Database) { + products := applyPromotions([]models.Product{*product}, database) + *product = products[0] +} diff --git a/backend/gestion/models/product.go b/backend/gestion/models/product.go index 9225c778..1ad4b83d 100644 --- a/backend/gestion/models/product.go +++ b/backend/gestion/models/product.go @@ -32,6 +32,13 @@ type ProductPrice struct { // TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et // seulement source du bug. ActivePrice bool `json:"active_price" gorm:"column:active_price"` + + // Champs transitoires (non persistés, gorm:"-") : annotés à la volée sur + // les endpoints de lecture client si une promotion s'applique à ce palier + // précis (voir handlers.applyPromotions) — permet d'afficher le prix + // barré + le prix promo sans toucher au prix catalogue réel. + PromoPrice *float64 `json:"promo_price,omitempty" gorm:"-"` + PromoPercent float64 `json:"promo_percent,omitempty" gorm:"-"` } func (ProductPrice) TableName() string { return "product_prices" } diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index 64ff4f08..f2330fc3 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -59,6 +59,33 @@ type PointsReward struct { CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie } +// PromotionProductQuantity associe un produit à sa propre quantité en promo, +// pour le cas où une catégorie n'est pas configurée en "tous les produits" — +// même logique que RewardProductQuantity mais pour les promotions. +type PromotionProductQuantity struct { + ProductID int `json:"product_id"` + Quantity float64 `json:"quantity"` +} + +// CategoryPromotionConfig définit une promotion (réduction en %) appliquée +// automatiquement au prix catalogue d'un produit pour une quantité donnée — +// contrairement à RewardCategoryConfig, ça ne dépend d'aucun seuil de points : +// le prix réduit s'applique à tout client qui commande ce produit à cette +// quantité, affiché directement sur le produit. La quantité correspond au +// palier de prix catalogue existant (voir GetActiveProductPrice), pas une +// valeur libre. +// +// 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 CategoryPromotionConfig struct { + Category string `json:"category"` // nom de la catégorie + DiscountPercent float64 `json:"discount_percent"` // pourcentage de réduction libre (ex: 10, 20, 33.5) + AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie + Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true + Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false +} + // DaySchedule représente les horaires de livraison pour un jour de la semaine type DaySchedule struct { Enabled bool `json:"enabled"` @@ -106,28 +133,30 @@ type DeliveryModeConfig struct { // AppSettings contient les paramètres globaux de l'application type AppSettings struct { - PenaltiesEnabled bool `json:"penalties_enabled"` - ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine - PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable) - PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points - PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés - PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points - ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage - ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage - CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto - CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces) - NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments - NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments - NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) - DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour - PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande - TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison - TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) - TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram - DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs - ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client - Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs - ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact + PenaltiesEnabled bool `json:"penalties_enabled"` + ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine + PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable) + PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points + PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés + PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points + PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions + Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie + ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage + ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage + CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto + CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces) + NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments + NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments + NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"]) + DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour + PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande + TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison + TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @) + TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram + DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs + ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client + Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs + ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact // Palette de couleurs — espace admin AdminColorPrimary string `json:"admin_color_primary"` AdminColorSecondary string `json:"admin_color_secondary"` diff --git a/backend/gestion/tests/promotions_test.go b/backend/gestion/tests/promotions_test.go new file mode 100644 index 00000000..e9b80ab2 --- /dev/null +++ b/backend/gestion/tests/promotions_test.go @@ -0,0 +1,271 @@ +package tests + +import ( + "encoding/json" + "gestion/db" + "gestion/handlers" + "gestion/models" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/gin-gonic/gin" +) + +// configurePromotionSettings applique les settings donnés (avec Promotions) +// via testDB.UpdateSettings, comme le ferait l'admin — testDB.UpdateSettings +// normalise déjà les slices nil, donc ce helper reste minimal. +func configurePromotionSettings(t *testing.T, settings models.AppSettings) { + t.Helper() + if err := testDB.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } +} + +// ── Persistance des settings (save→reload) ────────────────────────────────── + +func TestUpdateSettings_PromotionsRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + { + Category: "test", + DiscountPercent: 15.5, + AllProducts: false, + Products: []models.PromotionProductQuantity{ + {ProductID: 111, Quantity: 2}, + {ProductID: 222, Quantity: 1}, + }, + }, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if !loaded.PromotionsEnabled { + t.Fatal("promotions_enabled devrait être true après reload") + } + if len(loaded.Promotions) != 1 { + t.Fatalf("promotions: got=%d want=1: %+v", len(loaded.Promotions), loaded.Promotions) + } + promo := loaded.Promotions[0] + if promo.Category != "test" || promo.DiscountPercent != 15.5 { + t.Errorf("promo mal persistée: got=%+v", promo) + } + if len(promo.Products) != 2 || promo.Products[0].ProductID != 111 || promo.Products[0].Quantity != 2 { + t.Errorf("products mal persistés: got=%+v", promo.Products) + } + + // Désactivation : doit persister à false, pas de résurrection (même + // classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil). + s.PromotionsEnabled = false + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings (désactivation): %v", err) + } + loaded, err = testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (désactivation): %v", err) + } + if loaded.PromotionsEnabled { + t.Error("promotions_enabled devrait rester false après désactivation") + } +} + +// ── Résolution de la réduction (logique pure) ─────────────────────────────── + +func TestResolvePromotionDiscount_MatchesAllProductsAtConfiguredQuantity(t *testing.T) { + settings := &models.AppSettings{ + PromotionsEnabled: true, + Promotions: []models.CategoryPromotionConfig{ + {Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5}, + }, + } + discount, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5) + if !ok || discount != 20 { + t.Errorf("got discount=%.2f ok=%v want=20/true", discount, ok) + } + // Mauvaise quantité : pas de promo. + if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 3); ok { + t.Error("ne devrait pas matcher une quantité différente de celle configurée") + } + // Mauvaise catégorie : pas de promo. + if _, ok := db.ResolvePromotionDiscount(settings, 42, "autre", 5); ok { + t.Error("ne devrait pas matcher une catégorie différente") + } +} + +func TestResolvePromotionDiscount_DisabledReturnsNoDiscount(t *testing.T) { + settings := &models.AppSettings{ + PromotionsEnabled: false, + Promotions: []models.CategoryPromotionConfig{ + {Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5}, + }, + } + if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5); ok { + t.Error("aucune promo ne doit s'appliquer si promotions_enabled = false") + } +} + +func TestResolvePromotionDiscount_PerProductSelection(t *testing.T) { + settings := &models.AppSettings{ + PromotionsEnabled: true, + Promotions: []models.CategoryPromotionConfig{ + { + Category: "fleurs", + AllProducts: false, + Products: []models.PromotionProductQuantity{ + {ProductID: 1, Quantity: 2}, + }, + DiscountPercent: 10, + }, + }, + } + if discount, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 2); !ok || discount != 10 { + t.Errorf("produit sélectionné à la bonne quantité: got discount=%.2f ok=%v", discount, ok) + } + if _, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 3); ok { + t.Error("mauvaise quantité pour ce produit : ne doit pas matcher") + } + if _, ok := db.ResolvePromotionDiscount(settings, 2, "fleurs", 2); ok { + t.Error("produit non sélectionné : ne doit pas matcher") + } +} + +// ── AddToBasket applique réellement la réduction au prix facturé ─────────── + +func TestAddToBasket_AppliesPromotionDiscount(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "promo_basket_applies") + productID := newTestProduct(t, "PromoBasketApplies", 10) + // newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test". + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + {Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1}, + } + configurePromotionSettings(t, s) + + basket, err := testDB.AddToBasket(username, productID, 1) + if err != nil { + t.Fatalf("AddToBasket: %v", err) + } + if basket.Price != 8.0 { + t.Errorf("prix attendu = 10€ - 20%% = 8.00€: got=%.2f", basket.Price) + } +} + +func TestAddToBasket_NoDiscountWhenPromotionsDisabled(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "promo_basket_disabled") + productID := newTestProduct(t, "PromoBasketDisabled", 10) + + s := db.DefaultSettings() + s.PromotionsEnabled = false + s.Promotions = []models.CategoryPromotionConfig{ + {Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1}, + } + configurePromotionSettings(t, s) + + basket, err := testDB.AddToBasket(username, productID, 1) + if err != nil { + t.Fatalf("AddToBasket: %v", err) + } + if basket.Price != 10.0 { + t.Errorf("promotions désactivées: le prix catalogue plein doit s'appliquer: got=%.2f want=10.00", basket.Price) + } +} + +func TestAddToBasket_NoDiscountForDifferentProductSelection(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "promo_basket_other_product") + promotedID := newTestProduct(t, "PromoBasketOtherPromoted", 10) + otherID := newTestProduct(t, "PromoBasketOtherPlain", 10) + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + { + Category: "test", + AllProducts: false, + Products: []models.PromotionProductQuantity{{ProductID: promotedID, Quantity: 1}}, + DiscountPercent: 50, + }, + } + configurePromotionSettings(t, s) + + promotedBasket, err := testDB.AddToBasket(username, promotedID, 1) + if err != nil { + t.Fatalf("AddToBasket (promu): %v", err) + } + if promotedBasket.Price != 5.0 { + t.Errorf("produit promu: prix attendu = 10€ - 50%% = 5.00€: got=%.2f", promotedBasket.Price) + } + + otherBasket, err := testDB.AddToBasket(username, otherID, 1) + if err != nil { + t.Fatalf("AddToBasket (autre): %v", err) + } + if otherBasket.Price != 10.0 { + t.Errorf("produit non sélectionné dans la promo: prix plein attendu=10.00: got=%.2f", otherBasket.Price) + } +} + +// ── Affichage catalogue : le prix promo est annoté sur le palier concerné ── + +func TestGetProductByID_AnnotatesPromoPriceOnMatchingTier(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + productID := newTestProduct(t, "PromoDisplayAnnotated", 10) + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + {Category: "test", DiscountPercent: 25, AllProducts: true, Quantity: 1}, + } + configurePromotionSettings(t, s) + + idStr := strconv.Itoa(productID) + req := httptest.NewRequest(http.MethodGet, "/api/v1/products/"+idStr, nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Params = gin.Params{{Key: "id", Value: idStr}} + c.Set("role", "client") + handlers.GetProductByID(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Data models.Product `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) + } + if len(resp.Data.Prices) != 1 { + t.Fatalf("attendu 1 palier de prix: got=%+v", resp.Data.Prices) + } + tier := resp.Data.Prices[0] + if tier.PromoPrice == nil { + t.Fatal("PromoPrice devrait être renseigné pour ce palier couvert par la promo") + } + if *tier.PromoPrice != 7.5 { + t.Errorf("promo_price attendu = 10€ - 25%% = 7.50€: got=%.2f", *tier.PromoPrice) + } + if tier.PromoPercent != 25 { + t.Errorf("promo_percent attendu=25: got=%.2f", tier.PromoPercent) + } +} diff --git a/backend/gestion/tests/settings_persistence_test.go b/backend/gestion/tests/settings_persistence_test.go index 47d56f61..2c9c9ef9 100644 --- a/backend/gestion/tests/settings_persistence_test.go +++ b/backend/gestion/tests/settings_persistence_test.go @@ -1,9 +1,16 @@ package tests import ( + "bytes" + "encoding/json" "gestion/db" + "gestion/handlers" "gestion/models" + "net/http" + "net/http/httptest" "testing" + + "github.com/gin-gonic/gin" ) // resetSettingsAfterTest restaure les settings par défaut à la fin du test — @@ -304,3 +311,80 @@ func TestUpdateSettings_ColorAndGradientFieldsRoundTrip(t *testing.T) { t.Errorf("client_title_gradient: got from=%q to=%q", loaded.ClientTitleGradientFrom, loaded.ClientTitleGradientTo) } } + +// Reproduit exactement le flux réel de l'admin : PUT /settings avec le JSON +// tel qu'envoyé par le frontend (category_configs[].products, en mode +// sélection), puis GET /settings pour vérifier ce qui revient — contrairement +// aux autres tests de ce fichier qui appellent testDB.UpdateSettings / +// GetSettings directement en Go, en contournant le binding JSON HTTP réel. +func TestUpdateSettingsHTTP_CategoryConfigProductsSurviveSaveReload(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.PointsReward = &models.PointsReward{ + Threshold: 20, + CategoryConfigs: []models.RewardCategoryConfig{ + { + Category: "test", + Type: "free_product", + AllProducts: false, + Products: []models.RewardProductQuantity{ + {ProductID: 111, Quantity: 2}, + {ProductID: 222, Quantity: 1}, + }, + }, + }, + } + + body, err := json.Marshal(s) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + + putReq := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/settings", bytes.NewReader(body)) + putReq.Header.Set("Content-Type", "application/json") + putRec := httptest.NewRecorder() + putCtx, _ := gin.CreateTestContext(putRec) + putCtx.Request = putReq + putCtx.Set("database", testDB) + handlers.UpdateSettings(putCtx) + + if putRec.Code != http.StatusOK { + t.Fatalf("PUT /settings: status=%d body=%s", putRec.Code, putRec.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/v2/admin/protected/settings", nil) + getRec := httptest.NewRecorder() + getCtx, _ := gin.CreateTestContext(getRec) + getCtx.Request = getReq + getCtx.Set("database", testDB) + handlers.GetSettings(getCtx) + + if getRec.Code != http.StatusOK { + t.Fatalf("GET /settings: status=%d body=%s", getRec.Code, getRec.Body.String()) + } + + var resp struct { + Settings models.AppSettings `json:"settings"` + } + if err := json.Unmarshal(getRec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse GET: %v body=%s", err, getRec.Body.String()) + } + + if resp.Settings.PointsReward == nil { + t.Fatalf("points_reward est nil après reload") + } + if len(resp.Settings.PointsReward.CategoryConfigs) != 1 { + t.Fatalf("category_configs: got=%d want=1: %+v", len(resp.Settings.PointsReward.CategoryConfigs), resp.Settings.PointsReward.CategoryConfigs) + } + cfg := resp.Settings.PointsReward.CategoryConfigs[0] + if len(cfg.Products) != 2 { + t.Fatalf("products: got=%d want=2 (produits sélectionnés non persistés): %+v", len(cfg.Products), cfg.Products) + } + if cfg.Products[0].ProductID != 111 || cfg.Products[0].Quantity != 2 { + t.Errorf("products[0]: got=%+v want={ProductID:111 Quantity:2}", cfg.Products[0]) + } + if cfg.Products[1].ProductID != 222 || cfg.Products[1].Quantity != 1 { + t.Errorf("products[1]: got=%+v want={ProductID:222 Quantity:1}", cfg.Products[1]) + } +} diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 615905cf..7cfb5650 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -1126,6 +1126,19 @@ export interface PointsReward { category_configs: RewardCategoryConfig[]; } +export interface PromotionProductQuantity { + product_id: number; + quantity: number; // quantité individuelle de ce produit (palier de prix catalogue) +} + +export interface CategoryPromotionConfig { + category: string; + discount_percent: number; // pourcentage de réduction libre (ex: 10, 20, 33.5) + all_products: boolean; + quantity: number; // quantité uniforme si all_products = true + products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false +} + export interface PointsPool { key: string; name: string; @@ -1225,6 +1238,8 @@ export interface AppSettings { points_enabled: boolean; points_pools: PointsPool[]; points_reward?: PointsReward | null; + promotions_enabled: boolean; + promotions: CategoryPromotionConfig[]; referral_enabled: boolean; delivery_schedule: DeliverySchedule; postal_zones: PostalZone[]; diff --git a/frontend-admin/src/screens/admin/SettingsScreen.tsx b/frontend-admin/src/screens/admin/SettingsScreen.tsx index 278fdfbf..0fb7af5d 100644 --- a/frontend-admin/src/screens/admin/SettingsScreen.tsx +++ b/frontend-admin/src/screens/admin/SettingsScreen.tsx @@ -17,7 +17,7 @@ import { Ionicons } from "@expo/vector-icons"; import { spacing, fontSize, borderRadius } from "../../theme"; import { useTheme } from "../../context/ThemeContext"; import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin"; -import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin"; +import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, CategoryPromotionConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin"; import type { Product } from "../../api/types"; import AlertModal from "../../components/ui/AlertModal"; import { useAlert } from "../../hooks/useAlert"; @@ -1123,6 +1123,390 @@ function CentralRewardSection({ ); } +// ────────────────────────────────────────────────────────────── +// Sélecteur de produits pour une catégorie dans une promotion — +// même logique que CategoryProductPicker (récompenses), sans notion +// de "type" : une seule réduction (%) par catégorie. +// ────────────────────────────────────────────────────────────── +const PROMO_ACCENT = "#22c55e"; + +function PromotionProductPicker({ + catConfig, + products, + onChange, + colors, + s, +}: { + catConfig: CategoryPromotionConfig; + products: Product[]; + onChange: (cfg: CategoryPromotionConfig) => void; + colors: any; + s: any; +}) { + const catProducts = products.filter((p) => p.category === catConfig.category); + + const toggleProduct = (id: number) => { + const exists = catConfig.products.some((pq) => pq.product_id === id); + if (exists) { + onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false }); + return; + } + // Présélectionne le premier palier de prix actif du produit. + const prod = catProducts.find((p) => p.id === id); + const firstTier = (prod?.prices ?? []).find((pr) => pr.active_price !== false); + onChange({ + ...catConfig, + products: [...catConfig.products, { product_id: id, quantity: firstTier?.quantity ?? 0 }], + all_products: false, + }); + }; + + const updateProductQuantity = (id: number, quantity: number) => { + onChange({ + ...catConfig, + products: catConfig.products.map((pq) => + pq.product_id === id ? { ...pq, quantity } : pq + ), + }); + }; + + return ( + + {/* Toggle tous / sélection */} + + onChange({ ...catConfig, all_products: true, products: [] })} + style={{ + flexDirection: "row", alignItems: "center", gap: spacing.xs, + paddingHorizontal: spacing.m, paddingVertical: spacing.xs, + borderRadius: borderRadius.full, borderWidth: 1.5, + borderColor: catConfig.all_products ? PROMO_ACCENT : colors.border, + backgroundColor: catConfig.all_products ? PROMO_ACCENT + "22" : "transparent", + }} + > + + + Tous ({catProducts.length}) + + + onChange({ ...catConfig, all_products: false })} + style={{ + flexDirection: "row", alignItems: "center", gap: spacing.xs, + paddingHorizontal: spacing.m, paddingVertical: spacing.xs, + borderRadius: borderRadius.full, borderWidth: 1.5, + borderColor: !catConfig.all_products ? PROMO_ACCENT : colors.border, + backgroundColor: !catConfig.all_products ? PROMO_ACCENT + "22" : "transparent", + }} + > + + + Sélection + + + + + {/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */} + {catConfig.all_products && ( + + Quantité : + 0 ? String(catConfig.quantity) : ""} + onChangeText={(v) => { + const n = parseFloat(v); + onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n }); + }} + placeholder="1" + placeholderTextColor={colors.textMuted} + /> + + doit correspondre à un palier de prix existant + + + )} + + {/* Mode "Sélection" : chaque produit choisi a sa propre quantité, + via les paliers de prix réels du produit (pas de saisie libre) */} + {!catConfig.all_products && ( + + {catProducts.length === 0 ? ( + + Aucun produit dans cette catégorie + + ) : ( + + {catProducts.map((p) => { + const sel = catConfig.products.some((pq) => pq.product_id === p.id); + return ( + toggleProduct(p.id)} + style={{ + paddingHorizontal: spacing.s, paddingVertical: 4, + borderRadius: borderRadius.sm, borderWidth: 1.5, + borderColor: sel ? PROMO_ACCENT : colors.border, + backgroundColor: sel ? PROMO_ACCENT + "22" : "transparent", + flexDirection: "row", alignItems: "center", gap: 4, + }} + > + {sel && } + + {p.name} + + + ); + })} + + )} + + {catConfig.products.length > 0 && ( + + {catConfig.products.map((pq) => { + const prod = catProducts.find((p) => p.id === pq.product_id); + const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false); + return ( + + + {prod?.name ?? `Produit #${pq.product_id}`} + + + {tiers.length === 0 ? ( + + Aucun palier de prix actif pour ce produit + + ) : tiers.map((tier) => { + const isSel = pq.quantity === tier.quantity; + return ( + updateProductQuantity(pq.product_id, tier.quantity)} + style={{ + paddingHorizontal: spacing.s, paddingVertical: 3, + borderRadius: borderRadius.sm, borderWidth: 1.5, + borderColor: isSel ? PROMO_ACCENT : colors.border, + backgroundColor: isSel ? PROMO_ACCENT + "22" : "transparent", + flexDirection: "row", alignItems: "center", gap: 4, + }} + > + {isSel && } + + {tier.quantity}{prod?.unit ?? ""} · {tier.price}€ + + + ); + })} + + + ); + })} + + )} + + )} + + ); +} + +// ────────────────────────────────────────────────────────────── +// Section centralisée promotions — réduction (%) automatique sur des +// produits/quantités d'une catégorie, appliquée à toute commande +// (indépendant des points de fidélité, contrairement aux récompenses). +// ────────────────────────────────────────────────────────────── +function PromotionsSection({ + enabled, + promotions, + allCategories, + productsByCategory, + onToggle, + onChangePromotions, + colors, + s, +}: { + enabled: boolean; + promotions: CategoryPromotionConfig[]; + allCategories: Category[]; + productsByCategory: Record; + onToggle: (v: boolean) => void; + onChangePromotions: (promotions: CategoryPromotionConfig[]) => void; + colors: any; + s: any; +}) { + const getCatConfig = (catName: string): CategoryPromotionConfig => + promotions.find((p) => p.category === catName) ?? + { category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] }; + + const isCatSelected = (catName: string) => promotions.some((p) => p.category === catName); + + const toggleCategory = (catName: string) => { + if (isCatSelected(catName)) { + onChangePromotions(promotions.filter((p) => p.category !== catName)); + } else { + onChangePromotions([...promotions, { category: catName, discount_percent: 10, all_products: true, quantity: 1, products: [] }]); + } + }; + + const updateCatConfig = (cfg: CategoryPromotionConfig) => { + onChangePromotions(promotions.map((p) => (p.category === cfg.category ? cfg : p))); + }; + + const [expandedCats, setExpandedCats] = useState>(new Set()); + const toggleExpanded = (catName: string) => { + setExpandedCats((prev) => { + const next = new Set(prev); + if (next.has(catName)) next.delete(catName); else next.add(catName); + return next; + }); + }; + + const promoBadge = ( + + + {enabled ? "Activées" : "Désactivées"} + + + ); + + return ( + + + + Promotions activées + + Réduction automatique appliquée au prix affiché et facturé, pour tout client — indépendant des points de fidélité. + + + + + + {enabled && ( + + + Catégories en promo + + Sélectionnez une catégorie, définissez le pourcentage de réduction, puis tous les produits ou une sélection avec leur quantité. + + {allCategories.length === 0 ? ( + Aucune catégorie disponible + ) : ( + + {allCategories.map((cat) => { + const selected = isCatSelected(cat.name); + const expanded = expandedCats.has(cat.name); + const catColor = cat.color || PROMO_ACCENT; + const cfg = getCatConfig(cat.name); + return ( + + toggleExpanded(cat.name)} + style={{ + flexDirection: "row", alignItems: "center", gap: spacing.xs, + alignSelf: "flex-start", + paddingHorizontal: spacing.m, paddingVertical: spacing.s, + borderRadius: borderRadius.full, borderWidth: 1.5, + borderColor: selected ? catColor : colors.border, + backgroundColor: selected ? catColor + "22" : "transparent", + }} + > + + + {cat.name}{selected ? ` · -${cfg.discount_percent}%` : ""} + + + + + {expanded && ( + + toggleCategory(cat.name)} + style={{ + flexDirection: "row", alignItems: "center", gap: 4, + alignSelf: "flex-start", + paddingHorizontal: spacing.s, paddingVertical: 4, + borderRadius: borderRadius.sm, borderWidth: 1.5, + borderColor: selected ? PROMO_ACCENT : colors.border, + backgroundColor: selected ? PROMO_ACCENT + "22" : "transparent", + }} + > + + + + Promo active sur cette catégorie + + + + {selected && ( + <> + + Réduction : + 0 ? String(cfg.discount_percent) : ""} + onChangeText={(v) => { + const n = parseFloat(v); + updateCatConfig({ ...cfg, discount_percent: isNaN(n) ? 0 : n }); + }} + placeholder="10" + placeholderTextColor={colors.textMuted} + /> + % + + + + )} + + )} + + ); + })} + + )} + + + {/* Récapitulatif */} + {promotions.length > 0 && ( + + Récapitulatif + {promotions.map((cfg, idx) => ( + + • {cfg.category} — -{cfg.discount_percent}% sur {cfg.all_products + ? `tous les produits · qté ${cfg.quantity > 0 ? cfg.quantity : 1}` + : `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}×${pq.quantity}`).join(", ")}`} + + ))} + + )} + + )} + + ); +} + // Palette violette d'origine de l'application (thème par défaut historique) const ORIGINAL_THEME_COLORS = { admin_color_primary: "#7c3aed", @@ -1178,6 +1562,8 @@ export default function SettingsScreen() { shop_name: "Milieu-Nantais", contact_telegram: "", points_reward: null, + promotions_enabled: false, + promotions: [], admin_color_primary: "#7c3aed", admin_color_secondary: "#22d3ee", admin_color_success: "#4ade80", @@ -1257,6 +1643,11 @@ export default function SettingsScreen() { })), } : null, + promotions_enabled: s.promotions_enabled ?? false, + promotions: (s.promotions ?? []).map((cfg) => ({ + ...cfg, + products: cfg.products ?? [], + })), }); } if (categoriesRes) { @@ -1872,6 +2263,18 @@ export default function SettingsScreen() { s={s} /> + {/* Promotions — réduction automatique, indépendante des points */} + setSettings((p) => ({ ...p, promotions_enabled: v }))} + onChangePromotions={(promotions) => setSettings((p) => ({ ...p, promotions }))} + colors={colors} + s={s} + /> + {/* Horaires de livraison */} ({ quantity: parseFloat(String(p.quantity)), price: parseFloat(String(p.price)), active_price: p.active_price, + promo_price: + p.promo_price != null + ? parseFloat(String(p.promo_price)) + : undefined, + promo_percent: p.promo_percent, }), ) || [], }; @@ -118,8 +125,9 @@ function ProductDetail() { // initialise le prix par défaut (float) if (fixedProduct.prices.length > 0) { - setSelectedGrams(fixedProduct.prices[0].quantity); - setSelectedPrice(fixedProduct.prices[0].price); + const first = fixedProduct.prices[0]; + setSelectedGrams(first.quantity); + setSelectedPrice(first.promo_price ?? first.price); } // Couleur de la catégorie depuis la DB @@ -152,7 +160,11 @@ function ProductDetail() { ); if (priceOption) { - setSelectedPrice(parseFloat(String(priceOption.price))); + setSelectedPrice( + priceOption.promo_price != null + ? parseFloat(String(priceOption.promo_price)) + : parseFloat(String(priceOption.price)), + ); } }; @@ -301,13 +313,29 @@ function ProductDetail() {

{product.name}

- {selectedPrice > 0 && ( -

- {selectedPrice.toFixed(2)} €{" "} - {selectedGrams && - `pour ${selectedGrams}${product.unit || "g"}`} -

- )} + {selectedPrice > 0 && (() => { + const selectedTier = product.prices?.find( + (p) => p.quantity === selectedGrams, + ); + const hasPromo = + selectedTier?.promo_price != null && + selectedTier.promo_price < selectedTier.price; + return ( +

+ {hasPromo && ( + + {selectedTier!.price.toFixed(2)} € + + )} + + {selectedPrice.toFixed(2)} € + {" "} + {selectedGrams && + `pour ${selectedGrams}${product.unit || "g"}`} + {hasPromo && ` (-${selectedTier!.promo_percent}%)`} +

+ ); + })()}

Description

@@ -345,7 +373,9 @@ function ProductDetail() { > {p.quantity} {product.unit || "g"} -{" "} - {p.price.toFixed(2)} € + {p.promo_price != null && p.promo_price < p.price + ? `${p.promo_price.toFixed(2)} € (au lieu de ${p.price.toFixed(2)} €, -${p.promo_percent}%)` + : `${p.price.toFixed(2)} €`} ))} diff --git a/mobile/src/api/api_types.ts b/mobile/src/api/api_types.ts index b66244cb..5876835b 100644 --- a/mobile/src/api/api_types.ts +++ b/mobile/src/api/api_types.ts @@ -355,6 +355,8 @@ export interface ProductPrice { quantity: number; price: number; active_price?: boolean; + promo_price?: number; // prix réduit si une promotion couvre ce palier + promo_percent?: number; // pourcentage de réduction appliqué } export interface Product { id: number; diff --git a/mobile/src/screens/client/ProductDetailScreen.tsx b/mobile/src/screens/client/ProductDetailScreen.tsx index d65ff269..1306b46a 100644 --- a/mobile/src/screens/client/ProductDetailScreen.tsx +++ b/mobile/src/screens/client/ProductDetailScreen.tsx @@ -63,12 +63,18 @@ export default function ProductDetailScreen() { quantity: parseFloat(String(pr.quantity)), price: parseFloat(String(pr.price)), active_price: pr.active_price, + promo_price: + pr.promo_price != null + ? parseFloat(String(pr.promo_price)) + : undefined, + promo_percent: pr.promo_percent, })) || [], }; setProduct(fixedProduct); if (fixedProduct.prices.length > 0) { - setSelectedGrams(fixedProduct.prices[0].quantity); - setSelectedPrice(fixedProduct.prices[0].price); + const first = fixedProduct.prices[0]; + setSelectedGrams(first.quantity); + setSelectedPrice(first.promo_price ?? first.price); } const matched = categories.find( (c) => @@ -90,7 +96,7 @@ export default function ProductDetailScreen() { const handleGramsChange = (quantity: number) => { setSelectedGrams(quantity); const opt = product?.prices?.find((p) => p.quantity === quantity); - if (opt) setSelectedPrice(opt.price); + if (opt) setSelectedPrice(opt.promo_price ?? opt.price); setShowQuantityPicker(false); }; @@ -585,16 +591,45 @@ export default function ProductDetailScreen() { {product.name} - {selectedPrice > 0 && ( - - - - {selectedPrice.toFixed(2)} €{" "} - {selectedGrams && - `pour ${selectedGrams}${product.unit || "g"}`} - - - )} + {selectedPrice > 0 && (() => { + const selectedTier = product.prices?.find( + (p) => p.quantity === selectedGrams, + ); + const hasPromo = + selectedTier?.promo_price != null && + selectedTier.promo_price < selectedTier.price; + return ( + + + {hasPromo && ( + + {selectedTier!.price.toFixed(2)} € + + )} + + {selectedPrice.toFixed(2)} €{" "} + {selectedGrams && + `pour ${selectedGrams}${product.unit || "g"}`} + {hasPromo && + ` (-${selectedTier!.promo_percent}%)`} + + + ); + })()} Description @@ -719,16 +754,32 @@ export default function ProductDetailScreen() { {p.quantity} {product.unit || "g"} - - {p.price.toFixed(2)} € - + {p.promo_price != null && p.promo_price < p.price ? ( + + + {p.price.toFixed(2)} € + + + {p.promo_price.toFixed(2)} € (-{p.promo_percent}%) + + + ) : ( + + {p.price.toFixed(2)} € + + )} {selectedGrams === p.quantity && (