Compare commits
24
Commits
bf718c5b13
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9688acbc2 | ||
|
|
d7d7496a66 | ||
|
|
f2f537a194 | ||
|
|
91745636ec | ||
|
|
181147e3bf | ||
|
|
624df79974 | ||
|
|
42ed11bc22 | ||
|
|
6fca16a58c | ||
|
|
a35031b2a1 | ||
|
|
96e7d33739 | ||
|
|
01ac0626be | ||
|
|
6c88ec0cc7 | ||
|
|
62b4ac1866 | ||
|
|
6cc303c59c | ||
|
|
40cbb41dec | ||
|
|
149040391d | ||
|
|
b8ccb55653 | ||
|
|
3949701dd2 | ||
|
|
d8073bdc46 | ||
|
|
5ee979fcc3 | ||
|
|
30eebd12d2 | ||
|
|
f469e52c32 | ||
|
|
de075a8fe3 | ||
|
|
53f6a1bb1a |
@@ -149,6 +149,7 @@ jobs:
|
||||
run: |
|
||||
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
|
||||
npx expo export --platform android --output-dir dist
|
||||
npx expo config --json > dist/expoconfig.json
|
||||
cd dist && zip -r ../bundle.zip . && cd ..
|
||||
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
|
||||
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
|
||||
|
||||
@@ -149,6 +149,7 @@ jobs:
|
||||
run: |
|
||||
RUNTIME_VERSION=$(jq -r '.expo.runtimeVersion' app.json)
|
||||
npx expo export --platform android --output-dir dist
|
||||
npx expo config --json > dist/expoconfig.json
|
||||
cd dist && zip -r ../bundle.zip . && cd ..
|
||||
curl -X POST "${{ steps.config.outputs.xavia_url }}/api/upload" \
|
||||
-H "Authorization: Bearer ${{ steps.config.outputs.xavia_key }}" \
|
||||
|
||||
@@ -130,12 +130,12 @@ func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
|
||||
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
|
||||
var basket models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var currentStock float64
|
||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, productID).Scan(¤tStock).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock: %w", err)
|
||||
var productInfo struct {
|
||||
Stock float64 `gorm:"column:stock"`
|
||||
Category string `gorm:"column:category"`
|
||||
}
|
||||
if currentStock < quantity {
|
||||
return fmt.Errorf("stock insuffisant")
|
||||
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)
|
||||
}
|
||||
|
||||
var priceResult struct {
|
||||
@@ -148,28 +148,51 @@ func (d *Database) AddToBasket(username string, productID int, quantity float64)
|
||||
productID, quantity).Scan(&priceResult).Error; err != nil || priceResult.Price == 0 {
|
||||
return fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
// 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). 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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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`,
|
||||
existing.Quantity+quantity, existing.Price+priceResult.Price,
|
||||
existing.ID).Scan(&basket).Error
|
||||
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.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, quantity, 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"`
|
||||
@@ -232,6 +233,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
@@ -261,6 +263,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
ci.promo_discount,
|
||||
ci.is_reward,
|
||||
ci.reward_pool_key,
|
||||
ci.client_username,
|
||||
@@ -309,6 +312,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
"product_id": productIDValue,
|
||||
"quantite": row.Quantite,
|
||||
"prix": row.Prix,
|
||||
"promo_discount": row.PromoDiscount,
|
||||
"is_reward": row.IsReward,
|
||||
"reward_pool_key": row.RewardPoolKey,
|
||||
"client_username": row.ClientUsername,
|
||||
@@ -352,6 +356,7 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
|
||||
ProductID *int64 `gorm:"column:product_id"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
PromoDiscount float64 `gorm:"column:promo_discount"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
ClientUsername string `gorm:"column:client_username"`
|
||||
@@ -376,7 +381,7 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
|
||||
err := d.GDB.Raw(`
|
||||
SELECT
|
||||
ci.id, ci.command_id, ci.produit, ci.product_id,
|
||||
ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key,
|
||||
ci.quantite, ci.prix, ci.promo_discount, ci.is_reward, ci.reward_pool_key,
|
||||
ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone,
|
||||
ci.delivery_address, ci.status, ci.created_at, ci.updated_at,
|
||||
c.status as command_status, c.adresse as command_address,
|
||||
@@ -406,7 +411,7 @@ func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]
|
||||
item := map[string]any{
|
||||
"id": row.ID, "command_id": row.CommandID,
|
||||
"produit": row.Produit, "product_id": productIDValue,
|
||||
"quantite": row.Quantite, "prix": row.Prix,
|
||||
"quantite": row.Quantite, "prix": row.Prix, "promo_discount": row.PromoDiscount,
|
||||
"is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey,
|
||||
"client_username": row.ClientUsername, "client_nom": row.ClientNom,
|
||||
"client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 $$
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"math"
|
||||
)
|
||||
|
||||
// ResolvePromotionDiscount retourne le pourcentage de réduction actif pour un
|
||||
// produit, sa catégorie catalogue et une quantité donnés, si une promotion
|
||||
// configurée dans les settings couvre exactement ce couple (produit,
|
||||
// quantité) — contrairement aux récompenses, aucun seuil de points n'entre
|
||||
// en jeu : la promotion s'applique à toute commande de cette quantité.
|
||||
func ResolvePromotionDiscount(settings *models.AppSettings, productID int, category string, quantity float64) (float64, bool) {
|
||||
if settings == nil || !settings.PromotionsEnabled {
|
||||
return 0, false
|
||||
}
|
||||
for _, promo := range settings.Promotions {
|
||||
if promo.Category != category || promo.DiscountPercent <= 0 {
|
||||
continue
|
||||
}
|
||||
if promo.AllProducts {
|
||||
if promo.Quantity == quantity {
|
||||
return promo.DiscountPercent, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, pq := range promo.Products {
|
||||
if pq.ProductID == productID && pq.Quantity == quantity {
|
||||
return promo.DiscountPercent, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ApplyPromotionToPrice applique la réduction (si une promotion couvre ce
|
||||
// produit/quantité/catégorie) au prix catalogue donné, arrondi au centime.
|
||||
func (d *Database) ApplyPromotionToPrice(productID int, category string, quantity, price float64) (float64, bool) {
|
||||
settings, err := d.GetSettings()
|
||||
if err != nil {
|
||||
return price, false
|
||||
}
|
||||
discount, ok := ResolvePromotionDiscount(&settings, productID, category, quantity)
|
||||
if !ok {
|
||||
return price, false
|
||||
}
|
||||
return math.Round(price*(1-discount/100)*100) / 100, true
|
||||
}
|
||||
@@ -72,19 +72,19 @@ func DefaultSettings() models.AppSettings {
|
||||
Mode: "single",
|
||||
CategoryRoutes: []models.CategoryRoute{},
|
||||
},
|
||||
AdminColorPrimary: "#7c3aed",
|
||||
AdminColorSecondary: "#000000",
|
||||
AdminColorSuccess: "#4ade80",
|
||||
AdminColorDanger: "#ef4444",
|
||||
AdminColorWarning: "#f59e0b",
|
||||
ClientColorPrimary: "#7c3aed",
|
||||
ClientColorSecondary: "#000000",
|
||||
ClientColorSuccess: "#4ade80",
|
||||
ClientColorDanger: "#ef4444",
|
||||
ClientColorWarning: "#f59e0b",
|
||||
AdminColorPrimary: "#7c3aed",
|
||||
AdminColorSecondary: "#000000",
|
||||
AdminColorSuccess: "#4ade80",
|
||||
AdminColorDanger: "#ef4444",
|
||||
AdminColorWarning: "#f59e0b",
|
||||
ClientColorPrimary: "#7c3aed",
|
||||
ClientColorSecondary: "#000000",
|
||||
ClientColorSuccess: "#4ade80",
|
||||
ClientColorDanger: "#ef4444",
|
||||
ClientColorWarning: "#f59e0b",
|
||||
ClientTitleGradientFrom: "#a78bfa",
|
||||
ClientTitleGradientTo: "#22d3ee",
|
||||
DeliverySchedule: DefaultDeliverySchedule(),
|
||||
DeliverySchedule: DefaultDeliverySchedule(),
|
||||
PostalZones: []models.PostalZone{
|
||||
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
|
||||
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
|
||||
@@ -115,6 +115,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
switch row.Key {
|
||||
case "penalties_enabled":
|
||||
settings.PenaltiesEnabled = row.Value == "true"
|
||||
case "penalty_tiers":
|
||||
var tiers []models.PenaltyTier
|
||||
if err := json.Unmarshal([]byte(row.Value), &tiers); err == nil {
|
||||
settings.PenaltyTiers = tiers
|
||||
}
|
||||
case "show_amende_score":
|
||||
settings.ShowAmendeScore = row.Value == "true"
|
||||
case "points_enabled":
|
||||
@@ -125,9 +130,31 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
|
||||
settings.PointsPools = pools
|
||||
}
|
||||
case "points_reward":
|
||||
var reward models.PointsReward
|
||||
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
||||
settings.PointsReward = &reward
|
||||
// row.Value peut valoir la chaîne littérale "null" (récompense
|
||||
// désactivée puis sauvegardée : json.Marshal(nil *PointsReward)
|
||||
// produit "null"). json.Unmarshal d'un null JSON dans une valeur
|
||||
// non-pointeur est un no-op sans erreur (voir doc encoding/json),
|
||||
// donc sans ce garde-fou &reward pointerait vers une struct vide
|
||||
// mais non-nil, et la récompense réapparaîtrait activée.
|
||||
if row.Value != "null" && row.Value != "" {
|
||||
var reward models.PointsReward
|
||||
if err := json.Unmarshal([]byte(row.Value), &reward); err == nil {
|
||||
settings.PointsReward = &reward
|
||||
}
|
||||
}
|
||||
case "promotions_enabled":
|
||||
settings.PromotionsEnabled = row.Value == "true"
|
||||
case "promotions":
|
||||
var promotions []models.CategoryPromotionConfig
|
||||
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"
|
||||
@@ -213,6 +240,14 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return "false"
|
||||
}
|
||||
|
||||
if s.PenaltyTiers == nil {
|
||||
s.PenaltyTiers = []models.PenaltyTier{}
|
||||
}
|
||||
tiersJSON, err := json.Marshal(s.PenaltyTiers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation penalty_tiers: %w", err)
|
||||
}
|
||||
|
||||
if s.PointsPools == nil {
|
||||
s.PointsPools = []models.PointsPool{}
|
||||
}
|
||||
@@ -230,11 +265,52 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
return fmt.Errorf("erreur sérialisation pools: %w", err)
|
||||
}
|
||||
|
||||
if s.PointsReward != nil {
|
||||
for i := range s.PointsReward.CategoryConfigs {
|
||||
if s.PointsReward.CategoryConfigs[i].Products == nil {
|
||||
s.PointsReward.CategoryConfigs[i].Products = []models.RewardProductQuantity{}
|
||||
}
|
||||
}
|
||||
}
|
||||
rewardJSON, err := json.Marshal(s.PointsReward)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation points_reward: %w", err)
|
||||
}
|
||||
|
||||
if s.Promotions == nil {
|
||||
s.Promotions = []models.CategoryPromotionConfig{}
|
||||
}
|
||||
for i := range s.Promotions {
|
||||
if s.Promotions[i].Products == nil {
|
||||
s.Promotions[i].Products = []models.PromotionProductQuantity{}
|
||||
}
|
||||
}
|
||||
promotionsJSON, err := json.Marshal(s.Promotions)
|
||||
if err != nil {
|
||||
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{}
|
||||
}
|
||||
@@ -269,10 +345,15 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
}
|
||||
pairs := [][2]string{
|
||||
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
|
||||
{"penalty_tiers", string(tiersJSON)},
|
||||
{"show_amende_score", boolStr(s.ShowAmendeScore)},
|
||||
{"points_enabled", boolStr(s.PointsEnabled)},
|
||||
{"points_pools", string(poolsJSON)},
|
||||
{"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)},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -73,10 +73,11 @@ func GetMyDeliveries(c *gin.Context) {
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for j, item := range items {
|
||||
itemsSummary[j] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"is_reward": item["is_reward"],
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"promo_discount": item["promo_discount"],
|
||||
"is_reward": item["is_reward"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +156,11 @@ func GetDeliveryDetails(c *gin.Context) {
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for i, item := range items {
|
||||
itemsSummary[i] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"is_reward": item["is_reward"],
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"promo_discount": item["promo_discount"],
|
||||
"is_reward": item["is_reward"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+135
-131
@@ -23,72 +23,79 @@ func normalizeRewardCategoryType(t string) string {
|
||||
return "free_product"
|
||||
}
|
||||
|
||||
// eligibleRewardProducts détermine, pour un pool donné, quels product_id de
|
||||
// reward.RewardItems sont éligibles et avec quel type de récompense
|
||||
// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs)
|
||||
// doit faire partie des catégories du pool, soit par whitelist explicite
|
||||
// (ProductIDs) soit par correspondance de catégorie produit (AllProducts).
|
||||
func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string {
|
||||
eligible := make(map[int]string)
|
||||
// categoryRewardCandidate représente un produit éligible à la récompense pour
|
||||
// une config de catégorie donnée : son type ("free_product" |
|
||||
// "half_price_product") et la quantité configurée pour cette catégorie.
|
||||
type categoryRewardCandidate struct {
|
||||
Category string
|
||||
Type string
|
||||
ProductID int
|
||||
Name string
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de
|
||||
// la récompense, la liste des produits éligibles — tous ceux du catalogue si
|
||||
// AllProducts, sinon la sélection explicite — avec le type et la quantité
|
||||
// configurés directement dans le bloc catégorie (RewardCategoryConfig).
|
||||
// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est
|
||||
// l'unique source de vérité (type + produits + quantité).
|
||||
func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) {
|
||||
candidates := make([]categoryRewardCandidate, 0)
|
||||
if reward == nil {
|
||||
return eligible
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
catalogCache := make(map[string][]models.Product)
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !poolCategories[cfg.Category] {
|
||||
continue
|
||||
}
|
||||
rewardType := normalizeRewardCategoryType(cfg.Type)
|
||||
if cfg.AllProducts {
|
||||
for pid, cat := range productCategories {
|
||||
if cat == cfg.Category {
|
||||
eligible[pid] = rewardType
|
||||
products, ok := catalogCache[cfg.Category]
|
||||
if !ok {
|
||||
var err error
|
||||
products, err = database.GetProductsByCategory(cfg.Category)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err)
|
||||
}
|
||||
catalogCache[cfg.Category] = products
|
||||
}
|
||||
} else {
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
eligible[pid] = rewardType
|
||||
for _, p := range products {
|
||||
candidates = append(candidates, categoryRewardCandidate{
|
||||
Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity,
|
||||
})
|
||||
}
|
||||
} else if len(cfg.Products) > 0 {
|
||||
ids := make([]int, len(cfg.Products))
|
||||
for i, pq := range cfg.Products {
|
||||
ids[i] = pq.ProductID
|
||||
}
|
||||
names, err := database.GetProductNamesByIDs(ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err)
|
||||
}
|
||||
for _, pq := range cfg.Products {
|
||||
candidates = append(candidates, categoryRewardCandidate{
|
||||
Category: cfg.Category, Type: rewardType, ProductID: pq.ProductID, Name: names[pq.ProductID], Quantity: pq.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return eligible
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// categoryConfigTypeForProduct détermine le type de récompense applicable à un
|
||||
// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé
|
||||
// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis.
|
||||
func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string {
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
matches := false
|
||||
if cfg.AllProducts {
|
||||
matches = cfg.Category == productCategory
|
||||
} else {
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
if pid == productID {
|
||||
matches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return normalizeRewardCategoryType(cfg.Type)
|
||||
}
|
||||
}
|
||||
return "free_product"
|
||||
}
|
||||
|
||||
// effectiveRewardPrice calcule le prix réellement facturé pour un item
|
||||
// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du
|
||||
// prix catalogue actif (palier correspondant à la quantité) pour
|
||||
// effectiveRewardPrice calcule le prix réellement facturé pour une quantité
|
||||
// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
|
||||
// "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour
|
||||
// "half_price_product". Erreur si le prix catalogue est introuvable (produit
|
||||
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
|
||||
// être proposée/réclamée plutôt que de facturer un montant incorrect.
|
||||
func effectiveRewardPrice(database *db.Database, item models.RewardItem, rewardType string) (float64, error) {
|
||||
func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) {
|
||||
if rewardType != "half_price_product" {
|
||||
return 0, nil
|
||||
}
|
||||
catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity)
|
||||
catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err)
|
||||
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err)
|
||||
}
|
||||
return math.Round(catalogPrice/2*100) / 100, nil
|
||||
}
|
||||
@@ -123,12 +130,18 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
|
||||
reward := settings.PointsReward
|
||||
|
||||
type ConfigProductResponse struct {
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type EligibleConfigResponse struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
AllProducts bool `json:"all_products"`
|
||||
ProductIDs []int `json:"product_ids"`
|
||||
ProductNames []string `json:"product_names"`
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
AllProducts bool `json:"all_products"`
|
||||
Products []ConfigProductResponse `json:"products"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type RewardItemResponse struct {
|
||||
@@ -150,22 +163,10 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
|
||||
}
|
||||
|
||||
// Collecter tous les product_ids nécessaires en un seul passage
|
||||
allProductIDs := make([]int, 0)
|
||||
if reward != nil {
|
||||
for _, cfg := range reward.CategoryConfigs {
|
||||
if !cfg.AllProducts {
|
||||
allProductIDs = append(allProductIDs, cfg.ProductIDs...)
|
||||
}
|
||||
}
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID > 0 {
|
||||
allProductIDs = append(allProductIDs, item.ProductID)
|
||||
}
|
||||
}
|
||||
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
|
||||
}
|
||||
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
|
||||
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
|
||||
|
||||
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
||||
for _, pool := range settings.PointsPools {
|
||||
@@ -189,43 +190,48 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
if !poolCats[cfg.Category] {
|
||||
continue
|
||||
}
|
||||
names := make([]string, 0, len(cfg.ProductIDs))
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
if n, ok := productNames[pid]; ok {
|
||||
names = append(names, n)
|
||||
products := make([]ConfigProductResponse, 0, len(cfg.Products))
|
||||
for _, pq := range cfg.Products {
|
||||
name := ""
|
||||
for _, cand := range candidates {
|
||||
if cand.ProductID == pq.ProductID && cand.Category == cfg.Category {
|
||||
name = cand.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
products = append(products, ConfigProductResponse{
|
||||
ProductID: pq.ProductID,
|
||||
ProductName: name,
|
||||
Quantity: pq.Quantity,
|
||||
})
|
||||
}
|
||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||
Category: cfg.Category,
|
||||
Type: normalizeRewardCategoryType(cfg.Type),
|
||||
AllProducts: cfg.AllProducts,
|
||||
ProductIDs: cfg.ProductIDs,
|
||||
ProductNames: names,
|
||||
Category: cfg.Category,
|
||||
Type: normalizeRewardCategoryType(cfg.Type),
|
||||
AllProducts: cfg.AllProducts,
|
||||
Products: products,
|
||||
Quantity: cfg.Quantity,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
|
||||
eligibleRewardItems := make([]RewardItemResponse, 0)
|
||||
if reward != nil {
|
||||
for _, item := range reward.RewardItems {
|
||||
rewardType, ok := eligibleProducts[item.ProductID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, item, rewardType)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
|
||||
continue
|
||||
}
|
||||
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
|
||||
ProductID: item.ProductID,
|
||||
ProductName: productNames[item.ProductID],
|
||||
Quantity: item.Quantity,
|
||||
Price: price,
|
||||
Type: rewardType,
|
||||
})
|
||||
for _, cand := range candidates {
|
||||
if !poolCats[cand.Category] {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
|
||||
continue
|
||||
}
|
||||
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
|
||||
ProductID: cand.ProductID,
|
||||
ProductName: cand.Name,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
Type: cand.Type,
|
||||
})
|
||||
}
|
||||
|
||||
pools = append(pools, PoolInfo{
|
||||
@@ -240,28 +246,22 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Construire la liste des produits récompense avec leurs noms (aperçu
|
||||
// global, indépendant d'un pool précis — le type/prix effectif par pool
|
||||
// est celui exposé dans pools[].eligible_reward_items).
|
||||
// Aperçu global des produits récompense, indépendant d'un pool précis — le
|
||||
// type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items.
|
||||
var rewardMeta gin.H
|
||||
if reward != nil {
|
||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID <= 0 {
|
||||
rewardItems := make([]RewardItemResponse, 0, len(candidates))
|
||||
for _, cand := range candidates {
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID])
|
||||
price, err := effectiveRewardPrice(database, item, rewardType)
|
||||
if err != nil {
|
||||
price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible
|
||||
}
|
||||
name := productNames[item.ProductID]
|
||||
rewardItems = append(rewardItems, RewardItemResponse{
|
||||
ProductID: item.ProductID,
|
||||
ProductName: name,
|
||||
Quantity: item.Quantity,
|
||||
ProductID: cand.ProductID,
|
||||
ProductName: cand.Name,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
Type: rewardType,
|
||||
Type: cand.Type,
|
||||
})
|
||||
}
|
||||
rewardMeta = gin.H{
|
||||
@@ -324,46 +324,40 @@ func ClaimMyReward(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Un produit récompense n'est éligible pour ce pool que si sa catégorie
|
||||
// fait partie des catégories du pool (via CategoryConfigs) — sans ce
|
||||
// filtre, un client pourrait réclamer n'importe quel produit récompense
|
||||
// (toutes catégories confondues) avec les points d'un pool quelconque.
|
||||
// fait partie des catégories du pool — sans ce filtre, un client pourrait
|
||||
// réclamer n'importe quel produit récompense (toutes catégories
|
||||
// confondues) avec les points d'un pool quelconque.
|
||||
poolCategories := make(map[string]bool, len(selectedPool.Categories))
|
||||
for _, cat := range selectedPool.Categories {
|
||||
poolCategories[cat] = true
|
||||
}
|
||||
|
||||
rewardProductIDs := make([]int, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
if item.ProductID > 0 {
|
||||
rewardProductIDs = append(rewardProductIDs, item.ProductID)
|
||||
}
|
||||
}
|
||||
productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs)
|
||||
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lecture catégories produits", err)
|
||||
utils.ServerErr(c, "Erreur résolution produits récompense", err)
|
||||
return
|
||||
}
|
||||
|
||||
eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories)
|
||||
|
||||
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
|
||||
// avant toute écriture — si un item ne peut pas être tarifé (produit sans
|
||||
// palier de prix actif), la réclamation entière échoue proprement, avant
|
||||
// même de démarrer la transaction de consommation de points.
|
||||
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
|
||||
for _, item := range reward.RewardItems {
|
||||
rewardType, ok := eligibleProducts[item.ProductID]
|
||||
if !ok {
|
||||
eligibleItems := make([]models.RewardItem, 0, len(candidates))
|
||||
for _, cand := range candidates {
|
||||
if !poolCategories[cand.Category] {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, item, rewardType)
|
||||
price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLAIM] %s: %v", username, err)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
item.Price = price
|
||||
eligibleItems = append(eligibleItems, item)
|
||||
eligibleItems = append(eligibleItems, models.RewardItem{
|
||||
ProductID: cand.ProductID,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
})
|
||||
}
|
||||
|
||||
itemsToAdd := eligibleItems
|
||||
@@ -381,6 +375,16 @@ func ClaimMyReward(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sans produit éligible pour ce pool (ex: catégories de la récompense mal
|
||||
// alignées avec celles du pool), on refuse avant de consommer un point —
|
||||
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
|
||||
// jamais ajouté au panier (récompense perdue silencieusement).
|
||||
if len(itemsToAdd) == 0 {
|
||||
log.Printf("❌ [CLAIM] Aucun produit éligible pour %s (pool=%s)", username, req.PoolKey)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
|
||||
return
|
||||
}
|
||||
|
||||
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -412,6 +413,7 @@ func GetAllProducts(c *gin.Context) {
|
||||
if role != "admin" && role != "cabine" {
|
||||
products = filterActivePrices(products)
|
||||
}
|
||||
products = applyPromotions(products, database)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
@@ -450,6 +452,7 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
if roleCtx != "admin" && roleCtx != "cabine" {
|
||||
products = filterActivePrices(products)
|
||||
}
|
||||
products = applyPromotions(products, database)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": products,
|
||||
@@ -482,6 +485,7 @@ func GetProductByID(c *gin.Context) {
|
||||
if role != "admin" && role != "cabine" {
|
||||
filterActivepricesSingle(&product)
|
||||
}
|
||||
applyPromotionsSingle(&product, database)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
@@ -1002,3 +1006,32 @@ func filterActivepricesSingle(product *models.Product) {
|
||||
}
|
||||
product.Prices = activePrices
|
||||
}
|
||||
|
||||
// applyPromotions annote chaque palier de prix éligible avec le prix promo
|
||||
// (PromoPrice/PromoPercent) si une promotion couvre ce produit/quantité —
|
||||
// affichage seulement, le prix catalogue (Price) n'est jamais modifié ici ;
|
||||
// le prix réellement facturé est recalculé indépendamment dans AddToBasket.
|
||||
func applyPromotions(products []models.Product, database *db.Database) []models.Product {
|
||||
settings, err := database.GetSettings()
|
||||
if err != nil || !settings.PromotionsEnabled {
|
||||
return products
|
||||
}
|
||||
for i := range products {
|
||||
for j := range products[i].Prices {
|
||||
pr := &products[i].Prices[j]
|
||||
discount, ok := db.ResolvePromotionDiscount(&settings, products[i].ID, products[i].Category, pr.Quantity)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
promoPrice := math.Round(pr.Price*(1-discount/100)*100) / 100
|
||||
pr.PromoPrice = &promoPrice
|
||||
pr.PromoPercent = discount
|
||||
}
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
func applyPromotionsSingle(product *models.Product, database *db.Database) {
|
||||
products := applyPromotions([]models.Product{*product}, database)
|
||||
*product = products[0]
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ type ProductPrice struct {
|
||||
// TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et
|
||||
// seulement source du bug.
|
||||
ActivePrice bool `json:"active_price" gorm:"column:active_price"`
|
||||
|
||||
// Champs transitoires (non persistés, gorm:"-") : annotés à la volée sur
|
||||
// les endpoints de lecture client si une promotion s'applique à ce palier
|
||||
// précis (voir handlers.applyPromotions) — permet d'afficher le prix
|
||||
// barré + le prix promo sans toucher au prix catalogue réel.
|
||||
PromoPrice *float64 `json:"promo_price,omitempty" gorm:"-"`
|
||||
PromoPercent float64 `json:"promo_percent,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
func (ProductPrice) TableName() string { return "product_prices" }
|
||||
|
||||
@@ -14,30 +14,109 @@ type PointsTier struct {
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
|
||||
// ainsi que le type de récompense appliqué pour cette catégorie précise.
|
||||
type RewardCategoryConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false
|
||||
// RewardProductQuantity associe un produit à sa propre quantité offerte / à
|
||||
// -50%, pour le cas où une catégorie n'est pas configurée en "tous les
|
||||
// produits" — ex: produit A à 2g offerts, produit B à 1g offert, tous deux
|
||||
// dans la même catégorie et le même type de récompense.
|
||||
type RewardProductQuantity struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé
|
||||
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
|
||||
// le type de récompense appliqué pour cette catégorie précise, et la quantité
|
||||
// concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier
|
||||
// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur
|
||||
// libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€.
|
||||
//
|
||||
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
|
||||
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
|
||||
type RewardCategoryConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
|
||||
Products []RewardProductQuantity `json:"products"` // produits + quantité individuelle si AllProducts = false
|
||||
}
|
||||
|
||||
// RewardItem représente un produit résolu à ajouter au panier lors d'un
|
||||
// claim (ProductID + Quantity + Price effectif) — construit dynamiquement à
|
||||
// partir des CategoryConfigs au moment du claim, plus une liste saisie à part.
|
||||
type RewardItem struct {
|
||||
ProductID int `json:"product_id"` // ID du produit ajouté au panier
|
||||
Quantity float64 `json:"quantity"` // quantité offerte
|
||||
Price float64 `json:"price"` // valeur indicative affichée au client
|
||||
Price float64 `json:"price"` // prix effectif facturé (0 si offert, 50% du prix catalogue si -50%)
|
||||
}
|
||||
|
||||
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
|
||||
// Le type de récompense (gratuit ou -50%) n'est plus global : il est défini par catégorie
|
||||
// dans CategoryConfigs (voir RewardCategoryConfig.Type).
|
||||
// Le type de récompense (gratuit ou -50%) et la quantité concernée sont
|
||||
// définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) —
|
||||
// les produits éligibles et leur quantité ne sont plus saisis à part.
|
||||
type PointsReward struct {
|
||||
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
||||
Description string `json:"description"` // description libre affichée au client
|
||||
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie
|
||||
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
|
||||
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie
|
||||
}
|
||||
|
||||
// PromotionProductQuantity associe un produit à sa propre quantité en promo,
|
||||
// pour le cas où une catégorie n'est pas configurée en "tous les produits" —
|
||||
// même logique que RewardProductQuantity mais pour les promotions.
|
||||
type PromotionProductQuantity struct {
|
||||
ProductID int `json:"product_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// CategoryPromotionConfig définit une promotion (réduction en %) appliquée
|
||||
// automatiquement au prix catalogue d'un produit pour une quantité donnée —
|
||||
// contrairement à RewardCategoryConfig, ça ne dépend d'aucun seuil de points :
|
||||
// le prix réduit s'applique à tout client qui commande ce produit à cette
|
||||
// quantité, affiché directement sur le produit. La quantité correspond au
|
||||
// palier de prix catalogue existant (voir GetActiveProductPrice), pas une
|
||||
// valeur libre.
|
||||
//
|
||||
// Si AllProducts = true, Quantity s'applique uniformément à tous les produits
|
||||
// de la catégorie. Si AllProducts = false, chaque produit sélectionné dans
|
||||
// Products a sa propre quantité (Quantity au niveau catégorie est alors ignoré).
|
||||
type CategoryPromotionConfig struct {
|
||||
Category string `json:"category"` // nom de la catégorie
|
||||
DiscountPercent float64 `json:"discount_percent"` // pourcentage de réduction libre (ex: 10, 20, 33.5)
|
||||
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
|
||||
Quantity float64 `json:"quantity"` // quantité uniforme si AllProducts = true
|
||||
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
|
||||
@@ -87,28 +166,32 @@ type DeliveryModeConfig struct {
|
||||
|
||||
// AppSettings contient les paramètres globaux de l'application
|
||||
type AppSettings struct {
|
||||
PenaltiesEnabled bool `json:"penalties_enabled"`
|
||||
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
|
||||
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||
PointsReward *PointsReward `json:"points_reward"` // récompense globale par palier de points
|
||||
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
|
||||
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
|
||||
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
|
||||
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
|
||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
||||
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
|
||||
PenaltiesEnabled bool `json:"penalties_enabled"`
|
||||
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
|
||||
PenaltyTiers []PenaltyTier `json:"penalty_tiers"` // barème des amendes (liste configurable)
|
||||
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
|
||||
PointsPools []PointsPool `json:"points_pools"` // types de points personnalisés
|
||||
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
|
||||
CryptoOnly bool `json:"crypto_only"` // forcer le paiement crypto uniquement (pas d'espèces)
|
||||
NowPaymentsAPIKey string `json:"nowpayments_api_key"` // clé API NowPayments
|
||||
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret"` // secret IPN NowPayments
|
||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
||||
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
|
||||
// Palette de couleurs — espace admin
|
||||
AdminColorPrimary string `json:"admin_color_primary"`
|
||||
AdminColorSecondary string `json:"admin_color_secondary"`
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func myDeliveriesContext(username, status string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
url := "/api/v1/livreur/deliveries"
|
||||
if status != "" {
|
||||
url += "?status=" + status
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
c.Set("role", "livreur")
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// Le livreur doit voir qu'un article a bénéficié d'une promotion de prix
|
||||
// (promo_discount > 0), pour pouvoir justifier au client un montant total
|
||||
// inférieur au prix catalogue — voir GetDeliveryDetails/GetMyDeliveries
|
||||
// (backend/gestion/handlers/deleviry.go) et GetCommandItems/GetCommandItemsBatch
|
||||
// (backend/gestion/db/db_command_items.go).
|
||||
func TestGetDeliveryDetails_ExposesPromoDiscountPerItem(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
client := newTestClient(t, "delivpromo_client")
|
||||
livreur := newTestClient(t, "delivpromo_livreur")
|
||||
productID := newTestProduct(t, "DelivPromoDiscount", 20)
|
||||
|
||||
// 3g normalement à 50€, facturés 25€ (-50%) : promo_discount = 25€.
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 3, 25)
|
||||
if err := testDB.GDB.Exec(
|
||||
`UPDATE command_items SET promo_discount = 25 WHERE command_id = ? AND product_id = ?`,
|
||||
cmdID, productID,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("mise à jour promo_discount: %v", err)
|
||||
}
|
||||
|
||||
c, rec := deliveryDetailsContext(livreur, cmdID)
|
||||
handlers.GetDeliveryDetails(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Delivery struct {
|
||||
Items []struct {
|
||||
Produit string `json:"produit"`
|
||||
Prix float64 `json:"prix"`
|
||||
PromoDiscount float64 `json:"promo_discount"`
|
||||
} `json:"items"`
|
||||
} `json:"delivery"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !resp.Success || len(resp.Delivery.Items) != 1 {
|
||||
t.Fatalf("réponse inattendue: body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
item := resp.Delivery.Items[0]
|
||||
if item.PromoDiscount != 25 {
|
||||
t.Errorf("promo_discount doit être exposé au livreur: got=%.2f want=25.00 (body=%s)", item.PromoDiscount, rec.Body.String())
|
||||
}
|
||||
if item.Prix != 25 {
|
||||
t.Errorf("le prix affiché doit rester le prix déjà réduit facturé: got=%.2f want=25.00", item.Prix)
|
||||
}
|
||||
}
|
||||
|
||||
// Même vérification côté GetMyDeliveries (liste des livraisons), qui passe
|
||||
// par un chemin de requête différent (GetCommandItemsBatch) que
|
||||
// GetDeliveryDetails (GetCommandItems).
|
||||
func TestGetMyDeliveries_ExposesPromoDiscountPerItem(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
client := newTestClient(t, "delivpromo_list_client")
|
||||
livreur := newTestClient(t, "delivpromo_list_livreur")
|
||||
productID := newTestProduct(t, "DelivPromoListDiscount", 20)
|
||||
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 3, 25)
|
||||
if err := testDB.GDB.Exec(
|
||||
`UPDATE command_items SET promo_discount = 25 WHERE command_id = ? AND product_id = ?`,
|
||||
cmdID, productID,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("mise à jour promo_discount: %v", err)
|
||||
}
|
||||
|
||||
c, rec := myDeliveriesContext(livreur, "")
|
||||
handlers.GetMyDeliveries(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Deliveries []struct {
|
||||
ID int `json:"id"`
|
||||
Items []struct {
|
||||
PromoDiscount float64 `json:"promo_discount"`
|
||||
} `json:"items"`
|
||||
} `json:"deliveries"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("réponse non successful: body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, d := range resp.Deliveries {
|
||||
if d.ID != cmdID {
|
||||
continue
|
||||
}
|
||||
if len(d.Items) != 1 || d.Items[0].PromoDiscount != 25 {
|
||||
t.Fatalf("promo_discount doit être exposé dans GetMyDeliveries: %+v", d.Items)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("commande %d introuvable dans la réponse: body=%s", cmdID, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"gestion/handlers"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// configurePromotionSettings applique les settings donnés (avec Promotions)
|
||||
// via testDB.UpdateSettings, comme le ferait l'admin — testDB.UpdateSettings
|
||||
// normalise déjà les slices nil, donc ce helper reste minimal.
|
||||
func configurePromotionSettings(t *testing.T, settings models.AppSettings) {
|
||||
t.Helper()
|
||||
if err := testDB.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persistance des settings (save→reload) ──────────────────────────────────
|
||||
|
||||
func TestUpdateSettings_PromotionsRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{
|
||||
Category: "test",
|
||||
DiscountPercent: 15.5,
|
||||
AllProducts: false,
|
||||
Products: []models.PromotionProductQuantity{
|
||||
{ProductID: 111, Quantity: 2},
|
||||
{ProductID: 222, Quantity: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
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.PromotionsEnabled {
|
||||
t.Fatal("promotions_enabled devrait être true après reload")
|
||||
}
|
||||
if len(loaded.Promotions) != 1 {
|
||||
t.Fatalf("promotions: got=%d want=1: %+v", len(loaded.Promotions), loaded.Promotions)
|
||||
}
|
||||
promo := loaded.Promotions[0]
|
||||
if promo.Category != "test" || promo.DiscountPercent != 15.5 {
|
||||
t.Errorf("promo mal persistée: got=%+v", promo)
|
||||
}
|
||||
if len(promo.Products) != 2 || promo.Products[0].ProductID != 111 || promo.Products[0].Quantity != 2 {
|
||||
t.Errorf("products mal persistés: got=%+v", promo.Products)
|
||||
}
|
||||
|
||||
// Désactivation : doit persister à false, pas de résurrection (même
|
||||
// classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil).
|
||||
s.PromotionsEnabled = 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.PromotionsEnabled {
|
||||
t.Error("promotions_enabled devrait rester false après désactivation")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Résolution de la réduction (logique pure) ───────────────────────────────
|
||||
|
||||
func TestResolvePromotionDiscount_MatchesAllProductsAtConfiguredQuantity(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
PromotionsEnabled: true,
|
||||
Promotions: []models.CategoryPromotionConfig{
|
||||
{Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5},
|
||||
},
|
||||
}
|
||||
discount, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5)
|
||||
if !ok || discount != 20 {
|
||||
t.Errorf("got discount=%.2f ok=%v want=20/true", discount, ok)
|
||||
}
|
||||
// Mauvaise quantité : pas de promo.
|
||||
if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 3); ok {
|
||||
t.Error("ne devrait pas matcher une quantité différente de celle configurée")
|
||||
}
|
||||
// Mauvaise catégorie : pas de promo.
|
||||
if _, ok := db.ResolvePromotionDiscount(settings, 42, "autre", 5); ok {
|
||||
t.Error("ne devrait pas matcher une catégorie différente")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePromotionDiscount_DisabledReturnsNoDiscount(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
PromotionsEnabled: false,
|
||||
Promotions: []models.CategoryPromotionConfig{
|
||||
{Category: "fleurs", DiscountPercent: 20, AllProducts: true, Quantity: 5},
|
||||
},
|
||||
}
|
||||
if _, ok := db.ResolvePromotionDiscount(settings, 42, "fleurs", 5); ok {
|
||||
t.Error("aucune promo ne doit s'appliquer si promotions_enabled = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePromotionDiscount_PerProductSelection(t *testing.T) {
|
||||
settings := &models.AppSettings{
|
||||
PromotionsEnabled: true,
|
||||
Promotions: []models.CategoryPromotionConfig{
|
||||
{
|
||||
Category: "fleurs",
|
||||
AllProducts: false,
|
||||
Products: []models.PromotionProductQuantity{
|
||||
{ProductID: 1, Quantity: 2},
|
||||
},
|
||||
DiscountPercent: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
if discount, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 2); !ok || discount != 10 {
|
||||
t.Errorf("produit sélectionné à la bonne quantité: got discount=%.2f ok=%v", discount, ok)
|
||||
}
|
||||
if _, ok := db.ResolvePromotionDiscount(settings, 1, "fleurs", 3); ok {
|
||||
t.Error("mauvaise quantité pour ce produit : ne doit pas matcher")
|
||||
}
|
||||
if _, ok := db.ResolvePromotionDiscount(settings, 2, "fleurs", 2); ok {
|
||||
t.Error("produit non sélectionné : ne doit pas matcher")
|
||||
}
|
||||
}
|
||||
|
||||
// ── AddToBasket applique réellement la réduction au prix facturé ───────────
|
||||
|
||||
func TestAddToBasket_AppliesPromotionDiscount(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "promo_basket_applies")
|
||||
productID := newTestProduct(t, "PromoBasketApplies", 10)
|
||||
// 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", DiscountPercent: 20, AllProducts: true, Quantity: 1},
|
||||
}
|
||||
configurePromotionSettings(t, s)
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.Price != 8.0 {
|
||||
t.Errorf("prix attendu = 10€ - 20%% = 8.00€: got=%.2f", basket.Price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToBasket_NoDiscountWhenPromotionsDisabled(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "promo_basket_disabled")
|
||||
productID := newTestProduct(t, "PromoBasketDisabled", 10)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = false
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", DiscountPercent: 20, AllProducts: true, Quantity: 1},
|
||||
}
|
||||
configurePromotionSettings(t, s)
|
||||
|
||||
basket, err := testDB.AddToBasket(username, productID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if basket.Price != 10.0 {
|
||||
t.Errorf("promotions désactivées: le prix catalogue plein doit s'appliquer: got=%.2f want=10.00", basket.Price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToBasket_NoDiscountForDifferentProductSelection(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
username := newTestClient(t, "promo_basket_other_product")
|
||||
promotedID := newTestProduct(t, "PromoBasketOtherPromoted", 10)
|
||||
otherID := newTestProduct(t, "PromoBasketOtherPlain", 10)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{
|
||||
Category: "test",
|
||||
AllProducts: false,
|
||||
Products: []models.PromotionProductQuantity{{ProductID: promotedID, Quantity: 1}},
|
||||
DiscountPercent: 50,
|
||||
},
|
||||
}
|
||||
configurePromotionSettings(t, s)
|
||||
|
||||
promotedBasket, err := testDB.AddToBasket(username, promotedID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket (promu): %v", err)
|
||||
}
|
||||
if promotedBasket.Price != 5.0 {
|
||||
t.Errorf("produit promu: prix attendu = 10€ - 50%% = 5.00€: got=%.2f", promotedBasket.Price)
|
||||
}
|
||||
|
||||
otherBasket, err := testDB.AddToBasket(username, otherID, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("AddToBasket (autre): %v", err)
|
||||
}
|
||||
if otherBasket.Price != 10.0 {
|
||||
t.Errorf("produit non sélectionné dans la promo: prix plein attendu=10.00: got=%.2f", otherBasket.Price)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Affichage catalogue : le prix promo est annoté sur le palier concerné ──
|
||||
|
||||
func TestGetProductByID_AnnotatesPromoPriceOnMatchingTier(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
resetSettingsAfterTest(t)
|
||||
productID := newTestProduct(t, "PromoDisplayAnnotated", 10)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PromotionsEnabled = true
|
||||
s.Promotions = []models.CategoryPromotionConfig{
|
||||
{Category: "test", DiscountPercent: 25, AllProducts: true, Quantity: 1},
|
||||
}
|
||||
configurePromotionSettings(t, s)
|
||||
|
||||
idStr := strconv.Itoa(productID)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/products/"+idStr, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Params = gin.Params{{Key: "id", Value: idStr}}
|
||||
c.Set("role", "client")
|
||||
handlers.GetProductByID(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data models.Product `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if len(resp.Data.Prices) != 1 {
|
||||
t.Fatalf("attendu 1 palier de prix: got=%+v", resp.Data.Prices)
|
||||
}
|
||||
tier := resp.Data.Prices[0]
|
||||
if tier.PromoPrice == nil {
|
||||
t.Fatal("PromoPrice devrait être renseigné pour ce palier couvert par la promo")
|
||||
}
|
||||
if *tier.PromoPrice != 7.5 {
|
||||
t.Errorf("promo_price attendu = 10€ - 25%% = 7.50€: got=%.2f", *tier.PromoPrice)
|
||||
}
|
||||
if tier.PromoPercent != 25 {
|
||||
t.Errorf("promo_percent attendu=25: got=%.2f", tier.PromoPercent)
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,9 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R
|
||||
}
|
||||
|
||||
// configureRewardSettings applique la récompense donnée, avec pool_0 mappé
|
||||
// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts
|
||||
// sur la catégorie "test" — nécessaire pour que resolveCategoryRewardCandidates
|
||||
// (qui croise pool.Categories et reward.CategoryConfigs) considère les
|
||||
// reward_items comme éligibles.
|
||||
// produits de la catégorie comme éligibles.
|
||||
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
|
||||
t.Helper()
|
||||
settings := db.DefaultSettings()
|
||||
@@ -39,17 +39,20 @@ func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
|
||||
}
|
||||
|
||||
// Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter
|
||||
// le produit récompense configuré au panier et décompter la récompense.
|
||||
// le produit récompense configuré au panier et décompter la récompense. Le
|
||||
// produit éligible et sa quantité sont désormais définis directement dans le
|
||||
// bloc catégorie (RewardCategoryConfig), plus de liste "reward_items" à part.
|
||||
func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_flow")
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Description: "Un produit offert",
|
||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
Threshold: 20,
|
||||
Description: "Un produit offert",
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "test", Type: "free_product", AllProducts: true, Quantity: 1},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
@@ -85,9 +88,9 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
|
||||
}
|
||||
}
|
||||
|
||||
// Catégorie configurée en "half_price_product" : le produit récompense doit
|
||||
// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix
|
||||
// indicatif RewardItem.Price saisi par l'admin).
|
||||
// Catégorie configurée en "half_price_product" avec quantité=1 : le produit
|
||||
// récompense doit être ajouté au panier à 50% du prix catalogue actif pour
|
||||
// cette quantité (palier ≤ 1), pas 0€.
|
||||
func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_halfprice")
|
||||
@@ -95,10 +98,11 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri
|
||||
// newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Description: "Un produit à moitié prix",
|
||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "half_price_product", AllProducts: true}},
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré
|
||||
Threshold: 20,
|
||||
Description: "Un produit à moitié prix",
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 1},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
@@ -119,15 +123,60 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri
|
||||
}
|
||||
}
|
||||
|
||||
// La quantité configurée dans le bloc catégorie détermine le palier de prix
|
||||
// utilisé pour le calcul du -50% (ex: 30€ le palier quantity=1 → 15€ facturé),
|
||||
// pas un prix indicatif saisi ailleurs.
|
||||
func TestClaimMyReward_HTTPFlow_HalfPriceUsesConfiguredQuantityForPriceTier(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_halfprice_qty")
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPHalfPriceQty", 20)
|
||||
// Ajoute un palier quantity=3 à 30€ (en plus du palier quantity=1 à 10€ créé par newTestProduct).
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 3, 30.00, true)`,
|
||||
rewardProductID,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création palier de prix supplémentaire: %v", err)
|
||||
}
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Description: "Un produit à moitié prix, quantité 3",
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 3},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
|
||||
t.Fatalf("le produit récompense doit être dans le panier: %+v", rows)
|
||||
}
|
||||
if rows[0].Quantity != 3 {
|
||||
t.Errorf("la quantité en panier doit être celle configurée pour la catégorie: got=%.2f want=3", rows[0].Quantity)
|
||||
}
|
||||
if rows[0].Price != 15.0 {
|
||||
t.Errorf("palier quantity=3 à 30€ : prix attendu = 50%% = 15.00€: got=%.2f", rows[0].Price)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_below")
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
|
||||
newTestProduct(t, "RewardHTTPBelow", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
Threshold: 20,
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "test", Type: "free_product", AllProducts: true, Quantity: 1},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 5)
|
||||
|
||||
@@ -140,22 +189,21 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Si un item récompense configuré par l'admin pointe vers un produit
|
||||
// supprimé/inexistant, la réclamation entière doit échouer — la récompense
|
||||
// ne doit pas être consommée sans qu'aucun produit ne soit livré au client
|
||||
// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même
|
||||
// transaction via ClaimPoolRewardAndAddToBasket).
|
||||
// Si un produit configuré par l'admin (via Products explicite) pointe vers
|
||||
// un produit supprimé/inexistant, la réclamation entière doit échouer — la
|
||||
// récompense ne doit pas être consommée sans qu'aucun produit ne soit livré
|
||||
// au client (ClaimPoolReward + AddRewardsToBasket sont dans la même
|
||||
// transaction via ClaimPoolRewardAndAddToBasket ; la contrainte de clé
|
||||
// étrangère sur baskets.product_id fait échouer l'insertion).
|
||||
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_missing_product")
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
// ProductIDs explicite (pas AllProducts) : le produit n'existe pas en
|
||||
// base, donc il n'apparaîtrait jamais dans productCategories et ne
|
||||
// serait jamais éligible via une correspondance AllProducts.
|
||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", ProductIDs: []int{999999999}}},
|
||||
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: 999999999, Quantity: 1}}},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
@@ -175,3 +223,159 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
|
||||
t.Errorf("la récompense ne doit PAS être consommée si le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
|
||||
// Si les catégories configurées sur la récompense ne correspondent à aucune
|
||||
// catégorie du pool réclamé (erreur de configuration admin : pool assigné à
|
||||
// "test", récompense configurée sur "other"), la liste de produits éligibles
|
||||
// est vide et la réclamation doit échouer avant de consommer un point —
|
||||
// sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit
|
||||
// jamais ajouté au panier (régression couverte : la récompense était
|
||||
// auparavant "consommée" silencieusement sans rien livrer).
|
||||
func TestClaimMyReward_HTTPFlow_RejectsWhenNoEligibleItemsForPoolCategories(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_no_eligible")
|
||||
newTestProduct(t, "RewardHTTPNoEligible", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "other", Type: "free_product", AllProducts: true, Quantity: 1},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 25)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
||||
}
|
||||
|
||||
points, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 0 {
|
||||
t.Errorf("la récompense ne doit PAS être consommée si aucun produit n'est éligible: got redeemed=%d want=0", redeemed["pool_0"])
|
||||
}
|
||||
if points["pool_0"] != 25 {
|
||||
t.Errorf("les points accumulés ne doivent pas être touchés: got=%d want=25", points["pool_0"])
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("aucun produit récompense ne doit être ajouté au panier: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// Une même catégorie peut avoir les deux types de récompense actifs en
|
||||
// parallèle (un lot de produits offerts + un lot de produits à -50%), chacun
|
||||
// avec sa propre sélection de produits et sa propre quantité. Un seul claim
|
||||
// doit alors ajouter les deux produits au panier, chacun tarifé selon son
|
||||
// propre type et sa propre quantité.
|
||||
func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_dual_type")
|
||||
freeProductID := newTestProduct(t, "RewardHTTPDualFree", 5)
|
||||
halfProductID := newTestProduct(t, "RewardHTTPDualHalf", 5)
|
||||
// newTestProduct crée les deux produits dans la catégorie "test", avec un
|
||||
// prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Description: "Un produit offert + un produit à -50%",
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: freeProductID, Quantity: 1}}},
|
||||
{Category: "test", Type: "half_price_product", Products: []models.RewardProductQuantity{{ProductID: halfProductID, Quantity: 1}}},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows)
|
||||
}
|
||||
|
||||
var freeRow, halfRow *rewardBasketRow
|
||||
for i := range rows {
|
||||
switch rows[i].ProductID {
|
||||
case freeProductID:
|
||||
freeRow = &rows[i]
|
||||
case halfProductID:
|
||||
halfRow = &rows[i]
|
||||
}
|
||||
}
|
||||
if freeRow == nil || halfRow == nil {
|
||||
t.Fatalf("les deux produits attendus doivent être présents: %+v", rows)
|
||||
}
|
||||
if freeRow.Price != 0 {
|
||||
t.Errorf("produit de la config free_product: le prix en panier doit être 0: got=%.2f", freeRow.Price)
|
||||
}
|
||||
if halfRow.Price != 5.0 {
|
||||
t.Errorf("produit de la config half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", halfRow.Price)
|
||||
}
|
||||
}
|
||||
|
||||
// Quand une catégorie n'est pas configurée en "tous les produits", chaque
|
||||
// produit sélectionné a sa propre quantité (ex: produit A à 2g offerts,
|
||||
// produit B à 1g offert, tous deux dans la même catégorie et le même type).
|
||||
func TestClaimMyReward_HTTPFlow_PerProductQuantityWithinSameCategoryAndType(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_per_product_qty")
|
||||
productA := newTestProduct(t, "RewardHTTPPerProductA", 5)
|
||||
productB := newTestProduct(t, "RewardHTTPPerProductB", 5)
|
||||
// newTestProduct crée les deux produits dans la catégorie "test".
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Description: "Produit A 2g offert, produit B 1g offert",
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{
|
||||
{ProductID: productA, Quantity: 2},
|
||||
{ProductID: productB, Quantity: 1},
|
||||
}},
|
||||
},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows)
|
||||
}
|
||||
|
||||
var rowA, rowB *rewardBasketRow
|
||||
for i := range rows {
|
||||
switch rows[i].ProductID {
|
||||
case productA:
|
||||
rowA = &rows[i]
|
||||
case productB:
|
||||
rowB = &rows[i]
|
||||
}
|
||||
}
|
||||
if rowA == nil || rowB == nil {
|
||||
t.Fatalf("les deux produits attendus doivent être présents: %+v", rows)
|
||||
}
|
||||
if rowA.Quantity != 2 {
|
||||
t.Errorf("produit A: quantité attendue=2, got=%.2f", rowA.Quantity)
|
||||
}
|
||||
if rowB.Quantity != 1 {
|
||||
t.Errorf("produit B: quantité attendue=1, got=%.2f", rowB.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -40,6 +41,41 @@ func basketRewardItems(t *testing.T, username string) []rewardBasketRow {
|
||||
return rows
|
||||
}
|
||||
|
||||
// Désactiver la récompense (PointsReward = nil) puis sauvegarder ne doit pas
|
||||
// la faire réapparaître activée au rechargement — régression : json.Marshal
|
||||
// d'un pointeur nil produit la chaîne "null", et json.Unmarshal d'un null
|
||||
// JSON dans une valeur non-pointeur est un no-op sans erreur, ce qui laissait
|
||||
// settings.PointsReward pointer vers une struct vide mais non-nil.
|
||||
func TestUpdateSettings_DisablingPointsRewardPersistsAsNil(t *testing.T) {
|
||||
settings := db.DefaultSettings()
|
||||
settings.PointsReward = &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Description: "Un produit offert",
|
||||
}
|
||||
if err := testDB.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings (activation): %v", err)
|
||||
}
|
||||
loaded, err := testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings (activation): %v", err)
|
||||
}
|
||||
if loaded.PointsReward == nil {
|
||||
t.Fatal("la récompense devrait être active après la première sauvegarde")
|
||||
}
|
||||
|
||||
settings.PointsReward = nil
|
||||
if err := testDB.UpdateSettings(settings); 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.PointsReward != nil {
|
||||
t.Errorf("la récompense désactivée ne doit pas réapparaître après sauvegarde: got=%+v", loaded.PointsReward)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ClaimPoolReward : seuil, atomicité, épuisement ──────────────────────────
|
||||
|
||||
func TestClaimPoolReward_BelowThresholdFails(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"gestion/handlers"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// resetSettingsAfterTest restaure les settings par défaut à la fin du test —
|
||||
// AppSettings est un état global partagé (une seule ligne par clé dans
|
||||
// app_settings), donc un test qui le modifie ne doit pas laisser de résidu
|
||||
// pour les tests suivants (ex: DeliveryMode utilisé par d'autres suites).
|
||||
func resetSettingsAfterTest(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
if err := testDB.UpdateSettings(db.DefaultSettings()); err != nil {
|
||||
t.Logf("⚠️ resetSettingsAfterTest: restauration des settings par défaut échouée: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Bascules booléennes (activer/désactiver une option) ─────────────────────
|
||||
//
|
||||
// Régression visée : chaque option doit persister à sa valeur exacte après un
|
||||
// cycle save→reload, dans les deux sens (activation ET désactivation) — voir
|
||||
// TestUpdateSettings_DisablingPointsRewardPersistsAsNil pour un cas où la
|
||||
// désactivation ne persistait pas correctement.
|
||||
func TestUpdateSettings_DisablingBooleanTogglesPersists(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
set := func(v bool) models.AppSettings {
|
||||
s := db.DefaultSettings()
|
||||
s.PenaltiesEnabled = v
|
||||
s.ShowAmendeScore = v
|
||||
s.PointsEnabled = v
|
||||
s.ReferralEnabled = v
|
||||
s.CryptoPaymentEnabled = v
|
||||
s.CryptoOnly = v
|
||||
s.TelegramNotificationsEnabled = v
|
||||
s.Telegram2FAEnabled = v
|
||||
return s
|
||||
}
|
||||
|
||||
assertAll := func(t *testing.T, want bool) {
|
||||
t.Helper()
|
||||
loaded, err := testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings: %v", err)
|
||||
}
|
||||
checks := map[string]bool{
|
||||
"penalties_enabled": loaded.PenaltiesEnabled,
|
||||
"show_amende_score": loaded.ShowAmendeScore,
|
||||
"points_enabled": loaded.PointsEnabled,
|
||||
"referral_enabled": loaded.ReferralEnabled,
|
||||
"crypto_payment_enabled": loaded.CryptoPaymentEnabled,
|
||||
"crypto_only": loaded.CryptoOnly,
|
||||
"telegram_notifications_enabled": loaded.TelegramNotificationsEnabled,
|
||||
"telegram_2fa_enabled": loaded.Telegram2FAEnabled,
|
||||
}
|
||||
for key, got := range checks {
|
||||
if got != want {
|
||||
t.Errorf("%s: got=%v want=%v", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := testDB.UpdateSettings(set(true)); err != nil {
|
||||
t.Fatalf("UpdateSettings (activation): %v", err)
|
||||
}
|
||||
assertAll(t, true)
|
||||
|
||||
if err := testDB.UpdateSettings(set(false)); err != nil {
|
||||
t.Fatalf("UpdateSettings (désactivation): %v", err)
|
||||
}
|
||||
assertAll(t, false)
|
||||
}
|
||||
|
||||
// ── Options non-booléennes (hors NowPayments) ───────────────────────────────
|
||||
|
||||
// Le barème des amendes (penalty_tiers) est éditable dans l'admin
|
||||
// ("Barème des amendes") mais aucune clé "penalty_tiers" n'existe dans les
|
||||
// pairs persistées par UpdateSettings ni dans le switch de GetSettings — la
|
||||
// configuration saisie par l'admin est donc silencieusement perdue au
|
||||
// prochain rechargement, et retombe toujours sur le barème par défaut.
|
||||
func TestUpdateSettings_PenaltyTiersRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PenaltyTiers = []models.PenaltyTier{
|
||||
{MinCancel: 0, Amount: 10},
|
||||
{MinCancel: 5, Amount: 999},
|
||||
}
|
||||
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 len(loaded.PenaltyTiers) != 2 || loaded.PenaltyTiers[1].Amount != 999 {
|
||||
t.Errorf("le barème des amendes personnalisé n'a pas été persisté: got=%+v", loaded.PenaltyTiers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings_ReferralAmountRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.ReferralAmount = 12.5
|
||||
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.ReferralAmount != 12.5 {
|
||||
t.Errorf("referral_amount: got=%.2f want=12.50", loaded.ReferralAmount)
|
||||
}
|
||||
|
||||
s.ReferralAmount = 0
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings (remise à zéro): %v", err)
|
||||
}
|
||||
loaded, err = testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings (remise à zéro): %v", err)
|
||||
}
|
||||
if loaded.ReferralAmount != 0 {
|
||||
t.Errorf("referral_amount remis à 0: got=%.2f want=0.00", loaded.ReferralAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings_PointsPoolsRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PointsPools = []models.PointsPool{
|
||||
{
|
||||
Key: "pool_custom",
|
||||
Name: "Pool Custom",
|
||||
Categories: []string{"catA", "catB"},
|
||||
Tiers: []models.PointsTier{{Min: 10, Max: 20, Points: 7}},
|
||||
},
|
||||
}
|
||||
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 len(loaded.PointsPools) != 1 || loaded.PointsPools[0].Key != "pool_custom" ||
|
||||
len(loaded.PointsPools[0].Categories) != 2 || loaded.PointsPools[0].Tiers[0].Points != 7 {
|
||||
t.Errorf("points_pools personnalisé mal persisté: got=%+v", loaded.PointsPools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings_DeliveryScheduleRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.DeliverySchedule.Monday = models.DaySchedule{Enabled: false, OpenTime: "10:00", CloseTime: "18:00"}
|
||||
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.DeliverySchedule.Monday.Enabled != false ||
|
||||
loaded.DeliverySchedule.Monday.OpenTime != "10:00" ||
|
||||
loaded.DeliverySchedule.Monday.CloseTime != "18:00" {
|
||||
t.Errorf("delivery_schedule.monday mal persisté: got=%+v", loaded.DeliverySchedule.Monday)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings_PostalZonesRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PostalZones = []models.PostalZone{
|
||||
{Name: "Zone Test", MinAmount: 42, Codes: []string{"11111", "22222"}},
|
||||
}
|
||||
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 len(loaded.PostalZones) != 1 || loaded.PostalZones[0].MinAmount != 42 ||
|
||||
len(loaded.PostalZones[0].Codes) != 2 {
|
||||
t.Errorf("postal_zones mal persisté: got=%+v", loaded.PostalZones)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings_DeliveryModeRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.DeliveryMode = models.DeliveryModeConfig{
|
||||
Mode: "category_based",
|
||||
CategoryRoutes: []models.CategoryRoute{
|
||||
{DeliverymanUsername: "livreur_test", Categories: []string{"catA"}},
|
||||
},
|
||||
}
|
||||
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.DeliveryMode.Mode != "category_based" || len(loaded.DeliveryMode.CategoryRoutes) != 1 ||
|
||||
loaded.DeliveryMode.CategoryRoutes[0].DeliverymanUsername != "livreur_test" {
|
||||
t.Errorf("delivery_mode mal persisté: got=%+v", loaded.DeliveryMode)
|
||||
}
|
||||
|
||||
// Repasser en mode "single" avec une liste vide doit aussi persister
|
||||
// correctement (pas de résidu de l'ancienne liste category_routes).
|
||||
s.DeliveryMode = models.DeliveryModeConfig{Mode: "single", CategoryRoutes: []models.CategoryRoute{}}
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings (retour single): %v", err)
|
||||
}
|
||||
loaded, err = testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings (retour single): %v", err)
|
||||
}
|
||||
if loaded.DeliveryMode.Mode != "single" || len(loaded.DeliveryMode.CategoryRoutes) != 0 {
|
||||
t.Errorf("delivery_mode retour à single mal persisté: got=%+v", loaded.DeliveryMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings_ShopAndTelegramTextFieldsRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.ShopName = "Ma Boutique Test"
|
||||
s.TelegramBotToken = "123456:ABC-test-token"
|
||||
s.TelegramBotUsername = "mon_bot_test"
|
||||
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.ShopName != "Ma Boutique Test" {
|
||||
t.Errorf("shop_name: got=%q want=%q", loaded.ShopName, "Ma Boutique Test")
|
||||
}
|
||||
if loaded.TelegramBotToken != "123456:ABC-test-token" {
|
||||
t.Errorf("telegram_bot_token: got=%q", loaded.TelegramBotToken)
|
||||
}
|
||||
if loaded.TelegramBotUsername != "mon_bot_test" {
|
||||
t.Errorf("telegram_bot_username: got=%q", loaded.TelegramBotUsername)
|
||||
}
|
||||
|
||||
// Effacer le token/username (chaîne vide) doit aussi persister tel quel —
|
||||
// contrairement à contact_telegram qui a un repli explicite non-vide.
|
||||
s.TelegramBotToken = ""
|
||||
s.TelegramBotUsername = ""
|
||||
if err := testDB.UpdateSettings(s); err != nil {
|
||||
t.Fatalf("UpdateSettings (effacement): %v", err)
|
||||
}
|
||||
loaded, err = testDB.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettings (effacement): %v", err)
|
||||
}
|
||||
if loaded.TelegramBotToken != "" || loaded.TelegramBotUsername != "" {
|
||||
t.Errorf("token/username effacés devraient rester vides: got token=%q username=%q", loaded.TelegramBotToken, loaded.TelegramBotUsername)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettings_ColorAndGradientFieldsRoundTrip(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.AdminColorPrimary = "#111111"
|
||||
s.ClientColorDanger = "#222222"
|
||||
s.ClientTitleGradientFrom = "#333333"
|
||||
s.ClientTitleGradientTo = "#444444"
|
||||
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.AdminColorPrimary != "#111111" {
|
||||
t.Errorf("admin_color_primary: got=%q", loaded.AdminColorPrimary)
|
||||
}
|
||||
if loaded.ClientColorDanger != "#222222" {
|
||||
t.Errorf("client_color_danger: got=%q", loaded.ClientColorDanger)
|
||||
}
|
||||
if loaded.ClientTitleGradientFrom != "#333333" || loaded.ClientTitleGradientTo != "#444444" {
|
||||
t.Errorf("client_title_gradient: got from=%q to=%q", loaded.ClientTitleGradientFrom, loaded.ClientTitleGradientTo)
|
||||
}
|
||||
}
|
||||
|
||||
// Reproduit exactement le flux réel de l'admin : PUT /settings avec le JSON
|
||||
// tel qu'envoyé par le frontend (category_configs[].products, en mode
|
||||
// sélection), puis GET /settings pour vérifier ce qui revient — contrairement
|
||||
// aux autres tests de ce fichier qui appellent testDB.UpdateSettings /
|
||||
// GetSettings directement en Go, en contournant le binding JSON HTTP réel.
|
||||
func TestUpdateSettingsHTTP_CategoryConfigProductsSurviveSaveReload(t *testing.T) {
|
||||
resetSettingsAfterTest(t)
|
||||
|
||||
s := db.DefaultSettings()
|
||||
s.PointsReward = &models.PointsReward{
|
||||
Threshold: 20,
|
||||
CategoryConfigs: []models.RewardCategoryConfig{
|
||||
{
|
||||
Category: "test",
|
||||
Type: "free_product",
|
||||
AllProducts: false,
|
||||
Products: []models.RewardProductQuantity{
|
||||
{ProductID: 111, Quantity: 2},
|
||||
{ProductID: 222, Quantity: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
putReq := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/settings", bytes.NewReader(body))
|
||||
putReq.Header.Set("Content-Type", "application/json")
|
||||
putRec := httptest.NewRecorder()
|
||||
putCtx, _ := gin.CreateTestContext(putRec)
|
||||
putCtx.Request = putReq
|
||||
putCtx.Set("database", testDB)
|
||||
handlers.UpdateSettings(putCtx)
|
||||
|
||||
if putRec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT /settings: status=%d body=%s", putRec.Code, putRec.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v2/admin/protected/settings", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
getCtx, _ := gin.CreateTestContext(getRec)
|
||||
getCtx.Request = getReq
|
||||
getCtx.Set("database", testDB)
|
||||
handlers.GetSettings(getCtx)
|
||||
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /settings: status=%d body=%s", getRec.Code, getRec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Settings models.AppSettings `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(getRec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse GET: %v body=%s", err, getRec.Body.String())
|
||||
}
|
||||
|
||||
if resp.Settings.PointsReward == nil {
|
||||
t.Fatalf("points_reward est nil après reload")
|
||||
}
|
||||
if len(resp.Settings.PointsReward.CategoryConfigs) != 1 {
|
||||
t.Fatalf("category_configs: got=%d want=1: %+v", len(resp.Settings.PointsReward.CategoryConfigs), resp.Settings.PointsReward.CategoryConfigs)
|
||||
}
|
||||
cfg := resp.Settings.PointsReward.CategoryConfigs[0]
|
||||
if len(cfg.Products) != 2 {
|
||||
t.Fatalf("products: got=%d want=2 (produits sélectionnés non persistés): %+v", len(cfg.Products), cfg.Products)
|
||||
}
|
||||
if cfg.Products[0].ProductID != 111 || cfg.Products[0].Quantity != 2 {
|
||||
t.Errorf("products[0]: got=%+v want={ProductID:111 Quantity:2}", cfg.Products[0])
|
||||
}
|
||||
if cfg.Products[1].ProductID != 222 || cfg.Products[1].Quantity != 1 {
|
||||
t.Errorf("products[1]: got=%+v want={ProductID:222 Quantity:1}", cfg.Products[1])
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,8 @@ export default function App() {
|
||||
await Updates.fetchUpdateAsync();
|
||||
await Updates.reloadAsync();
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore update errors
|
||||
} catch (e) {
|
||||
console.error("[OTA] Échec de la vérification/application de la mise à jour:", e);
|
||||
}
|
||||
};
|
||||
checkForUpdate();
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDJTCCAg2gAwIBAgIUWaJqFsa0hKO1IjUH6d1nVN0u+2gwDQYJKoZIhvcNAQEL
|
||||
BQAwIjEgMB4GA1UEAwwXVWJlciBTdHVwIEFkbWluIFByZXByb2QwHhcNMjYwODIy
|
||||
MTMzNTQ3WhcNMzYwODE5MTMzNTQ3WjAiMSAwHgYDVQQDDBdVYmVyIFN0dXAgQWRt
|
||||
MIIDGzCCAgOgAwIBAgIUE8d7MB8k8EDm+Ai4QgEHdIJi3nUwDQYJKoZIhvcNAQEL
|
||||
BQAwIjEgMB4GA1UEAwwXVWJlciBTdHVwIEFkbWluIFByZXByb2QwHhcNMjYwODI2
|
||||
MTAyMjQ1WhcNMzYwODIzMTAyMjQ1WjAiMSAwHgYDVQQDDBdVYmVyIFN0dXAgQWRt
|
||||
aW4gUHJlcHJvZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM55/nO+
|
||||
DDMIqAcoaRMe/IFu6GTP+iX+M4UpvJXMYH9n4oCp7cHegxrq5KkW5Q8Hg1Qic/2N
|
||||
4W47G9LoEyjg58lOZISeBu6tltnfiFIMaqyuxDJvq851jFf2g4uXR2DpG4nW46dz
|
||||
d36MWAbI2UyEhUKPVEJGhZc5s9eP+CECYmSDj0oyObseMcieolqCV7itSzwmck2e
|
||||
VWrbOJF5TgQ26G8buA8gXUbJUVHMyan8LDWDl/+JTckJ1ENdcrBPyjA/ce3wbVBV
|
||||
m33Te758JWb5wxAP2nMi2rqy/GdvgQDH6m82u/BBEFsmk+Nn7PJBYlEUeUP0rdf5
|
||||
RqPJH0bzuPUzygkCAwEAAaNTMFEwHQYDVR0OBBYEFI5vAhPX/ijBCedv1+l0pAnI
|
||||
h5leMB8GA1UdIwQYMBaAFI5vAhPX/ijBCedv1+l0pAnIh5leMA8GA1UdEwEB/wQF
|
||||
MAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGsAIzTDNlTytr1v/JYC65vLUKDUyJ6e
|
||||
osyLqIN3jpA9EUIBsx96XQyHBgNWeyggZkHJl1scd+nd2qkA9/16vh+Gpdye0766
|
||||
sBJbYxgrnddfwHJiMNGj/rqqQDOyftiPn+yQh1GQP07rSBCavRsfLkz1G+n5BcXL
|
||||
2wPE99AGkyesFrNroTQmxqHSAdDswKmf1ydxRQOSL8eVcntkaV89E/OJba8E97Q5
|
||||
GuNG1cj0Sq04gr0X3qr41mRbigZRQKRkMxoKnI1U4Y2LXdAZeGITi9OpRYNLRdOb
|
||||
5hw2s8wW1Fp0PnAoMS2AxXJnEimXXz4+RGPR0T1fWGOpPYvviuJBjB4=
|
||||
RqPJH0bzuPUzygkCAwEAAaNJMEcwDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQM
|
||||
MAoGCCsGAQUFBwMDMB0GA1UdDgQWBBSObwIT1/4owQnnb9fpdKQJyIeZXjANBgkq
|
||||
hkiG9w0BAQsFAAOCAQEAKFPhk6qGylJzpjJzh7WTJrD78zkvzQfyl2OLHLy6q4HI
|
||||
0NUeKlwGccUe6ujvB85HBqlLox3mQOB4uuR3hz1fKhIJ2StNvX/3Ko/da8a+WeiN
|
||||
ZfniBDNPUKAaRG6/DH80n83r7GT07hHq4zJrWIauOOSdkOmwHYrHl79ceNk94WhC
|
||||
XHtr+9/n/z0WG83NePHPPqnTT/IRCpWPCNzFQf1vT7GPWTKaRjTKfRpgAFzzumho
|
||||
wj65OMSD5ZRenkTG7KMxssYRN+2UPeoZ+nAKgx0K5vVbFfFVfogfYLFf6xwvXg8i
|
||||
1Iokq3r8g7BACGJlWxPqtDo272lOD7HdQ7sLxLqxUw==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDFTCCAf2gAwIBAgIUeZdFKJ2R28Wu71qCK2bQCacAQsQwDQYJKoZIhvcNAQEL
|
||||
BQAwGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMB4XDTI2MDgyMTIwNDYzMVoX
|
||||
DTM2MDgxODIwNDYzMVowGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMIIBIjAN
|
||||
MIIDCzCCAfOgAwIBAgIUP26Wjyp3YylJDp5TspqcnBfttXgwDQYJKoZIhvcNAQEL
|
||||
BQAwGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMB4XDTI2MDgyNjEwMjI0NVoX
|
||||
DTM2MDgyMzEwMjI0NVowGjEYMBYGA1UEAwwPVWJlciBTdHVwIEFkbWluMIIBIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv5BLKcCBTmbDf5kh8Iwrtuhbhizt
|
||||
kHF1CsR8CRh4diuoXT8fdlwmQ6xf8tYFMkF4Q1ytHXHZ1VfLslU4fWVTErJW9/e0
|
||||
yTx1sP4sITzpujkOTSeFlvNxJ2Y6MKFoqwxVG/999oSteNTLAQeBNbnwgHox7Bu1
|
||||
WJGV3fAjv7y6VttH/u9ZUtAn6dwrHcsGFZ5vqr4z2ZMM+dU1L/sjF41wQAaCLSpY
|
||||
5rjch2FeD1gjVFpVMmMqxJado7B4UPcYZf1YCftjpp3Ojb0ZCy11uXo7rIOYR5EB
|
||||
g1vTEgkMIp7CsC4FUdZKNltHDkNiml7hELp29C+auDjcHRkYZvSZNPa6vQIDAQAB
|
||||
o1MwUTAdBgNVHQ4EFgQUjWIGwKFSIr4T+seTQ7hKvuA+BGkwHwYDVR0jBBgwFoAU
|
||||
jWIGwKFSIr4T+seTQ7hKvuA+BGkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B
|
||||
AQsFAAOCAQEAMS4P6nAuLFh72rvbjTViN1JOy/KBZAUi5KgQADTrTXzh2XdEwIJN
|
||||
YgWCg0TONRExGyEoKAbDQTyywgQOM+zX4BIY2QgrtnOVbLrImrsbzSRHDrya3aFw
|
||||
AkBAm5IcuTKC5zYsj2ZlZrGjFgIVaO+EtonTbC/hGh/FArE167wUWys39mlvB+H/
|
||||
kaMbz3EkwU3cXoOeQhg91dg2WUk5v2BA/pCg0r79u5c9tvQVFdq9MzO9NZop3u7L
|
||||
sXDrdun8P7mQp3VWRj1KQggCDmj+8rsnHNZEhEgr0TmRG5MCYyyfOW+CxS0X6MhC
|
||||
32G5mbt4AO8nfW5ImPI6S5m0tKn/nNIAQg==
|
||||
o0kwRzAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwMwHQYD
|
||||
VR0OBBYEFI1iBsChUiK+E/rHk0O4Sr7gPgRpMA0GCSqGSIb3DQEBCwUAA4IBAQBf
|
||||
q3IFEOFM+7FNuYEUDhNDjAC6teZPbM5yUMeX13Ei3MOdalaNCwuTTSQTIrBpjMpm
|
||||
Lqd6y/qjF/jefXDOF4VHUv/MWhTtwlklPB4zvYK81gZu0piNK9CDPgnoYa8WASlj
|
||||
8MZURgmmVHvoCAVjtqVrU+8H4SFTCL0SxBq1giJwqyEogsMGyaTIXDfOn0+HRsEg
|
||||
BZatKJwWCSHCox18i+6gMED+WsgrS/topvjiV7PR6iZQGckT1rEmG11m2IjgrvFt
|
||||
MFXPeyDEhvr2E9cqaOyMgRP/r+0f5AhELzZygom+9XTXdNwvGQuUuT76YiQGfJEf
|
||||
B64IP3rw+0Rs+9XAHXF3
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
|
||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
|
||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota-preprod.uber-stup.club/api/manifest"
|
||||
},
|
||||
"channel": "pre-prod-admin"
|
||||
},
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
|
||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
|
||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota-prod.uber-stup.club/api/manifest"
|
||||
},
|
||||
"channel": "production-admin"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1107,24 +1109,53 @@ export interface PointsTier {
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface RewardProductQuantity {
|
||||
product_id: number;
|
||||
quantity: number; // quantité individuelle de ce produit (palier de prix catalogue, ex: 1g)
|
||||
}
|
||||
|
||||
export interface RewardCategoryConfig {
|
||||
category: string;
|
||||
type: "free_product" | "half_price_product";
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
}
|
||||
|
||||
export interface RewardItem {
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
price: number;
|
||||
quantity: number; // quantité uniforme si all_products = true
|
||||
products: RewardProductQuantity[]; // produits + quantité individuelle si all_products = false
|
||||
}
|
||||
|
||||
export interface PointsReward {
|
||||
threshold: number;
|
||||
description: string;
|
||||
category_configs: RewardCategoryConfig[];
|
||||
reward_items: RewardItem[];
|
||||
}
|
||||
|
||||
export interface PromotionProductQuantity {
|
||||
product_id: number;
|
||||
quantity: number; // quantité individuelle de ce produit (palier de prix catalogue)
|
||||
}
|
||||
|
||||
export interface CategoryPromotionConfig {
|
||||
category: string;
|
||||
discount_percent: number; // pourcentage de réduction libre (ex: 10, 20, 33.5)
|
||||
all_products: boolean;
|
||||
quantity: number; // quantité uniforme si all_products = true
|
||||
products: PromotionProductQuantity[]; // produits + quantité individuelle si all_products = false
|
||||
}
|
||||
|
||||
export interface FreeGiftTier {
|
||||
buy_quantity: number; // quantité à acheter pour déclencher l'offre
|
||||
free_quantity: number; // quantité offerte du même produit
|
||||
}
|
||||
|
||||
export interface FreeGiftProductQuantity {
|
||||
product_id: number;
|
||||
tiers: FreeGiftTier[]; // seuils propres à ce produit
|
||||
}
|
||||
|
||||
export interface CategoryFreeGiftConfig {
|
||||
category: string;
|
||||
all_products: boolean;
|
||||
tiers: FreeGiftTier[]; // seuils uniformes si all_products = true
|
||||
products: FreeGiftProductQuantity[]; // produits + seuils individuels si all_products = false
|
||||
}
|
||||
|
||||
export interface PointsPool {
|
||||
@@ -1226,6 +1257,10 @@ export interface AppSettings {
|
||||
points_enabled: boolean;
|
||||
points_pools: PointsPool[];
|
||||
points_reward?: PointsReward | null;
|
||||
promotions_enabled: boolean;
|
||||
promotions: CategoryPromotionConfig[];
|
||||
free_gifts_enabled: boolean;
|
||||
free_gifts: CategoryFreeGiftConfig[];
|
||||
referral_enabled: boolean;
|
||||
delivery_schedule: DeliverySchedule;
|
||||
postal_zones: PostalZone[];
|
||||
|
||||
@@ -164,7 +164,6 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => {
|
||||
|
||||
// ============================================
|
||||
// LIVREURS
|
||||
// ============================================
|
||||
|
||||
const parseStatus = (status: any): "available" | "busy" | "offline" => {
|
||||
if (!status) return "offline";
|
||||
|
||||
@@ -123,7 +123,6 @@ export async function calculateRoute(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: straight line
|
||||
if (coordinates.length === 0) {
|
||||
coordinates.push(origin, destination);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
StatusBar,
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { spacing, fontSize, borderRadius } from "../../theme";
|
||||
@@ -503,6 +502,9 @@ export default function DeliveryScreen() {
|
||||
padding: spacing.l,
|
||||
maxHeight: "80%",
|
||||
},
|
||||
ratingsList: {
|
||||
padding: spacing.s,
|
||||
},
|
||||
ratingsHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
@@ -1076,69 +1078,67 @@ export default function DeliveryScreen() {
|
||||
animationType="slide"
|
||||
onRequestClose={() => setRatingsModal(null)}
|
||||
>
|
||||
<Pressable style={styles.ratingsOverlay} onPress={() => setRatingsModal(null)}>
|
||||
<Pressable onPress={() => {}}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsHeader}>
|
||||
<Text style={styles.ratingsTitle}>
|
||||
Avis — {ratingsModal?.username}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setRatingsModal(null)}>
|
||||
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{ratingsLoading ? (
|
||||
<Text style={styles.ratingsEmpty}>Chargement...</Text>
|
||||
) : ratingsModal && ratingsModal.count > 0 ? (
|
||||
<>
|
||||
<View style={styles.ratingsAvg}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
|
||||
size={20}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
<Text style={styles.ratingsAvgText}>
|
||||
{ratingsModal.average.toFixed(1)}
|
||||
</Text>
|
||||
<Text style={styles.ratingsCount}>
|
||||
({ratingsModal.count} avis)
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{ratingsModal.ratings.map((r) => (
|
||||
<View key={r.id} style={styles.ratingItem}>
|
||||
<View style={styles.ratingItemHeader}>
|
||||
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
|
||||
<Text style={styles.ratingItemDate}>
|
||||
{new Date(r.created_at).toLocaleDateString("fr-FR")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ratingStarsRow}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= r.rating ? "star" : "star-outline"}
|
||||
size={14}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{r.comment !== "" && (
|
||||
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
|
||||
)}
|
||||
<View style={styles.ratingsOverlay}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsHeader}>
|
||||
<Text style={styles.ratingsTitle}>
|
||||
Avis — {ratingsModal?.username}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setRatingsModal(null)}>
|
||||
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
{ratingsLoading ? (
|
||||
<Text style={styles.ratingsEmpty}>Chargement...</Text>
|
||||
) : ratingsModal && ratingsModal.count > 0 ? (
|
||||
<>
|
||||
<View style={styles.ratingsAvg}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
|
||||
size={20}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
<Text style={styles.ratingsAvgText}>
|
||||
{ratingsModal.average.toFixed(1)}
|
||||
</Text>
|
||||
<Text style={styles.ratingsCount}>
|
||||
({ratingsModal.count} avis)
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView style={styles.ratingsList}>
|
||||
{ratingsModal.ratings.map((r) => (
|
||||
<View key={r.id} style={styles.ratingItem}>
|
||||
<View style={styles.ratingItemHeader}>
|
||||
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
|
||||
<Text style={styles.ratingItemDate}>
|
||||
{new Date(r.created_at).toLocaleDateString("fr-FR")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ratingStarsRow}>
|
||||
{[1,2,3,4,5].map((s) => (
|
||||
<Ionicons
|
||||
key={s}
|
||||
name={s <= r.rating ? "star" : "star-outline"}
|
||||
size={14}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{r.comment !== "" && (
|
||||
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* ── Modal historique de connexion livreur ── */}
|
||||
@@ -1148,12 +1148,8 @@ export default function DeliveryScreen() {
|
||||
animationType="slide"
|
||||
onRequestClose={() => setLoginHistoryModal(null)}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.ratingsOverlay}
|
||||
onPress={() => setLoginHistoryModal(null)}
|
||||
>
|
||||
<Pressable onPress={() => {}}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsOverlay}>
|
||||
<View style={styles.ratingsSheet}>
|
||||
<View style={styles.ratingsHeader}>
|
||||
<Text style={styles.ratingsTitle}>
|
||||
Connexions — {loginHistoryModal?.username}
|
||||
@@ -1258,7 +1254,7 @@ export default function DeliveryScreen() {
|
||||
</Text>
|
||||
) : loginHistoryModal &&
|
||||
loginHistoryModal.weeks.length > 0 ? (
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<ScrollView style={styles.ratingsList}>
|
||||
{loginHistoryModal.weeks.map((week) => (
|
||||
<View key={week.week}>
|
||||
<Text style={styles.historyWeekLabel}>
|
||||
@@ -1310,9 +1306,8 @@ export default function DeliveryScreen() {
|
||||
Aucune connexion ce mois-ci
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 && (
|
||||
|
||||
@@ -75,7 +75,7 @@ interface EnrichedDelivery extends DeliveryItem {
|
||||
clientUsername?: string;
|
||||
clientNom?: string;
|
||||
clientPrenom?: string;
|
||||
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean }>;
|
||||
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean; promo_discount?: number }>;
|
||||
}
|
||||
|
||||
export default function DashboardScreen() {
|
||||
@@ -707,7 +707,14 @@ export default function DashboardScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.items.map((prod, idx) => (
|
||||
{item.items.map((prod, idx) => {
|
||||
const promoDiscount = prod.promo_discount ?? 0;
|
||||
const hasPromo = !prod.is_reward && promoDiscount > 0;
|
||||
const originalPrice = (prod.prix ?? 0) + promoDiscount;
|
||||
const promoPercent = hasPromo && originalPrice > 0
|
||||
? Math.round((promoDiscount / originalPrice) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<View key={idx} style={styles.itemRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
@@ -718,16 +725,34 @@ export default function DashboardScreen() {
|
||||
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
|
||||
</View>
|
||||
)}
|
||||
{hasPromo && (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(34,197,94,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
|
||||
<Ionicons name="pricetag-outline" size={10} color="#22c55e" />
|
||||
<Text style={{ fontSize: 10, color: "#22c55e", fontWeight: "700" }}>-{promoPercent}%</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.itemQty}>
|
||||
Quantité: {prod.quantite}{prod.unit || ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||
</Text>
|
||||
{hasPromo ? (
|
||||
<View style={{ alignItems: "flex-end" }}>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted, textDecorationLine: "line-through" }}>
|
||||
{originalPrice.toFixed(2)}€
|
||||
</Text>
|
||||
<Text style={[styles.itemPrice, { color: "#22c55e" }]}>
|
||||
{(prod.prix ?? 0).toFixed(2)}€
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Total</Text>
|
||||
<Text style={styles.totalValue}>
|
||||
@@ -2010,7 +2035,14 @@ export default function DashboardScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{detailsDelivery.items.map((prod, idx) => (
|
||||
{detailsDelivery.items.map((prod, idx) => {
|
||||
const promoDiscount = prod.promo_discount ?? 0;
|
||||
const hasPromo = !prod.is_reward && promoDiscount > 0;
|
||||
const originalPrice = (prod.prix ?? 0) + promoDiscount;
|
||||
const promoPercent = hasPromo && originalPrice > 0
|
||||
? Math.round((promoDiscount / originalPrice) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<View key={idx} style={styles.detailProductRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
@@ -2021,16 +2053,34 @@ export default function DashboardScreen() {
|
||||
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
|
||||
</View>
|
||||
)}
|
||||
{hasPromo && (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(34,197,94,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
|
||||
<Ionicons name="pricetag-outline" size={11} color="#22c55e" />
|
||||
<Text style={{ fontSize: 11, color: "#22c55e", fontWeight: "700" }}>-{promoPercent}%</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.detailProductQty}>
|
||||
Quantité : {prod.quantite}{prod.unit || ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||
</Text>
|
||||
{hasPromo ? (
|
||||
<View style={{ alignItems: "flex-end" }}>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted, textDecorationLine: "line-through" }}>
|
||||
{originalPrice.toFixed(2)}€
|
||||
</Text>
|
||||
<Text style={[styles.detailProductPrice, { color: "#22c55e" }]}>
|
||||
{(prod.prix ?? 0).toFixed(2)}€
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
||||
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.detailEmpty}>Aucun produit</Text>
|
||||
|
||||
@@ -796,7 +796,13 @@ export interface Product {
|
||||
category: string;
|
||||
unit?: string;
|
||||
stock: number;
|
||||
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||
prices?: Array<{
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number | null;
|
||||
promo_percent?: number;
|
||||
}>;
|
||||
media?: MediaItem[]; // ✅ CHANGÉ: string[] → MediaItem[]
|
||||
coming_soon?: boolean;
|
||||
}
|
||||
@@ -1440,6 +1446,60 @@ export const cancelCommand = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ UPDATE OWN COMMAND ADDRESS - Corriger l'adresse de sa propre commande
|
||||
* PUT /api/v1/commands/:id/address
|
||||
*/
|
||||
export const updateOwnCommandAddress = async (
|
||||
commandId: number,
|
||||
deliveryAddress: string,
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const token = sessionStorage.getItem("token");
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Session invalide",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/commands/${commandId}/address`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ delivery_address: deliveryAddress }),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await safeJson(response);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: data.error || "Erreur lors de la mise à jour de l'adresse",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.message || "Adresse mise à jour",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors de la mise à jour de l'adresse",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ✅ GET MY CANCELLATION HISTORY - Historique des annulations
|
||||
* GET /api/v1/my-cancellation-history
|
||||
@@ -2166,13 +2226,18 @@ export const unlinkTelegram = async (): Promise<void> => {
|
||||
// 🏆 POINTS — RÉCOMPENSES
|
||||
// ============================================
|
||||
|
||||
export type RewardConfigProduct = {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type RewardCategoryConfig = {
|
||||
category: string;
|
||||
type: "free_product" | "half_price_product";
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
product_names: string[];
|
||||
amount: number;
|
||||
products: RewardConfigProduct[];
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type RewardItemConfig = {
|
||||
|
||||
@@ -358,6 +358,8 @@ export interface ProductPrice {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number; // prix réduit si une promotion couvre ce palier
|
||||
promo_percent?: number; // pourcentage de réduction appliqué
|
||||
}
|
||||
export interface Product {
|
||||
id: number;
|
||||
|
||||
@@ -122,6 +122,13 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-price-strike {
|
||||
color: var(--text-muted);
|
||||
text-decoration: line-through;
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
color: var(--text-muted);
|
||||
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
||||
|
||||
@@ -28,7 +28,13 @@ interface ProductCardProps {
|
||||
image: string;
|
||||
stock: number;
|
||||
category: string;
|
||||
prices?: Array<{ quantity: number; price: number; active_price?: boolean }>;
|
||||
prices?: Array<{
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number | null;
|
||||
promo_percent?: number;
|
||||
}>;
|
||||
hasVideo?: boolean;
|
||||
videoUrl?: string; // ✨ Nouveau prop pour l'URL de la vidéo
|
||||
categoryColor?: string;
|
||||
@@ -63,6 +69,11 @@ function ProductCard({
|
||||
const isOutOfStock = stock === 0;
|
||||
const isComingSoon = coming_soon === true;
|
||||
const normalizedCategory = (category || "autre").toLowerCase().trim();
|
||||
const firstPromoPrice =
|
||||
prices?.[0]?.promo_price != null &&
|
||||
prices[0].promo_price < prices[0].price
|
||||
? prices[0].promo_price
|
||||
: null;
|
||||
|
||||
const handleDetailsClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -171,9 +182,20 @@ function ProductCard({
|
||||
<div className="product-info">
|
||||
<h3 className="product-name">{name}</h3>
|
||||
<p className="product-price">
|
||||
{price > 0
|
||||
? `${price.toFixed(2)} €`
|
||||
: "Prix non disponible"}
|
||||
{price > 0 ? (
|
||||
firstPromoPrice !== null ? (
|
||||
<>
|
||||
<span className="product-price-strike">
|
||||
{price.toFixed(2)} €
|
||||
</span>{" "}
|
||||
{firstPromoPrice.toFixed(2)} €
|
||||
</>
|
||||
) : (
|
||||
`${price.toFixed(2)} €`
|
||||
)
|
||||
) : (
|
||||
"Prix non disponible"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,9 +246,11 @@ function ProductCard({
|
||||
key={priceOption.quantity}
|
||||
value={priceOption.quantity}
|
||||
>
|
||||
{priceOption.quantity}
|
||||
{unit} - {priceOption.price.toFixed(2)}{" "}
|
||||
€
|
||||
{priceOption.promo_price != null &&
|
||||
priceOption.promo_price <
|
||||
priceOption.price
|
||||
? `${priceOption.quantity}${unit} - ${priceOption.promo_price.toFixed(2)} € (au lieu de ${priceOption.price.toFixed(2)} €, -${priceOption.promo_percent}%)`
|
||||
: `${priceOption.quantity}${unit} - ${priceOption.price.toFixed(2)} €`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -452,18 +452,18 @@ function ConsultationHistorique() {
|
||||
</span>,
|
||||
]
|
||||
: (
|
||||
cfg.product_names ??
|
||||
cfg.products ??
|
||||
[]
|
||||
).map(
|
||||
(
|
||||
name,
|
||||
p,
|
||||
) => (
|
||||
<span
|
||||
key={`${cfg.category}-${name}`}
|
||||
key={`${cfg.category}-${p.product_id}`}
|
||||
className="reward-eligible-cat"
|
||||
>
|
||||
{
|
||||
name
|
||||
p.product_name
|
||||
}
|
||||
</span>
|
||||
),
|
||||
|
||||
@@ -106,10 +106,17 @@ function ProductDetail() {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number;
|
||||
promo_percent?: number;
|
||||
}) => ({
|
||||
quantity: parseFloat(String(p.quantity)),
|
||||
price: parseFloat(String(p.price)),
|
||||
active_price: p.active_price,
|
||||
promo_price:
|
||||
p.promo_price != null
|
||||
? parseFloat(String(p.promo_price))
|
||||
: undefined,
|
||||
promo_percent: p.promo_percent,
|
||||
}),
|
||||
) || [],
|
||||
};
|
||||
@@ -118,8 +125,9 @@ function ProductDetail() {
|
||||
|
||||
// initialise le prix par défaut (float)
|
||||
if (fixedProduct.prices.length > 0) {
|
||||
setSelectedGrams(fixedProduct.prices[0].quantity);
|
||||
setSelectedPrice(fixedProduct.prices[0].price);
|
||||
const first = fixedProduct.prices[0];
|
||||
setSelectedGrams(first.quantity);
|
||||
setSelectedPrice(first.promo_price ?? first.price);
|
||||
}
|
||||
|
||||
// Couleur de la catégorie depuis la DB
|
||||
@@ -152,7 +160,11 @@ function ProductDetail() {
|
||||
);
|
||||
|
||||
if (priceOption) {
|
||||
setSelectedPrice(parseFloat(String(priceOption.price)));
|
||||
setSelectedPrice(
|
||||
priceOption.promo_price != null
|
||||
? parseFloat(String(priceOption.promo_price))
|
||||
: parseFloat(String(priceOption.price)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -301,13 +313,29 @@ function ProductDetail() {
|
||||
<div className="product-info-section">
|
||||
<h1 className="product-detail-name">{product.name}</h1>
|
||||
|
||||
{selectedPrice > 0 && (
|
||||
<p className="product-detail-price">
|
||||
{selectedPrice.toFixed(2)} €{" "}
|
||||
{selectedGrams &&
|
||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</p>
|
||||
)}
|
||||
{selectedPrice > 0 && (() => {
|
||||
const selectedTier = product.prices?.find(
|
||||
(p) => p.quantity === selectedGrams,
|
||||
);
|
||||
const hasPromo =
|
||||
selectedTier?.promo_price != null &&
|
||||
selectedTier.promo_price < selectedTier.price;
|
||||
return (
|
||||
<p className="product-detail-price">
|
||||
{hasPromo && (
|
||||
<span style={{ textDecoration: "line-through", opacity: 0.6, marginRight: 8 }}>
|
||||
{selectedTier!.price.toFixed(2)} €
|
||||
</span>
|
||||
)}
|
||||
<span style={hasPromo ? { color: "#22c55e" } : undefined}>
|
||||
{selectedPrice.toFixed(2)} €
|
||||
</span>
|
||||
{!hasPromo &&
|
||||
selectedGrams &&
|
||||
` pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="product-description">
|
||||
<h3>Description</h3>
|
||||
@@ -345,7 +373,9 @@ function ProductDetail() {
|
||||
>
|
||||
{p.quantity}
|
||||
{product.unit || "g"} -{" "}
|
||||
{p.price.toFixed(2)} €
|
||||
{p.promo_price != null && p.promo_price < p.price
|
||||
? `${p.promo_price.toFixed(2)} € (au lieu de ${p.price.toFixed(2)} €, -${p.promo_percent}%)`
|
||||
: `${p.price.toFixed(2)} €`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getOrderETA,
|
||||
confirmReception,
|
||||
cancelCommand,
|
||||
updateOwnCommandAddress,
|
||||
isUserAuthenticated,
|
||||
getPublicSettings,
|
||||
} from "../../api/api";
|
||||
@@ -287,6 +288,12 @@ function SuiviLivraison() {
|
||||
useState<CancelCommandResponse | null>(null);
|
||||
const [poolNames, setPoolNames] = useState<string[]>([]);
|
||||
|
||||
const [editingAddressOrder, setEditingAddressOrder] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [newAddress, setNewAddress] = useState("");
|
||||
const [editAddressLoading, setEditAddressLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getPublicSettings().then((s) => setPoolNames(s.pool_names ?? []));
|
||||
}, []);
|
||||
@@ -574,6 +581,53 @@ function SuiviLivraison() {
|
||||
handleCancelOrder(true);
|
||||
};
|
||||
|
||||
const openEditAddressDialog = (orderId: number) => {
|
||||
if (!isUserAuthenticated()) {
|
||||
navigate("/login/client", { replace: true });
|
||||
return;
|
||||
}
|
||||
const order = orders.find((o) => o.id === orderId);
|
||||
setNewAddress(order ? getDeliveryAddress(order) : "");
|
||||
setEditingAddressOrder(orderId);
|
||||
};
|
||||
|
||||
const closeEditAddressDialog = () => {
|
||||
setEditingAddressOrder(null);
|
||||
setNewAddress("");
|
||||
};
|
||||
|
||||
const handleUpdateAddress = async () => {
|
||||
if (!editingAddressOrder || !newAddress.trim()) return;
|
||||
|
||||
try {
|
||||
setEditAddressLoading(true);
|
||||
const response = await updateOwnCommandAddress(
|
||||
editingAddressOrder,
|
||||
newAddress.trim(),
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
showToast("Adresse mise à jour", "success");
|
||||
closeEditAddressDialog();
|
||||
loadOrders();
|
||||
} else {
|
||||
showToast(
|
||||
response.message || "Erreur lors de la mise à jour",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors de la mise à jour de l'adresse",
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
setEditAddressLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && orders.length === 0) {
|
||||
return (
|
||||
<>
|
||||
@@ -1143,6 +1197,27 @@ function SuiviLivraison() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="action-buttons">
|
||||
{(statusLow ===
|
||||
"pending" ||
|
||||
statusLow ===
|
||||
"assigned") && (
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() =>
|
||||
openEditAddressDialog(
|
||||
order.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
faMapMarkerAlt
|
||||
}
|
||||
/>{" "}
|
||||
Modifier
|
||||
l'adresse
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn-cancel-order"
|
||||
onClick={() =>
|
||||
@@ -1263,6 +1338,73 @@ function SuiviLivraison() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog de modification d'adresse */}
|
||||
{editingAddressOrder !== null && (
|
||||
<div
|
||||
className="confirm-dialog-overlay"
|
||||
onClick={closeEditAddressDialog}
|
||||
>
|
||||
<div
|
||||
className="confirm-dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="confirm-dialog-header">
|
||||
<h3>
|
||||
<FontAwesomeIcon icon={faMapMarkerAlt} />{" "}
|
||||
Modifier l'adresse de livraison
|
||||
</h3>
|
||||
</div>
|
||||
<div className="confirm-dialog-body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-address">
|
||||
Nouvelle adresse de livraison
|
||||
</label>
|
||||
<textarea
|
||||
id="new-address"
|
||||
value={newAddress}
|
||||
onChange={(e) =>
|
||||
setNewAddress(e.target.value)
|
||||
}
|
||||
placeholder="Adresse complète"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="confirm-dialog-actions">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={closeEditAddressDialog}
|
||||
disabled={editAddressLoading}
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
<button
|
||||
className="btn-confirm"
|
||||
onClick={handleUpdateAddress}
|
||||
disabled={
|
||||
editAddressLoading || !newAddress.trim()
|
||||
}
|
||||
>
|
||||
{editAddressLoading ? (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
spin
|
||||
/>{" "}
|
||||
Enregistrement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faCheck} />{" "}
|
||||
Enregistrer
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog d'annulation */}
|
||||
{showCancelDialog && (
|
||||
<div
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDJzCCAg+gAwIBAgIUM7z+tj5rLi14rOAxeYv24ocU0XUwDQYJKoZIhvcNAQEL
|
||||
MIIDHTCCAgWgAwIBAgIUfteDb4QnVA4dHw75YlHAKlxv7e0wDQYJKoZIhvcNAQEL
|
||||
BQAwIzEhMB8GA1UEAwwYVWJlciBTdHVwIENsaWVudCBQcmVwcm9kMB4XDTI2MDgy
|
||||
MjEzMzU0N1oXDTM2MDgxOTEzMzU0N1owIzEhMB8GA1UEAwwYVWJlciBTdHVwIENs
|
||||
NjEwMjI0NVoXDTM2MDgyMzEwMjI0NVowIzEhMB8GA1UEAwwYVWJlciBTdHVwIENs
|
||||
aWVudCBQcmVwcm9kMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvc/P
|
||||
p8uCt0SHb+tCaM/Pi7wm8QBKf6qnSnFr+peHmM3xWZkSEk7v4NelTlwcJ/A+Azfv
|
||||
0Py7euIGdOU13bXZRSDP5wbXVOJJt1eftJsiWlOT6ehGrnZOHd+telnTnl/fWbjJ
|
||||
qtDphpt3bm0DfxUypatG/NAnQ1SEiLMyUwiBTrIWoLFQ+XbC6ULnoKfhROqXj1h7
|
||||
eR+xCJ28R+LuB+kJk8EhD8L4CZqlO/xVk93eN3oJuTHJYT7jWff2uT+1SczRVvv4
|
||||
ZmnznU/gUPXJcQlISnmvaG/+8Ng8Z9ThDKDnnneJpFXAhFqU62Wjb/Y9rTb9FWZn
|
||||
saBQuDZGp+nBVHcT5QIDAQABo1MwUTAdBgNVHQ4EFgQUTnhvom3Cc72XDlqyhLER
|
||||
DvsHcUcwHwYDVR0jBBgwFoAUTnhvom3Cc72XDlqyhLERDvsHcUcwDwYDVR0TAQH/
|
||||
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAfoJFVyfqn9BdLyl3kp6aQcVWy7qW
|
||||
KmTXXqQw0QS32NFn493NEszi1gtKX/B7OhoA++i6lTHxTWfK3TmqWRDdZMWcsPBu
|
||||
xzc1Y83hzH3B3RefKIhLXmKB6wrrl8kLK+14eamVsLNd1IddWh2ywqc4FOBotJ0q
|
||||
Vds+vnsBB14iM/LGdYcKRbIfCeygL4bcHAM46gnthojd4HZRhIaCjVWdTM12kpK3
|
||||
1cw5ETlWN6gnpgY1h6RCJN3FmYOToZx9DoaIqJWcmBrPvVBm16aT1+1plDP3NqR7
|
||||
5LMfkor4/FvkCLI2GmWNlPcMnepz80sNtfH95I81rtnlS1eXMSgp/Izt9g==
|
||||
saBQuDZGp+nBVHcT5QIDAQABo0kwRzAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/
|
||||
BAwwCgYIKwYBBQUHAwMwHQYDVR0OBBYEFE54b6JtwnO9lw5asoSxEQ77B3FHMA0G
|
||||
CSqGSIb3DQEBCwUAA4IBAQA/UDUlMYaYaArtYl/BEKSj7jZTC3gFRA8393XLFdUi
|
||||
/roiIdd6suX+T957wgXRSTpGPfFVO+azJChosEKMRI477r0vWRX4J8B0GXNo+jcr
|
||||
okMjt5cY6G1egTvl+slJANoevJAClgOVOZ/+HShB0k9i9sIJf/rViKj7OV19UEur
|
||||
0m2gK/qdvxbeFuw2RUq5tFRgUZzL8TyZmbJVKu0iRX4wB1MuUezDlr5a/k1qd27V
|
||||
24U0+IiAQTjTVZj1ab8k6oP6376p6ydKoL2JLR7A/f/B1y/TojJbDNsU5EMCMt64
|
||||
TRZ0aeslLtcnynqlpzr3JNXugtRPaz2WxT8NmB8H6fQR
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDFzCCAf+gAwIBAgIUPjMMNeZyiuXB1G6OV0ENWtDoir8wDQYJKoZIhvcNAQEL
|
||||
BQAwGzEZMBcGA1UEAwwQVWJlciBTdHVwIENsaWVudDAeFw0yNjA4MjEyMDQ2MzFa
|
||||
Fw0zNjA4MTgyMDQ2MzFaMBsxGTAXBgNVBAMMEFViZXIgU3R1cCBDbGllbnQwggEi
|
||||
MIIDDTCCAfWgAwIBAgIUJrRf0VNrabHd9GaoW3iBxw8RzygwDQYJKoZIhvcNAQEL
|
||||
BQAwGzEZMBcGA1UEAwwQVWJlciBTdHVwIENsaWVudDAeFw0yNjA4MjYxMDIyNDVa
|
||||
Fw0zNjA4MjMxMDIyNDVaMBsxGTAXBgNVBAMMEFViZXIgU3R1cCBDbGllbnQwggEi
|
||||
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2foB+W04MkXIyANrUeffDy1Vo
|
||||
soZ6UgD/FWvvzYY0Pf29kWsatBTfuwfMQPg4VVl2OXci7oL01Steml2ZOMb+kF0f
|
||||
n3da5dRFFPVEp7KI+y1bRSBcy3M79Y8oon2Q1TcooCeaKVXVx7Ykg6/GPDLL/+tI
|
||||
0HVXdtQxMDr1EcCZuTo2g+91o84MLXJXipHkuS64UYAjlPrJFcq9jR4TJ7zYsL01
|
||||
P9fPmokAm2Vc2B9dG+BjlSBXp7oyLIOtMwC1zACkWiU+81d59NMZAv04FJENB/P6
|
||||
xeG3WqM/n/9INGy2Xvd9wkUuZEObZJiNh4CL6ZWzhJPCPq2cHcqrcHxXeRtxAgMB
|
||||
AAGjUzBRMB0GA1UdDgQWBBRzKjfUhq94HxcKky3+bSsu8L+STjAfBgNVHSMEGDAW
|
||||
gBRzKjfUhq94HxcKky3+bSsu8L+STjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
|
||||
DQEBCwUAA4IBAQAEsXOMagEh4/+LF80uhtipaMrfKTjmtOBXkY46n29BDvZlklgb
|
||||
5U0jcu7qJdeBbvyiS+X5Mlw8PqZOuCzbUoBb3pIcujbTkZZR3BD3aVKedPNfnXYp
|
||||
xQ2Kl6rVkPCsvqSWbdndAETECkXfOiqK9X9kqDlQmNXzD5aZiQqEEN7UZEJgF8kn
|
||||
woh+3m2puzuwFkgzoMDVsq+HJ5mYB74hWDdZPZ2gti8oigOcw9ydWl/Dn3T1ujJQ
|
||||
RsMf8wMMtpfSdl4W8DPXUz15dJigl1erIvuhIVadxf7hILaP+4RTg1pEhppJJS7i
|
||||
ZZkji9foAnqftHo9GjlLgwEyIIJ6YPZjeFz3
|
||||
AAGjSTBHMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzAd
|
||||
BgNVHQ4EFgQUcyo31IaveB8XCpMt/m0rLvC/kk4wDQYJKoZIhvcNAQELBQADggEB
|
||||
ACVgBn04MtRN/VysKaus837+x5XtiXm+V6Bi57+JkqORKEgzV2PdmFCtpcG6ePef
|
||||
0uUVkK7IF2tGm1AfNUkwvw/CoKNaFe9rtcNMLVYZSDbn6KOAyBSAxb2yQewJaSLN
|
||||
/qpjkg45Jrcwyl0cQ6tQfQgWmliXE1AbgAN5j0foKA0b4ioLsI0dPFncYo5hzmOb
|
||||
9FQ7QGbHwiAMmnQ3PHbpoby6DVpmEeuIj22FgAxt9TI7bYon/OHVO894jN0CCEOJ
|
||||
8wrAUxgIGJKLFYSQdodaQ4WESyYnAcGTneqypC+l9yJQBWNUT+cJeNaQmu47Zbra
|
||||
eLmJVdPXnnRe2UT7wSa02xg=
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_URL": "https://uber-demo.club",
|
||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
|
||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota-mobile-preprod.uber-stup.club/api/manifest"
|
||||
},
|
||||
"channel": "pre-prod-client"
|
||||
},
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_API_URL": "https://mln-uber.club",
|
||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
|
||||
"EXPO_PUBLIC_UPDATE_URL": "https://ota-mobile-prod.uber-stup.club/api/manifest"
|
||||
},
|
||||
"channel": "production-client"
|
||||
}
|
||||
|
||||
+31
-4
@@ -457,6 +457,29 @@ export const respondToAddressProposal = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateOwnCommandAddress = async (
|
||||
commandId: number,
|
||||
deliveryAddress: string,
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
try {
|
||||
const { data } = await apiClient.put(
|
||||
`${V1}/commands/${commandId}/address`,
|
||||
{ delivery_address: deliveryAddress },
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: data.message || "Adresse mise à jour",
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error.response?.data?.error ||
|
||||
"Erreur lors de la mise à jour de l'adresse",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getOrderTracking = async (
|
||||
commandId: number,
|
||||
): Promise<TrackingResponse> => {
|
||||
@@ -529,7 +552,6 @@ export const getOrdersWithTracking = async () => {
|
||||
|
||||
// ============================================
|
||||
// HISTORY
|
||||
// ============================================
|
||||
|
||||
export const getMyCompletedOrders = async (): Promise<HistoryResponse> => {
|
||||
try {
|
||||
@@ -962,13 +984,18 @@ export const toggle2FA = async (
|
||||
// 🏆 POINTS — RÉCOMPENSES
|
||||
// ============================================
|
||||
|
||||
export type RewardConfigProduct = {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type RewardCategoryConfig = {
|
||||
category: string;
|
||||
type: "free_product" | "half_price_product";
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
product_names: string[];
|
||||
amount: number;
|
||||
products: RewardConfigProduct[];
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type RewardItemConfig = {
|
||||
|
||||
@@ -355,6 +355,8 @@ export interface ProductPrice {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number; // prix réduit si une promotion couvre ce palier
|
||||
promo_percent?: number; // pourcentage de réduction appliqué
|
||||
}
|
||||
export interface Product {
|
||||
id: number;
|
||||
|
||||
@@ -47,6 +47,8 @@ interface ProductCardProps {
|
||||
quantity: number;
|
||||
price: number;
|
||||
active_price?: boolean;
|
||||
promo_price?: number | null;
|
||||
promo_percent?: number;
|
||||
}>;
|
||||
media?: Array<{ url: string; type: string }>;
|
||||
};
|
||||
@@ -67,6 +69,11 @@ export default function ProductCard({
|
||||
const activePrices =
|
||||
product.prices?.filter((p) => p.active_price !== false) ?? [];
|
||||
const firstPrice = activePrices[0]?.price ?? null;
|
||||
const firstPromoPrice =
|
||||
activePrices[0]?.promo_price != null &&
|
||||
activePrices[0].promo_price < activePrices[0].price
|
||||
? activePrices[0].promo_price
|
||||
: null;
|
||||
|
||||
const imageMedia = product.media?.find((m) => m.type === "image");
|
||||
const videoMedia = product.media?.find((m) => m.type === "video");
|
||||
@@ -198,11 +205,33 @@ export default function ProductCard({
|
||||
>
|
||||
{product.name}
|
||||
</Text>
|
||||
<Text style={[styles.price, { color: colors.success }]}>
|
||||
{firstPrice !== null
|
||||
? `${firstPrice.toFixed(2)} €`
|
||||
: "Prix non disponible"}
|
||||
</Text>
|
||||
{firstPrice !== null ? (
|
||||
firstPromoPrice !== null ? (
|
||||
<View style={styles.priceRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.priceStrike,
|
||||
{ color: colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{firstPrice.toFixed(2)} €
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.price, { color: colors.success }]}
|
||||
>
|
||||
{firstPromoPrice.toFixed(2)} €
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.price, { color: colors.success }]}>
|
||||
{firstPrice.toFixed(2)} €
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text style={[styles.price, { color: colors.success }]}>
|
||||
Prix non disponible
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
@@ -317,14 +346,39 @@ export default function ProductCard({
|
||||
{p.quantity}
|
||||
{product.unit || "g"}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
{ color: catColor },
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
{p.promo_price != null &&
|
||||
p.promo_price < p.price ? (
|
||||
<View style={styles.pickerPriceRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.priceStrike,
|
||||
{ color: colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
{ color: catColor },
|
||||
]}
|
||||
>
|
||||
{p.promo_price.toFixed(2)} €
|
||||
{p.promo_percent
|
||||
? ` (-${p.promo_percent}%)`
|
||||
: ""}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
{ color: catColor },
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Ionicons
|
||||
name="add-circle"
|
||||
@@ -515,6 +569,21 @@ const styles = StyleSheet.create({
|
||||
fontWeight: fontWeight.bold,
|
||||
textAlign: "center",
|
||||
},
|
||||
priceRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
pickerPriceRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
priceStrike: {
|
||||
fontSize: fontSize.md,
|
||||
textDecorationLine: "line-through",
|
||||
},
|
||||
quickAddSection: { padding: spacing.m, borderTopWidth: 1 },
|
||||
quickAddBtn: {
|
||||
width: "100%",
|
||||
|
||||
@@ -830,14 +830,14 @@ export default function OrderHistoryScreen() {
|
||||
</View>,
|
||||
]
|
||||
: (
|
||||
cfg.product_names ??
|
||||
cfg.products ??
|
||||
[]
|
||||
).map(
|
||||
(
|
||||
name,
|
||||
p,
|
||||
) => (
|
||||
<View
|
||||
key={`${cfg.category}-${name}`}
|
||||
key={`${cfg.category}-${p.product_id}`}
|
||||
style={
|
||||
styles.rewardAmountBadge
|
||||
}
|
||||
@@ -848,7 +848,7 @@ export default function OrderHistoryScreen() {
|
||||
}
|
||||
>
|
||||
{
|
||||
name
|
||||
p.product_name
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
confirmReception,
|
||||
cancelCommand,
|
||||
respondToAddressProposal,
|
||||
updateOwnCommandAddress,
|
||||
formatOrderDate,
|
||||
formatPrice,
|
||||
calculateOrderTotal,
|
||||
@@ -74,6 +75,11 @@ export default function OrderTrackingScreen() {
|
||||
useState<CancelCommandResponse | null>(null);
|
||||
const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(null);
|
||||
const [penaltiesEnabled, setPenaltiesEnabled] = useState(false);
|
||||
const [editingAddressId, setEditingAddressId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [newAddress, setNewAddress] = useState("");
|
||||
const [editAddressLoading, setEditAddressLoading] = useState(false);
|
||||
const [toastMsg, setToastMsg] = useState("");
|
||||
const [toastType, setToastType] = useState<
|
||||
"success" | "error" | "warning" | "info"
|
||||
@@ -187,6 +193,29 @@ export default function OrderTrackingScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateAddress = async (orderId: number) => {
|
||||
if (!newAddress.trim()) return;
|
||||
setEditAddressLoading(true);
|
||||
try {
|
||||
const res = await updateOwnCommandAddress(
|
||||
orderId,
|
||||
newAddress.trim(),
|
||||
);
|
||||
if (res.success) {
|
||||
showToast("Adresse mise à jour", "success");
|
||||
setEditingAddressId(null);
|
||||
setNewAddress("");
|
||||
fetchOrders();
|
||||
} else {
|
||||
showToast(res.message || "Erreur", "error");
|
||||
}
|
||||
} catch {
|
||||
showToast("Erreur lors de la mise à jour de l'adresse", "error");
|
||||
} finally {
|
||||
setEditAddressLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const styles = useMemo(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
@@ -407,6 +436,10 @@ export default function OrderTrackingScreen() {
|
||||
"assigned",
|
||||
"en_route",
|
||||
].includes(order.status);
|
||||
const canEditAddress = [
|
||||
"pending",
|
||||
"assigned",
|
||||
].includes(order.status);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -636,6 +669,23 @@ export default function OrderTrackingScreen() {
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
{canEditAddress && (
|
||||
<Button
|
||||
title="Modifier l'adresse"
|
||||
onPress={() => {
|
||||
setNewAddress(
|
||||
order.delivery_address ||
|
||||
order.adresse ||
|
||||
"",
|
||||
);
|
||||
setEditingAddressId(
|
||||
order.id,
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button
|
||||
title="Annuler"
|
||||
@@ -689,6 +739,52 @@ export default function OrderTrackingScreen() {
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={editingAddressId !== null}
|
||||
onClose={() => {
|
||||
setEditingAddressId(null);
|
||||
setNewAddress("");
|
||||
}}
|
||||
title="Modifier l'adresse de livraison"
|
||||
icon="location-outline"
|
||||
iconColor={colors.accent}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.modalText}>
|
||||
Nouvelle adresse de livraison :
|
||||
</Text>
|
||||
<RNTextInput
|
||||
style={styles.cancelInput}
|
||||
placeholder="Adresse complète"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={newAddress}
|
||||
onChangeText={setNewAddress}
|
||||
multiline
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<Button
|
||||
title="Retour"
|
||||
onPress={() => {
|
||||
setEditingAddressId(null);
|
||||
setNewAddress("");
|
||||
}}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
<Button
|
||||
title="Enregistrer"
|
||||
onPress={() =>
|
||||
editingAddressId &&
|
||||
handleUpdateAddress(editingAddressId)
|
||||
}
|
||||
loading={editAddressLoading}
|
||||
variant="success"
|
||||
size="md"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={cancellingId !== null}
|
||||
onClose={() => {
|
||||
|
||||
@@ -63,12 +63,18 @@ export default function ProductDetailScreen() {
|
||||
quantity: parseFloat(String(pr.quantity)),
|
||||
price: parseFloat(String(pr.price)),
|
||||
active_price: pr.active_price,
|
||||
promo_price:
|
||||
pr.promo_price != null
|
||||
? parseFloat(String(pr.promo_price))
|
||||
: undefined,
|
||||
promo_percent: pr.promo_percent,
|
||||
})) || [],
|
||||
};
|
||||
setProduct(fixedProduct);
|
||||
if (fixedProduct.prices.length > 0) {
|
||||
setSelectedGrams(fixedProduct.prices[0].quantity);
|
||||
setSelectedPrice(fixedProduct.prices[0].price);
|
||||
const first = fixedProduct.prices[0];
|
||||
setSelectedGrams(first.quantity);
|
||||
setSelectedPrice(first.promo_price ?? first.price);
|
||||
}
|
||||
const matched = categories.find(
|
||||
(c) =>
|
||||
@@ -90,7 +96,7 @@ export default function ProductDetailScreen() {
|
||||
const handleGramsChange = (quantity: number) => {
|
||||
setSelectedGrams(quantity);
|
||||
const opt = product?.prices?.find((p) => p.quantity === quantity);
|
||||
if (opt) setSelectedPrice(opt.price);
|
||||
if (opt) setSelectedPrice(opt.promo_price ?? opt.price);
|
||||
setShowQuantityPicker(false);
|
||||
};
|
||||
|
||||
@@ -585,16 +591,44 @@ export default function ProductDetailScreen() {
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.productName}>{product.name}</Text>
|
||||
{selectedPrice > 0 && (
|
||||
<View style={styles.priceRow}>
|
||||
<View style={styles.priceIndicator} />
|
||||
<Text style={styles.priceText}>
|
||||
{selectedPrice.toFixed(2)} €{" "}
|
||||
{selectedGrams &&
|
||||
`pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{selectedPrice > 0 && (() => {
|
||||
const selectedTier = product.prices?.find(
|
||||
(p) => p.quantity === selectedGrams,
|
||||
);
|
||||
const hasPromo =
|
||||
selectedTier?.promo_price != null &&
|
||||
selectedTier.promo_price < selectedTier.price;
|
||||
return (
|
||||
<View style={styles.priceRow}>
|
||||
<View style={styles.priceIndicator} />
|
||||
{hasPromo && (
|
||||
<Text
|
||||
style={[
|
||||
styles.priceText,
|
||||
{
|
||||
textDecorationLine: "line-through",
|
||||
opacity: 0.6,
|
||||
marginRight: 6,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{selectedTier!.price.toFixed(2)} €
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.priceText,
|
||||
hasPromo && { color: "#22c55e" },
|
||||
]}
|
||||
>
|
||||
{selectedPrice.toFixed(2)} €
|
||||
{!hasPromo &&
|
||||
selectedGrams &&
|
||||
` pour ${selectedGrams}${product.unit || "g"}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
<View style={styles.descriptionCard}>
|
||||
<Text style={styles.descriptionTitle}>Description</Text>
|
||||
<Text style={styles.descriptionText}>
|
||||
@@ -719,16 +753,32 @@ export default function ProductDetailScreen() {
|
||||
{p.quantity}
|
||||
{product.unit || "g"}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
selectedGrams === p.quantity && {
|
||||
color: catColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
{p.promo_price != null && p.promo_price < p.price ? (
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
{ textDecorationLine: "line-through", opacity: 0.6 },
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
<Text style={[styles.pickerOptionPrice, { color: "#22c55e" }]}>
|
||||
{p.promo_price.toFixed(2)} € (-{p.promo_percent}%)
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.pickerOptionPrice,
|
||||
selectedGrams === p.quantity && {
|
||||
color: catColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{p.price.toFixed(2)} €
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedGrams === p.quantity && (
|
||||
<View
|
||||
|
||||
@@ -34,7 +34,6 @@ const logoGrosSemi = require("../../../assets/logo-gros-semi.png");
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const CARD_WIDTH = SCREEN_WIDTH - 48;
|
||||
|
||||
// Les catégories sont chargées dynamiquement depuis l'API
|
||||
|
||||
type Nav = NativeStackNavigationProp<ClientStackParamList>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user