chore: build
Backend - Build & Lint / build (push) Successful in 32m38s
Frontend Admin - EAS Build / build (push) Successful in 1h34m28s
Frontend Client - EAS Build / build (push) Successful in 1h30m42s
Frontend Web - Build & Lint / build (push) Canceled after 12m2s

This commit is contained in:
Xor290
2026-09-13 16:11:30 +02:00
parent f2f537a194
commit d7d7496a66
12 changed files with 631 additions and 52 deletions
+18 -12
View File
@@ -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
+1
View File
@@ -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"`
+3 -1
View File
@@ -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,
+14
View File
@@ -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 $$
+33
View File
@@ -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")
+31 -17
View File
@@ -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),
+11 -10
View File
@@ -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 {
+13 -12
View File
@@ -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"`
}
+183
View File
@@ -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)
}
}
+308
View File
@@ -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)
}
}