chore: build
This commit is contained in:
@@ -137,9 +137,6 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
||||
if err := tx.Raw(`SELECT stock, category FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&productInfo).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||
}
|
||||
if productInfo.Stock < quantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
|
||||
var priceResult struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
@@ -158,6 +155,17 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
||||
priceResult.Price = discounted
|
||||
}
|
||||
|
||||
// Offre "achetez X, Y offert" : le client reçoit une quantité
|
||||
// supplémentaire du même produit, gratuite, sans changer le prix déjà
|
||||
// calculé sur la quantité demandée — la quantité livrée/décomptée du
|
||||
// stock est donc supérieure à la quantité facturée.
|
||||
freeQuantity := d.ResolveFreeGiftQuantity(productID, productInfo.Category, quantity)
|
||||
deliveredQuantity := quantity + freeQuantity
|
||||
|
||||
if productInfo.Stock < deliveredQuantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
}
|
||||
|
||||
var existing struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
@@ -171,14 +179,14 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
||||
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`,
|
||||
existing.Quantity+quantity, existing.Price+priceResult.Price,
|
||||
existing.Quantity+deliveredQuantity, existing.Price+priceResult.Price,
|
||||
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, quantity, priceResult.Price).Scan(&basket).Error
|
||||
username, productID, deliveredQuantity, priceResult.Price).Scan(&basket).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package db
|
||||
|
||||
import "gestion/models"
|
||||
|
||||
// ResolveFreeGift retourne la quantité offerte (du même produit) pour un
|
||||
// produit, sa catégorie catalogue et une quantité commandée donnés — le seuil
|
||||
// le plus élevé (BuyQuantity) atteint par la quantité commandée est retenu,
|
||||
// tous seuils confondus pour ce produit (ex: seuils 10g→+1g et 20g→+3g, une
|
||||
// commande de 25g retient +3g, pas +1g).
|
||||
func ResolveFreeGift(settings *models.AppSettings, productID int, category string, quantity float64) float64 {
|
||||
if settings == nil || !settings.FreeGiftsEnabled {
|
||||
return 0
|
||||
}
|
||||
|
||||
var bestBuy, bestFree float64
|
||||
found := false
|
||||
consider := func(tiers []models.FreeGiftTier) {
|
||||
for _, t := range tiers {
|
||||
if t.BuyQuantity <= 0 || t.FreeQuantity <= 0 || quantity < t.BuyQuantity {
|
||||
continue
|
||||
}
|
||||
if !found || t.BuyQuantity > bestBuy {
|
||||
bestBuy, bestFree = t.BuyQuantity, t.FreeQuantity
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, g := range settings.FreeGifts {
|
||||
if g.Category != category {
|
||||
continue
|
||||
}
|
||||
if g.AllProducts {
|
||||
consider(g.Tiers)
|
||||
continue
|
||||
}
|
||||
for _, pq := range g.Products {
|
||||
if pq.ProductID == productID {
|
||||
consider(pq.Tiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return 0
|
||||
}
|
||||
return bestFree
|
||||
}
|
||||
|
||||
// ResolveFreeGiftQuantity lit les settings courants et applique
|
||||
// ResolveFreeGift — wrapper pratique pour les appelants qui n'ont pas déjà
|
||||
// les settings sous la main (même style que ApplyPromotionToPrice).
|
||||
func (d *Database) ResolveFreeGiftQuantity(productID int, category string, quantity float64) float64 {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return ResolveFreeGift(&settings, productID, category, quantity)
|
||||
}
|
||||
@@ -149,6 +149,13 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
if err := json.Unmarshal([]byte(row.Value), &promotions); err == nil {
|
||||
settings.Promotions = promotions
|
||||
}
|
||||
case "free_gifts_enabled":
|
||||
settings.FreeGiftsEnabled = row.Value == "true"
|
||||
case "free_gifts":
|
||||
var freeGifts []models.CategoryFreeGiftConfig
|
||||
if err := json.Unmarshal([]byte(row.Value), &freeGifts); err == nil {
|
||||
settings.FreeGifts = freeGifts
|
||||
}
|
||||
case "referral_enabled":
|
||||
settings.ReferralEnabled = row.Value == "true"
|
||||
case "referral_amount":
|
||||
@@ -283,6 +290,27 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return fmt.Errorf("erreur sérialisation promotions: %w", err)
|
||||
}
|
||||
|
||||
if s.FreeGifts == nil {
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{}
|
||||
}
|
||||
for i := range s.FreeGifts {
|
||||
if s.FreeGifts[i].Tiers == nil {
|
||||
s.FreeGifts[i].Tiers = []models.FreeGiftTier{}
|
||||
}
|
||||
if s.FreeGifts[i].Products == nil {
|
||||
s.FreeGifts[i].Products = []models.FreeGiftProductQuantity{}
|
||||
}
|
||||
for j := range s.FreeGifts[i].Products {
|
||||
if s.FreeGifts[i].Products[j].Tiers == nil {
|
||||
s.FreeGifts[i].Products[j].Tiers = []models.FreeGiftTier{}
|
||||
}
|
||||
}
|
||||
}
|
||||
freeGiftsJSON, err := json.Marshal(s.FreeGifts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation free_gifts: %w", err)
|
||||
}
|
||||
|
||||
if s.NowPaymentsCurrencies == nil {
|
||||
s.NowPaymentsCurrencies = []string{}
|
||||
}
|
||||
@@ -324,6 +352,8 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
{"points_reward", string(rewardJSON)},
|
||||
{"promotions_enabled", boolStr(s.PromotionsEnabled)},
|
||||
{"promotions", string(promotionsJSON)},
|
||||
{"free_gifts_enabled", boolStr(s.FreeGiftsEnabled)},
|
||||
{"free_gifts", string(freeGiftsJSON)},
|
||||
{"referral_enabled", boolStr(s.ReferralEnabled)},
|
||||
{"referral_amount", strconv.FormatFloat(s.ReferralAmount, 'f', 2, 64)},
|
||||
{"crypto_payment_enabled", boolStr(s.CryptoPaymentEnabled)},
|
||||
|
||||
@@ -86,6 +86,39 @@ type CategoryPromotionConfig struct {
|
||||
Products []PromotionProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||
}
|
||||
|
||||
// FreeGiftTier définit un seuil d'achat et la quantité offerte associée, du
|
||||
// même produit — plusieurs seuils peuvent coexister pour un même produit
|
||||
// (ex: 10g achetés → 1g offert, 20g achetés → 3g offerts) ; le seuil le plus
|
||||
// élevé atteint par la quantité commandée est retenu (voir ResolveFreeGift).
|
||||
type FreeGiftTier struct {
|
||||
BuyQuantity float64 `json:"buy_quantity"` // quantité à acheter pour déclencher l'offre
|
||||
FreeQuantity float64 `json:"free_quantity"` // quantité offerte du même produit
|
||||
}
|
||||
|
||||
// FreeGiftProductQuantity associe un produit à ses propres seuils
|
||||
// d'achat/offre, pour le cas où une catégorie n'est pas configurée en "tous
|
||||
// les produits" — même logique que PromotionProductQuantity mais pour les
|
||||
// offres quantité achetée/offerte.
|
||||
type FreeGiftProductQuantity struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Tiers []FreeGiftTier `json:"tiers"`
|
||||
}
|
||||
|
||||
// CategoryFreeGiftConfig définit une offre "achetez X, Y offert" (du même
|
||||
// produit) appliquée automatiquement dès que la quantité ajoutée au panier
|
||||
// atteint un seuil configuré — indépendant des points de fidélité et des
|
||||
// promotions (cumulable avec elles).
|
||||
//
|
||||
// Si AllProducts = true, Tiers s'applique uniformément à tous les produits de
|
||||
// la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||
// Products a ses propres seuils (Tiers au niveau catégorie est alors ignoré).
|
||||
type CategoryFreeGiftConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
Tiers []FreeGiftTier `json:"tiers"` // seuils uniformes si AllProducts = true
|
||||
Products []FreeGiftProductQuantity `json:"products"` // produits + seuils individuels si AllProducts = false
|
||||
}
|
||||
|
||||
// DaySchedule représente les horaires de livraison pour un jour de la semaine
|
||||
type DaySchedule struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -141,6 +174,8 @@ type AppSettings struct {
|
||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
||||
PromotionsEnabled bool `json:"promotions_enabled"` // activer/désactiver les promotions
|
||||
Promotions []CategoryPromotionConfig `json:"promotions"` // promotions (% de réduction) par catégorie
|
||||
FreeGiftsEnabled bool `json:"free_gifts_enabled"` // activer/désactiver les offres "achetez X, Y offert"
|
||||
FreeGifts []CategoryFreeGiftConfig `json:"free_gifts"` // offres quantité achetée/offerte par catégorie
|
||||
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
|
||||
ReferralAmount float64 `json:"referral_amount"` // montant crédité par parrainage
|
||||
CryptoPaymentEnabled bool `json:"crypto_payment_enabled"` // activer/désactiver le paiement crypto
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Persistance des settings (save→reload) ──────────────────────────────────
|
||||
|
||||
func TestUpdateSettings_FreeGiftsRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.FreeGiftsEnabled = true
|
||||
s.FreeGifts = []models.CategoryFreeGiftConfig{
|
||||
{
|
||||
Category: "test",
|
||||
AllProducts: false,
|
||||
Products: []models.FreeGiftProductQuantity{
|
||||
{ProductID: 111, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
{BuyQuantity: 20, FreeQuantity: 3},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
if !loaded.FreeGiftsEnabled {
|
||||
t.Fatal("free_gifts_enabled devrait être true après reload")
|
||||
}
|
||||
if len(loaded.FreeGifts) != 1 {
|
||||
t.Fatalf("free_gifts: got=%d want=1: %+v", len(loaded.FreeGifts), loaded.FreeGifts)
|
||||
}
|
||||
gift := loaded.FreeGifts[0]
|
||||
if gift.Category != "test" || len(gift.Products) != 1 {
|
||||
t.Fatalf("free gift mal persistée: got=%+v", gift)
|
||||
}
|
||||
if len(gift.Products[0].Tiers) != 2 || gift.Products[0].Tiers[1].BuyQuantity != 20 || gift.Products[0].Tiers[1].FreeQuantity != 3 {
|
||||
t.Errorf("tiers mal persistés: got=%+v", gift.Products[0].Tiers)
|
||||
}
|
||||
|
||||
// Désactivation : doit persister à false, pas de résurrection (même
|
||||
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
|
||||
s.FreeGiftsEnabled = false
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings (désactivation): %v", err)
|
||||
}
|
||||
loaded, err = testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings (désactivation): %v", err)
|
||||
}
|
||||
if loaded.FreeGiftsEnabled {
|
||||
t.Error("free_gifts_enabled devrait rester false après désactivation")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Résolution de la quantité offerte (logique pure) ────────────────────────
|
||||
|
||||
func TestResolveFreeGift_AllProductsAtOrAboveThreshold(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
FreeGiftsEnabled: true,
|
||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 1 {
|
||||
t.Errorf("quantité offerte: got=%.2f want=1", got)
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 15); got != 1 {
|
||||
t.Errorf("au-dessus du seuil, le cadeau reste dû: got=%.2f want=1", got)
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 9); got != 0 {
|
||||
t.Errorf("sous le seuil, aucun cadeau: got=%.2f want=0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFreeGift_DisabledReturnsZero(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
FreeGiftsEnabled: false,
|
||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||
{Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
}},
|
||||
},
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 0 {
|
||||
t.Errorf("offres désactivées: aucun cadeau attendu: got=%.2f", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFreeGift_PerProductHighestTierApplies(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
FreeGiftsEnabled: true,
|
||||
FreeGifts: []models.CategoryFreeGiftConfig{
|
||||
{
|
||||
Category: "fleurs",
|
||||
AllProducts: false,
|
||||
Products: []models.FreeGiftProductQuantity{
|
||||
{ProductID: 111, Tiers: []models.FreeGiftTier{
|
||||
{BuyQuantity: 10, FreeQuantity: 1},
|
||||
{BuyQuantity: 20, FreeQuantity: 3},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if got := db.ResolveFreeGift(settings, 111, "fleurs", 10); got != 1 {
|
||||
t.Errorf("seuil 10g: got=%.2f want=1", got)
|
||||
}
|
||||
// 25g dépasse les deux seuils : le plus élevé (20g→3g) doit être retenu,
|
||||
// pas le premier de la liste (10g→1g).
|
||||
if got := db.ResolveFreeGift(settings, 111, "fleurs", 25); got != 3 {
|
||||
t.Errorf("seuil le plus élevé atteint (20g→3g): got=%.2f want=3", got)
|
||||
}
|
||||
// Produit non listé dans cette config : aucun cadeau.
|
||||
if got := db.ResolveFreeGift(settings, 222, "fleurs", 25); got != 0 {
|
||||
t.Errorf("produit non couvert: got=%.2f want=0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Intégration AddToBasket : la quantité livrée inclut le cadeau, au même prix ──
|
||||
|
||||
func TestAddToBasket_AppliesFreeGiftQuantityAtSamePrice(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_basket_applies")
|
||||
productID := newTestProduct(t, "FreeGiftBasketApplies", 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)
|
||||
}
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.Quantity != 11 {
|
||||
t.Errorf("quantité livrée attendue = 10 + 1 offert = 11: got=%.2f", basket.Quantity)
|
||||
}
|
||||
if basket.Price != 10.0 {
|
||||
t.Errorf("le prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", basket.Price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToBasket_NoFreeGiftBelowThreshold(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_basket_below")
|
||||
productID := newTestProduct(t, "FreeGiftBasketBelow", 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)
|
||||
}
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.Quantity != 5 {
|
||||
t.Errorf("sous le seuil, aucune quantité offerte: got=%.2f want=5", basket.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// La quantité réellement décomptée du stock doit inclure le cadeau : un stock
|
||||
// suffisant pour la quantité demandée mais pas pour demandée+offerte doit
|
||||
// faire échouer l'ajout, pas livrer un cadeau partiel.
|
||||
func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "freegift_basket_stock")
|
||||
productID := newTestProduct(t, "FreeGiftBasketStock", 10) // stock = 10, pile la quantité demandée
|
||||
|
||||
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.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user