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
|
||||
// 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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 $$
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
total_orders: number;
|
||||
total_revenue: number;
|
||||
total_promo_discount: number;
|
||||
promo_orders_count: number;
|
||||
peak_weekday: string;
|
||||
top_product: string;
|
||||
avg_per_day: number;
|
||||
|
||||
@@ -1643,6 +1643,20 @@ export default function StatsScreen() {
|
||||
color={CHART_AMBER}
|
||||
/>
|
||||
</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 ── */}
|
||||
{stats?.daily_detail && (
|
||||
|
||||
Reference in New Issue
Block a user