chore: build
This commit is contained in:
@@ -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
|
// Une promotion active pour ce produit/quantité/catégorie s'applique
|
||||||
// automatiquement au prix facturé — indépendamment des points de
|
// 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 {
|
if discounted, ok := d.ApplyPromotionToPrice(productID, productInfo.Category, quantity, priceResult.Price); ok {
|
||||||
|
promoDiscount = priceResult.Price - discounted
|
||||||
priceResult.Price = discounted
|
priceResult.Price = discounted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,23 +175,24 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
|||||||
ID int `gorm:"column:id"`
|
ID int `gorm:"column:id"`
|
||||||
Quantity float64 `gorm:"column:quantity"`
|
Quantity float64 `gorm:"column:quantity"`
|
||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
|
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||||
}
|
}
|
||||||
// Chercher uniquement un item normal (non-récompense) pour ce produit
|
// 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)
|
username, productID).Scan(&existing)
|
||||||
|
|
||||||
if existing.ID != 0 {
|
if existing.ID != 0 {
|
||||||
return tx.Raw(`
|
return tx.Raw(`
|
||||||
UPDATE baskets SET quantity = ?, price = ?, created_at = CURRENT_TIMESTAMP
|
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, created_at`,
|
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.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
|
||||||
existing.ID).Scan(&basket).Error
|
existing.PromoDiscount+promoDiscount, existing.ID).Scan(&basket).Error
|
||||||
}
|
}
|
||||||
return tx.Raw(`
|
return tx.Raw(`
|
||||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
INSERT INTO baskets (username, product_id, quantity, price, is_reward, promo_discount, created_at)
|
||||||
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, false, ?, CURRENT_TIMESTAMP)
|
||||||
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
|
RETURNING id, username, product_id, quantity, price, is_reward, promo_discount, created_at`,
|
||||||
username, productID, deliveredQuantity, priceResult.Price).Scan(&basket).Error
|
username, productID, deliveredQuantity, priceResult.Price, promoDiscount).Scan(&basket).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type commandItemFull struct {
|
|||||||
Prix float64 `gorm:"column:prix"`
|
Prix float64 `gorm:"column:prix"`
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
|
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||||
ClientUsername string `gorm:"column:client_username"`
|
ClientUsername string `gorm:"column:client_username"`
|
||||||
ClientNom string `gorm:"column:client_nom"`
|
ClientNom string `gorm:"column:client_nom"`
|
||||||
ClientPrenom string `gorm:"column:client_prenom"`
|
ClientPrenom string `gorm:"column:client_prenom"`
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type basketItem struct {
|
|||||||
Price float64 `gorm:"column:price"`
|
Price float64 `gorm:"column:price"`
|
||||||
IsReward bool `gorm:"column:is_reward"`
|
IsReward bool `gorm:"column:is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||||
|
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCommandStatus vérifie si le statut est valide
|
// 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
|
// bloque ici puis échoue proprement ("panier vide") une fois le premier
|
||||||
// passage terminé, au lieu de créer une commande fantôme.
|
// passage terminé, au lieu de créer une commande fantôme.
|
||||||
var basketItems []basketItem
|
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)
|
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||||
}
|
}
|
||||||
if len(basketItems) == 0 {
|
if len(basketItems) == 0 {
|
||||||
@@ -162,6 +163,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
|||||||
Prix: item.Price,
|
Prix: item.Price,
|
||||||
IsReward: item.IsReward,
|
IsReward: item.IsReward,
|
||||||
RewardPoolKey: item.RewardPoolKey,
|
RewardPoolKey: item.RewardPoolKey,
|
||||||
|
PromoDiscount: item.PromoDiscount,
|
||||||
ClientUsername: username,
|
ClientUsername: username,
|
||||||
ClientNom: clientNom,
|
ClientNom: clientNom,
|
||||||
ClientPrenom: clientPrenom,
|
ClientPrenom: clientPrenom,
|
||||||
|
|||||||
@@ -140,6 +140,20 @@ func InitDB() *Database {
|
|||||||
log.Fatalf("❌ Erreur migration command_items.reward_pool_key: %v", err)
|
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
|
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||||
if _, err = database.Exec(`
|
if _, err = database.Exec(`
|
||||||
DO $$
|
DO $$
|
||||||
|
|||||||
@@ -379,6 +379,39 @@ func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
|
|||||||
return total, err
|
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.
|
// 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) {
|
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
|
||||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
dailyRows []models.DailyProductRow
|
dailyRows []models.DailyProductRow
|
||||||
totalOrders int64
|
totalOrders int64
|
||||||
totalRevenue float64
|
totalRevenue float64
|
||||||
|
totalPromoDiscount float64
|
||||||
|
promoOrdersCount int64
|
||||||
dailyTotalOrders int64
|
dailyTotalOrders int64
|
||||||
activeDays int64
|
activeDays int64
|
||||||
last30Count int64
|
last30Count int64
|
||||||
@@ -236,6 +238,16 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
|
||||||
return err
|
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 {
|
eg.Go(func() error {
|
||||||
var err error
|
var err error
|
||||||
dailyTotalOrders, err = database.DailyOrdersCount()
|
dailyTotalOrders, err = database.DailyOrdersCount()
|
||||||
@@ -432,6 +444,8 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
"summary": gin.H{
|
"summary": gin.H{
|
||||||
"total_orders": totalOrders,
|
"total_orders": totalOrders,
|
||||||
"total_revenue": totalRevenue,
|
"total_revenue": totalRevenue,
|
||||||
|
"total_promo_discount": totalPromoDiscount,
|
||||||
|
"promo_orders_count": promoOrdersCount,
|
||||||
"peak_weekday": peakWeekday,
|
"peak_weekday": peakWeekday,
|
||||||
"top_product": topProductName,
|
"top_product": topProductName,
|
||||||
"avg_per_day": avgPerDay,
|
"avg_per_day": avgPerDay,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type CommandItem struct {
|
|||||||
Price float64 `gorm:"column:prix" json:"price"`
|
Price float64 `gorm:"column:prix" json:"price"`
|
||||||
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
IsReward bool `gorm:"column:is_reward" json:"is_reward"`
|
||||||
RewardPoolKey string `gorm:"column:reward_pool_key" json:"reward_pool_key,omitempty"`
|
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"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Panier struct {
|
|||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
IsReward bool `json:"is_reward"`
|
IsReward bool `json:"is_reward"`
|
||||||
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
RewardPoolKey string `json:"reward_pool_key,omitempty"`
|
||||||
|
PromoDiscount float64 `json:"promo_discount,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,3 +210,186 @@ func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T)
|
|||||||
t.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,8 @@ export const logoutAdmin = async (): Promise<void> => {
|
|||||||
export interface StatsSummary {
|
export interface StatsSummary {
|
||||||
total_orders: number;
|
total_orders: number;
|
||||||
total_revenue: number;
|
total_revenue: number;
|
||||||
|
total_promo_discount: number;
|
||||||
|
promo_orders_count: number;
|
||||||
peak_weekday: string;
|
peak_weekday: string;
|
||||||
top_product: string;
|
top_product: string;
|
||||||
avg_per_day: number;
|
avg_per_day: number;
|
||||||
|
|||||||
@@ -1643,6 +1643,20 @@ export default function StatsScreen() {
|
|||||||
color={CHART_AMBER}
|
color={CHART_AMBER}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<View style={styles.summaryRow}>
|
||||||
|
<SummaryCard
|
||||||
|
icon="pricetag-outline"
|
||||||
|
label="Économisé (promos)"
|
||||||
|
value={fmtEuro(s?.total_promo_discount ?? 0)}
|
||||||
|
color={CHART_GREEN}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
icon="gift-outline"
|
||||||
|
label="Commandes avec promo"
|
||||||
|
value={fmtNum(s?.promo_orders_count ?? 0)}
|
||||||
|
color={CHART_AMBER}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* ── Activité du jour ── */}
|
{/* ── Activité du jour ── */}
|
||||||
{stats?.daily_detail && (
|
{stats?.daily_detail && (
|
||||||
|
|||||||
Reference in New Issue
Block a user