diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index 96680a73..0c04699e 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -150,8 +150,13 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64) } // 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). + // fidélité (contrairement aux récompenses par palier). Le montant + // économisé est conservé (promoDiscount) pour les statistiques + // admin, indépendamment de la config de promo courante au moment où + // ces stats seront consultées. + var promoDiscount float64 if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok { + promoDiscount = priceResult.Price - discounted priceResult.Price = discounted } @@ -167,26 +172,27 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64) } var existing struct { - ID int `gorm:"column:id"` - Quantity float64 `gorm:"column:quantity"` - Price float64 `gorm:"column:price"` + ID int `gorm:"column:id"` + Quantity float64 `gorm:"column:quantity"` + Price float64 `gorm:"column:price"` + PromoDiscount float64 `gorm:"column:promo_discount"` } // Chercher uniquement un item normal (non-récompense) pour ce produit - tx.Raw(`SELECT id, quantity, price FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`, + tx.Raw(`SELECT id, quantity, price, promo_discount FROM baskets WHERE username = ? AND product_id = ? AND is_reward = false`, username, productID).Scan(&existing) if existing.ID != 0 { return tx.Raw(` - UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP - WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, created_at`, + UPDATE baskets SET quantity = ?, price = ?, promo_discount = ?, created_at = CURRENT_TIMESTAMP + WHERE id = ? AND is_reward = false RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`, existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price, - existing.ID).Scan(&basket).Error + existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error } return tx.Raw(` - INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) - VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP) - RETURNING id, username, product_id, quantity, price, is_reward, created_at`, - username, productID, deliveredQuantity, priceResult.Price).Scan(&basket).Error + INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at) + VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP) + RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`, + username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error }) if err != nil { return nil, err diff --git a/backend/gestion/db/db_command_items.go b/backend/gestion/db/db_command_items.go index 87a4db3d..ba894e1d 100644 --- a/backend/gestion/db/db_command_items.go +++ b/backend/gestion/db/db_command_items.go @@ -19,6 +19,7 @@ type commandItemFull struct { Prix float64 `gorm:"column:prix"` IsReward bool `gorm:"column:is_reward"` RewardPoolKey string `gorm:"column:reward_pool_key"` + PromoDiscount float64 `gorm:"column:promo_discount"` ClientUsername string `gorm:"column:client_username"` ClientNom string `gorm:"column:client_nom"` ClientPrenom string `gorm:"column:client_prenom"` diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go index 5427ffea..e39bbab8 100644 --- a/backend/gestion/db/db_commands.go +++ b/backend/gestion/db/db_commands.go @@ -55,6 +55,7 @@ type basketItem struct { Price float64 `gorm:"column:price"` IsReward bool `gorm:"column:is_reward"` RewardPoolKey string `gorm:"column:reward_pool_key"` + PromoDiscount float64 `gorm:"column:promo_discount"` } // validateCommandStatus vérifie si le statut est valide @@ -109,7 +110,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (* // bloque ici puis échoue proprement ("panier vide") une fois le premier // passage terminé, au lieu de créer une commande fantôme. var basketItems []basketItem - if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil { + if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key, promo_discount FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil { return fmt.Errorf("erreur récupération panier: %w", err) } if len(basketItems) == 0 { @@ -162,6 +163,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (* Prix: item.Price, IsReward: item.IsReward, RewardPoolKey: item.RewardPoolKey, + PromoDiscount: item.PromoDiscount, ClientUsername: username, ClientNom: clientNom, ClientPrenom: clientPrenom, diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index c3f0d7e6..18ea2e9f 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -140,6 +140,20 @@ func InitDB() *Database { log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err) } + // Migration: baskets.promo_discount + command_items.promo_discount — + // montant (en €) économisé par une promotion de prix sur cette ligne, + // capturé une fois pour toutes au moment de AddToBasket (voir + // db_basket.go) puis copié tel quel au checkout, pour permettre des + // statistiques historiques fiables même si la config de promo change + // ensuite (contrairement à un recalcul a posteriori sur les settings + // courants, qui donnerait un résultat faux pour les anciennes commandes). + if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil { + log.Fatalf("❌ Erreur migration baskets.promo_discount: %v", err) + } + if _, err = database.Exec(`ALTER TABLE command_items ADD COLUMN IF NOT EXISTS promo_discount NUMERIC(10,2) NOT NULL DEFAULT 0`); err != nil { + log.Fatalf("❌ Erreur migration command_items.promo_discount: %v", err) + } + // Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires if _, err = database.Exec(` DO $$ diff --git a/backend/gestion/db/db_stat.go b/backend/gestion/db/db_stat.go index a0d52e4d..2a0fac28 100644 --- a/backend/gestion/db/db_stat.go +++ b/backend/gestion/db/db_stat.go @@ -379,6 +379,39 @@ func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) { return total, err } +// TotalPromoDiscount renvoie le montant total (€) des réductions de prix +// accordées par des promotions sur les commandes approuvées, filtré par le +// reset "revenus" (même périmètre que TotalRevenue, dont c'est un +// sous-indicateur). Basé sur command_items.promo_discount, capturé au moment +// de AddToBasket — reflète donc les promos réellement appliquées à l'époque, +// pas la config de promotions courante. +func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) { + where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at") + var total float64 + query := ` + SELECT COALESCE(SUM(ci.promo_discount), 0) + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + WHERE ` + where + err := d.GDB.Raw(query, args...).Scan(&total).Error + return total, err +} + +// PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées) +// ayant bénéficié d'au moins une réduction de prix promo, filtré par le +// reset "revenus". +func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) { + where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at") + var count int64 + query := ` + SELECT COUNT(DISTINCT ci.command_id) + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + WHERE ci.promo_discount > 0 AND ` + where + err := d.GDB.Raw(query, args...).Scan(&count).Error + return count, err +} + // ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours. func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) { where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at") diff --git a/backend/gestion/handlers/stats.go b/backend/gestion/handlers/stats.go index 20fe969b..c059fecc 100644 --- a/backend/gestion/handlers/stats.go +++ b/backend/gestion/handlers/stats.go @@ -204,18 +204,20 @@ func GetAdminStats(c *gin.Context) { // Toutes les requêtes sont indépendantes — on les lance en parallèle. var ( - wdRows []models.WeekdayRow - dayRows []models.DayRow - dayRevRows []models.DayRevenueRow - hourRows []models.HourRow - prodRows []models.ProductRow - qtyRows []models.QuantityBreakdownRow - dailyRows []models.DailyProductRow - totalOrders int64 - totalRevenue float64 - dailyTotalOrders int64 - activeDays int64 - last30Count int64 + wdRows []models.WeekdayRow + dayRows []models.DayRow + dayRevRows []models.DayRevenueRow + hourRows []models.HourRow + prodRows []models.ProductRow + qtyRows []models.QuantityBreakdownRow + dailyRows []models.DailyProductRow + totalOrders int64 + totalRevenue float64 + totalPromoDiscount float64 + promoOrdersCount int64 + dailyTotalOrders int64 + activeDays int64 + last30Count int64 ) eg, _ := errgroup.WithContext(context.Background()) @@ -236,6 +238,16 @@ func GetAdminStats(c *gin.Context) { totalRevenue, err = database.TotalRevenue(filters.ResetRevenus) return err }) + eg.Go(func() error { + var err error + totalPromoDiscount, err = database.TotalPromoDiscount(filters.ResetRevenus) + return err + }) + eg.Go(func() error { + var err error + promoOrdersCount, err = database.PromoOrdersCount(filters.ResetRevenus) + return err + }) eg.Go(func() error { var err error dailyTotalOrders, err = database.DailyOrdersCount() @@ -430,11 +442,13 @@ func GetAdminStats(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "summary": gin.H{ - "total_orders": totalOrders, - "total_revenue": totalRevenue, - "peak_weekday": peakWeekday, - "top_product": topProductName, - "avg_per_day": avgPerDay, + "total_orders": totalOrders, + "total_revenue": totalRevenue, + "total_promo_discount": totalPromoDiscount, + "promo_orders_count": promoOrdersCount, + "peak_weekday": peakWeekday, + "top_product": topProductName, + "avg_per_day": avgPerDay, }, "reset_at_commandes": dateFilter(filters.ResetCommandes), "reset_at_revenus": dateFilter(filters.ResetRevenus), diff --git a/backend/gestion/models/commands.go b/backend/gestion/models/commands.go index bb74ff91..fcdd5f64 100644 --- a/backend/gestion/models/commands.go +++ b/backend/gestion/models/commands.go @@ -19,16 +19,17 @@ func (Command) TableName() string { return "commandes" } // CommandItem représente un produit dans une commande type CommandItem struct { - ID int `gorm:"primaryKey;autoIncrement" json:"id"` - CommandID int `gorm:"column:command_id" json:"command_id"` - Produit string `gorm:"column:produit" json:"produit"` - ProductID int `gorm:"column:product_id" json:"product_id"` - Quantity float64 `gorm:"column:quantite" json:"quantity"` - Price float64 `gorm:"column:prix" json:"price"` - IsReward bool `gorm:"column:is_reward" json:"is_reward"` - RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID int `gorm:"primaryKey;autoIncrement" json:"id"` + CommandID int `gorm:"column:command_id" json:"command_id"` + Produit string `gorm:"column:produit" json:"produit"` + ProductID int `gorm:"column:product_id" json:"product_id"` + Quantity float64 `gorm:"column:quantite" json:"quantity"` + Price float64 `gorm:"column:prix" json:"price"` + IsReward bool `gorm:"column:is_reward" json:"is_reward"` + RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"` + PromoDiscount float64 `gorm:"column:promo_discount" json:"promo_discount,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` } type CommandLog struct { diff --git a/backend/gestion/models/panier.go b/backend/gestion/models/panier.go index 695f3fe5..e1ede3dd 100644 --- a/backend/gestion/models/panier.go +++ b/backend/gestion/models/panier.go @@ -3,16 +3,17 @@ package models import "time" type Panier struct { - ID int `json:"id"` - Username string `json:"username"` - ProductID int `json:"product_id"` - ProductName string `json:"product_name"` - Category string `json:"category"` - Description string `json:"description"` - Quantity float64 `json:"quantity"` - Price float64 `json:"price"` - IsReward bool `json:"is_reward"` - RewardPoolKey string `json:"reward_pool_key,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int `json:"id"` + Username string `json:"username"` + ProductID int `json:"product_id"` + ProductName string `json:"product_name"` + Category string `json:"category"` + Description string `json:"description"` + Quantity float64 `json:"quantity"` + Price float64 `json:"price"` + IsReward bool `json:"is_reward"` + RewardPoolKey string `json:"reward_pool_key,omitempty"` + PromoDiscount float64 `json:"promo_discount,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } diff --git a/backend/gestion/tests/free_gifts_test.go b/backend/gestion/tests/free_gifts_test.go index 52e62af6..d7f7ab23 100644 --- a/backend/gestion/tests/free_gifts_test.go +++ b/backend/gestion/tests/free_gifts_test.go @@ -210,3 +210,186 @@ func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T) t.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert") } } + +// ── Intégration checkout : le bonus offert est bien décompté du stock ────── +// +// AddToBasket stocke déjà quantity = demandée + offerte (voir tests +// ci-dessus) ; CreateCommandWithAddress ne relit ni ne recalcule cette +// quantité — elle est copiée telle quelle dans command_items.quantite et +// utilisée telle quelle pour décrémenter products.stock (db_commands.go). +// Ces tests vérifient ce chemin de bout en bout, pas juste AddToBasket isolé. + +func TestCheckout_FreeGiftBonusQuantityDecrementsStock(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "freegift_checkout_stock") + productID := newTestProduct(t, "FreeGiftCheckoutStock", 50) + // newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test". + + s := db.DefaultSettings() + s.FreeGiftsEnabled = true + s.FreeGifts = []models.CategoryFreeGiftConfig{ + {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ + {BuyQuantity: 10, FreeQuantity: 1}, + }}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + if _, err := testDB.AddToBasket(username, productID, 10); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + // 50 initial - (10 demandés + 1 offert) = 39, pas 40. + if got := productStock(t, productID); got != 39 { + t.Errorf("stock après checkout avec cadeau: got=%.2f want=39 (50 - 11)", got) + } + + var item struct { + Quantite float64 `gorm:"column:quantite"` + Prix float64 `gorm:"column:prix"` + } + if err := testDB.GDB.Raw( + `SELECT quantite, prix FROM command_items WHERE command_id = ? AND product_id = ?`, + cmd.ID, productID, + ).Scan(&item).Error; err != nil { + t.Fatalf("lecture command_items: %v", err) + } + if item.Quantite != 11 { + t.Errorf("command_items.quantite doit inclure le cadeau: got=%.2f want=11", item.Quantite) + } + if item.Prix != 10.0 { + t.Errorf("command_items.prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", item.Prix) + } +} + +func TestCheckout_FreeGiftRollsBackWhenStockInsufficientForBonus(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "freegift_checkout_rollback") + productID := newTestProduct(t, "FreeGiftCheckoutRollback", 50) + + s := db.DefaultSettings() + s.FreeGiftsEnabled = true + s.FreeGifts = []models.CategoryFreeGiftConfig{ + {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ + {BuyQuantity: 10, FreeQuantity: 1}, + }}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if _, err := testDB.AddToBasket(username, productID, 10); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + + // Le stock chute sous 11 (10 demandés + 1 offert) après l'ajout au panier, + // simulant une vente concurrente qui vide le stock entre AddToBasket et + // checkout — le checkout doit échouer et ne rien décrémenter. + if err := testDB.GDB.Exec(`UPDATE products SET stock = 10 WHERE id = ?`, productID).Error; err != nil { + t.Fatalf("réduction stock: %v", err) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil { + t.Fatal("checkout attendu en échec: stock=10 insuffisant pour 10 demandés + 1 offert") + } + if got := productStock(t, productID); got != 10 { + t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=10", got) + } +} + +// ── Intégration annulation : le remboursement inclut le bonus offert ─────── + +func TestCancelCommandAtomic_RefundsFreeGiftBonusQuantity(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "freegift_cancel_refund") + productID := newTestProduct(t, "FreeGiftCancelRefund", 50) + + s := db.DefaultSettings() + s.FreeGiftsEnabled = true + s.FreeGifts = []models.CategoryFreeGiftConfig{ + {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ + {BuyQuantity: 10, FreeQuantity: 1}, + }}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if _, err := testDB.AddToBasket(username, productID, 10); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + if got := productStock(t, productID); got != 39 { + t.Fatalf("précondition stock post-checkout: got=%.2f want=39", got) + } + + if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil { + t.Fatalf("CancelCommandAtomic: %v", err) + } + + // 39 + 11 (10 demandés + 1 offert) = 50, retour exact au stock initial. + if got := productStock(t, productID); got != 50 { + t.Errorf("stock après annulation (bonus offert inclus dans le remboursement): got=%.2f want=50", got) + } + + // Rejeu : ne doit rembourser qu'une fois. + if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil { + t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois") + } + if got := productStock(t, productID); got != 50 { + t.Errorf("stock après double annulation: got=%.2f want=50 (un seul remboursement)", got) + } +} + +// ── Cumul avec les promotions de prix ─────────────────────────────────────── +// +// Une offre "achetez X, Y offert" et une promotion de réduction (%) sur le +// même produit doivent pouvoir s'appliquer ensemble : la promotion réduit le +// prix facturé sur la quantité demandée, le cadeau ajoute de la quantité +// livrée sans toucher au prix — les deux mécanismes sont indépendants dans +// AddToBasket (voir db_basket.go) mais rien ne garantissait jusqu'ici qu'ils +// ne s'écrasent pas mutuellement une fois combinés. +func TestAddToBasket_FreeGiftAndPromotionBothApplyTogether(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "freegift_promo_combo") + productID := newTestProduct(t, "FreeGiftPromoCombo", 50) + // 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", AllProducts: true, Quantity: 10, DiscountPercent: 20}, + } + s.FreeGiftsEnabled = true + s.FreeGifts = []models.CategoryFreeGiftConfig{ + {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ + {BuyQuantity: 10, FreeQuantity: 1}, + }}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + basket, err := testDB.AddToBasket(username, productID, 10) + if err != nil { + t.Fatalf("AddToBasket: %v", err) + } + if basket.Quantity != 11 { + t.Errorf("le cadeau doit s'appliquer malgré la promo active: got quantity=%.2f want=11", basket.Quantity) + } + if basket.Price != 8.0 { + t.Errorf("la promo doit s'appliquer malgré le cadeau actif: got price=%.2f want=8.00 (10€ - 20%%)", basket.Price) + } +} diff --git a/backend/gestion/tests/promo_stats_test.go b/backend/gestion/tests/promo_stats_test.go new file mode 100644 index 00000000..abb74b8c --- /dev/null +++ b/backend/gestion/tests/promo_stats_test.go @@ -0,0 +1,308 @@ +package tests + +import ( + "gestion/db" + "gestion/models" + "testing" + "time" +) + +// ── Traçage du montant économisé (AddToBasket → command_items) ───────────── +// +// command_items.promo_discount / baskets.promo_discount capturent le montant +// (€) économisé par une promotion de prix au moment de AddToBasket, pour +// permettre des statistiques historiques fiables même si la configuration de +// promotion change ensuite (voir db_basket.go, commentaire sur promoDiscount). + +func TestAddToBasket_TracksPromoDiscountAmount(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "promo_discount_track") + productID := newTestProduct(t, "PromoDiscountTrack", 20) + // 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", AllProducts: true, Quantity: 1, DiscountPercent: 20}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + basket, err := testDB.AddToBasket(username, productID, 1) + if err != nil { + t.Fatalf("AddToBasket: %v", err) + } + if basket.Price != 8.0 { + t.Fatalf("précondition prix promo: got=%.2f want=8.00", basket.Price) + } + if basket.PromoDiscount != 2.0 { + t.Errorf("promo_discount doit être l'écart catalogue/promo: got=%.2f want=2.00 (10€-8€)", basket.PromoDiscount) + } +} + +func TestAddToBasket_NoPromoDiscountWithoutPromotion(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "promo_discount_none") + productID := newTestProduct(t, "PromoDiscountNone", 20) + + basket, err := testDB.AddToBasket(username, productID, 1) + if err != nil { + t.Fatalf("AddToBasket: %v", err) + } + if basket.PromoDiscount != 0 { + t.Errorf("sans promo, promo_discount doit rester à 0: got=%.2f", basket.PromoDiscount) + } +} + +// Deux ajouts successifs du même produit (même ligne panier, is_reward=false) +// fusionnent quantité et prix (voir AddToBasket) — promo_discount doit être +// cumulé de la même façon, pas remplacé par le dernier ajout. +func TestAddToBasket_MergePromoDiscountAccumulatesAcrossAdds(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "promo_discount_merge") + productID := newTestProduct(t, "PromoDiscountMerge", 20) + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + {Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + if _, err := testDB.AddToBasket(username, productID, 1); err != nil { + t.Fatalf("AddToBasket (1er ajout): %v", err) + } + basket, err := testDB.AddToBasket(username, productID, 1) + if err != nil { + t.Fatalf("AddToBasket (2e ajout): %v", err) + } + if basket.PromoDiscount != 4.0 { + t.Errorf("le cumul des deux ajouts doit sommer les remises: got=%.2f want=4.00 (2×2€)", basket.PromoDiscount) + } +} + +func TestCheckout_PromoDiscountCopiedToCommandItems(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "promo_discount_checkout") + productID := newTestProduct(t, "PromoDiscountCheckout", 20) + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + {Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if _, err := testDB.AddToBasket(username, productID, 1); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + var discount float64 + if err := testDB.GDB.Raw( + `SELECT promo_discount FROM command_items WHERE command_id = ? AND product_id = ?`, + cmd.ID, productID, + ).Scan(&discount).Error; err != nil { + t.Fatalf("lecture command_items: %v", err) + } + if discount != 2.0 { + t.Errorf("promo_discount doit être copié tel quel au checkout: got=%.2f want=2.00", discount) + } +} + +// ── Stats admin : total économisé et nombre de commandes concernées ──────── + +func approveTestCommand(t *testing.T, commandID int) { + t.Helper() + if err := testDB.GDB.Exec(`UPDATE commandes SET status = 'approved' WHERE id = ?`, commandID).Error; err != nil { + t.Fatalf("passage en approved: %v", err) + } +} + +func TestTotalPromoDiscount_SumsOnlyApprovedOrders(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "stats_promo_discount") + productID := newTestProduct(t, "StatsPromoDiscount", 20) + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + {Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + // Commande 1 : avec promo, approuvée → comptée. + if _, err := testDB.AddToBasket(username, productID, 1); err != nil { + t.Fatalf("AddToBasket (cmd1): %v", err) + } + cmd1, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress (cmd1): %v", err) + } + approveTestCommand(t, cmd1.ID) + + // Commande 2 : avec promo, restée "pending" (statut par défaut du + // checkout) → NE DOIT PAS être comptée. + if _, err := testDB.AddToBasket(username, productID, 1); err != nil { + t.Fatalf("AddToBasket (cmd2): %v", err) + } + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress (cmd2): %v", err) + } + + total, err := testDB.TotalPromoDiscount(time.Time{}) + if err != nil { + t.Fatalf("TotalPromoDiscount: %v", err) + } + if total != 2.0 { + t.Errorf("seule la commande approuvée doit compter: got=%.2f want=2.00", total) + } + + count, err := testDB.PromoOrdersCount(time.Time{}) + if err != nil { + t.Fatalf("PromoOrdersCount: %v", err) + } + if count != 1 { + t.Errorf("une seule commande approuvée avec promo: got=%d want=1", count) + } +} + +func TestTotalPromoDiscount_RespectsResetFilter(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "stats_promo_reset") + productID := newTestProduct(t, "StatsPromoReset", 20) + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + {Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if _, err := testDB.AddToBasket(username, productID, 1); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + approveTestCommand(t, cmd.ID) + + // Un reset postérieur à la création de la commande doit l'exclure — sert + // aussi à vérifier que la jointure command_items/commandes qualifie bien + // created_at par l'alias (les deux tables ont une colonne created_at, + // donc une clause non qualifiée provoquerait une erreur Postgres + // "ambiguous column" plutôt qu'un mauvais résultat). + future := time.Now().Add(time.Hour) + total, err := testDB.TotalPromoDiscount(future) + if err != nil { + t.Fatalf("TotalPromoDiscount: %v", err) + } + if total != 0 { + t.Errorf("commande antérieure au reset: doit être exclue: got=%.2f want=0", total) + } + + count, err := testDB.PromoOrdersCount(future) + if err != nil { + t.Fatalf("PromoOrdersCount: %v", err) + } + if count != 0 { + t.Errorf("commande antérieure au reset: doit être exclue: got=%d want=0", count) + } +} + +// Une commande avec plusieurs lignes en promo ne doit compter qu'une fois +// dans PromoOrdersCount (COUNT DISTINCT command_id), mais le montant total +// doit sommer toutes les lignes. +func TestPromoOrdersCount_CountsOrderOnceDespiteMultipleDiscountedItems(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "stats_promo_multi") + productA := newTestProduct(t, "StatsPromoMultiA", 20) + productB := newTestProduct(t, "StatsPromoMultiB", 20) + + s := db.DefaultSettings() + s.PromotionsEnabled = true + s.Promotions = []models.CategoryPromotionConfig{ + {Category: "test", AllProducts: true, Quantity: 1, DiscountPercent: 20}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + if _, err := testDB.AddToBasket(username, productA, 1); err != nil { + t.Fatalf("AddToBasket A: %v", err) + } + if _, err := testDB.AddToBasket(username, productB, 1); err != nil { + t.Fatalf("AddToBasket B: %v", err) + } + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + approveTestCommand(t, cmd.ID) + + count, err := testDB.PromoOrdersCount(time.Time{}) + if err != nil { + t.Fatalf("PromoOrdersCount: %v", err) + } + if count != 1 { + t.Errorf("une commande avec 2 lignes en promo doit compter une seule fois: got=%d want=1", count) + } + + total, err := testDB.TotalPromoDiscount(time.Time{}) + if err != nil { + t.Fatalf("TotalPromoDiscount: %v", err) + } + if total != 4.0 { + t.Errorf("le montant total doit sommer les deux lignes: got=%.2f want=4.00 (2×2€)", total) + } +} + +func TestPromoOrdersCount_IgnoresOrdersWithoutDiscount(t *testing.T) { + cleanupStockTestData(t) + resetSettingsAfterTest(t) + username := newTestClient(t, "stats_promo_zero") + productID := newTestProduct(t, "StatsPromoZero", 20) + // Aucune promotion configurée : promo_discount reste à 0 pour cette commande. + + if _, err := testDB.AddToBasket(username, productID, 1); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + approveTestCommand(t, cmd.ID) + + count, err := testDB.PromoOrdersCount(time.Time{}) + if err != nil { + t.Fatalf("PromoOrdersCount: %v", err) + } + if count != 0 { + t.Errorf("aucune commande sans promo ne doit être comptée: got=%d want=0", count) + } + total, err := testDB.TotalPromoDiscount(time.Time{}) + if err != nil { + t.Fatalf("TotalPromoDiscount: %v", err) + } + if total != 0 { + t.Errorf("aucun montant économisé sans promo: got=%.2f want=0", total) + } +} diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 96e785cf..471362a5 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -56,6 +56,8 @@ export const logoutAdmin = async (): Promise => { export interface StatsSummary { total_orders: number; total_revenue: number; + total_promo_discount: number; + promo_orders_count: number; peak_weekday: string; top_product: string; avg_per_day: number; diff --git a/frontend-admin/src/screens/admin/StatsScreen.tsx b/frontend-admin/src/screens/admin/StatsScreen.tsx index dd31f3d9..149c7f88 100644 --- a/frontend-admin/src/screens/admin/StatsScreen.tsx +++ b/frontend-admin/src/screens/admin/StatsScreen.tsx @@ -1643,6 +1643,20 @@ export default function StatsScreen() { color={CHART_AMBER} /> + + + + {/* ── Activité du jour ── */} {stats?.daily_detail && (